Compare commits
8
Commits
dfb5a64917
..
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9ebcb0296 | ||
|
|
df72981a9a | ||
|
|
5cb77b9af7 | ||
|
|
bb0faf424b | ||
|
|
3999996e98 | ||
|
|
3eb21dadc4 | ||
|
|
5780c266b7 | ||
|
|
91e2d78b4f |
No files matched your search
@@ -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
+336
-7
@@ -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"
|
||||
@@ -956,11 +1045,16 @@ dependencies = [
|
||||
"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"
|
||||
|
||||
+11
@@ -2,6 +2,11 @@
|
||||
name = "ink-notes"
|
||||
version = "0.1.0"
|
||||
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"
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,22 @@
|
||||
## 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**: 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
@@ -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
@@ -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!"
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 ¬ebook.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(¬ebook, &pdf_path, ExportStrategy::FilledPolygon);
|
||||
assert!(export_res.is_ok(), "PDF-Export zu Datei muss erfolgreich sein");
|
||||
let export_res =
|
||||
export_notebook_to_pdf(¬ebook, &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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+31
-14
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+44
-6
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user