Compare commits

...
27 Commits
Author SHA1 Message Date
haraldandClaude Opus 5.5 5cd45831e2 chore(release): bump version to 0.2.3
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 10m30s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:04:54 +02:00
haraldandClaude Opus 5.5 71f6a70829 fix(ci): Release-Signatur mit rsign2 statt minisign-CLI
Der Signaturschlüssel wurde mit `rsign generate -W` erzeugt und ist mit
leerem Passwort verschlüsselt; das minisign-CLI aus Ubuntu (0.11) verlangt
dafür ein Terminal und brach in der CI mit "Error while loading the secret
key file" ab.

- Workflow installiert rsign2 0.6.7 (ohne -D warnings) statt minisign
- build-release.sh signiert und verifiziert mit rsign; da rsign bei falscher
  Key-ID mit Exit-Code 0 endet, wird zusätzlich die Erfolgsmeldung geprüft
- README: Signierwerkzeug und Fallstrick dokumentiert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:04:54 +02:00
haraldandClaude Opus 5.5 b751b71223 chore(release): bump version to 0.2.2
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 9m55s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:44:51 +02:00
haraldandClaude Opus 5.5 afb057692d fix(ci): rustfmt und clippy in der Release-Pipeline installieren
setup-rust-toolchain installiert mit --profile minimal; ohne explizite
components fehlen rustfmt und clippy, wodurch die Quality Gates bei jedem
Release-Lauf sofort abbrachen (cargo fmt --check nach 2 Sekunden).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:42:58 +02:00
haraldandClaude Opus 5.5 eeae143dc6 chore(release): bump version to 0.2.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 1m55s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:35:10 +02:00
haraldandClaude Opus 5.5 992c3daad7 fix(ci): Formatierung, Root-fester I/O-Test und robuste Schlüsselsuche
- updater.rs: rustfmt-konforme Formatierung des öffentlichen Schlüssels
  (cargo fmt --check ließ die Release-Pipeline scheitern)
- storage.rs: test_io_error_* simuliert den I/O-Fehler über ein Verzeichnis
  statt chmod 000, damit er auch als root (CI-Runner) greift
- build-release.sh: findet den öffentlichen Schlüssel auch, wenn rustfmt ihn
  in die Folgezeile umbricht

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:30:52 +02:00
haraldandClaude Opus 5.5 9684238a53 chore(release): bump version to 0.2.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 2m28s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:20:29 +02:00
haraldandClaude Opus 5.5 e8674e730f feat(updater): Minisign-Schlüssel für Release-Signaturen hinterlegen
Öffentlicher Schlüssel (Key-ID 3CFE290C190DA9D9). Der geheime Schlüssel wird
in der CI als Secret MINISIGN_SECRET_KEY erwartet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:10:57 +02:00
haraldandClaude Opus 5.5 7e99d59042 Merge fix/storage-datensicherheit in main
Konfliktlösung: Speicher-Architektur des Branches (Formatversionierung,
Papierkorb, Backups, Instance-Lock, robuster Autosave) übernommen und die
dort fehlenden Korrekturen aus main darauf übertragen:

- Autosave gleicht das Manifest bei jeder Änderung mit dem Notizbuch ab
  (mark_dirty); per Taste N/Bild-ab angelegte Seiten gingen sonst nach
  einem Neustart verloren
- Start öffnet keine beschädigten Notizbücher automatisch und verwendet den
  Ordner eines nicht ladbaren Notizbuchs nicht wieder
- PDF-Export lädt nicht geöffnete Seiten nach (load_all_pages), verweigert
  unvollständige Exporte und meldet das echte Ergebnis an die UI
- Cargo.toml: minisign-verify und fs2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:07:22 +02:00
haraldandClaude Opus 5.5 7ed971d2a2 feat(updater): Pflicht-Signaturprüfung der Releases mit Minisign
- SHA256SUMS.txt muss mit dem eingebauten Minisign-Schlüssel
  (UPDATE_SIGNING_PUBLIC_KEY) signiert sein; fehlende oder ungültige
  Signaturen und Versionen ohne Schlüssel lehnen Updates ab
- Prüfsummen und Signatur werden vor dem Paket geladen und geprüft
- build-release.sh signiert mit MINISIGN_SECRET_KEY und prüft, dass der
  Schlüssel zum eingebauten öffentlichen Schlüssel passt; in der CI ist die
  Signatur Pflicht
- publish-gitea-release.sh lädt SHA256SUMS.txt.minisig hoch
- README: Einrichtung des Schlüsselpaars dokumentiert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:49:25 +02:00
haraldandClaude Opus 5.5 5ce2c250bb fix(updater): HTTPS für alle Anfragen erzwingen
Bisher wurde nur die Server-URL geprüft. Download-Links aus der Release-API
und Weiterleitungsziele durften auch http:// sein, womit Paket und
SHA256SUMS.txt unverschlüsselt geladen werden konnten. Jetzt wird jede
Anfrage einzeln geprüft (allow_insecure_http bleibt als Ausnahme).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:45:16 +02:00
haraldandClaude Opus 5.5 d61e1819ed fix(storage): Datenverlust bei Ladefehlern, neuen Seiten und Löschen verhindern
- Beschädigte Seiten werden als page_<id>.json.corrupt-<zeit> gesichert statt
  leer überschrieben; bei I/O-Fehlern wird die Ersatzseite als load_failed
  markiert und nie gespeichert
- Autosave gleicht das Manifest (page_ids, Zähler) mit dem Notizbuch ab, damit
  neu angelegte Seiten nach einem Neustart nicht verloren gehen
- Start: nicht ladbares Notizbuch bleibt unverändert, stattdessen wird ein
  neues angelegt
- Aktuelles Notizbuch löschen: ausstehende Schreibvorgänge vorher abarbeiten,
  damit der Ordner nicht wieder angelegt wird
- PDF-Export lädt nicht geöffnete Seiten nach und verweigert unvollständige
  Exporte; Ergebnis wird per Kanal an die UI gemeldet
- Strg+S speichert alle geladenen Seiten und meldet Schreibfehler; flush()
  liefert Fehler zurück statt immer "Gespeichert"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:43:16 +02:00
harald 12755d8eea test(storage): Crash-Simulation und Stresstest gegen unvollständige Schreibvorgänge 2026-10-06 19:21:59 +02:00
harald 510035e807 perf(storage): kompaktes JSON, Stroke-Treue und Snapshot-Optimierung 2026-10-06 19:14:52 +02:00
harald 1afe4a0f9d feat(storage): Dateisystem-Härtung, Trash, Rolling-Backup und Instance-Lock 2026-10-06 18:55:08 +02:00
harald 04666bf389 feat(autosave): robuster Autosave mit Max-Wait, Retries, PageStore-Trait und Shutdown-Sicherheit 2026-10-06 17:52:09 +02:00
harald a82402bfce feat(storage): Formatversionierung, Migrations-Backups und Fixture-Tests 2026-10-06 17:16:39 +02:00
harald 06b3aeb311 fix(storage): kein stilles Überschreiben bei Ladefehlern und Quarantäne defekter Seiten 2026-10-06 16:58:23 +02:00
harald f9b39c6fe3 chore(release): bump version to 0.1.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 1m22s
2026-10-06 15:59:01 +02:00
harald f9ebcb0296 fix(ci): remove all duplicate assets before re-uploading in publish-gitea-release.sh
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 1m19s
2026-10-06 15:43:11 +02:00
harald df72981a9a feat(input): Wayland-Tablet-Protokoll für aktive Stifte (Lazarite) und Touchscreen-Pinch-to-Zoom
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 2m0s
2026-10-06 14:58:59 +02:00
harald 5cb77b9af7 feat(ui): Sichere Shortcuts, dediziertes Ctrl+S Speichern und kollisionsfreier PDF-Export 2026-10-06 12:56:39 +02:00
harald bb0faf424b chore(meta): Lizenzdatei, Cargo-Metadaten und bereinigte Dokumentation 2026-10-06 12:40:52 +02:00
harald 3999996e98 fix(packaging): Debian-Paket-Abhängigkeiten, AppStream-Metadaten und reproduzierbares AppImage-Build 2026-10-06 12:36:10 +02:00
harald 3eb21dadc4 fix(scripts): Idempotentes Release-Publishing und robuste Template-Payloads 2026-10-06 12:26:37 +02:00
harald 5780c266b7 ci(gitea): Strikte Quality-Gates und sichere Token-Übergabe in Release-Workflow 2026-10-06 12:24:33 +02:00
harald 91e2d78b4f fix(updater): Sichere In-App Updates mit ureq, SHA256-Integrität und atomarem AppImage-Tausch 2026-10-06 12:23:14 +02:00
37 changed files with 6851 additions and 1233 deletions

No files matched your search

+33 -6
View File
@@ -9,15 +9,25 @@ on:
jobs:
release:
name: Build Packages & Publish to Gitea
# Hinweis zum Gitea act_runner:
# Standardmäßig 'ubuntu-latest'. Falls Ihr Runner unter einem anderen Label registriert ist
# (z. B. 'linux', 'ubuntu-24.04' oder 'host'), passen Sie dieses Label an.
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
uses: actions/checkout@v4.2.2
- name: Install System Packaging Tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
export DEBIAN_FRONTEND=noninteractive
SUDO=""
if [ "$(id -u)" -ne 0 ]; then
if command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
fi
fi
$SUDO apt-get update -y
$SUDO apt-get install -y --no-install-recommends \
build-essential \
pkg-config \
libx11-dev \
@@ -31,20 +41,37 @@ jobs:
python3
- name: Setup Rust Toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
with:
toolchain: stable
# Die Action installiert mit --profile minimal; ohne diese Komponenten
# schlagen cargo fmt und cargo clippy in den Quality Gates sofort fehl
components: rustfmt, clippy
cache: true
- name: Build Release Artifacts (.deb, .AppImage, SHA256SUMS.txt)
- name: Install rsign2 (Release-Signatur)
# RUSTFLAGS leeren: die Toolchain-Action setzt -D warnings, das soll nicht für rsign2 gelten
run: RUSTFLAGS="" cargo install rsign2 --locked --version 0.6.7
- name: Code Quality & Testing (Quality Gates)
run: |
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
- name: Build Release Artifacts (.deb, .AppImage, SHA256SUMS.txt + .minisig)
run: |
chmod +x scripts/*.sh
./scripts/build-release.sh
env:
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
INK_NOTES_REQUIRE_SIGNATURE: "1"
- name: Publish Release to Gitea
run: |
./scripts/publish-gitea-release.sh "${{ secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN }}"
./scripts/publish-gitea-release.sh
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_SERVER: ${{ github.server_url }}
REPO_OWNER: ${{ github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
Generated
+355 -8
View File
@@ -118,6 +118,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bit-set"
version = "0.5.3"
@@ -151,6 +157,15 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "block-sys"
version = "0.2.1"
@@ -269,6 +284,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "codespan-reporting"
version = "0.11.1"
@@ -375,6 +396,15 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -390,6 +420,16 @@ version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "cursor-icon"
version = "1.2.0"
@@ -403,7 +443,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e3d747f100290a1ca24b752186f61f6637e1deffe3bf6320de6fcb29510a307"
dependencies = [
"bitflags 2.13.2",
"libloading 0.7.4",
"libloading 0.8.9",
"winapi",
]
@@ -413,6 +453,16 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -529,6 +579,18 @@ dependencies = [
"simdutf8",
]
[[package]]
name = "enum_dispatch"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd"
dependencies = [
"once_cell",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "epaint"
version = "0.27.2"
@@ -620,6 +682,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "futures-core"
version = "0.3.34"
@@ -644,6 +716,16 @@ dependencies = [
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
@@ -654,6 +736,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "getrandom"
version = "0.3.4"
@@ -747,7 +840,7 @@ dependencies = [
"presser",
"thiserror",
"winapi",
"windows",
"windows 0.52.0",
]
[[package]]
@@ -801,6 +894,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.3"
@@ -949,18 +1048,25 @@ dependencies = [
[[package]]
name = "ink-notes"
version = "0.1.0"
version = "0.2.3"
dependencies = [
"bytemuck",
"egui",
"egui-wgpu",
"egui-winit",
"fs2",
"glam",
"minisign-verify",
"octotablet",
"pollster",
"printpdf",
"semver",
"serde",
"serde_json",
"sha2",
"tempfile",
"ureq",
"url",
"webbrowser",
"wgpu",
"winit",
@@ -1199,6 +1305,12 @@ dependencies = [
"paste",
]
[[package]]
name = "minisign-verify"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
[[package]]
name = "miniz_oxide"
version = "0.9.1"
@@ -1349,6 +1461,25 @@ dependencies = [
"cc",
]
[[package]]
name = "octotablet"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c4b9470450ffa669cb3be55ef863dfd9592a06284cb291546363c514bb4aa3"
dependencies = [
"bitflags 2.13.2",
"cfg_aliases 0.2.2",
"enum_dispatch",
"raw-window-handle 0.6.2",
"smallvec",
"strum",
"thiserror",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"windows 0.54.0",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -1609,6 +1740,20 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -1647,6 +1792,41 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.23"
@@ -1687,6 +1867,12 @@ dependencies = [
"tiny-skia",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
@@ -1730,6 +1916,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "2.0.1"
@@ -1830,6 +2027,34 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn 2.0.119",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "1.0.109"
@@ -2041,6 +2266,12 @@ dependencies = [
"rustc-hash 2.1.3",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.26"
@@ -2065,6 +2296,30 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
dependencies = [
"base64",
"flate2",
"log",
"once_cell",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"url",
"webpki-roots 0.26.11",
]
[[package]]
name = "url"
version = "2.5.8"
@@ -2099,6 +2354,12 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
@@ -2310,6 +2571,24 @@ dependencies = [
"web-sys",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.9",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
@@ -2324,7 +2603,7 @@ checksum = "cbd7311dbd2abcfebaabf1841a2824ed7c8be443a0f29166e5d3c6a53a762c01"
dependencies = [
"arrayvec",
"cfg-if",
"cfg_aliases",
"cfg_aliases 0.1.1",
"js-sys",
"log",
"naga",
@@ -2350,7 +2629,7 @@ dependencies = [
"arrayvec",
"bit-vec",
"bitflags 2.13.2",
"cfg_aliases",
"cfg_aliases 0.1.1",
"codespan-reporting",
"indexmap",
"log",
@@ -2379,7 +2658,7 @@ dependencies = [
"bit-set",
"bitflags 2.13.2",
"block",
"cfg_aliases",
"cfg_aliases 0.1.1",
"core-graphics-types",
"d3d12",
"glow",
@@ -2466,7 +2745,19 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
dependencies = [
"windows-core",
"windows-core 0.52.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
dependencies = [
"windows-core 0.54.0",
"windows-implement",
"windows-interface",
"windows-targets 0.52.6",
]
@@ -2479,12 +2770,53 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
dependencies = [
"windows-result",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-implement"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "942ac266be9249c84ca862f0a164a39533dc2f6f33dc98ec89c8da99b82ea0bd"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-interface"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da33557140a288fae4e1d5f8873aaf9eb6613a9cf82c3e070223ff177f598b60"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.45.0"
@@ -2503,6 +2835,15 @@ dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -2711,7 +3052,7 @@ dependencies = [
"bitflags 2.13.2",
"bytemuck",
"calloop",
"cfg_aliases",
"cfg_aliases 0.1.1",
"core-foundation",
"core-graphics",
"cursor-icon",
@@ -2895,6 +3236,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
version = "0.2.5"
+14 -1
View File
@@ -1,7 +1,12 @@
[package]
name = "ink-notes"
version = "0.1.0"
version = "0.2.3"
edition = "2021"
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes"
license = "MIT"
readme = "README.md"
rust-version = "1.75"
[dependencies]
# Fenster- & Event-Handling (Wayland/X11, Stylus)
@@ -29,6 +34,13 @@ egui = "0.27"
egui-wgpu = "0.27"
egui-winit = { version = "0.27", default-features = false, features = ["links"] }
webbrowser = "0.8"
semver = "1.0"
sha2 = "0.10"
ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] }
url = "2.5"
octotablet = "0.1.0"
minisign-verify = "0.3.0"
fs2 = "0.4"
[profile.dev]
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
@@ -37,6 +49,7 @@ opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
lto = true
codegen-units = 1
opt-level = 3
strip = true
[dev-dependencies]
tempfile = "3.27.0"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Harald Pansi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+113 -45
View File
@@ -10,9 +10,10 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü
### ⚡ Latenzoptimierte Inking-Pipeline
- **Hardware-Rendering:** Direct-to-Screen-Rendering mit `wgpu` (Vulkan / X11 / Wayland) unter Nutzung von `PresentMode::Mailbox` für minimale Eingabeverzögerung.
- **1€-Filter (One-Euro-Filter):** Dynamischer Tiefpassfilter zur Glättung von Zittern bei langsamen Bewegungen ohne spürbare Latenz bei schnellen Schwüngen.
- **Zwei-Stufen-Modell:**
1. *Live-Phase:* Eingehende Stylus-Samples werden in Echtzeit durch einen 1€-Filter (One-Euro-Filter) entprellt und inkrementell als GPU-Meshes dargestellt.
2. *Commit-Phase (Post-Stroke Finalisierung):* Akausale Ecken-Erkennung (Corner Preservation bei Knicken > 65°), gewichtetes 3-Punkt-Druckfenster, Ramer-Douglas-Peucker (RDP) Dezimierung und saubere runde Endkappen.
- **Centripetal Catmull-Rom Splines:** Weiche C1-stetige Kurveninterpolation ohne Überschwinger oder Schleifen.
- **Drucksensitive Dreiecksstreifen-Tessellierung:** Rechnerische Generierung von Triangle-Meshes mit winkelhalbierenden Normalenvektoren entlang des Pfads.
- **Werkzeuge:**
- **Stift (Pen):** Feine, variable Strichstärken (0.3 mm bis 1.2 mm) mit Notiz-Farben (Schwarz, Blau, Rot, Grün, Grau).
- **Textmarker (Highlighter):** Breite, halbtransparente Striche (4.0 mm bis 8.0 mm) mit Multiplikations-Blending.
@@ -29,7 +30,7 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü
- **Geraden (Line):** Sehnen- zu Pfadlängenverhältnis > 0.92.
- **Rechtecke (Rectangle):** 4 orthogonale Ecken via Douglas-Peucker-Polygonzug (RDP).
- **Kreise & Ellipsen (Circle/Ellipse):** Geschlossene Pfade mit minimaler Varianz via Least-Squares Circle Fit.
- **Koordinatensysteme (Axes):** Zwei orthogonale Achsen (80°–100° gesnappt auf exakt 90°) mit optionalen Richtungspfeilen.
- **Koordinatensysteme (Axes):** Zwei orthogonale Achsen (80°–100° gesnappt auf exakt 90°) mit Richtungspfeilen.
- **Dreiecke, Rhomben / Rauten & Oktaeder:** Regelmäßige Polygone mit Eckpunkt-Analyse.
- **Interaktionsmodelle:**
- **Draw-and-Hold:** Verweilt der Stift am Ende eines Strichs für > 400 ms regungslos (< 3 px), snappt die Skizze sofort automatisch in eine perfekte Vektorform ein.
@@ -46,93 +47,158 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü
### 📚 Multi-Dokumenten- & Notizbuch-Verwaltung
- **Dateisystem-Architektur:** Jedes Dokument wird in einem separaten Verzeichnis unter `~/.local/share/ink-notes/notebooks/<id>/` mit `manifest.json` und Einzelseiten (`page_<id>.json`) gespeichert.
- **Dokumenten-Manager:** Dunkles Glassmorphism-Modal direkt in der Toolbar:
- Neues Notizbuch anlegen.
- Zwischen Notizbüchern mit einem Klick wechseln.
- Dokumente direkt umbenennen (`✏`).
- Dokumente löschen (`🗑`).
- **Dokumenten-Manager:** Toolbar-Modal zum Anlegen, Wechseln, Umbenennen und Löschen von Notizbüchern.
- **Verlustfreier Wechsel:** Beim Dokumentenwechsel sichert ein synchroner Disk-Flush alle anstehenden Änderungen garantiert ab.
### 📄 Seiten & Hintergründe
- **Virtuelles DIN-A4-Raster:** 2100 × 2970 Koordinateneinheiten (10 Einheiten = 1 mm).
- **Hintergrundmuster:**
- Blanko
- Kariert (5 mm Raster)
- Liniert (7.5 mm Zeilenabstand)
- Punktraster (5 mm Dot-Grid)
- **Hintergrundmuster:** Blanko, Kariert (5 mm), Liniert (7.5 mm), Punktraster (5 mm).
- **Seitenverwaltung:** Vor- und Zurückblättern, neue Seiten anfügen, Seitensprung-Menü.
### 💾 Autosave & Persistenz
- **Asynchroner Autosave-Controller:** Sichert Änderungen im Hintergrund-Thread nach 1.5 s Ruhezeit (Debounce), ohne das Zeichnen zu blockieren.
- **Asynchroner Autosave-Controller:** Sichert Änderungen im Hintergrund nach 1.5 s Ruhezeit (Debounce), ohne das Zeichnen zu blockieren.
- **Atomares Schreiben:** Temp-Dateien mit atomarem Rename (`manifest.json.tmp` -> `manifest.json`) schützen vor Datenkorruption bei Abstürzen.
- **Sicherer Shutdown:** `ESC` oder Schließen des Fensters führt vor Programmende einen vollständigen synchronen Flush aus.
- **Dediziertes Speichern (`Strg + S`):** Sofortiger, synchroner Disk-Flush mit visueller Toast-Rückmeldung.
- **Sicherer Shutdown:** `Strg + Q` oder Schließen des Fensters führt vor Programmende einen vollständigen synchronen Flush aus.
### 🖨️ Vektor-PDF-Export
- Exportiert das gesamte Dokument via `printpdf` nach `./export.pdf`.
- Striche und geometrische Formen werden als skalierbare Vektoren ausgegeben.
- Exportiert das Dokument via `printpdf` nach `~/Documents/<Titel>.pdf` (mit automatischer Kollisionsvermeidung `_1`, `_2`).
- Striche und geometrische Formen werden als skalierbare Vektoren ausgegeben. Kürzel: `Strg + E`.
---
### 🚀 Release, Packaging & In-App Auto-Update (Zorin OS 18 / Ubuntu)
- **Natives .deb-Paket**: Optimiert für Zorin OS 18 (Ubuntu 24.04 LTS Noble-Basis) mit vollständiger XDG Desktop-Integration (`.desktop`, Hicolor-Icons SVG & 256x256 PNG, AppStream Metainfo).
- **Portables .AppImage**: Eigenständiges, distributionsunabhängiges Bundle (x86_64) mit integrierten Laufzeitabhängigkeiten.
- **In-App Gitea Updater (`src/updater.rs`)**:
- Fragt im Hintergrund die Gitea Releases API (`https://gitea.pansi.eu/api/v1/repos/harald/ink-notes/releases/latest`) ab.
- Zeigt bei neuen Versionen ein Update-Badge in der Toolbar an.
- Release-Modal mit Versionsvergleich, Changelog und 1-Click-Download / In-Place-Aktualisierung.
- **Gitea Actions CI/CD (`.gitea/workflows/release.yaml`)**: Automatisches Paketieren und Veröffentlichen auf Gitea bei Git-Tags (`v*`).
## 📦 Release, Packaging & Updater
---
## 📦 Paketierung & Installation (Zorin OS 18)
- **Natives .deb-Paket**: Optimiert für Zorin OS 18 (Ubuntu 24.04 LTS Basis) mit vollständiger XDG Desktop-Integration (`.desktop`, Hicolor-Icons SVG & PNG, AppStream Metainfo, Debian Copyright & Changelog).
- **Portables .AppImage**: Eigenständiges x86_64 Bundle.
- **In-App Updater (`src/updater.rs`)**: Automatischer Prüflauf gegen Gitea Releases, SemVer-Filterung, Minisign-Signaturprüfung, SHA-256-Prüfung und atomarer In-Place Austausch.
### 1. Release-Pakete bauen
```bash
# Baut sowohl .deb als auch .AppImage und berechnet SHA256SUMS.txt in target/
# Baut .deb, .AppImage und berechnet SHA256SUMS.txt in target/
./scripts/build-release.sh
```
Ergebnis in `target/`:
- `ink-notes_0.1.0_amd64.deb` (~4.2 MB)
- `ink-notes_0.1.0_x86_64.AppImage` (~5.6 MB)
- `ink-notes_0.1.0_amd64.deb`
- `ink-notes_0.1.0_x86_64.AppImage`
- `SHA256SUMS.txt`
- `SHA256SUMS.txt.minisig` (nur mit gesetztem `MINISIGN_SECRET_KEY`, siehe unten)
### 2. Installation auf Zorin OS 18
### Release-Signatur einrichten (einmalig)
In-App-Updates werden nur mit gültiger Minisign-Signatur installiert. Signiert wird mit [rsign2](https://crates.io/crates/rsign2), einer Minisign-kompatiblen Implementierung (`cargo install rsign2 --locked`). Einmalig ein Schlüsselpaar erzeugen (ohne Passwort, da es nur als CI-Secret verwendet wird):
```bash
rsign generate -W -p ink-notes.pub -s ink-notes.key
```
> **Wichtig:** Schlüssel und Signierwerkzeug müssen zusammenpassen. `rsign generate -W` verschlüsselt den Schlüssel mit einem leeren Passwort; das `minisign`-CLI (z. B. Ubuntu-Paket 0.11) kann ihn ohne Terminal nicht laden. `build-release.sh` und die CI signieren deshalb mit `rsign`.
1. Die zweite Zeile aus `ink-notes.pub` (beginnt mit `RW`) in `src/updater.rs` als `UPDATE_SIGNING_PUBLIC_KEY` eintragen und committen.
2. Den kompletten Inhalt von `ink-notes.key` in Gitea unter *Repository → Einstellungen → Actions → Secrets* als `MINISIGN_SECRET_KEY` anlegen.
3. `ink-notes.key` sicher offline aufbewahren und vom Rechner löschen. Geht der Schlüssel verloren, können bestehende Installationen keine Updates mehr prüfen.
Lokal signiert `build-release.sh` nur, wenn `MINISIGN_SECRET_KEY` gesetzt und `rsign` installiert ist. In der CI ist die Signatur Pflicht (`INK_NOTES_REQUIRE_SIGNATURE=1`), und das Skript prüft, dass der geheime Schlüssel zum eingebauten öffentlichen Schlüssel passt.
### 2. Installation auf Zorin OS 18 / Ubuntu 24.04
```bash
# Natives Debian-Paket installieren
sudo dpkg -i target/ink-notes_0.1.0_amd64.deb
# Oder portables AppImage direkt starten
# Oder portables AppImage starten
chmod +x target/ink-notes_0.1.0_x86_64.AppImage
./target/ink-notes_0.1.0_x86_64.AppImage
```
Nach der `.deb`-Installation ist `ink-notes` nahtlos im Zorin OS Anwendungsmenü unter *Büro* und *Grafik* integriert.
#### Hinweise zu AppImage, glibc & FUSE:
- **glibc-Mindestversion:** `ink-notes` benötigt `glibc >= 2.39` (Standard unter Ubuntu 24.04 LTS und Zorin OS 18).
- **Host-Grafikstack:** Das AppImage bündelt die Anwendungslogik, nutzt für maximale Performance und Hardwarebeschleunigung jedoch den Treiber- und Grafikstack des Host-Systems (Vulkan ICD-Treiber, Wayland bzw. X11).
- **FUSE 2 / FUSE 3 Kompatibilität:** Ubuntu 24.04-basierte Distributionen nutzen standardmäßig FUSE 3 (`fuse3`). Sollte das AppImage beim Start einen Fehler bezüglich `libfuse.so.2` ausgeben, kann entweder das Kompatibilitätspaket installiert werden:
```bash
sudo apt install libfuse2t64
```
oder das AppImage direkt ohne FUSE entpackt und ausgeführt werden:
```bash
./ink-notes_0.1.0_x86_64.AppImage --appimage-extract-and-run
```
### 3. Release auf Gitea veröffentlichen
Das Skript arbeitet vollkommen idempotent und aktualisiert bei wiederholter Ausführung Release-Notizen und Assets:
```bash
# Veröffentlicht die Artefakte auf https://gitea.pansi.eu/harald/ink-notes
./scripts/publish-gitea-release.sh <DEIN_GITEA_TOKEN>
export GITEA_TOKEN="<DEIN_GITEA_TOKEN>"
./scripts/publish-gitea-release.sh
```
---
## 🛡️ Updater-Sicherheit
Der In-App-Updater (`src/updater.rs`) folgt strengen Sicherheitsrichtlinien:
1. **Minisign-Signatur (Pflicht):**
- Jedes Release enthält `SHA256SUMS.txt.minisig`, eine Minisign-Signatur der Prüfsummendatei.
- Der Updater lädt `SHA256SUMS.txt` und die Signatur **vor** dem Paket und prüft sie gegen den in die App eingebauten öffentlichen Schlüssel (`UPDATE_SIGNING_PUBLIC_KEY` in `src/updater.rs`, Crate `minisign-verify`).
- Fehlt die Signatur, ist sie ungültig oder ist kein Schlüssel eingebaut, wird das Update abgelehnt. Ein kompromittierter Release-Server kann so keine manipulierten Pakete ausliefern.
2. **SHA-256-Integritätsprüfung:**
- Der SHA-256-Hash des heruntergeladenen Pakets wird mit dem Eintrag in der signierten `SHA256SUMS.txt` verglichen (`sha2`).
- Weicht die Prüfsumme ab oder fehlt ein Eintrag, wird die Datei sofort gelöscht und der Installationsvorgang mit Fehlermeldung abgebrochen.
3. **Redirect-Schutz & HTTPS:**
- Jede Anfrage erzwingt HTTPS – auch Download-Links aus der Release-API und jedes Weiterleitungsziel.
- Bei HTTP-Redirects werden sensible Autorisierungs-Header (`Authorization: token ...`) verworfen, sobald die Weiterleitung auf eine andere Origin oder ein externes CDN verweist.
4. **Isolierter Download-Pfad & Path-Traversal-Schutz:**
- Downloads werden in einem isolierten Benutzerverzeichnis abgelegt (`$XDG_CACHE_HOME/ink-notes/updates` bzw. `~/.cache/ink-notes/updates`, Berechtigung `0700`).
- Asset-Namen werden sanitisiert: Pfadtrenner (`/`, `\`), Pfadüberquerungen (`..`), Steuerzeichen und leere Dateinamen werden strikt abgewiesen.
5. **Atomarer AppImage-Tausch:**
- Kein direktes Überschreiben laufender Programmdateien per `fs::copy` (verhindert den Linux-Kernel-Fehler *"Text file busy"*).
- Die neue Datei wird temporär im selben Verzeichnis angelegt (`<name>.new.<pid>`), mit Ausführungsrechten (`0755`) versehen, das bestehende AppImage als `<name>.bak` gesichert (dessen Fehler nicht ignoriert werden) und anschließend per atomarem `fs::rename` ausgetauscht.
---
## ⚙️ CI-Runner einrichten (Gitea Actions / act_runner)
Der Veröffentlichungs-Workflow liegt unter [`.gitea/workflows/release.yaml`](.gitea/workflows/release.yaml).
### Runner-Label konfigurieren
Der Workflow nutzt standardmäßig das Runner-Label:
```yaml
runs-on: ubuntu-latest
```
In der Konfigurationsdatei Ihres Gitea `act_runner` (`config.yaml`) muss dieses Label auf ein passendes Container-Image oder den Host gemappt sein:
```yaml
runner:
labels:
- "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"
# oder für native Host-Ausführung:
# - "ubuntu-latest:host"
```
### Diagnose & Fehlerbehebung
Bleibt ein Release-Job auf **"Waiting to run"** hängen:
1. **Runner online?** Prüfen Sie in Gitea unter *Einstellungen -> Aktionen -> Runner*, ob der `act_runner` aktiv und mit einem grünen Punkt als *Online* markiert ist.
2. **Label-Übereinstimmung:** Stimmen die im Runner registrierten Labels exakt mit dem `runs-on`-Feld in `.gitea/workflows/release.yaml` überein?
3. **Rechte:** Der Workflow prüft `if [ "$(id -u)" -ne 0 ]` und setzt `sudo` nur ein, wenn der Runner nicht als `root` läuft. Das verhindert Fehler sowohl in unprivilegierten Host-Umgebungen als auch in Root-Containern.
---
## 🚀 Schnellstart (Entwicklung)
### Voraussetzungen
- **Rust & Cargo:** Version 1.75 oder neuer (`rustup update stable`)
- **System-Bibliotheken (Linux):**
- Vulkan-Treiber (z. B. `mesa-vulkan-drivers` oder proprietäre Treiber)
- X11 / XWayland Bibliotheken (`libx11-dev`, `libxcursor-dev`, `libxrandr-dev`, `libxi-dev`, `libxkbcommon-dev`)
- X11 / Wayland Bibliotheken (`libx11-dev`, `libxcursor-dev`, `libxrandr-dev`, `libxi-dev`, `libxkbcommon-dev`)
### Bauen & Ausführen
```bash
# Repository klonen
git clone gitea@farmersdog.pansi.eu:harald/ink-notes.git
git clone https://gitea.pansi.eu/harald/ink-notes.git
cd ink-notes
# Kompilieren und starten via Starter-Skript
@@ -164,8 +230,10 @@ cargo run --release
| **Nächste Seite** | `Bild ab (PageDown)` oder `N` |
| **Vorherige Seite** | `Bild auf (PageUp)` |
| **Erste / Letzte Seite** | `Pos 1 (Home)` / `Ende (End)` |
| **PDF-Export** | `Strg + E` oder `Strg + S` |
| **Beenden (mit Disk-Flush)** | `Esc` oder `Q` |
| **Jetzt speichern (Disk Flush)** | `Strg + S` |
| **Vektor-PDF-Export** | `Strg + E` |
| **Dialoge / Menüs schließen** | `Esc` |
| **Beenden (mit Disk-Flush)** | `Strg + Q` (oder Fenster schließen) |
---
@@ -179,7 +247,7 @@ src/
├── viewport.rs # Kamera-Pan, Zoom, 2D-Transformationsmatrizen
├── shader.wgsl # WGSL Vertex- & Fragment-Shader für Inking-Meshes
├── filter.rs # One-Euro-Filter gegen Eingabe-Jitter
├── spline.rs # Centripetal Catmull-Rom & Dreiecksstreifen-Tessellierung
├── spline.rs # Catmull-Rom, Corner Preservation & Dreiecksstreifen
├── geometry.rs # RDP-Algorithmus, Least-Squares Circle Fit, Shape Recognition
├── selection.rs # Lasso-Auswahl, Bounding-Box, Griffe, Rotation & Skalierung
├── background.rs # Tessellierung für Kariert, Liniert, Punktraster
@@ -187,7 +255,7 @@ src/
├── history.rs # Undo/Redo-Transaktionsstapel (Add, Erase, Transform)
├── storage.rs # Atomares Laden/Speichern von Manifest & Seiten, Multi-Notebooks
├── autosave.rs # Debounced Hintergrund-Autosave mit MPSC-Kanal
├── updater.rs # In-App Gitea Updater (SemVer, Release-API, Download, Self-Update)
├── updater.rs # In-App Gitea Updater (SemVer, HTTPS, SHA256-Integrität, Self-Update)
├── export.rs # Skalierbarer Vektor-PDF-Export via printpdf
└── ui.rs # Floating Glassmorphism-Toolbar, Dokumenten- & Update-Manager (egui)
```
@@ -198,16 +266,16 @@ Ausführliche Details zur Architektur und mathematischen Modellen findest du unt
## 🧪 Tests ausführen
Die gesamte Inking-Pipeline, Geometrieerkennung, Speicherlogik und der Updater sind mit Unit-Tests abgesichert:
Die gesamte Inking-Pipeline, Geometrieerkennung, Speicherlogik und der Updater sind mit umfassenden Unit-Tests abgesichert:
```bash
cargo test
```
Alle 61 Unit-Tests prüfen Berechnungen, Hit-Testing, Undo/Redo, Persistenz und Versionsvergleiche konsistent ab.
Alle Unit-Tests prüfen Berechnungen, Hit-Testing, Undo/Redo, Persistenz, Updater-Sicherheit und Versionsvergleiche konsistent ab.
---
## 📄 Lizenz
MIT License / Open Source Inking Project for Linux.
Dieses Projekt ist unter der [MIT-Lizenz](LICENSE) lizenziert (Copyright © 2026 Harald Pansi).
+54 -51
View File
@@ -1,6 +1,6 @@
# Dateiformat & Speicherstruktur von ink-notes
Dieses Dokument beschreibt die Speicherstruktur und die JSON-Dateiformate, mit denen `ink-notes` Dokumente auf dem Dateisystem ablegt.
Dieses Dokument beschreibt die Speicherstruktur, die JSON-Dateiformate und die Versionshistorie, mit denen `ink-notes` Dokumente auf dem Dateisystem ablegt.
---
@@ -13,7 +13,9 @@ Notizbücher werden standardmäßig unter dem XDG-Datenpfad gespeichert:
└── notebooks/
├── <notebook_id_1>/
│ ├── manifest.json
│ ├── manifest.json.v0.bak (optional, Migrations-Backup)
│ ├── page_1.json
│ ├── page_1.json.corrupt-1775460000 (optional, Quarantäne bei Parsefehlern)
│ ├── page_2.json
│ └── ...
└── <notebook_id_2>/
@@ -29,19 +31,20 @@ Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge
```json
{
"format_version": 2,
"id": "notebook-a1b2c3d4",
"title": "Mathematik Vorlesung",
"version": 1,
"pages": [
{
"id": 1,
"file_name": "page_1.json"
"background": "Grid5mm"
},
{
"id": 2,
"file_name": "page_2.json"
"background": "DotGrid"
}
],
"page_ids": [1, 2],
"current_page_index": 0,
"next_page_id": 3,
"next_stroke_id": 42,
@@ -51,10 +54,11 @@ Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge
```
### Felder:
- `format_version`: Format-Versionsnummer (aktuell `2`, bei unversionierten Legacy-Dateien implizit `0`).
- `id`: Eindeutige ID des Notizbuchs (z. B. UUID oder `"default"`).
- `title`: Vom Nutzer vergebener Anzeigename des Notizbuchs.
- `version`: Format-Versionsnummer (aktuell `1`).
- `pages`: Geordnete Liste von Seitenreferenzen.
- `pages`: Geordnete Liste von Seitenmetadaten (`id`, optional `background`).
- `page_ids`: Abwärtskompatible Liste der Seiten-IDs.
- `current_page_index`: Index der zuletzt aktiven Seite für Session-Restore.
- `next_page_id`: Inkrementierender ID-Zähler für neue Seiten.
- `next_stroke_id`: Globaler ID-Zähler für neue Striche.
@@ -65,58 +69,21 @@ Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge
## 3. Seitendatei (`page_<id>.json`)
Jede Seite wird in einer eigenen Datei gespeichert, um Lazy-Loading und schnelles inkrementelles Speichern zu ermöglichen.
Die Speicherung erfolgt ab Version 2 als kompaktes JSON (`serde_json::to_vec`), wodurch gegenüber Pretty-Printed JSON über 50 % Speicherplatz eingespart wird.
```json
{
"id": 1,
"background": "Grid5mm",
"strokes": [
{
"id": 1,
"color": [0.12, 0.12, 0.14, 1.0],
"width_mm": 0.5,
"samples": [
{
"x": 350.2,
"y": 420.5,
"pressure": 0.65,
"timestamp": 12.345
},
{
"x": 352.1,
"y": 425.8,
"pressure": 0.70,
"timestamp": 12.355
}
],
"recognized_shape": null
},
{
"id": 2,
"color": [0.15, 0.35, 0.85, 1.0],
"width_mm": 0.8,
"samples": [],
"recognized_shape": {
"Rectangle": {
"top_left": { "x": 500.0, "y": 600.0 },
"width": 300.0,
"height": 200.0
}
}
}
]
}
{"format_version":2,"id":1,"background":"Grid5mm","strokes":[{"id":1,"color":[0.12,0.12,0.14,1.0],"samples":[{"x":350.2,"y":420.5,"p":0.65,"t":12.345},{"x":352.1,"y":425.8,"p":0.70,"t":12.355}],"shape":null,"tool":"pen","width":5.0,"finalized":true},{"id":2,"color":[0.15,0.35,0.85,1.0],"samples":[],"shape":{"Rectangle":{"top_left":{"x":500.0,"y":600.0},"width":300.0,"height":200.0}},"tool":"shape","width":20.0,"finalized":true}]}
```
### Felder:
- `format_version`: Format-Versionsnummer (`2`).
- `id`: Eindeutige ID der Seite.
- `background`: Papiermuster (`Blank`, `Grid5mm`, `Lined`, `DotGrid`).
- `strokes`: Liste der auf der Seite gezeichneten Striche und Vektorformen.
- `id`: Eindeutige ID des Strichs.
- `color`: RGBA-Farbwerte im Bereich `[0.0, 1.0]`.
- `width_mm`: Basis-Strichbreite in Millimetern.
- `samples`: Rohabtastpunkte entlang des Pfads (`x`, `y` in DIN-A4-Einheiten, `pressure` in `[0.0, 1.0]`, `timestamp` in Sekunden).
- `recognized_shape`: Optionales Enum für geometrische Vektorformen:
- `samples`: Rohabtastpunkte entlang des Pfads (`x`, `y` in DIN-A4-Einheiten, `p` Druckwert in `[0.0, 1.0]`, `t` Zeitstempel in Sekunden).
- `shape`: Optionales Enum für geometrische Vektorformen:
- `Line { start, end }`
- `Rectangle { top_left, width, height }`
- `Circle { center, radius }`
@@ -124,11 +91,47 @@ Jede Seite wird in einer eigenen Datei gespeichert, um Lazy-Loading und schnelle
- `Triangle { p1, p2, p3 }`
- `Rhombus { center, width, height }`
- `Octahedron { center, radius }`
- `tool`: Zeichenwerkzeug (`"pen"`, `"highlighter"`, `"shape"`).
- `width`: Strichbreite in internen DIN-A4-Einheiten (10 Einheiten = 1 mm; z. B. `5.0` für 0.5 mm Stift, `60.0` für 6.0 mm Marker).
- `finalized`: Boolesches Flag für Striche, die das Zwei-Stufen-Inking (Eckenerkennung, Glättung, runde Endkappen) durchlaufen haben.
---
## 4. Atomares Schreibverfahren
## 4. Datensicherheit & Migration
Um Datenverlust bei Stromausfall oder plötzlichem Prozessabbruch zu verhindern:
1. Daten werden in `<ziel>.tmp` serialisiert und per `fsync` auf das Speichermedium geschrieben.
### Atomares Schreiben & Verzeichnis-Sync
1. Daten werden in `<ziel>.tmp.<pid>.<nanos>` serialisiert und per `fsync` auf das Speichermedium geschrieben (Dateirechte `0600` auf Unix).
2. Mittels `std::fs::rename("<ziel>.tmp", "<ziel>")` wird die Zieldatei auf POSIX-Systemen atomar ersetzt.
3. Nach dem Rename wird das übergeordnete Verzeichnis synchronisiert (`sync_all()`), um Persistenz bei Stromausfall zu garantieren. Verzeichnisse erhalten Berechtigung `0700`.
### Quarantäne bei Parsefehlern
Wird eine Seitendatei beim Laden als fehlerhaft erkannt (z. B. unvollständiges oder korruptes JSON), wird sie nicht stillschweigend gelöscht oder überschrieben, sondern nach `page_<id>.json.corrupt-<unix_ts>` verschoben. Die Ersatzseite wird als schreibgeschützt markiert.
### Migrations-Backups & Rolling Backups
Beim ersten Speichern einer Datei im Legacy-Format (Version 0) wird vor dem Überschreiben ein Backup `*.v0.bak` angelegt (z. B. `manifest.json.v0.bak`, `page_1.json.v0.bak`).
Zusätzlich existiert ein Rolling-Backup `manifest.json.bak`, auf das bei beschädigtem Manifest automatisch zurückgegriffen wird.
---
## 5. Versionshistorie
| Version | Status | Beschreibung |
|---|---|---|
| **0** | Legacy | Unversioniertes Format. `format_version` fehlt in JSON. Implizit als Version 0 interpretiert. |
| **1** | Überholt | Einführung von `format_version: u32` in `manifest.json` und `page_<id>.json`. Seiten-Hintergrund im Manifest gecached (`pages: Vec<PageMeta>`). Strenges Versionsmatching (neuere Versionen werden abgelehnt). Automatische Migration mit `*.v0.bak`. |
| **2** | **Aktuell** | Stroke-Treue: Ergänzung von `tool`, `width` und `finalized` in `StrokeData` für exakte Rekonstruktion aller Vertizes und Splines. Umstellung auf kompaktes JSON (`serde_json::to_vec`, ca. 54% Platzerparnis). |
---
## 6. Analyse & Bewertung: Binärformat (z. B. `postcard`)
Eine Umstellung auf ein Binärformat wie `postcard` wurde evaluiert:
- **Vorteile**:
- Weitere 20–30 % Reduktion der Dateigröße gegenüber kompaktem JSON.
- Minimal schnellere Serialisierung auf leistungsschwachen Systemen.
- **Nachteile**:
- Verlust der Text-Transparenz (JSON lässt sich direkt inspizieren, mit `git`/Tools diffen und bei Korruption manuell reparieren).
- Snapshotting und JSON-Verarbeitung liegen im Benchmark bereits bei **0,02 ms (1k Samples)** bis **0,70 ms (50k Samples)** – also weit unter dem Zielwert von 1 ms.
- Kompaktes JSON spart bereits über 53 % Dateigröße ein.
- **Entscheidung**:
Kompaktes JSON bietet aktuell das optimale Verhältnis aus Ausfallsicherheit, Benutzerfreundlichkeit, Performance und Portabilität. Ein Binärformat wie `postcard` bleibt als Option für spätere Riesen-Notizbücher (> 500 Seiten) denkbar, wird aktuell jedoch nicht eingeführt.
+18
View File
@@ -0,0 +1,18 @@
[Desktop Entry]
Version=1.0
Name=ink-notes
GenericName=Notizbuch- & Inking-App
Comment=Latenzoptimierte Vektor-Notizen mit Stylus, Stift & Geometrie-Erkennung
Exec=ink-notes %F
Icon=eu.pansi.ink-notes
Terminal=false
Type=Application
Categories=Office;Graphics;Utility;
Keywords=Note;Notiz;Pen;Stift;Ink;Inking;Stylus;Drawing;Zeichnen;PDF;Vector;
StartupWMClass=ink-notes
MimeType=application/x-inknotes;
Actions=NewWindow;
[Desktop Action NewWindow]
Name=Neues Fenster
Exec=ink-notes
+38
View File
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>eu.pansi.ink-notes</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<name>ink-notes</name>
<summary>Latenzoptimierte Vektor-Notizbuch- und Inking-App für Linux</summary>
<description>
<p>
ink-notes ist eine hardwarebeschleunigte, hochperformante Notizbuch- und Inking-Anwendung für Linux,
geschrieben in Rust mit wgpu. Sie bietet minimale Eingabelatenz mit Stylus, Touchpad oder Maus.
</p>
<p>Hauptmerkmale:</p>
<ul>
<li>Zwei-Stufen-Inking-Modell: Echtzeit-Glättung mit 1€-Filter und akausale Ecken-Erhaltung</li>
<li>Automatische Geometrie- und Formerkennung (Geraden, Rechtecke, Kreise, Koordinatensysteme, Polygone)</li>
<li>Stufenloser Viewport mit cursor-zentriertem Zoom (25% bis 500%) und Panning</li>
<li>Objektauswahl, Lasso, Skalieren, stufenloses Drehen und Multicolor-Umfärbung</li>
<li>Multi-Dokumenten- und Notizbuch-Verwaltung mit atomarem Autosave</li>
<li>Vektor-PDF-Export für Druck und Weitergabe</li>
</ul>
</description>
<launchable type="desktop-id">eu.pansi.ink-notes.desktop</launchable>
<provides>
<binary>ink-notes</binary>
</provides>
<categories>
<category>Office</category>
<category>Graphics</category>
<category>Utility</category>
</categories>
<developer id="eu.pansi">
<name>Harald</name>
</developer>
<url type="homepage">https://gitea.pansi.eu/harald/ink-notes</url>
<url type="bugtracker">https://gitea.pansi.eu/harald/ink-notes/issues</url>
<content_rating type="oars-1.1" />
</component>
+1 -1
View File
@@ -4,7 +4,7 @@ Name=ink-notes
GenericName=Notizbuch- & Inking-App
Comment=Latenzoptimierte Vektor-Notizen mit Stylus, Stift & Geometrie-Erkennung
Exec=ink-notes %F
Icon=ink-notes
Icon=eu.pansi.ink-notes
Terminal=false
Type=Application
Categories=Office;Graphics;Utility;
+3 -3
View File
@@ -20,7 +20,7 @@
<li>Vektor-PDF-Export für Druck und Weitergabe</li>
</ul>
</description>
<launchable type="desktop-id">ink-notes.desktop</launchable>
<launchable type="desktop-id">eu.pansi.ink-notes.desktop</launchable>
<provides>
<binary>ink-notes</binary>
</provides>
@@ -32,7 +32,7 @@
<developer id="eu.pansi">
<name>Harald</name>
</developer>
<url type="homepage">https://farmersdog.pansi.eu/harald/ink-notes</url>
<url type="bugtracker">https://farmersdog.pansi.eu/harald/ink-notes/issues</url>
<url type="homepage">https://gitea.pansi.eu/harald/ink-notes</url>
<url type="bugtracker">https://gitea.pansi.eu/harald/ink-notes/issues</url>
<content_rating type="oars-1.1" />
</component>
+24
View File
@@ -0,0 +1,24 @@
## ink-notes {{TAG_NAME}}
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
### Highlights:
- **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Natives Wayland-Tablet-Protokoll mit präziser Druck- und Neigungserfassung
- **Touchscreen Multi-Touch**: Stufenloses 2-Finger Pinch-to-Zoom und Panning mit umschaltbarem Finger-Zeichenmodus
- **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation)
- **Geometrie- und Formerkennung**: Geraden, Rechtecke, Kreise, Rauten, Polygone & Koordinatensysteme (Draw-and-Hold)
- **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave, Papierkorb und fortlaufenden Backups
- **Datensicherheit**: Versioniertes Dateiformat mit Migration, Quarantäne beschädigter Seiten, Schutz vor Doppelöffnung und Crash-getestetem Speichern
- **In-App Updater**: Automatische Versionsprüfung und sichere Atomar-Aktualisierung via Gitea Releases – mit Pflicht-Signaturprüfung (Minisign) und durchgängigem HTTPS
- **Vektor-PDF-Export**: Verlustfreie Ausgabe für Druck und Weitergabe
### Installation:
- **Zorin OS / Ubuntu / Debian (.deb)**:
```bash
sudo dpkg -i ink-notes_{{VERSION}}_amd64.deb
```
- **Portables AppImage (Alle Linux-Distributionen)**:
```bash
chmod +x ink-notes_{{VERSION}}_x86_64.AppImage
./ink-notes_{{VERSION}}_x86_64.AppImage
```
+42 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# ==============================================================================
# Haupt-Release-Build-Skript für ink-notes (Zorin OS 18 / Linux)
# Erstellt .deb, .AppImage und SHA256-Prüfsummen in target/
# Erstellt .deb, .AppImage, SHA256-Prüfsummen und deren Minisign-Signatur in target/
# ==============================================================================
set -euo pipefail
@@ -30,10 +30,51 @@ echo "=== Berechne SHA256-Prüfsummen in target/${CHECKSUM_FILE} ==="
cd "${ROOT_DIR}/target"
sha256sum "${DEB_NAME}" "${APPIMAGE_NAME}" > "${CHECKSUM_FILE}"
# 4. Prüfsummen signieren (Pflicht für In-App-Updates)
# Signiert wird mit rsign2 (minisign-kompatibles Format): Der Schlüssel wurde mit
# `rsign generate -W` erzeugt; das minisign-CLI kann ihn ohne Terminal nicht laden.
SIGNATURE_FILE="${CHECKSUM_FILE}.minisig"
rm -f "${SIGNATURE_FILE}"
# Schlüssel kann von rustfmt in die Folgezeile umgebrochen sein
EMBEDDED_PUBKEY=$(grep -m1 -A1 'pub const UPDATE_SIGNING_PUBLIC_KEY' "${ROOT_DIR}/src/updater.rs" | grep -o '"[^"]*"' | head -n1 | tr -d '"')
if [ -n "${MINISIGN_SECRET_KEY:-}" ]; then
if [ -z "${EMBEDDED_PUBKEY}" ]; then
echo "FEHLER: UPDATE_SIGNING_PUBLIC_KEY in src/updater.rs ist leer – diese Version könnte keine Updates prüfen."
exit 1
fi
if ! command -v rsign >/dev/null 2>&1; then
echo "FEHLER: rsign nicht gefunden (Installation: cargo install rsign2 --locked)."
exit 1
fi
echo "=== Signiere ${CHECKSUM_FILE} mit rsign2 ==="
KEY_FILE="$(mktemp)"
trap 'rm -f "${KEY_FILE}"' EXIT
printf '%s\n' "${MINISIGN_SECRET_KEY}" > "${KEY_FILE}"
rsign sign -W -s "${KEY_FILE}" -t "ink-notes v${VERSION}" -x "${SIGNATURE_FILE}" "${CHECKSUM_FILE}"
rm -f "${KEY_FILE}"
# Stellt sicher, dass der geheime Schlüssel zum eingebauten öffentlichen Schlüssel passt
# rsign beendet sich bei falscher Key-ID mit Exit-Code 0, daher zusätzlich die Meldung prüfen
if ! VERIFY_OUT=$(rsign verify -P "${EMBEDDED_PUBKEY}" -x "${SIGNATURE_FILE}" "${CHECKSUM_FILE}" 2>&1) \
|| ! grep -q "Signature and comment signature verified" <<<"${VERIFY_OUT}"; then
echo "${VERIFY_OUT}"
echo "FEHLER: Signatur passt nicht zum eingebauten öffentlichen Schlüssel (UPDATE_SIGNING_PUBLIC_KEY)."
rm -f "${SIGNATURE_FILE}"
exit 1
fi
echo "${VERIFY_OUT}"
elif [ "${INK_NOTES_REQUIRE_SIGNATURE:-0}" = "1" ]; then
echo "FEHLER: MINISIGN_SECRET_KEY ist nicht gesetzt, eine Signatur ist aber Pflicht."
exit 1
else
echo "WARNUNG: MINISIGN_SECRET_KEY nicht gesetzt – ${CHECKSUM_FILE} bleibt unsigniert."
echo " Installierte Apps lehnen ein solches Release als Update ab."
fi
echo "======================================================================"
echo "Release-Artefakte für v${VERSION} erfolgreich erstellt:"
echo "======================================================================"
ls -lh "${DEB_NAME}" "${APPIMAGE_NAME}" "${CHECKSUM_FILE}"
if [ -f "${SIGNATURE_FILE}" ]; then ls -lh "${SIGNATURE_FILE}"; fi
echo ""
echo "Inhalt von target/${CHECKSUM_FILE}:"
cat "${CHECKSUM_FILE}"
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# ==============================================================================
# scripts/crash-test.sh
# Stresstest für ink-notes Speicher- und Autosave-Robustheit.
# Simuliert harte Programmabstürze (SIGKILL / kill -9) während Schreibvorgängen
# und verifiziert die Integrität der Notizbücher und Seiten.
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
ITERATIONS="${1:-200}"
TEST_DIR="${REPO_ROOT}/target/crash_test_workspace"
CRASH_SIM_BIN="${REPO_ROOT}/target/release/crash_sim"
echo "=================================================================="
echo " ink-notes Crash- & Datenverlust-Stresstest"
echo " Iterationen: ${ITERATIONS}"
echo " Testverzeichnis: ${TEST_DIR}"
echo "=================================================================="
# 1. Sicherstellen, dass das Test-Binary gebaut ist
if [ ! -f "${CRASH_SIM_BIN}" ]; then
echo "Baue crash_sim Release-Binary..."
cargo build --release --bin crash_sim --manifest-path "${REPO_ROOT}/Cargo.toml"
fi
# 2. Sauberes Testverzeichnis anlegen und Notizbuch initialisieren
rm -rf "${TEST_DIR}"
mkdir -p "${TEST_DIR}"
"${CRASH_SIM_BIN}" init "${TEST_DIR}"
SUCCESS_COUNT=0
INTERRUPTED_WRITES=0
trap 'rm -rf "${TEST_DIR}"' EXIT
echo "Starte Testlauf mit ${ITERATIONS} SIGKILL-Zyklen..."
for i in $(seq 1 "${ITERATIONS}"); do
# Worker im Hintergrund starten
"${CRASH_SIM_BIN}" worker "${TEST_DIR}" > /dev/null 2>&1 &
WORKER_PID=$!
# Zufällige Schlafdauer zwischen 2ms und 35ms, um Schreiboperationen mitten im Flug zu treffen
# RANDOM % 33 + 2 ergibt 2..34 Millisekunden
MS_DELAY=$(( (RANDOM % 33) + 2 ))
DELAY="0.$(printf "%03d" "$MS_DELAY")"
sleep "${DELAY}"
# Harter Prozessabbruch ohne Clean-up (SIGKILL)
if kill -9 "${WORKER_PID}" 2>/dev/null; then
INTERRUPTED_WRITES=$((INTERRUPTED_WRITES + 1))
fi
wait "${WORKER_PID}" 2>/dev/null || true
# Integritäts- und Konsistenzprüfung
if ! "${CRASH_SIM_BIN}" verify "${TEST_DIR}" > /dev/null 2>&1; then
echo ""
echo "❌ FEHLER in Iteration ${i}!"
echo "Notizbuch im Zustand nach Absturz nicht konsistent ladbar!"
"${CRASH_SIM_BIN}" verify "${TEST_DIR}" || true
trap - EXIT
exit 1
fi
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
if [ $((i % 25)) -eq 0 ] || [ "${i}" -eq "${ITERATIONS}" ]; then
echo " [$(date +%T)] Iteration ${i}/${ITERATIONS} bestanden (erfolgreich unterbrochene Schreibvorgänge: ${INTERRUPTED_WRITES})."
fi
done
echo ""
echo "=================================================================="
echo "✅ ALLE ${ITERATIONS} CRASH-TESTS ERFOLGREICH BESTANDEN!"
echo " - Überstandene SIGKILLs: ${INTERRUPTED_WRITES}"
echo " - Konsistente Ladevorgänge: ${SUCCESS_COUNT}/${ITERATIONS}"
echo " - Keine korrupten JSON-Dateien ohne Quarantäne"
echo " - Keine unvollständigen Atom-Schreibungen"
echo "=================================================================="
+48 -11
View File
@@ -18,11 +18,22 @@ APPDIR="${ROOT_DIR}/target/AppDir"
CACHE_DIR="${ROOT_DIR}/target/tools"
APPIMAGETOOL="${CACHE_DIR}/appimagetool-x86_64.AppImage"
# Pinned appimagetool Release & SHA-256 Hash
APPIMAGETOOL_VERSION="1.9.1"
APPIMAGETOOL_URL="https://github.com/AppImage/appimagetool/releases/download/${APPIMAGETOOL_VERSION}/appimagetool-x86_64.AppImage"
APPIMAGETOOL_SHA256="ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0"
echo "=== [1/5] Stelle Release-Binary sicher (ink-notes v${VERSION}) ==="
if [ ! -f "target/release/ink-notes" ]; then
cargo build --release
fi
# Prüfe glibc-Mindestversion
echo "=== Prüfe glibc-Symbolversionen ==="
MAX_GLIBC=$(objdump -T "target/release/ink-notes" | grep "GLIBC_" | sed -e 's/.*GLIBC_//' | cut -d')' -f1 | sort -V | tail -n 1 || echo "unknown")
echo "Höchste benötigte glibc-Symbolversion: GLIBC_${MAX_GLIBC}"
echo "Hinweis: ink-notes nutzt den Host-Grafikstack (Vulkan ICD, Wayland/X11) und glibc >= ${MAX_GLIBC}."
# Sicherstellen, dass das PNG-Icon existiert
if [ ! -f "assets/ink-notes.png" ]; then
echo "Generiere PNG-Icon..."
@@ -41,15 +52,20 @@ mkdir -p "${APPDIR}/usr/share/metainfo"
cp "target/release/ink-notes" "${APPDIR}/usr/bin/ink-notes"
chmod 755 "${APPDIR}/usr/bin/ink-notes"
cp "packaging/ink-notes.desktop" "${APPDIR}/usr/share/applications/ink-notes.desktop"
cp "packaging/ink-notes.desktop" "${APPDIR}/ink-notes.desktop"
# Desktop-Datei
cp "packaging/eu.pansi.ink-notes.desktop" "${APPDIR}/usr/share/applications/eu.pansi.ink-notes.desktop"
cp "packaging/eu.pansi.ink-notes.desktop" "${APPDIR}/eu.pansi.ink-notes.desktop"
ln -sf "eu.pansi.ink-notes.desktop" "${APPDIR}/ink-notes.desktop"
cp "assets/ink-notes.svg" "${APPDIR}/usr/share/icons/hicolor/scalable/apps/ink-notes.svg"
cp "assets/ink-notes.png" "${APPDIR}/usr/share/icons/hicolor/256x256/apps/ink-notes.png"
cp "assets/ink-notes.png" "${APPDIR}/ink-notes.png"
ln -sf "ink-notes.png" "${APPDIR}/.DirIcon"
# Icons
cp "assets/ink-notes.svg" "${APPDIR}/usr/share/icons/hicolor/scalable/apps/eu.pansi.ink-notes.svg"
cp "assets/ink-notes.png" "${APPDIR}/usr/share/icons/hicolor/256x256/apps/eu.pansi.ink-notes.png"
cp "assets/ink-notes.png" "${APPDIR}/eu.pansi.ink-notes.png"
ln -sf "eu.pansi.ink-notes.png" "${APPDIR}/.DirIcon"
cp "packaging/ink-notes.metainfo.xml" "${APPDIR}/usr/share/metainfo/eu.pansi.ink-notes.metainfo.xml"
# Metainfo
cp "packaging/eu.pansi.ink-notes.metainfo.xml" "${APPDIR}/usr/share/metainfo/eu.pansi.ink-notes.metainfo.xml"
cp "packaging/eu.pansi.ink-notes.metainfo.xml" "${APPDIR}/usr/share/metainfo/eu.pansi.ink-notes.appdata.xml"
# AppRun Skript erstellen
cat <<'EOF' > "${APPDIR}/AppRun"
@@ -62,11 +78,32 @@ exec "${SELF_DIR}/usr/bin/ink-notes" "$@"
EOF
chmod 755 "${APPDIR}/AppRun"
echo "=== [3/5] Hole appimagetool (gecacht in ${CACHE_DIR}) ==="
echo "=== [3/5] Hole & verifiziere gepinntes appimagetool v${APPIMAGETOOL_VERSION} ==="
mkdir -p "${CACHE_DIR}"
if [ ! -f "${APPIMAGETOOL}" ]; then
echo "Lade appimagetool herunter..."
curl -fsSL -o "${APPIMAGETOOL}" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage"
need_download=true
if [ -f "${APPIMAGETOOL}" ]; then
CURRENT_HASH=$(sha256sum "${APPIMAGETOOL}" | cut -d' ' -f1)
if [ "${CURRENT_HASH}" = "${APPIMAGETOOL_SHA256}" ]; then
need_download=false
else
echo "Gecachtes appimagetool hat ungültigen Hash. Lade neu herunter..."
rm -f "${APPIMAGETOOL}"
fi
fi
if [ "${need_download}" = true ]; then
echo "Lade appimagetool v${APPIMAGETOOL_VERSION} herunter..."
curl -fsSL -o "${APPIMAGETOOL}.tmp" "${APPIMAGETOOL_URL}"
DOWNLOAD_HASH=$(sha256sum "${APPIMAGETOOL}.tmp" | cut -d' ' -f1)
if [ "${DOWNLOAD_HASH}" != "${APPIMAGETOOL_SHA256}" ]; then
echo "FEHLER: SHA256-Prüfsumme für appimagetool stimmt nicht überein!"
echo "Erwartet: ${APPIMAGETOOL_SHA256}"
echo "Erhalten: ${DOWNLOAD_HASH}"
rm -f "${APPIMAGETOOL}.tmp"
exit 1
fi
mv "${APPIMAGETOOL}.tmp" "${APPIMAGETOOL}"
chmod +x "${APPIMAGETOOL}"
fi
+96 -20
View File
@@ -16,7 +16,7 @@ ARCH="amd64"
DEB_NAME="${PKG_NAME}_${VERSION}_${ARCH}.deb"
STAGING_DIR="${ROOT_DIR}/target/deb_staging"
echo "=== [1/4] Baue Release-Binary mit Cargo (ink-notes v${VERSION}) ==="
echo "=== [1/5] Baue Release-Binary mit Cargo (ink-notes v${VERSION}) ==="
cargo build --release
# Sicherstellen, dass das PNG-Icon existiert
@@ -25,7 +25,23 @@ if [ ! -f "assets/ink-notes.png" ]; then
python3 scripts/generate_png_icon.py
fi
echo "=== [2/4] Erstelle Paketstruktur in ${STAGING_DIR} ==="
echo "=== [2/5] Ermittle tatsächliche Abhängigkeiten (ldd & dpkg-shlibdeps) ==="
echo "Linker-Abhängigkeiten (ldd):"
ldd target/release/ink-notes
if command -v dpkg-shlibdeps >/dev/null 2>&1; then
echo "Ermittle automatische shlibdeps via dpkg-shlibdeps..."
TEMP_CONTROL_DIR="$(mktemp -d)"
mkdir -p "${TEMP_CONTROL_DIR}/debian"
echo "Source: ${PKG_NAME}" > "${TEMP_CONTROL_DIR}/debian/control"
(
cd "${TEMP_CONTROL_DIR}"
dpkg-shlibdeps -O "${ROOT_DIR}/target/release/ink-notes" 2>/dev/null || true
)
rm -rf "${TEMP_CONTROL_DIR}"
fi
echo "=== [3/5] Erstelle Paketstruktur in ${STAGING_DIR} ==="
rm -rf "${STAGING_DIR}"
mkdir -p "${STAGING_DIR}/DEBIAN"
mkdir -p "${STAGING_DIR}/usr/bin"
@@ -33,44 +49,104 @@ mkdir -p "${STAGING_DIR}/usr/share/applications"
mkdir -p "${STAGING_DIR}/usr/share/icons/hicolor/scalable/apps"
mkdir -p "${STAGING_DIR}/usr/share/icons/hicolor/256x256/apps"
mkdir -p "${STAGING_DIR}/usr/share/metainfo"
mkdir -p "${STAGING_DIR}/usr/share/doc/${PKG_NAME}"
# 2. Dateien kopieren
# Dateien kopieren
cp "target/release/ink-notes" "${STAGING_DIR}/usr/bin/ink-notes"
# Desktop-Dateien (sowohl mit AppStream ID als auch Legacy Name)
cp "packaging/eu.pansi.ink-notes.desktop" "${STAGING_DIR}/usr/share/applications/eu.pansi.ink-notes.desktop"
cp "packaging/eu.pansi.ink-notes.desktop" "${STAGING_DIR}/usr/share/applications/ink-notes.desktop"
# Icons (eu.pansi.ink-notes & ink-notes)
cp "assets/ink-notes.svg" "${STAGING_DIR}/usr/share/icons/hicolor/scalable/apps/eu.pansi.ink-notes.svg"
cp "assets/ink-notes.svg" "${STAGING_DIR}/usr/share/icons/hicolor/scalable/apps/ink-notes.svg"
cp "assets/ink-notes.png" "${STAGING_DIR}/usr/share/icons/hicolor/256x256/apps/eu.pansi.ink-notes.png"
cp "assets/ink-notes.png" "${STAGING_DIR}/usr/share/icons/hicolor/256x256/apps/ink-notes.png"
# AppStream Metainfo
cp "packaging/eu.pansi.ink-notes.metainfo.xml" "${STAGING_DIR}/usr/share/metainfo/eu.pansi.ink-notes.metainfo.xml"
# Debian doc: copyright
cat <<'EOF' > "${STAGING_DIR}/usr/share/doc/ink-notes/copyright"
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: ink-notes
Upstream-Contact: Harald <harald@pansi.eu>
Source: https://gitea.pansi.eu/harald/ink-notes
Files: *
Copyright: 2026 Harald Pansi <harald@pansi.eu>
License: MIT
License: MIT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
EOF
# Debian doc: changelog.gz
CHANGELOG_TMP="$(mktemp)"
cat <<EOF > "${CHANGELOG_TMP}"
ink-notes (${VERSION}-1) stable; urgency=medium
* Release v${VERSION}.
* Low-latency vector inking with two-stage pipeline.
* Shape recognition and infinite canvas with zoom/pan.
* Multi-notebook management and PDF export.
-- Harald <harald@pansi.eu> $(date -R)
EOF
gzip -n -9 -c "${CHANGELOG_TMP}" > "${STAGING_DIR}/usr/share/doc/ink-notes/changelog.gz"
rm -f "${CHANGELOG_TMP}"
# Berechtigungen penibel setzen
find "${STAGING_DIR}" -type d -exec chmod 755 {} +
find "${STAGING_DIR}/usr/share" -type f -exec chmod 644 {} +
chmod 755 "${STAGING_DIR}/DEBIAN"
chmod 755 "${STAGING_DIR}/usr/bin/ink-notes"
cp "packaging/ink-notes.desktop" "${STAGING_DIR}/usr/share/applications/ink-notes.desktop"
chmod 644 "${STAGING_DIR}/usr/share/applications/ink-notes.desktop"
cp "assets/ink-notes.svg" "${STAGING_DIR}/usr/share/icons/hicolor/scalable/apps/ink-notes.svg"
chmod 644 "${STAGING_DIR}/usr/share/icons/hicolor/scalable/apps/ink-notes.svg"
cp "assets/ink-notes.png" "${STAGING_DIR}/usr/share/icons/hicolor/256x256/apps/ink-notes.png"
chmod 644 "${STAGING_DIR}/usr/share/icons/hicolor/256x256/apps/ink-notes.png"
cp "packaging/ink-notes.metainfo.xml" "${STAGING_DIR}/usr/share/metainfo/eu.pansi.ink-notes.metainfo.xml"
chmod 644 "${STAGING_DIR}/usr/share/metainfo/eu.pansi.ink-notes.metainfo.xml"
# 3. DEBIAN/control Datei generieren
# DEBIAN/control Datei generieren
cat <<EOF > "${STAGING_DIR}/DEBIAN/control"
Package: ${PKG_NAME}
Version: ${VERSION}
Architecture: ${ARCH}
Maintainer: Harald <harald@pansi.eu>
Installed-Size: $(du -ks "${STAGING_DIR}/usr" | cut -f1)
Depends: libc6, libx11-6, libxcursor1, libxrandr2, libxi6, libxkbcommon0
Depends: libc6 (>= 2.38), libgcc-s1, libvulkan1, mesa-vulkan-drivers | vulkan-icd, libwayland-client0, libx11-6, libxcursor1, libxrandr2, libxi6, libxkbcommon0, xdg-utils
Section: utils
Priority: optional
Homepage: https://farmersdog.pansi.eu/harald/ink-notes
Homepage: https://gitea.pansi.eu/harald/ink-notes
Description: Latenzoptimierte Vektor-Notizbuch- und Inking-App für Linux
ink-notes kombiniert minimale Stylus-Latenz mit GPU-Hardwarebeschleunigung (wgpu),
stufenlosem Viewport, Geometrie- und Formerkennung, Objekt-Transformationen,
Multi-Dokumenten-Verwaltung und Vektor-PDF-Export.
EOF
chmod 644 "${STAGING_DIR}/DEBIAN/control"
echo "=== [3/4] Schnüre .deb-Paket mit dpkg-deb ==="
echo "=== [4/5] Schnüre .deb-Paket mit dpkg-deb ==="
dpkg-deb --build --root-owner-group "${STAGING_DIR}" "${ROOT_DIR}/target/${DEB_NAME}"
echo "=== [4/4] Paket erfolgreich erstellt: target/${DEB_NAME} ==="
echo "=== [5/5] Paket erfolgreich erstellt: target/${DEB_NAME} ==="
ls -lh "${ROOT_DIR}/target/${DEB_NAME}"
dpkg-deb -I "${ROOT_DIR}/target/${DEB_NAME}"
if command -v lintian >/dev/null 2>&1; then
echo "=== Prüfe Paket mit lintian ==="
lintian "${ROOT_DIR}/target/${DEB_NAME}" || true
fi
echo "Fertig!"
+179 -74
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# ==============================================================================
# Veröffentlicht ein Release auf dem Gitea-Server (gitea.pansi.eu)
# Lädt .deb, .AppImage und SHA256SUMS.txt als Release-Assets hoch.
# Lädt .deb, .AppImage, SHA256SUMS.txt und SHA256SUMS.txt.minisig idempotent als Release-Assets hoch.
# ==============================================================================
set -euo pipefail
@@ -14,13 +14,15 @@ GITEA_SERVER="${GITEA_SERVER:-https://gitea.pansi.eu}"
REPO_OWNER="${REPO_OWNER:-harald}"
REPO_NAME="${REPO_NAME:-ink-notes}"
# Token aus Argument oder Umgebungsvariablen ermitteln
TOKEN="${1:-${INK_NOTES_GITEA_TOKEN:-${GITEA_TOKEN:-${GITHUB_TOKEN:-}}}}"
# Token ausschließlich aus Umgebungsvariablen ermitteln (Fallback für Legacy-Aufruf mit Argument)
TOKEN="${INK_NOTES_GITEA_TOKEN:-${GITEA_TOKEN:-${GITHUB_TOKEN:-}}}"
if [ -z "${TOKEN}" ] && [ -n "${1:-}" ]; then
TOKEN="$1"
fi
if [ -z "${TOKEN}" ]; then
echo "FEHLER: Kein Gitea-API-Token angegeben."
echo "Verwendung: $0 <GITEA_API_TOKEN>"
echo "Oder setze die Umgebungsvariable INK_NOTES_GITEA_TOKEN."
echo "Bitte setze die Umgebungsvariable GITEA_TOKEN oder INK_NOTES_GITEA_TOKEN."
exit 1
fi
@@ -31,6 +33,13 @@ RELEASE_TITLE="ink-notes ${TAG_NAME}"
DEB_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_amd64.deb"
APPIMAGE_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_x86_64.AppImage"
CHECKSUMS_FILE="${ROOT_DIR}/target/SHA256SUMS.txt"
SIGNATURE_FILE="${CHECKSUMS_FILE}.minisig"
TEMPLATE_FILE="${ROOT_DIR}/packaging/release-notes.md.tmpl"
if [ ! -f "${TEMPLATE_FILE}" ]; then
echo "FEHLER: Release-Template '${TEMPLATE_FILE}' nicht gefunden."
exit 1
fi
# Sicherstellen, dass die Artefakte existieren
if [ ! -f "${DEB_FILE}" ] || [ ! -f "${APPIMAGE_FILE}" ] || [ ! -f "${CHECKSUMS_FILE}" ]; then
@@ -38,101 +47,197 @@ if [ ! -f "${DEB_FILE}" ] || [ ! -f "${APPIMAGE_FILE}" ] || [ ! -f "${CHECKSUMS_
"${SCRIPT_DIR}/build-release.sh"
fi
# Ohne Signatur lehnen installierte Apps das Release als Update ab
if [ ! -f "${SIGNATURE_FILE}" ]; then
echo "FEHLER: '${SIGNATURE_FILE}' fehlt. Release mit gesetztem MINISIGN_SECRET_KEY bauen."
exit 1
fi
echo "======================================================================"
echo "Veröffentliche Release ${TAG_NAME} auf ${GITEA_SERVER}/${REPO_OWNER}/${REPO_NAME}"
echo "======================================================================"
RELEASE_BODY=$(cat <<EOF
## ink-notes ${TAG_NAME}
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
### Highlights:
- **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation)
- **Geometrie- und Formerkennung**: Geraden, Rechtecke, Kreise, Rauten, Polygone & Koordinatensysteme (Draw-and-Hold)
- **Viewport**: Stufenloser Zoom (25% - 500%) und Panning
- **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave
- **In-App Updater**: Automatische Versionsprüfung und Aktualisierung via Gitea Releases
- **Vektor-PDF-Export**: Verluste freie Ausgabe für Druck und Weitergabe
### Installation:
- **Zorin OS / Ubuntu / Debian (.deb)**:
\`\`\`bash
sudo dpkg -i ink-notes_${VERSION}_amd64.deb
\`\`\`
- **Portables AppImage (Alle Linux-Distributionen)**:
\`\`\`bash
chmod +x ink-notes_${VERSION}_x86_64.AppImage
./ink-notes_${VERSION}_x86_64.AppImage
\`\`\`
EOF
)
# 1. Release via Gitea REST API erstellen (oder ID abfragen, falls bereits existent)
CREATE_PAYLOAD=$(python3 -c "
import json, sys
data = {
'tag_name': '${TAG_NAME}',
'target_commitish': 'main',
'name': '${RELEASE_TITLE}',
'body': '''${RELEASE_BODY}''',
'draft': False,
'prerelease': False
# 1. Payload robust per Python aus Template erzeugen (verhindert Shell-Escaping-Probleme)
PAYLOAD_FILE="$(mktemp)"
CLEANUP_FILES=("${PAYLOAD_FILE}")
cleanup() {
for f in "${CLEANUP_FILES[@]}"; do
rm -f "${f}"
done
}
print(json.dumps(data))
")
trap cleanup EXIT
RELEASE_VERSION="${VERSION}" RELEASE_TAG="${TAG_NAME}" RELEASE_TITLE="${RELEASE_TITLE}" \
python3 -c '
import json, os, sys
template_path = sys.argv[1]
with open(template_path, "r", encoding="utf-8") as f:
template_content = f.read()
version = os.environ.get("RELEASE_VERSION", "")
tag = os.environ.get("RELEASE_TAG", "")
title = os.environ.get("RELEASE_TITLE", "")
body = template_content.replace("{{VERSION}}", version).replace("{{TAG_NAME}}", tag)
payload = {
"tag_name": tag,
"target_commitish": "main",
"name": title,
"body": body,
"draft": False,
"prerelease": False
}
print(json.dumps(payload))
' "${TEMPLATE_FILE}" > "${PAYLOAD_FILE}"
API_BASE="${GITEA_SERVER}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}"
echo "Erstelle Release über Gitea API..."
HTTP_RESPONSE=$(curl -s -w "\nHTTP_STATUS:%{http_code}" \
-X POST "${API_BASE}/releases" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "Authorization: token ${TOKEN}" \
-d "${CREATE_PAYLOAD}")
# 2. Prüfen, ob Release bereits existiert (Idempotenz)
echo "Prüfe bestehende Releases auf ${GITEA_SERVER}..."
TAG_RESP_FILE="$(mktemp)"
CLEANUP_FILES+=("${TAG_RESP_FILE}")
HTTP_STATUS=$(echo "${HTTP_RESPONSE}" | grep "HTTP_STATUS:" | cut -d':' -f2)
RESPONSE_BODY=$(echo "${HTTP_RESPONSE}" | sed '/HTTP_STATUS:/d')
HTTP_STATUS=$(curl -s -S -o "${TAG_RESP_FILE}" -w "%{http_code}" \
-X GET "${API_BASE}/releases/tags/${TAG_NAME}" \
-H "Accept: application/json" \
-H "Authorization: token ${TOKEN}" || echo "000")
RELEASE_ID=""
if [ "${HTTP_STATUS}" -eq 201 ]; then
RELEASE_ID=$(echo "${RESPONSE_BODY}" | python3 -c "import sys, json; print(json.load(sys.stdin)['id'])")
echo "Release erfolgreich angelegt (ID: ${RELEASE_ID})!"
else
echo "Release existiert womöglich bereits (Status ${HTTP_STATUS}). Suche bestehendes Release..."
EXISTING_RELEASE=$(curl -fsSL \
-H "Accept: application/json" \
-H "Authorization: token ${TOKEN}" \
"${API_BASE}/releases/tags/${TAG_NAME}" || true)
if [ -n "${EXISTING_RELEASE}" ]; then
RELEASE_ID=$(echo "${EXISTING_RELEASE}" | python3 -c "import sys, json; print(json.load(sys.stdin).get('id', ''))")
fi
fi
if [ -z "${RELEASE_ID}" ]; then
echo "FEHLER: Konnte Release nicht erstellen oder abfragen. Serverantwort:"
echo "${RESPONSE_BODY}"
if [ "${HTTP_STATUS}" = "200" ]; then
RELEASE_ID=$(python3 -c '
import sys, json
try:
data = json.load(open(sys.argv[1]))
print(data.get("id", ""))
except Exception:
pass
' "${TAG_RESP_FILE}")
echo "Release ${TAG_NAME} existiert bereits (ID: ${RELEASE_ID}). Aktualisiere Release-Notizen..."
PATCH_STATUS=$(curl -s -S -o /dev/null -w "%{http_code}" \
-X PATCH "${API_BASE}/releases/${RELEASE_ID}" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "Authorization: token ${TOKEN}" \
-d @"${PAYLOAD_FILE}" || echo "000")
if [ "${PATCH_STATUS}" != "200" ]; then
echo "Hinweis: Aktualisierung der Metadaten ergab HTTP ${PATCH_STATUS}."
fi
elif [ "${HTTP_STATUS}" = "404" ]; then
echo "Release existiert noch nicht. Erstelle neues Release ${TAG_NAME}..."
CREATE_RESP_FILE="$(mktemp)"
CLEANUP_FILES+=("${CREATE_RESP_FILE}")
CREATE_STATUS=$(curl -s -S -o "${CREATE_RESP_FILE}" -w "%{http_code}" \
-X POST "${API_BASE}/releases" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "Authorization: token ${TOKEN}" \
-d @"${PAYLOAD_FILE}" || echo "000")
if [ "${CREATE_STATUS}" = "201" ]; then
RELEASE_ID=$(python3 -c '
import sys, json
try:
data = json.load(open(sys.argv[1]))
print(data.get("id", ""))
except Exception:
pass
' "${CREATE_RESP_FILE}")
echo "Release erfolgreich angelegt (ID: ${RELEASE_ID})!"
else
echo "FEHLER: Release konnte nicht angelegt werden (HTTP ${CREATE_STATUS})."
cat "${CREATE_RESP_FILE}"
exit 1
fi
else
echo "FEHLER: Unerwartete Serverantwort beim Abfragen des Release-Tags (HTTP ${HTTP_STATUS})."
cat "${TAG_RESP_FILE}"
exit 1
fi
# 2. Assets hochladen
if [ -z "${RELEASE_ID}" ]; then
echo "FEHLER: Keine gültige Release-ID ermittelt."
exit 1
fi
# 3. Vorhandene Assets auflisten (um Dubletten vor Upload sauber zu löschen)
ASSETS_LIST_FILE="$(mktemp)"
CLEANUP_FILES+=("${ASSETS_LIST_FILE}")
LIST_STATUS=$(curl -s -S -o "${ASSETS_LIST_FILE}" -w "%{http_code}" \
-X GET "${API_BASE}/releases/${RELEASE_ID}/assets" \
-H "Accept: application/json" \
-H "Authorization: token ${TOKEN}" || echo "000")
if [ "${LIST_STATUS}" != "200" ]; then
echo "Hinweis: Konnte bestehende Assets nicht abrufen (HTTP ${LIST_STATUS})."
fi
upload_asset() {
local file_path="$1"
local filename="$(basename "${file_path}")"
local filename
filename="$(basename "${file_path}")"
# Asset-Namen in URL URL-kodieren
local encoded_name
encoded_name=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1]))' "${filename}")
# Prüfen, ob bereits Assets mit demselben Namen existieren (können mehrere sein)
local existing_ids=()
if [ -f "${ASSETS_LIST_FILE}" ] && [ -s "${ASSETS_LIST_FILE}" ]; then
mapfile -t existing_ids < <(python3 -c '
import sys, json
try:
assets = json.load(open(sys.argv[1]))
target = sys.argv[2]
for a in assets:
if a.get("name") == target and a.get("id"):
print(a.get("id"))
except Exception:
pass
' "${ASSETS_LIST_FILE}" "${filename}")
fi
for eid in "${existing_ids[@]}"; do
if [ -n "${eid}" ]; then
echo "Lösche bestehendes Asset '${filename}' (ID: ${eid}) vor Re-Upload..."
local del_status
del_status=$(curl -s -S -o /dev/null -w "%{http_code}" \
-X DELETE "${API_BASE}/releases/${RELEASE_ID}/assets/${eid}" \
-H "Authorization: token ${TOKEN}" || echo "000")
if [ "${del_status}" != "204" ] && [ "${del_status}" != "200" ]; then
echo "Warnung: Löschen von bestehendem Asset '${filename}' ergab HTTP ${del_status}."
fi
fi
done
echo "Lade Asset hoch: ${filename}..."
curl -fsSL \
-X POST "${API_BASE}/releases/${RELEASE_ID}/assets?name=${filename}" \
local upload_res_file="$(mktemp)"
CLEANUP_FILES+=("${upload_res_file}")
local upload_status
upload_status=$(curl -s -S -o "${upload_res_file}" -w "%{http_code}" \
-X POST "${API_BASE}/releases/${RELEASE_ID}/assets?name=${encoded_name}" \
-H "Accept: application/json" \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${file_path}" > /dev/null
echo "✓ ${filename} erfolgreich hochgeladen."
--data-binary "@${file_path}" || echo "000")
if [ "${upload_status}" = "201" ]; then
echo "✓ ${filename} erfolgreich hochgeladen."
else
echo "FEHLER: Asset-Upload für '${filename}' fehlgeschlagen (HTTP ${upload_status})."
cat "${upload_res_file}"
exit 1
fi
}
upload_asset "${DEB_FILE}"
upload_asset "${APPIMAGE_FILE}"
upload_asset "${CHECKSUMS_FILE}"
upload_asset "${SIGNATURE_FILE}"
echo "======================================================================"
echo "Release ${TAG_NAME} erfolgreich veröffentlicht!"
+897 -83
View File
File diff suppressed because it is too large. Load diff
+13 -9
View File
@@ -39,13 +39,7 @@ pub fn generate_paper_quad() -> Vec<InkVertex> {
/// Fügt ein Quad (2 Dreiecke) zur Vertexliste hinzu.
#[inline]
fn push_quad(
vertices: &mut Vec<InkVertex>,
x_min: f32,
y_min: f32,
x_max: f32,
y_max: f32,
) {
fn push_quad(vertices: &mut Vec<InkVertex>, x_min: f32, y_min: f32, x_max: f32, y_max: f32) {
vertices.push(InkVertex::new(x_min, y_min, BACKGROUND_PATTERN_COLOR));
vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR));
vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR));
@@ -102,7 +96,13 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
while y < PAGE_HEIGHT {
let mut x = GRID_5MM_SPACING;
while x < PAGE_WIDTH {
push_quad(&mut vertices, x - half_d, y - half_d, x + half_d, y + half_d);
push_quad(
&mut vertices,
x - half_d,
y - half_d,
x + half_d,
y + half_d,
);
x += GRID_5MM_SPACING;
}
y += GRID_5MM_SPACING;
@@ -132,7 +132,11 @@ mod tests {
let grid = generate_background_mesh(PageBackground::Grid5mm);
assert!(!grid.is_empty());
assert_eq!(grid.len() % 6, 0, "Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)");
assert_eq!(
grid.len() % 6,
0,
"Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)"
);
let lined = generate_background_mesh(PageBackground::Lined);
assert!(!lined.is_empty());
+221
View File
@@ -0,0 +1,221 @@
//! Crash-Simulationstesthelfer für ink-notes.
//! Wird von scripts/crash-test.sh verwendet, um unvollständige Schreibvorgänge
//! und SIGKILL (kill -9) Robustheit zu verifizieren.
use std::env;
use std::path::Path;
use std::process;
use std::time::Duration;
use glam::Vec2;
use ink_notes::document::{Notebook, PageBackground, StylusSample};
use ink_notes::spline::{finalize_stroke_with_config, SplineConfig};
use ink_notes::storage::{
cleanup_orphaned_temp_files, load_notebook, load_page, save_manifest, save_notebook, save_page,
NotebookManifest, PageData,
};
fn run_init(dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
let nb = Notebook::new();
let id = dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("crash_test");
let mut m = NotebookManifest::new(id, "Crash Test Notebook");
m.sync_from_notebook(&nb);
save_notebook(dir, &nb, &mut m)?;
println!("[init] Initialized clean notebook in {:?}", dir);
Ok(())
}
fn run_worker(dir: &Path) {
let (mut notebook, mut manifest) = match load_notebook(dir) {
Ok((nb, m)) => (nb, m),
Err(_) => {
let nb = Notebook::new();
let id = dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("crash_test");
let mut m = NotebookManifest::new(id, "Crash Test Notebook");
m.sync_from_notebook(&nb);
let _ = save_notebook(dir, &nb, &mut m);
(nb, m)
}
};
let spline_cfg = SplineConfig::default();
let mut stroke_counter = manifest.next_stroke_id;
println!("[worker] Started writing loop in {:?}", dir);
loop {
// Erzeuge neuen Strich mit einigen Samples
let base_x = (stroke_counter % 500) as f32;
let samples = vec![
StylusSample {
position: Vec2::new(base_x, 100.0),
pressure: 0.5,
timestamp: 0.0,
},
StylusSample {
position: Vec2::new(base_x + 10.0, 150.0),
pressure: 0.7,
timestamp: 0.01,
},
StylusSample {
position: Vec2::new(base_x + 20.0, 200.0),
pressure: 0.6,
timestamp: 0.02,
},
];
let stroke = finalize_stroke_with_config(
stroke_counter,
&samples,
[0.0, 0.0, 0.0, 1.0],
&spline_cfg,
);
stroke_counter += 1;
manifest.next_stroke_id = stroke_counter;
let page = notebook.current_page_mut();
page.add_stroke(stroke);
let page_data = PageData::from(&*page);
// 1. Schreibe Seite atomar
if let Err(e) = save_page(dir, &page_data) {
eprintln!("[worker] Error saving page: {:?}", e);
}
// 2. Aktualisiere Manifest atomar
manifest.sync_from_notebook(&notebook);
if let Err(e) = save_manifest(dir, &manifest) {
eprintln!("[worker] Error saving manifest: {:?}", e);
}
// 3. Gelegentlich Gesamtsave (wie Autosave-Flush)
if stroke_counter % 5 == 0 {
if let Err(e) = save_notebook(dir, &notebook, &mut manifest) {
eprintln!("[worker] Error saving notebook: {:?}", e);
}
}
// 4. Evtl. neue Seite anlegen und sofort sichern, bevor Manifest aktualisiert wird
if stroke_counter % 25 == 0 {
let page_idx = notebook.add_page(PageBackground::DotGrid);
notebook.current_page_index = page_idx;
let new_page_data = PageData::from(notebook.current_page());
if let Err(e) = save_page(dir, &new_page_data) {
eprintln!("[worker] Error saving new page: {:?}", e);
}
manifest.sync_from_notebook(&notebook);
if let Err(e) = save_manifest(dir, &manifest) {
eprintln!("[worker] Error saving manifest: {:?}", e);
}
}
}
}
fn run_verify(dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
println!("[verify] Checking notebook in {:?}", dir);
// 1. Bereinige eventuelle verwaiste .tmp-Dateien
let _ = cleanup_orphaned_temp_files(dir, Duration::from_secs(0));
// Prüfe, ob das Verzeichnis noch gar kein Notizbuch enthält (Prozess vor erstem Flush getötet)
let manifest_path = dir.join("manifest.json");
let manifest_bak = dir.join("manifest.json.bak");
if !manifest_path.exists() && !manifest_bak.exists() {
println!("[verify] Notebook not yet created on disk (killed prior to first save). Valid empty state.");
return Ok(());
}
// 2. Notizbuch laden
let (_notebook, manifest) = match load_notebook(dir) {
Ok(res) => res,
Err(e) => {
eprintln!("[verify] FAILED to load notebook: {:?}", e);
return Err(Box::new(e));
}
};
println!(
"[verify] Manifest loaded successfully: title='{}', pages={}, next_stroke_id={}",
manifest.title,
manifest.pages.len(),
manifest.next_stroke_id
);
// 3. Alle im Manifest registrierten Seiten laden und prüfen
for page_meta in &manifest.pages {
match load_page(dir, page_meta.id) {
Ok(page_data) => {
let page = page_data.to_page();
println!(
"[verify] Page {} verified: {} strokes, background={:?}",
page_meta.id,
page.strokes.len(),
page.background
);
}
Err(e) => {
eprintln!(
"[verify] FAILED to load page {} referenced by manifest: {:?}",
page_meta.id, e
);
return Err(Box::new(e));
}
}
}
// 4. Prüfen ob Quarantäne-Dateien existieren
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
if name.contains(".corrupt-") {
println!("[verify] Notice: Found quarantined file: {}", name);
}
}
}
println!("[verify] SUCCESS: All pages and manifest are fully consistent!");
Ok(())
}
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 3 {
eprintln!("Usage: crash_sim <init|worker|verify> <notebook_dir>");
process::exit(2);
}
let command = &args[1];
let notebook_dir = Path::new(&args[2]);
match command.as_str() {
"init" => match run_init(notebook_dir) {
Ok(_) => process::exit(0),
Err(e) => {
eprintln!("[init] Error: {}", e);
process::exit(1);
}
},
"worker" => {
run_worker(notebook_dir);
}
"verify" => match run_verify(notebook_dir) {
Ok(_) => process::exit(0),
Err(e) => {
eprintln!("[verify] Error: {}", e);
process::exit(1);
}
},
other => {
eprintln!("Unknown command: {}", other);
process::exit(2);
}
}
}
+51 -27
View File
@@ -11,9 +11,7 @@ use std::sync::Arc;
use winit::dpi::PhysicalSize;
use winit::window::Window;
use crate::background::{
generate_background_mesh, generate_paper_quad, DESK_COLOR,
};
use crate::background::{generate_background_mesh, generate_paper_quad, DESK_COLOR};
use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample};
use crate::filter::OneEuroFilter;
use crate::spline::{self, ControlPoint, SplineConfig};
@@ -106,6 +104,8 @@ pub struct CanvasState {
pub camera_buffer: wgpu::Buffer,
pub camera_bind_group: wgpu::BindGroup,
pub current_ink_color: [f32; 4],
pub current_stroke_width_mm: f32,
pub current_tool: crate::document::StrokeTool,
// Statische Hintergrund-Geometrie
pub paper_buffer: wgpu::Buffer,
@@ -143,7 +143,8 @@ impl CanvasState {
/// Initialisiert den CanvasState für das übergebene Winit-Fenster.
pub async fn new(window: Arc<Window>) -> Result<Self, Box<dyn std::error::Error>> {
let size = window.inner_size();
let viewport = PageViewport::calculate(size.width as f32, size.height as f32, Self::VIEWPORT_MARGIN);
let viewport =
PageViewport::calculate(size.width as f32, size.height as f32, Self::VIEWPORT_MARGIN);
// 1. WGPU Instance & Surface
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
@@ -187,7 +188,10 @@ impl CanvasState {
let present_mode = if std::env::var("INK_FORCE_FIFO").is_ok() {
eprintln!("Erzwinge PresentMode::Fifo (via INK_FORCE_FIFO)");
wgpu::PresentMode::Fifo
} else if surface_caps.present_modes.contains(&wgpu::PresentMode::Mailbox) {
} else if surface_caps
.present_modes
.contains(&wgpu::PresentMode::Mailbox)
{
eprintln!("Verwende Zero-Latency PresentMode::Mailbox");
wgpu::PresentMode::Mailbox
} else {
@@ -358,12 +362,7 @@ impl CanvasState {
});
// 8. egui Renderer für das schwebende UI initialisieren
let egui_renderer = egui_wgpu::Renderer::new(
&device,
config.format,
None,
1,
);
let egui_renderer = egui_wgpu::Renderer::new(&device, config.format, None, 1);
Ok(Self {
surface,
@@ -377,6 +376,8 @@ impl CanvasState {
camera_buffer,
camera_bind_group,
current_ink_color: default_ink_color,
current_stroke_width_mm: 0.5,
current_tool: crate::document::StrokeTool::Pen,
paper_buffer,
background_buffer,
background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY,
@@ -410,7 +411,11 @@ impl CanvasState {
transform_scale: scale,
transform_offset: offset,
};
self.queue.write_buffer(&self.camera_buffer, 0, bytemuck::cast_slice(&[camera_uniforms]));
self.queue.write_buffer(
&self.camera_buffer,
0,
bytemuck::cast_slice(&[camera_uniforms]),
);
}
/// Passt die Surface und Projektionsmatrizen an eine neue Fenstergröße an.
@@ -433,13 +438,15 @@ impl CanvasState {
/// Verschiebt den Bildausschnitt um Bildschirm-Pixel delta.
pub fn pan(&mut self, delta_x: f32, delta_y: f32) {
self.camera.pan_by_screen_delta(delta_x, delta_y, &self.viewport);
self.camera
.pan_by_screen_delta(delta_x, delta_y, &self.viewport);
self.update_uniforms();
}
/// Zoomt zentriert auf eine Cursor-Bildschirmposition.
pub fn zoom_at(&mut self, cursor_x: f32, cursor_y: f32, factor: f32) {
self.camera.zoom_at_cursor([cursor_x, cursor_y], factor, &self.viewport);
self.camera
.zoom_at_cursor([cursor_x, cursor_y], factor, &self.viewport);
self.update_uniforms();
}
@@ -451,12 +458,14 @@ impl CanvasState {
/// Transformiert Fenster-Pixel in Seitenkoordinaten.
pub fn screen_to_page(&self, screen_x: f32, screen_y: f32) -> Option<glam::Vec2> {
self.camera.screen_to_page(screen_x, screen_y, &self.viewport)
self.camera
.screen_to_page(screen_x, screen_y, &self.viewport)
}
/// Transformiert Fenster-Pixel in Seitenkoordinaten und klemmt auf die Seite.
pub fn screen_to_page_clamped(&self, screen_x: f32, screen_y: f32) -> glam::Vec2 {
self.camera.screen_to_page_clamped(screen_x, screen_y, &self.viewport)
self.camera
.screen_to_page_clamped(screen_x, screen_y, &self.viewport)
}
/// Transformiert einen Punkt aus DIN-A4-Seitenkoordinaten in Fenster-Bildschirmkoordinaten.
@@ -471,7 +480,8 @@ impl CanvasState {
self.live_vertex_count = self.live_vertices.len() as u32;
if self.live_vertices.len() > self.live_vertex_capacity {
self.live_vertex_capacity = (self.live_vertices.len() * 2).max(Self::INITIAL_LIVE_CAPACITY);
self.live_vertex_capacity =
(self.live_vertices.len() * 2).max(Self::INITIAL_LIVE_CAPACITY);
self.live_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Live Stroke Vertex Buffer (Reallocated)"),
size: (self.live_vertex_capacity * std::mem::size_of::<InkVertex>()) as u64,
@@ -496,10 +506,14 @@ impl CanvasState {
/// Konfiguriert die Strichbreite in Millimetern.
pub fn set_stroke_width_mm(&mut self, width_mm: f32) {
// 10 Einheiten = 1 mm. Radius = (width_mm * 10.0) / 2.0 = width_mm * 5.0.
let base_radius = (width_mm * 5.0).max(0.25);
self.spline_config.min_radius = (base_radius * 0.5).max(0.15);
self.spline_config.max_radius = (base_radius * 1.5).max(0.35);
self.current_stroke_width_mm = width_mm;
self.spline_config =
SplineConfig::from_width_units(width_mm * crate::document::UNITS_PER_MM);
}
/// Setzt das aktive Zeichenwerkzeug für den Stift.
pub fn set_tool(&mut self, tool: crate::document::StrokeTool) {
self.current_tool = tool;
}
/// Setzt den Seitenhintergrund und aktualisiert bei Bedarf den Vertex-Puffer.
@@ -522,7 +536,8 @@ impl CanvasState {
}
if !mesh.is_empty() {
self.queue.write_buffer(&self.background_buffer, 0, bytemuck::cast_slice(&mesh));
self.queue
.write_buffer(&self.background_buffer, 0, bytemuck::cast_slice(&mesh));
}
}
@@ -539,7 +554,8 @@ impl CanvasState {
}
if page.vertex_cache.len() > self.committed_vertex_capacity {
self.committed_vertex_capacity = (page.vertex_cache.len() * 2).max(Self::INITIAL_COMMITTED_CAPACITY);
self.committed_vertex_capacity =
(page.vertex_cache.len() * 2).max(Self::INITIAL_COMMITTED_CAPACITY);
self.committed_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Committed Strokes Vertex Buffer (Reallocated)"),
size: (self.committed_vertex_capacity * std::mem::size_of::<InkVertex>()) as u64,
@@ -635,6 +651,10 @@ impl CanvasState {
self.current_ink_color,
&self.spline_config,
);
let mut stroke = stroke;
stroke.tool = self.current_tool;
stroke.width = self.current_stroke_width_mm * crate::document::UNITS_PER_MM;
stroke.finalized = true;
// Temporären GPU-Live-Buffer vollständig leeren
self.live_samples.clear();
@@ -655,7 +675,8 @@ impl CanvasState {
}
let color = self.current_ink_color;
let raw_strip = spline::generate_triangle_strip(&self.live_control_points, &self.spline_config);
let raw_strip =
spline::generate_triangle_strip(&self.live_control_points, &self.spline_config);
self.live_vertices = raw_strip
.into_iter()
.map(|v| InkVertex::new(v.position[0], v.position[1], color))
@@ -667,7 +688,8 @@ impl CanvasState {
}
if self.live_vertices.len() > self.live_vertex_capacity {
self.live_vertex_capacity = (self.live_vertices.len() * 2).max(Self::INITIAL_LIVE_CAPACITY);
self.live_vertex_capacity =
(self.live_vertices.len() * 2).max(Self::INITIAL_LIVE_CAPACITY);
self.live_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Live Stroke Vertex Buffer (Reallocated)"),
size: (self.live_vertex_capacity * std::mem::size_of::<InkVertex>()) as u64,
@@ -694,7 +716,8 @@ impl CanvasState {
) -> Result<(), wgpu::SurfaceError> {
// Vor dem Renderpass: egui-Texturen aktualisieren
for (id, image_delta) in &textures_delta.set {
self.egui_renderer.update_texture(&self.device, &self.queue, *id, image_delta);
self.egui_renderer
.update_texture(&self.device, &self.queue, *id, image_delta);
}
let output = self.surface.get_current_texture()?;
@@ -784,7 +807,8 @@ impl CanvasState {
occlusion_query_set: None,
});
self.egui_renderer.render(&mut egui_pass, clipped_primitives, screen_descriptor);
self.egui_renderer
.render(&mut egui_pass, clipped_primitives, screen_descriptor);
}
self.queue.submit(std::iter::once(encoder.finish()));
+137 -21
View File
@@ -103,6 +103,16 @@ pub fn point_to_segment_distance(p: glam::Vec2, a: glam::Vec2, b: glam::Vec2) ->
(p - projection).length()
}
/// Das Werkzeug, mit dem ein Strich gezeichnet wurde.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StrokeTool {
#[default]
Pen,
Highlighter,
Shape,
}
/// Ein einzelner Vektor-Strich auf einer Seite.
#[derive(Debug, Clone, PartialEq)]
pub struct Stroke {
@@ -112,15 +122,26 @@ pub struct Stroke {
pub aabb: Aabb,
pub color: [f32; 4],
pub shape: Option<crate::geometry::RecognizedShape>,
pub tool: StrokeTool,
pub width: f32,
pub finalized: bool,
}
impl Stroke {
pub fn new(id: u64, points: Vec<StylusSample>, vertices: Vec<InkVertex>, color: [f32; 4]) -> Self {
pub fn new(
id: u64,
points: Vec<StylusSample>,
vertices: Vec<InkVertex>,
color: [f32; 4],
) -> Self {
let aabb = if !points.is_empty() {
let pts: Vec<glam::Vec2> = points.iter().map(|s| s.position).collect();
Aabb::from_points(&pts)
} else if !vertices.is_empty() {
let pts: Vec<glam::Vec2> = vertices.iter().map(|v| glam::Vec2::new(v.position[0], v.position[1])).collect();
let pts: Vec<glam::Vec2> = vertices
.iter()
.map(|v| glam::Vec2::new(v.position[0], v.position[1]))
.collect();
Aabb::from_points(&pts)
} else {
Aabb::new(glam::Vec2::ZERO, glam::Vec2::ZERO)
@@ -133,6 +154,9 @@ impl Stroke {
aabb,
color,
shape: None,
tool: StrokeTool::Pen,
width: 5.0,
finalized: true,
}
}
@@ -149,6 +173,22 @@ impl Stroke {
/// Verknüpft diesen Strich mit einer mathematisch erkannten Form.
pub fn with_shape(mut self, shape: crate::geometry::RecognizedShape) -> Self {
self.shape = Some(shape);
self.tool = StrokeTool::Shape;
self
}
pub fn with_tool(mut self, tool: StrokeTool) -> Self {
self.tool = tool;
self
}
pub fn with_width(mut self, width: f32) -> Self {
self.width = width;
self
}
pub fn with_finalized(mut self, finalized: bool) -> Self {
self.finalized = finalized;
self
}
@@ -171,7 +211,12 @@ impl Stroke {
}
/// Prüft, ob dieser Strich von einem Radierer an Position `eraser_pos` mit Radius `eraser_radius` getroffen wird.
pub fn intersects_eraser(&self, eraser_pos: glam::Vec2, eraser_radius: f32, max_stroke_radius: f32) -> bool {
pub fn intersects_eraser(
&self,
eraser_pos: glam::Vec2,
eraser_radius: f32,
max_stroke_radius: f32,
) -> bool {
// 1. Vorabtest: Schneidet der Radierer die vergrößerte AABB?
let total_margin = eraser_radius + max_stroke_radius;
if !self.aabb.expanded(total_margin).contains_point(eraser_pos) {
@@ -254,9 +299,7 @@ impl Stroke {
/// Skaliert den Strich bezüglich `origin` um die Faktoren `factors`.
pub fn scale(&mut self, origin: glam::Vec2, factors: glam::Vec2) {
let sc = |pos: glam::Vec2| -> glam::Vec2 {
origin + (pos - origin) * factors
};
let sc = |pos: glam::Vec2| -> glam::Vec2 { origin + (pos - origin) * factors };
for pt in &mut self.points {
pt.position = sc(pt.position);
@@ -293,6 +336,22 @@ pub enum PageBackground {
DotGrid,
}
/// Persistenzstatus einer Seite zum Schutz vor unbeabsichtigtem Überschreiben bei Ladefehlern.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PersistState {
#[default]
Normal,
ReadOnlyAfterLoadError,
}
/// Warnung beim Laden einer Seite (beschädigte Datei oder I/O-Fehler).
#[derive(Debug, Clone, PartialEq)]
pub struct PageLoadWarning {
pub page_id: u64,
pub message: String,
pub quarantine_path: Option<std::path::PathBuf>,
}
/// Eine einzelne Notizbuchseite mit Strichen, Hintergrund und gecachten Meshes.
#[derive(Debug, Clone)]
pub struct Page {
@@ -303,6 +362,7 @@ pub struct Page {
pub stroke_ranges: Vec<Range<u32>>,
pub is_dirty: bool,
pub is_loaded: bool,
pub persist: PersistState,
pub history: HistoryStack,
}
@@ -316,6 +376,7 @@ impl Page {
stroke_ranges: Vec::new(),
is_dirty: false,
is_loaded: true,
persist: PersistState::Normal,
history: HistoryStack::new(),
}
}
@@ -330,10 +391,19 @@ impl Page {
stroke_ranges: Vec::new(),
is_dirty: false,
is_loaded: false,
persist: PersistState::Normal,
history: HistoryStack::new(),
}
}
pub fn is_readonly(&self) -> bool {
self.persist == PersistState::ReadOnlyAfterLoadError
}
pub fn unlock_after_load_error(&mut self) {
self.persist = PersistState::Normal;
}
/// Fügt einen neuen Strich hinzu und registriert ihn im History-Stack.
pub fn add_stroke(&mut self, stroke: Stroke) {
self.history.push(HistoryAction::AddStroke(stroke.clone()));
@@ -402,7 +472,12 @@ impl Page {
/// Radiert alle Striche an der Position `eraser_pos` mit Radius `eraser_radius`.
///
/// Gibt die gelöschten Striche zurück (falls welche getroffen wurden).
pub fn erase_at(&mut self, eraser_pos: glam::Vec2, eraser_radius: f32, max_stroke_radius: f32) -> Vec<Stroke> {
pub fn erase_at(
&mut self,
eraser_pos: glam::Vec2,
eraser_radius: f32,
max_stroke_radius: f32,
) -> Vec<Stroke> {
let mut hit_indices = Vec::new();
for (i, stroke) in self.strokes.iter().enumerate() {
@@ -422,7 +497,8 @@ impl Page {
}
removed.reverse();
self.history.push(HistoryAction::RemoveStrokes(removed.clone()));
self.history
.push(HistoryAction::RemoveStrokes(removed.clone()));
self.is_dirty = true;
removed
@@ -473,7 +549,11 @@ impl Page {
/// Liefert Klone der Striche mit den angegebenen IDs.
pub fn get_strokes(&self, ids: &[u64]) -> Vec<Stroke> {
self.strokes.iter().filter(|s| ids.contains(&s.id)).cloned().collect()
self.strokes
.iter()
.filter(|s| ids.contains(&s.id))
.cloned()
.collect()
}
/// Aktualisiert eine Menge geänderter Striche transaktional im History-Stack.
@@ -482,7 +562,12 @@ impl Page {
return;
}
let ids: Vec<u64> = modified.iter().map(|s| s.id).collect();
let before: Vec<Stroke> = self.strokes.iter().filter(|s| ids.contains(&s.id)).cloned().collect();
let before: Vec<Stroke> = self
.strokes
.iter()
.filter(|s| ids.contains(&s.id))
.cloned()
.collect();
for m in modified.iter() {
if let Some(pos) = self.strokes.iter().position(|s| s.id == m.id) {
@@ -517,7 +602,8 @@ impl Page {
self.strokes[pos] = m.clone();
}
}
self.history.push(HistoryAction::ModifyStrokes { before, after });
self.history
.push(HistoryAction::ModifyStrokes { before, after });
self.is_dirty = true;
}
@@ -534,7 +620,8 @@ impl Page {
});
if !removed.is_empty() {
self.history.push(HistoryAction::RemoveStrokes(removed.clone()));
self.history
.push(HistoryAction::RemoveStrokes(removed.clone()));
self.is_dirty = true;
}
removed
@@ -545,7 +632,8 @@ impl Page {
if new_strokes.is_empty() {
return;
}
self.history.push(HistoryAction::AddStrokes(new_strokes.clone()));
self.history
.push(HistoryAction::AddStrokes(new_strokes.clone()));
self.strokes.extend(new_strokes);
self.is_dirty = true;
}
@@ -558,6 +646,7 @@ pub struct Notebook {
pub current_page_index: usize,
pub next_stroke_id: u64,
pub next_page_id: u64,
pub load_warnings: std::collections::HashMap<u64, PageLoadWarning>,
}
impl Notebook {
@@ -567,6 +656,7 @@ impl Notebook {
current_page_index: 0,
next_stroke_id: 1,
next_page_id: 1,
load_warnings: std::collections::HashMap::new(),
};
// Erste Standardseite anlegen (5mm Grid)
nb.add_page(PageBackground::Grid5mm);
@@ -585,6 +675,7 @@ impl Notebook {
current_page_index,
next_stroke_id,
next_page_id,
load_warnings: std::collections::HashMap::new(),
};
if nb.pages.is_empty() {
nb.add_page(PageBackground::Grid5mm);
@@ -653,7 +744,11 @@ impl Notebook {
/// Text für das On-Screen-Display (OSD), z. B. "Seite 3 / 12".
pub fn osd_text(&self) -> String {
format!("Seite {} / {}", self.current_page_index + 1, self.pages.len())
format!(
"Seite {} / {}",
self.current_page_index + 1,
self.pages.len()
)
}
}
@@ -787,13 +882,25 @@ mod tests {
let stroke_radius = 2.0;
// Treffer: Radierer bei (150, 105) (Abstand 5 <= 12)
assert!(stroke.intersects_eraser(glam::Vec2::new(150.0, 105.0), eraser_radius, stroke_radius));
assert!(stroke.intersects_eraser(
glam::Vec2::new(150.0, 105.0),
eraser_radius,
stroke_radius
));
// Kein Treffer: Radierer bei (150, 150) (Abstand 50 > 12)
assert!(!stroke.intersects_eraser(glam::Vec2::new(150.0, 150.0), eraser_radius, stroke_radius));
assert!(!stroke.intersects_eraser(
glam::Vec2::new(150.0, 150.0),
eraser_radius,
stroke_radius
));
// Kein Treffer: Weit außerhalb der AABB
assert!(!stroke.intersects_eraser(glam::Vec2::new(500.0, 500.0), eraser_radius, stroke_radius));
assert!(!stroke.intersects_eraser(
glam::Vec2::new(500.0, 500.0),
eraser_radius,
stroke_radius
));
}
#[test]
@@ -966,9 +1073,12 @@ mod tests {
let mut moved = s.clone();
moved.translate(glam::Vec2::new(20.0, 30.0));
page.update_strokes_ephemeral(&[moved.clone()]);
assert_eq!(page.strokes[0].points[0].position, glam::Vec2::new(30.0, 40.0));
assert_eq!(
page.strokes[0].points[0].position,
glam::Vec2::new(30.0, 40.0)
);
// History should not have been updated yet
assert_eq!(page.history.can_redo(), false);
assert!(!page.history.can_redo());
// Transactional commit (mouse release)
let before = vec![s.clone()];
@@ -977,10 +1087,16 @@ mod tests {
// Verify Undo restores original position
assert!(page.undo());
assert_eq!(page.strokes[0].points[0].position, glam::Vec2::new(10.0, 10.0));
assert_eq!(
page.strokes[0].points[0].position,
glam::Vec2::new(10.0, 10.0)
);
// Verify Redo applies modified position
assert!(page.redo());
assert_eq!(page.strokes[0].points[0].position, glam::Vec2::new(30.0, 40.0));
assert_eq!(
page.strokes[0].points[0].position,
glam::Vec2::new(30.0, 40.0)
);
}
}
+137 -26
View File
@@ -12,7 +12,7 @@
use std::error::Error;
use std::fs::File;
use std::io::BufWriter;
use std::path::Path;
use std::path::{Path, PathBuf};
use printpdf::path::{PaintMode, WindingOrder};
use printpdf::{Color, Line, Mm, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb};
@@ -22,6 +22,70 @@ use crate::document::{
Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH,
};
/// Bereinigt einen Titel für die Verwendung in Dateinamen (entfernt illegale Zeichen).
pub fn sanitize_filename_component(title: &str) -> String {
let sanitized: String = title
.chars()
.map(|c| match c {
'/' | '\\' | '\0' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
c => c,
})
.collect();
let trimmed = sanitized.trim();
if trimmed.is_empty() {
"Notizbuch".to_string()
} else {
trimmed.to_string()
}
}
/// Ermittelt das Standard-Dokumentenverzeichnis ($XDG_DOCUMENTS_DIR mit Fallback ~/Documents).
pub fn default_documents_dir() -> PathBuf {
if let Ok(dir) = std::env::var("XDG_DOCUMENTS_DIR") {
if !dir.trim().is_empty() {
return PathBuf::from(dir);
}
}
if let Ok(home) = std::env::var("HOME") {
if !home.trim().is_empty() {
return PathBuf::from(home).join("Documents");
}
}
PathBuf::from(".")
}
/// Ermittelt den kollisionsfreien Export-Pfad für ein Notizbuch im übergebenen Verzeichnis.
/// Existiert die Datei bereits, wird ein Zähler angehängt (`_1`, `_2`, etc.).
pub fn resolve_pdf_export_path_in_dir(dir: &Path, title: &str) -> PathBuf {
let sanitized_title = sanitize_filename_component(title);
let base_name = format!("{}.pdf", sanitized_title);
let primary_path = dir.join(&base_name);
if !primary_path.exists() {
return primary_path;
}
let mut counter = 1;
loop {
let candidate_name = format!("{}_{}.pdf", sanitized_title, counter);
let candidate_path = dir.join(candidate_name);
if !candidate_path.exists() {
return candidate_path;
}
counter += 1;
}
}
/// Ermittelt das Ziel für den PDF-Export unter Berücksichtigung von XDG_DOCUMENTS_DIR
/// und automatischer Namenskollisionsvermeidung. Erstellt das Verzeichnis, falls nötig.
pub fn resolve_pdf_export_path(title: &str) -> PathBuf {
let docs_dir = default_documents_dir();
let _ = std::fs::create_dir_all(&docs_dir);
resolve_pdf_export_path_in_dir(&docs_dir, title)
}
/// Wählbare Exportstrategie für Vektorstriche.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ExportStrategy {
@@ -75,15 +139,21 @@ pub fn generate_pdf_document(
if notebook.pages.is_empty() {
return Err("Notizbuch enthält keine Seiten".into());
}
// Nicht geladene Seiten würden sonst stillschweigend als leere Seiten exportiert
if let Some(page) = notebook.pages.iter().find(|p| {
!p.is_loaded || p.persist == crate::document::PersistState::ReadOnlyAfterLoadError
}) {
return Err(format!(
"Seite {} ist nicht geladen – Export abgebrochen (vorher storage::load_all_pages aufrufen)",
page.id
)
.into());
}
// 1. Erstes Dokument mit Seite 1 erstellen
let title = "ink-notes Notebook";
let (doc, page1_idx, layer1_bg) = PdfDocument::new(
title,
Mm(A4_WIDTH_MM),
Mm(A4_HEIGHT_MM),
"Background",
);
let (doc, page1_idx, layer1_bg) =
PdfDocument::new(title, Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background");
// Seite 1 befüllen
let page1_ref = doc.get_page(page1_idx);
@@ -95,11 +165,7 @@ pub fn generate_pdf_document(
// 2. Alle nachfolgenden Seiten hinzufügen
for page in &notebook.pages[1..] {
let (page_idx, layer_bg) = doc.add_page(
Mm(A4_WIDTH_MM),
Mm(A4_HEIGHT_MM),
"Background",
);
let (page_idx, layer_bg) = doc.add_page(Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background");
let page_ref = doc.get_page(page_idx);
let layer_bg_ref = page_ref.get_layer(layer_bg);
render_background_layer(&layer_bg_ref, page.background);
@@ -112,10 +178,7 @@ pub fn generate_pdf_document(
}
/// Rendert das Vektorraster auf Layer 1 ("Background").
fn render_background_layer(
layer: &printpdf::PdfLayerReference,
background: PageBackground,
) {
fn render_background_layer(layer: &printpdf::PdfLayerReference, background: PageBackground) {
let grid_color = Color::Rgb(Rgb::new(0.88, 0.88, 0.88, None));
layer.set_outline_color(grid_color.clone());
layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt)
@@ -203,11 +266,7 @@ fn render_background_layer(
}
/// Rendert alle Striche einer Seite auf Layer 2 ("Ink").
fn render_ink_layer(
layer: &printpdf::PdfLayerReference,
page: &Page,
strategy: ExportStrategy,
) {
fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy: ExportStrategy) {
for stroke in &page.strokes {
if let Some(ref shape) = stroke.shape {
render_recognized_shape(layer, shape, stroke.color);
@@ -326,7 +385,12 @@ fn render_recognized_shape(
layer.add_polygon(poly);
}
RecognizedShape::Octahedron { center, radius_x, radius_y, depth } => {
RecognizedShape::Octahedron {
center,
radius_x,
radius_y,
depth,
} => {
let edges = crate::geometry::octahedron_edges(*center, *radius_x, *radius_y, *depth);
for (p1, p2) in edges {
let (x1, y1) = page_to_pdf_mm(p1.x, p1.y);
@@ -514,7 +578,8 @@ fn render_stroke_bezier(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
layer.set_line_join_style(printpdf::LineJoinStyle::Round);
// Durchschnittliche Linienstärke aus dem Druck berechnen
let avg_pressure = stroke.points.iter().map(|p| p.pressure).sum::<f32>() / stroke.points.len() as f32;
let avg_pressure =
stroke.points.iter().map(|p| p.pressure).sum::<f32>() / stroke.points.len() as f32;
let radius_mm = (avg_pressure * 0.75 + 0.25).max(0.25);
let thickness_pt = radius_mm * 2.0 * (72.0 / 25.4); // mm in DTP-Punkte
layer.set_outline_thickness(thickness_pt);
@@ -693,11 +758,18 @@ mod tests {
let temp_dir = std::env::temp_dir();
let pdf_path = temp_dir.join("ink_notes_test_export.pdf");
let export_res = export_notebook_to_pdf(&notebook, &pdf_path, ExportStrategy::FilledPolygon);
assert!(export_res.is_ok(), "PDF-Export zu Datei muss erfolgreich sein");
let export_res =
export_notebook_to_pdf(&notebook, &pdf_path, ExportStrategy::FilledPolygon);
assert!(
export_res.is_ok(),
"PDF-Export zu Datei muss erfolgreich sein"
);
// Datei-Existenz und Größe prüfen
assert!(pdf_path.exists(), "Die generierte PDF-Datei muss existieren");
assert!(
pdf_path.exists(),
"Die generierte PDF-Datei muss existieren"
);
let metadata = std::fs::metadata(&pdf_path).unwrap();
assert!(
metadata.len() > 1024,
@@ -821,4 +893,43 @@ mod tests {
assert!(bytes.len() > 1024);
assert!(bytes.starts_with(b"%PDF-1."));
}
#[test]
fn test_sanitize_filename_component() {
assert_eq!(sanitize_filename_component("Notizbuch 1"), "Notizbuch 1");
assert_eq!(
sanitize_filename_component("Projekt/2026: Entwurf"),
"Projekt_2026_ Entwurf"
);
assert_eq!(sanitize_filename_component(" "), "Notizbuch");
assert_eq!(sanitize_filename_component(""), "Notizbuch");
assert_eq!(
sanitize_filename_component("Mathe * Vorlesung?"),
"Mathe _ Vorlesung_"
);
}
#[test]
fn test_resolve_pdf_export_path_collision_avoidance() {
let temp_dir = tempfile::tempdir().expect("Konnte tempdir nicht erstellen");
let dir = temp_dir.path();
// 1. Erstmaliger Export: keine Kollision
let path1 = resolve_pdf_export_path_in_dir(dir, "Mathe Vorlesung");
assert_eq!(path1, dir.join("Mathe Vorlesung.pdf"));
// Datei anlegen
std::fs::write(&path1, b"dummy pdf 1").expect("Konnte Datei nicht schreiben");
// 2. Zweiter Export: Kollision -> _1
let path2 = resolve_pdf_export_path_in_dir(dir, "Mathe Vorlesung");
assert_eq!(path2, dir.join("Mathe Vorlesung_1.pdf"));
// Zweite Datei anlegen
std::fs::write(&path2, b"dummy pdf 2").expect("Konnte Datei nicht schreiben");
// 3. Dritter Export: Kollision -> _2
let path3 = resolve_pdf_export_path_in_dir(dir, "Mathe Vorlesung");
assert_eq!(path3, dir.join("Mathe Vorlesung_2.pdf"));
}
}
+28 -7
View File
@@ -265,9 +265,18 @@ mod tests {
// 4. dt > 1.0 s (großer Zeitsprung z.B. auf t = 5.0 s): Muss reset() auslösen
let (x4, y4, p4) = filter.filter(50.0, 100.0, 0.9, 5.0);
// Nach dem Reset muss der Filter sofort exakt den neuen Punkt als Startpunkt annehmen
assert_eq!(x4, 50.0, "Nach Reset muss der neue X-Wert übernommen werden");
assert_eq!(y4, 100.0, "Nach Reset muss der neue Y-Wert übernommen werden");
assert_eq!(p4, 0.9, "Nach Reset muss der neue Druckwert übernommen werden");
assert_eq!(
x4, 50.0,
"Nach Reset muss der neue X-Wert übernommen werden"
);
assert_eq!(
y4, 100.0,
"Nach Reset muss der neue Y-Wert übernommen werden"
);
assert_eq!(
p4, 0.9,
"Nach Reset muss der neue Druckwert übernommen werden"
);
}
#[test]
@@ -344,9 +353,18 @@ mod tests {
// 2. Vergiftungsversuch mit NaN und Infinity
let (x_nan, y_nan, p_nan) = filter.filter(f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.02);
assert!(x_nan.is_finite(), "Filter darf kein NaN bei Vergiftung ausgeben");
assert!(y_nan.is_finite(), "Filter darf kein Inf bei Vergiftung ausgeben");
assert!(p_nan.is_finite(), "Filter darf kein -Inf bei Vergiftung ausgeben");
assert!(
x_nan.is_finite(),
"Filter darf kein NaN bei Vergiftung ausgeben"
);
assert!(
y_nan.is_finite(),
"Filter darf kein Inf bei Vergiftung ausgeben"
);
assert!(
p_nan.is_finite(),
"Filter darf kein -Inf bei Vergiftung ausgeben"
);
let (x_nan2, y_nan2, p_nan2) = filter.filter(12.0, 22.0, 0.6, f64::NAN);
assert!(x_nan2.is_finite());
@@ -364,6 +382,9 @@ mod tests {
assert!(x3.is_finite());
assert!(y3.is_finite());
assert!(p3.is_finite());
assert!(x3 > x2, "Filter muss sich nach Vergiftung weiter normal anpassen");
assert!(
x3 > x2,
"Filter muss sich nach Vergiftung weiter normal anpassen"
);
}
}
+216 -76
View File
@@ -3,9 +3,9 @@
//! Erkennt Geraden, Rechtecke, Kreise und Koordinatensysteme (Achsen)
//! und erzeugt saubere Vektor-Primitiven und GPU-Triangle-Meshes.
use std::f32::consts::PI;
use glam::Vec2;
use serde::{Deserialize, Serialize};
use std::f32::consts::PI;
use crate::canvas::InkVertex;
use crate::document::{Aabb, Stroke, StylusSample};
@@ -117,7 +117,12 @@ impl RecognizedShape {
*c += delta;
}
}
Self::CoordinateSystem { origin, x_axis_end, y_axis_end, .. } => {
Self::CoordinateSystem {
origin,
x_axis_end,
y_axis_end,
..
} => {
*origin += delta;
*x_axis_end += delta;
*y_axis_end += delta;
@@ -159,7 +164,12 @@ impl RecognizedShape {
*c = rot(*c);
}
}
Self::CoordinateSystem { origin: o, x_axis_end, y_axis_end, .. } => {
Self::CoordinateSystem {
origin: o,
x_axis_end,
y_axis_end,
..
} => {
*o = rot(*o);
*x_axis_end = rot(*x_axis_end);
*y_axis_end = rot(*y_axis_end);
@@ -177,9 +187,7 @@ impl RecognizedShape {
/// Skaliert die geometrische Form bezüglich `origin` um den Faktor `factors`.
pub fn scale(&mut self, origin: Vec2, factors: Vec2) {
let sc = |pt: Vec2| -> Vec2 {
origin + (pt - origin) * factors
};
let sc = |pt: Vec2| -> Vec2 { origin + (pt - origin) * factors };
match self {
Self::Line { start, end } => {
@@ -200,7 +208,12 @@ impl RecognizedShape {
*c = sc(*c);
}
}
Self::CoordinateSystem { origin: o, x_axis_end, y_axis_end, .. } => {
Self::CoordinateSystem {
origin: o,
x_axis_end,
y_axis_end,
..
} => {
*o = sc(*o);
*x_axis_end = sc(*x_axis_end);
*y_axis_end = sc(*y_axis_end);
@@ -210,7 +223,12 @@ impl RecognizedShape {
*v = sc(*v);
}
}
Self::Octahedron { center, radius_x, radius_y, depth } => {
Self::Octahedron {
center,
radius_x,
radius_y,
depth,
} => {
*center = sc(*center);
*radius_x *= factors.x.abs();
*radius_y *= factors.y.abs();
@@ -415,18 +433,17 @@ pub fn detect_rectangle(points: &[Vec2]) -> Option<RecognizedShape> {
let epsilon = (total_len * 0.05).clamp(8.0, 45.0);
let simplified = ramer_douglas_peucker(points, epsilon);
let corners: Vec<Vec2> = if simplified.len() == 5
&& (simplified[0] - simplified[4]).length() < epsilon * 2.5
{
simplified[0..4].to_vec()
} else if simplified.len() == 4 {
simplified
} else if simplified.len() > 4 {
// Suche die 4 markantesten Ecken (stärkste Richtungsänderung)
extract_k_corners(&simplified, 4)?
} else {
return None;
};
let corners: Vec<Vec2> =
if simplified.len() == 5 && (simplified[0] - simplified[4]).length() < epsilon * 2.5 {
simplified[0..4].to_vec()
} else if simplified.len() == 4 {
simplified
} else if simplified.len() > 4 {
// Suche die 4 markantesten Ecken (stärkste Richtungsänderung)
extract_k_corners(&simplified, 4)?
} else {
return None;
};
if corners.len() != 4 {
return None;
@@ -535,7 +552,12 @@ pub fn extract_k_corners(points: &[Vec2], k: usize) -> Option<Vec<Vec2>> {
let mut selected_indices: Vec<usize> = scored.iter().take(k).map(|&(idx, _)| idx).collect();
selected_indices.sort_unstable();
Some(selected_indices.into_iter().map(|idx| points[idx]).collect())
Some(
selected_indices
.into_iter()
.map(|idx| points[idx])
.collect(),
)
}
/// 4. Dreieck-Erkennung: Geschlossener Pfad mit 3 dominanten Ecken.
@@ -561,7 +583,8 @@ pub fn detect_triangle(points: &[Vec2]) -> Option<RecognizedShape> {
let epsilon = (total_len * 0.07).clamp(8.0, 50.0);
let simplified = ramer_douglas_peucker(points, epsilon);
let corners: Vec<Vec2> = if (simplified.len() == 4 && (simplified[0] - simplified[3]).length() < epsilon * 2.5)
let corners: Vec<Vec2> = if (simplified.len() == 4
&& (simplified[0] - simplified[3]).length() < epsilon * 2.5)
|| simplified.len() == 3
{
simplified[0..3].to_vec()
@@ -617,9 +640,19 @@ pub fn detect_triangle(points: &[Vec2]) -> Option<RecognizedShape> {
let snapped_angle = snap_angle(base_angle, 15.0);
let c0 = center + Vec2::new(snapped_angle.cos(), snapped_angle.sin()) * r;
let c1 = center + Vec2::new((snapped_angle + 2.0 * PI / 3.0).cos(), (snapped_angle + 2.0 * PI / 3.0).sin()) * r;
let c2 = center + Vec2::new((snapped_angle + 4.0 * PI / 3.0).cos(), (snapped_angle + 4.0 * PI / 3.0).sin()) * r;
return Some(RecognizedShape::Triangle { corners: [c0, c1, c2] });
let c1 = center
+ Vec2::new(
(snapped_angle + 2.0 * PI / 3.0).cos(),
(snapped_angle + 2.0 * PI / 3.0).sin(),
) * r;
let c2 = center
+ Vec2::new(
(snapped_angle + 4.0 * PI / 3.0).cos(),
(snapped_angle + 4.0 * PI / 3.0).sin(),
) * r;
return Some(RecognizedShape::Triangle {
corners: [c0, c1, c2],
});
}
// 2. Rechtwinklig? (Ein Winkel nahe 90°)
@@ -639,7 +672,11 @@ pub fn detect_triangle(points: &[Vec2]) -> Option<RecognizedShape> {
let len2 = v2.length();
v1 = Vec2::new(a1_snapped.cos(), a1_snapped.sin()) * len1;
let sign = if (v1.x * v2.y - v1.y * v2.x) >= 0.0 { 1.0 } else { -1.0 };
let sign = if (v1.x * v2.y - v1.y * v2.x) >= 0.0 {
1.0
} else {
-1.0
};
v2 = Vec2::new(-v1.y * sign, v1.x * sign).normalize_or_zero() * len2;
let new_p1 = corner_v + v1;
@@ -667,7 +704,9 @@ pub fn detect_triangle(points: &[Vec2]) -> Option<RecognizedShape> {
let half_base = ((a - mid_ac).length() + (c - mid_ac).length()) * 0.5;
let new_a = mid_ac + perp * half_base;
c = mid_ac - perp * half_base;
return Some(RecognizedShape::Triangle { corners: [new_a, b, c] });
return Some(RecognizedShape::Triangle {
corners: [new_a, b, c],
});
} else if (l_bc - l_ca).abs() / l_bc.max(l_ca) < 0.12 {
let mid_ab = (a + b) * 0.5;
let axis = (c - mid_ab).normalize_or_zero();
@@ -675,7 +714,9 @@ pub fn detect_triangle(points: &[Vec2]) -> Option<RecognizedShape> {
let half_base = ((a - mid_ab).length() + (b - mid_ab).length()) * 0.5;
let new_a = mid_ab + perp * half_base;
b = mid_ab - perp * half_base;
return Some(RecognizedShape::Triangle { corners: [new_a, b, c] });
return Some(RecognizedShape::Triangle {
corners: [new_a, b, c],
});
}
Some(RecognizedShape::Triangle { corners: [a, b, c] })
@@ -701,15 +742,16 @@ pub fn detect_rhombus(points: &[Vec2]) -> Option<RecognizedShape> {
let epsilon = (total_len * 0.05).clamp(8.0, 45.0);
let simplified = ramer_douglas_peucker(points, epsilon);
let corners: Vec<Vec2> = if simplified.len() == 5 && (simplified[0] - simplified[4]).length() < epsilon * 2.5 {
simplified[0..4].to_vec()
} else if simplified.len() == 4 {
simplified
} else if simplified.len() > 4 {
extract_k_corners(&simplified, 4)?
} else {
return None;
};
let corners: Vec<Vec2> =
if simplified.len() == 5 && (simplified[0] - simplified[4]).length() < epsilon * 2.5 {
simplified[0..4].to_vec()
} else if simplified.len() == 4 {
simplified
} else if simplified.len() > 4 {
extract_k_corners(&simplified, 4)?
} else {
return None;
};
if corners.len() != 4 {
return None;
@@ -756,7 +798,11 @@ pub fn detect_rhombus(points: &[Vec2]) -> Option<RecognizedShape> {
let snapped_a1 = snap_angle(angle1, 15.0);
dir1 = Vec2::new(snapped_a1.cos(), snapped_a1.sin());
let sign = if (dir1.x * d2.y - dir1.y * d2.x) >= 0.0 { 1.0 } else { -1.0 };
let sign = if (dir1.x * d2.y - dir1.y * d2.x) >= 0.0 {
1.0
} else {
-1.0
};
let dir2 = Vec2::new(-dir1.y * sign, dir1.x * sign);
let h1 = len1 * 0.5;
@@ -806,7 +852,10 @@ pub fn detect_polygon(points: &[Vec2]) -> Option<RecognizedShape> {
return None;
}
let max_r_diff = radii.iter().map(|r| (r - mean_r).abs()).fold(0.0f32, f32::max);
let max_r_diff = radii
.iter()
.map(|r| (r - mean_r).abs())
.fold(0.0f32, f32::max);
let snapped_vertices = if max_r_diff / mean_r < 0.25 {
let base_dir = (corners[0] - center).normalize_or_zero();
@@ -830,7 +879,9 @@ pub fn detect_polygon(points: &[Vec2]) -> Option<RecognizedShape> {
fn ramer_douucker_closed(points: &[Vec2], epsilon: f32) -> Vec<Vec2> {
let simplified = ramer_douglas_peucker(points, epsilon);
if simplified.len() >= 2 && (simplified[0] - *simplified.last().unwrap()).length() < epsilon * 2.0 {
if simplified.len() >= 2
&& (simplified[0] - *simplified.last().unwrap()).length() < epsilon * 2.0
{
simplified[0..simplified.len() - 1].to_vec()
} else {
simplified
@@ -1043,8 +1094,16 @@ pub fn closed_polygon_to_mesh(
let n_next = Vec2::new(-d_next.y, d_next.x);
let corner_norm = (n_prev + n_next).normalize_or_zero() * half_w;
vertices.push(InkVertex::new(curr.x + corner_norm.x, curr.y + corner_norm.y, color));
vertices.push(InkVertex::new(curr.x - corner_norm.x, curr.y - corner_norm.y, color));
vertices.push(InkVertex::new(
curr.x + corner_norm.x,
curr.y + corner_norm.y,
color,
));
vertices.push(InkVertex::new(
curr.x - corner_norm.x,
curr.y - corner_norm.y,
color,
));
let edge_len = (next - curr).length();
let steps = ((edge_len / 10.0).ceil() as usize).max(2);
@@ -1069,7 +1128,12 @@ pub fn closed_polygon_to_mesh(
}
/// Berechnet die 12 Kanten der 3D-Schrägbild-Projektion eines Oktaeders (Doppelpyramide).
pub fn octahedron_edges(center: Vec2, radius_x: f32, radius_y: f32, depth: f32) -> Vec<(Vec2, Vec2)> {
pub fn octahedron_edges(
center: Vec2,
radius_x: f32,
radius_y: f32,
depth: f32,
) -> Vec<(Vec2, Vec2)> {
let top = center + Vec2::new(0.0, -radius_y);
let bottom = center + Vec2::new(0.0, radius_y);
let left = center + Vec2::new(-radius_x, 0.0);
@@ -1143,8 +1207,16 @@ pub fn shape_to_mesh(
let angle = (i as f32 / segments as f32) * 2.0 * PI;
let (sin, cos) = angle.sin_cos();
vertices.push(InkVertex::new(center.x + cos * r_out, center.y + sin * r_out, color));
vertices.push(InkVertex::new(center.x + cos * r_in, center.y + sin * r_in, color));
vertices.push(InkVertex::new(
center.x + cos * r_out,
center.y + sin * r_out,
color,
));
vertices.push(InkVertex::new(
center.x + cos * r_in,
center.y + sin * r_in,
color,
));
if i < segments {
samples.push(StylusSample {
@@ -1158,23 +1230,20 @@ pub fn shape_to_mesh(
(samples, vertices)
}
RecognizedShape::Rectangle { corners } => {
closed_polygon_to_mesh(corners, half_w, color)
}
RecognizedShape::Rectangle { corners } => closed_polygon_to_mesh(corners, half_w, color),
RecognizedShape::Triangle { corners } => {
closed_polygon_to_mesh(corners, half_w, color)
}
RecognizedShape::Triangle { corners } => closed_polygon_to_mesh(corners, half_w, color),
RecognizedShape::Rhombus { corners } => {
closed_polygon_to_mesh(corners, half_w, color)
}
RecognizedShape::Rhombus { corners } => closed_polygon_to_mesh(corners, half_w, color),
RecognizedShape::Polygon { vertices } => {
closed_polygon_to_mesh(vertices, half_w, color)
}
RecognizedShape::Polygon { vertices } => closed_polygon_to_mesh(vertices, half_w, color),
RecognizedShape::Octahedron { center, radius_x, radius_y, depth } => {
RecognizedShape::Octahedron {
center,
radius_x,
radius_y,
depth,
} => {
let edges = octahedron_edges(*center, *radius_x, *radius_y, *depth);
let mut vertices = Vec::new();
let mut samples = Vec::new();
@@ -1228,10 +1297,26 @@ pub fn shape_to_mesh(
let y_norm = Vec2::new(-y_dir.y, y_dir.x) * half_w;
// Y-Linienband
vertices.push(InkVertex::new(origin.x + y_norm.x, origin.y + y_norm.y, color));
vertices.push(InkVertex::new(origin.x - y_norm.x, origin.y - y_norm.y, color));
vertices.push(InkVertex::new(y_axis_end.x + y_norm.x, y_axis_end.y + y_norm.y, color));
vertices.push(InkVertex::new(y_axis_end.x - y_norm.x, y_axis_end.y - y_norm.y, color));
vertices.push(InkVertex::new(
origin.x + y_norm.x,
origin.y + y_norm.y,
color,
));
vertices.push(InkVertex::new(
origin.x - y_norm.x,
origin.y - y_norm.y,
color,
));
vertices.push(InkVertex::new(
y_axis_end.x + y_norm.x,
y_axis_end.y + y_norm.y,
color,
));
vertices.push(InkVertex::new(
y_axis_end.x - y_norm.x,
y_axis_end.y - y_norm.y,
color,
));
// Degenerate Dreiecke für Übergang zur X-Achse
let x_dir = (*x_axis_end - *origin).normalize_or_zero();
@@ -1244,9 +1329,21 @@ pub fn shape_to_mesh(
// 2. X-Linienband
vertices.push(next_v);
vertices.push(InkVertex::new(origin.x - x_norm.x, origin.y - x_norm.y, color));
vertices.push(InkVertex::new(x_axis_end.x + x_norm.x, x_axis_end.y + x_norm.y, color));
vertices.push(InkVertex::new(x_axis_end.x - x_norm.x, x_axis_end.y - x_norm.y, color));
vertices.push(InkVertex::new(
origin.x - x_norm.x,
origin.y - x_norm.y,
color,
));
vertices.push(InkVertex::new(
x_axis_end.x + x_norm.x,
x_axis_end.y + x_norm.y,
color,
));
vertices.push(InkVertex::new(
x_axis_end.x - x_norm.x,
x_axis_end.y - x_norm.y,
color,
));
// 3. Optionale Pfeilspitzen
if *with_arrows {
@@ -1314,6 +1411,9 @@ pub fn shape_to_stroke(
let (samples, vertices) = shape_to_mesh(&shape, width_units, color);
let mut stroke = Stroke::new(stroke_id, samples, vertices, color);
stroke.shape = Some(shape);
stroke.width = width_units;
stroke.tool = crate::document::StrokeTool::Shape;
stroke.finalized = true;
stroke
}
@@ -1328,7 +1428,10 @@ mod tests {
for i in 0..=50 {
let t = i as f32 / 50.0;
let jitter = (i as f32 * 0.7).sin() * 1.5;
points.push(Vec2::new(100.0 + t * 400.0 + jitter, 100.0 + t * 300.0 - jitter));
points.push(Vec2::new(
100.0 + t * 400.0 + jitter,
100.0 + t * 300.0 - jitter,
));
}
let detected = detect_line(&points);
@@ -1380,7 +1483,12 @@ mod tests {
let v1 = (prev - curr).normalize();
let v2 = (next - curr).normalize();
let dot = v1.dot(v2).abs();
assert!(dot < 1e-4, "Winkel an Ecke {} ist nicht rechtwinklig: dot={}", i, dot);
assert!(
dot < 1e-4,
"Winkel an Ecke {} ist nicht rechtwinklig: dot={}",
i,
dot
);
}
} else {
panic!("Falscher Formtyp erkannt");
@@ -1402,7 +1510,11 @@ mod tests {
let detected = detect_circle(&points);
assert!(detected.is_some(), "Kreis sollte erkannt werden");
if let Some(RecognizedShape::Circle { center: c, radius: r }) = detected {
if let Some(RecognizedShape::Circle {
center: c,
radius: r,
}) = detected
{
assert!((c - center).length() < 5.0, "Zentrum weicht ab: {:?}", c);
assert!((r - 54.0).abs() < 5.0, "Radius weicht ab: {}", r);
} else {
@@ -1431,14 +1543,27 @@ mod tests {
}
let detected = detect_coordinate_system(&points);
assert!(detected.is_some(), "Koordinatensystem sollte erkannt werden");
assert!(
detected.is_some(),
"Koordinatensystem sollte erkannt werden"
);
if let Some(RecognizedShape::CoordinateSystem { origin: o, x_axis_end, y_axis_end, .. }) = detected {
if let Some(RecognizedShape::CoordinateSystem {
origin: o,
x_axis_end,
y_axis_end,
..
}) = detected
{
assert!((o - origin).length() < 2.0, "Ursprung weicht ab");
let v_x = (x_axis_end - o).normalize();
let v_y = (y_axis_end - o).normalize();
let dot = v_x.dot(v_y).abs();
assert!(dot < 1e-4, "Achsen sind nach Orthogonalitäts-Snap nicht exakt 90°: dot={}", dot);
assert!(
dot < 1e-4,
"Achsen sind nach Orthogonalitäts-Snap nicht exakt 90°: dot={}",
dot
);
} else {
panic!("Falscher Formtyp erkannt");
}
@@ -1476,7 +1601,10 @@ mod tests {
let ab = (corners[1] - corners[0]).length();
let bc = (corners[2] - corners[1]).length();
let ca = (corners[0] - corners[2]).length();
assert!((ab - bc).abs() < 5.0, "Gleichseitiges Dreieck sollte gesnappt sein");
assert!(
(ab - bc).abs() < 5.0,
"Gleichseitiges Dreieck sollte gesnappt sein"
);
assert!((bc - ca).abs() < 5.0);
} else {
panic!("Falscher Formtyp erkannt");
@@ -1490,7 +1618,7 @@ mod tests {
let c0 = Vec2::new(200.0, 100.0); // Oben
let c1 = Vec2::new(320.0, 250.0); // Rechts
let c2 = Vec2::new(200.0, 400.0); // Unten
let c3 = Vec2::new(80.0, 250.0); // Links
let c3 = Vec2::new(80.0, 250.0); // Links
for i in 0..20 {
let t = i as f32 / 20.0;
@@ -1520,7 +1648,10 @@ mod tests {
let l1 = (corners[2] - corners[1]).length();
let l2 = (corners[3] - corners[2]).length();
let l3 = (corners[0] - corners[3]).length();
assert!((l0 - l1).abs() < 1e-3, "Alle 4 Seiten einer Raute müssen exakt gleich lang sein");
assert!(
(l0 - l1).abs() < 1e-3,
"Alle 4 Seiten einer Raute müssen exakt gleich lang sein"
);
assert!((l1 - l2).abs() < 1e-3);
assert!((l2 - l3).abs() < 1e-3);
} else {
@@ -1549,7 +1680,10 @@ mod tests {
points.push(points[0]);
let detected = detect_polygon(&points);
assert!(detected.is_some(), "Oktagon / Achteck sollte erkannt werden");
assert!(
detected.is_some(),
"Oktagon / Achteck sollte erkannt werden"
);
if let Some(RecognizedShape::Polygon { vertices }) = detected {
assert_eq!(vertices.len(), 8, "Achteck muss 8 Vertizes haben");
@@ -1587,7 +1721,13 @@ mod tests {
let detected = detect_octahedron(&points);
assert!(detected.is_some(), "Oktaeder sollte erkannt werden");
if let Some(RecognizedShape::Octahedron { center, radius_x, radius_y, depth }) = detected {
if let Some(RecognizedShape::Octahedron {
center,
radius_x,
radius_y,
depth,
}) = detected
{
assert!((center.x - 200.0).abs() < 5.0);
assert!((center.y - 200.0).abs() < 5.0);
assert!(radius_x > 100.0);
+8 -4
View File
@@ -83,7 +83,8 @@ impl HistoryStack {
if let Some(pos) = strokes.iter().position(|s| s.id == new.id) {
strokes[pos] = old.clone();
}
self.redo_stack.push(HistoryAction::ReplaceStroke { old, new });
self.redo_stack
.push(HistoryAction::ReplaceStroke { old, new });
}
HistoryAction::ModifyStrokes { before, after } => {
for b in &before {
@@ -91,7 +92,8 @@ impl HistoryStack {
strokes[pos] = b.clone();
}
}
self.redo_stack.push(HistoryAction::ModifyStrokes { before, after });
self.redo_stack
.push(HistoryAction::ModifyStrokes { before, after });
}
}
@@ -126,7 +128,8 @@ impl HistoryStack {
if let Some(pos) = strokes.iter().position(|s| s.id == old.id) {
strokes[pos] = new.clone();
}
self.undo_stack.push(HistoryAction::ReplaceStroke { old, new });
self.undo_stack
.push(HistoryAction::ReplaceStroke { old, new });
}
HistoryAction::ModifyStrokes { before, after } => {
for a in &after {
@@ -134,7 +137,8 @@ impl HistoryStack {
strokes[pos] = a.clone();
}
}
self.undo_stack.push(HistoryAction::ModifyStrokes { before, after });
self.undo_stack
.push(HistoryAction::ModifyStrokes { before, after });
}
}
+998 -352
View File
File diff suppressed because it is too large. Load diff
+31 -14
View File
@@ -2,8 +2,8 @@
//!
//! Stellt transaktionale Manipulationen für einzelne oder mehrere Striche und geometrische Formen bereit.
use std::f32::consts::FRAC_PI_2;
use glam::Vec2;
use std::f32::consts::FRAC_PI_2;
use crate::canvas::CanvasState;
use crate::document::{Aabb, Page, Stroke};
@@ -144,16 +144,16 @@ impl SelectionState {
// 2. 4 Eck-Skalierungsgriffe testen
let corners_page = [
aabb.min, // 0: oben-links
Vec2::new(aabb.max.x, aabb.min.y), // 1: oben-rechts
aabb.max, // 2: unten-rechts
Vec2::new(aabb.min.x, aabb.max.y), // 3: unten-links
aabb.min, // 0: oben-links
Vec2::new(aabb.max.x, aabb.min.y), // 1: oben-rechts
aabb.max, // 2: unten-rechts
Vec2::new(aabb.min.x, aabb.max.y), // 3: unten-links
];
let fixed_corners_page = [
aabb.max, // gegenüber 0 -> unten-rechts
Vec2::new(aabb.min.x, aabb.max.y), // gegenüber 1 -> unten-links
aabb.min, // gegenüber 2 -> oben-links
Vec2::new(aabb.max.x, aabb.min.y), // gegenüber 3 -> oben-rechts
aabb.max, // gegenüber 0 -> unten-rechts
Vec2::new(aabb.min.x, aabb.max.y), // gegenüber 1 -> unten-links
aabb.min, // gegenüber 2 -> oben-links
Vec2::new(aabb.max.x, aabb.min.y), // gegenüber 3 -> oben-rechts
];
let corners_screen = [
@@ -285,7 +285,11 @@ pub fn render_selection_overlay(
));
// 1. Wenn aktuell ein Auswahlrahmen gezogen wird (Marquee Box)
if let DragMode::BoxSelect { start_page, current_page } = selection.drag_mode {
if let DragMode::BoxSelect {
start_page,
current_page,
} = selection.drag_mode
{
let p1 = canvas.page_to_screen(start_page);
let p2 = canvas.page_to_screen(current_page);
let min_x = p1[0].min(p2[0]);
@@ -304,7 +308,10 @@ pub fn render_selection_overlay(
painter.rect_stroke(
rect,
egui::Rounding::same(4.0_f32),
egui::Stroke::new(1.5_f32, egui::Color32::from_rgba_premultiplied(80, 160, 255, 220)),
egui::Stroke::new(
1.5_f32,
egui::Color32::from_rgba_premultiplied(80, 160, 255, 220),
),
);
return;
}
@@ -357,7 +364,11 @@ pub fn render_selection_overlay(
for c in corners {
let handle_rect = egui::Rect::from_center_size(c, egui::vec2(handle_size, handle_size));
painter.rect_filled(handle_rect, egui::Rounding::same(1.5_f32), egui::Color32::WHITE);
painter.rect_filled(
handle_rect,
egui::Rounding::same(1.5_f32),
egui::Color32::WHITE,
);
painter.rect_stroke(
handle_rect,
egui::Rounding::same(1.5_f32),
@@ -369,7 +380,11 @@ pub fn render_selection_overlay(
let badge_text = format!(
"{} {} | [R] Drehen [Entf] Löschen",
selection.count(),
if selection.count() == 1 { "Objekt" } else { "Objekte" }
if selection.count() == 1 {
"Objekt"
} else {
"Objekte"
}
);
let badge_pos = egui::pos2(rect.center().x, rect.max.y + 16.0);
painter.text(
@@ -459,7 +474,9 @@ mod tests {
let orig_pos = page.strokes[0].points[0].position;
assert!(sel.rotate_selected_90_deg(&mut page));
let rotated_pos = page.strokes[0].points[0].position;
assert!((orig_pos.x - rotated_pos.x).abs() > 0.1 || (orig_pos.y - rotated_pos.y).abs() > 0.1);
assert!(
(orig_pos.x - rotated_pos.x).abs() > 0.1 || (orig_pos.y - rotated_pos.y).abs() > 0.1
);
// Undo rotate
assert!(page.undo());
+50 -16
View File
@@ -105,6 +105,18 @@ impl Default for SplineConfig {
}
}
impl SplineConfig {
/// Erzeugt eine Spline-Konfiguration aus der Strichbreite in internen Einheiten (10 Einheiten = 1 mm).
pub fn from_width_units(width_units: f32) -> Self {
let base_radius = (width_units / 2.0).max(0.25);
Self {
min_radius: (base_radius * 0.5).max(0.15),
max_radius: (base_radius * 1.5).max(0.35),
samples_per_segment: 16,
}
}
}
/// Berechnet den druckabhängigen Radius r(p) = min_r + p * (max_r - min_r).
pub fn calculate_radius(pressure: f32, min_radius: f32, max_radius: f32) -> f32 {
let p = pressure.clamp(0.0, 1.0);
@@ -355,11 +367,11 @@ fn rdp_mark(positions: &[glam::Vec2], start: usize, end: usize, epsilon: f32, ke
let mut max_dist = 0.0_f32;
let mut max_idx = start;
for i in (start + 1)..end {
let dist = point_to_segment_distance(positions[i], a, b);
for (offset, &pos) in positions[(start + 1)..end].iter().enumerate() {
let dist = point_to_segment_distance(pos, a, b);
if dist > max_dist {
max_dist = dist;
max_idx = i;
max_idx = start + 1 + offset;
}
}
@@ -418,10 +430,7 @@ pub fn detect_corners(samples: &[StylusSample], threshold_deg: f32) -> Vec<usize
}
/// Tastet einen zusammenhängenden, glatten Teilabschnitt (Run) zwischen Ecken ab.
fn sample_smooth_run(
control_points: &[ControlPoint],
config: &SplineConfig,
) -> Vec<SplineSample> {
fn sample_smooth_run(control_points: &[ControlPoint], config: &SplineConfig) -> Vec<SplineSample> {
match control_points.len() {
0 => Vec::new(),
1 => {
@@ -621,7 +630,12 @@ pub fn generate_finalized_triangle_strip(
/// 3. Ramer-Douglas-Peucker (RDP) Dezimierung (Toleranz ca. 0.05 bis 0.1 mm)
/// 4. Generierung der finalen Triangle-Strips mit runden Kappen an Start und Ende.
pub fn finalize_stroke(raw_samples: &[StylusSample]) -> Stroke {
finalize_stroke_with_config(0, raw_samples, [0.0, 0.0, 0.0, 1.0], &SplineConfig::default())
finalize_stroke_with_config(
0,
raw_samples,
[0.0, 0.0, 0.0, 1.0],
&SplineConfig::default(),
)
}
/// Vollständige Finalisierung mit konfigurierbarer Stroke-ID, Farbe und SplineConfig.
@@ -651,7 +665,10 @@ pub fn finalize_stroke_with_config(
.map(|v| InkVertex::new(v.position[0], v.position[1], color))
.collect();
Stroke::new(stroke_id, decimated, ink_vertices, color)
let mut stroke = Stroke::new(stroke_id, decimated, ink_vertices, color);
stroke.width = config.min_radius + config.max_radius;
stroke.finalized = true;
stroke
}
#[cfg(test)]
@@ -808,9 +825,10 @@ mod tests {
assert!(!stroke.vertices.is_empty());
// 2. Der Eckpunkt (100.0, 0.0) muss in den Strichpunkten erhalten bleiben
let corner_found = stroke.points.iter().any(|s| {
(s.position.x - 100.0).abs() < 1e-3 && (s.position.y - 0.0).abs() < 1e-3
});
let corner_found = stroke
.points
.iter()
.any(|s| (s.position.x - 100.0).abs() < 1e-3 && (s.position.y - 0.0).abs() < 1e-3);
assert!(
corner_found,
"Eckpunkt (100.0, 0.0) muss in den finalen Strichpunkten erhalten bleiben!"
@@ -837,7 +855,11 @@ mod tests {
// 4. Ecken-Erkennung prüft den 90°-Winkel (> 65 Grad)
let corners = detect_corners(&stroke.points, CORNER_ANGLE_THRESHOLD_DEG);
assert_eq!(corners.len(), 1, "Exakt eine 90-Grad-Ecke muss erkannt werden");
assert_eq!(
corners.len(),
1,
"Exakt eine 90-Grad-Ecke muss erkannt werden"
);
let corner_idx = corners[0];
let corner_pt = stroke.points[corner_idx];
assert!((corner_pt.position.x - 100.0).abs() < 1e-3);
@@ -847,9 +869,21 @@ mod tests {
#[test]
fn test_pressure_smoothing() {
let samples = vec![
StylusSample { position: glam::Vec2::new(0.0, 0.0), pressure: 0.2, timestamp: 0.0 },
StylusSample { position: glam::Vec2::new(1.0, 0.0), pressure: 0.8, timestamp: 0.1 },
StylusSample { position: glam::Vec2::new(2.0, 0.0), pressure: 0.2, timestamp: 0.2 },
StylusSample {
position: glam::Vec2::new(0.0, 0.0),
pressure: 0.2,
timestamp: 0.0,
},
StylusSample {
position: glam::Vec2::new(1.0, 0.0),
pressure: 0.8,
timestamp: 0.1,
},
StylusSample {
position: glam::Vec2::new(2.0, 0.0),
pressure: 0.2,
timestamp: 0.2,
},
];
let smoothed = smooth_pressure(&samples);
+1524 -105
View File
File diff suppressed because it is too large. Load diff
+446 -103
View File
@@ -81,11 +81,8 @@ pub const PEN_WIDTH_PRESETS: [(f32, &str); 4] = [
];
/// Dicken-Presets für den Textmarker:
pub const HIGHLIGHTER_WIDTH_PRESETS: [(f32, &str); 3] = [
(4.0, "4.0 mm"),
(6.0, "6.0 mm"),
(8.0, "8.0 mm"),
];
pub const HIGHLIGHTER_WIDTH_PRESETS: [(f32, &str); 3] =
[(4.0, "4.0 mm"), (6.0, "6.0 mm"), (8.0, "8.0 mm")];
/// Aktionen, die von der UI an die Anwendungslogik signalisiert werden.
#[derive(Debug, Clone, PartialEq)]
@@ -111,15 +108,30 @@ pub enum UiAction {
// Dokumenten- & Notizbuch-Verwaltung
OpenNotebook(String),
CreateNotebook(String),
RenameNotebook { id: String, new_title: String },
RenameNotebook {
id: String,
new_title: String,
},
DeleteNotebook(String),
UndoDeleteNotebook,
RestoreNotebookBackup(String),
OpenNotebookFolder(String),
RefreshNotebooks,
// Gitea Release & Updater
CheckForUpdates,
DownloadUpdateAsset(crate::updater::GiteaAsset),
InstallOrOpenUpdate(std::path::PathBuf),
OpenUrlInBrowser(String),
SaveUpdaterConfig { server_url: String, auth_token: Option<String> },
SaveUpdaterConfig {
server_url: String,
auth_token: Option<String>,
},
// Datenintegrität / Quarantäne
UnlockCorruptedPage(u64),
// Beenden-Sicherheit bei Fehlschlag
RetryShutdown,
ExportRecoveryAndExit,
ForceExit,
}
/// Zustand der grafischen Benutzeroberfläche.
@@ -157,6 +169,12 @@ pub struct UiState {
pub updater_token_input: String,
pub show_updater_config: bool,
pub update_modal_rect: Option<egui::Rect>,
/// Steuert, ob Touch-Eingaben mit einem Finger zeichnen oder das Dokument verschieben (Default: false = reine Gesten-Navigation & Zoom)
pub touch_draw_enabled: bool,
/// Zuletzt gelöschtes Notizbuch für Rückgängig während der Sitzung (Papierkorb-Pfad, Titel)
pub last_deleted_notebook: Option<(std::path::PathBuf, String)>,
/// Fehler beim Flushen vor dem Beenden
pub shutdown_error: Option<crate::autosave::AutosaveError>,
}
impl Default for UiState {
@@ -187,6 +205,9 @@ impl Default for UiState {
updater_token_input: String::new(),
show_updater_config: false,
update_modal_rect: None,
touch_draw_enabled: false,
last_deleted_notebook: None,
shutdown_error: None,
}
}
}
@@ -312,25 +333,36 @@ fn tool_pill_button_with_indicator(
(
egui::Color32::from_rgba_premultiplied(45, 80, 145, 230),
egui::Color32::WHITE,
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(110, 165, 255, 190)),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(110, 165, 255, 190),
),
)
} else if response.hovered() {
(
egui::Color32::from_rgba_premultiplied(255, 255, 255, 16),
egui::Color32::WHITE,
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 25)),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(255, 255, 255, 25),
),
)
} else {
(
egui::Color32::from_rgba_premultiplied(255, 255, 255, 8),
egui::Color32::from_gray(215),
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 15)),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(255, 255, 255, 15),
),
)
};
// Button-Hintergrund
ui.painter().rect_filled(rect, egui::Rounding::same(8.0), bg);
ui.painter().rect_stroke(rect, egui::Rounding::same(8.0), stroke);
ui.painter()
.rect_filled(rect, egui::Rounding::same(8.0), bg);
ui.painter()
.rect_stroke(rect, egui::Rounding::same(8.0), stroke);
// Text & Caret
let label = if is_menu_open {
@@ -359,7 +391,8 @@ fn tool_pill_button_with_indicator(
(indicator_color[2] * 255.0) as u8,
((indicator_color[3] * 255.0) as u8).max(180),
);
ui.painter().rect_filled(bar_rect, egui::Rounding::same(1.5), bar_color);
ui.painter()
.rect_filled(bar_rect, egui::Rounding::same(1.5), bar_color);
}
response.on_hover_text(tooltip)
@@ -376,13 +409,19 @@ fn tool_pill_button(
(
egui::Color32::from_rgba_premultiplied(45, 80, 145, 230),
egui::Color32::WHITE,
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(110, 165, 255, 190)),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(110, 165, 255, 190),
),
)
} else {
(
egui::Color32::from_rgba_premultiplied(255, 255, 255, 8),
egui::Color32::from_gray(215),
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 15)),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(255, 255, 255, 15),
),
)
};
@@ -415,22 +454,33 @@ fn stroke_preview_button(
let (bg, border) = if is_active {
(
egui::Color32::from_rgba_premultiplied(50, 85, 150, 220),
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(120, 175, 255, 200)),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(120, 175, 255, 200),
),
)
} else if response.hovered() {
(
egui::Color32::from_rgba_premultiplied(255, 255, 255, 16),
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 25)),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(255, 255, 255, 25),
),
)
} else {
(
egui::Color32::from_rgba_premultiplied(255, 255, 255, 8),
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 14)),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(255, 255, 255, 14),
),
)
};
ui.painter().rect_filled(rect, egui::Rounding::same(6.0), bg);
ui.painter().rect_stroke(rect, egui::Rounding::same(6.0), border);
ui.painter()
.rect_filled(rect, egui::Rounding::same(6.0), bg);
ui.painter()
.rect_stroke(rect, egui::Rounding::same(6.0), border);
// Horizontale Vorschau-Linie zeichnen
let line_thickness = (stroke_width_mm * 2.5).clamp(1.0, 9.0);
@@ -456,7 +506,11 @@ fn stroke_preview_button(
egui::Align2::CENTER_CENTER,
label_text,
egui::FontId::proportional(10.0),
if is_active { egui::Color32::WHITE } else { egui::Color32::from_gray(190) },
if is_active {
egui::Color32::WHITE
} else {
egui::Color32::from_gray(190)
},
);
}
@@ -464,34 +518,32 @@ fn stroke_preview_button(
}
/// Kompakter Strichbreiten-Preset-Button für die Haupt-Toolbar.
fn preset_width_button(
ui: &mut egui::Ui,
is_active: bool,
text: &str,
) -> egui::Response {
fn preset_width_button(ui: &mut egui::Ui, is_active: bool, text: &str) -> egui::Response {
let (bg, text_color, stroke) = if is_active {
(
egui::Color32::from_rgba_premultiplied(50, 85, 150, 230),
egui::Color32::WHITE,
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(120, 175, 255, 190)),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(120, 175, 255, 190),
),
)
} else {
(
egui::Color32::from_rgba_premultiplied(255, 255, 255, 6),
egui::Color32::from_gray(190),
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 12)),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(255, 255, 255, 12),
),
)
};
let button = egui::Button::new(
egui::RichText::new(text)
.color(text_color)
.size(11.5),
)
.fill(bg)
.stroke(stroke)
.rounding(egui::Rounding::same(6.0))
.min_size(egui::vec2(0.0, 24.0));
let button = egui::Button::new(egui::RichText::new(text).color(text_color).size(11.5))
.fill(bg)
.stroke(stroke)
.rounding(egui::Rounding::same(6.0))
.min_size(egui::vec2(0.0, 24.0));
ui.add(button)
}
@@ -512,24 +564,26 @@ fn action_pill_button(
let (bg, stroke) = if enabled {
(
egui::Color32::from_rgba_premultiplied(255, 255, 255, 10),
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 22)),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(255, 255, 255, 22),
),
)
} else {
(
egui::Color32::from_rgba_premultiplied(255, 255, 255, 3),
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 8)),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(255, 255, 255, 8),
),
)
};
let button = egui::Button::new(
egui::RichText::new(text)
.color(text_color)
.size(12.5),
)
.fill(bg)
.stroke(stroke)
.rounding(egui::Rounding::same(7.0))
.min_size(egui::vec2(0.0, 26.0));
let button = egui::Button::new(egui::RichText::new(text).color(text_color).size(12.5))
.fill(bg)
.stroke(stroke)
.rounding(egui::Rounding::same(7.0))
.min_size(egui::vec2(0.0, 26.0));
ui.add_enabled(enabled, button).on_hover_text(tooltip)
}
@@ -541,7 +595,10 @@ fn toolbar_divider(ui: &mut egui::Ui) {
ui.painter().vline(
rect.center().x,
rect.y_range(),
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 25)),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(255, 255, 255, 25),
),
);
ui.add_space(2.0);
}
@@ -911,7 +968,19 @@ pub fn render_ui(
toolbar_divider(ui);
// --- 9. AUTOSAVE-STATUSANZEIGE ---
// --- 9. TOUCH-MODUS UMSCHALTER (Gesten vs. Zeichnen) ---
let (touch_icon, touch_tt) = if state.touch_draw_enabled {
("✏️ Touch: Stift", "Touch-Modus: Einzelfinger zeichnet, zwei Finger zoomen/verschieben. Klicken zum Umschalten auf reine Gesten-Navigation.")
} else {
("🖐️ Touch: Zoom/Pan", "Touch-Modus: Finger zoomen und verschieben die Seite (ideal für Stifte wie Lazarite!). Klicken für Finger-Zeichnen.")
};
if action_pill_button(ui, true, touch_icon, touch_tt).clicked() {
state.touch_draw_enabled = !state.touch_draw_enabled;
}
toolbar_divider(ui);
// --- 10. AUTOSAVE-STATUSANZEIGE ---
let (dot_color, label_text) = match autosave_status {
AutosaveStatus::Saved => (egui::Color32::from_rgb(46, 204, 113), "Gespeichert"),
AutosaveStatus::Saving => (egui::Color32::from_rgb(241, 196, 15), "Speichert..."),
@@ -1014,14 +1083,22 @@ pub fn render_ui(
.color(egui::Color32::WHITE)
.size(12.5),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(egui::Button::new(egui::RichText::new("✕").size(11.0)).frame(false))
.clicked()
{
state.is_pen_menu_open = false;
}
});
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if ui
.add(
egui::Button::new(
egui::RichText::new("✕").size(11.0),
)
.frame(false),
)
.clicked()
{
state.is_pen_menu_open = false;
}
},
);
});
ui.add_space(8.0);
@@ -1040,7 +1117,8 @@ pub fn render_ui(
let is_sel = (state.pen_color[0] - color[0]).abs() < 0.05
&& (state.pen_color[1] - color[1]).abs() < 0.05
&& (state.pen_color[2] - color[2]).abs() < 0.05;
if color_circle_swatch(ui, color, is_sel, label, 22.0).clicked() {
if color_circle_swatch(ui, color, is_sel, label, 22.0).clicked()
{
state.pen_color = color;
actions.push(UiAction::SetColor(color));
}
@@ -1079,7 +1157,14 @@ pub fn render_ui(
ui.spacing_mut().item_spacing = egui::vec2(6.0, 0.0);
for (preset, label) in PEN_WIDTH_PRESETS {
let is_sel = (state.pen_stroke_width_mm - preset).abs() < 0.05;
if stroke_preview_button(ui, is_sel, preset, label, state.pen_color).clicked()
if stroke_preview_button(
ui,
is_sel,
preset,
label,
state.pen_color,
)
.clicked()
{
state.pen_stroke_width_mm = preset;
actions.push(UiAction::SetStrokeWidthMm(preset));
@@ -1145,14 +1230,22 @@ pub fn render_ui(
.color(egui::Color32::WHITE)
.size(12.5),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(egui::Button::new(egui::RichText::new("✕").size(11.0)).frame(false))
.clicked()
{
state.is_highlighter_menu_open = false;
}
});
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if ui
.add(
egui::Button::new(
egui::RichText::new("✕").size(11.0),
)
.frame(false),
)
.clicked()
{
state.is_highlighter_menu_open = false;
}
},
);
});
ui.add_space(8.0);
@@ -1168,10 +1261,12 @@ pub fn render_ui(
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(8.0, 0.0);
for (color, label) in HIGHLIGHTER_COLOR_PRESETS {
let is_sel = (state.highlighter_color[0] - color[0]).abs() < 0.05
let is_sel = (state.highlighter_color[0] - color[0]).abs()
< 0.05
&& (state.highlighter_color[1] - color[1]).abs() < 0.05
&& (state.highlighter_color[2] - color[2]).abs() < 0.05;
if color_circle_swatch(ui, color, is_sel, label, 22.0).clicked() {
if color_circle_swatch(ui, color, is_sel, label, 22.0).clicked()
{
state.highlighter_color = color;
actions.push(UiAction::SetColor(color));
}
@@ -1208,7 +1303,8 @@ pub fn render_ui(
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(6.0, 0.0);
for (preset, label) in HIGHLIGHTER_WIDTH_PRESETS {
let is_sel = (state.highlighter_stroke_width_mm - preset).abs() < 0.1;
let is_sel =
(state.highlighter_stroke_width_mm - preset).abs() < 0.1;
if stroke_preview_button(
ui,
is_sel,
@@ -1236,13 +1332,18 @@ pub fn render_ui(
ui.add_space(2.0);
if ui
.add(
egui::Slider::new(&mut state.highlighter_stroke_width_mm, 2.0..=16.0)
.suffix(" mm")
.step_by(0.5),
egui::Slider::new(
&mut state.highlighter_stroke_width_mm,
2.0..=16.0,
)
.suffix(" mm")
.step_by(0.5),
)
.changed()
{
actions.push(UiAction::SetStrokeWidthMm(state.highlighter_stroke_width_mm));
actions.push(UiAction::SetStrokeWidthMm(
state.highlighter_stroke_width_mm,
));
}
});
@@ -1299,16 +1400,18 @@ pub fn render_ui(
);
ui.separator();
egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
for idx in 0..notebook.pages.len() {
let is_current = idx == notebook.current_page_index;
let text = format!("Seite {}", idx + 1);
if tool_pill_button(ui, is_current, &text, "").clicked() {
actions.push(UiAction::GoToPage(idx));
state.show_page_jump_popup = false;
egui::ScrollArea::vertical()
.max_height(220.0)
.show(ui, |ui| {
for idx in 0..notebook.pages.len() {
let is_current = idx == notebook.current_page_index;
let text = format!("Seite {}", idx + 1);
if tool_pill_button(ui, is_current, &text, "").clicked() {
actions.push(UiAction::GoToPage(idx));
state.show_page_jump_popup = false;
}
}
}
});
});
ui.separator();
if action_pill_button(ui, true, "Schließen", "Fenster schließen").clicked() {
@@ -1458,11 +1561,17 @@ pub fn render_ui(
.size(13.0)
.color(egui::Color32::WHITE),
);
if is_current {
if summary.status == crate::storage::NotebookStatus::Damaged {
ui.colored_label(egui::Color32::from_rgb(255, 100, 100), "⚠️ Beschädigt");
} else if is_current {
ui.colored_label(egui::Color32::from_rgb(46, 204, 113), "● Aktiv");
}
});
let subtext = format!("{} Seite(n)", summary.page_count);
let subtext = if let Some(ref err) = summary.error {
format!("Fehler: {err}")
} else {
format!("{} Seite(n)", summary.page_count)
};
ui.label(
egui::RichText::new(subtext)
.size(11.0)
@@ -1471,27 +1580,47 @@ pub fn render_ui(
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if state.available_notebooks.len() > 1 {
if ui.button(egui::RichText::new("🗑").size(11.0).color(egui::Color32::from_rgb(255, 120, 120)))
.on_hover_text("Notizbuch unwiderruflich löschen")
if summary.status == crate::storage::NotebookStatus::Damaged {
if ui
.button(egui::RichText::new("🔄 Backup").size(11.0).color(egui::Color32::from_rgb(100, 200, 255)))
.on_hover_text("Aus manifest.json.bak wiederherstellen")
.clicked()
{
actions.push(UiAction::RestoreNotebookBackup(summary.id.clone()));
}
if ui
.button(egui::RichText::new("📁 Ordner").size(11.0))
.on_hover_text("Pfad in Statusleiste anzeigen")
.clicked()
{
actions.push(UiAction::OpenNotebookFolder(summary.id.clone()));
}
} else {
if state.available_notebooks.len() > 1
&& ui
.button(egui::RichText::new("🗑").size(11.0).color(egui::Color32::from_rgb(255, 120, 120)))
.on_hover_text("Notizbuch in Papierkorb verschieben")
.clicked()
{
actions.push(UiAction::DeleteNotebook(summary.id.clone()));
}
}
if ui.button(egui::RichText::new("✏").size(11.0))
.on_hover_text("Titel umbenennen")
.clicked()
{
state.renaming_notebook_id = Some(summary.id.clone());
state.renaming_title_input = summary.title.clone();
if ui.button(egui::RichText::new("✏").size(11.0))
.on_hover_text("Titel umbenennen")
.clicked()
{
state.renaming_notebook_id = Some(summary.id.clone());
state.renaming_title_input = summary.title.clone();
}
}
});
});
});
if frame_resp.response.interact(egui::Sense::click()).clicked() && !is_current {
if summary.status != crate::storage::NotebookStatus::Damaged
&& frame_resp.response.interact(egui::Sense::click()).clicked()
&& !is_current
{
actions.push(UiAction::OpenNotebook(summary.id.clone()));
state.is_document_menu_open = false;
}
@@ -1501,6 +1630,23 @@ pub fn render_ui(
}
});
if let Some((_, ref title)) = state.last_deleted_notebook {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(format!("'{title}' gelöscht"))
.size(11.0)
.color(egui::Color32::from_rgb(255, 180, 100)),
);
if ui
.button(egui::RichText::new("↩ Rückgängig").size(11.0).color(egui::Color32::from_rgb(100, 200, 255)))
.clicked()
{
actions.push(UiAction::UndoDeleteNotebook);
}
});
}
ui.add_space(8.0);
ui.separator();
ui.add_space(4.0);
@@ -1701,10 +1847,10 @@ pub fn render_ui(
}
ui.add_space(8.0);
if !release.html_url.is_empty() {
if ui.button(egui::RichText::new("🌐 Release auf Gitea öffnen").size(12.0)).clicked() {
actions.push(UiAction::OpenUrlInBrowser(release.html_url.clone()));
}
if !release.html_url.is_empty()
&& ui.button(egui::RichText::new("🌐 Release auf Gitea öffnen").size(12.0)).clicked()
{
actions.push(UiAction::OpenUrlInBrowser(release.html_url.clone()));
}
});
}
@@ -1810,7 +1956,12 @@ pub fn render_ui(
.order(egui::Order::Tooltip)
.show(ctx, |ui| {
egui::Frame::none()
.fill(egui::Color32::from_rgba_unmultiplied(20, 25, 30, alpha.min(235)))
.fill(egui::Color32::from_rgba_unmultiplied(
20,
25,
30,
alpha.min(235),
))
.rounding(egui::Rounding::same(10.0))
.stroke(egui::Stroke::new(
1.0_f32,
@@ -1826,7 +1977,9 @@ pub fn render_ui(
.show(ui, |ui| {
ui.label(
egui::RichText::new(format!("✓ {}", msg))
.color(egui::Color32::from_rgba_unmultiplied(220, 255, 230, alpha))
.color(egui::Color32::from_rgba_unmultiplied(
220, 255, 230, alpha,
))
.size(13.0)
.strong(),
);
@@ -1837,6 +1990,183 @@ pub fn render_ui(
}
}
// 5. Nicht-wegklickbare Warnung bei Ladefehlern / Quarantäne (Block 1)
if let Some(warning) = notebook.load_warnings.get(&notebook.current_page().id) {
egui::Window::new("⚠️ Datenintegritäts-Warnung")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
.frame(
egui::Frame::none()
.fill(egui::Color32::from_rgba_premultiplied(35, 20, 20, 245))
.rounding(egui::Rounding::same(12.0))
.stroke(egui::Stroke::new(
1.5_f32,
egui::Color32::from_rgb(235, 87, 87),
))
.inner_margin(egui::Margin::symmetric(24.0, 20.0))
.shadow(egui::epaint::Shadow {
offset: egui::vec2(0.0, 8.0),
blur: 24.0,
spread: 0.0,
color: egui::Color32::from_black_alpha(180),
}),
)
.show(ctx, |ui| {
ui.set_width(420.0);
ui.horizontal(|ui| {
ui.colored_label(egui::Color32::from_rgb(255, 80, 80), "⚠️");
ui.label(
egui::RichText::new(format!(
"Seite {} konnte nicht fehlerfrei geladen werden!",
warning.page_id
))
.strong()
.size(15.0)
.color(egui::Color32::WHITE),
);
});
ui.add_space(8.0);
ui.label(
egui::RichText::new(format!("Fehlermeldung: {}", warning.message))
.size(12.0)
.color(egui::Color32::from_gray(200)),
);
ui.add_space(8.0);
if let Some(ref q_path) = warning.quarantine_path {
ui.label(
egui::RichText::new("Die beschädigte Originaldatei wurde in Quarantäne gesichert:")
.size(12.0)
.color(egui::Color32::from_gray(220)),
);
ui.add_space(4.0);
ui.monospace(
egui::RichText::new(q_path.display().to_string())
.size(11.0)
.color(egui::Color32::from_rgb(255, 215, 0)),
);
ui.add_space(10.0);
ui.label(
egui::RichText::new(
"Um Datenverlust zu verhindern, ist diese Seite schreibgeschützt.\n\
Wenn Sie diese Seite bearbeiten möchten, können Sie den Schreibschutz aufheben.",
)
.size(11.5)
.color(egui::Color32::from_gray(180)),
);
ui.add_space(12.0);
if ui
.button(
egui::RichText::new("Als leere Seite bearbeiten (Schreibschutz aufheben)")
.color(egui::Color32::WHITE)
.size(12.5),
)
.clicked()
{
actions.push(UiAction::UnlockCorruptedPage(warning.page_id));
}
} else {
ui.label(
egui::RichText::new(
"Zugriffsfehler (I/O). Die Datei existiert, kann aber nicht gelesen werden.\n\
Die Seite bleibt schreibgeschützt, um ein Überschreiben zu verhindern.",
)
.size(12.0)
.color(egui::Color32::from_gray(200)),
);
}
});
}
// Notfall-/Sicherheits-Dialog bei Fehlschlag des Speicherns vor dem Beenden
if let Some(err) = &state.shutdown_error {
let mut dismiss = false;
egui::Window::new("⚠️ Ungespeicherte Änderungen")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
.frame(
egui::Frame::none()
.fill(egui::Color32::from_rgba_premultiplied(35, 20, 20, 250))
.rounding(egui::Rounding::same(12.0))
.stroke(egui::Stroke::new(
1.5_f32,
egui::Color32::from_rgb(235, 87, 87),
))
.inner_margin(egui::Margin::symmetric(24.0, 20.0))
.shadow(egui::epaint::Shadow {
offset: egui::vec2(0.0, 8.0),
blur: 24.0,
spread: 0.0,
color: egui::Color32::from_black_alpha(200),
}),
)
.show(ctx, |ui| {
ui.set_width(460.0);
ui.horizontal(|ui| {
ui.colored_label(egui::Color32::from_rgb(255, 80, 80), "⚠️");
ui.label(
egui::RichText::new("Fehler beim Beenden")
.color(egui::Color32::from_rgb(255, 100, 100))
.size(16.0)
.strong(),
);
});
ui.add_space(8.0);
ui.label(
egui::RichText::new(
"Ungespeicherte Änderungen konnten nicht gesichert werden:",
)
.size(13.0)
.color(egui::Color32::WHITE),
);
ui.add_space(6.0);
egui::Frame::none()
.fill(egui::Color32::from_black_alpha(100))
.rounding(egui::Rounding::same(6.0))
.inner_margin(egui::Margin::same(8.0))
.show(ui, |ui| {
for msg in &err.messages {
ui.label(
egui::RichText::new(msg)
.size(11.5)
.color(egui::Color32::from_rgb(255, 180, 180)),
);
}
});
ui.add_space(8.0);
ui.label(
egui::RichText::new("Trotzdem beenden?")
.size(13.0)
.color(egui::Color32::WHITE),
);
ui.add_space(12.0);
ui.horizontal_wrapped(|ui| {
if ui.button("Abbrechen").clicked() {
dismiss = true;
}
if ui.button("Wiederholen").clicked() {
actions.push(UiAction::RetryShutdown);
}
if ui.button("Notfall-Export").clicked() {
actions.push(UiAction::ExportRecoveryAndExit);
}
if ui
.button(
egui::RichText::new("Trotzdem beenden")
.color(egui::Color32::from_rgb(255, 100, 100)),
)
.clicked()
{
actions.push(UiAction::ForceExit);
}
});
});
if dismiss {
state.shutdown_error = None;
}
}
actions
}
@@ -1963,12 +2293,16 @@ mod tests {
title: "Mathematik I".to_string(),
page_count: 3,
modified_at: 100,
status: crate::storage::NotebookStatus::Normal,
error: None,
},
NotebookSummary {
id: "nb-2".to_string(),
title: "Physik II".to_string(),
page_count: 5,
modified_at: 200,
status: crate::storage::NotebookStatus::Normal,
error: None,
},
];
state.current_notebook_id = "nb-1".to_string();
@@ -2003,4 +2337,13 @@ mod tests {
assert!(state.is_point_in_ui(rect.center().x, rect.center().y));
}
}
#[test]
fn test_touch_draw_mode_toggle() {
let mut state = UiState::new();
assert!(!state.touch_draw_enabled); // Default: false = Gesten-Navigation & Zoom
state.touch_draw_enabled = true;
assert!(state.touch_draw_enabled);
}
}
+874 -163
View File
File diff suppressed because it is too large. Load diff
+44 -6
View File
@@ -3,8 +3,8 @@
//! Berechnet die affine Transformation zwischen Bildschirmkoordinaten (Window-Pixel),
//! virtuellen DIN-A4-Seitenkoordinaten ([0, 2100] x [0, 2970]) und dem GPU-NDC-Raum ([-1, 1]).
use glam::{Mat4, Vec2, Vec4};
use crate::document::{PageViewport, PAGE_HEIGHT, PAGE_WIDTH};
use glam::{Mat4, Vec2, Vec4};
/// Minimaler und maximaler Zoomfaktor.
pub const MIN_ZOOM: f32 = 0.25;
@@ -40,7 +40,12 @@ impl ViewportState {
}
/// Passt den Pan-Offset um eine Bildschirmverschiebung (in Fensterpixeln) an.
pub fn pan_by_screen_delta(&mut self, delta_x: f32, delta_y: f32, page_viewport: &PageViewport) {
pub fn pan_by_screen_delta(
&mut self,
delta_x: f32,
delta_y: f32,
page_viewport: &PageViewport,
) {
let base_scale = page_viewport.page_width / PAGE_WIDTH;
if base_scale * self.zoom > 1e-6 {
self.pan_offset[0] += delta_x / (base_scale * self.zoom);
@@ -122,10 +127,7 @@ impl ViewportState {
page_viewport: &PageViewport,
) -> Vec2 {
let pt = self.screen_to_page_unclamped(screen_x, screen_y, page_viewport);
Vec2::new(
pt.x.clamp(0.0, PAGE_WIDTH),
pt.y.clamp(0.0, PAGE_HEIGHT),
)
Vec2::new(pt.x.clamp(0.0, PAGE_WIDTH), pt.y.clamp(0.0, PAGE_HEIGHT))
}
/// Transformiert einen DIN-A4-Seitenpunkt in Fenster-Pixelkoordinaten.
@@ -244,4 +246,40 @@ mod tests {
assert!((offset1[0] - offset2[0]).abs() < 1e-5);
assert!((offset1[1] - offset2[1]).abs() < 1e-5);
}
#[test]
fn test_pinch_to_zoom_math() {
let mut vp = ViewportState::new();
let page_vp = PageViewport::calculate(1920.0, 1080.0, 24.0);
// Simulation von 2 Fingern:
// Finger 1 bei (400, 500), Finger 2 bei (600, 500) -> Distanz: 200, Zentrum: (500, 500)
let p1_start = (400.0_f32, 500.0_f32);
let p2_start = (600.0_f32, 500.0_f32);
let dist_start =
((p2_start.0 - p1_start.0).powi(2) + (p2_start.1 - p1_start.1).powi(2)).sqrt();
let center_start = (
(p1_start.0 + p2_start.0) * 0.5,
(p1_start.1 + p2_start.1) * 0.5,
);
assert_eq!(dist_start, 200.0);
assert_eq!(center_start, (500.0, 500.0));
// Finger auseinanderziehen und leicht verschieben:
// Finger 1 bei (350, 520), Finger 2 bei (650, 520) -> Distanz: 300, Zentrum: (500, 520)
let p1_end = (350.0_f32, 520.0_f32);
let p2_end = (650.0_f32, 520.0_f32);
let dist_end = ((p2_end.0 - p1_end.0).powi(2) + (p2_end.1 - p1_end.1).powi(2)).sqrt();
let center_end = ((p1_end.0 + p2_end.0) * 0.5, (p1_end.1 + p2_end.1) * 0.5);
assert_eq!(dist_end, 300.0);
assert_eq!(center_end, (500.0, 520.0));
let zoom_factor = dist_end / dist_start; // 1.5x Vergrößerung
let pan_delta = (center_end.0 - center_start.0, center_end.1 - center_start.1); // (0, +20)
vp.pan_by_screen_delta(pan_delta.0, pan_delta.1, &page_vp);
vp.zoom_at_cursor([center_end.0, center_end.1], zoom_factor, &page_vp);
assert!((vp.zoom - 1.5).abs() < 1e-4);
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"format_version": 99,
"id": "future_notebook",
"title": "Zukunfts-Notizbuch",
"created_at": 1800000000,
"modified_at": 1800001000,
"current_page_index": 0,
"pages": [
{ "id": 1, "background": "Grid5mm" }
],
"page_ids": [1],
"next_stroke_id": 10,
"next_page_id": 2
}
+6
View File
@@ -0,0 +1,6 @@
{
"format_version": 99,
"id": 1,
"background": "Blank",
"strokes": []
}
+10
View File
@@ -0,0 +1,10 @@
{
"id": "legacy_notebook",
"title": "Legacy Notizbuch v0",
"created_at": 1700000000,
"modified_at": 1700001000,
"current_page_index": 0,
"page_ids": [1],
"next_stroke_id": 5,
"next_page_id": 2
}
+14
View File
@@ -0,0 +1,14 @@
{
"id": 1,
"background": "DotGrid",
"strokes": [
{
"id": 1,
"color": [0.1, 0.2, 0.3, 1.0],
"samples": [
{ "x": 100.0, "y": 200.0, "p": 0.5, "t": 1.0 },
{ "x": 110.0, "y": 210.0, "p": 0.6, "t": 1.1 }
]
}
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"format_version": 1,
"id": 1,
"background": "Lined",
"strokes": [],
"unknown_experimental_feature": {
"nested_data": [1, 2, 3],
"author": "Antigravity"
},
"random_future_flag": true
}