Compare commits
17
Commits
7ed971d2a2
...
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b03faedd27 | ||
|
|
f17e50ec32 | ||
|
|
5cd45831e2 | ||
|
|
71f6a70829 | ||
|
|
b751b71223 | ||
|
|
afb057692d | ||
|
|
eeae143dc6 | ||
|
|
992c3daad7 | ||
|
|
9684238a53 | ||
|
|
e8674e730f | ||
|
|
7e99d59042 | ||
|
|
12755d8eea | ||
|
|
510035e807 | ||
|
|
1afe4a0f9d | ||
|
|
04666bf389 | ||
|
|
a82402bfce | ||
|
|
06b3aeb311 |
No files matched your search
@@ -38,15 +38,21 @@ jobs:
|
||||
squashfs-tools \
|
||||
dpkg-dev \
|
||||
curl \
|
||||
minisign \
|
||||
python3
|
||||
|
||||
- name: Setup Rust Toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
|
||||
with:
|
||||
toolchain: stable
|
||||
# Die Action installiert mit --profile minimal; ohne diese Komponenten
|
||||
# schlagen cargo fmt und cargo clippy in den Quality Gates sofort fehl
|
||||
components: rustfmt, clippy
|
||||
cache: true
|
||||
|
||||
- name: Install rsign2 (Release-Signatur)
|
||||
# RUSTFLAGS leeren: die Toolchain-Action setzt -D warnings, das soll nicht für rsign2 gelten
|
||||
run: RUSTFLAGS="" cargo install rsign2 --locked --version 0.6.7
|
||||
|
||||
- name: Code Quality & Testing (Quality Gates)
|
||||
run: |
|
||||
cargo fmt --check
|
||||
|
||||
Generated
+12
-1
@@ -682,6 +682,16 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
@@ -1038,12 +1048,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ink-notes"
|
||||
version = "0.1.1"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
"egui-winit",
|
||||
"fs2",
|
||||
"glam",
|
||||
"minisign-verify",
|
||||
"octotablet",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ink-notes"
|
||||
version = "0.1.1"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
|
||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||
@@ -40,6 +40,7 @@ ureq = { version = "2.10", default-features = false, features = ["tls", "gzip",
|
||||
url = "2.5"
|
||||
octotablet = "0.1.0"
|
||||
minisign-verify = "0.3.0"
|
||||
fs2 = "0.4"
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
|
||||
|
||||
@@ -88,19 +88,19 @@ Ergebnis in `target/`:
|
||||
|
||||
### Release-Signatur einrichten (einmalig)
|
||||
|
||||
In-App-Updates werden nur mit gültiger Minisign-Signatur installiert. Einmalig ein Schlüsselpaar erzeugen (ohne Passwort, da es nur als CI-Secret verwendet wird):
|
||||
In-App-Updates werden nur mit gültiger Minisign-Signatur installiert. Signiert wird mit [rsign2](https://crates.io/crates/rsign2), einer Minisign-kompatiblen Implementierung (`cargo install rsign2 --locked`). Einmalig ein Schlüsselpaar erzeugen (ohne Passwort, da es nur als CI-Secret verwendet wird):
|
||||
|
||||
```bash
|
||||
minisign -G -W -p ink-notes.pub -s ink-notes.key
|
||||
# oder ohne minisign-Paket, mit dem kompatiblen rsign2 (cargo install rsign2):
|
||||
rsign generate -W -p ink-notes.pub -s ink-notes.key
|
||||
```
|
||||
|
||||
> **Wichtig:** Schlüssel und Signierwerkzeug müssen zusammenpassen. `rsign generate -W` verschlüsselt den Schlüssel mit einem leeren Passwort; das `minisign`-CLI (z. B. Ubuntu-Paket 0.11) kann ihn ohne Terminal nicht laden. `build-release.sh` und die CI signieren deshalb mit `rsign`.
|
||||
|
||||
1. Die zweite Zeile aus `ink-notes.pub` (beginnt mit `RW`) in `src/updater.rs` als `UPDATE_SIGNING_PUBLIC_KEY` eintragen und committen.
|
||||
2. Den kompletten Inhalt von `ink-notes.key` in Gitea unter *Repository → Einstellungen → Actions → Secrets* als `MINISIGN_SECRET_KEY` anlegen.
|
||||
3. `ink-notes.key` sicher offline aufbewahren und vom Rechner löschen. Geht der Schlüssel verloren, können bestehende Installationen keine Updates mehr prüfen.
|
||||
|
||||
Lokal signiert `build-release.sh` nur, wenn `MINISIGN_SECRET_KEY` gesetzt ist. In der CI ist die Signatur Pflicht (`INK_NOTES_REQUIRE_SIGNATURE=1`), und das Skript prüft, dass der geheime Schlüssel zum eingebauten öffentlichen Schlüssel passt.
|
||||
Lokal signiert `build-release.sh` nur, wenn `MINISIGN_SECRET_KEY` gesetzt und `rsign` installiert ist. In der CI ist die Signatur Pflicht (`INK_NOTES_REQUIRE_SIGNATURE=1`), und das Skript prüft, dass der geheime Schlüssel zum eingebauten öffentlichen Schlüssel passt.
|
||||
|
||||
### 2. Installation auf Zorin OS 18 / Ubuntu 24.04
|
||||
|
||||
|
||||
+54
-51
@@ -1,6 +1,6 @@
|
||||
# Dateiformat & Speicherstruktur von ink-notes
|
||||
|
||||
Dieses Dokument beschreibt die Speicherstruktur und die JSON-Dateiformate, mit denen `ink-notes` Dokumente auf dem Dateisystem ablegt.
|
||||
Dieses Dokument beschreibt die Speicherstruktur, die JSON-Dateiformate und die Versionshistorie, mit denen `ink-notes` Dokumente auf dem Dateisystem ablegt.
|
||||
|
||||
---
|
||||
|
||||
@@ -13,7 +13,9 @@ Notizbücher werden standardmäßig unter dem XDG-Datenpfad gespeichert:
|
||||
└── notebooks/
|
||||
├── <notebook_id_1>/
|
||||
│ ├── manifest.json
|
||||
│ ├── manifest.json.v0.bak (optional, Migrations-Backup)
|
||||
│ ├── page_1.json
|
||||
│ ├── page_1.json.corrupt-1775460000 (optional, Quarantäne bei Parsefehlern)
|
||||
│ ├── page_2.json
|
||||
│ └── ...
|
||||
└── <notebook_id_2>/
|
||||
@@ -29,19 +31,20 @@ Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge
|
||||
|
||||
```json
|
||||
{
|
||||
"format_version": 2,
|
||||
"id": "notebook-a1b2c3d4",
|
||||
"title": "Mathematik Vorlesung",
|
||||
"version": 1,
|
||||
"pages": [
|
||||
{
|
||||
"id": 1,
|
||||
"file_name": "page_1.json"
|
||||
"background": "Grid5mm"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"file_name": "page_2.json"
|
||||
"background": "DotGrid"
|
||||
}
|
||||
],
|
||||
"page_ids": [1, 2],
|
||||
"current_page_index": 0,
|
||||
"next_page_id": 3,
|
||||
"next_stroke_id": 42,
|
||||
@@ -51,10 +54,11 @@ Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge
|
||||
```
|
||||
|
||||
### Felder:
|
||||
- `format_version`: Format-Versionsnummer (aktuell `2`, bei unversionierten Legacy-Dateien implizit `0`).
|
||||
- `id`: Eindeutige ID des Notizbuchs (z. B. UUID oder `"default"`).
|
||||
- `title`: Vom Nutzer vergebener Anzeigename des Notizbuchs.
|
||||
- `version`: Format-Versionsnummer (aktuell `1`).
|
||||
- `pages`: Geordnete Liste von Seitenreferenzen.
|
||||
- `pages`: Geordnete Liste von Seitenmetadaten (`id`, optional `background`).
|
||||
- `page_ids`: Abwärtskompatible Liste der Seiten-IDs.
|
||||
- `current_page_index`: Index der zuletzt aktiven Seite für Session-Restore.
|
||||
- `next_page_id`: Inkrementierender ID-Zähler für neue Seiten.
|
||||
- `next_stroke_id`: Globaler ID-Zähler für neue Striche.
|
||||
@@ -65,58 +69,21 @@ Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge
|
||||
## 3. Seitendatei (`page_<id>.json`)
|
||||
|
||||
Jede Seite wird in einer eigenen Datei gespeichert, um Lazy-Loading und schnelles inkrementelles Speichern zu ermöglichen.
|
||||
Die Speicherung erfolgt ab Version 2 als kompaktes JSON (`serde_json::to_vec`), wodurch gegenüber Pretty-Printed JSON über 50 % Speicherplatz eingespart wird.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"background": "Grid5mm",
|
||||
"strokes": [
|
||||
{
|
||||
"id": 1,
|
||||
"color": [0.12, 0.12, 0.14, 1.0],
|
||||
"width_mm": 0.5,
|
||||
"samples": [
|
||||
{
|
||||
"x": 350.2,
|
||||
"y": 420.5,
|
||||
"pressure": 0.65,
|
||||
"timestamp": 12.345
|
||||
},
|
||||
{
|
||||
"x": 352.1,
|
||||
"y": 425.8,
|
||||
"pressure": 0.70,
|
||||
"timestamp": 12.355
|
||||
}
|
||||
],
|
||||
"recognized_shape": null
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"color": [0.15, 0.35, 0.85, 1.0],
|
||||
"width_mm": 0.8,
|
||||
"samples": [],
|
||||
"recognized_shape": {
|
||||
"Rectangle": {
|
||||
"top_left": { "x": 500.0, "y": 600.0 },
|
||||
"width": 300.0,
|
||||
"height": 200.0
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
{"format_version":2,"id":1,"background":"Grid5mm","strokes":[{"id":1,"color":[0.12,0.12,0.14,1.0],"samples":[{"x":350.2,"y":420.5,"p":0.65,"t":12.345},{"x":352.1,"y":425.8,"p":0.70,"t":12.355}],"shape":null,"tool":"pen","width":5.0,"finalized":true},{"id":2,"color":[0.15,0.35,0.85,1.0],"samples":[],"shape":{"Rectangle":{"top_left":{"x":500.0,"y":600.0},"width":300.0,"height":200.0}},"tool":"shape","width":20.0,"finalized":true}]}
|
||||
```
|
||||
|
||||
### Felder:
|
||||
- `format_version`: Format-Versionsnummer (`2`).
|
||||
- `id`: Eindeutige ID der Seite.
|
||||
- `background`: Papiermuster (`Blank`, `Grid5mm`, `Lined`, `DotGrid`).
|
||||
- `strokes`: Liste der auf der Seite gezeichneten Striche und Vektorformen.
|
||||
- `id`: Eindeutige ID des Strichs.
|
||||
- `color`: RGBA-Farbwerte im Bereich `[0.0, 1.0]`.
|
||||
- `width_mm`: Basis-Strichbreite in Millimetern.
|
||||
- `samples`: Rohabtastpunkte entlang des Pfads (`x`, `y` in DIN-A4-Einheiten, `pressure` in `[0.0, 1.0]`, `timestamp` in Sekunden).
|
||||
- `recognized_shape`: Optionales Enum für geometrische Vektorformen:
|
||||
- `samples`: Rohabtastpunkte entlang des Pfads (`x`, `y` in DIN-A4-Einheiten, `p` Druckwert in `[0.0, 1.0]`, `t` Zeitstempel in Sekunden).
|
||||
- `shape`: Optionales Enum für geometrische Vektorformen:
|
||||
- `Line { start, end }`
|
||||
- `Rectangle { top_left, width, height }`
|
||||
- `Circle { center, radius }`
|
||||
@@ -124,11 +91,47 @@ Jede Seite wird in einer eigenen Datei gespeichert, um Lazy-Loading und schnelle
|
||||
- `Triangle { p1, p2, p3 }`
|
||||
- `Rhombus { center, width, height }`
|
||||
- `Octahedron { center, radius }`
|
||||
- `tool`: Zeichenwerkzeug (`"pen"`, `"highlighter"`, `"shape"`).
|
||||
- `width`: Strichbreite in internen DIN-A4-Einheiten (10 Einheiten = 1 mm; z. B. `5.0` für 0.5 mm Stift, `60.0` für 6.0 mm Marker).
|
||||
- `finalized`: Boolesches Flag für Striche, die das Zwei-Stufen-Inking (Eckenerkennung, Glättung, runde Endkappen) durchlaufen haben.
|
||||
|
||||
---
|
||||
|
||||
## 4. Atomares Schreibverfahren
|
||||
## 4. Datensicherheit & Migration
|
||||
|
||||
Um Datenverlust bei Stromausfall oder plötzlichem Prozessabbruch zu verhindern:
|
||||
1. Daten werden in `<ziel>.tmp` serialisiert und per `fsync` auf das Speichermedium geschrieben.
|
||||
### Atomares Schreiben & Verzeichnis-Sync
|
||||
1. Daten werden in `<ziel>.tmp.<pid>.<nanos>` serialisiert und per `fsync` auf das Speichermedium geschrieben (Dateirechte `0600` auf Unix).
|
||||
2. Mittels `std::fs::rename("<ziel>.tmp", "<ziel>")` wird die Zieldatei auf POSIX-Systemen atomar ersetzt.
|
||||
3. Nach dem Rename wird das übergeordnete Verzeichnis synchronisiert (`sync_all()`), um Persistenz bei Stromausfall zu garantieren. Verzeichnisse erhalten Berechtigung `0700`.
|
||||
|
||||
### Quarantäne bei Parsefehlern
|
||||
Wird eine Seitendatei beim Laden als fehlerhaft erkannt (z. B. unvollständiges oder korruptes JSON), wird sie nicht stillschweigend gelöscht oder überschrieben, sondern nach `page_<id>.json.corrupt-<unix_ts>` verschoben. Die Ersatzseite wird als schreibgeschützt markiert.
|
||||
|
||||
### Migrations-Backups & Rolling Backups
|
||||
Beim ersten Speichern einer Datei im Legacy-Format (Version 0) wird vor dem Überschreiben ein Backup `*.v0.bak` angelegt (z. B. `manifest.json.v0.bak`, `page_1.json.v0.bak`).
|
||||
Zusätzlich existiert ein Rolling-Backup `manifest.json.bak`, auf das bei beschädigtem Manifest automatisch zurückgegriffen wird.
|
||||
|
||||
---
|
||||
|
||||
## 5. Versionshistorie
|
||||
|
||||
| Version | Status | Beschreibung |
|
||||
|---|---|---|
|
||||
| **0** | Legacy | Unversioniertes Format. `format_version` fehlt in JSON. Implizit als Version 0 interpretiert. |
|
||||
| **1** | Überholt | Einführung von `format_version: u32` in `manifest.json` und `page_<id>.json`. Seiten-Hintergrund im Manifest gecached (`pages: Vec<PageMeta>`). Strenges Versionsmatching (neuere Versionen werden abgelehnt). Automatische Migration mit `*.v0.bak`. |
|
||||
| **2** | **Aktuell** | Stroke-Treue: Ergänzung von `tool`, `width` und `finalized` in `StrokeData` für exakte Rekonstruktion aller Vertizes und Splines. Umstellung auf kompaktes JSON (`serde_json::to_vec`, ca. 54% Platzerparnis). |
|
||||
|
||||
---
|
||||
|
||||
## 6. Analyse & Bewertung: Binärformat (z. B. `postcard`)
|
||||
|
||||
Eine Umstellung auf ein Binärformat wie `postcard` wurde evaluiert:
|
||||
- **Vorteile**:
|
||||
- Weitere 20–30 % Reduktion der Dateigröße gegenüber kompaktem JSON.
|
||||
- Minimal schnellere Serialisierung auf leistungsschwachen Systemen.
|
||||
- **Nachteile**:
|
||||
- Verlust der Text-Transparenz (JSON lässt sich direkt inspizieren, mit `git`/Tools diffen und bei Korruption manuell reparieren).
|
||||
- Snapshotting und JSON-Verarbeitung liegen im Benchmark bereits bei **0,02 ms (1k Samples)** bis **0,70 ms (50k Samples)** – also weit unter dem Zielwert von 1 ms.
|
||||
- Kompaktes JSON spart bereits über 53 % Dateigröße ein.
|
||||
- **Entscheidung**:
|
||||
Kompaktes JSON bietet aktuell das optimale Verhältnis aus Ausfallsicherheit, Benutzerfreundlichkeit, Performance und Portabilität. Ein Binärformat wie `postcard` bleibt als Option für spätere Riesen-Notizbücher (> 500 Seiten) denkbar, wird aktuell jedoch nicht eingeführt.
|
||||
@@ -3,12 +3,15 @@
|
||||
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
|
||||
|
||||
### Highlights:
|
||||
- **Neue Werkzeugleiste**: Schmale senkrechte Leiste mit Symbol-Knöpfen, links oder rechts andockbar; selten genutzte Funktionen im ☰-Menü, das Blatt wird daneben zentriert
|
||||
- **UI per Stift und Touch bedienbar**: Werkzeugleiste, Popover und Dialoge reagieren jetzt auch auf Stift und Finger, auch bei Display-Skalierung
|
||||
- **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Natives Wayland-Tablet-Protokoll mit präziser Druck- und Neigungserfassung
|
||||
- **Touchscreen Multi-Touch**: Stufenloses 2-Finger Pinch-to-Zoom und Panning mit umschaltbarem Finger-Zeichenmodus
|
||||
- **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation)
|
||||
- **Geometrie- und Formerkennung**: Geraden, Rechtecke, Kreise, Rauten, Polygone & Koordinatensysteme (Draw-and-Hold)
|
||||
- **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave
|
||||
- **In-App Updater**: Automatische Versionsprüfung und sichere Atomar-Aktualisierung via Gitea Releases
|
||||
- **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave, Papierkorb und fortlaufenden Backups
|
||||
- **Datensicherheit**: Versioniertes Dateiformat mit Migration, Quarantäne beschädigter Seiten, Schutz vor Doppelöffnung und Crash-getestetem Speichern
|
||||
- **In-App Updater**: Automatische Versionsprüfung und sichere Atomar-Aktualisierung via Gitea Releases – mit Pflicht-Signaturprüfung (Minisign) und durchgängigem HTTPS
|
||||
- **Vektor-PDF-Export**: Verlustfreie Ausgabe für Druck und Weitergabe
|
||||
|
||||
### Installation:
|
||||
|
||||
@@ -30,23 +30,38 @@ echo "=== Berechne SHA256-Prüfsummen in target/${CHECKSUM_FILE} ==="
|
||||
cd "${ROOT_DIR}/target"
|
||||
sha256sum "${DEB_NAME}" "${APPIMAGE_NAME}" > "${CHECKSUM_FILE}"
|
||||
|
||||
# 4. Prüfsummen mit Minisign signieren (Pflicht für In-App-Updates)
|
||||
# 4. Prüfsummen signieren (Pflicht für In-App-Updates)
|
||||
# Signiert wird mit rsign2 (minisign-kompatibles Format): Der Schlüssel wurde mit
|
||||
# `rsign generate -W` erzeugt; das minisign-CLI kann ihn ohne Terminal nicht laden.
|
||||
SIGNATURE_FILE="${CHECKSUM_FILE}.minisig"
|
||||
rm -f "${SIGNATURE_FILE}"
|
||||
EMBEDDED_PUBKEY=$(grep -m1 'pub const UPDATE_SIGNING_PUBLIC_KEY' "${ROOT_DIR}/src/updater.rs" | cut -d '"' -f2)
|
||||
# Schlüssel kann von rustfmt in die Folgezeile umgebrochen sein
|
||||
EMBEDDED_PUBKEY=$(grep -m1 -A1 'pub const UPDATE_SIGNING_PUBLIC_KEY' "${ROOT_DIR}/src/updater.rs" | grep -o '"[^"]*"' | head -n1 | tr -d '"')
|
||||
if [ -n "${MINISIGN_SECRET_KEY:-}" ]; then
|
||||
if [ -z "${EMBEDDED_PUBKEY}" ]; then
|
||||
echo "FEHLER: UPDATE_SIGNING_PUBLIC_KEY in src/updater.rs ist leer – diese Version könnte keine Updates prüfen."
|
||||
exit 1
|
||||
fi
|
||||
echo "=== Signiere ${CHECKSUM_FILE} mit Minisign ==="
|
||||
if ! command -v rsign >/dev/null 2>&1; then
|
||||
echo "FEHLER: rsign nicht gefunden (Installation: cargo install rsign2 --locked)."
|
||||
exit 1
|
||||
fi
|
||||
echo "=== Signiere ${CHECKSUM_FILE} mit rsign2 ==="
|
||||
KEY_FILE="$(mktemp)"
|
||||
trap 'rm -f "${KEY_FILE}"' EXIT
|
||||
printf '%s\n' "${MINISIGN_SECRET_KEY}" > "${KEY_FILE}"
|
||||
minisign -S -s "${KEY_FILE}" -m "${CHECKSUM_FILE}" -t "ink-notes v${VERSION}"
|
||||
rsign sign -W -s "${KEY_FILE}" -t "ink-notes v${VERSION}" -x "${SIGNATURE_FILE}" "${CHECKSUM_FILE}"
|
||||
rm -f "${KEY_FILE}"
|
||||
# Stellt sicher, dass der geheime Schlüssel zum eingebauten öffentlichen Schlüssel passt
|
||||
minisign -V -P "${EMBEDDED_PUBKEY}" -m "${CHECKSUM_FILE}"
|
||||
# rsign beendet sich bei falscher Key-ID mit Exit-Code 0, daher zusätzlich die Meldung prüfen
|
||||
if ! VERIFY_OUT=$(rsign verify -P "${EMBEDDED_PUBKEY}" -x "${SIGNATURE_FILE}" "${CHECKSUM_FILE}" 2>&1) \
|
||||
|| ! grep -q "Signature and comment signature verified" <<<"${VERIFY_OUT}"; then
|
||||
echo "${VERIFY_OUT}"
|
||||
echo "FEHLER: Signatur passt nicht zum eingebauten öffentlichen Schlüssel (UPDATE_SIGNING_PUBLIC_KEY)."
|
||||
rm -f "${SIGNATURE_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
echo "${VERIFY_OUT}"
|
||||
elif [ "${INK_NOTES_REQUIRE_SIGNATURE:-0}" = "1" ]; then
|
||||
echo "FEHLER: MINISIGN_SECRET_KEY ist nicht gesetzt, eine Signatur ist aber Pflicht."
|
||||
exit 1
|
||||
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# scripts/crash-test.sh
|
||||
# Stresstest für ink-notes Speicher- und Autosave-Robustheit.
|
||||
# Simuliert harte Programmabstürze (SIGKILL / kill -9) während Schreibvorgängen
|
||||
# und verifiziert die Integrität der Notizbücher und Seiten.
|
||||
# ==============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
ITERATIONS="${1:-200}"
|
||||
TEST_DIR="${REPO_ROOT}/target/crash_test_workspace"
|
||||
CRASH_SIM_BIN="${REPO_ROOT}/target/release/crash_sim"
|
||||
|
||||
echo "=================================================================="
|
||||
echo " ink-notes Crash- & Datenverlust-Stresstest"
|
||||
echo " Iterationen: ${ITERATIONS}"
|
||||
echo " Testverzeichnis: ${TEST_DIR}"
|
||||
echo "=================================================================="
|
||||
|
||||
# 1. Sicherstellen, dass das Test-Binary gebaut ist
|
||||
if [ ! -f "${CRASH_SIM_BIN}" ]; then
|
||||
echo "Baue crash_sim Release-Binary..."
|
||||
cargo build --release --bin crash_sim --manifest-path "${REPO_ROOT}/Cargo.toml"
|
||||
fi
|
||||
|
||||
# 2. Sauberes Testverzeichnis anlegen und Notizbuch initialisieren
|
||||
rm -rf "${TEST_DIR}"
|
||||
mkdir -p "${TEST_DIR}"
|
||||
"${CRASH_SIM_BIN}" init "${TEST_DIR}"
|
||||
|
||||
SUCCESS_COUNT=0
|
||||
INTERRUPTED_WRITES=0
|
||||
|
||||
trap 'rm -rf "${TEST_DIR}"' EXIT
|
||||
|
||||
echo "Starte Testlauf mit ${ITERATIONS} SIGKILL-Zyklen..."
|
||||
|
||||
for i in $(seq 1 "${ITERATIONS}"); do
|
||||
# Worker im Hintergrund starten
|
||||
"${CRASH_SIM_BIN}" worker "${TEST_DIR}" > /dev/null 2>&1 &
|
||||
WORKER_PID=$!
|
||||
|
||||
# Zufällige Schlafdauer zwischen 2ms und 35ms, um Schreiboperationen mitten im Flug zu treffen
|
||||
# RANDOM % 33 + 2 ergibt 2..34 Millisekunden
|
||||
MS_DELAY=$(( (RANDOM % 33) + 2 ))
|
||||
DELAY="0.$(printf "%03d" "$MS_DELAY")"
|
||||
sleep "${DELAY}"
|
||||
|
||||
# Harter Prozessabbruch ohne Clean-up (SIGKILL)
|
||||
if kill -9 "${WORKER_PID}" 2>/dev/null; then
|
||||
INTERRUPTED_WRITES=$((INTERRUPTED_WRITES + 1))
|
||||
fi
|
||||
wait "${WORKER_PID}" 2>/dev/null || true
|
||||
|
||||
# Integritäts- und Konsistenzprüfung
|
||||
if ! "${CRASH_SIM_BIN}" verify "${TEST_DIR}" > /dev/null 2>&1; then
|
||||
echo ""
|
||||
echo "❌ FEHLER in Iteration ${i}!"
|
||||
echo "Notizbuch im Zustand nach Absturz nicht konsistent ladbar!"
|
||||
"${CRASH_SIM_BIN}" verify "${TEST_DIR}" || true
|
||||
trap - EXIT
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
|
||||
if [ $((i % 25)) -eq 0 ] || [ "${i}" -eq "${ITERATIONS}" ]; then
|
||||
echo " [$(date +%T)] Iteration ${i}/${ITERATIONS} bestanden (erfolgreich unterbrochene Schreibvorgänge: ${INTERRUPTED_WRITES})."
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=================================================================="
|
||||
echo "✅ ALLE ${ITERATIONS} CRASH-TESTS ERFOLGREICH BESTANDEN!"
|
||||
echo " - Überstandene SIGKILLs: ${INTERRUPTED_WRITES}"
|
||||
echo " - Konsistente Ladevorgänge: ${SUCCESS_COUNT}/${ITERATIONS}"
|
||||
echo " - Keine korrupten JSON-Dateien ohne Quarantäne"
|
||||
echo " - Keine unvollständigen Atom-Schreibungen"
|
||||
echo "=================================================================="
|
||||
+891
-153
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,221 @@
|
||||
//! Crash-Simulationstesthelfer für ink-notes.
|
||||
//! Wird von scripts/crash-test.sh verwendet, um unvollständige Schreibvorgänge
|
||||
//! und SIGKILL (kill -9) Robustheit zu verifizieren.
|
||||
|
||||
use std::env;
|
||||
use std::path::Path;
|
||||
use std::process;
|
||||
use std::time::Duration;
|
||||
|
||||
use glam::Vec2;
|
||||
use ink_notes::document::{Notebook, PageBackground, StylusSample};
|
||||
use ink_notes::spline::{finalize_stroke_with_config, SplineConfig};
|
||||
use ink_notes::storage::{
|
||||
cleanup_orphaned_temp_files, load_notebook, load_page, save_manifest, save_notebook, save_page,
|
||||
NotebookManifest, PageData,
|
||||
};
|
||||
|
||||
fn run_init(dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let nb = Notebook::new();
|
||||
let id = dir
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("crash_test");
|
||||
let mut m = NotebookManifest::new(id, "Crash Test Notebook");
|
||||
m.sync_from_notebook(&nb);
|
||||
save_notebook(dir, &nb, &mut m)?;
|
||||
println!("[init] Initialized clean notebook in {:?}", dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_worker(dir: &Path) {
|
||||
let (mut notebook, mut manifest) = match load_notebook(dir) {
|
||||
Ok((nb, m)) => (nb, m),
|
||||
Err(_) => {
|
||||
let nb = Notebook::new();
|
||||
let id = dir
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("crash_test");
|
||||
let mut m = NotebookManifest::new(id, "Crash Test Notebook");
|
||||
m.sync_from_notebook(&nb);
|
||||
let _ = save_notebook(dir, &nb, &mut m);
|
||||
(nb, m)
|
||||
}
|
||||
};
|
||||
|
||||
let spline_cfg = SplineConfig::default();
|
||||
let mut stroke_counter = manifest.next_stroke_id;
|
||||
|
||||
println!("[worker] Started writing loop in {:?}", dir);
|
||||
|
||||
loop {
|
||||
// Erzeuge neuen Strich mit einigen Samples
|
||||
let base_x = (stroke_counter % 500) as f32;
|
||||
let samples = vec![
|
||||
StylusSample {
|
||||
position: Vec2::new(base_x, 100.0),
|
||||
pressure: 0.5,
|
||||
timestamp: 0.0,
|
||||
},
|
||||
StylusSample {
|
||||
position: Vec2::new(base_x + 10.0, 150.0),
|
||||
pressure: 0.7,
|
||||
timestamp: 0.01,
|
||||
},
|
||||
StylusSample {
|
||||
position: Vec2::new(base_x + 20.0, 200.0),
|
||||
pressure: 0.6,
|
||||
timestamp: 0.02,
|
||||
},
|
||||
];
|
||||
|
||||
let stroke = finalize_stroke_with_config(
|
||||
stroke_counter,
|
||||
&samples,
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
&spline_cfg,
|
||||
);
|
||||
stroke_counter += 1;
|
||||
manifest.next_stroke_id = stroke_counter;
|
||||
|
||||
let page = notebook.current_page_mut();
|
||||
page.add_stroke(stroke);
|
||||
|
||||
let page_data = PageData::from(&*page);
|
||||
|
||||
// 1. Schreibe Seite atomar
|
||||
if let Err(e) = save_page(dir, &page_data) {
|
||||
eprintln!("[worker] Error saving page: {:?}", e);
|
||||
}
|
||||
|
||||
// 2. Aktualisiere Manifest atomar
|
||||
manifest.sync_from_notebook(¬ebook);
|
||||
if let Err(e) = save_manifest(dir, &manifest) {
|
||||
eprintln!("[worker] Error saving manifest: {:?}", e);
|
||||
}
|
||||
|
||||
// 3. Gelegentlich Gesamtsave (wie Autosave-Flush)
|
||||
if stroke_counter % 5 == 0 {
|
||||
if let Err(e) = save_notebook(dir, ¬ebook, &mut manifest) {
|
||||
eprintln!("[worker] Error saving notebook: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Evtl. neue Seite anlegen und sofort sichern, bevor Manifest aktualisiert wird
|
||||
if stroke_counter % 25 == 0 {
|
||||
let page_idx = notebook.add_page(PageBackground::DotGrid);
|
||||
notebook.current_page_index = page_idx;
|
||||
let new_page_data = PageData::from(notebook.current_page());
|
||||
if let Err(e) = save_page(dir, &new_page_data) {
|
||||
eprintln!("[worker] Error saving new page: {:?}", e);
|
||||
}
|
||||
manifest.sync_from_notebook(¬ebook);
|
||||
if let Err(e) = save_manifest(dir, &manifest) {
|
||||
eprintln!("[worker] Error saving manifest: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_verify(dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("[verify] Checking notebook in {:?}", dir);
|
||||
|
||||
// 1. Bereinige eventuelle verwaiste .tmp-Dateien
|
||||
let _ = cleanup_orphaned_temp_files(dir, Duration::from_secs(0));
|
||||
|
||||
// Prüfe, ob das Verzeichnis noch gar kein Notizbuch enthält (Prozess vor erstem Flush getötet)
|
||||
let manifest_path = dir.join("manifest.json");
|
||||
let manifest_bak = dir.join("manifest.json.bak");
|
||||
if !manifest_path.exists() && !manifest_bak.exists() {
|
||||
println!("[verify] Notebook not yet created on disk (killed prior to first save). Valid empty state.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 2. Notizbuch laden
|
||||
let (_notebook, manifest) = match load_notebook(dir) {
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
eprintln!("[verify] FAILED to load notebook: {:?}", e);
|
||||
return Err(Box::new(e));
|
||||
}
|
||||
};
|
||||
|
||||
println!(
|
||||
"[verify] Manifest loaded successfully: title='{}', pages={}, next_stroke_id={}",
|
||||
manifest.title,
|
||||
manifest.pages.len(),
|
||||
manifest.next_stroke_id
|
||||
);
|
||||
|
||||
// 3. Alle im Manifest registrierten Seiten laden und prüfen
|
||||
for page_meta in &manifest.pages {
|
||||
match load_page(dir, page_meta.id) {
|
||||
Ok(page_data) => {
|
||||
let page = page_data.to_page();
|
||||
println!(
|
||||
"[verify] Page {} verified: {} strokes, background={:?}",
|
||||
page_meta.id,
|
||||
page.strokes.len(),
|
||||
page.background
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"[verify] FAILED to load page {} referenced by manifest: {:?}",
|
||||
page_meta.id, e
|
||||
);
|
||||
return Err(Box::new(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Prüfen ob Quarantäne-Dateien existieren
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let file_name = entry.file_name();
|
||||
let name = file_name.to_string_lossy();
|
||||
if name.contains(".corrupt-") {
|
||||
println!("[verify] Notice: Found quarantined file: {}", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("[verify] SUCCESS: All pages and manifest are fully consistent!");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
if args.len() < 3 {
|
||||
eprintln!("Usage: crash_sim <init|worker|verify> <notebook_dir>");
|
||||
process::exit(2);
|
||||
}
|
||||
|
||||
let command = &args[1];
|
||||
let notebook_dir = Path::new(&args[2]);
|
||||
|
||||
match command.as_str() {
|
||||
"init" => match run_init(notebook_dir) {
|
||||
Ok(_) => process::exit(0),
|
||||
Err(e) => {
|
||||
eprintln!("[init] Error: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
},
|
||||
"worker" => {
|
||||
run_worker(notebook_dir);
|
||||
}
|
||||
"verify" => match run_verify(notebook_dir) {
|
||||
Ok(_) => process::exit(0),
|
||||
Err(e) => {
|
||||
eprintln!("[verify] Error: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
},
|
||||
other => {
|
||||
eprintln!("Unknown command: {}", other);
|
||||
process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
-12
@@ -94,6 +94,9 @@ pub struct CanvasState {
|
||||
pub config: wgpu::SurfaceConfiguration,
|
||||
pub size: PhysicalSize<u32>,
|
||||
pub viewport: PageViewport,
|
||||
/// Von seitlicher UI belegte Breite links/rechts in physischen Pixeln
|
||||
ui_inset_left: f32,
|
||||
ui_inset_right: f32,
|
||||
pub camera: crate::viewport::ViewportState,
|
||||
|
||||
// Pipelines
|
||||
@@ -104,6 +107,8 @@ pub struct CanvasState {
|
||||
pub camera_buffer: wgpu::Buffer,
|
||||
pub camera_bind_group: wgpu::BindGroup,
|
||||
pub current_ink_color: [f32; 4],
|
||||
pub current_stroke_width_mm: f32,
|
||||
pub current_tool: crate::document::StrokeTool,
|
||||
|
||||
// Statische Hintergrund-Geometrie
|
||||
pub paper_buffer: wgpu::Buffer,
|
||||
@@ -369,11 +374,15 @@ impl CanvasState {
|
||||
config,
|
||||
size,
|
||||
viewport,
|
||||
ui_inset_left: 0.0,
|
||||
ui_inset_right: 0.0,
|
||||
strip_pipeline,
|
||||
triangle_pipeline,
|
||||
camera_buffer,
|
||||
camera_bind_group,
|
||||
current_ink_color: default_ink_color,
|
||||
current_stroke_width_mm: 0.5,
|
||||
current_tool: crate::document::StrokeTool::Pen,
|
||||
paper_buffer,
|
||||
background_buffer,
|
||||
background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY,
|
||||
@@ -414,6 +423,29 @@ impl CanvasState {
|
||||
);
|
||||
}
|
||||
|
||||
/// Setzt die von seitlicher UI belegten Streifen (physische Pixel), damit die Seite
|
||||
/// im freien Bereich zentriert wird. Gibt `true` zurück, wenn sich etwas geändert hat.
|
||||
pub fn set_ui_insets(&mut self, left: f32, right: f32) -> bool {
|
||||
if (self.ui_inset_left - left).abs() < 0.5 && (self.ui_inset_right - right).abs() < 0.5 {
|
||||
return false;
|
||||
}
|
||||
self.ui_inset_left = left;
|
||||
self.ui_inset_right = right;
|
||||
self.recalculate_viewport();
|
||||
true
|
||||
}
|
||||
|
||||
fn recalculate_viewport(&mut self) {
|
||||
self.viewport = PageViewport::calculate_with_insets(
|
||||
self.size.width as f32,
|
||||
self.size.height as f32,
|
||||
Self::VIEWPORT_MARGIN,
|
||||
self.ui_inset_left,
|
||||
self.ui_inset_right,
|
||||
);
|
||||
self.update_uniforms();
|
||||
}
|
||||
|
||||
/// Passt die Surface und Projektionsmatrizen an eine neue Fenstergröße an.
|
||||
pub fn resize(&mut self, new_size: PhysicalSize<u32>) {
|
||||
if new_size.width > 0 && new_size.height > 0 {
|
||||
@@ -421,14 +453,7 @@ impl CanvasState {
|
||||
self.config.width = new_size.width;
|
||||
self.config.height = new_size.height;
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
|
||||
self.viewport = PageViewport::calculate(
|
||||
new_size.width as f32,
|
||||
new_size.height as f32,
|
||||
Self::VIEWPORT_MARGIN,
|
||||
);
|
||||
|
||||
self.update_uniforms();
|
||||
self.recalculate_viewport();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,10 +527,14 @@ impl CanvasState {
|
||||
|
||||
/// Konfiguriert die Strichbreite in Millimetern.
|
||||
pub fn set_stroke_width_mm(&mut self, width_mm: f32) {
|
||||
// 10 Einheiten = 1 mm. Radius = (width_mm * 10.0) / 2.0 = width_mm * 5.0.
|
||||
let base_radius = (width_mm * 5.0).max(0.25);
|
||||
self.spline_config.min_radius = (base_radius * 0.5).max(0.15);
|
||||
self.spline_config.max_radius = (base_radius * 1.5).max(0.35);
|
||||
self.current_stroke_width_mm = width_mm;
|
||||
self.spline_config =
|
||||
SplineConfig::from_width_units(width_mm * crate::document::UNITS_PER_MM);
|
||||
}
|
||||
|
||||
/// Setzt das aktive Zeichenwerkzeug für den Stift.
|
||||
pub fn set_tool(&mut self, tool: crate::document::StrokeTool) {
|
||||
self.current_tool = tool;
|
||||
}
|
||||
|
||||
/// Setzt den Seitenhintergrund und aktualisiert bei Bedarf den Vertex-Puffer.
|
||||
@@ -643,6 +672,10 @@ impl CanvasState {
|
||||
self.current_ink_color,
|
||||
&self.spline_config,
|
||||
);
|
||||
let mut stroke = stroke;
|
||||
stroke.tool = self.current_tool;
|
||||
stroke.width = self.current_stroke_width_mm * crate::document::UNITS_PER_MM;
|
||||
stroke.finalized = true;
|
||||
|
||||
// Temporären GPU-Live-Buffer vollständig leeren
|
||||
self.live_samples.clear();
|
||||
|
||||
+97
-7
@@ -103,6 +103,16 @@ pub fn point_to_segment_distance(p: glam::Vec2, a: glam::Vec2, b: glam::Vec2) ->
|
||||
(p - projection).length()
|
||||
}
|
||||
|
||||
/// Das Werkzeug, mit dem ein Strich gezeichnet wurde.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum StrokeTool {
|
||||
#[default]
|
||||
Pen,
|
||||
Highlighter,
|
||||
Shape,
|
||||
}
|
||||
|
||||
/// Ein einzelner Vektor-Strich auf einer Seite.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Stroke {
|
||||
@@ -112,6 +122,9 @@ pub struct Stroke {
|
||||
pub aabb: Aabb,
|
||||
pub color: [f32; 4],
|
||||
pub shape: Option<crate::geometry::RecognizedShape>,
|
||||
pub tool: StrokeTool,
|
||||
pub width: f32,
|
||||
pub finalized: bool,
|
||||
}
|
||||
|
||||
impl Stroke {
|
||||
@@ -141,6 +154,9 @@ impl Stroke {
|
||||
aabb,
|
||||
color,
|
||||
shape: None,
|
||||
tool: StrokeTool::Pen,
|
||||
width: 5.0,
|
||||
finalized: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +173,22 @@ impl Stroke {
|
||||
/// Verknüpft diesen Strich mit einer mathematisch erkannten Form.
|
||||
pub fn with_shape(mut self, shape: crate::geometry::RecognizedShape) -> Self {
|
||||
self.shape = Some(shape);
|
||||
self.tool = StrokeTool::Shape;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_tool(mut self, tool: StrokeTool) -> Self {
|
||||
self.tool = tool;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_width(mut self, width: f32) -> Self {
|
||||
self.width = width;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_finalized(mut self, finalized: bool) -> Self {
|
||||
self.finalized = finalized;
|
||||
self
|
||||
}
|
||||
|
||||
@@ -304,6 +336,22 @@ pub enum PageBackground {
|
||||
DotGrid,
|
||||
}
|
||||
|
||||
/// Persistenzstatus einer Seite zum Schutz vor unbeabsichtigtem Überschreiben bei Ladefehlern.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum PersistState {
|
||||
#[default]
|
||||
Normal,
|
||||
ReadOnlyAfterLoadError,
|
||||
}
|
||||
|
||||
/// Warnung beim Laden einer Seite (beschädigte Datei oder I/O-Fehler).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PageLoadWarning {
|
||||
pub page_id: u64,
|
||||
pub message: String,
|
||||
pub quarantine_path: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
/// Eine einzelne Notizbuchseite mit Strichen, Hintergrund und gecachten Meshes.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Page {
|
||||
@@ -314,9 +362,7 @@ pub struct Page {
|
||||
pub stroke_ranges: Vec<Range<u32>>,
|
||||
pub is_dirty: bool,
|
||||
pub is_loaded: bool,
|
||||
/// Laden von der Festplatte ist fehlgeschlagen; die Seite ist ein leerer Ersatz und
|
||||
/// darf niemals gespeichert werden, damit die Originaldatei nicht überschrieben wird.
|
||||
pub load_failed: bool,
|
||||
pub persist: PersistState,
|
||||
pub history: HistoryStack,
|
||||
}
|
||||
|
||||
@@ -330,7 +376,7 @@ impl Page {
|
||||
stroke_ranges: Vec::new(),
|
||||
is_dirty: false,
|
||||
is_loaded: true,
|
||||
load_failed: false,
|
||||
persist: PersistState::Normal,
|
||||
history: HistoryStack::new(),
|
||||
}
|
||||
}
|
||||
@@ -345,11 +391,19 @@ impl Page {
|
||||
stroke_ranges: Vec::new(),
|
||||
is_dirty: false,
|
||||
is_loaded: false,
|
||||
load_failed: false,
|
||||
persist: PersistState::Normal,
|
||||
history: HistoryStack::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_readonly(&self) -> bool {
|
||||
self.persist == PersistState::ReadOnlyAfterLoadError
|
||||
}
|
||||
|
||||
pub fn unlock_after_load_error(&mut self) {
|
||||
self.persist = PersistState::Normal;
|
||||
}
|
||||
|
||||
/// Fügt einen neuen Strich hinzu und registriert ihn im History-Stack.
|
||||
pub fn add_stroke(&mut self, stroke: Stroke) {
|
||||
self.history.push(HistoryAction::AddStroke(stroke.clone()));
|
||||
@@ -592,6 +646,7 @@ pub struct Notebook {
|
||||
pub current_page_index: usize,
|
||||
pub next_stroke_id: u64,
|
||||
pub next_page_id: u64,
|
||||
pub load_warnings: std::collections::HashMap<u64, PageLoadWarning>,
|
||||
}
|
||||
|
||||
impl Notebook {
|
||||
@@ -601,6 +656,7 @@ impl Notebook {
|
||||
current_page_index: 0,
|
||||
next_stroke_id: 1,
|
||||
next_page_id: 1,
|
||||
load_warnings: std::collections::HashMap::new(),
|
||||
};
|
||||
// Erste Standardseite anlegen (5mm Grid)
|
||||
nb.add_page(PageBackground::Grid5mm);
|
||||
@@ -619,6 +675,7 @@ impl Notebook {
|
||||
current_page_index,
|
||||
next_stroke_id,
|
||||
next_page_id,
|
||||
load_warnings: std::collections::HashMap::new(),
|
||||
};
|
||||
if nb.pages.is_empty() {
|
||||
nb.add_page(PageBackground::Grid5mm);
|
||||
@@ -715,7 +772,21 @@ pub struct PageViewport {
|
||||
impl PageViewport {
|
||||
/// Berechnet die zentrierte DIN-A4-Projektionsfläche unter Beibehaltung des 210x297-Seitenverhältnisses.
|
||||
pub fn calculate(window_width: f32, window_height: f32, margin: f32) -> Self {
|
||||
let avail_w = (window_width - 2.0 * margin).max(10.0);
|
||||
Self::calculate_with_insets(window_width, window_height, margin, 0.0, 0.0)
|
||||
}
|
||||
|
||||
/// Wie [`Self::calculate`], aber ohne die von seitlicher UI (z. B. Werkzeugleiste)
|
||||
/// belegten Streifen links/rechts: Die Seite wird im verbleibenden Bereich zentriert.
|
||||
pub fn calculate_with_insets(
|
||||
window_width: f32,
|
||||
window_height: f32,
|
||||
margin: f32,
|
||||
inset_left: f32,
|
||||
inset_right: f32,
|
||||
) -> Self {
|
||||
let inset_left = inset_left.clamp(0.0, window_width * 0.5);
|
||||
let inset_right = inset_right.clamp(0.0, window_width * 0.5);
|
||||
let avail_w = (window_width - inset_left - inset_right - 2.0 * margin).max(10.0);
|
||||
let avail_h = (window_height - 2.0 * margin).max(10.0);
|
||||
|
||||
let window_ratio = avail_w / avail_h;
|
||||
@@ -733,7 +804,7 @@ impl PageViewport {
|
||||
(w, h)
|
||||
};
|
||||
|
||||
let page_x = (window_width - page_w) / 2.0;
|
||||
let page_x = inset_left + (window_width - inset_left - inset_right - page_w) / 2.0;
|
||||
let page_y = (window_height - page_h) / 2.0;
|
||||
|
||||
Self {
|
||||
@@ -874,6 +945,25 @@ mod tests {
|
||||
assert_eq!(nb.current_page_index, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_viewport_insets_center_page_in_free_area() {
|
||||
// Breites Fenster: Seite wird durch die Höhe begrenzt und im freien Bereich zentriert
|
||||
let plain = PageViewport::calculate(1600.0, 900.0, 20.0);
|
||||
let inset = PageViewport::calculate_with_insets(1600.0, 900.0, 20.0, 120.0, 0.0);
|
||||
assert!((inset.page_width - plain.page_width).abs() < 0.01);
|
||||
let free_center = 120.0 + (1600.0 - 120.0) / 2.0;
|
||||
assert!((inset.page_x + inset.page_width / 2.0 - free_center).abs() < 0.01);
|
||||
|
||||
// Hohes Fenster: Breite begrenzt, Seite wird schmaler und bleibt rechts der Leiste
|
||||
let narrow = PageViewport::calculate_with_insets(600.0, 1200.0, 20.0, 100.0, 0.0);
|
||||
assert!(narrow.page_x >= 100.0 + 20.0 - 0.01);
|
||||
assert!(narrow.page_x + narrow.page_width <= 600.0 - 20.0 + 0.01);
|
||||
|
||||
// Leiste rechts
|
||||
let right = PageViewport::calculate_with_insets(600.0, 1200.0, 20.0, 0.0, 100.0);
|
||||
assert!(right.page_x + right.page_width <= 500.0 - 20.0 + 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_page_viewport_projection() {
|
||||
let vp = PageViewport::calculate(1600.0, 900.0, 20.0);
|
||||
|
||||
+3
-5
@@ -140,11 +140,9 @@ pub fn generate_pdf_document(
|
||||
return Err("Notizbuch enthält keine Seiten".into());
|
||||
}
|
||||
// Nicht geladene Seiten würden sonst stillschweigend als leere Seiten exportiert
|
||||
if let Some(page) = notebook
|
||||
.pages
|
||||
.iter()
|
||||
.find(|p| !p.is_loaded || p.load_failed)
|
||||
{
|
||||
if let Some(page) = notebook.pages.iter().find(|p| {
|
||||
!p.is_loaded || p.persist == crate::document::PersistState::ReadOnlyAfterLoadError
|
||||
}) {
|
||||
return Err(format!(
|
||||
"Seite {} ist nicht geladen – Export abgebrochen (vorher storage::load_all_pages aufrufen)",
|
||||
page.id
|
||||
|
||||
@@ -1411,6 +1411,9 @@ pub fn shape_to_stroke(
|
||||
let (samples, vertices) = shape_to_mesh(&shape, width_units, color);
|
||||
let mut stroke = Stroke::new(stroke_id, samples, vertices, color);
|
||||
stroke.shape = Some(shape);
|
||||
stroke.width = width_units;
|
||||
stroke.tool = crate::document::StrokeTool::Shape;
|
||||
stroke.finalized = true;
|
||||
stroke
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod filter;
|
||||
pub mod geometry;
|
||||
pub mod history;
|
||||
pub mod selection;
|
||||
pub mod settings;
|
||||
pub mod spline;
|
||||
pub mod storage;
|
||||
pub mod ui;
|
||||
|
||||
+432
-128
@@ -23,6 +23,7 @@ use ink_notes::geometry::{self, RecognizedShape};
|
||||
use ink_notes::selection::{
|
||||
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
|
||||
};
|
||||
use ink_notes::settings::{SidebarSide, UiSettings};
|
||||
use ink_notes::storage::{self, NotebookManifest};
|
||||
use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
|
||||
use ink_notes::updater::UpdaterController;
|
||||
@@ -54,6 +55,21 @@ fn update_window_title(
|
||||
window.set_title(&title);
|
||||
}
|
||||
|
||||
/// Prüft, ob ein Bildschirmpunkt (physische Pixel) auf einem UI-Element liegt.
|
||||
///
|
||||
/// egui arbeitet in logischen Punkten; Touch- und Stift-Positionen kommen in physischen
|
||||
/// Pixeln. `layer_id_at` erfasst jedes egui-Element des letzten Frames (Leiste, Popover,
|
||||
/// Fenster), auch wenn egui den Zeiger selbst noch nicht kennt (Stift, erster Touch).
|
||||
fn is_screen_point_in_ui(
|
||||
egui_ctx: &egui::Context,
|
||||
ui_state: &UiState,
|
||||
screen_pos: (f32, f32),
|
||||
scale: f32,
|
||||
) -> bool {
|
||||
let pos = egui::pos2(screen_pos.0 / scale, screen_pos.1 / scale);
|
||||
ui_state.is_point_in_ui(pos.x, pos.y) || egui_ctx.layer_id_at(pos).is_some()
|
||||
}
|
||||
|
||||
/// Exportiert das Notizbuch im Hintergrund als PDF. Noch nicht geladene Seiten werden vorher
|
||||
/// von der Festplatte nachgeladen; das Ergebnis geht über `result_tx` an den Event-Loop zurück.
|
||||
fn start_pdf_export(
|
||||
@@ -159,9 +175,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// 1. Notizbuch aus Storage laden oder initialisieren (Session-Wiederherstellung)
|
||||
let storage_base_dir = storage::default_storage_dir();
|
||||
let _ = storage::cleanup_trash(
|
||||
&storage_base_dir,
|
||||
std::time::Duration::from_secs(30 * 24 * 3600),
|
||||
);
|
||||
let available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
|
||||
let (initial_id, initial_title) = if let Some(first) = available_notebooks.first() {
|
||||
// Beschädigte Notizbücher nicht automatisch öffnen
|
||||
let (initial_id, initial_title) = if let Some(first) = available_notebooks
|
||||
.iter()
|
||||
.find(|s| s.status == storage::NotebookStatus::Normal)
|
||||
{
|
||||
(first.id.clone(), first.title.clone())
|
||||
} else {
|
||||
("default".to_string(), "Standard-Notizbuch".to_string())
|
||||
@@ -222,6 +246,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
);
|
||||
|
||||
let mut ui_state = UiState::new();
|
||||
// Gespeicherte UI-Einstellungen (Seite der Werkzeugleiste)
|
||||
let ui_settings_path = UiSettings::default_path();
|
||||
let mut ui_settings = ui_settings_path
|
||||
.as_deref()
|
||||
.map(UiSettings::load_from)
|
||||
.unwrap_or_default();
|
||||
ui_state.sidebar_side = ui_settings.sidebar_side;
|
||||
ui_state.current_notebook_id = manifest.id.clone();
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
ui_state.available_notebooks = available_notebooks;
|
||||
@@ -231,6 +262,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
let mut selection_state = SelectionState::new();
|
||||
|
||||
let mut _notebook_lock = match storage::NotebookLock::acquire(&storage_dir) {
|
||||
Ok(lock) => {
|
||||
autosave.set_read_only(false);
|
||||
Some(lock)
|
||||
}
|
||||
Err(storage::StorageError::AlreadyLocked(msg)) => {
|
||||
eprintln!("[Storage-Lock] {msg}");
|
||||
autosave.set_read_only(true);
|
||||
ui_state.set_export_message("⚠️ In anderer Instanz geöffnet (Schreibschutz)");
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[Storage-Lock] Konnte Lock nicht erwerben: {e}");
|
||||
autosave.set_read_only(true);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Initiale Seite und Werkzeugeinstellungen mit Canvas synchronisieren
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
canvas.set_stroke_color(ui_state.pen_color);
|
||||
@@ -249,6 +298,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut active_touches: std::collections::HashMap<u64, (f32, f32)> =
|
||||
std::collections::HashMap::new();
|
||||
let mut pinch_prev_distance: Option<f32> = None;
|
||||
// Berührungen, die auf der UI begonnen haben, gehören bis zum Abheben egui
|
||||
let mut ui_touch_ids: std::collections::HashSet<u64> = std::collections::HashSet::new();
|
||||
let mut pinch_prev_center: Option<(f32, f32)> = None;
|
||||
let mut is_pinch_gesturing = false;
|
||||
let mut is_touch_drawing = false;
|
||||
@@ -261,6 +312,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut stylus_tool_id: Option<octotablet::tool::ID> = None;
|
||||
let mut stylus_barrel_pressed = false;
|
||||
let mut stylus_screen_pos = (0.0_f32, 0.0_f32);
|
||||
// Stift hat auf der UI aufgesetzt: alle Ereignisse bis zum Abheben gehen an egui
|
||||
let mut stylus_ui_capture = false;
|
||||
// Von egui angeforderter Zeitpunkt für das nächste Neuzeichnen (Layout-Vermessung, Tooltips)
|
||||
let mut egui_repaint_at: Option<Instant> = None;
|
||||
|
||||
// Viewport-Pan & Zoom-Zustand
|
||||
let mut is_panning = false;
|
||||
@@ -353,6 +408,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
octotablet::events::ToolEvent::Out
|
||||
| octotablet::events::ToolEvent::Removed => {
|
||||
is_stylus_in_proximity = false;
|
||||
if stylus_ui_capture {
|
||||
stylus_ui_capture = false;
|
||||
let (sx, sy) = stylus_screen_pos;
|
||||
let input = egui_winit_state.egui_input_mut();
|
||||
input.events.push(egui::Event::PointerButton {
|
||||
pos: egui::pos2(sx / scale, sy / scale),
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: false,
|
||||
modifiers: egui::Modifiers::default(),
|
||||
});
|
||||
input.events.push(egui::Event::PointerGone);
|
||||
window.request_redraw();
|
||||
}
|
||||
if stylus_tool_id == Some(tool.id()) {
|
||||
stylus_tool_id = None;
|
||||
if is_stylus_drawing {
|
||||
@@ -400,6 +468,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
current_pressure = p;
|
||||
}
|
||||
|
||||
// Stiftbewegung an egui weitergeben (Hover, Schieberegler ziehen),
|
||||
// solange nicht auf dem Blatt gezeichnet oder radiert wird
|
||||
if stylus_ui_capture || (!is_stylus_drawing && !is_erasing) {
|
||||
let ui_pos = egui::pos2(sx / scale, sy / scale);
|
||||
egui_winit_state
|
||||
.egui_input_mut()
|
||||
.events
|
||||
.push(egui::Event::PointerMoved(ui_pos));
|
||||
if stylus_ui_capture
|
||||
|| is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale)
|
||||
{
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
if stylus_ui_capture {
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_erasing {
|
||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||
let page = notebook.current_page_mut();
|
||||
@@ -410,7 +496,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(
|
||||
&window,
|
||||
¬ebook,
|
||||
@@ -446,28 +532,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
stylus_tool_id = Some(tool.id());
|
||||
let (sx, sy) = stylus_screen_pos;
|
||||
|
||||
let is_stylus_in_ui = egui_ctx.wants_pointer_input()
|
||||
|| ui_state.is_point_in_ui(sx, sy)
|
||||
|| (ui_state.show_page_jump_popup
|
||||
&& egui_ctx.is_pointer_over_area())
|
||||
|| (ui_state.is_document_menu_open
|
||||
&& egui_ctx.is_pointer_over_area())
|
||||
|| (ui_state.is_update_dialog_open
|
||||
&& egui_ctx.is_pointer_over_area());
|
||||
|
||||
if is_stylus_in_ui {
|
||||
egui_ctx.input_mut(|i| {
|
||||
i.events.push(egui::Event::PointerMoved(egui::pos2(
|
||||
sx / scale,
|
||||
sy / scale,
|
||||
)));
|
||||
i.events.push(egui::Event::PointerButton {
|
||||
pos: egui::pos2(sx / scale, sy / scale),
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: true,
|
||||
modifiers: egui::Modifiers::default(),
|
||||
});
|
||||
if is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale) {
|
||||
// In die Eingabe-Warteschlange des NÄCHSTEN egui-Frames
|
||||
// (egui_ctx.input_mut würde vom nächsten Frame überschrieben)
|
||||
let ui_pos = egui::pos2(sx / scale, sy / scale);
|
||||
let input = egui_winit_state.egui_input_mut();
|
||||
input.events.push(egui::Event::PointerMoved(ui_pos));
|
||||
input.events.push(egui::Event::PointerButton {
|
||||
pos: ui_pos,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: true,
|
||||
modifiers: egui::Modifiers::default(),
|
||||
});
|
||||
stylus_ui_capture = true;
|
||||
window.request_redraw();
|
||||
continue;
|
||||
}
|
||||
@@ -487,7 +564,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(
|
||||
&window,
|
||||
¬ebook,
|
||||
@@ -520,24 +597,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
octotablet::events::ToolEvent::Up => {
|
||||
let (sx, sy) = stylus_screen_pos;
|
||||
let is_stylus_in_ui = egui_ctx.wants_pointer_input()
|
||||
|| ui_state.is_point_in_ui(sx, sy)
|
||||
|| (ui_state.show_page_jump_popup
|
||||
&& egui_ctx.is_pointer_over_area())
|
||||
|| (ui_state.is_document_menu_open
|
||||
&& egui_ctx.is_pointer_over_area())
|
||||
|| (ui_state.is_update_dialog_open
|
||||
&& egui_ctx.is_pointer_over_area());
|
||||
|
||||
if is_stylus_in_ui {
|
||||
egui_ctx.input_mut(|i| {
|
||||
i.events.push(egui::Event::PointerButton {
|
||||
if stylus_ui_capture {
|
||||
stylus_ui_capture = false;
|
||||
egui_winit_state.egui_input_mut().events.push(
|
||||
egui::Event::PointerButton {
|
||||
pos: egui::pos2(sx / scale, sy / scale),
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: false,
|
||||
modifiers: egui::Modifiers::default(),
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
window.request_redraw();
|
||||
continue;
|
||||
}
|
||||
@@ -607,6 +676,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
|
||||
// Von egui angefordertes, verzögertes Neuzeichnen (Tooltips, Animationen)
|
||||
if let Some(at) = egui_repaint_at {
|
||||
if Instant::now() >= at {
|
||||
egui_repaint_at = None;
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
// Bei anstehenden Änderungen, aktivem Toast, Stylus in Proximity oder aktivem Zeichnen regelmäßig aufwachen
|
||||
if new_status == AutosaveStatus::Pending
|
||||
|| new_status == AutosaveStatus::Saving
|
||||
@@ -616,6 +693,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|| is_stylus_in_proximity
|
||||
{
|
||||
target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8)));
|
||||
} else if let Some(at) = egui_repaint_at {
|
||||
target.set_control_flow(ControlFlow::WaitUntil(at));
|
||||
} else {
|
||||
target.set_control_flow(ControlFlow::Wait);
|
||||
}
|
||||
@@ -631,15 +710,26 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// 2. Prüfen, ob das UI Pointer-Interaktionen beansprucht (Event-Isolation)
|
||||
// Konsumiert Klicks innerhalb der abgerundeten Pill-Toolbar, des Popovers oder offener Menüs
|
||||
let is_pointer_in_ui = egui_ctx.wants_pointer_input()
|
||||
|| ui_state.is_point_in_ui(cursor_screen_pos.0, cursor_screen_pos.1)
|
||||
|| (ui_state.show_page_jump_popup && egui_ctx.is_pointer_over_area())
|
||||
|| (ui_state.is_document_menu_open && egui_ctx.is_pointer_over_area());
|
||||
|| is_screen_point_in_ui(
|
||||
&egui_ctx,
|
||||
&ui_state,
|
||||
cursor_screen_pos,
|
||||
window.scale_factor() as f32,
|
||||
);
|
||||
|
||||
match event {
|
||||
WindowEvent::CloseRequested => {
|
||||
println!("[Shutdown] Sichere alle ungespeicherten Daten auf Festplatte...");
|
||||
autosave.shutdown();
|
||||
target.exit();
|
||||
match autosave.shutdown() {
|
||||
Ok(()) => {
|
||||
target.exit();
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("[Shutdown-Fehler] Konnte nicht geordnet beenden: {err}");
|
||||
ui_state.shutdown_error = Some(err);
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
WindowEvent::Focused(false) => {
|
||||
@@ -652,6 +742,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
is_erasing = false;
|
||||
is_mouse_drawing = false;
|
||||
active_touches.clear();
|
||||
ui_touch_ids.clear();
|
||||
stylus_ui_capture = false;
|
||||
is_pinch_gesturing = false;
|
||||
pinch_prev_distance = None;
|
||||
pinch_prev_center = None;
|
||||
@@ -708,7 +800,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
match &logical_key {
|
||||
Key::Named(NamedKey::Escape) => {
|
||||
let mut changed = false;
|
||||
if ui_state.is_pen_menu_open
|
||||
if ui_state.shutdown_error.is_some() {
|
||||
ui_state.shutdown_error = None;
|
||||
changed = true;
|
||||
} else if ui_state.is_pen_menu_open
|
||||
|| ui_state.is_highlighter_menu_open
|
||||
|| ui_state.show_page_jump_popup
|
||||
|| ui_state.is_document_menu_open
|
||||
@@ -737,7 +832,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if notebook.current_page_mut().undo() {
|
||||
selection_state.clear();
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -751,7 +846,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if notebook.current_page_mut().redo() {
|
||||
selection_state.clear();
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -781,7 +876,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if !new_ids.is_empty() {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
manifest.next_stroke_id = notebook.next_stroke_id;
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len()));
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -793,7 +888,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let removed = selection_state.delete_selected(notebook.current_page_mut());
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
ui_state.set_export_message(format!("{} Objekt(e) gelöscht", removed.len()));
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -802,9 +897,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Jetzt speichern (Disk Flush): Strg + S
|
||||
Key::Character(c) if c.eq_ignore_ascii_case("s") && ctrl => {
|
||||
match autosave.save_now(¬ebook, &manifest) {
|
||||
Ok(()) => ui_state.set_export_message("Gespeichert"),
|
||||
Err(err) => ui_state.set_export_message(format!("Speichern fehlgeschlagen: {err}")),
|
||||
match autosave.flush() {
|
||||
Ok(()) => {
|
||||
ui_state.set_export_message("Gespeichert");
|
||||
}
|
||||
Err(err) => {
|
||||
ui_state.set_export_message(format!(
|
||||
"Speicherfehler: {}",
|
||||
err.messages.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
update_window_title(
|
||||
&window,
|
||||
@@ -855,7 +957,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let color = [0.12, 0.12, 0.14, 1.0];
|
||||
if selection_state.recolor_selected(notebook.current_page_mut(), color) {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
ui_state.set_export_message("Auswahl auf Schwarz umgefärbt".to_string());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -875,7 +977,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let color = [0.15, 0.35, 0.85, 1.0];
|
||||
if selection_state.recolor_selected(notebook.current_page_mut(), color) {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
ui_state.set_export_message("Auswahl auf Blau umgefärbt".to_string());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -894,7 +996,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if !selection_state.is_empty() {
|
||||
if selection_state.rotate_selected_90_deg(notebook.current_page_mut()) {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
ui_state.set_export_message("Auswahl um 90° gedreht".to_string());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -918,7 +1020,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
PageBackground::Blank => PageBackground::Grid5mm,
|
||||
};
|
||||
canvas.sync_page(page);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -932,7 +1034,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
manifest.current_page_index = notebook.current_page_index;
|
||||
manifest.next_page_id = notebook.next_page_id;
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
manifest.sync_from_notebook(¬ebook);
|
||||
autosave.mark_manifest_dirty(&manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -944,7 +1048,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
manifest.current_page_index = notebook.current_page_index;
|
||||
manifest.next_page_id = notebook.next_page_id;
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
manifest.sync_from_notebook(¬ebook);
|
||||
autosave.mark_manifest_dirty(&manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -957,7 +1063,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr);
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
manifest.current_page_index = notebook.current_page_index;
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
manifest.sync_from_notebook(¬ebook);
|
||||
autosave.mark_manifest_dirty(&manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -970,7 +1078,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr);
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
manifest.current_page_index = notebook.current_page_index;
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
manifest.sync_from_notebook(¬ebook);
|
||||
autosave.mark_manifest_dirty(&manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -983,7 +1093,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr);
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
manifest.current_page_index = notebook.current_page_index;
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
manifest.sync_from_notebook(¬ebook);
|
||||
autosave.mark_manifest_dirty(&manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1023,8 +1135,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Beenden: Strg + Q
|
||||
Key::Character(c) if c.eq_ignore_ascii_case("q") && ctrl => {
|
||||
println!("[Shutdown] Sichere alle ungespeicherten Daten auf Festplatte...");
|
||||
autosave.shutdown();
|
||||
target.exit();
|
||||
match autosave.shutdown() {
|
||||
Ok(()) => {
|
||||
target.exit();
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("[Shutdown-Fehler] Konnte nicht geordnet beenden: {err}");
|
||||
ui_state.shutdown_error = Some(err);
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1064,7 +1184,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1162,7 +1282,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let new_stroke = geometry::shape_to_stroke(shape, old_id, stroke_color, stroke_width);
|
||||
notebook.current_page_mut().replace_stroke(old_id, new_stroke);
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
ui_state.set_export_message(format!("{shape_name} ausgerichtet! (Undo mit Strg+Z)"));
|
||||
window.request_redraw();
|
||||
return;
|
||||
@@ -1219,7 +1339,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let final_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids);
|
||||
if !initial_strokes.is_empty() && !final_strokes.is_empty() && initial_strokes != final_strokes {
|
||||
notebook.current_page_mut().commit_modified_strokes(initial_strokes, final_strokes);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1332,7 +1452,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1407,15 +1527,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Berührungen der UI (egui-winit hat sie bereits an egui weitergegeben)
|
||||
if ui_touch_ids.contains(&touch.id) {
|
||||
if matches!(touch.phase, TouchPhase::Ended | TouchPhase::Cancelled) {
|
||||
ui_touch_ids.remove(&touch.id);
|
||||
}
|
||||
window.request_redraw();
|
||||
return;
|
||||
}
|
||||
|
||||
match touch.phase {
|
||||
TouchPhase::Started => {
|
||||
// Event-Isolation für Touch-Eingaben
|
||||
let is_touch_in_ui = egui_ctx.wants_pointer_input()
|
||||
|| ui_state.is_point_in_ui(tx, ty)
|
||||
|| (ui_state.show_page_jump_popup && egui_ctx.is_pointer_over_area())
|
||||
|| (ui_state.is_document_menu_open && egui_ctx.is_pointer_over_area())
|
||||
|| (ui_state.is_update_dialog_open && egui_ctx.is_pointer_over_area());
|
||||
let is_touch_in_ui = is_screen_point_in_ui(
|
||||
&egui_ctx,
|
||||
&ui_state,
|
||||
(tx, ty),
|
||||
window.scale_factor() as f32,
|
||||
);
|
||||
if is_touch_in_ui {
|
||||
ui_touch_ids.insert(touch.id);
|
||||
window.request_redraw();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1456,7 +1588,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1597,7 +1729,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1688,7 +1820,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let final_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids);
|
||||
if !initial_strokes.is_empty() && !final_strokes.is_empty() && initial_strokes != final_strokes {
|
||||
notebook.current_page_mut().commit_modified_strokes(initial_strokes, final_strokes);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1745,6 +1877,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
updater.update();
|
||||
let actions = ui::render_ui(&egui_ctx, &mut ui_state, ¬ebook, autosave.status(), &updater);
|
||||
|
||||
// Seite neben der Werkzeugleiste zentrieren (Leistenbreite in physischen Pixeln)
|
||||
let reserved = ui_state.sidebar_reserved_width() * window.scale_factor() as f32;
|
||||
let (inset_left, inset_right) = match ui_state.sidebar_side {
|
||||
SidebarSide::Left => (reserved, 0.0),
|
||||
SidebarSide::Right => (0.0, reserved),
|
||||
};
|
||||
if canvas.set_ui_insets(inset_left, inset_right) {
|
||||
window.request_redraw();
|
||||
}
|
||||
|
||||
// 4. UI-Aktionen verarbeiten
|
||||
for action in actions {
|
||||
match action {
|
||||
@@ -1772,7 +1914,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if !selection_state.is_empty()
|
||||
&& selection_state.recolor_selected(notebook.current_page_mut(), color) {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
ui_state.set_export_message("Auswahl umgefärbt".to_string());
|
||||
}
|
||||
match ui_state.active_tool {
|
||||
@@ -1807,7 +1949,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let removed = selection_state.delete_selected(notebook.current_page_mut());
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
ui_state.set_export_message(format!("{} Objekt(e) gelöscht", removed.len()));
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1834,7 +1976,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if !new_ids.is_empty() {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
manifest.next_stroke_id = notebook.next_stroke_id;
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len()));
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1843,7 +1985,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if !selection_state.is_empty()
|
||||
&& selection_state.rotate_selected_90_deg(notebook.current_page_mut()) {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
ui_state.set_export_message("Auswahl um 90° gedreht".to_string());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1871,7 +2013,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if notebook.current_page_mut().undo() {
|
||||
selection_state.clear();
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1880,7 +2022,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if notebook.current_page_mut().redo() {
|
||||
selection_state.clear();
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1892,7 +2034,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr);
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
manifest.current_page_index = notebook.current_page_index;
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
manifest.sync_from_notebook(¬ebook);
|
||||
autosave.mark_manifest_dirty(&manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1904,7 +2048,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
manifest.current_page_index = notebook.current_page_index;
|
||||
manifest.next_page_id = notebook.next_page_id;
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
manifest.pages = notebook
|
||||
.pages
|
||||
.iter()
|
||||
.map(|p| storage::PageMeta {
|
||||
id: p.id,
|
||||
background: Some(p.background),
|
||||
})
|
||||
.collect();
|
||||
manifest.sync_from_notebook(¬ebook);
|
||||
autosave.mark_manifest_dirty(&manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1914,8 +2068,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
notebook.add_page(bg);
|
||||
manifest.current_page_index = notebook.current_page_index;
|
||||
manifest.next_page_id = notebook.next_page_id;
|
||||
manifest.pages = notebook
|
||||
.pages
|
||||
.iter()
|
||||
.map(|p| storage::PageMeta {
|
||||
id: p.id,
|
||||
background: Some(p.background),
|
||||
})
|
||||
.collect();
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
manifest.sync_from_notebook(¬ebook);
|
||||
autosave.mark_manifest_dirty(&manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1926,7 +2090,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, idx);
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
manifest.current_page_index = idx;
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
manifest.sync_from_notebook(¬ebook);
|
||||
autosave.mark_manifest_dirty(&manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1935,7 +2101,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let page = notebook.current_page_mut();
|
||||
page.background = bg;
|
||||
canvas.sync_page(page);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1957,9 +2123,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let new_notebook_dir = storage_base_dir.join(&id);
|
||||
match storage::load_notebook(&new_notebook_dir) {
|
||||
Ok((loaded_nb, loaded_manifest)) => {
|
||||
if let Err(err) = autosave.switch_notebook_dir(new_notebook_dir.clone()) {
|
||||
eprintln!("[Autosave-Fehler] Vorheriges Notizbuch nicht vollständig gespeichert: {err}");
|
||||
}
|
||||
let _ = autosave.flush();
|
||||
match storage::NotebookLock::acquire(&new_notebook_dir) {
|
||||
Ok(lock) => {
|
||||
_notebook_lock = Some(lock);
|
||||
autosave.set_read_only(false);
|
||||
}
|
||||
Err(storage::StorageError::AlreadyLocked(msg)) => {
|
||||
eprintln!("[Storage-Lock] {msg}");
|
||||
_notebook_lock = None;
|
||||
autosave.set_read_only(true);
|
||||
ui_state.set_export_message("⚠️ In anderer Instanz geöffnet (Schreibschutz)");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[Storage-Lock] Konnte Lock nicht erwerben: {e}");
|
||||
_notebook_lock = None;
|
||||
autosave.set_read_only(true);
|
||||
}
|
||||
}
|
||||
autosave.switch_notebook_dir(new_notebook_dir.clone());
|
||||
notebook = loaded_nb;
|
||||
manifest = loaded_manifest;
|
||||
storage_dir = new_notebook_dir;
|
||||
@@ -1970,7 +2152,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
ui_state.is_document_menu_open = false;
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
ui_state.set_export_message(format!("Dokument '{}' geöffnet", manifest.title));
|
||||
if _notebook_lock.is_some() {
|
||||
ui_state.set_export_message(format!("Dokument '{}' geöffnet", manifest.title));
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -1981,11 +2165,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
UiAction::CreateNotebook(title) => {
|
||||
let _ = autosave.flush();
|
||||
match storage::create_new_notebook(&storage_base_dir, &title) {
|
||||
Ok((new_nb, new_manifest, new_dir)) => {
|
||||
if let Err(err) = autosave.switch_notebook_dir(new_dir.clone()) {
|
||||
eprintln!("[Autosave-Fehler] Vorheriges Notizbuch nicht vollständig gespeichert: {err}");
|
||||
}
|
||||
_notebook_lock = storage::NotebookLock::acquire(&new_dir).ok();
|
||||
autosave.set_read_only(_notebook_lock.is_none());
|
||||
autosave.switch_notebook_dir(new_dir.clone());
|
||||
notebook = new_nb;
|
||||
manifest = new_manifest;
|
||||
storage_dir = new_dir;
|
||||
@@ -2012,6 +2197,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(()) => {
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
manifest.sync_from_notebook(¬ebook);
|
||||
autosave.mark_manifest_dirty(&manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
ui_state.set_export_message(format!("Umbenannt in '{}'", new_title));
|
||||
window.request_redraw();
|
||||
@@ -2036,54 +2223,66 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
UiAction::DeleteNotebook(id) => {
|
||||
let target_dir = storage_base_dir.join(&id);
|
||||
let is_current = id == manifest.id;
|
||||
let deleted_title = ui_state
|
||||
.available_notebooks
|
||||
.iter()
|
||||
.find(|s| s.id == id)
|
||||
.map(|s| s.title.clone())
|
||||
.unwrap_or_else(|| id.clone());
|
||||
|
||||
if is_current {
|
||||
// Ausstehende Autosave-Schreibvorgänge VOR dem Löschen abarbeiten,
|
||||
// sonst legen sie den gelöschten Ordner danach wieder an.
|
||||
let _ = autosave.flush();
|
||||
drop(_notebook_lock.take());
|
||||
}
|
||||
match storage::delete_notebook(&target_dir) {
|
||||
Ok(()) => {
|
||||
|
||||
match storage::delete_notebook(&target_dir, &storage_base_dir) {
|
||||
Ok(trash_path) => {
|
||||
ui_state.last_deleted_notebook = Some((trash_path, deleted_title));
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
if is_current {
|
||||
// Erstes ladbares Notizbuch öffnen, sonst ein neues anlegen
|
||||
let replacement = ui_state
|
||||
if let Some(first) = ui_state
|
||||
.available_notebooks
|
||||
.iter()
|
||||
.find_map(|summary| {
|
||||
let dir = storage_base_dir.join(&summary.id);
|
||||
match storage::load_notebook(&dir) {
|
||||
Ok((nb, m)) => Some((nb, m, dir)),
|
||||
Err(err) => {
|
||||
eprintln!("[Dokument-Fehler] Konnte Dokument '{}' nicht laden: {err}", summary.id);
|
||||
None
|
||||
}
|
||||
.find(|s| s.status != storage::NotebookStatus::Damaged)
|
||||
.cloned()
|
||||
{
|
||||
let new_notebook_dir = storage_base_dir.join(&first.id);
|
||||
match storage::load_notebook(&new_notebook_dir) {
|
||||
Ok((loaded_nb, loaded_manifest)) => {
|
||||
_notebook_lock = storage::NotebookLock::acquire(&new_notebook_dir).ok();
|
||||
autosave.set_read_only(_notebook_lock.is_none());
|
||||
autosave.switch_notebook_dir(new_notebook_dir.clone());
|
||||
notebook = loaded_nb;
|
||||
manifest = loaded_manifest;
|
||||
storage_dir = new_notebook_dir;
|
||||
selection_state.clear();
|
||||
ui_state.current_notebook_id = manifest.id.clone();
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
ui_state.set_export_message(format!("Gewechselt zu '{}'", manifest.title));
|
||||
}
|
||||
})
|
||||
.map(Ok)
|
||||
.unwrap_or_else(|| storage::create_new_notebook(&storage_base_dir, "Standard-Notizbuch"));
|
||||
match replacement {
|
||||
Ok((new_nb, new_manifest, new_dir)) => {
|
||||
if let Err(err) = autosave.switch_notebook_dir(new_dir.clone()) {
|
||||
eprintln!("[Autosave-Fehler] Vorheriges Notizbuch nicht vollständig gespeichert: {err}");
|
||||
Err(err) => {
|
||||
eprintln!("[Dokument-Fehler] Konnte Dokument '{}' nicht laden: {err}", first.id);
|
||||
}
|
||||
notebook = new_nb;
|
||||
manifest = new_manifest;
|
||||
storage_dir = new_dir;
|
||||
selection_state.clear();
|
||||
ui_state.current_notebook_id = manifest.id.clone();
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
ui_state.set_export_message(format!("Gewechselt zu '{}'", manifest.title));
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("[Dokument-Fehler] Konnte kein Ersatz-Notizbuch anlegen: {err}");
|
||||
ui_state.set_export_message(format!("Dokument gelöscht, aber kein Ersatz-Notizbuch: {err}"));
|
||||
}
|
||||
} else if let Ok((new_nb, new_manifest, new_dir)) = storage::create_new_notebook(&storage_base_dir, "Standard-Notizbuch") {
|
||||
_notebook_lock = storage::NotebookLock::acquire(&new_dir).ok();
|
||||
autosave.set_read_only(_notebook_lock.is_none());
|
||||
autosave.switch_notebook_dir(new_dir.clone());
|
||||
notebook = new_nb;
|
||||
manifest = new_manifest;
|
||||
storage_dir = new_dir;
|
||||
selection_state.clear();
|
||||
ui_state.current_notebook_id = manifest.id.clone();
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
ui_state.set_export_message("Standard-Notizbuch erstellt".to_string());
|
||||
}
|
||||
} else {
|
||||
ui_state.set_export_message("Dokument gelöscht".to_string());
|
||||
ui_state.set_export_message("Dokument in Papierkorb verschoben".to_string());
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -2093,6 +2292,40 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
}
|
||||
UiAction::UndoDeleteNotebook => {
|
||||
if let Some((trash_path, _title)) = ui_state.last_deleted_notebook.take() {
|
||||
match storage::restore_notebook_from_trash(&storage_base_dir, &trash_path) {
|
||||
Ok(_restored_dir) => {
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
ui_state.set_export_message("Notizbuch wiederhergestellt".to_string());
|
||||
window.request_redraw();
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("[Dokument-Fehler] Konnte Notizbuch nicht wiederherstellen: {err}");
|
||||
ui_state.set_export_message(format!("Fehler beim Wiederherstellen: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
UiAction::RestoreNotebookBackup(id) => {
|
||||
let target_dir = storage_base_dir.join(&id);
|
||||
match storage::restore_manifest_from_backup(&target_dir) {
|
||||
Ok(()) => {
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
ui_state.set_export_message("Manifest aus Backup wiederhergestellt".to_string());
|
||||
window.request_redraw();
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("[Dokument-Fehler] Konnte Backup für '{id}' nicht wiederherstellen: {err}");
|
||||
ui_state.set_export_message(format!("Wiederherstellung fehlgeschlagen: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
UiAction::OpenNotebookFolder(id) => {
|
||||
let target_dir = storage_base_dir.join(&id);
|
||||
ui_state.set_export_message(format!("Ordner: {}", target_dir.display()));
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::CheckForUpdates => {
|
||||
updater.check_for_updates();
|
||||
window.request_redraw();
|
||||
@@ -2118,6 +2351,61 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
ui_state.set_export_message("Server-Einstellungen gespeichert");
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::UnlockCorruptedPage(page_id) => {
|
||||
notebook.load_warnings.remove(&page_id);
|
||||
if let Some(page) = notebook.pages.iter_mut().find(|p| p.id == page_id) {
|
||||
page.unlock_after_load_error();
|
||||
}
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::RetryShutdown => {
|
||||
println!("[Shutdown] Wiederhole Speichern vor Beenden...");
|
||||
match autosave.shutdown() {
|
||||
Ok(()) => {
|
||||
ui_state.shutdown_error = None;
|
||||
target.exit();
|
||||
}
|
||||
Err(err) => {
|
||||
ui_state.shutdown_error = Some(err);
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
UiAction::ExportRecoveryAndExit => {
|
||||
println!("[Shutdown] Starte Notfall-Export aller ungespeicherten Daten...");
|
||||
match autosave.export_recovery(Some(¬ebook)) {
|
||||
Ok(paths) => {
|
||||
eprintln!(
|
||||
"[Recovery] Notfall-Export erfolgreich geschrieben ({} Dateien): {:?}",
|
||||
paths.len(),
|
||||
paths
|
||||
);
|
||||
target.exit();
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("[Recovery-Fehler] Konnte Notfall-Export nicht schreiben: {err}");
|
||||
if let Some(ref mut se) = ui_state.shutdown_error {
|
||||
se.messages.push(format!("Notfall-Export fehlgeschlagen: {err}"));
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
UiAction::SetSidebarSide(side) => {
|
||||
ui_settings.sidebar_side = side;
|
||||
if let Some(path) = ui_settings_path.as_deref() {
|
||||
if let Err(err) = ui_settings.save_to(path) {
|
||||
eprintln!("[Einstellungen] Konnte '{}' nicht speichern: {err}", path.display());
|
||||
ui_state.set_export_message(format!("Einstellung nicht gespeichert: {err}"));
|
||||
}
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::ForceExit => {
|
||||
println!("[Shutdown] Beenden erzwungen trotz ungespeicherter Änderungen.");
|
||||
target.exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2125,6 +2413,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let full_output = egui_ctx.end_frame();
|
||||
egui_winit_state.handle_platform_output(&window, full_output.platform_output);
|
||||
|
||||
// egui braucht Folge-Frames, z. B. um neu angeordnete Bereiche zu vermessen
|
||||
// (die Werkzeugleiste wird erst im 2.–3. Frame korrekt zentriert)
|
||||
let repaint_delay = full_output
|
||||
.viewport_output
|
||||
.get(&egui::ViewportId::ROOT)
|
||||
.map(|v| v.repaint_delay)
|
||||
.unwrap_or(Duration::MAX);
|
||||
if repaint_delay.is_zero() {
|
||||
egui_repaint_at = None;
|
||||
window.request_redraw();
|
||||
} else if repaint_delay < Duration::from_secs(10) {
|
||||
egui_repaint_at = Some(Instant::now() + repaint_delay);
|
||||
} else {
|
||||
egui_repaint_at = None;
|
||||
}
|
||||
|
||||
let clipped_primitives = egui_ctx.tessellate(full_output.shapes, egui_ctx.pixels_per_point());
|
||||
let screen_descriptor = egui_wgpu::ScreenDescriptor {
|
||||
size_in_pixels: [canvas.size.width, canvas.size.height],
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Persistente UI-Einstellungen (`$XDG_CONFIG_HOME/ink-notes/ui.json`).
|
||||
//!
|
||||
//! Fehlende oder unlesbare Dateien führen zu Standardwerten, damit die App immer startet.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::storage::{self, StorageError};
|
||||
|
||||
/// Fensterseite, an der die Werkzeugleiste angedockt ist.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum SidebarSide {
|
||||
/// Links (Standard: die Schreibhand von Rechtshändern verdeckt die Leiste nicht)
|
||||
#[default]
|
||||
Left,
|
||||
/// Rechts (für Linkshänder)
|
||||
Right,
|
||||
}
|
||||
|
||||
/// Benutzereinstellungen der Oberfläche.
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct UiSettings {
|
||||
pub sidebar_side: SidebarSide,
|
||||
}
|
||||
|
||||
impl UiSettings {
|
||||
/// Standardpfad gemäß XDG-Spezifikation.
|
||||
pub fn default_path() -> Option<PathBuf> {
|
||||
let base = match std::env::var("XDG_CONFIG_HOME") {
|
||||
Ok(dir) if !dir.trim().is_empty() => PathBuf::from(dir),
|
||||
_ => PathBuf::from(std::env::var("HOME").ok()?).join(".config"),
|
||||
};
|
||||
Some(base.join("ink-notes").join("ui.json"))
|
||||
}
|
||||
|
||||
/// Lädt die Einstellungen; bei fehlender oder beschädigter Datei gelten Standardwerte.
|
||||
pub fn load_from(path: &Path) -> Self {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
|
||||
eprintln!(
|
||||
"[Einstellungen] '{}' ist ungültig ({e}), verwende Standardwerte.",
|
||||
path.display()
|
||||
);
|
||||
Self::default()
|
||||
}),
|
||||
Err(_) => Self::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Speichert die Einstellungen atomar.
|
||||
pub fn save_to(&self, path: &Path) -> Result<(), StorageError> {
|
||||
storage::atomic_write_json(path, self)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_settings_round_trip_and_fallbacks() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("ink-notes").join("ui.json");
|
||||
|
||||
// Fehlende Datei -> Standard (links)
|
||||
assert_eq!(UiSettings::load_from(&path).sidebar_side, SidebarSide::Left);
|
||||
|
||||
let settings = UiSettings {
|
||||
sidebar_side: SidebarSide::Right,
|
||||
};
|
||||
settings.save_to(&path).unwrap();
|
||||
assert_eq!(UiSettings::load_from(&path), settings);
|
||||
|
||||
// Beschädigte Datei -> Standard statt Absturz
|
||||
std::fs::write(&path, b"{ kaputt").unwrap();
|
||||
assert_eq!(UiSettings::load_from(&path), UiSettings::default());
|
||||
|
||||
// Unbekannte / fehlende Felder werden toleriert
|
||||
std::fs::write(&path, br#"{"zukunft": 1}"#).unwrap();
|
||||
assert_eq!(UiSettings::load_from(&path), UiSettings::default());
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -105,6 +105,18 @@ impl Default for SplineConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl SplineConfig {
|
||||
/// Erzeugt eine Spline-Konfiguration aus der Strichbreite in internen Einheiten (10 Einheiten = 1 mm).
|
||||
pub fn from_width_units(width_units: f32) -> Self {
|
||||
let base_radius = (width_units / 2.0).max(0.25);
|
||||
Self {
|
||||
min_radius: (base_radius * 0.5).max(0.15),
|
||||
max_radius: (base_radius * 1.5).max(0.35),
|
||||
samples_per_segment: 16,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Berechnet den druckabhängigen Radius r(p) = min_r + p * (max_r - min_r).
|
||||
pub fn calculate_radius(pressure: f32, min_radius: f32, max_radius: f32) -> f32 {
|
||||
let p = pressure.clamp(0.0, 1.0);
|
||||
@@ -653,7 +665,10 @@ pub fn finalize_stroke_with_config(
|
||||
.map(|v| InkVertex::new(v.position[0], v.position[1], color))
|
||||
.collect();
|
||||
|
||||
Stroke::new(stroke_id, decimated, ink_vertices, color)
|
||||
let mut stroke = Stroke::new(stroke_id, decimated, ink_vertices, color);
|
||||
stroke.width = config.min_radius + config.max_radius;
|
||||
stroke.finalized = true;
|
||||
stroke
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+1446
-307
File diff suppressed because it is too large.
Load diff
+2
-1
@@ -317,7 +317,8 @@ pub fn verify_checksum(
|
||||
/// signiert sein muss. Ist er leer, lehnt diese Version alle In-App-Updates ab.
|
||||
/// Erzeugen: `minisign -G -W -p ink-notes.pub -s ink-notes.key`, dann die zweite Zeile
|
||||
/// aus `ink-notes.pub` hier eintragen (siehe README, Abschnitt Release-Signatur).
|
||||
pub const UPDATE_SIGNING_PUBLIC_KEY: &str = "";
|
||||
pub const UPDATE_SIGNING_PUBLIC_KEY: &str =
|
||||
"RWTZqQ0ZDCn+PHTpqgI8NwjtgtkJk5QyJ0FYHWZlCZtABsXBcdPDv9WI";
|
||||
|
||||
/// Name des Prüfsummen-Assets eines Releases.
|
||||
pub const CHECKSUMS_ASSET_NAME: &str = "SHA256SUMS.txt";
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"format_version": 99,
|
||||
"id": "future_notebook",
|
||||
"title": "Zukunfts-Notizbuch",
|
||||
"created_at": 1800000000,
|
||||
"modified_at": 1800001000,
|
||||
"current_page_index": 0,
|
||||
"pages": [
|
||||
{ "id": 1, "background": "Grid5mm" }
|
||||
],
|
||||
"page_ids": [1],
|
||||
"next_stroke_id": 10,
|
||||
"next_page_id": 2
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"format_version": 99,
|
||||
"id": 1,
|
||||
"background": "Blank",
|
||||
"strokes": []
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "legacy_notebook",
|
||||
"title": "Legacy Notizbuch v0",
|
||||
"created_at": 1700000000,
|
||||
"modified_at": 1700001000,
|
||||
"current_page_index": 0,
|
||||
"page_ids": [1],
|
||||
"next_stroke_id": 5,
|
||||
"next_page_id": 2
|
||||
}
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"id": 1,
|
||||
"background": "DotGrid",
|
||||
"strokes": [
|
||||
{
|
||||
"id": 1,
|
||||
"color": [0.1, 0.2, 0.3, 1.0],
|
||||
"samples": [
|
||||
{ "x": 100.0, "y": 200.0, "p": 0.5, "t": 1.0 },
|
||||
{ "x": 110.0, "y": 210.0, "p": 0.6, "t": 1.1 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"id": 1,
|
||||
"background": "Lined",
|
||||
"strokes": [],
|
||||
"unknown_experimental_feature": {
|
||||
"nested_data": [1, 2, 3],
|
||||
"author": "Antigravity"
|
||||
},
|
||||
"random_future_flag": true
|
||||
}
|
||||
Reference in new issue
Block a user