Compare commits

...
Author SHA1 Message Date
haraldandClaude Opus 5.5 693f62c2e7 feat: Werkzeug „Platz schaffen“ (Issue #22)
- Neues Werkzeug ↕: aufs Blatt tippen und ziehen; alles unterhalb der
  Ansatzhöhe rutscht mit, auch der Inhalt der folgenden Seiten (bis vor die
  nächste PDF-Seite)
- Was über ein Seitenende rutschen würde, beginnt auf der nächsten Seite
  (10 mm Abstand), alles danach rutscht weiter; fehlende Seiten werden angehängt
- Nach oben wird nur leerer Platz geschlossen
- Vorschau beim Ziehen (Band mit Millimeterangabe); mit Maus, Stift und Finger
- Ein Schritt für Rückgängig/Wiederholen über alle beteiligten Seiten
- Nachbarseiten werden neu gezeichnet, wenn sich ihr Inhalt bei gleicher
  Strichzahl ändert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:15:18 +02:00
haraldandClaude Opus 5.5 f212b2e23f chore(release): bump version to 0.21.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m15s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:42:48 +02:00
haraldandClaude Opus 5.5 61c0c903a4 feat: Achsenkreuz aus einem „+“ in einem Zug (Issue #20)
- Formerkennung (Halten oder Formen-Werkzeug): Ein „+“ in einem Strich wird zum
  Achsenkreuz – waagerechte und senkrechte Achse, Pfeilspitzen an allen vier
  Enden; die Arme dürfen unterschiedlich lang sein
- Gespeichert als Koordinatensystem mit dem neuen optionalen Feld
  negative_ends: ältere Versionen zeigen es als L-Koordinatensystem, kein neues
  Dateiformat nötig
- Auch im PDF-, PNG- und SVG-Export mit vier Pfeilspitzen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:42:03 +02:00
haraldandClaude Opus 5.5 680fa96f32 chore(release): bump version to 0.20.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m32s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:25:26 +02:00
haraldandClaude Opus 5.5 eb0a801921 fix: Erstes Antippen mit dem Stift trifft (Issues #24, #25)
octotablet meldet das Aufsetzen des Stifts vor der Position desselben Frames.
Bis dahin galt die alte Position – nähert man den Stift und tippt sofort,
lag sie bei der vorherigen Stelle: Der Druck ging an den alten Ort, beim
Loslassen hatte sich der Zeiger „bewegt“, egui wertete das nicht als Klick
und zeigte nur den Tooltip. Erst das zweite Tippen traf. Auf dem Blatt
konnte ein Strich so mit einer Linie von der alten Stelle beginnen.

Jetzt wird das Aufsetzen erst nach der Position seines Frames verarbeitet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:24:12 +02:00
haraldandClaude Opus 5.5 76ecdac081 chore(release): bump version to 0.20.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m29s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:05:48 +02:00
haraldandClaude Opus 5.5 ea9ee6c0d7 feat: Tasten am Stift belegen (Issue #23)
- Im ☰-Menü unter Eingabe → „Tasten am Stift“: je Taste Radieren (Standard),
  Auswählen, Textmarker, Stift oder Nichts; gilt, solange die Taste gedrückt ist
- Tasten werden beim ersten Drücken gelernt und in ui.json gemerkt; die gerade
  gedrückte leuchtet im Menü grün
- Mit einer Werkzeug-Taste geschriebene Striche benutzen dieses Werkzeug, danach
  ist wieder das gewählte Werkzeug aktiv

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:05:09 +02:00
haraldandClaude Opus 5.5 d1f0b46fac chore(release): bump version to 0.19.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 7m40s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:47:28 +02:00
haraldandClaude Opus 5.5 82a297f395 feat: Ordner in andere Ordner verschieben (Issue #18)
- 📂 auch bei Ordnern: verschiebt den Ordner samt Unterordnern und
  Notizbüchern in einen anderen Ordner oder auf die oberste Ebene
- Nicht in sich selbst oder einen eigenen Unterordner; gibt es am Ziel schon
  einen gleichnamigen Ordner, kommt eine Meldung statt einer Zusammenlegung

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:46:51 +02:00
16 changed files with 1534 additions and 82 deletions
Generated
+1 -1
View File
@@ -1393,7 +1393,7 @@ dependencies = [
[[package]] [[package]]
name = "ink-notes" name = "ink-notes"
version = "0.18.0" version = "0.21.0"
dependencies = [ dependencies = [
"ab_glyph", "ab_glyph",
"arboard", "arboard",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "ink-notes" name = "ink-notes"
version = "0.18.0" version = "0.21.0"
edition = "2021" edition = "2021"
description = "Notizbuch-App für Stift, Touch und Maus unter Linux" description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes" repository = "https://gitea.pansi.eu/harald/ink-notes"
+5 -3
View File
@@ -22,12 +22,14 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
- **Eigene Farben:** Jede Farbe in der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen – gewählte Farbe erneut antippen, eine Vorgabe oder mit 🎨 einen freien Farbton wählen. Die Leiste merkt sich die Farben. Am Bildschirm erscheinen sie genauso wie im PDF- und Bildexport. - **Eigene Farben:** Jede Farbe in der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen – gewählte Farbe erneut antippen, eine Vorgabe oder mit 🎨 einen freien Farbton wählen. Die Leiste merkt sich die Farben. Am Bildschirm erscheinen sie genauso wie im PDF- und Bildexport.
- **Glättung in zwei Stufen:** Während des Schreibens glättet ein 1€-Filter das Zittern, die Linie erscheint sofort. Beim Absetzen wird der Strich nachbearbeitet: Ecken (Knicke über 65°) bleiben erhalten, der Druckverlauf wird geglättet, überflüssige Punkte entfallen, die Enden werden rund. - **Glättung in zwei Stufen:** Während des Schreibens glättet ein 1€-Filter das Zittern, die Linie erscheint sofort. Beim Absetzen wird der Strich nachbearbeitet: Ecken (Knicke über 65°) bleiben erhalten, der Druckverlauf wird geglättet, überflüssige Punkte entfallen, die Enden werden rund.
- **Druckkurve** im ☰-Menü an den eigenen Stift anpassen (weicher/härter, Schwelle gegen Grundrauschen). - **Druckkurve** im ☰-Menü an den eigenen Stift anpassen (weicher/härter, Schwelle gegen Grundrauschen).
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Stiftrückseite und Seitentaste des Stifts radieren ebenfalls. - **Tasten am Stift** im ☰-Menü belegen: Radieren (Standard), Auswählen, Textmarker, Stift oder nichts – gilt, solange die Taste gedrückt ist. Jede Taste erscheint dort nach dem ersten Drücken.
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Die Stiftrückseite radiert ebenfalls.
- **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade. - **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade.
- **Platz schaffen** (↕ in der Werkzeugleiste): aufs Blatt tippen und nach unten ziehen – alles darunter rutscht mit, auch auf den folgenden Seiten. Was über ein Seitenende rutschen würde, beginnt auf der nächsten Seite; fehlende Seiten werden angehängt. Nach oben ziehen schließt leeren Platz. Ein Schritt für Rückgängig.
### Formerkennung ### Formerkennung
- **Formen-Werkzeug** (`S`/`F`) oder **Halten** am Strichende (über 400 ms stillhalten): Die Skizze wird durch eine saubere Form ersetzt. - **Formen-Werkzeug** (`S`/`F`) oder **Halten** am Strichende (über 400 ms stillhalten): Die Skizze wird durch eine saubere Form ersetzt.
- Erkannt werden Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Vielecke, Koordinatensysteme und Oktaeder (Skizze als 3D-Körper). - Erkannt werden Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Vielecke, Koordinatensysteme (L-Form), Achsenkreuze (ein „+“ in einem Zug, Pfeilspitzen an allen vier Enden) und Oktaeder (Skizze als 3D-Körper).
- Die Erkennung arbeitet rein geometrisch (Bogenlänge, Douglas-Peucker, Kreis-Fit), ohne Machine Learning. Nicht alles wird erkannt – was nicht passt, bleibt Handschrift. - Die Erkennung arbeitet rein geometrisch (Bogenlänge, Douglas-Peucker, Kreis-Fit), ohne Machine Learning. Nicht alles wird erkannt – was nicht passt, bleibt Handschrift.
### Auswählen und Bearbeiten ### Auswählen und Bearbeiten
@@ -53,7 +55,7 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
### Notizbücher und Speichern ### Notizbücher und Speichern
- Mehrere **Notizbücher** anlegen, umbenennen, wechseln und löschen (gelöschte landen zunächst im Papierkorb und lassen sich wiederherstellen). - Mehrere **Notizbücher** anlegen, umbenennen, wechseln und löschen (gelöschte landen zunächst im Papierkorb und lassen sich wiederherstellen).
- **Ordner** für Notizbücher, beliebig verschachtelt (z. B. 3. Semester › Mathe): anlegen, umbenennen, löschen (der Inhalt rückt eine Ebene nach oben) und Notizbücher mit 📂 verschieben. Neue Notizbücher landen im gerade geöffneten Ordner. - **Ordner** für Notizbücher, beliebig verschachtelt (z. B. 3. Semester › Mathe): anlegen, umbenennen, löschen (der Inhalt rückt eine Ebene nach oben) und Notizbücher wie Ordner (samt Inhalt) mit 📂 verschieben. Neue Notizbücher landen im gerade geöffneten Ordner.
- **Automatisches Speichern** im Hintergrund: 1,5 Sekunden nach der letzten Änderung, bei ununterbrochenem Schreiben spätestens alle 10 Sekunden. Zeichnen wird dabei nicht unterbrochen. - **Automatisches Speichern** im Hintergrund: 1,5 Sekunden nach der letzten Änderung, bei ununterbrochenem Schreiben spätestens alle 10 Sekunden. Zeichnen wird dabei nicht unterbrochen.
- Jede Datei wird erst vollständig in eine temporäre Datei geschrieben und dann ersetzt – ein Absturz beim Speichern hinterlässt keine halbe Datei. - Jede Datei wird erst vollständig in eine temporäre Datei geschrieben und dann ersetzt – ein Absturz beim Speichern hinterlässt keine halbe Datei.
- **Beim Schließen** (Fenster oder `Strg + Q`) und beim Wechsel des Notizbuchs wird alles Ausstehende geschrieben, bevor es weitergeht. Schlägt das fehl, bleibt die App offen und bietet Wiederholen oder eine Notfall-Sicherung an. - **Beim Schließen** (Fenster oder `Strg + Q`) und beim Wechsel des Notizbuchs wird alles Ausstehende geschrieben, bevor es weitergeht. Schlägt das fehl, bleibt die App offen und bietet Wiederholen oder eine Notfall-Sicherung an.
+4 -1
View File
@@ -132,16 +132,19 @@ Alles auf einer Seite ist ein `Stroke` (`document.rs`):
### 5.2 Rückgängig ### 5.2 Rückgängig
Jede Seite hat einen eigenen Verlauf (`history.rs`): Hinzufügen, Entfernen, Ersetzen und Ändern von Strichen. Verschieben/Drehen/Skalieren einer Auswahl, ein Radierzug mit dem Teilstrich-Radierer und eine Textbearbeitung sind jeweils ein Schritt. Jede Seite hat einen eigenen Verlauf (`history.rs`): Hinzufügen, Entfernen, Ersetzen und Ändern von Strichen. Verschieben/Drehen/Skalieren einer Auswahl, ein Radierzug mit dem Teilstrich-Radierer und eine Textbearbeitung sind jeweils ein Schritt.
**Platz schaffen** (`Notebook::make_space`) betrifft mehrere Seiten: Die Seite, auf der angesetzt wurde, und die folgenden bis vor die nächste PDF-Seite gelten als durchgehende Spalte. Alles unterhalb der Ansatzhöhe rutscht um denselben Betrag; ein Objekt, das über ein Seitenende ragen würde, beginnt 10 mm unter dem Anfang der nächsten Seite, und alles danach rutscht entsprechend weiter. Fehlende Seiten werden eingefügt. Der Schritt (`SpaceShift`: jeder verschobene Strich vorher/nachher mit Seite) landet im Verlauf der Seite, auf der angesetzt wurde; `Notebook::undo`/`redo` buchen ihn über alle Seiten zurück (später geänderte Striche bleiben unberührt) und entfernen dafür angelegte, leer gebliebene Seiten wieder.
--- ---
## 6. Formerkennung ## 6. Formerkennung
`geometry.rs` erkennt Formen rein geometrisch, ohne Machine Learning. Geschlossene Formen (Kreis, Ellipse, Dreieck, Rechteck, Raute) treten gegeneinander an; gewählt wird die, deren Umriss am besten zu den Punkten passt. Danach folgen Vielecke, Pfeile, Koordinatensysteme und Linien. Übernommen wird eine Form nur, wenn der Strich im Mittel höchstens 4 % seiner Diagonale von ihr abweicht – sonst bleibt er Handschrift. `geometry.rs` erkennt Formen rein geometrisch, ohne Machine Learning. Zuerst wird auf ein Achsenkreuz geprüft (alle Teilstücke liegen auf einer waagerechten und einer senkrechten Linie, beide reichen auf beiden Seiten über den Schnittpunkt hinaus). Geschlossene Formen (Kreis, Ellipse, Dreieck, Rechteck, Raute) treten gegeneinander an; gewählt wird die, deren Umriss am besten zu den Punkten passt. Danach folgen Vielecke, Pfeile, Koordinatensysteme und Linien. Übernommen wird eine Form nur, wenn der Strich im Mittel höchstens 4 % seiner Diagonale von ihr abweicht – sonst bleibt er Handschrift.
- **Linie:** Verhältnis von Abstand Anfang–Ende zur Strichlänge über 0,92. - **Linie:** Verhältnis von Abstand Anfang–Ende zur Strichlänge über 0,92.
- **Kreis, Ellipse:** Kreis- bzw. Ellipsen-Fit über die Punkte. - **Kreis, Ellipse:** Kreis- bzw. Ellipsen-Fit über die Punkte.
- **Rechteck, Dreieck, Raute, Vieleck, Pfeil:** Analyse der mit Douglas-Peucker vereinfachten Kontur (Eckenzahl, Winkel, Seitenverhältnisse). - **Rechteck, Dreieck, Raute, Vieleck, Pfeil:** Analyse der mit Douglas-Peucker vereinfachten Kontur (Eckenzahl, Winkel, Seitenverhältnisse).
- **Koordinatensystem:** zwei etwa rechtwinklige Achsen; der Winkel wird auf 90° gesetzt. - **Koordinatensystem:** zwei etwa rechtwinklige Achsen; der Winkel wird auf 90° gesetzt.
- **Achsenkreuz:** ein „+“ in einem Zug; die Achsen werden waagerecht und senkrecht ausgerichtet und bekommen an allen vier Enden Pfeilspitzen.
- **Oktaeder:** Skizze einer Doppelpyramide als 3D-Kantenmodell; nur, wenn kein einfacher Umriss passt. - **Oktaeder:** Skizze einer Doppelpyramide als 3D-Kantenmodell; nur, wenn kein einfacher Umriss passt.
**Halten am Strichende:** Bleibt der Stift über 400 ms innerhalb von 3 Pixeln ruhig, wird die erkannte Form schon vor dem Absetzen angezeigt und beim Absetzen übernommen. Mit dem Formen-Werkzeug wird jeder Strich beim Absetzen geprüft. **Halten am Strichende:** Bleibt der Stift über 400 ms innerhalb von 3 Pixeln ruhig, wird die erkannte Form schon vor dem Absetzen angezeigt und beim Absetzen übernommen. Mit dem Formen-Werkzeug wird jeder Strich beim Absetzen geprüft.
+2 -2
View File
@@ -31,7 +31,7 @@ Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die
- Alle Dateien werden mit Rechten `0600`, Ordner mit `0700` angelegt. - Alle Dateien werden mit Rechten `0600`, Ordner mit `0700` angelegt.
### Ordner (`library.json`) ### Ordner (`library.json`)
Ordner wie „3. Semester › Mathe“ sind rein logisch: Die Notizbücher bleiben in ihren Verzeichnissen, `library.json` hält nur fest, welcher Ordner welches Notizbuch enthält. Umbenennen, Verschieben und Löschen von Ordnern ändern deshalb nie eine Notizbuch-Datei. Ordner wie „3. Semester › Mathe“ sind rein logisch: Die Notizbücher bleiben in ihren Verzeichnissen, `library.json` hält nur fest, welcher Ordner welches Notizbuch enthält. Umbenennen, Verschieben (samt Inhalt) und Löschen von Ordnern ändern deshalb nie eine Notizbuch-Datei.
```json ```json
{ {
@@ -125,7 +125,7 @@ Jedes Objekt hat eine `id`, eine `color` (RGBA, linear, Werte 0–1) und `sample
"shape": { "Rectangle": { "corners": [[500, 600], [800, 600], [800, 800], [500, 800]] } } "shape": { "Rectangle": { "corners": [[500, 600], [800, 600], [800, 800], [500, 800]] } }
``` ```
Mögliche Formen: `Line { start, end }`, `Arrow { start, end, double }`, `Circle { center, radius }`, `Ellipse { center, radius_x, radius_y, rotation }`, `Rectangle { corners }`, `Triangle { corners }`, `Rhombus { corners }`, `Polygon { vertices }`, `CoordinateSystem { origin, x_axis_end, y_axis_end, with_arrows }`, `Octahedron { … }`. Punkte sind `[x, y]`. Mögliche Formen: `Line { start, end }`, `Arrow { start, end, double }`, `Circle { center, radius }`, `Ellipse { center, radius_x, radius_y, rotation }`, `Rectangle { corners }`, `Triangle { corners }`, `Rhombus { corners }`, `Polygon { vertices }`, `CoordinateSystem { origin, x_axis_end, y_axis_end, with_arrows, negative_ends? }`, `Octahedron { … }`. Punkte sind `[x, y]`. Beim Achsenkreuz enthält `negative_ends` die Enden der x- und der y-Achse auf der anderen Seite des Ursprungs; ältere Versionen ignorieren das Feld und zeigen ein L-förmiges Koordinatensystem.
**Bilder** haben `image` (Dateiname in `assets/`); die vier `samples` sind die Ecken oben links, oben rechts, unten rechts, unten links: **Bilder** haben `image` (Dateiname in `assets/`); die vier `samples` sind die Ecken oben links, oben rechts, unten rechts, unten links:
+4
View File
@@ -3,6 +3,10 @@
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04). Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
### Highlights: ### Highlights:
- **Achsenkreuz**: Ein „+“ in einem Zug (mit Halten oder dem Formen-Werkzeug) wird zum Achsenkreuz mit Pfeilspitzen an allen vier Enden; auch im PDF-/Bildexport
- **Fehlerbehebung Stift**: Das erste Antippen mit dem Stift trifft jetzt – Knöpfe und Notizbücher reagieren sofort statt erst beim zweiten Tippen; Striche beginnen nicht mehr mit einer Linie von der vorherigen Stiftposition
- **Tasten am Stift belegen**: Im ☰-Menü unter Eingabe jede Stifttaste mit Radieren, Auswählen, Textmarker, Stift oder Nichts belegen (gilt, solange die Taste gedrückt ist); Tasten erscheinen nach dem ersten Drücken und leuchten beim Drücken grün
- **Ordner verschieben**: Ordner lassen sich jetzt wie Notizbücher mit 📂 samt Inhalt in andere Ordner oder auf die oberste Ebene verschieben
- **Ordner für Notizbücher**: Beliebig verschachtelte Ordner (z. B. 3. Semester › Mathe) im Dokumenten-Manager anlegen, umbenennen und löschen, Notizbücher mit 📂 verschieben; neue Notizbücher landen im geöffneten Ordner. Die Notizbuch-Dateien bleiben unverändert (Zuordnung in library.json) - **Ordner für Notizbücher**: Beliebig verschachtelte Ordner (z. B. 3. Semester › Mathe) im Dokumenten-Manager anlegen, umbenennen und löschen, Notizbücher mit 📂 verschieben; neue Notizbücher landen im geöffneten Ordner. Die Notizbuch-Dateien bleiben unverändert (Zuordnung in library.json)
- **Einfügen per Finger**: Lange (½ s) mit dem Finger aufs Blatt drücken öffnet ein Menü „Einfügen“ – Screenshot, kopierter Text oder kopierte Objekte landen genau an dieser Stelle (wie in Rnote) - **Einfügen per Finger**: Lange (½ s) mit dem Finger aufs Blatt drücken öffnet ein Menü „Einfügen“ – Screenshot, kopierter Text oder kopierte Objekte landen genau an dieser Stelle (wie in Rnote)
- **Eigene Farben**: Jede Farbe der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen – erneut antippen, Vorgabe oder mit 🎨 frei wählen; die Leiste merkt sich die Farben. Farben am Bildschirm wie im PDF-/Bildexport (Schwarz ist schwarz, Textmarker mischt wie im PDF); Karos und Linien im PDF so hell wie am Bildschirm - **Eigene Farben**: Jede Farbe der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen – erneut antippen, Vorgabe oder mit 🎨 frei wählen; die Leiste merkt sich die Farben. Farben am Bildschirm wie im PDF-/Bildexport (Schwarz ist schwarz, Textmarker mischt wie im PDF); Karos und Linien im PDF so hell wie am Bildschirm
+3
View File
@@ -128,6 +128,8 @@ struct NeighborFingerprint {
vertex_count: usize, vertex_count: usize,
stroke_count: usize, stroke_count: usize,
last_stroke_id: Option<u64>, last_stroke_id: Option<u64>,
/// Neuaufbau des Caches (z. B. verschobener Inhalt bei gleicher Strichzahl)
cache_generation: u64,
} }
impl NeighborFingerprint { impl NeighborFingerprint {
@@ -141,6 +143,7 @@ impl NeighborFingerprint {
vertex_count: page.vertex_cache.len(), vertex_count: page.vertex_cache.len(),
stroke_count: page.strokes.len(), stroke_count: page.strokes.len(),
last_stroke_id: page.strokes.last().map(|s| s.id), last_stroke_id: page.strokes.last().map(|s| s.id),
cache_generation: page.cache_generation,
} }
} }
} }
+402
View File
@@ -722,6 +722,8 @@ pub struct Page {
pub vertex_cache: Vec<InkVertex>, pub vertex_cache: Vec<InkVertex>,
pub stroke_ranges: Vec<Range<u32>>, pub stroke_ranges: Vec<Range<u32>>,
pub is_dirty: bool, pub is_dirty: bool,
/// Zählt die Neuaufbauten des Vertex-Caches (erkennt geänderte Nachbarseiten)
pub cache_generation: u64,
pub is_loaded: bool, pub is_loaded: bool,
pub persist: PersistState, pub persist: PersistState,
pub history: HistoryStack, pub history: HistoryStack,
@@ -742,6 +744,7 @@ impl Page {
is_loaded: true, is_loaded: true,
persist: PersistState::Normal, persist: PersistState::Normal,
history: HistoryStack::new(), history: HistoryStack::new(),
cache_generation: 0,
} }
} }
@@ -760,6 +763,7 @@ impl Page {
is_loaded: false, is_loaded: false,
persist: PersistState::Normal, persist: PersistState::Normal,
history: HistoryStack::new(), history: HistoryStack::new(),
cache_generation: 0,
} }
} }
@@ -958,6 +962,7 @@ impl Page {
} }
self.is_dirty = false; self.is_dirty = false;
self.cache_generation += 1;
} }
/// Berechnet die kombinierte AABB einer Menge von Strichen. /// Berechnet die kombinierte AABB einer Menge von Strichen.
@@ -1315,6 +1320,298 @@ impl Default for Notebook {
} }
} }
/// Abstand zum oberen Rand, mit dem ein Objekt auf der nächsten Seite landet, wenn es beim
/// Platzschaffen über das Seitenende rutschen würde (10 mm).
pub const SPACE_PAGE_BREAK_MARGIN: f32 = 10.0 * UNITS_PER_MM;
/// Ein beim Platzschaffen verschobener Strich: vorher und nachher, jeweils mit Seite.
#[derive(Debug, Clone)]
pub struct StrokeMove {
pub from_page: u64,
pub before: Stroke,
pub to_page: u64,
pub after: Stroke,
}
/// Für das Platzschaffen neu eingefügte Seite.
#[derive(Debug, Clone)]
pub struct AddedPage {
pub id: u64,
/// Seite, hinter der sie liegt
pub after_page: u64,
pub size: glam::Vec2,
pub background: PageBackground,
}
/// Ein Platzschaffen-Schritt (für Rückgängig und Wiederholen).
#[derive(Debug, Clone)]
pub struct SpaceShift {
pub moves: Vec<StrokeMove>,
pub added_pages: Vec<AddedPage>,
}
impl Notebook {
/// Seiten, die beim Platzschaffen auf Seite `page_index` mitrutschen: sie selbst und die
/// folgenden bis vor die nächste Seite mit importiertem PDF (deren Notizen gehören zur
/// PDF-Seite). `None` auf einer PDF-Seite.
pub fn space_run(&self, page_index: usize) -> Option<std::ops::Range<usize>> {
let page = self.pages.get(page_index)?;
if page.pdf_background.is_some() {
return None;
}
let end = (page_index + 1..self.pages.len())
.find(|&i| self.pages[i].pdf_background.is_some())
.unwrap_or(self.pages.len());
Some(page_index..end)
}
/// Wie weit sich der Inhalt unter `y` auf Seite `page_index` höchstens nach oben schieben
/// lässt: bis an das unterste Objekt darüber (bzw. den oberen Seitenrand).
pub fn space_up_limit(&self, page_index: usize, y: f32) -> f32 {
let Some(page) = self.pages.get(page_index) else {
return 0.0;
};
let bottom = page
.strokes
.iter()
.filter(|s| s.aabb.min.y < y)
.map(|s| s.aabb.max.y)
.fold(0.0_f32, f32::max);
(y - bottom).max(0.0)
}
/// Platz schaffen: Alles, was auf Seite `page_index` unterhalb von `y` beginnt, und der
/// ganze Inhalt der folgenden Seiten (siehe [`Self::space_run`]) rutscht um `dy` nach unten
/// (negativ: nach oben, höchstens bis [`Self::space_up_limit`]). Die Seiten gelten dabei
/// als durchgehende Spalte: Was über ein Seitenende hinausragen würde, beginnt auf der
/// nächsten Seite (mit [`SPACE_PAGE_BREAK_MARGIN`] Abstand), alles danach rutscht
/// entsprechend weiter; fehlende Seiten werden angehängt. Der Schritt landet im Verlauf
/// der Seite und gibt die Kennungen der geänderten Seiten zurück (`None`: nichts zu tun).
pub fn make_space(&mut self, page_index: usize, y: f32, dy: f32) -> Option<Vec<u64>> {
let run = self.space_run(page_index)?;
if self.pages[run.clone()].iter().any(|p| !p.is_loaded) {
return None;
}
let dy = if dy < 0.0 {
dy.max(-self.space_up_limit(page_index, y))
} else {
dy
};
if dy.abs() < 1.0 {
return None;
}
// Seiten als durchgehende Spalte: Lage jeder Seite (auch noch anzulegender)
let last_size = self.pages[run.end - 1].size;
let mut tops = Vec::new();
let mut heights = Vec::new();
let mut top = 0.0;
for page in &self.pages[run.clone()] {
tops.push(top);
heights.push(page.size.y);
top += page.size.y;
}
// Verschoben wird alles unterhalb von y, in der Reihenfolge von oben nach unten
let mut movable: Vec<(usize, f32, Stroke)> = Vec::new();
for (k, page) in self.pages[run.clone()].iter().enumerate() {
for stroke in &page.strokes {
if k > 0 || stroke.aabb.min.y >= y {
movable.push((k, tops[k] + stroke.aabb.min.y, stroke.clone()));
}
}
}
if movable.is_empty() {
return None;
}
movable.sort_by(|a, b| a.1.total_cmp(&b.1));
let mut extra = 0.0_f32;
let mut targets = Vec::with_capacity(movable.len());
for (_, global_top, stroke) in &movable {
let height = stroke.aabb.max.y - stroke.aabb.min.y;
let mut new_top = global_top + dy + extra;
let mut target = 0;
for _ in 0..2 {
// Seite, auf der das Objekt beginnt (bei Bedarf weitere Seiten)
target = 0;
while target + 1 < tops.len() && tops[target + 1] <= new_top {
target += 1;
}
while tops[target] + heights[target] <= new_top {
tops.push(tops[target] + heights[target]);
heights.push(last_size.y);
target += 1;
}
let page_end = tops[target] + heights[target];
let fits_on_a_page = height <= heights[target] - 2.0 * SPACE_PAGE_BREAK_MARGIN;
if new_top + height <= page_end || !fits_on_a_page {
break;
}
// Ragt über das Seitenende: auf die nächste Seite, alles Folgende rutscht mit
let margin = if dy > 0.0 {
SPACE_PAGE_BREAK_MARGIN
} else {
0.0
};
let bump = page_end + margin - new_top;
extra += bump;
new_top += bump;
}
targets.push((target, new_top));
}
// Fehlende Seiten hinter dem Abschnitt einfügen
let needed = targets.iter().map(|(t, _)| t + 1).max().unwrap_or(0);
let mut added_pages = Vec::new();
let background = self.pages[run.end - 1].background;
for k in run.len()..needed {
let after_page = self.pages[run.start + k - 1].id;
let id = self.next_page_id;
self.next_page_id += 1;
self.pages.insert(
run.start + k,
Page::new(id, background).with_size(last_size),
);
added_pages.push(AddedPage {
id,
after_page,
size: last_size,
background,
});
}
// Umbuchen
let mut moves = Vec::with_capacity(movable.len());
for ((k, _, before), (target, new_top)) in movable.into_iter().zip(targets) {
let mut after = before.clone();
after.translate(glam::Vec2::new(
0.0,
new_top - tops[target] - before.aabb.min.y,
));
moves.push(StrokeMove {
from_page: self.pages[run.start + k].id,
before,
to_page: self.pages[run.start + target].id,
after,
});
}
let shift = SpaceShift { moves, added_pages };
let changed = self.apply_space_moves(&shift, false);
self.pages[page_index]
.history
.push(HistoryAction::SpaceShift(Box::new(shift)));
Some(changed)
}
/// Bucht die Striche eines Platzschaffen-Schritts um (`undo`: zurück) und gibt die
/// geänderten Seiten zurück. Später geänderte oder gelöschte Striche bleiben unberührt.
fn apply_space_moves(&mut self, shift: &SpaceShift, undo: bool) -> Vec<u64> {
let mut changed = Vec::new();
let index_of = |pages: &[Page], id: u64| pages.iter().position(|p| p.id == id);
for m in &shift.moves {
let (from, old, to, new) = if undo {
(m.to_page, &m.after, m.from_page, &m.before)
} else {
(m.from_page, &m.before, m.to_page, &m.after)
};
let (Some(from_i), Some(to_i)) =
(index_of(&self.pages, from), index_of(&self.pages, to))
else {
continue;
};
let Some(pos) = self.pages[from_i]
.strokes
.iter()
.position(|s| s.id == old.id)
else {
continue;
};
self.pages[from_i].strokes.remove(pos);
self.pages[to_i].strokes.push(new.clone());
for i in [from_i, to_i] {
self.pages[i].is_dirty = true;
if !changed.contains(&self.pages[i].id) {
changed.push(self.pages[i].id);
}
}
}
changed
}
/// Macht die letzte Aktion der aktuellen Seite rückgängig; ein Platzschaffen-Schritt
/// betrifft dabei alle beteiligten Seiten. Gibt die geänderten Seiten zurück.
pub fn undo(&mut self) -> Option<Vec<u64>> {
let index = self.current_page_index;
if let Some(HistoryAction::SpaceShift(_)) = self.pages[index].history.undo_stack.last() {
let Some(HistoryAction::SpaceShift(shift)) = self.pages[index].history.undo_stack.pop()
else {
unreachable!()
};
let mut changed = self.apply_space_moves(&shift, true);
// Dafür angelegte Seiten wieder entfernen, sofern sie leer geblieben sind
for added in shift.added_pages.iter().rev() {
if let Some(i) = self.pages.iter().position(|p| p.id == added.id) {
if self.pages[i].strokes.is_empty() && self.pages.len() > 1 {
self.pages.remove(i);
if self.current_page_index >= i && self.current_page_index > 0 {
self.current_page_index -= 1;
}
changed.retain(|&id| id != added.id);
}
}
}
// Der Schritt gehört weiter zur aktuellen Seite (sie selbst wird nie entfernt)
let owner = self.current_page_index;
self.pages[owner]
.history
.redo_stack
.push(HistoryAction::SpaceShift(shift));
return Some(changed);
}
let page = &mut self.pages[index];
page.undo().then(|| vec![page.id])
}
/// Stellt die zuletzt rückgängig gemachte Aktion der aktuellen Seite wieder her.
pub fn redo(&mut self) -> Option<Vec<u64>> {
let index = self.current_page_index;
if let Some(HistoryAction::SpaceShift(_)) = self.pages[index].history.redo_stack.last() {
let Some(HistoryAction::SpaceShift(shift)) = self.pages[index].history.redo_stack.pop()
else {
unreachable!()
};
// Entfernte Seiten wieder einfügen
for added in &shift.added_pages {
if self.pages.iter().any(|p| p.id == added.id) {
continue;
}
let at = self
.pages
.iter()
.position(|p| p.id == added.after_page)
.map_or(self.pages.len(), |i| i + 1);
self.pages.insert(
at,
Page::new(added.id, added.background).with_size(added.size),
);
if at <= self.current_page_index {
self.current_page_index += 1;
}
}
let changed = self.apply_space_moves(&shift, false);
let owner = self.current_page_index;
self.pages[owner]
.history
.undo_stack
.push(HistoryAction::SpaceShift(shift));
return Some(changed);
}
let page = &mut self.pages[index];
page.redo().then(|| vec![page.id])
}
}
/// Lage der aktuellen Seite im Fenster bei Zoom 100 % ohne Verschiebung: Ein Rahmen der /// Lage der aktuellen Seite im Fenster bei Zoom 100 % ohne Verschiebung: Ein Rahmen der
/// Größe `fit_box` (alle Seiten des Notizbuchs passen hinein) wird ins Fenster eingepasst /// Größe `fit_box` (alle Seiten des Notizbuchs passen hinein) wird ins Fenster eingepasst
/// (Letterboxing/Pillarboxing); die aktuelle Seite liegt darin waagerecht zentriert und oben. /// (Letterboxing/Pillarboxing); die aktuelle Seite liegt darin waagerecht zentriert und oben.
@@ -2005,6 +2302,111 @@ mod tests {
assert!(new.pdf_background.is_none()); assert!(new.pdf_background.is_none());
} }
/// Senkrechter Strich bei x = 300 von `y` bis `y + h`.
fn block(id: u64, y: f32, h: f32) -> Stroke {
let samples = [y, y + h]
.map(|y| StylusSample {
position: glam::Vec2::new(300.0, y),
pressure: 0.5,
timestamp: 0.0,
})
.to_vec();
Stroke::new(id, samples, Vec::new(), [0.0, 0.0, 0.0, 1.0])
}
fn top_of(nb: &Notebook, page: usize, id: u64) -> Option<f32> {
nb.pages[page]
.strokes
.iter()
.find(|s| s.id == id)
.map(|s| s.aabb.min.y)
}
#[test]
fn test_make_space_moves_content_below_and_following_pages() {
let mut nb = Notebook::new();
nb.add_page(PageBackground::Lined);
let a4 = A4_SIZE.y;
nb.pages[0].strokes = vec![block(1, 200.0, 100.0), block(2, 1000.0, 100.0)];
nb.pages[1].strokes = vec![block(3, 500.0, 100.0)];
// 30 mm Platz bei y = 800 auf Seite 1
let changed = nb.make_space(0, 800.0, 300.0).unwrap();
assert_eq!(top_of(&nb, 0, 1), Some(200.0), "darüber bleibt stehen");
assert_eq!(top_of(&nb, 0, 2), Some(1300.0));
assert_eq!(
top_of(&nb, 1, 3),
Some(800.0),
"die nächste Seite rutscht mit"
);
assert_eq!(changed.len(), 2);
assert_eq!(nb.pages.len(), 2);
// Rückgängig und wiederholen über beide Seiten
nb.current_page_index = 0;
assert!(nb.undo().is_some());
assert_eq!(top_of(&nb, 0, 2), Some(1000.0));
assert_eq!(top_of(&nb, 1, 3), Some(500.0));
assert!(nb.redo().is_some());
assert_eq!(top_of(&nb, 0, 2), Some(1300.0));
assert_eq!(top_of(&nb, 1, 3), Some(800.0));
// Über das Seitenende: auf die nächste Seite mit Abstand, das Folgende rutscht weiter
let mut nb = Notebook::new();
nb.pages[0].strokes = vec![block(1, a4 - 400.0, 200.0), block(2, a4 - 150.0, 100.0)];
nb.make_space(0, a4 - 500.0, 300.0).unwrap();
assert_eq!(nb.pages.len(), 2, "eine Seite wurde angehängt");
assert_eq!(nb.pages[1].background, nb.pages[0].background);
assert!(nb.pages[0].strokes.is_empty());
let first = top_of(&nb, 1, 1).unwrap();
assert!((first - SPACE_PAGE_BREAK_MARGIN).abs() < 1e-3, "{first}");
// Abstand zwischen beiden bleibt (50 Einheiten unter dem Ende des ersten)
assert!((top_of(&nb, 1, 2).unwrap() - (first + 250.0)).abs() < 1e-3);
// Rückgängig entfernt die angehängte leere Seite
assert!(nb.undo().is_some());
assert_eq!(nb.pages.len(), 1);
assert_eq!(top_of(&nb, 0, 2), Some(a4 - 150.0));
assert!(nb.redo().is_some());
assert_eq!(nb.pages.len(), 2);
}
#[test]
fn test_make_space_upwards_only_closes_empty_space() {
let mut nb = Notebook::new();
nb.pages[0].strokes = vec![block(1, 200.0, 100.0), block(2, 1000.0, 100.0)];
// Nach oben höchstens bis an das Objekt darüber (Unterkante 300)
assert_eq!(nb.space_up_limit(0, 900.0), 600.0);
nb.make_space(0, 900.0, -2000.0).unwrap();
assert_eq!(top_of(&nb, 0, 2), Some(400.0));
assert_eq!(top_of(&nb, 0, 1), Some(200.0));
// Kein Platz mehr: nichts zu tun
assert!(nb.make_space(0, 300.5, -50.0).is_none());
}
#[test]
fn test_make_space_stops_before_pdf_pages() {
let mut nb = Notebook::new();
nb.add_page(PageBackground::Blank);
nb.add_page(PageBackground::Blank);
nb.pages[1].pdf_background = Some(PdfBackground {
asset: "x.pdf".to_string(),
page: 0,
width_pt: 595.0,
height_pt: 842.0,
});
nb.pages[0].strokes = vec![block(1, 1000.0, 100.0)];
nb.pages[2].strokes = vec![block(2, 500.0, 100.0)];
assert!(nb.space_run(1).is_none(), "nicht auf PDF-Seiten");
assert_eq!(nb.space_run(0), Some(0..1));
nb.make_space(0, 900.0, 300.0).unwrap();
assert_eq!(top_of(&nb, 0, 1), Some(1300.0));
assert_eq!(
top_of(&nb, 2, 2),
Some(500.0),
"hinter der PDF-Seite bleibt alles"
);
}
#[test] #[test]
fn test_remove_and_restore_current_page() { fn test_remove_and_restore_current_page() {
let mut nb = Notebook::new(); let mut nb = Notebook::new();
+54
View File
@@ -1033,6 +1033,59 @@ fn render_recognized_shape(
x_axis_end, x_axis_end,
y_axis_end, y_axis_end,
with_arrows, with_arrows,
negative_ends: Some([x_axis_start, y_axis_start]),
} => {
// Achsenkreuz: zwei durchgehende Achsen, Pfeilspitzen an allen vier Enden
const PDF_SHAPE_HALF_WIDTH: f32 = 2.65;
let to_pdf = |p: &glam::Vec2| {
let (px, py) = page_to_pdf_mm(p.x, p.y);
(Point::new(Mm(px), Mm(py)), false)
};
let mut heads = Vec::new();
for (neg, pos) in [(*x_axis_start, *x_axis_end), (*y_axis_start, *y_axis_end)] {
let dir = (pos - neg).normalize_or_zero();
let pos_head = crate::geometry::arrow_head(
pos,
dir,
PDF_SHAPE_HALF_WIDTH,
(pos - *origin).length(),
);
let neg_head = crate::geometry::arrow_head(
neg,
-dir,
PDF_SHAPE_HALF_WIDTH,
(neg - *origin).length(),
);
let (from, to) = if *with_arrows {
(neg_head.0, pos_head.0)
} else {
(neg, pos)
};
layer.add_line(Line {
points: [from, to].iter().map(to_pdf).collect(),
is_closed: false,
});
heads.push((pos, pos_head));
heads.push((neg, neg_head));
}
if *with_arrows {
layer.set_fill_color(stroke_color);
for (tip, (_, left, right)) in heads {
layer.add_polygon(Polygon {
rings: vec![[tip, left, right].iter().map(to_pdf).collect()],
mode: PaintMode::Fill,
winding_order: WindingOrder::NonZero,
});
}
}
}
RecognizedShape::CoordinateSystem {
origin,
x_axis_end,
y_axis_end,
with_arrows,
negative_ends: None,
} => { } => {
// Gleiche Pfeilspitzen wie auf dem Bildschirm; Achsen enden an der Basis der Spitze // Gleiche Pfeilspitzen wie auf dem Bildschirm; Achsen enden an der Basis der Spitze
// (Linienstärke 1,5 pt ≈ 5,3 Seiteneinheiten, halbe Breite ≈ 2,65) // (Linienstärke 1,5 pt ≈ 5,3 Seiteneinheiten, halbe Breite ≈ 2,65)
@@ -1450,6 +1503,7 @@ mod tests {
x_axis_end: glam::Vec2::new(800.0, 1500.0), x_axis_end: glam::Vec2::new(800.0, 1500.0),
y_axis_end: glam::Vec2::new(300.0, 1000.0), y_axis_end: glam::Vec2::new(300.0, 1000.0),
with_arrows: true, with_arrows: true,
negative_ends: None,
}, },
); );
page.add_stroke(coords_stroke); page.add_stroke(coords_stroke);
+342 -1
View File
@@ -59,6 +59,11 @@ pub enum RecognizedShape {
x_axis_end: Vec2, x_axis_end: Vec2,
y_axis_end: Vec2, y_axis_end: Vec2,
with_arrows: bool, with_arrows: bool,
/// Achsenkreuz: Enden der x- und der y-Achse auf der anderen Seite des Ursprungs
/// (Pfeilspitzen an allen vier Enden). Fehlt es, ist es das L-förmige
/// Koordinatensystem; ältere Versionen ignorieren das Feld und zeigen das L.
#[serde(default, skip_serializing_if = "Option::is_none")]
negative_ends: Option<[Vec2; 2]>,
}, },
Triangle { Triangle {
/// 3 Eckpunkte in umlaufender Reihenfolge. /// 3 Eckpunkte in umlaufender Reihenfolge.
@@ -105,6 +110,10 @@ impl RecognizedShape {
Self::Arrow { double: false, .. } => "Pfeil", Self::Arrow { double: false, .. } => "Pfeil",
Self::Arrow { double: true, .. } => "Doppelpfeil", Self::Arrow { double: true, .. } => "Doppelpfeil",
Self::Rectangle { .. } => "Rechteck", Self::Rectangle { .. } => "Rechteck",
Self::CoordinateSystem {
negative_ends: Some(_),
..
} => "Achsenkreuz",
Self::CoordinateSystem { .. } => "Koordinatensystem", Self::CoordinateSystem { .. } => "Koordinatensystem",
Self::Triangle { .. } => "Dreieck", Self::Triangle { .. } => "Dreieck",
Self::Rhombus { .. } => "Raute (Rhombus)", Self::Rhombus { .. } => "Raute (Rhombus)",
@@ -146,11 +155,15 @@ impl RecognizedShape {
origin, origin,
x_axis_end, x_axis_end,
y_axis_end, y_axis_end,
negative_ends,
.. ..
} => { } => {
*origin += delta; *origin += delta;
*x_axis_end += delta; *x_axis_end += delta;
*y_axis_end += delta; *y_axis_end += delta;
for end in negative_ends.iter_mut().flatten() {
*end += delta;
}
} }
Self::Polygon { vertices } => { Self::Polygon { vertices } => {
for v in vertices { for v in vertices {
@@ -203,11 +216,15 @@ impl RecognizedShape {
origin: o, origin: o,
x_axis_end, x_axis_end,
y_axis_end, y_axis_end,
negative_ends,
.. ..
} => { } => {
*o = rot(*o); *o = rot(*o);
*x_axis_end = rot(*x_axis_end); *x_axis_end = rot(*x_axis_end);
*y_axis_end = rot(*y_axis_end); *y_axis_end = rot(*y_axis_end);
for end in negative_ends.iter_mut().flatten() {
*end = rot(*end);
}
} }
Self::Polygon { vertices } => { Self::Polygon { vertices } => {
for v in vertices { for v in vertices {
@@ -267,11 +284,15 @@ impl RecognizedShape {
origin: o, origin: o,
x_axis_end, x_axis_end,
y_axis_end, y_axis_end,
negative_ends,
.. ..
} => { } => {
*o = sc(*o); *o = sc(*o);
*x_axis_end = sc(*x_axis_end); *x_axis_end = sc(*x_axis_end);
*y_axis_end = sc(*y_axis_end); *y_axis_end = sc(*y_axis_end);
for end in negative_ends.iter_mut().flatten() {
*end = sc(*end);
}
} }
Self::Polygon { vertices } => { Self::Polygon { vertices } => {
for v in vertices { for v in vertices {
@@ -1394,6 +1415,87 @@ pub fn detect_coordinate_system(points: &[Vec2]) -> Option<RecognizedShape> {
x_axis_end: x_end, x_axis_end: x_end,
y_axis_end: y_end, y_axis_end: y_end,
with_arrows: true, with_arrows: true,
negative_ends: None,
})
}
/// Achsenkreuz in einem Strich („+“, z. B. waagerecht hin und zurück, dann senkrecht): Alle
/// Teilstücke liegen auf einer waagerechten und einer senkrechten Linie, und beide reichen
/// auf beiden Seiten deutlich über den Schnittpunkt hinaus. Ergebnis wie beim
/// Koordinatensystem achsenparallel, mit Pfeilspitzen an allen vier Enden.
pub fn detect_axis_cross(points: &[Vec2]) -> Option<RecognizedShape> {
if points.len() < 8 {
return None;
}
let aabb = Aabb::from_points(points);
let diagonal = (aabb.max - aabb.min).length();
if diagonal < 60.0 {
return None;
}
let simplified = ramer_douglas_peucker(points, (diagonal * 0.05).clamp(8.0, 40.0));
// Teilstücke nach Richtung: waagerecht oder senkrecht (höchstens 25° schief); kurze
// Stücke (Umkehrpunkte) zählen nicht, schräge lange Stücke schließen ein Kreuz aus
let max_slope = 25.0_f32.to_radians().tan();
let (mut horizontal, mut vertical) = ((0.0, 0.0), (0.0, 0.0));
for w in simplified.windows(2) {
let d = w[1] - w[0];
let len = d.length();
if len < diagonal * 0.08 {
continue;
}
let mid = (w[0] + w[1]) * 0.5;
if d.y.abs() <= d.x.abs() * max_slope {
horizontal = (horizontal.0 + mid.y * len, horizontal.1 + len);
} else if d.x.abs() <= d.y.abs() * max_slope {
vertical = (vertical.0 + mid.x * len, vertical.1 + len);
} else {
return None;
}
}
if horizontal.1 == 0.0 || vertical.1 == 0.0 {
return None;
}
let origin = Vec2::new(vertical.0 / vertical.1, horizontal.0 / horizontal.1);
// Jeder Punkt liegt auf einer der beiden Linien
let tolerance = (diagonal * 0.08).max(10.0);
let (mut x_min, mut x_max) = (f32::INFINITY, f32::NEG_INFINITY);
let (mut y_min, mut y_max) = (f32::INFINITY, f32::NEG_INFINITY);
for p in points {
let on_horizontal = (p.y - origin.y).abs() <= tolerance;
let on_vertical = (p.x - origin.x).abs() <= tolerance;
if !on_horizontal && !on_vertical {
return None;
}
if on_horizontal {
x_min = x_min.min(p.x);
x_max = x_max.max(p.x);
}
if on_vertical {
y_min = y_min.min(p.y);
y_max = y_max.max(p.y);
}
}
// Vier Arme: jeder mindestens ein Fünftel seiner Achse und deutlich länger als die Toleranz
let arm_ok = |arm: f32, axis: f32| arm >= axis * 0.2 && arm >= tolerance * 1.5;
let (width, height) = (x_max - x_min, y_max - y_min);
if !(arm_ok(origin.x - x_min, width)
&& arm_ok(x_max - origin.x, width)
&& arm_ok(origin.y - y_min, height)
&& arm_ok(y_max - origin.y, height))
{
return None;
}
// x nach rechts, y nach oben (auf dem Blatt: kleineres y)
Some(RecognizedShape::CoordinateSystem {
origin,
x_axis_end: Vec2::new(x_max, origin.y),
y_axis_end: Vec2::new(origin.x, y_min),
with_arrows: true,
negative_ends: Some([Vec2::new(x_min, origin.y), Vec2::new(origin.x, y_max)]),
}) })
} }
@@ -1423,7 +1525,10 @@ pub fn detect_shape(points: &[StylusSample]) -> Option<RecognizedShape> {
// Kandidaten in Prioritätsreihenfolge; 3. offene Formen (Koordinatensystem, Gerade) zuletzt. // Kandidaten in Prioritätsreihenfolge; 3. offene Formen (Koordinatensystem, Gerade) zuletzt.
// Übernommen wird der erste, der den Strich wirklich beschreibt. Passt keiner, bleibt der // Übernommen wird der erste, der den Strich wirklich beschreibt. Passt keiner, bleibt der
// Strich unverändert (vorher wurde fast jede geschlossene Kritzelei zu einer Form). // Strich unverändert (vorher wurde fast jede geschlossene Kritzelei zu einer Form).
let candidates: [&dyn Fn() -> Option<RecognizedShape>; 6] = [ let candidates: [&dyn Fn() -> Option<RecognizedShape>; 7] = [
// Das Achsenkreuz zuerst: Seine Prüfung ist streng, und ein hin und her gezeichnetes
// „+“ könnte sonst als Vieleck durchgehen
&|| detect_axis_cross(&pts),
&|| { &|| {
if closed_fits_well { if closed_fits_well {
None None
@@ -1908,6 +2013,51 @@ pub fn shape_to_mesh(
x_axis_end, x_axis_end,
y_axis_end, y_axis_end,
with_arrows, with_arrows,
negative_ends: Some([x_axis_start, y_axis_start]),
} => {
// Achsenkreuz: zwei durchgehende Achsen, Pfeilspitzen an allen vier Enden
let mut vertices = Vec::new();
for (neg, pos) in [(*x_axis_start, *x_axis_end), (*y_axis_start, *y_axis_end)] {
let dir = (pos - neg).normalize_or_zero();
let pos_head = arrow_head(pos, dir, half_w, (pos - *origin).length());
let neg_head = arrow_head(neg, -dir, half_w, (neg - *origin).length());
let (from, to) = if *with_arrows {
(neg_head.0, pos_head.0)
} else {
(neg, pos)
};
push_band(&mut vertices, from, to, half_w, color);
if *with_arrows {
for (tip, (_, left, right)) in [(pos, pos_head), (neg, neg_head)] {
push_triangle(&mut vertices, tip, left, right, color);
}
}
}
// Abtastpunkte als Linienzug ohne Diagonalen (jeder Arm vom Ursprung aus)
let samples = [
*x_axis_start,
*origin,
*x_axis_end,
*origin,
*y_axis_end,
*origin,
*y_axis_start,
]
.map(|position| StylusSample {
position,
pressure: 0.5,
timestamp: 0.0,
})
.to_vec();
(samples, vertices)
}
RecognizedShape::CoordinateSystem {
origin,
x_axis_end,
y_axis_end,
with_arrows,
negative_ends: None,
} => { } => {
let mut vertices = Vec::new(); let mut vertices = Vec::new();
let mut samples = Vec::new(); let mut samples = Vec::new();
@@ -2012,6 +2162,40 @@ pub fn shape_to_mesh(
} }
} }
/// Hängt ein gerades Band der halben Breite `half_w` von `a` nach `b` an einen Triangle-Strip
/// an (über degenerierte Dreiecke, falls er schon Vertizes hat).
fn push_band(vertices: &mut Vec<InkVertex>, a: Vec2, b: Vec2, half_w: f32, color: [f32; 4]) {
let dir = (b - a).normalize_or_zero();
let norm = Vec2::new(-dir.y, dir.x) * half_w;
let first = InkVertex::new(a.x + norm.x, a.y + norm.y, color);
if let Some(&last) = vertices.last() {
vertices.push(last);
vertices.push(first);
}
vertices.push(first);
vertices.push(InkVertex::new(a.x - norm.x, a.y - norm.y, color));
vertices.push(InkVertex::new(b.x + norm.x, b.y + norm.y, color));
vertices.push(InkVertex::new(b.x - norm.x, b.y - norm.y, color));
}
/// Hängt ein Dreieck (Pfeilspitze) an einen Triangle-Strip an.
fn push_triangle(
vertices: &mut Vec<InkVertex>,
tip: Vec2,
left: Vec2,
right: Vec2,
color: [f32; 4],
) {
let tip_v = InkVertex::new(tip.x, tip.y, color);
if let Some(&last) = vertices.last() {
vertices.push(last);
vertices.push(tip_v);
}
vertices.push(tip_v);
vertices.push(InkVertex::new(left.x, left.y, color));
vertices.push(InkVertex::new(right.x, right.y, color));
}
/// Erzeugt einen neuen `Stroke` aus einer erkannten Form. /// Erzeugt einen neuen `Stroke` aus einer erkannten Form.
pub fn shape_to_stroke( pub fn shape_to_stroke(
shape: RecognizedShape, shape: RecognizedShape,
@@ -2210,6 +2394,7 @@ mod tests {
x_axis_end: Vec2::new(900.0, 900.0), x_axis_end: Vec2::new(900.0, 900.0),
y_axis_end: Vec2::new(300.0, 300.0), y_axis_end: Vec2::new(300.0, 300.0),
with_arrows: true, with_arrows: true,
negative_ends: None,
}; };
let width = 30.0; let width = 30.0;
let (_, vertices) = shape_to_mesh(&shape, width, [0.0, 0.0, 0.0, 1.0]); let (_, vertices) = shape_to_mesh(&shape, width, [0.0, 0.0, 0.0, 1.0]);
@@ -2479,12 +2664,167 @@ mod tests {
Some(RecognizedShape::Triangle { .. }) => "Dreieck", Some(RecognizedShape::Triangle { .. }) => "Dreieck",
Some(RecognizedShape::Rhombus { .. }) => "Raute", Some(RecognizedShape::Rhombus { .. }) => "Raute",
Some(RecognizedShape::Polygon { .. }) => "Polygon", Some(RecognizedShape::Polygon { .. }) => "Polygon",
Some(RecognizedShape::CoordinateSystem {
negative_ends: Some(_),
..
}) => "Achsenkreuz",
Some(RecognizedShape::CoordinateSystem { .. }) => "Koordinatensystem", Some(RecognizedShape::CoordinateSystem { .. }) => "Koordinatensystem",
Some(RecognizedShape::Octahedron { .. }) => "Oktaeder", Some(RecognizedShape::Octahedron { .. }) => "Oktaeder",
None => "nichts", None => "nichts",
} }
} }
#[test]
fn test_detect_axis_cross_in_one_stroke() {
let c = Vec2::new(800.0, 900.0);
let v = |x: f32, y: f32| c + Vec2::new(x, y);
// Verschiedene Zeichenreihenfolgen eines „+“ in einem Zug
let crosses: [(&str, Vec<Vec2>); 4] = [
(
"waagerecht hin, zurück zur Mitte, senkrecht",
vec![
v(-200.0, 0.0),
v(200.0, 0.0),
v(0.0, 0.0),
v(0.0, -150.0),
v(0.0, 150.0),
],
),
(
"senkrecht zuerst",
vec![
v(0.0, -180.0),
v(0.0, 180.0),
v(0.0, 0.0),
v(-220.0, 0.0),
v(220.0, 0.0),
],
),
(
"Arme ungleich lang",
vec![
v(-100.0, 0.0),
v(320.0, 0.0),
v(0.0, 0.0),
v(0.0, 250.0),
v(0.0, -120.0),
],
),
(
"Mitte zuerst, alle Arme hin und zurück",
vec![
v(0.0, 0.0),
v(200.0, 0.0),
v(0.0, 0.0),
v(0.0, -200.0),
v(0.0, 0.0),
v(-200.0, 0.0),
v(0.0, 0.0),
v(0.0, 200.0),
],
),
];
for (seed, (name, outline)) in crosses.iter().enumerate() {
let samples = freehand(outline, false, seed as f32);
let shape = detect_shape(&samples);
assert_eq!(kind(&shape), "Achsenkreuz", "{name}");
let Some(RecognizedShape::CoordinateSystem {
origin,
x_axis_end,
y_axis_end,
negative_ends: Some([x_start, y_start]),
with_arrows: true,
}) = shape
else {
unreachable!()
};
assert!((origin - c).length() < 8.0, "{name}: Ursprung {origin}");
// Achsenparallel, x nach rechts, y nach oben
assert_eq!(x_axis_end.y, origin.y);
assert_eq!(x_start.y, origin.y);
assert_eq!(y_axis_end.x, origin.x);
assert!(x_axis_end.x > origin.x && x_start.x < origin.x, "{name}");
assert!(y_axis_end.y < origin.y && y_start.y > origin.y, "{name}");
}
// Keine Kreuze: L-Form, T-Form, einzelne Linie, Rechteck
let not_crosses: [(&str, Vec<Vec2>, bool); 4] = [
("L", vec![v(0.0, -200.0), v(0.0, 0.0), v(250.0, 0.0)], false),
(
"T",
vec![v(-200.0, 0.0), v(200.0, 0.0), v(0.0, 0.0), v(0.0, 200.0)],
false,
),
("Linie", vec![v(-200.0, 0.0), v(200.0, 0.0)], false),
(
"Rechteck",
vec![
v(-200.0, -100.0),
v(200.0, -100.0),
v(200.0, 100.0),
v(-200.0, 100.0),
],
true,
),
];
for (seed, (name, outline, closed)) in not_crosses.iter().enumerate() {
let samples = freehand(outline, *closed, seed as f32 + 10.0);
let points: Vec<Vec2> = samples.iter().map(|s| s.position).collect();
assert!(
detect_axis_cross(&points).is_none(),
"{name} ist kein Kreuz"
);
assert_ne!(kind(&detect_shape(&samples)), "Achsenkreuz", "{name}");
}
}
#[test]
fn test_axis_cross_mesh_and_transforms() {
let mut shape = RecognizedShape::CoordinateSystem {
origin: Vec2::new(500.0, 500.0),
x_axis_end: Vec2::new(800.0, 500.0),
y_axis_end: Vec2::new(500.0, 300.0),
with_arrows: true,
negative_ends: Some([Vec2::new(300.0, 500.0), Vec2::new(500.0, 700.0)]),
};
assert_eq!(shape.name(), "Achsenkreuz");
let (samples, vertices) = shape_to_mesh(&shape, 10.0, [0.0, 0.0, 0.0, 1.0]);
// Alle vier Pfeilspitzen sind Eckpunkte des Netzes
for tip in [
(800.0, 500.0),
(500.0, 300.0),
(300.0, 500.0),
(500.0, 700.0),
] {
assert!(
vertices
.iter()
.any(|v| (Vec2::from(v.position) - Vec2::from(tip)).length() < 1e-3),
"Spitze {tip:?} fehlt"
);
}
// Die Form passt zu sich selbst gezeichnet (Abtastpunkte ohne Diagonalen)
let points: Vec<Vec2> = samples.iter().map(|s| s.position).collect();
assert!(shape_fit_error(&points, &shape).unwrap() < 1e-3);
// Verschieben bewegt auch die negativen Enden
shape.translate(Vec2::new(10.0, 20.0));
let RecognizedShape::CoordinateSystem {
negative_ends: Some([x_start, y_start]),
..
} = shape
else {
unreachable!()
};
assert_eq!(x_start, Vec2::new(310.0, 520.0));
assert_eq!(y_start, Vec2::new(510.0, 720.0));
// Ältere Dateien ohne das Feld bleiben L-Koordinatensysteme; neue Dateien enthalten es
let json = serde_json::to_string(&shape).unwrap();
assert!(json.contains("negative_ends"));
let old = r#"{"CoordinateSystem":{"origin":[0.0,0.0],"x_axis_end":[1.0,0.0],"y_axis_end":[0.0,-1.0],"with_arrows":true}}"#;
let parsed: RecognizedShape = serde_json::from_str(old).unwrap();
assert_eq!(parsed.name(), "Koordinatensystem");
}
#[test] #[test]
fn test_detect_shape_freehand_classification() { fn test_detect_shape_freehand_classification() {
let c = Vec2::new(1000.0, 1400.0); let c = Vec2::new(1000.0, 1400.0);
@@ -2884,6 +3224,7 @@ mod tests {
x_axis_end: x_end, x_axis_end: x_end,
y_axis_end: y_end, y_axis_end: y_end,
with_arrows: true, with_arrows: true,
negative_ends: None,
}; };
let (_, vertices) = shape_to_mesh(&shape, 5.0, [0.0, 0.0, 0.0, 1.0]); let (_, vertices) = shape_to_mesh(&shape, 5.0, [0.0, 0.0, 0.0, 1.0]);
let (y_base, ..) = arrow_head(y_end, Vec2::new(0.0, -1.0), 2.5, 200.0); let (y_base, ..) = arrow_head(y_end, Vec2::new(0.0, -1.0), 2.5, 200.0);
+13
View File
@@ -3,6 +3,9 @@
use crate::document::Stroke; use crate::document::Stroke;
/// Eine rückgängig machbare Benutzeraktion auf einer Seite. /// Eine rückgängig machbare Benutzeraktion auf einer Seite.
// Der Verlauf hält nur wenige hundert Einträge; die Größe des Ersetzen-Eintrags (zwei Striche)
// fällt nicht ins Gewicht, eine Box würde nur die Aufrufe umständlicher machen
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum HistoryAction { pub enum HistoryAction {
/// Ein Strich wurde neu gezeichnet. /// Ein Strich wurde neu gezeichnet.
@@ -18,6 +21,9 @@ pub enum HistoryAction {
before: Vec<Stroke>, before: Vec<Stroke>,
after: Vec<Stroke>, after: Vec<Stroke>,
}, },
/// Platz geschaffen: Inhalt mehrerer Seiten wurde verschoben. Rückgängig macht das das
/// Notizbuch (`Notebook::undo`), nicht die einzelne Seite.
SpaceShift(Box<crate::document::SpaceShift>),
/// Teilradierer: Striche wurden durch ihre übrig gebliebenen Stücke ersetzt. /// Teilradierer: Striche wurden durch ihre übrig gebliebenen Stücke ersetzt.
SplitStrokes { SplitStrokes {
removed: Vec<Stroke>, removed: Vec<Stroke>,
@@ -136,6 +142,10 @@ impl HistoryStack {
self.redo_stack self.redo_stack
.push(HistoryAction::ModifyStrokes { before, after }); .push(HistoryAction::ModifyStrokes { before, after });
} }
// Betrifft mehrere Seiten: Das Notizbuch macht es rückgängig, hier nur umbuchen
HistoryAction::SpaceShift(shift) => {
self.redo_stack.push(HistoryAction::SpaceShift(shift));
}
HistoryAction::SplitStrokes { removed, added, at } => { HistoryAction::SplitStrokes { removed, added, at } => {
strokes.retain(|s| !added.iter().any(|a| a.id == s.id)); strokes.retain(|s| !added.iter().any(|a| a.id == s.id));
strokes.extend(removed.iter().cloned()); strokes.extend(removed.iter().cloned());
@@ -187,6 +197,9 @@ impl HistoryStack {
self.undo_stack self.undo_stack
.push(HistoryAction::ModifyStrokes { before, after }); .push(HistoryAction::ModifyStrokes { before, after });
} }
HistoryAction::SpaceShift(shift) => {
self.undo_stack.push(HistoryAction::SpaceShift(shift));
}
HistoryAction::SplitStrokes { removed, added, at } => { HistoryAction::SplitStrokes { removed, added, at } => {
strokes.retain(|s| !removed.iter().any(|r| r.id == s.id)); strokes.retain(|s| !removed.iter().any(|r| r.id == s.id));
strokes.extend(added.iter().cloned()); strokes.extend(added.iter().cloned());
+65
View File
@@ -236,6 +236,39 @@ impl Library {
} }
} }
/// Verschiebt einen Ordner samt Inhalt nach `new_parent` (oberste Ebene: [`ROOT`]) und
/// gibt seinen neuen Pfad zurück. Nicht in sich selbst oder einen eigenen Unterordner.
pub fn move_folder(&mut self, path: &str, new_parent: &str) -> Result<String, String> {
if !self.folders.contains(path) {
return Err("Den Ordner gibt es nicht mehr".to_string());
}
if !new_parent.is_empty() && !self.folders.contains(new_parent) {
return Err("Den Zielordner gibt es nicht mehr".to_string());
}
if is_within(new_parent, path) {
return Err("Ein Ordner kann nicht in sich selbst verschoben werden".to_string());
}
let name = folder_name(path);
let new_path = join(new_parent, name);
if new_path == path {
return Ok(new_path);
}
if self.folders.contains(&new_path) {
return Err(format!("Im Zielordner gibt es schon einen Ordner „{name}“"));
}
self.move_contents(path, &new_path);
Ok(new_path)
}
/// Mögliche Ziele für das Verschieben des Ordners `path`: alle Ordner außer ihm selbst und
/// seinen Unterordnern (die oberste Ebene ist immer ein Ziel).
pub fn folder_move_targets(&self, path: &str) -> Vec<&str> {
self.all_folders()
.into_iter()
.filter(|f| !is_within(f, path))
.collect()
}
/// Legt das Notizbuch in `folder` (oberste Ebene: [`ROOT`]). /// Legt das Notizbuch in `folder` (oberste Ebene: [`ROOT`]).
pub fn move_notebook(&mut self, notebook_id: &str, folder: &str) -> Result<(), String> { pub fn move_notebook(&mut self, notebook_id: &str, folder: &str) -> Result<(), String> {
if folder.is_empty() { if folder.is_empty() {
@@ -338,6 +371,38 @@ mod tests {
assert_eq!(lib.subfolders(ROOT), ["Mathe", "Physik"]); assert_eq!(lib.subfolders(ROOT), ["Mathe", "Physik"]);
} }
#[test]
fn test_move_folder() {
let mut lib = library();
lib.create_folder(ROOT, "Mathe-Sammlung").unwrap();
// Ordner samt Inhalt in einen anderen Ordner schieben
let new = lib
.move_folder("3. Semester/Mathe", "Mathe-Sammlung")
.unwrap();
assert_eq!(new, "Mathe-Sammlung/Mathe");
assert_eq!(lib.folder_of("analysis"), "Mathe-Sammlung/Mathe");
assert!(!lib.folders.contains("3. Semester/Mathe"));
// Auf die oberste Ebene und zurück
assert_eq!(
lib.move_folder("Mathe-Sammlung/Mathe", ROOT).unwrap(),
"Mathe"
);
assert_eq!(
lib.move_folder("Mathe", "3. Semester").unwrap(),
"3. Semester/Mathe"
);
// Nicht in sich selbst oder in einen eigenen Unterordner
assert!(lib.move_folder("3. Semester", "3. Semester").is_err());
assert!(lib.move_folder("3. Semester", "3. Semester/Mathe").is_err());
assert_eq!(lib.folder_move_targets("3. Semester"), ["Mathe-Sammlung"]);
// Namenskollision am Ziel
lib.create_folder("Mathe-Sammlung", "Physik").unwrap();
assert!(lib
.move_folder("3. Semester/Physik", "Mathe-Sammlung")
.is_err());
assert_eq!(lib.folder_of("uebung"), "3. Semester");
}
#[test] #[test]
fn test_move_notebook() { fn test_move_notebook() {
let mut lib = library(); let mut lib = library();
+271 -47
View File
@@ -30,11 +30,122 @@ use ink_notes::platform::{self, DisplayBackend};
use ink_notes::selection::{ use ink_notes::selection::{
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState, render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
}; };
use ink_notes::settings::{SidebarSide, UiSettings}; use ink_notes::settings::{
SidebarSide, StylusAction, StylusButton, UiSettings, MAX_STYLUS_BUTTONS,
};
use ink_notes::storage::{self, NotebookManifest}; use ink_notes::storage::{self, NotebookManifest};
use ink_notes::ui::{self, ActiveTool, UiAction, UiState}; use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
use ink_notes::updater::UpdaterController; use ink_notes::updater::UpdaterController;
/// Platz schaffen: Druck auf das Blatt (physische Pixel) setzt die Ansatzhöhe auf der
/// aktuellen Seite. `false` neben dem Blatt oder auf einer PDF-Seite.
fn space_press(
screen: (f32, f32),
canvas: &CanvasState,
notebook: &Notebook,
ui_state: &mut UiState,
) -> bool {
let Some(page_pt) = canvas.screen_to_page(screen.0, screen.1) else {
return false;
};
let index = notebook.current_page_index;
if notebook.space_run(index).is_none() {
ui_state.set_export_message(
"Auf Seiten mit importiertem PDF lässt sich kein Platz schaffen".to_string(),
);
return false;
}
ui_state.space_drag = Some(ui::SpaceDrag {
page_index: index,
y: page_pt.y,
dy: 0.0,
up_limit: notebook.space_up_limit(index, page_pt.y),
});
true
}
/// Platz schaffen: Verschiebung aus dem senkrechten Abstand zur Ansatzhöhe.
fn space_move(screen: (f32, f32), canvas: &CanvasState, ui_state: &mut UiState) {
if let Some(drag) = &mut ui_state.space_drag {
let start = canvas.page_to_screen(Vec2::new(0.0, drag.y));
let below = canvas.page_to_screen(Vec2::new(0.0, drag.y + 1000.0));
let px_per_unit = (below[1] - start[1]) / 1000.0;
if px_per_unit > 0.0 {
drag.dy = (screen.1 - start[1]) / px_per_unit;
}
}
}
/// Platz schaffen beim Loslassen ausführen (alle beteiligten Seiten werden dafür geladen).
fn space_release(
canvas: &mut CanvasState,
notebook: &mut Notebook,
manifest: &mut NotebookManifest,
autosave: &mut AutosaveController,
storage_dir: &Path,
ui_state: &mut UiState,
) {
let Some(drag) = ui_state.space_drag.take() else {
return;
};
if let Some(run) = notebook.space_run(drag.page_index) {
for index in run {
let _ = storage::ensure_page_loaded(storage_dir, notebook, index);
}
}
let dy = drag.clamped_dy();
if let Some(changed) = notebook.make_space(drag.page_index, drag.y, dy) {
persist_pages(&changed, notebook, manifest, autosave);
canvas.sync_page(notebook.current_page_mut());
let mm = (dy / ink_notes::document::UNITS_PER_MM).abs().round();
ui_state.set_export_message(if dy > 0.0 {
format!("{mm} mm Platz geschaffen (Rückgängig mit Strg+Z)")
} else {
format!("{mm} mm Platz geschlossen (Rückgängig mit Strg+Z)")
});
}
}
/// Merkt geänderte Seiten (Kennungen) und das Manifest zum Speichern vor.
fn persist_pages(
ids: &[u64],
notebook: &Notebook,
manifest: &mut NotebookManifest,
autosave: &mut AutosaveController,
) {
manifest.sync_from_notebook(notebook);
for page in notebook.pages.iter().filter(|p| ids.contains(&p.id)) {
autosave.mark_page_dirty(page, manifest);
}
autosave.mark_manifest_dirty(manifest);
}
/// Wählt `tool` und stellt Farbe und Stärke des Live-Strichs passend ein.
fn activate_tool(tool: ActiveTool, ui_state: &mut UiState, canvas: &mut CanvasState) {
ui_state.active_tool = tool;
match tool {
ActiveTool::Pen | ActiveTool::Shape => {
canvas.set_stroke_color(ui_state.pen_color);
canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm);
}
ActiveTool::Highlighter => {
canvas.set_stroke_color(ui_state.highlighter_color);
canvas.set_stroke_width_mm(ui_state.highlighter_stroke_width_mm);
}
ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text | ActiveTool::Space => {}
}
}
/// Kennung einer Stifttaste, die über Programmstarts hinweg gleich bleibt (die Tablet-
/// Schnittstelle liefert nur eine undurchsichtige, aber vergleichbare Kennung).
fn stylus_button_key(id: &octotablet::tool::ButtonID) -> u64 {
use std::hash::{Hash, Hasher};
// DefaultHasher::new() verwendet feste Schlüssel: gleiche Taste, gleicher Wert
let mut hasher = std::collections::hash_map::DefaultHasher::new();
id.hash(&mut hasher);
hasher.finish()
}
/// Liest Notizbücher und Ordner für den Dokumenten-Manager neu ein. /// Liest Notizbücher und Ordner für den Dokumenten-Manager neu ein.
fn refresh_notebooks(base_dir: &Path, ui_state: &mut UiState) { fn refresh_notebooks(base_dir: &Path, ui_state: &mut UiState) {
ui_state.available_notebooks = storage::list_notebooks(base_dir); ui_state.available_notebooks = storage::list_notebooks(base_dir);
@@ -1107,6 +1218,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
ui_state.pressure_curve = ui_settings.pressure_curve; ui_state.pressure_curve = ui_settings.pressure_curve;
ui_state.continuous_scroll = ui_settings.continuous_scroll; ui_state.continuous_scroll = ui_settings.continuous_scroll;
ui_state.pen_presets = ui_settings.pen_presets; ui_state.pen_presets = ui_settings.pen_presets;
ui_state.stylus_buttons = ui_settings.stylus_buttons.clone();
ui_state.select_pen(ui_settings.active_pen); ui_state.select_pen(ui_settings.active_pen);
ui_state.highlighter_colors = ui_settings.highlighter_colors; ui_state.highlighter_colors = ui_settings.highlighter_colors;
ui_state.select_highlighter_color(ui_settings.active_highlighter); ui_state.select_highlighter_color(ui_settings.active_highlighter);
@@ -1186,7 +1298,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut is_stylus_selecting = false; let mut is_stylus_selecting = false;
let mut is_stylus_in_proximity = false; let mut is_stylus_in_proximity = false;
let mut stylus_tool_id: Option<octotablet::tool::ID> = None; let mut stylus_tool_id: Option<octotablet::tool::ID> = None;
let mut stylus_barrel_pressed = false; // Gedrückte Stifttaste (Kennung, Belegung) und das Werkzeug, das während eines Strichs mit
// einer Werkzeug-Taste (Auswählen, Textmarker, Stift) vertreten wird
let mut stylus_held: Option<(u64, StylusAction)> = None;
let mut tool_before_button: Option<ActiveTool> = None;
// Platz schaffen läuft mit dem Stift bzw. dem Finger (sonst mit der Maus)
let mut is_stylus_spacing = false;
let mut is_touch_spacing = false;
let mut stylus_screen_pos = (0.0_f32, 0.0_f32); 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 // Stift hat auf der UI aufgesetzt: alle Ereignisse bis zum Abheben gehen an egui
let mut stylus_ui_capture = false; let mut stylus_ui_capture = false;
@@ -1307,6 +1425,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if let Ok(tablet_events) = mgr.pump() { if let Ok(tablet_events) = mgr.pump() {
let scale = window.scale_factor() as f32; let scale = window.scale_factor() as f32;
let now = start_time.elapsed().as_secs_f64(); let now = start_time.elapsed().as_secs_f64();
// Aufsetzen erst mit der Position desselben Frames verarbeiten
let tablet_events = ink_notes::platform::down_after_pose(
tablet_events,
|e| matches!(e, octotablet::events::Event::Tool { event: octotablet::events::ToolEvent::Down, .. }),
|e| matches!(e, octotablet::events::Event::Tool { event: octotablet::events::ToolEvent::Pose(_), .. }),
);
for t_event in tablet_events { for t_event in tablet_events {
if let octotablet::events::Event::Tool { tool, event } = t_event { if let octotablet::events::Event::Tool { tool, event } = t_event {
@@ -1352,28 +1476,55 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if is_erasing { if is_erasing {
is_erasing = false; is_erasing = false;
} }
if is_stylus_spacing {
is_stylus_spacing = false;
space_release(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &storage_dir, &mut ui_state);
window.request_redraw();
}
if is_stylus_selecting { if is_stylus_selecting {
is_stylus_selecting = false; is_stylus_selecting = false;
selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state); selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state);
window.request_redraw(); window.request_redraw();
} }
if let Some(tool) = tool_before_button.take() {
activate_tool(tool, &mut ui_state, &mut canvas);
}
stylus_held = None;
ui_state.pressed_stylus_button = None;
} }
} }
octotablet::events::ToolEvent::Button { pressed, .. } => { octotablet::events::ToolEvent::Button { button_id, pressed } => {
stylus_barrel_pressed = pressed; // Tasten werden beim ersten Drücken gelernt (Standard: Radieren)
if pressed && is_stylus_drawing { let key = stylus_button_key(&button_id);
is_stylus_drawing = false; let mut index = ui_state.stylus_buttons.iter().position(|b| b.id == key);
is_drawing = false; if index.is_none() && pressed && ui_state.stylus_buttons.len() < MAX_STYLUS_BUTTONS {
canvas.cancel_stroke(); ui_state.stylus_buttons.push(StylusButton { id: key, action: StylusAction::Erase });
is_erasing = true; index = Some(ui_state.stylus_buttons.len() - 1);
window.request_redraw(); ui_settings.stylus_buttons = ui_state.stylus_buttons.clone();
} else if !pressed save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
&& is_erasing
&& !tool_is_hardware_eraser
&& ui_state.active_tool != ActiveTool::Eraser
{
is_erasing = false;
} }
let action = index.map_or(StylusAction::Erase, |i| ui_state.stylus_buttons[i].action);
if pressed {
stylus_held = Some((key, action));
ui_state.pressed_stylus_button = index;
if action == StylusAction::Erase && is_stylus_drawing {
is_stylus_drawing = false;
is_drawing = false;
canvas.cancel_stroke();
is_erasing = true;
}
} else if stylus_held.is_some_and(|(held, _)| held == key) {
stylus_held = None;
ui_state.pressed_stylus_button = None;
if action == StylusAction::Erase
&& is_erasing
&& !tool_is_hardware_eraser
&& ui_state.active_tool != ActiveTool::Eraser
{
is_erasing = false;
}
}
window.request_redraw();
} }
octotablet::events::ToolEvent::Pose(pose) => { octotablet::events::ToolEvent::Pose(pose) => {
is_stylus_in_proximity = true; is_stylus_in_proximity = true;
@@ -1405,7 +1556,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
continue; continue;
} }
if is_erasing { if is_stylus_spacing {
space_move((sx, sy), &canvas, &mut ui_state);
window.request_redraw();
} else if is_erasing {
focus_page_at!((sx, sy)); focus_page_at!((sx, sy));
if let Some(page_pt) = canvas.screen_to_page(sx, sy) { if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
if notebook.erase_at(page_pt, if notebook.erase_at(page_pt,
@@ -1471,11 +1625,28 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
focus_page_at!((sx, sy)); focus_page_at!((sx, sy));
// Werkzeug-Taste gedrückt: für diesen Strich dieses Werkzeug
let button_action = stylus_held.map(|(_, action)| action);
let button_tool = match button_action {
Some(StylusAction::Select) => Some(ActiveTool::Select),
Some(StylusAction::Highlighter) => Some(ActiveTool::Highlighter),
Some(StylusAction::Pen) => Some(ActiveTool::Pen),
_ => None,
};
if let Some(tool) = button_tool
.filter(|&t| !tool_is_hardware_eraser && t != ui_state.active_tool)
{
tool_before_button = Some(ui_state.active_tool);
activate_tool(tool, &mut ui_state, &mut canvas);
}
let should_erase = tool_is_hardware_eraser let should_erase = tool_is_hardware_eraser
|| stylus_barrel_pressed || button_action == Some(StylusAction::Erase)
|| ui_state.active_tool == ActiveTool::Eraser; || ui_state.active_tool == ActiveTool::Eraser;
if !should_erase && ui_state.active_tool == ActiveTool::Text { if !should_erase && ui_state.active_tool == ActiveTool::Space {
is_stylus_spacing = space_press((sx, sy), &canvas, &notebook, &mut ui_state);
window.request_redraw();
} else if !should_erase && ui_state.active_tool == ActiveTool::Text {
if let Some(page_pt) = canvas.screen_to_page(sx, sy) { if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
text_tool_click(page_pt, &mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state); text_tool_click(page_pt, &mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
@@ -1544,12 +1715,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
is_erasing = false; is_erasing = false;
} }
if is_stylus_spacing {
is_stylus_spacing = false;
space_release(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &storage_dir, &mut ui_state);
window.request_redraw();
}
if is_stylus_selecting { if is_stylus_selecting {
is_stylus_selecting = false; is_stylus_selecting = false;
if selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state) { if selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state) {
window.request_redraw(); window.request_redraw();
} }
} }
let restore_tool = tool_before_button.take();
if is_stylus_drawing { if is_stylus_drawing {
is_stylus_drawing = false; is_stylus_drawing = false;
@@ -1584,6 +1762,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
snapped_shape, snapped_shape,
); );
} }
// Nach einem Strich mit Werkzeug-Taste wieder das gewählte Werkzeug
if let Some(tool) = restore_tool {
activate_tool(tool, &mut ui_state, &mut canvas);
window.request_redraw();
}
} }
_ => {} _ => {}
} }
@@ -1825,10 +2008,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Undo: Strg + Z // Undo: Strg + Z
Key::Character(c) if c.eq_ignore_ascii_case("z") && ctrl && !shift => { Key::Character(c) if c.eq_ignore_ascii_case("z") && ctrl && !shift => {
finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state); finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
if notebook.current_page_mut().undo() { if let Some(changed) = notebook.undo() {
selection_state.clear(); selection_state.clear();
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest); persist_pages(&changed, &notebook, &mut manifest, &mut autosave);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -1840,10 +2023,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|| (c.eq_ignore_ascii_case("z") && ctrl && shift) => || (c.eq_ignore_ascii_case("z") && ctrl && shift) =>
{ {
finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state); finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
if notebook.current_page_mut().redo() { if let Some(changed) = notebook.redo() {
selection_state.clear(); selection_state.clear();
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest); persist_pages(&changed, &notebook, &mut manifest, &mut autosave);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -1930,7 +2113,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Radierer umschalten: E // Radierer umschalten: E
Key::Character(c) if c.eq_ignore_ascii_case("e") && !ctrl => { Key::Character(c) if c.eq_ignore_ascii_case("e") && !ctrl => {
ui_state.active_tool = match ui_state.active_tool { ui_state.active_tool = match ui_state.active_tool {
ActiveTool::Pen | ActiveTool::Highlighter | ActiveTool::Shape | ActiveTool::Select | ActiveTool::Text => ActiveTool::Eraser, ActiveTool::Pen | ActiveTool::Highlighter | ActiveTool::Shape | ActiveTool::Select | ActiveTool::Text | ActiveTool::Space => ActiveTool::Eraser,
ActiveTool::Eraser => ActiveTool::Pen, ActiveTool::Eraser => ActiveTool::Pen,
}; };
ui_state.is_pen_menu_open = false; ui_state.is_pen_menu_open = false;
@@ -2207,6 +2390,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} else if button == MouseButton::Left { } else if button == MouseButton::Left {
let (sx, sy) = cursor_screen_pos; let (sx, sy) = cursor_screen_pos;
// Platz schaffen: Ansatzhöhe merken
if ui_state.active_tool == ActiveTool::Space {
space_press((sx, sy), &canvas, &notebook, &mut ui_state);
window.request_redraw();
return;
}
// Text-Werkzeug: Textfeld öffnen // Text-Werkzeug: Textfeld öffnen
if ui_state.active_tool == ActiveTool::Text { if ui_state.active_tool == ActiveTool::Text {
if let Some(page_pt) = canvas.screen_to_page(sx, sy) { if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
@@ -2268,6 +2458,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if is_erasing { if is_erasing {
is_erasing = false; is_erasing = false;
} }
if ui_state.space_drag.is_some() && !is_stylus_spacing && !is_touch_spacing {
space_release(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &storage_dir, &mut ui_state);
window.request_redraw();
}
if ui_state.active_tool == ActiveTool::Select if ui_state.active_tool == ActiveTool::Select
&& !is_stylus_selecting && !is_stylus_selecting
&& selection_release(modifiers.shift_key(), &mut notebook, &mut manifest, &mut autosave, &mut selection_state) && selection_release(modifiers.shift_key(), &mut notebook, &mut manifest, &mut autosave, &mut selection_state)
@@ -2321,7 +2515,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Wenn der Stylus aktiv zeichnet, keine Mauscursor-Bewegungen verarbeiten // Wenn der Stylus aktiv zeichnet, keine Mauscursor-Bewegungen verarbeiten
if !is_stylus_drawing && !is_stylus_selecting { if !is_stylus_drawing && !is_stylus_selecting {
if ui_state.active_tool == ActiveTool::Select && selection_state.drag_mode != DragMode::None { if ui_state.space_drag.is_some() && !is_stylus_spacing && !is_touch_spacing {
space_move((sx, sy), &canvas, &mut ui_state);
window.request_redraw();
} else if ui_state.active_tool == ActiveTool::Select && selection_state.drag_mode != DragMode::None {
if let Some(page_pt) = canvas.screen_to_page(sx, sy) { if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
selection_drag(page_pt, notebook.current_page_mut(), &mut canvas, &mut selection_state); selection_drag(page_pt, notebook.current_page_mut(), &mut canvas, &mut selection_state);
window.request_redraw(); window.request_redraw();
@@ -2474,6 +2671,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
window.request_redraw(); window.request_redraw();
} }
is_touch_panning = false; is_touch_panning = false;
if is_touch_spacing {
is_touch_spacing = false;
ui_state.space_drag = None;
}
is_pinch_gesturing = true; is_pinch_gesturing = true;
let pts: Vec<(f32, f32)> = active_touches.values().copied().take(2).collect(); let pts: Vec<(f32, f32)> = active_touches.values().copied().take(2).collect();
@@ -2514,6 +2715,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if selection_press((tx, ty), false, &canvas, notebook.current_page(), egui_ctx.pixels_per_point(), &mut selection_state) { if selection_press((tx, ty), false, &canvas, notebook.current_page(), egui_ctx.pixels_per_point(), &mut selection_state) {
window.request_redraw(); window.request_redraw();
} }
} else if ui_state.active_tool == ActiveTool::Space {
is_touch_spacing = space_press((tx, ty), &canvas, &notebook, &mut ui_state);
window.request_redraw();
} else if ui_state.active_tool.is_drawing() || ui_state.active_tool == ActiveTool::Shape { } else if ui_state.active_tool.is_drawing() || ui_state.active_tool == ActiveTool::Shape {
if let Some(page_pt) = canvas.screen_to_page(tx, ty) { if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
is_touch_drawing = true; is_touch_drawing = true;
@@ -2580,6 +2784,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
canvas.extend_stroke(page_pt.x, page_pt.y, pressure, now); canvas.extend_stroke(page_pt.x, page_pt.y, pressure, now);
window.request_redraw(); window.request_redraw();
} }
} else if is_touch_spacing {
space_move((tx, ty), &canvas, &mut ui_state);
window.request_redraw();
} else if is_erasing { } else if is_erasing {
focus_page_at!((tx, ty)); focus_page_at!((tx, ty));
if let Some(page_pt) = canvas.screen_to_page(tx, ty) { if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
@@ -2612,6 +2819,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
is_erasing = false; is_erasing = false;
} }
if is_touch_spacing {
is_touch_spacing = false;
space_release(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &storage_dir, &mut ui_state);
window.request_redraw();
}
if ui_state.active_tool == ActiveTool::Select if ui_state.active_tool == ActiveTool::Select
&& selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state) && selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state)
{ {
@@ -2674,6 +2887,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.is_none_or(|last: [f32; 3]| last.iter().zip(scroll).any(|(a, b)| (a - b).abs() > 1e-3)); .is_none_or(|last: [f32; 3]| last.iter().zip(scroll).any(|(a, b)| (a - b).abs() > 1e-3));
let busy = is_drawing let busy = is_drawing
|| is_erasing || is_erasing
|| ui_state.space_drag.is_some()
|| !selection_state.is_empty() || !selection_state.is_empty()
|| selection_state.drag_mode != DragMode::None || selection_state.drag_mode != DragMode::None
|| ui_state.ruler_drag.is_some() || ui_state.ruler_drag.is_some()
@@ -2731,22 +2945,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
for action in actions { for action in actions {
match action { match action {
UiAction::SetTool(tool) => { UiAction::SetTool(tool) => {
ui_state.active_tool = tool; activate_tool(tool, &mut ui_state, &mut canvas);
match tool {
ActiveTool::Pen => {
canvas.set_stroke_color(ui_state.pen_color);
canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm);
}
ActiveTool::Highlighter => {
canvas.set_stroke_color(ui_state.highlighter_color);
canvas.set_stroke_width_mm(ui_state.highlighter_stroke_width_mm);
}
ActiveTool::Shape => {
canvas.set_stroke_color(ui_state.pen_color);
canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm);
}
ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {}
}
update_window_title(&window, &notebook, &manifest, tool, autosave.status()); update_window_title(&window, &notebook, &manifest, tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -2766,7 +2965,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
ui_state.highlighter_color = color; ui_state.highlighter_color = color;
canvas.set_stroke_color(color); canvas.set_stroke_color(color);
} }
ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {} ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text | ActiveTool::Space => {}
} }
window.request_redraw(); window.request_redraw();
} }
@@ -2780,7 +2979,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
ui_state.highlighter_stroke_width_mm = width; ui_state.highlighter_stroke_width_mm = width;
canvas.set_stroke_width_mm(width); canvas.set_stroke_width_mm(width);
} }
ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {} ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text | ActiveTool::Space => {}
} }
window.request_redraw(); window.request_redraw();
} }
@@ -2855,20 +3054,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
UiAction::Undo => { UiAction::Undo => {
finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state); finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
if notebook.current_page_mut().undo() { if let Some(changed) = notebook.undo() {
selection_state.clear(); selection_state.clear();
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest); persist_pages(&changed, &notebook, &mut manifest, &mut autosave);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
} }
UiAction::Redo => { UiAction::Redo => {
finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state); finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
if notebook.current_page_mut().redo() { if let Some(changed) = notebook.redo() {
selection_state.clear(); selection_state.clear();
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest); persist_pages(&changed, &notebook, &mut manifest, &mut autosave);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -3045,6 +3244,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
window.request_redraw(); window.request_redraw();
} }
UiAction::MoveFolder { path, new_parent } => {
if let Some(new_path) = change_library(&storage_base_dir, &mut ui_state, |library| {
library.move_folder(&path, &new_parent)
}) {
// Der gezeigte Ordner kann darin liegen
let shown = &ui_state.notebook_folder;
if shown == &path || shown.starts_with(&format!("{path}/")) {
ui_state.notebook_folder = format!("{new_path}{}", &shown[path.len()..]);
}
let target = if new_parent.is_empty() {
"die oberste Ebene".to_string()
} else {
format!("„{}“", ink_notes::library::folder_name(&new_parent))
};
ui_state.set_export_message(format!(
"Ordner „{}“ verschoben in {target}",
ink_notes::library::folder_name(&path)
));
}
window.request_redraw();
}
UiAction::MoveNotebook { id, folder } => { UiAction::MoveNotebook { id, folder } => {
if change_library(&storage_base_dir, &mut ui_state, |library| { if change_library(&storage_base_dir, &mut ui_state, |library| {
library.move_notebook(&id, &folder) library.move_notebook(&id, &folder)
@@ -3412,6 +3632,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
window.request_redraw(); window.request_redraw();
} }
UiAction::SaveStylusButtons => {
ui_settings.stylus_buttons = ui_state.stylus_buttons.clone();
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
}
UiAction::SavePressureCurve(curve) => { UiAction::SavePressureCurve(curve) => {
ui_settings.pressure_curve = curve; ui_settings.pressure_curve = curve;
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
+52
View File
@@ -58,10 +58,62 @@ pub fn display_backend_from_env() -> DisplayBackend {
) )
} }
/// Bringt die Ereignisse eines Tablet-Frames in eine brauchbare Reihenfolge: octotablet meldet
/// das Aufsetzen des Stifts (`Down`) vor der Position desselben Frames (`Pose`). Bis dahin
/// gälte noch die alte Position – kam der Stift schnell heran (Annähern und Aufsetzen im selben
/// Frame), läge sie ganz woanders: Ein Tippen auf einen Knopf ginge daneben, ein Strich begänne
/// mit einer Linie von der alten Stelle. Hier folgt jedes `Down` auf die nächste `Pose`, spätestens
/// vor dem nächsten anderen Ereignis.
pub fn down_after_pose<T>(
events: impl IntoIterator<Item = T>,
is_down: impl Fn(&T) -> bool,
is_pose: impl Fn(&T) -> bool,
) -> Vec<T> {
let mut ordered = Vec::new();
let mut deferred = None;
for event in events {
if is_down(&event) {
if let Some(down) = deferred.replace(event) {
ordered.push(down);
}
continue;
}
let pose = is_pose(&event);
if !pose {
ordered.extend(deferred.take());
}
ordered.push(event);
if pose {
ordered.extend(deferred.take());
}
}
ordered.extend(deferred);
ordered
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn test_down_follows_pose_of_its_frame() {
let order = |events: &str| {
down_after_pose(events.chars(), |c| *c == 'D', |c| *c == 'P')
.into_iter()
.collect::<String>()
};
// In, Down, Pose, Button, Frame -> Down erst mit der neuen Position
assert_eq!(order("IDPBF"), "IPDBF");
// Down ohne Pose im Frame: vor dem Frame-Ende
assert_eq!(order("DF"), "DF");
assert_eq!(order("DBF"), "DBF");
// Am Ende ohne weiteres Ereignis
assert_eq!(order("PD"), "PD");
// Andere Ereignisse bleiben unverändert
assert_eq!(order("PFPFUF"), "PFPFUF");
assert_eq!(order("IDPFPFUF"), "IPDFPFUF");
}
#[test] #[test]
fn test_choose_display_backend() { fn test_choose_display_backend() {
// WSL mit X11-Display: X11 (Wayland unter WSLg bricht ab) // WSL mit X11-Display: X11 (Wayland unter WSLg bricht ab)
+61
View File
@@ -150,6 +150,53 @@ fn sanitized_color(color: [f32; 4], fallback: [f32; 4]) -> [f32; 4] {
out out
} }
/// Was eine Taste am Stift bewirkt, solange sie gedrückt ist.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum StylusAction {
/// Radieren (Standard, wie bisher)
#[default]
Erase,
/// Auswählen und verschieben
Select,
/// Mit dem Textmarker schreiben
Highlighter,
/// Mit dem gewählten Stift schreiben (z. B. während der Textmarker aktiv ist)
Pen,
/// Taste nicht verwenden
Nothing,
}
impl StylusAction {
pub const ALL: [StylusAction; 5] = [
StylusAction::Erase,
StylusAction::Select,
StylusAction::Highlighter,
StylusAction::Pen,
StylusAction::Nothing,
];
pub fn label(self) -> &'static str {
match self {
StylusAction::Erase => "Radieren",
StylusAction::Select => "Auswählen",
StylusAction::Highlighter => "Textmarker",
StylusAction::Pen => "Stift",
StylusAction::Nothing => "Nichts",
}
}
}
/// Eine Taste am Stift: Kennung (aus der Tablet-Schnittstelle, beim ersten Drücken gelernt)
/// und ihre Belegung.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct StylusButton {
pub id: u64,
pub action: StylusAction,
}
/// Höchstens so viele Stifttasten werden gemerkt.
pub const MAX_STYLUS_BUTTONS: usize = 4;
/// Benutzereinstellungen der Oberfläche. /// Benutzereinstellungen der Oberfläche.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
@@ -173,6 +220,8 @@ pub struct UiSettings {
pub active_highlighter: usize, pub active_highlighter: usize,
pub text_colors: [[f32; 4]; 5], pub text_colors: [[f32; 4]; 5],
pub active_text_color: usize, pub active_text_color: usize,
/// Tasten am Stift in der Reihenfolge, in der sie zuerst gedrückt wurden
pub stylus_buttons: Vec<StylusButton>,
} }
impl Default for UiSettings { impl Default for UiSettings {
@@ -190,6 +239,7 @@ impl Default for UiSettings {
active_highlighter: 0, active_highlighter: 0,
text_colors: DEFAULT_TEXT_COLORS, text_colors: DEFAULT_TEXT_COLORS,
active_text_color: 0, active_text_color: 0,
stylus_buttons: Vec::new(),
} }
} }
} }
@@ -237,6 +287,7 @@ impl UiSettings {
settings.active_text_color = settings settings.active_text_color = settings
.active_text_color .active_text_color
.min(settings.text_colors.len() - 1); .min(settings.text_colors.len() - 1);
settings.stylus_buttons.truncate(MAX_STYLUS_BUTTONS);
settings settings
} }
@@ -277,6 +328,16 @@ mod tests {
active_highlighter: 3, active_highlighter: 3,
text_colors: [[0.3, 0.1, 0.6, 1.0]; 5], text_colors: [[0.3, 0.1, 0.6, 1.0]; 5],
active_text_color: 4, active_text_color: 4,
stylus_buttons: vec![
StylusButton {
id: 17,
action: StylusAction::Select,
},
StylusButton {
id: 42,
action: StylusAction::Nothing,
},
],
}; };
settings.save_to(&path).unwrap(); settings.save_to(&path).unwrap();
assert_eq!(UiSettings::load_from(&path), settings); assert_eq!(UiSettings::load_from(&path), settings);
+254 -26
View File
@@ -35,6 +35,8 @@ pub enum ActiveTool {
Select, Select,
/// Textfelder schreiben und bearbeiten /// Textfelder schreiben und bearbeiten
Text, Text,
/// Platz schaffen: Inhalt ab einer Höhe nach unten oder oben schieben
Space,
} }
impl ActiveTool { impl ActiveTool {
@@ -46,6 +48,7 @@ impl ActiveTool {
ActiveTool::Shape => "Formen (Shape)", ActiveTool::Shape => "Formen (Shape)",
ActiveTool::Select => "Auswahl / Lasso (Select)", ActiveTool::Select => "Auswahl / Lasso (Select)",
ActiveTool::Text => "Text (T)", ActiveTool::Text => "Text (T)",
ActiveTool::Space => "Platz schaffen",
} }
} }
@@ -171,6 +174,31 @@ impl LongPress {
} }
} }
/// Was im Dokumenten-Manager gerade verschoben wird.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MoveSubject {
Notebook(String),
/// Pfad des Ordners
Folder(String),
}
/// Laufendes Platzschaffen (für die Vorschau): Seite, Höhe auf der Seite, bisherige
/// Verschiebung und wie weit es nach oben höchstens geht (Seiteneinheiten).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SpaceDrag {
pub page_index: usize,
pub y: f32,
pub dy: f32,
pub up_limit: f32,
}
impl SpaceDrag {
/// Verschiebung nach oben begrenzt auf den leeren Platz.
pub fn clamped_dy(&self) -> f32 {
self.dy.max(-self.up_limit)
}
}
/// Kontextmenü nach langem Drücken auf das Blatt. /// Kontextmenü nach langem Drücken auf das Blatt.
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct ContextMenu { pub struct ContextMenu {
@@ -248,6 +276,11 @@ pub enum UiAction {
id: String, id: String,
folder: String, folder: String,
}, },
/// Ordner samt Inhalt nach `new_parent` verschieben
MoveFolder {
path: String,
new_parent: String,
},
RenameNotebook { RenameNotebook {
id: String, id: String,
new_title: String, new_title: String,
@@ -275,6 +308,8 @@ pub enum UiAction {
theme: PaperTheme, theme: PaperTheme,
adapt_ink: bool, adapt_ink: bool,
}, },
/// Belegung der Stifttasten speichern (gilt sofort über `UiState::stylus_buttons`)
SaveStylusButtons,
/// Druckkurve speichern (gilt während des Ziehens bereits über `UiState::pressure_curve`) /// Druckkurve speichern (gilt während des Ziehens bereits über `UiState::pressure_curve`)
SavePressureCurve(PressureCurve), SavePressureCurve(PressureCurve),
/// Seiten durchgehend untereinander statt einzeln anzeigen (wird gespeichert) /// Seiten durchgehend untereinander statt einzeln anzeigen (wird gespeichert)
@@ -331,8 +366,8 @@ pub struct UiState {
/// Ordner, der gerade umbenannt wird, bzw. dessen Löschen bestätigt werden soll /// Ordner, der gerade umbenannt wird, bzw. dessen Löschen bestätigt werden soll
pub renaming_folder: Option<String>, pub renaming_folder: Option<String>,
pub confirm_delete_folder: Option<String>, pub confirm_delete_folder: Option<String>,
/// Notizbuch, für das gerade ein Zielordner gewählt wird /// Notizbuch oder Ordner, für das gerade ein Zielordner gewählt wird
pub moving_notebook_id: Option<String>, pub moving: Option<MoveSubject>,
pub renaming_title_input: String, pub renaming_title_input: String,
pub document_modal_rect: Option<egui::Rect>, pub document_modal_rect: Option<egui::Rect>,
/// Gitea Release- & Update-Manager /// Gitea Release- & Update-Manager
@@ -363,6 +398,11 @@ pub struct UiState {
pub show_color_picker: bool, pub show_color_picker: bool,
/// Kontextmenü nach langem Drücken (Einfügen an dieser Stelle) /// Kontextmenü nach langem Drücken (Einfügen an dieser Stelle)
pub context_menu: Option<ContextMenu>, pub context_menu: Option<ContextMenu>,
/// Laufendes Platzschaffen (Vorschau)
pub space_drag: Option<SpaceDrag>,
/// Tasten am Stift und ihre Belegung; die gerade gedrückte (Index) wird hervorgehoben
pub stylus_buttons: Vec<crate::settings::StylusButton>,
pub pressed_stylus_button: Option<usize>,
/// Rückfrage „Seite löschen?“ im ☰-Menü ist offen /// Rückfrage „Seite löschen?“ im ☰-Menü ist offen
pub confirm_delete_page: bool, pub confirm_delete_page: bool,
/// Seitenzahl der zuletzt gelöschten Seite, solange sie wiederhergestellt werden kann /// Seitenzahl der zuletzt gelöschten Seite, solange sie wiederhergestellt werden kann
@@ -439,7 +479,7 @@ impl Default for UiState {
notebook_folder: String::new(), notebook_folder: String::new(),
renaming_folder: None, renaming_folder: None,
confirm_delete_folder: None, confirm_delete_folder: None,
moving_notebook_id: None, moving: None,
renaming_title_input: String::new(), renaming_title_input: String::new(),
document_modal_rect: None, document_modal_rect: None,
is_update_dialog_open: false, is_update_dialog_open: false,
@@ -458,6 +498,9 @@ impl Default for UiState {
is_text_color_menu_open: false, is_text_color_menu_open: false,
show_color_picker: false, show_color_picker: false,
context_menu: None, context_menu: None,
space_drag: None,
stylus_buttons: Vec::new(),
pressed_stylus_button: None,
confirm_delete_page: false, confirm_delete_page: false,
restorable_page: None, restorable_page: None,
shutdown_error: None, shutdown_error: None,
@@ -573,10 +616,10 @@ impl UiState {
/// Blätterpfeile). /// Blätterpfeile).
pub fn sidebar_button_size(tool: ActiveTool, max_height: f32, continuous: bool) -> f32 { pub fn sidebar_button_size(tool: ActiveTool, max_height: f32, continuous: bool) -> f32 {
let select = matches!(tool, ActiveTool::Select | ActiveTool::Text); let select = matches!(tool, ActiveTool::Select | ActiveTool::Text);
// Notizbücher, 6 Werkzeuge, Undo/Redo, Seitenzahl und neue Seite (einzeln blättern: // Notizbücher, 7 Werkzeuge, Lineal, Undo/Redo, Seitenzahl und neue Seite (einzeln
// dazu zwei Pfeile), Menü (+ 4 Auswahl-Aktionen bzw. Schriftknöpfe) // blättern: dazu zwei Pfeile), Menü (+ 4 Auswahl-Aktionen bzw. Schriftknöpfe)
let page_buttons = if continuous { 2 } else { 4 }; let page_buttons = if continuous { 2 } else { 4 };
let buttons = 10 + page_buttons + if select { 4 } else { 0 }; let buttons = 11 + page_buttons + if select { 4 } else { 0 };
let dividers = 5 + usize::from(select); let dividers = 5 + usize::from(select);
let spacing = 3.0; let spacing = 3.0;
let needed = |b: f32| { let needed = |b: f32| {
@@ -659,7 +702,7 @@ impl UiState {
ActiveTool::Pen | ActiveTool::Shape | ActiveTool::Select => self.pen_color, ActiveTool::Pen | ActiveTool::Shape | ActiveTool::Select => self.pen_color,
ActiveTool::Highlighter => self.highlighter_color, ActiveTool::Highlighter => self.highlighter_color,
ActiveTool::Text => self.text_color, ActiveTool::Text => self.text_color,
ActiveTool::Eraser => [0.0, 0.0, 0.0, 0.0], ActiveTool::Eraser | ActiveTool::Space => [0.0, 0.0, 0.0, 0.0],
} }
} }
@@ -670,7 +713,7 @@ impl UiState {
self.pen_stroke_width_mm self.pen_stroke_width_mm
} }
ActiveTool::Highlighter => self.highlighter_stroke_width_mm, ActiveTool::Highlighter => self.highlighter_stroke_width_mm,
ActiveTool::Eraser => 3.0, ActiveTool::Eraser | ActiveTool::Space => 3.0,
} }
} }
@@ -1622,6 +1665,70 @@ fn pattern_color_button(
/// Schieberegler für die Druckkurve mit Vorschau der Kennlinie. Änderungen gelten sofort /// Schieberegler für die Druckkurve mit Vorschau der Kennlinie. Änderungen gelten sofort
/// (`state.pressure_curve`); zurückgegeben wird die Kurve, sobald sie gespeichert werden soll /// (`state.pressure_curve`); zurückgegeben wird die Kurve, sobald sie gespeichert werden soll
/// (Regler losgelassen oder per Tastatur geändert). /// (Regler losgelassen oder per Tastatur geändert).
/// Belegung der Tasten am Stift. Tasten erscheinen, sobald sie einmal gedrückt wurden; die
/// gerade gedrückte ist markiert. `true`, wenn sich etwas geändert hat.
fn stylus_buttons_editor(ui: &mut egui::Ui, state: &mut UiState) -> bool {
use crate::settings::StylusAction;
let mut changed = false;
ui.add_space(4.0);
ui.label(egui::RichText::new("Tasten am Stift").color(egui::Color32::from_gray(200)));
if state.stylus_buttons.is_empty() {
ui.label(
egui::RichText::new(
"Drücke eine Taste am Stift (über dem Bildschirm) – sie erscheint dann hier. \
Bis dahin radieren alle Tasten.",
)
.size(11.0)
.color(egui::Color32::from_gray(150)),
);
return false;
}
let mut remove = None;
for i in 0..state.stylus_buttons.len() {
ui.horizontal(|ui| {
let pressed = state.pressed_stylus_button == Some(i);
let label = egui::RichText::new(format!("Taste {}", i + 1));
ui.label(if pressed {
label.strong().color(egui::Color32::from_rgb(46, 204, 113))
} else {
label
})
.on_hover_text("Leuchtet grün, solange die Taste gedrückt ist");
let current = state.stylus_buttons[i].action;
egui::ComboBox::from_id_source(("stylus_button", i))
.selected_text(current.label())
.width(130.0)
.show_ui(ui, |ui| {
for action in StylusAction::ALL {
if ui
.selectable_label(action == current, action.label())
.clicked()
&& action != current
{
state.stylus_buttons[i].action = action;
changed = true;
}
}
});
if ui
.small_button("🗙")
.on_hover_text(
"Vergessen (erscheint beim nächsten Drücken wieder, dann mit Radieren)",
)
.clicked()
{
remove = Some(i);
}
});
}
if let Some(i) = remove {
state.stylus_buttons.remove(i);
state.pressed_stylus_button = None;
changed = true;
}
changed
}
fn pressure_curve_editor(ui: &mut egui::Ui, state: &mut UiState) -> Option<PressureCurve> { fn pressure_curve_editor(ui: &mut egui::Ui, state: &mut UiState) -> Option<PressureCurve> {
let mut save = false; let mut save = false;
let mut track = |response: egui::Response| { let mut track = |response: egui::Response| {
@@ -1819,6 +1926,13 @@ fn folder_row(ui: &mut egui::Ui, state: &mut UiState, path: &str, actions: &mut
{ {
state.confirm_delete_folder = Some(path.to_string()); state.confirm_delete_folder = Some(path.to_string());
} }
if ui
.button(egui::RichText::new("📂").size(16.0))
.on_hover_text("Ordner samt Inhalt in einen anderen Ordner verschieben")
.clicked()
{
state.moving = Some(MoveSubject::Folder(path.to_string()));
}
if ui if ui
.button(egui::RichText::new("✏").size(16.0)) .button(egui::RichText::new("✏").size(16.0))
.on_hover_text("Ordner umbenennen") .on_hover_text("Ordner umbenennen")
@@ -1860,24 +1974,35 @@ fn folder_row(ui: &mut egui::Ui, state: &mut UiState, path: &str, actions: &mut
} }
} }
/// Zielordner für das Verschieben eines Notizbuchs: oberste Ebene und alle Ordner als Baum. /// Zielordner für das Verschieben eines Notizbuchs oder Ordners: oberste Ebene und alle
/// Ordner als Baum (ein Ordner nicht in sich selbst oder seine Unterordner).
fn move_target_list( fn move_target_list(
ui: &mut egui::Ui, ui: &mut egui::Ui,
state: &mut UiState, state: &mut UiState,
notebook_id: &str, subject: &MoveSubject,
actions: &mut Vec<UiAction>, actions: &mut Vec<UiAction>,
) { ) {
let title = state let (title, current, folders) = match subject {
.available_notebooks MoveSubject::Notebook(id) => (
.iter() state
.find(|s| s.id == notebook_id) .available_notebooks
.map_or(notebook_id, |s| s.title.as_str()) .iter()
.to_string(); .find(|s| &s.id == id)
let current = state.library.folder_of(notebook_id).to_string(); .map_or(id.as_str(), |s| s.title.as_str())
.to_string(),
state.library.folder_of(id).to_string(),
state.library.all_folders(),
),
MoveSubject::Folder(path) => (
format!("📁 {}", crate::library::folder_name(path)),
crate::library::parent_folder(path).to_string(),
state.library.folder_move_targets(path),
),
};
ui.label(egui::RichText::new(format!("„{title}“ verschieben nach:")).strong()); ui.label(egui::RichText::new(format!("„{title}“ verschieben nach:")).strong());
ui.add_space(4.0); ui.add_space(4.0);
let mut targets = vec![(String::new(), "📚 Oberste Ebene".to_string(), 0)]; let mut targets = vec![(String::new(), "📚 Oberste Ebene".to_string(), 0)];
for path in state.library.all_folders() { for path in folders {
let depth = path.matches('/').count() + 1; let depth = path.matches('/').count() + 1;
targets.push(( targets.push((
path.to_string(), path.to_string(),
@@ -1897,18 +2022,24 @@ fn move_target_list(
.on_disabled_hover_text("Liegt bereits hier") .on_disabled_hover_text("Liegt bereits hier")
.clicked() .clicked()
{ {
actions.push(UiAction::MoveNotebook { actions.push(match subject {
id: notebook_id.to_string(), MoveSubject::Notebook(id) => UiAction::MoveNotebook {
folder: path.clone(), id: id.clone(),
folder: path.clone(),
},
MoveSubject::Folder(folder) => UiAction::MoveFolder {
path: folder.clone(),
new_parent: path.clone(),
},
}); });
state.moving_notebook_id = None; state.moving = None;
} }
}); });
} }
}); });
ui.add_space(6.0); ui.add_space(6.0);
if ui.button("Abbrechen").clicked() { if ui.button("Abbrechen").clicked() {
state.moving_notebook_id = None; state.moving = None;
} }
} }
@@ -2017,6 +2148,55 @@ fn context_menu(ctx: &egui::Context, state: &mut UiState, actions: &mut Vec<UiAc
} }
} }
/// Vorschau beim Platzschaffen: Linie an der Ansatzhöhe und das Band, das eingefügt (grün)
/// bzw. geschlossen (orange) wird, mit Angabe in Millimetern.
fn space_preview(ctx: &egui::Context, state: &UiState, notebook: &Notebook) {
let (Some(drag), Some(view)) = (state.space_drag, state.page_view) else {
return;
};
let Some(page) = notebook.pages.get(drag.page_index) else {
return;
};
let dy = drag.clamped_dy();
let painter = ctx.layer_painter(egui::LayerId::new(
egui::Order::Foreground,
egui::Id::new("space_preview"),
));
let left = view.to_screen(glam::Vec2::new(0.0, drag.y));
let right = view.to_screen(glam::Vec2::new(page.size.x, drag.y));
let moved = view.to_screen(glam::Vec2::new(page.size.x, drag.y + dy));
let (fill, line) = if dy >= 0.0 {
(
egui::Color32::from_rgba_unmultiplied(46, 204, 113, 50),
egui::Color32::from_rgb(39, 174, 96),
)
} else {
(
egui::Color32::from_rgba_unmultiplied(230, 126, 34, 60),
egui::Color32::from_rgb(211, 84, 0),
)
};
let band = egui::Rect::from_two_pos(left, moved);
painter.rect_filled(band, 0.0, fill);
painter.line_segment([left, right], egui::Stroke::new(2.0_f32, line));
painter.line_segment(
[egui::pos2(left.x, moved.y), moved],
egui::Stroke::new(2.0_f32, line),
);
let mm = dy / crate::document::UNITS_PER_MM;
painter.text(
egui::pos2(right.x - 8.0, moved.y.max(left.y) + 4.0),
egui::Align2::RIGHT_TOP,
if dy >= 0.0 {
format!("⏷ {mm:.0} mm Platz")
} else {
format!("⏶ {:.0} mm", -mm)
},
egui::FontId::proportional(14.0),
line,
);
}
/// Durchgehende Ansicht: dunkle Linie an jeder Grenze zweier Seiten (die Seiten liegen /// Durchgehende Ansicht: dunkle Linie an jeder Grenze zweier Seiten (die Seiten liegen
/// lückenlos untereinander). Unter allen Fenstern der Oberfläche, über der Tinte. /// lückenlos untereinander). Unter allen Fenstern der Oberfläche, über der Tinte.
fn page_separators(ctx: &egui::Context, state: &UiState, notebook: &Notebook) { fn page_separators(ctx: &egui::Context, state: &UiState, notebook: &Notebook) {
@@ -2447,6 +2627,7 @@ pub fn render_ui(
let mut actions = std::mem::take(&mut state.queued_actions); let mut actions = std::mem::take(&mut state.queued_actions);
state.screen_size = ctx.screen_rect().size(); state.screen_size = ctx.screen_rect().size();
page_separators(ctx, state, notebook); page_separators(ctx, state, notebook);
space_preview(ctx, state, notebook);
ruler_overlay(ctx, state); ruler_overlay(ctx, state);
text_edit_overlay(ctx, state); text_edit_overlay(ctx, state);
context_menu(ctx, state, &mut actions); context_menu(ctx, state, &mut actions);
@@ -2537,7 +2718,7 @@ pub fn render_ui(
.to_string(); .to_string();
state.renaming_folder = None; state.renaming_folder = None;
state.confirm_delete_folder = None; state.confirm_delete_folder = None;
state.moving_notebook_id = None; state.moving = None;
actions.push(UiAction::RefreshNotebooks); actions.push(UiAction::RefreshNotebooks);
} }
} }
@@ -2625,6 +2806,13 @@ pub fn render_ui(
SidebarIcon::Lasso, SidebarIcon::Lasso,
"Auswählen: verschieben, drehen, skalieren, kopieren (V)", "Auswählen: verschieben, drehen, skalieren, kopieren (V)",
), ),
(
ActiveTool::Space,
SidebarIcon::Glyph("↕"),
"Platz schaffen: auf das Blatt tippen und nach unten ziehen – alles \
darunter rutscht mit, auch auf die folgenden Seiten; nach oben \
schließt leeren Platz",
),
( (
ActiveTool::Text, ActiveTool::Text,
SidebarIcon::Glyph("T"), SidebarIcon::Glyph("T"),
@@ -3523,6 +3711,9 @@ pub fn render_ui(
if let Some(curve) = pressure_curve_editor(ui, state) { if let Some(curve) = pressure_curve_editor(ui, state) {
actions.push(UiAction::SavePressureCurve(curve)); actions.push(UiAction::SavePressureCurve(curve));
} }
if stylus_buttons_editor(ui, state) {
actions.push(UiAction::SaveStylusButtons);
}
section(ui, "WERKZEUGLEISTE"); section(ui, "WERKZEUGLEISTE");
ui.horizontal(|ui| { ui.horizontal(|ui| {
@@ -3804,7 +3995,7 @@ pub fn render_ui(
ui.add_space(6.0); ui.add_space(6.0);
// Zielordner für „Verschieben“ wählen // Zielordner für „Verschieben“ wählen
if let Some(moving) = state.moving_notebook_id.clone() { if let Some(moving) = state.moving.clone() {
move_target_list(ui, state, &moving, &mut actions); move_target_list(ui, state, &moving, &mut actions);
return; return;
} }
@@ -3917,7 +4108,7 @@ pub fn render_ui(
.on_hover_text("In einen anderen Ordner verschieben") .on_hover_text("In einen anderen Ordner verschieben")
.clicked() .clicked()
{ {
state.moving_notebook_id = Some(summary.id.clone()); state.moving = Some(MoveSubject::Notebook(summary.id.clone()));
} }
if ui.button(egui::RichText::new("✏").size(16.0)) if ui.button(egui::RichText::new("✏").size(16.0))
@@ -5258,6 +5449,43 @@ mod tests {
actions actions
} }
#[test]
fn test_stylus_buttons_in_menu() {
use crate::settings::{StylusAction, StylusButton};
let notebook = Notebook::new();
let mut state = UiState::new();
state.is_more_menu_open = true;
// Ohne gelernte Tasten: nur der Hinweis, keine Aktion
let ctx = egui::Context::default();
let updater = crate::updater::UpdaterController::new();
let shapes = ctx
.run(egui::RawInput::default(), |ctx| {
let _ = render_ui(ctx, &mut state, &notebook, AutosaveStatus::Saved, &updater);
})
.shapes
.len();
assert!(shapes > 0);
// Mit zwei Tasten: beide Zeilen, die gedrückte hervorgehoben, ohne Fehler
state.stylus_buttons = vec![
StylusButton {
id: 1,
action: StylusAction::Erase,
},
StylusButton {
id: 2,
action: StylusAction::Select,
},
];
state.pressed_stylus_button = Some(1);
let output = ctx.run(egui::RawInput::default(), |ctx| {
let actions = render_ui(ctx, &mut state, &notebook, AutosaveStatus::Saved, &updater);
assert!(!actions.contains(&UiAction::SaveStylusButtons));
});
assert!(!output.shapes.is_empty());
assert_eq!(state.stylus_buttons.len(), 2);
}
#[test] #[test]
fn test_long_press_detection() { fn test_long_press_detection() {
let t0 = Instant::now(); let t0 = Instant::now();