Compare commits

..
9 Commits
28 changed files with 3186 additions and 916 deletions

No files matched your search

+22 -5
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,11 +41,17 @@ 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
cache: true
- 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)
run: |
chmod +x scripts/*.sh
@@ -43,8 +59,9 @@ jobs:
- 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
+337 -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"
@@ -644,6 +706,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 +726,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 +830,7 @@ dependencies = [
"presser",
"thiserror",
"winapi",
"windows",
"windows 0.52.0",
]
[[package]]
@@ -801,6 +884,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 +1038,23 @@ dependencies = [
[[package]]
name = "ink-notes"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"bytemuck",
"egui",
"egui-wgpu",
"egui-winit",
"glam",
"octotablet",
"pollster",
"printpdf",
"semver",
"serde",
"serde_json",
"sha2",
"tempfile",
"ureq",
"url",
"webbrowser",
"wgpu",
"winit",
@@ -1349,6 +1443,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 +1722,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 +1774,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 +1849,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 +1898,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 +2009,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 +2248,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 +2278,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 +2336,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 +2553,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 +2585,7 @@ checksum = "cbd7311dbd2abcfebaabf1841a2824ed7c8be443a0f29166e5d3c6a53a762c01"
dependencies = [
"arrayvec",
"cfg-if",
"cfg_aliases",
"cfg_aliases 0.1.1",
"js-sys",
"log",
"naga",
@@ -2350,7 +2611,7 @@ dependencies = [
"arrayvec",
"bit-vec",
"bitflags 2.13.2",
"cfg_aliases",
"cfg_aliases 0.1.1",
"codespan-reporting",
"indexmap",
"log",
@@ -2379,7 +2640,7 @@ dependencies = [
"bit-set",
"bitflags 2.13.2",
"block",
"cfg_aliases",
"cfg_aliases 0.1.1",
"core-graphics-types",
"d3d12",
"glow",
@@ -2466,7 +2727,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 +2752,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 +2817,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 +3034,7 @@ dependencies = [
"bitflags 2.13.2",
"bytemuck",
"calloop",
"cfg_aliases",
"cfg_aliases 0.1.1",
"core-foundation",
"core-graphics",
"cursor-icon",
@@ -2895,6 +3218,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"
+12 -1
View File
@@ -1,7 +1,12 @@
[package]
name = "ink-notes"
version = "0.1.0"
version = "0.1.1"
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,11 @@ 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"
[profile.dev]
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
@@ -37,6 +47,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.
+96 -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,141 @@ 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, 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`
### 2. Installation auf Zorin OS 18
### 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. **SHA-256-Integritätsprüfung:**
- Vor jeder Ausführung oder Installation wird die offizielle Prüfsummendatei `SHA256SUMS.txt` aus dem Release bezogen.
- Der SHA-256-Hash des heruntergeladenen Pakets wird berechnet (`sha2`).
- Weicht die Prüfsumme ab oder fehlt ein Eintrag, wird die Datei sofort gelöscht und der Installationsvorgang mit Fehlermeldung abgebrochen.
- *Hinweis zur Authentizität:* Eine Prüfsumme vom selben Release-Server garantiert Schutz gegen Übertragungsfehler und Datei-Beschädigungen, jedoch keine kryptografische Nicht-Abstreitbarkeit gegenüber einem kompromittierten Server.
2. **Signatur-Ausblick:**
- Die Updater-Architektur enthält Schnittstellen für optionale `.sig`/`.minisig`-Signaturen zur zukünftigen End-to-End-Kryptoverifikation über öffentliche Schlüssel.
3. **Redirect-Schutz & HTTPS:**
- Alle API- und Download-Endpunkte erzwingen standardmäßig HTTPS.
- 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 +213,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 +230,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 +238,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 +249,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).
+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>
+23
View File
@@ -0,0 +1,23 @@
## 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
- **In-App Updater**: Automatische Versionsprüfung und sichere Atomar-Aktualisierung via Gitea Releases
- **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
```
+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!"
+171 -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 und SHA256SUMS.txt 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,12 @@ 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"
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
@@ -42,92 +50,181 @@ 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}"
+7 -2
View File
@@ -165,13 +165,18 @@ impl AutosaveController {
}
AutosaveResponse::ManifestSaved { result } => {
if let Err(e) = result {
eprintln!("[Autosave-Fehler] Manifest konnte nicht gespeichert werden: {e}");
eprintln!(
"[Autosave-Fehler] Manifest konnte nicht gespeichert werden: {e}"
);
self.status = AutosaveStatus::Error;
}
}
}
if self.in_flight_count == 0 && self.pending_pages.is_empty() && self.status != AutosaveStatus::Error {
if self.in_flight_count == 0
&& self.pending_pages.is_empty()
&& self.status != AutosaveStatus::Error
{
self.status = AutosaveStatus::Saved;
}
}
+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());
+35 -23
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};
@@ -143,7 +141,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 +186,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 +360,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,
@@ -410,7 +407,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 +434,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 +454,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 +476,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,
@@ -522,7 +528,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 +546,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,
@@ -655,7 +663,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 +676,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 +704,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 +795,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()));
+75 -21
View File
@@ -115,12 +115,20 @@ pub struct Stroke {
}
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)
@@ -171,7 +179,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 +267,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);
@@ -402,7 +413,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 +438,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 +490,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 +503,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 +543,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 +561,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 +573,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;
}
@@ -653,7 +682,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 +820,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 +1011,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 +1025,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)
);
}
}
+127 -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 {
@@ -78,12 +142,8 @@ pub fn generate_pdf_document(
// 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 +155,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 +168,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 +256,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 +375,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 +568,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 +748,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 +883,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"
);
}
}
+213 -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 {
@@ -1328,7 +1425,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 +1480,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 +1507,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 +1540,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 +1598,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 +1615,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 +1645,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 +1677,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 +1718,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 });
}
}
+711 -275
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());
+34 -15
View File
@@ -355,11 +355,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 +418,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 +618,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.
@@ -808,9 +810,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 +840,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 +854,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);
+37 -12
View File
@@ -69,7 +69,12 @@ impl StrokeData {
/// Wandelt die semantischen Daten deterministisch zurück in einen `Stroke` um,
/// inklusive Wiederherstellung des Triangle-Strip-Vertexpuffers und der AABB.
pub fn to_stroke(&self) -> Stroke {
let stylus_samples: Vec<StylusSample> = self.samples.iter().copied().map(StylusSample::from).collect();
let stylus_samples: Vec<StylusSample> = self
.samples
.iter()
.copied()
.map(StylusSample::from)
.collect();
let vertices = if let Some(ref shape) = self.shape {
let (_, shape_verts) = crate::geometry::shape_to_mesh(shape, 20.0, self.color);
shape_verts
@@ -207,7 +212,11 @@ pub fn reconstruct_stroke_vertices(samples: &[StylusSample], color: [f32; 4]) ->
let angle = (i as f32 / segments as f32) * std::f32::consts::TAU;
let offset = glam::Vec2::new(angle.cos() * radius, angle.sin() * radius);
vertices.push(InkVertex::new(pt.position.x, pt.position.y, color));
vertices.push(InkVertex::new(pt.position.x + offset.x, pt.position.y + offset.y, color));
vertices.push(InkVertex::new(
pt.position.x + offset.x,
pt.position.y + offset.y,
color,
));
}
return vertices;
}
@@ -241,7 +250,10 @@ pub fn reconstruct_stroke_vertices(samples: &[StylusSample], color: [f32; 4]) ->
/// Schreibt Inhalt atomar auf die Festplatte (über temporäre Datei + `std::fs::rename`).
pub fn atomic_write_file(path: &Path, content: &[u8]) -> Result<(), io::Error> {
let dir = path.parent().ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, "Übergeordnetes Verzeichnis nicht gefunden")
io::Error::new(
io::ErrorKind::NotFound,
"Übergeordnetes Verzeichnis nicht gefunden",
)
})?;
fs::create_dir_all(dir)?;
@@ -475,7 +487,7 @@ pub fn list_notebooks(base_dir: &Path) -> Vec<NotebookSummary> {
}
// Nach letztem Änderungsdatum absteigend sortieren (neueste zuerst)
list.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
list.sort_by_key(|a| std::cmp::Reverse(a.modified_at));
list
}
@@ -613,7 +625,11 @@ mod tests {
assert_eq!(loaded_stroke.color, [0.1, 0.2, 0.8, 1.0]);
assert_eq!(loaded_stroke.points.len(), 3);
for (orig, loaded) in page.strokes[0].points.iter().zip(loaded_stroke.points.iter()) {
for (orig, loaded) in page.strokes[0]
.points
.iter()
.zip(loaded_stroke.points.iter())
{
assert!((orig.position.x - loaded.position.x).abs() < 1e-4);
assert!((orig.position.y - loaded.position.y).abs() < 1e-4);
assert!((orig.pressure - loaded.pressure).abs() < 1e-4);
@@ -633,12 +649,16 @@ mod tests {
// Beschädigtes, unvollständiges JSON manuell schreiben
let corrupted_file = path.join("page_99.json");
let truncated_json = br#"{"id": 99, "background": "Grid5mm", "strokes": [{"id": 1, "color": [0.1, 0.2"#;
let truncated_json =
br#"{"id": 99, "background": "Grid5mm", "strokes": [{"id": 1, "color": [0.1, 0.2"#;
fs::write(&corrupted_file, truncated_json).expect("Konnte Datei nicht schreiben");
// Laden muss einen abgefangenen CorruptedPage-Fehler liefern und darf nicht panicken
let load_res = load_page(path, 99);
assert!(load_res.is_err(), "Laden beschädigter Daten muss fehlschlagen");
assert!(
load_res.is_err(),
"Laden beschädigter Daten muss fehlschlagen"
);
match load_res.unwrap_err() {
StorageError::CorruptedPage(id, msg) => {
@@ -729,16 +749,21 @@ mod tests {
// 3. Auflisten
let summaries = list_notebooks(base_dir);
assert_eq!(summaries.len(), 2);
assert!(summaries.iter().any(|s| s.id == m1.id && s.title == "Mathe Vorlesung"));
assert!(summaries.iter().any(|s| s.id == m2.id && s.title == "Skizzen & Entwürfe"));
assert!(summaries
.iter()
.any(|s| s.id == m1.id && s.title == "Mathe Vorlesung"));
assert!(summaries
.iter()
.any(|s| s.id == m2.id && s.title == "Skizzen & Entwürfe"));
// 4. Umbenennen
rename_notebook(&dir1, &mut m1, "Höhere Mathematik")
.expect("Umbenennen muss klappen");
rename_notebook(&dir1, &mut m1, "Höhere Mathematik").expect("Umbenennen muss klappen");
assert_eq!(m1.title, "Höhere Mathematik");
let updated_summaries = list_notebooks(base_dir);
assert!(updated_summaries.iter().any(|s| s.id == m1.id && s.title == "Höhere Mathematik"));
assert!(updated_summaries
.iter()
.any(|s| s.id == m1.id && s.title == "Höhere Mathematik"));
// 5. Löschen
delete_notebook(&dir2).expect("Löschen muss klappen");
+199 -95
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,7 +108,10 @@ pub enum UiAction {
// Dokumenten- & Notizbuch-Verwaltung
OpenNotebook(String),
CreateNotebook(String),
RenameNotebook { id: String, new_title: String },
RenameNotebook {
id: String,
new_title: String,
},
DeleteNotebook(String),
RefreshNotebooks,
// Gitea Release & Updater
@@ -119,7 +119,10 @@ pub enum UiAction {
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>,
},
}
/// Zustand der grafischen Benutzeroberfläche.
@@ -157,6 +160,8 @@ 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,
}
impl Default for UiState {
@@ -187,6 +192,7 @@ impl Default for UiState {
updater_token_input: String::new(),
show_updater_config: false,
update_modal_rect: None,
touch_draw_enabled: false,
}
}
}
@@ -312,25 +318,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 +376,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 +394,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 +439,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 +491,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 +503,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 +549,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 +580,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 +953,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 +1068,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 +1102,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 +1142,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 +1215,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 +1246,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 +1288,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 +1317,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 +1385,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() {
@@ -1471,13 +1559,13 @@ 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)))
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 unwiderruflich löschen")
.clicked()
{
actions.push(UiAction::DeleteNotebook(summary.id.clone()));
}
{
actions.push(UiAction::DeleteNotebook(summary.id.clone()));
}
if ui.button(egui::RichText::new("✏").size(11.0))
@@ -1701,10 +1789,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 +1898,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 +1919,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(),
);
@@ -2003,4 +2098,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);
}
}
+738 -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);
}
}