Compare commits

..
24 Commits
Author SHA1 Message Date
haraldandClaude Opus 5.5 57028dfbc6 chore(release): bump version to 0.23.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 7m17s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:21:29 +02:00
haraldandClaude Opus 5.5 6fb190cf79 fix: Bildschirmtastatur unter GNOME/Wayland (Issue #21)
GNOME blendet die Bildschirmtastatur erst ein, wenn ein bereits aktives
Textfeld zwp_text_input_v3.enable erneut meldet (wie GTK beim Antippen).
winit meldet enable nur einmal. Ein eigenes text-input-Objekt auf
derselben Wayland-Verbindung schickt das zweite enable, wenn ein Textfeld
den Fokus bekommt oder erneut angetippt wird.

- Zeichen der Bildschirmtastatur kommen als IME-Text ohne Vorschau; egui
  0.27 fügte davon nur das erste ein. Sie werden jetzt als normaler Text
  übergeben.
- Die Rücktaste löscht über delete_surrounding_text; dafür meldet das
  eigene Objekt einen Platzhalter-Umgebungstext und übersetzt das Löschen
  in Tastendrücke.
- Ein Fingertipp ging verloren, wenn GNOME beim Antippen den Mauszeiger
  ausblendet (CursorLeft bei aufliegendem Finger); das Ereignis erreicht
  egui in dem Fall nicht mehr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:11:45 +02:00
haraldandClaude Opus 5.5 1148851585 chore(release): bump version to 0.22.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 6m32s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:51:17 +02:00
haraldandClaude Opus 5.5 eef4cc6b2b feat: App-Symbol für das laufende Fenster
- Fenster meldet die App-Kennung eu.pansi.ink-notes (Wayland: app_id, X11:
  WM_CLASS), passend zur .desktop-Datei (StartupWMClass): Dock und Taskleiste
  zeigen das ink-notes-Symbol statt eines allgemeinen Symbols
- Unter X11 zusätzlich das eingebaute Symbol als Fenstersymbol (64 × 64)
- Fenstertitel ohne Werbezusatz

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:33:13 +02:00
haraldandClaude Opus 5.5 dc067af31e chore(release): bump version to 0.22.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m24s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:31:28 +02:00
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
haraldandClaude Opus 5.5 e33062fe0a chore(release): bump version to 0.18.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 7m17s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:42:38 +02:00
haraldandClaude Opus 5.5 36a1a3cd2e feat: Ordner für Notizbücher (Issue #18)
- Beliebig verschachtelte Ordner (z. B. 3. Semester › Mathe) im
  Dokumenten-Manager: Pfadleiste zum Navigieren, Ordner anlegen, umbenennen,
  löschen (Inhalt rückt eine Ebene nach oben) und Notizbücher mit 📂 verschieben
- Neue Notizbücher landen im angezeigten Ordner, importierte PDFs neben dem
  geöffneten Notizbuch; der Manager öffnet im Ordner des aktuellen Notizbuchs
- Ordner sind logisch (library.json im Notizbuch-Basisordner), Notizbuch-
  Dateien werden nie verschoben; fehlt die Datei, liegt alles oben
- Die ganze Titelfläche einer Zeile öffnet Notizbuch bzw. Ordner
- Ein ganz neues Standard-Notizbuch wird sofort gespeichert (erschien sonst
  bis zur ersten Änderung als „beschädigt“)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:41:05 +02:00
haraldandClaude Opus 5.5 70317caeb5 chore(release): bump version to 0.17.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 7m34s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:10:14 +02:00
haraldandClaude Opus 5.5 f9a6f8524d feat: Lange drücken zum Einfügen (Issue #16)
- Ein Finger, der eine halbe Sekunde still auf dem Blatt liegt, öffnet ein
  Kontextmenü mit „Einfügen“ und „Bild aus Datei …“ (wie in Rnote)
- Bild, Text oder kopierte Objekte aus der Zwischenablage landen an der
  gedrückten Stelle (Strg+V weiterhin in der Mitte)
- Meldung, wenn die Zwischenablage leer ist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:08:55 +02:00
haraldandClaude Opus 5.5 4a4492d034 chore(release): bump version to 0.16.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 6m52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:53:15 +02:00
haraldandClaude Opus 5.5 bb9dbb52d0 feat: Eigene Farben, Bildschirmfarben wie im Export (Issue #13)
- Jede Farbe der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen:
  gewählte Farbe erneut antippen, Vorgabe oder mit 🎨 frei wählen (Farbwähler
  direkt im Popover). Die Leiste merkt sich die Farben (ui.json).
- Tintenfarben sind sRGB-Werte wie im Export: Gezeichnet wird ohne
  sRGB-Umrechnung der GPU, Papier und Muster rechnet der Shader um. Schwarz ist
  am Bildschirm jetzt schwarz statt grau, der Textmarker mischt wie im PDF.
- PDF-Export: Musterfarbe wird nach sRGB umgerechnet (war zu dunkel).
- Auswahl wird in die Farben der Stifte umgefärbt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:51:39 +02:00
haraldandClaude Opus 5.5 568714d727 Merge origin/main (Banner-Logo) nach v0.15.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:04:02 +02:00
haraldandClaude Opus 5.5 c87f4102d7 chore(release): bump version to 0.15.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 7m47s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:03:39 +02:00
haraldandClaude Opus 5.5 3890b6f182 feat: Stifte, Seiten löschen, lückenlose Seiten, sauberer Textmarker (Issues #11, #12, #14, #15, #17, #19)
- #15: Koordinatensystem ohne Kerbe am Ursprung (Achsen beginnen eine halbe
  Linienbreite hinter dem Ursprung; im PDF ein Linienzug mit spitzer Ecke)
- #17: Jeder Strich deckt ein Pixel nur einmal (Stencil-Puffer), der
  Textmarker wird innerhalb eines Strichs nicht dunkler. Im PDF-Export ist der
  Textmarker jetzt halbtransparent statt deckend.
- #11: ➕ in der Werkzeugleiste fügt nach der aktuellen Seite eine Seite ein
- #12: Seite löschen im ☰-Menü mit Rückfrage, in der Sitzung wiederherstellbar
- #14: Seiten liegen lückenlos untereinander, eine dunkle Linie markiert die Grenze
- #19: Vier Stifte mit eigener Farbe und Stärke, die sich Änderungen merken
  (ui.json); Blätterpfeile nur noch in der Einzelseitenansicht

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:55:32 +02:00
harald 5115e217c2 docs: Banner-Logo hinzugefügt und in README.md eingebunden 2026-10-08 13:37:13 +02:00
28 changed files with 4516 additions and 379 deletions
Generated
+4 -1
View File
@@ -1393,7 +1393,7 @@ dependencies = [
[[package]]
name = "ink-notes"
version = "0.14.0"
version = "0.23.0"
dependencies = [
"ab_glyph",
"arboard",
@@ -1418,6 +1418,9 @@ dependencies = [
"tempfile",
"ureq",
"url",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"webbrowser",
"wgpu",
"winit",
+6 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ink-notes"
version = "0.14.0"
version = "0.23.0"
edition = "2021"
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes"
@@ -51,6 +51,11 @@ hayro-svg = "0.8"
# Text-Werkzeug: Schriftmetriken und Rasterung (Schriften aus egui)
ab_glyph = "0.2"
# Bildschirmtastatur unter Wayland anfordern (eigenes text-input-Objekt, siehe src/osk.rs)
wayland-client = "0.31"
wayland-backend = { version = "0.3", features = ["client_system"] }
wayland-protocols = { version = "0.31", features = ["client", "unstable"] }
[profile.dev]
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
+20 -6
View File
@@ -1,6 +1,13 @@
# ink-notes
<p align="center">
<img src="assets/ink-notes-banner.svg" alt="ink-notes Banner" width="100%">
</p>
Eine Notizbuch-App für Linux zum Schreiben mit Stift, Finger oder Maus – geschrieben in **Rust** mit **wgpu**, **winit** und **egui**.
<p align="center">
<strong>Eine moderne Notizbuch-App für Linux zum Schreiben mit Stift, Touch und Maus</strong><br>
Geschrieben in <strong>Rust</strong> mit <strong>wgpu</strong>, <strong>winit</strong> und <strong>egui</strong>.
</p>
---
ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen, kann PDFs zum Beschriften importieren, Bilder und Textfelder einfügen und alles wieder als PDF, PNG oder SVG exportieren. Gespeichert wird automatisch im Hintergrund.
@@ -11,15 +18,18 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
## Funktionen
### Schreiben und Zeichnen
- **Stift** mit Druckstärke, vier Strichstärken (0,3–1,2 mm) und fünf Farben; **Textmarker** (halbtransparent, 4–8 mm) in vier Farben.
- **Vier Stifte** in der Werkzeugleiste, jeder mit eigener Farbe und Stärke (0,15–5 mm, mit Druckstärke). Ändert man Farbe oder Stärke, merkt sich der gewählte Stift das – auch über einen Neustart hinweg. **Textmarker** (halbtransparent, 4–8 mm) in vier Farben; innerhalb eines Strichs wird er nicht dunkler, erst ein neuer Strich deckt stärker.
- **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.
- **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.
- **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
- **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.
### Auswählen und Bearbeiten
@@ -31,18 +41,21 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
### Text, Bilder und PDFs
- **Text-Werkzeug** (`T`): ins Blatt tippen und schreiben – normale oder Code-Schrift (feste Zeichenbreite, Tab rückt ein), 8–36 pt, Zeilenumbruch am Feldrand. Vorhandenen Text antippen zum Ändern, `Esc` beendet. Textfelder lassen sich wie Bilder verschieben, drehen und skalieren. In anderen Programmen kopierter Text wird mit `Strg + V` zum Textfeld.
- **Bilder** (PNG, JPEG, GIF, WebP, BMP) über `Strg + I`, aus der Zwischenablage oder per Ziehen ins Fenster einfügen.
- **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.
- **PDF importieren** (☰-Menü): Jede PDF-Seite wird eine Notizbuchseite mit der PDF-Seite als Hintergrund. Hochformat-Seiten werden DIN A4, Querformat-Seiten (z. B. Folien) bekommen eine Querformat-Seite ohne leeren Streifen darunter. Beim Hineinzoomen werden PDF-Seiten schärfer nachgerendert.
### Seiten und Ansicht
- **Hintergründe:** blanko, kariert (5 mm), liniert (7,5 mm), Punktraster (5 mm). Die **Farbe von Karos, Linien und Punkten** ist wählbar (hell, mittel, kräftig, blau, grün) und gilt auch im Export.
- **Papierfarbe** der Anzeige: weiß, creme, dunkelgrau, schwarz; auf dunklem Papier werden dunkle Stiftfarben auf Wunsch aufgehellt. Gespeichert und exportiert wird immer auf weißem Papier.
- **Durchgehendes Scrollen:** Seiten hängen untereinander; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite. Abschaltbar (dann Seite für Seite blättern).
- **Durchgehendes Scrollen:** Seiten hängen lückenlos untereinander, eine dunkle Linie markiert die Grenze; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite. Abschaltbar (dann Seite für Seite blättern, mit Pfeilen in der Werkzeugleiste).
- **Seiten einfügen und löschen:** ➕ in der Werkzeugleiste fügt nach der aktuellen Seite eine leere Seite ein. Löschen im ☰-Menü (mit Rückfrage); die zuletzt gelöschte Seite lässt sich dort wiederherstellen, solange die App läuft.
- **Zoom** von 25 % bis 1000 % (`Strg + Mausrad`, zwei Finger, `Strg + +/-/0`), Verschieben mit mittlerer Maustaste, `Leertaste + Ziehen` oder einem Finger.
- **Seiten** anhängen oder direkt nach der aktuellen Seite einfügen, **Seitentitel** vergeben; die Seitenübersicht zeigt Vorschaubilder oder ein Inhaltsverzeichnis. Seitentitel werden im PDF zu Lesezeichen.
- **Werkzeugleiste** links oder rechts andocken (für Links- und Rechtshänder).
### Notizbücher und Speichern
- 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 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.
- 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.
@@ -152,6 +165,7 @@ src/
├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen
├── history.rs # Rückgängig/Wiederholen
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb
├── library.rs # Ordner der Notizbücher (library.json)
├── autosave.rs # Speichern im Hintergrund
├── export.rs # PDF-Export (printpdf)
├── page_export.rs # Seite als PNG/SVG, Auswahl als Bild
+178
View File
@@ -0,0 +1,178 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 360" width="1200" height="360">
<defs>
<!-- Haupt-Hintergrund-Verlauf -->
<linearGradient id="bannerBg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0f1420"/>
<stop offset="40%" stop-color="#141a29"/>
<stop offset="100%" stop-color="#090b12"/>
</linearGradient>
<!-- Subtiles Ambient Glow -->
<radialGradient id="ambientGlow" cx="15%" cy="50%" r="45%">
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.22"/>
<stop offset="70%" stop-color="#1d4ed8" stop-opacity="0.05"/>
<stop offset="100%" stop-color="#1d4ed8" stop-opacity="0"/>
</radialGradient>
<radialGradient id="ambientGlowRight" cx="85%" cy="30%" r="40%">
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.10"/>
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
</radialGradient>
<!-- Icon Hintergrund-Verlauf -->
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e2638"/>
<stop offset="50%" stop-color="#141824"/>
<stop offset="100%" stop-color="#0c0e17"/>
</linearGradient>
<!-- Stiftkörper-Verlauf -->
<linearGradient id="penBody" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3b82f6"/>
<stop offset="100%" stop-color="#1d4ed8"/>
</linearGradient>
<!-- Goldene Feder-Spitze -->
<linearGradient id="nibGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#fbbf24"/>
<stop offset="50%" stop-color="#f59e0b"/>
<stop offset="100%" stop-color="#d97706"/>
</linearGradient>
<!-- Tintenspur-Verlauf -->
<linearGradient id="inkGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#60a5fa" stop-opacity="0.95"/>
<stop offset="50%" stop-color="#3b82f6" stop-opacity="0.85"/>
<stop offset="100%" stop-color="#2563eb" stop-opacity="0.4"/>
</linearGradient>
<!-- Text Ink Gradient -->
<linearGradient id="textInkGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#60a5fa"/>
<stop offset="100%" stop-color="#38bdf8"/>
</linearGradient>
<!-- Leucht-Effekt -->
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="8" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
<filter id="iconShadow" x="-10%" y="-10%" width="120%" height="130%">
<feDropShadow dx="0" dy="12" stdDeviation="16" flood-color="#000000" flood-opacity="0.6"/>
</filter>
<!-- Gitter-Muster für Hintergrund -->
<pattern id="gridPattern" width="30" height="30" patternUnits="userSpaceOnUse">
<path d="M 30 0 L 0 0 0 30" fill="none" stroke="#94a3b8" stroke-width="0.8" opacity="0.07"/>
</pattern>
</defs>
<!-- Banner-Rahmen & Hintergrund -->
<rect width="1200" height="360" rx="20" fill="url(#bannerBg)" stroke="#27324b" stroke-width="2"/>
<!-- Hintergrund-Gitter (Notizbuch-Raster) -->
<rect width="1200" height="360" rx="20" fill="url(#gridPattern)"/>
<!-- Subtile Glows -->
<rect width="1200" height="360" rx="20" fill="url(#ambientGlow)"/>
<rect width="1200" height="360" rx="20" fill="url(#ambientGlowRight)"/>
<!-- ==================== LINKER BEREICH: APP-ICON ==================== -->
<g transform="translate(65, 55) scale(0.488)" filter="url(#iconShadow)">
<!-- App-Icon Hintergrund (Squircle) -->
<rect x="24" y="24" width="464" height="464" rx="104" ry="104" fill="url(#bgGrad)" stroke="#384260" stroke-width="4"/>
<!-- DIN-A4 Dokumenten-Gitter im Icon -->
<g opacity="0.14" stroke="#94a3b8" stroke-width="2">
<line x1="80" y1="160" x2="432" y2="160" />
<line x1="80" y1="220" x2="432" y2="220" />
<line x1="80" y1="280" x2="432" y2="280" />
<line x1="80" y1="340" x2="432" y2="340" />
<line x1="80" y1="400" x2="432" y2="400" />
<line x1="140" y1="100" x2="140" y2="430" />
<line x1="200" y1="100" x2="200" y2="430" />
<line x1="260" y1="100" x2="260" y2="430" />
<line x1="320" y1="100" x2="320" y2="430" />
<line x1="380" y1="100" x2="380" y2="430" />
</g>
<!-- Tintenspur -->
<path d="M 110 390 C 180 430, 260 380, 240 310 C 220 240, 280 190, 340 220"
fill="none" stroke="url(#inkGrad)" stroke-width="26" stroke-linecap="round" filter="url(#glow)"/>
<path d="M 110 390 C 180 430, 260 380, 240 310 C 220 240, 280 190, 340 220"
fill="none" stroke="#93c5fd" stroke-width="12" stroke-linecap="round"/>
<!-- Stylus / Füllfederhalter -->
<g transform="translate(240, 110) rotate(45)">
<rect x="-24" y="-140" width="48" height="150" rx="8" fill="url(#penBody)" stroke="#60a5fa" stroke-width="3"/>
<rect x="-22" y="10" width="44" height="26" rx="4" fill="#1e293b" stroke="#cbd5e1" stroke-width="2"/>
<rect x="-25" y="6" width="50" height="6" fill="#f8fafc"/>
<path d="M -20 36 L 20 36 L 12 85 L 0 115 L -12 85 Z" fill="url(#nibGrad)" stroke="#fef08a" stroke-width="2"/>
<line x1="0" y1="46" x2="0" y2="108" stroke="#78350f" stroke-width="3" stroke-linecap="round"/>
<circle cx="0" cy="65" r="4" fill="#78350f"/>
</g>
<!-- Glanzpunkt -->
<circle cx="110" cy="390" r="14" fill="#60a5fa" filter="url(#glow)"/>
<circle cx="110" cy="390" r="8" fill="#ffffff"/>
</g>
<!-- ==================== RECHTER BEREICH: TYPOGRAFIE & BADGES ==================== -->
<g transform="translate(360, 0)">
<!-- Version & Platform Pill -->
<g transform="translate(0, 68)">
<rect width="254" height="30" rx="15" fill="#1e293b" stroke="#334155" stroke-width="1.2"/>
<circle cx="16" cy="15" r="4" fill="#22c55e"/>
<text x="28" y="20" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="600" fill="#cbd5e1" letter-spacing="0.5">
v0.14.0 • LINUX • RUST &amp; WGPU
</text>
</g>
<!-- Haupttitel: ink-notes -->
<text x="0" y="162" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="800" font-size="64" letter-spacing="-1">
<tspan fill="url(#textInkGrad)">ink</tspan><tspan fill="#f8fafc">-notes</tspan>
</text>
<!-- Subtitle / Tagline -->
<text x="0" y="206" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="400" font-size="22" fill="#94a3b8">
Notizbuch- &amp; PDF-Annotations-App für Stift, Touch und Maus
</text>
<!-- Feature Pills / Highlights -->
<g transform="translate(0, 248)">
<!-- Pill 1: Niedrige Latenz -->
<g transform="translate(0, 0)">
<rect width="186" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
⚡ Schnelles wgpu-Inking
</text>
</g>
<!-- Pill 2: Stylus mit Druck -->
<g transform="translate(198, 0)">
<rect width="190" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
🖊️ Druck &amp; Palm Rejection
</text>
</g>
<!-- Pill 3: Formerkennung -->
<g transform="translate(400, 0)">
<rect width="182" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
📐 Formenerkennung &amp; Lineal
</text>
</g>
<!-- Pill 4: 100% lokal -->
<g transform="translate(594, 0)">
<rect width="176" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
🔒 100% lokal &amp; atomar
</text>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.2 KiB

+10 -5
View File
@@ -93,7 +93,7 @@ $$\vec{p}_{\text{neu}} = \vec{c} - (\vec{c} - \vec{p}_{\text{alt}}) \cdot \frac{
- Im Shader wird jeder Punkt mit `transform_scale` und `transform_offset` (aus `CameraUniforms`) direkt in Gerätekoordinaten umgerechnet.
### 3.3 Durchgehende Ansicht
Alle Koordinaten beziehen sich auf die **aktuelle Seite**. `PageStack` (`viewport.rs`) legt die übrigen Seiten mit 8 mm Abstand darunter bzw. darüber, waagerecht zur aktuellen Seite zentriert. Jede sichtbare Nachbarseite wird mit einer eigenen Kamera gezeichnet, die um die Lage der Seite verschoben ist (höchstens 12 Nachbarseiten).
Alle Koordinaten beziehen sich auf die **aktuelle Seite**. `PageStack` (`viewport.rs`) legt die übrigen Seiten lückenlos darunter bzw. darüber (die Grenze zeichnet die Oberfläche als dunkle Linie), waagerecht zur aktuellen Seite zentriert. Jede sichtbare Nachbarseite wird mit einer eigenen Kamera gezeichnet, die um die Lage der Seite verschoben ist (höchstens 12 Nachbarseiten).
Nach dem Scrollen wird die Seite in der Fenstermitte zur aktuellen Seite; die Kamera wird dabei so verschoben, dass sich das Bild nicht bewegt. Während eines Strichs, einer Auswahl, beim Ziehen des Lineals oder beim Schreiben von Text wird nicht gewechselt. Wer auf einer sichtbaren Nachbarseite zu zeichnen beginnt, wechselt zuerst auf diese Seite.
@@ -105,7 +105,9 @@ Ein Frame besteht aus einem Render-Durchgang auf die Fensterfläche (`canvas.rs`
1. Für jede sichtbare Nachbarseite: Papier und Muster, Bilder/PDF-Seite/Text, Tinte.
2. Für die aktuelle Seite: Papier, Muster (Karo, Linien, Punkte), Bildebene, gespeicherte Striche, Live-Strich.
3. Die `egui`-Oberfläche: Werkzeugleiste, Menüs, Auswahlrahmen und Griffe, Lineal, Textbearbeitung.
3. Die `egui`-Oberfläche: Werkzeugleiste, Menüs, Seitengrenzen, Auswahlrahmen und Griffe, Lineal, Textbearbeitung.
Jeder Strich ist ein eigener Draw-Call mit eigener Nummer im Stencil-Puffer: Ein Pixel, das der Strich schon gefärbt hat, wird kein zweites Mal gefärbt. So bleibt halbtransparente Tinte (Textmarker) gleichmäßig, auch wo sich der Strich selbst überlappt (Anfangskappe, Kehren, langsames Hin- und Herfahren); erst ein neuer Strich deckt stärker. Nach 255 Strichen setzt ein bildschirmfüllendes Dreieck den Stencil-Puffer zurück.
### 4.1 Bildebene
`image_layer.rs` zeichnet texturierte Vierecke unter der Tinte: importierte PDF-Seiten, eingefügte Bilder und Textfelder. Texturen werden beim ersten Bedarf geladen, bekommen Mipmaps und werden freigegeben, wenn mehr als 384 MB belegt sind.
@@ -113,7 +115,7 @@ Ein Frame besteht aus einem Render-Durchgang auf die Fensterfläche (`canvas.rs`
- **Textfelder** werden mit `ab_glyph` gerastert, in einer Auflösung, die dem Zoom folgt (in Vierteln einer Zweierpotenz, höchstens 8 Pixel je Einheit). Der Texturschlüssel enthält einen Hash von Inhalt, Schrift und Farbe; jede Änderung erzeugt eine neue Textur.
### 4.2 Papier- und Musterfarben
Papierfarbe (`PaperTheme`) und Musterfarbe (`PatternColor`) stehen in `background.rs`. Alle Farben sind linear; die Fensterfläche ist sRGB, die Umrechnung übernimmt die GPU. Auf dunklem Papier kann der Shader dunkle Tinte aufhellen (`ink_params` in `CameraUniforms`), gespeichert werden immer die Originalfarben.
Papierfarbe (`PaperTheme`) und Musterfarbe (`PatternColor`) stehen in `background.rs`; sie sind linear angegeben und werden im Shader nach sRGB umgerechnet. Tintenfarben (Striche, Text) sind sRGB-Werte – dieselben Zahlen landen im PDF und in den Farbpunkten der Oberfläche. Gezeichnet wird ohne sRGB-Umrechnung der GPU (`render_format`), Texturen sind `Rgba8Unorm`: So wird halbtransparente Tinte wie im PDF in sRGB gemischt und sieht am Bildschirm genauso aus wie im Export. Auf dunklem Papier kann der Shader dunkle Tinte aufhellen (`ink_params` in `CameraUniforms`), gespeichert werden immer die Originalfarben.
---
@@ -130,16 +132,19 @@ Alles auf einer Seite ist ein `Stroke` (`document.rs`):
### 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.
**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
`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.
- **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).
- **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.
**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.
@@ -177,7 +182,7 @@ Jede Seite hat einen eigenen Verlauf (`history.rs`): Hinzufügen, Entfernen, Ers
2. **PDF** (nur im Notfall): importierte PDF-Seite als Bild, falls sie sich nicht als Vektor übernehmen lässt.
3. **Images:** eingefügte Bilder (höchstens 300 dpi).
4. **Text:** Textfelder als echter Text mit eingebetteter Schrift (durchsuchbar, kopierbar). Jedes Feld liegt in einem eigenen Koordinatensystem, das eine Transformationsmatrix auf seine Ecken abbildet.
5. **Ink:** Striche als gefüllte Polygone, Formen als Pfade.
5. **Ink:** Striche als gefüllte Polygone, Formen als Pfade. Halbtransparente Striche (Textmarker) bekommen einen Grafikzustand mit Deckkraft (`ca`/`CA`); printpdf kann solche Zustände nicht anlegen, deshalb trägt `add_alpha_states` sie nach dem Speichern mit `lopdf` in die Seitenressourcen ein. Jeder Strich ist ein einziger Pfad und deckt sich daher nicht selbst doppelt.
Importierte PDF-Seiten werden anschließend als Form-XObject unter die Seite gelegt (`pdf_merge.rs`), bleiben also Vektorgrafik. Seitentitel werden zu Lesezeichen.
+20 -1
View File
@@ -9,6 +9,7 @@ Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die
```text
~/.local/share/ink-notes/ ($XDG_DATA_HOME/ink-notes)
├── notebooks/
│ ├── library.json Ordner der Notizbücher (siehe unten)
│ ├── <notizbuch-id>/
│ │ ├── manifest.json Metadaten und Seitenreihenfolge
│ │ ├── manifest.json.bak Kopie des vorherigen Manifests
@@ -29,6 +30,21 @@ Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die
- **Assets** sind nach dem SHA-256-Hash ihres Inhalts benannt; dasselbe Bild wird nur einmal gespeichert. Bilder werden als PNG oder (unverändert) JPEG abgelegt, PDFs unverändert.
- Alle Dateien werden mit Rechten `0600`, Ordner mit `0700` angelegt.
### 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 (samt Inhalt) und Löschen von Ordnern ändern deshalb nie eine Notizbuch-Datei.
```json
{
"folders": ["3. Semester", "3. Semester/Mathe", "4. Semester"],
"notebooks": { "analysis_1775460000": "3. Semester/Mathe" }
}
```
- Ordner sind Pfade mit `/` als Trenner; Namen enthalten selbst kein `/` und höchstens 80 Zeichen.
- Notizbücher ohne Eintrag liegen auf der obersten Ebene.
- Wird ein Ordner gelöscht, rücken seine Notizbücher und Unterordner eine Ebene nach oben.
- Fehlt die Datei oder ist sie unlesbar, liegen alle Notizbücher auf der obersten Ebene; fehlende übergeordnete Ordner werden beim Laden ergänzt.
---
## 2. Manifest (`manifest.json`)
@@ -109,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]] } }
```
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:
@@ -142,6 +158,9 @@ Ein Absturz während des Speicherns hinterlässt deshalb entweder die alte oder
- Lässt sich eine Seite nicht lesen, wird sie nach `page_<id>.json.corrupt-<zeitstempel>` verschoben (nicht gelöscht) und die Seite schreibgeschützt angezeigt.
- Ist `manifest.json` beschädigt oder fehlt, wird `manifest.json.bak` verwendet.
### Gelöschte Seiten
Eine gelöschte Seite verschwindet aus `manifest.json`; ihre Datei `page_<id>.json` bleibt im Ordner liegen. Seitenkennungen werden nie wiederverwendet (`next_page_id`), die Datei kann also mit keiner neuen Seite kollidieren. Wird die Seite in derselben Sitzung wiederhergestellt, schreibt die App sie neu.
### Neuere Versionen
Dateien mit einer höheren `format_version` als der unterstützten werden nicht geöffnet – so kann eine ältere App-Version keine Inhalte verlieren, die sie nicht kennt.
+1 -1
View File
@@ -9,7 +9,7 @@ Terminal=false
Type=Application
Categories=Office;Graphics;Utility;
Keywords=Note;Notiz;Pen;Stift;Ink;Inking;Stylus;Drawing;Zeichnen;PDF;Vector;Text;Annotation;
StartupWMClass=ink-notes
StartupWMClass=eu.pansi.ink-notes
MimeType=application/x-inknotes;
Actions=NewWindow;
+1 -1
View File
@@ -9,7 +9,7 @@ Terminal=false
Type=Application
Categories=Office;Graphics;Utility;
Keywords=Note;Notiz;Pen;Stift;Ink;Inking;Stylus;Drawing;Zeichnen;PDF;Vector;Text;Annotation;
StartupWMClass=ink-notes
StartupWMClass=eu.pansi.ink-notes
MimeType=application/x-inknotes;
Actions=NewWindow;
+11
View File
@@ -3,6 +3,17 @@
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
### Highlights:
- **Bildschirmtastatur**: Unter GNOME/Zorin (Wayland) erscheint die Bildschirmtastatur jetzt beim Antippen eines Namensfelds – mit Finger oder Stift; Tippen und Rücktaste funktionieren. Außerdem geht der erste Fingertipp nach Mausbenutzung nicht mehr verloren
- **App-Symbol**: Das Fenster meldet sich als eu.pansi.ink-notes – Dock und Taskleiste zeigen das ink-notes-Symbol statt eines allgemeinen Symbols (unter X11 auch als Fenstersymbol)
- **Platz schaffen**: Neues Werkzeug ↕ – aufs Blatt tippen und nach unten ziehen, alles darunter rutscht mit, auch auf den folgenden Seiten (über das Seitenende hinaus geht es auf der nächsten Seite weiter, fehlende Seiten werden angehängt); nach oben ziehen schließt leeren Platz; ein Schritt für Rückgängig
- **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)
- **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
- **Beta-Feedback II**: Vier Stifte mit eigener Farbe und Stärke, die sich Änderungen merken; Textmarker wird innerhalb eines Strichs nicht dunkler und ist jetzt auch im PDF-/PNG-/SVG-Export durchsichtig; Seiten lückenlos untereinander mit Trennlinie; neue Seite per ➕ in der Werkzeugleiste; Seite löschen im ☰-Menü (wiederherstellbar); Koordinatensystem ohne Loch am Ursprung
- **Fehlerbehebungen**: Umbenennen und Löschen in der Notizbuchverwaltung reagieren wieder; Karos, Linien und Punkte werden pro Pixel berechnet und liegen in jedem Zoom gleichmäßig (mindestens ein Pixel breit, kantengeglättet)
- **Strichqualität**: Kleine Schleifen wie in „o“, „a“ oder „e“ bleiben rund (der fertige Strich entsteht aus den ungefilterten Stiftpunkten); Strichkanten werden kantengeglättet (4× Multisampling), runde Strichenden sind feiner unterteilt
- **Musterfarbe**: Karos, Linien und Punkte in hell, mittel, kräftig, blau oder grün (☰-Menü, gilt auch im Export); Punkte des Punktrasters am Bildschirm so groß wie im Export (0,4 mm). README und Dokumentation überarbeitet
+20 -2
View File
@@ -71,7 +71,8 @@ pub const LINED_SPACING: f32 = 75.0;
/// Papierfarbe der Anzeige. Betrifft nur die Darstellung am Bildschirm; gespeicherte
/// Strichfarben und der PDF-Export bleiben unverändert (weißes Papier).
///
/// Alle Farben sind linear (die Oberfläche ist sRGB, die Umrechnung macht die GPU).
/// Papier- und Musterfarben sind linear (der Shader rechnet sie in sRGB um); Tintenfarben
/// dagegen sind sRGB-Werte wie im Export.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PaperTheme {
@@ -138,7 +139,24 @@ impl PaperTheme {
PaperTheme::DarkGray => (0.004, 0.004, 0.005),
PaperTheme::Black => (0.03, 0.03, 0.035),
};
wgpu::Color { r, g, b, a: 1.0 }
// Linear angegeben; gezeichnet wird ohne sRGB-Umrechnung
let srgb = |c: f64| f64::from(linear_to_srgb(c as f32));
wgpu::Color {
r: srgb(r),
g: srgb(g),
b: srgb(b),
a: 1.0,
}
}
}
/// Linearer Farbanteil (Papier, Muster) als sRGB-Wert (Tinte, Export).
pub fn linear_to_srgb(c: f32) -> f32 {
let c = c.clamp(0.0, 1.0);
if c <= 0.003_130_8 {
c * 12.92
} else {
1.055 * c.powf(1.0 / 2.4) - 0.055
}
}
+167 -17
View File
@@ -128,6 +128,8 @@ struct NeighborFingerprint {
vertex_count: usize,
stroke_count: usize,
last_stroke_id: Option<u64>,
/// Neuaufbau des Caches (z. B. verschobener Inhalt bei gleicher Strichzahl)
cache_generation: u64,
}
impl NeighborFingerprint {
@@ -141,6 +143,7 @@ impl NeighborFingerprint {
vertex_count: page.vertex_cache.len(),
stroke_count: page.strokes.len(),
last_stroke_id: page.strokes.last().map(|s| s.id),
cache_generation: page.cache_generation,
}
}
}
@@ -166,6 +169,10 @@ pub struct CanvasState {
/// Multisampling der Zeichenfläche (1 = aus) und das zugehörige Zwischenbild
msaa_samples: u32,
msaa_view: Option<wgpu::TextureView>,
/// Stencil-Puffer: Jeder Strich deckt ein Pixel nur einmal, damit sich halbtransparente
/// Tinte (Textmarker) dort, wo der Strich sich selbst überlappt, nicht verdunkelt
stencil_view: wgpu::TextureView,
stencil_reset_pipeline: wgpu::RenderPipeline,
/// Meldung „keine Darstellungsmodi“ wurde bereits ausgegeben
presentation_warned: bool,
pub device: wgpu::Device,
@@ -312,13 +319,20 @@ impl CanvasState {
)
.await?;
// 4. Geringe Bildverzögerung (Mailbox, sonst Fifo) & Frame-Latency 1
// 4. Geringe Bildverzögerung (Mailbox, sonst Fifo) & Frame-Latency 1.
// Gezeichnet wird ohne sRGB-Umrechnung (siehe `render_format`); bietet die Fläche
// nur sRGB-Formate an, wird eine Ansicht ohne sRGB verwendet.
let surface_format = surface_caps
.formats
.iter()
.copied()
.find(|f| f.is_srgb())
.find(|f| !f.is_srgb())
.unwrap_or(surface_caps.formats[0]);
let view_formats = if surface_format.is_srgb() {
vec![surface_format.remove_srgb_suffix()]
} else {
vec![]
};
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
@@ -327,14 +341,14 @@ impl CanvasState {
height: size.height.max(1),
present_mode,
alpha_mode: surface_caps.alpha_modes[0],
view_formats: vec![],
view_formats,
desired_maximum_frame_latency: 1, // Minimiert Eingabelatenz auf 1 Frame
};
surface.configure(&device, &config);
// Kantenglättung der Zeichenfläche (4× Multisampling), falls das Format es erlaubt
let msaa_samples = if adapter
.get_texture_format_features(config.format)
.get_texture_format_features(render_format(&config))
.flags
.sample_count_supported(MSAA_SAMPLES)
{
@@ -343,6 +357,7 @@ impl CanvasState {
1
};
let msaa_view = create_msaa_view(&device, &config, msaa_samples);
let stencil_view = create_stencil_view(&device, &config, msaa_samples);
// 5. Shader & Bind Group Layout
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -409,7 +424,7 @@ impl CanvasState {
module: &shader,
entry_point: "fs_ink",
targets: &[Some(wgpu::ColorTargetState {
format: config.format,
format: render_format(&config),
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
@@ -423,7 +438,7 @@ impl CanvasState {
polygon_mode: wgpu::PolygonMode::Fill,
conservative: false,
},
depth_stencil: None,
depth_stencil: Some(stroke_stencil()),
multisample: wgpu::MultisampleState {
count: msaa_samples,
..Default::default()
@@ -431,6 +446,34 @@ impl CanvasState {
multiview: None,
});
// Setzt den Stencil-Puffer zurück, wenn die Strichnummern überlaufen
let stencil_reset_pipeline =
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Stencil Reset Pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: "vs_fullscreen",
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: "fs_none",
targets: &[Some(wgpu::ColorTargetState {
format: render_format(&config),
blend: None,
write_mask: wgpu::ColorWrites::empty(),
})],
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: Some(stencil_reset()),
multisample: wgpu::MultisampleState {
count: msaa_samples,
..Default::default()
},
multiview: None,
});
// TriangleList-Pipeline (für Hintergrundpapier und Muster)
let triangle_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Background TriangleList Pipeline"),
@@ -444,7 +487,7 @@ impl CanvasState {
module: &shader,
entry_point: "fs_main",
targets: &[Some(wgpu::ColorTargetState {
format: config.format,
format: render_format(&config),
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
@@ -458,7 +501,7 @@ impl CanvasState {
polygon_mode: wgpu::PolygonMode::Fill,
conservative: false,
},
depth_stencil: None,
depth_stencil: Some(passive_stencil()),
multisample: wgpu::MultisampleState {
count: msaa_samples,
..Default::default()
@@ -504,13 +547,13 @@ impl CanvasState {
module: &shader,
entry_point: "fs_pattern",
targets: &[Some(wgpu::ColorTargetState {
format: config.format,
format: render_format(&config),
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
depth_stencil: Some(passive_stencil()),
multisample: wgpu::MultisampleState {
count: msaa_samples,
..Default::default()
@@ -537,18 +580,20 @@ impl CanvasState {
&device,
&shader,
&bind_group_layout,
config.format,
render_format(&config),
msaa_samples,
);
// 8. egui Renderer für das schwebende UI initialisieren
let egui_renderer = egui_wgpu::Renderer::new(&device, config.format, None, 1);
let egui_renderer = egui_wgpu::Renderer::new(&device, render_format(&config), None, 1);
Ok(Self {
surface,
adapter,
msaa_samples,
msaa_view,
stencil_view,
stencil_reset_pipeline,
presentation_warned: false,
device,
queue,
@@ -698,6 +743,7 @@ impl CanvasState {
}
self.surface.configure(&self.device, &self.config);
self.msaa_view = create_msaa_view(&self.device, &self.config, self.msaa_samples);
self.stencil_view = create_stencil_view(&self.device, &self.config, self.msaa_samples);
self.recalculate_viewport();
}
}
@@ -1304,9 +1350,10 @@ impl CanvasState {
}
let output = self.surface.get_current_texture()?;
let view = output
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
let view = output.texture.create_view(&wgpu::TextureViewDescriptor {
format: Some(render_format(&self.config)),
..Default::default()
});
let mut encoder = self
.device
@@ -1340,10 +1387,18 @@ impl CanvasState {
},
},
})],
depth_stencil_attachment: None,
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.stencil_view,
depth_ops: None,
stencil_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0),
store: wgpu::StoreOp::Discard,
}),
}),
timestamp_writes: None,
occlusion_query_set: None,
});
let mut stencil_ref = 0;
// 0. Nachbarseiten der durchgehenden Ansicht (je mit eigener Kamera)
for (i, neighbor) in self.neighbors.iter().enumerate() {
@@ -1362,6 +1417,7 @@ impl CanvasState {
render_pass.set_vertex_buffer(0, buffer.slice(..));
for range in &neighbor.stroke_ranges {
if range.end > range.start {
self.next_stroke_stencil(&mut render_pass, &mut stencil_ref);
render_pass.draw(range.clone(), 0..1);
}
}
@@ -1393,6 +1449,7 @@ impl CanvasState {
render_pass.set_vertex_buffer(0, self.committed_vertex_buffer.slice(..));
for range in &self.committed_strokes {
if range.end > range.start {
self.next_stroke_stencil(&mut render_pass, &mut stencil_ref);
render_pass.draw(range.clone(), 0..1);
}
}
@@ -1401,6 +1458,7 @@ impl CanvasState {
// 5. Temporäre Live-Geometrie des aktuellen Strichs rendern (Zero Latency)
if self.live_vertex_count > 0 {
render_pass.set_vertex_buffer(0, self.live_vertex_buffer.slice(..));
self.next_stroke_stencil(&mut render_pass, &mut stencil_ref);
render_pass.draw(0..self.live_vertex_count, 0..1);
}
}
@@ -1437,6 +1495,20 @@ impl CanvasState {
Ok(())
}
/// Gibt dem nächsten Strich eine eigene Stencil-Nummer: Die Strich-Pipeline zeichnet nur
/// Pixel, die noch nicht diese Nummer tragen. Nach 255 Strichen wird der Stencil-Puffer
/// zurückgesetzt, damit kein späterer Strich die Nummer eines früheren wiederfindet.
fn next_stroke_stencil<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>, stencil_ref: &mut u32) {
if *stencil_ref == u32::from(u8::MAX) {
pass.set_pipeline(&self.stencil_reset_pipeline);
pass.draw(0..3, 0..1);
pass.set_pipeline(&self.strip_pipeline);
*stencil_ref = 0;
}
*stencil_ref += 1;
pass.set_stencil_reference(*stencil_ref);
}
/// Rendert nur die Canvas-Fläche ohne UI (z. B. für Headless-Tests).
pub fn render_canvas_only(&mut self) -> Result<(), wgpu::SurfaceError> {
let dummy_delta = egui::TexturesDelta::default();
@@ -1448,6 +1520,14 @@ impl CanvasState {
}
}
/// Format, in das gezeichnet wird: ohne sRGB-Umrechnung. Die Farben werden also in sRGB
/// gemischt – wie im PDF-Export, damit halbtransparente Tinte (Textmarker) am Bildschirm
/// genauso aussieht. Tintenfarben sind sRGB-Werte, Papier- und Musterfarben rechnet der
/// Shader aus linearen Werten um.
fn render_format(config: &wgpu::SurfaceConfiguration) -> wgpu::TextureFormat {
config.format.remove_srgb_suffix()
}
/// Multisampling der Zeichenfläche gegen gezackte Strichkanten.
const MSAA_SAMPLES: u32 = 4;
@@ -1469,7 +1549,7 @@ fn create_msaa_view(
mip_level_count: 1,
sample_count: samples,
dimension: wgpu::TextureDimension::D2,
format: config.format,
format: render_format(config),
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
})
@@ -1477,6 +1557,76 @@ fn create_msaa_view(
})
}
/// Format des Stencil-Puffers der Zeichenfläche.
const STENCIL_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth24PlusStencil8;
/// Stencil-Puffer in Fenstergröße (mit derselben Abtastzahl wie die Zeichenfläche).
fn create_stencil_view(
device: &wgpu::Device,
config: &wgpu::SurfaceConfiguration,
samples: u32,
) -> wgpu::TextureView {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Stroke Stencil Buffer"),
size: wgpu::Extent3d {
width: config.width.max(1),
height: config.height.max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: samples,
dimension: wgpu::TextureDimension::D2,
format: STENCIL_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
})
.create_view(&wgpu::TextureViewDescriptor::default())
}
/// Stencil-Zustand mit gleicher Prüfung und Aktion für Vorder- und Rückseiten.
fn stencil_state(
compare: wgpu::CompareFunction,
pass_op: wgpu::StencilOperation,
) -> wgpu::DepthStencilState {
let face = wgpu::StencilFaceState {
compare,
fail_op: wgpu::StencilOperation::Keep,
depth_fail_op: wgpu::StencilOperation::Keep,
pass_op,
};
wgpu::DepthStencilState {
format: STENCIL_FORMAT,
depth_write_enabled: false,
depth_compare: wgpu::CompareFunction::Always,
stencil: wgpu::StencilState {
front: face,
back: face,
read_mask: 0xff,
write_mask: 0xff,
},
bias: wgpu::DepthBiasState::default(),
}
}
/// Pipelines ohne Strichnummer (Papier, Muster, Bilder) lassen den Stencil-Puffer unberührt.
pub(crate) fn passive_stencil() -> wgpu::DepthStencilState {
stencil_state(wgpu::CompareFunction::Always, wgpu::StencilOperation::Keep)
}
/// Striche: nur Pixel, die die eigene Nummer noch nicht tragen, und diese dann markieren.
fn stroke_stencil() -> wgpu::DepthStencilState {
stencil_state(
wgpu::CompareFunction::NotEqual,
wgpu::StencilOperation::Replace,
)
}
/// Zurücksetzen: überall 0 eintragen.
fn stencil_reset() -> wgpu::DepthStencilState {
stencil_state(wgpu::CompareFunction::Always, wgpu::StencilOperation::Zero)
}
/// Zu rasterndes Textfeld.
struct TextSpec {
text: crate::text::TextBox,
+458
View File
@@ -722,6 +722,8 @@ pub struct Page {
pub vertex_cache: Vec<InkVertex>,
pub stroke_ranges: Vec<Range<u32>>,
pub is_dirty: bool,
/// Zählt die Neuaufbauten des Vertex-Caches (erkennt geänderte Nachbarseiten)
pub cache_generation: u64,
pub is_loaded: bool,
pub persist: PersistState,
pub history: HistoryStack,
@@ -742,6 +744,7 @@ impl Page {
is_loaded: true,
persist: PersistState::Normal,
history: HistoryStack::new(),
cache_generation: 0,
}
}
@@ -760,6 +763,7 @@ impl Page {
is_loaded: false,
persist: PersistState::Normal,
history: HistoryStack::new(),
cache_generation: 0,
}
}
@@ -958,6 +962,7 @@ impl Page {
}
self.is_dirty = false;
self.cache_generation += 1;
}
/// Berechnet die kombinierte AABB einer Menge von Strichen.
@@ -1217,6 +1222,29 @@ impl Notebook {
index
}
/// Entfernt die aktuelle Seite und gibt sie zurück; danach ist die folgende Seite aktuell
/// (bzw. die vorige, wenn es die letzte war). Die einzige Seite eines Notizbuchs bleibt
/// stehen (`None`).
pub fn remove_current_page(&mut self) -> Option<Page> {
if self.pages.len() < 2 {
return None;
}
let page = self.pages.remove(self.current_page_index);
self.load_warnings.remove(&page.id);
self.current_page_index = self.current_page_index.min(self.pages.len() - 1);
Some(page)
}
/// Setzt eine mit [`Self::remove_current_page`] entfernte Seite wieder an `index` ein
/// (höchstens ans Ende) und macht sie zur aktuellen Seite; gibt ihren Index zurück.
pub fn restore_page(&mut self, index: usize, page: Page) -> usize {
let index = index.min(self.pages.len());
self.next_page_id = self.next_page_id.max(page.id + 1);
self.pages.insert(index, page);
self.current_page_index = index;
index
}
/// Durchgehende Ansicht: Reicht ein neuer Strich auf der letzten Seite bis in deren
/// unteres Fünftel ([`AUTO_APPEND_FRACTION`]), wird darunter eine leere Seite mit gleichem
/// Hintergrund angehängt. `true`, wenn eine Seite angehängt wurde.
@@ -1292,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
/// 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.
@@ -1982,6 +2302,144 @@ mod tests {
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]
fn test_remove_and_restore_current_page() {
let mut nb = Notebook::new();
// Die einzige Seite bleibt
assert!(nb.remove_current_page().is_none());
nb.add_page(PageBackground::Lined);
nb.add_page(PageBackground::DotGrid);
let ids: Vec<u64> = nb.pages.iter().map(|p| p.id).collect();
// Mittlere Seite: danach ist die folgende aktuell
nb.current_page_index = 1;
let removed = nb.remove_current_page().unwrap();
assert_eq!(removed.id, ids[1]);
assert_eq!(
nb.pages.iter().map(|p| p.id).collect::<Vec<_>>(),
[ids[0], ids[2]]
);
assert_eq!(nb.current_page_index, 1);
// Wiederherstellen an der alten Stelle
assert_eq!(nb.restore_page(1, removed), 1);
assert_eq!(nb.pages.iter().map(|p| p.id).collect::<Vec<_>>(), ids);
assert_eq!(nb.current_page_index, 1);
// Letzte Seite: danach ist die vorige aktuell
nb.current_page_index = 2;
let last = nb.remove_current_page().unwrap();
assert_eq!(last.id, ids[2]);
assert_eq!(nb.current_page_index, 1);
// Neue Seiten bekommen keine schon vergebene Kennung
assert!(nb.next_page_id > ids[2]);
}
#[test]
fn test_append_page_when_writing_at_bottom_of_last_page() {
let mut nb = Notebook::new();
+162 -15
View File
@@ -180,7 +180,7 @@ pub fn export_notebook_to_bytes(
pattern: PatternColor,
) -> Result<Vec<u8>, Box<dyn Error>> {
let doc = generate_pdf_document(notebook, strategy, assets, pattern)?;
let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?;
let bytes = add_alpha_states(fix_text_encoding(doc.save_to_bytes()?, notebook)?, notebook)?;
// Importierte PDF-Seiten als Vektorgrafik unterlegen
let underlays = pdf_underlays(notebook);
@@ -206,7 +206,7 @@ pub fn export_notebook_to_bytes(
raster.insert(underlay.target_page);
}
let doc = generate_pdf_document_with(notebook, strategy, assets, pattern, &raster)?;
let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?;
let bytes = add_alpha_states(fix_text_encoding(doc.save_to_bytes()?, notebook)?, notebook)?;
let remaining: Vec<_> = underlays
.into_iter()
.filter(|u| !raster.contains(&u.target_page))
@@ -247,6 +247,77 @@ fn fix_text_encoding(bytes: Vec<u8>, notebook: &Notebook) -> Result<Vec<u8>, Box
Ok(out)
}
/// Name des Grafikzustands für halbtransparente Tinte mit Deckkraft `alpha` (auf Prozent
/// gerundet); `None` für deckende Farben.
fn alpha_state_name(alpha: f32) -> Option<String> {
let percent = (alpha.clamp(0.0, 1.0) * 100.0).round() as u32;
(percent < 100).then(|| format!("InkAlpha{percent}"))
}
/// printpdf kann keine Grafikzustände mit Deckkraft anlegen: Die Tintenebene verweist mit
/// `gs` auf die Namen aus [`alpha_state_name`], die Zustände selbst (`ca`/`CA`) werden hier
/// in die Ressourcen jeder Seite eingetragen. Ohne halbtransparente Striche bleibt das PDF
/// unverändert.
fn add_alpha_states(bytes: Vec<u8>, notebook: &Notebook) -> Result<Vec<u8>, Box<dyn Error>> {
use printpdf::lopdf::{Dictionary, Object};
let mut states: Vec<(String, f32)> = notebook
.pages
.iter()
.flat_map(|p| &p.strokes)
.filter(|s| !s.is_object())
.filter_map(|s| alpha_state_name(s.color[3]).map(|name| (name, s.color[3])))
.collect();
if states.is_empty() {
return Ok(bytes);
}
states.sort_by(|a, b| a.0.cmp(&b.0));
states.dedup_by(|a, b| a.0 == b.0);
let mut doc = printpdf::lopdf::Document::load_mem(&bytes)?;
let page_ids: Vec<_> = doc.get_pages().into_values().collect();
for page_id in page_ids {
let resources = dict_object_id(&mut doc, page_id, b"Resources")?;
let ext_g_state = dict_object_id(&mut doc, resources, b"ExtGState")?;
let dict = doc.get_object_mut(ext_g_state)?.as_dict_mut()?;
for (name, alpha) in &states {
let alpha = (alpha.clamp(0.0, 1.0) * 100.0).round() / 100.0;
let mut state = Dictionary::new();
state.set("Type", Object::Name(b"ExtGState".to_vec()));
state.set("ca", Object::Real(alpha));
state.set("CA", Object::Real(alpha));
dict.set(name.as_bytes().to_vec(), Object::Dictionary(state));
}
}
let mut out = Vec::new();
doc.save_to(&mut out)?;
Ok(out)
}
/// Kennung des Wörterbuchs unter `key` im Objekt `owner`; ein eingebettetes oder fehlendes
/// Wörterbuch wird dafür zu einem eigenen Objekt.
fn dict_object_id(
doc: &mut printpdf::lopdf::Document,
owner: printpdf::lopdf::ObjectId,
key: &[u8],
) -> Result<printpdf::lopdf::ObjectId, Box<dyn Error>> {
use printpdf::lopdf::{Dictionary, Object};
let entry = doc.get_object(owner)?.as_dict()?.get(key).ok().cloned();
if let Some(Object::Reference(id)) = entry {
return Ok(id);
}
let dict = match entry {
Some(Object::Dictionary(dict)) => dict,
_ => Dictionary::new(),
};
let id = doc.add_object(dict);
doc.get_object_mut(owner)?
.as_dict_mut()?
.set(key.to_vec(), Object::Reference(id));
Ok(id)
}
/// UTF-8-Text als PDF-Textstring in UTF-16BE mit BOM; `None` bei reinem ASCII oder
/// ungültigem UTF-8.
fn utf16be_text(raw: &[u8]) -> Option<Vec<u8>> {
@@ -374,8 +445,10 @@ fn render_background_layer(
) {
let background = page.background;
let (Mm(page_width_mm), Mm(page_height_mm)) = pdf_page_mm(page);
// Exportiert wird immer auf weißem Papier
let [r, g, b, _] = pattern.color(crate::background::PaperTheme::White);
// Exportiert wird immer auf weißem Papier; die Musterfarbe ist linear, PDF erwartet sRGB
let [r, g, b, _] = pattern
.color(crate::background::PaperTheme::White)
.map(crate::background::linear_to_srgb);
let grid_color = Color::Rgb(Rgb::new(r, g, b, None));
layer.set_outline_color(grid_color.clone());
layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt)
@@ -468,6 +541,17 @@ fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy:
if stroke.is_object() {
continue;
}
// Halbtransparente Tinte (Textmarker): Deckkraft über einen Grafikzustand, den
// `add_alpha_states` anlegt. Jeder Strich ist ein einziger Pfad und deckt daher jede
// Stelle nur einmal, auch wo er sich selbst überlappt.
let alpha_state = alpha_state_name(stroke.color[3]);
if let Some(name) = &alpha_state {
layer.save_graphics_state();
layer.add_operation(printpdf::lopdf::content::Operation::new(
"gs",
vec![printpdf::lopdf::Object::Name(name.as_bytes().to_vec())],
));
}
if let Some(ref shape) = stroke.shape {
render_recognized_shape(layer, shape, stroke.color);
} else {
@@ -480,6 +564,9 @@ fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy:
}
}
}
if alpha_state.is_some() {
layer.restore_graphics_state();
}
}
}
@@ -773,6 +860,10 @@ fn render_recognized_shape(
let stroke_color = Color::Rgb(Rgb::new(color[0], color[1], color[2], None));
layer.set_outline_color(stroke_color.clone());
layer.set_outline_thickness(1.5); // ca. 0.5 mm Standard-Liniendicke
// Wie auf dem Bildschirm: spitze Ecken und stumpfe Enden (der Grafikzustand kann noch
// runde Verbindungen von einem vorherigen Freihandstrich enthalten)
layer.set_line_cap_style(printpdf::LineCapStyle::Butt);
layer.set_line_join_style(printpdf::LineJoinStyle::Miter);
match shape {
RecognizedShape::Line { start, end } => {
@@ -942,6 +1033,59 @@ fn render_recognized_shape(
x_axis_end,
y_axis_end,
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
// (Linienstärke 1,5 pt ≈ 5,3 Seiteneinheiten, halbe Breite ≈ 2,65)
@@ -966,17 +1110,19 @@ fn render_recognized_shape(
(*y_axis_end, *x_axis_end)
};
let (ox, oy) = page_to_pdf_mm(origin.x, origin.y);
for end in [y_line_end, x_line_end] {
let (ex, ey) = page_to_pdf_mm(end.x, end.y);
layer.add_line(Line {
points: vec![
(Point::new(Mm(ox), Mm(oy)), false),
(Point::new(Mm(ex), Mm(ey)), false),
],
is_closed: false,
});
}
// Ein einziger Linienzug, damit die Ecke am Ursprung als Linienverbindung
// geschlossen gezeichnet wird statt als zwei stumpfe Enden
let points = [y_line_end, *origin, x_line_end]
.iter()
.map(|p| {
let (px, py) = page_to_pdf_mm(p.x, p.y);
(Point::new(Mm(px), Mm(py)), false)
})
.collect();
layer.add_line(Line {
points,
is_closed: false,
});
// Pfeilspitzen als gefüllte Dreiecke
if *with_arrows {
@@ -1357,6 +1503,7 @@ mod tests {
x_axis_end: glam::Vec2::new(800.0, 1500.0),
y_axis_end: glam::Vec2::new(300.0, 1000.0),
with_arrows: true,
negative_ends: None,
},
);
page.add_stroke(coords_stroke);
+378 -8
View File
@@ -59,6 +59,11 @@ pub enum RecognizedShape {
x_axis_end: Vec2,
y_axis_end: Vec2,
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 {
/// 3 Eckpunkte in umlaufender Reihenfolge.
@@ -105,6 +110,10 @@ impl RecognizedShape {
Self::Arrow { double: false, .. } => "Pfeil",
Self::Arrow { double: true, .. } => "Doppelpfeil",
Self::Rectangle { .. } => "Rechteck",
Self::CoordinateSystem {
negative_ends: Some(_),
..
} => "Achsenkreuz",
Self::CoordinateSystem { .. } => "Koordinatensystem",
Self::Triangle { .. } => "Dreieck",
Self::Rhombus { .. } => "Raute (Rhombus)",
@@ -146,11 +155,15 @@ impl RecognizedShape {
origin,
x_axis_end,
y_axis_end,
negative_ends,
..
} => {
*origin += delta;
*x_axis_end += delta;
*y_axis_end += delta;
for end in negative_ends.iter_mut().flatten() {
*end += delta;
}
}
Self::Polygon { vertices } => {
for v in vertices {
@@ -203,11 +216,15 @@ impl RecognizedShape {
origin: o,
x_axis_end,
y_axis_end,
negative_ends,
..
} => {
*o = rot(*o);
*x_axis_end = rot(*x_axis_end);
*y_axis_end = rot(*y_axis_end);
for end in negative_ends.iter_mut().flatten() {
*end = rot(*end);
}
}
Self::Polygon { vertices } => {
for v in vertices {
@@ -267,11 +284,15 @@ impl RecognizedShape {
origin: o,
x_axis_end,
y_axis_end,
negative_ends,
..
} => {
*o = sc(*o);
*x_axis_end = sc(*x_axis_end);
*y_axis_end = sc(*y_axis_end);
for end in negative_ends.iter_mut().flatten() {
*end = sc(*end);
}
}
Self::Polygon { vertices } => {
for v in vertices {
@@ -1394,6 +1415,87 @@ pub fn detect_coordinate_system(points: &[Vec2]) -> Option<RecognizedShape> {
x_axis_end: x_end,
y_axis_end: y_end,
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.
// Ü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).
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 {
None
@@ -1908,6 +2013,51 @@ pub fn shape_to_mesh(
x_axis_end,
y_axis_end,
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 samples = Vec::new();
@@ -1924,16 +2074,21 @@ pub fn shape_to_mesh(
(*y_axis_end, *x_axis_end)
};
// Beide Achsen beginnen um die halbe Linienbreite hinter dem Ursprung, damit die
// äußere Ecke geschlossen ist (sonst bleibt dort eine quadratische Kerbe)
let y_start = *origin - y_dir * half_w;
let x_start = *origin - x_dir * half_w;
// 1. Y-Achse (vom Ursprung zur Spitze)
let y_norm = Vec2::new(-y_dir.y, y_dir.x) * half_w;
vertices.push(InkVertex::new(
origin.x + y_norm.x,
origin.y + y_norm.y,
y_start.x + y_norm.x,
y_start.y + y_norm.y,
color,
));
vertices.push(InkVertex::new(
origin.x - y_norm.x,
origin.y - y_norm.y,
y_start.x - y_norm.x,
y_start.y - y_norm.y,
color,
));
vertices.push(InkVertex::new(
@@ -1950,15 +2105,15 @@ pub fn shape_to_mesh(
// Degenerierte Dreiecke für den Übergang zur X-Achse
let x_norm = Vec2::new(-x_dir.y, x_dir.x) * half_w;
let last_v = *vertices.last().unwrap();
let next_v = InkVertex::new(origin.x + x_norm.x, origin.y + x_norm.y, color);
let next_v = InkVertex::new(x_start.x + x_norm.x, x_start.y + x_norm.y, color);
vertices.push(last_v);
vertices.push(next_v);
// 2. X-Achse
vertices.push(next_v);
vertices.push(InkVertex::new(
origin.x - x_norm.x,
origin.y - x_norm.y,
x_start.x - x_norm.x,
x_start.y - x_norm.y,
color,
));
vertices.push(InkVertex::new(
@@ -2007,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.
pub fn shape_to_stroke(
shape: RecognizedShape,
@@ -2195,6 +2384,31 @@ mod tests {
}
}
#[test]
fn test_coordinate_system_corner_is_closed() {
// Die Achsen treffen sich ohne Kerbe: Die äußere Ecke des Ursprungs ist ein Eckpunkt
// der Achsenbänder (Issue #15)
let origin = Vec2::new(300.0, 900.0);
let shape = RecognizedShape::CoordinateSystem {
origin,
x_axis_end: Vec2::new(900.0, 900.0),
y_axis_end: Vec2::new(300.0, 300.0),
with_arrows: true,
negative_ends: None,
};
let width = 30.0;
let (_, vertices) = shape_to_mesh(&shape, width, [0.0, 0.0, 0.0, 1.0]);
let corner = origin + Vec2::new(-width / 2.0, width / 2.0);
let nearest = vertices
.iter()
.map(|v| (Vec2::from(v.position) - corner).length())
.fold(f32::INFINITY, f32::min);
assert!(
nearest < 1e-3,
"äußere Ecke fehlt, nächster Punkt {nearest}"
);
}
#[test]
fn test_detect_triangle() {
let mut points = Vec::new();
@@ -2450,12 +2664,167 @@ mod tests {
Some(RecognizedShape::Triangle { .. }) => "Dreieck",
Some(RecognizedShape::Rhombus { .. }) => "Raute",
Some(RecognizedShape::Polygon { .. }) => "Polygon",
Some(RecognizedShape::CoordinateSystem {
negative_ends: Some(_),
..
}) => "Achsenkreuz",
Some(RecognizedShape::CoordinateSystem { .. }) => "Koordinatensystem",
Some(RecognizedShape::Octahedron { .. }) => "Oktaeder",
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]
fn test_detect_shape_freehand_classification() {
let c = Vec2::new(1000.0, 1400.0);
@@ -2855,6 +3224,7 @@ mod tests {
x_axis_end: x_end,
y_axis_end: y_end,
with_arrows: true,
negative_ends: None,
};
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);
+13
View File
@@ -3,6 +3,9 @@
use crate::document::Stroke;
/// 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)]
pub enum HistoryAction {
/// Ein Strich wurde neu gezeichnet.
@@ -18,6 +21,9 @@ pub enum HistoryAction {
before: 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.
SplitStrokes {
removed: Vec<Stroke>,
@@ -136,6 +142,10 @@ impl HistoryStack {
self.redo_stack
.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 } => {
strokes.retain(|s| !added.iter().any(|a| a.id == s.id));
strokes.extend(removed.iter().cloned());
@@ -187,6 +197,9 @@ impl HistoryStack {
self.undo_stack
.push(HistoryAction::ModifyStrokes { before, after });
}
HistoryAction::SpaceShift(shift) => {
self.undo_stack.push(HistoryAction::SpaceShift(shift));
}
HistoryAction::SplitStrokes { removed, added, at } => {
strokes.retain(|s| !removed.iter().any(|r| r.id == s.id));
strokes.extend(added.iter().cloned());
+3 -2
View File
@@ -151,7 +151,7 @@ impl ImageLayer {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
depth_stencil: Some(crate::canvas::passive_stencil()),
multisample: wgpu::MultisampleState {
count: sample_count,
..Default::default()
@@ -363,7 +363,8 @@ impl ImageLayer {
mip_level_count: levels.len() as u32,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb,
// sRGB-Werte ohne Umrechnung: gezeichnet wird in sRGB (siehe canvas::render_format)
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
+2
View File
@@ -11,6 +11,8 @@ pub mod filter;
pub mod geometry;
pub mod history;
pub mod image_layer;
pub mod library;
pub mod osk;
pub mod page_export;
pub mod pdf_doc;
pub mod pdf_merge;
+441
View File
@@ -0,0 +1,441 @@
//! Ordner für Notizbücher (`<Basisordner>/library.json`).
//!
//! Ordner sind rein logisch: Die Notizbücher bleiben in ihren Verzeichnissen
//! `<Basisordner>/<id>/`, `library.json` hält nur fest, welcher Ordner welches Notizbuch
//! enthält. Umbenennen und Verschieben ändern so nie Notizbuch-Dateien. Fehlt die Datei oder
//! ist sie unlesbar, liegen alle Notizbücher auf der obersten Ebene – verloren geht nichts.
//!
//! Ordner werden als Pfade mit `/` als Trenner gespeichert (z. B. `3. Semester/Mathe`);
//! die oberste Ebene ist der leere Pfad.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::storage::{self, StorageError};
/// Oberste Ebene.
pub const ROOT: &str = "";
/// Längster erlaubter Ordnername (Zeichen).
const MAX_NAME_CHARS: usize = 80;
/// Ordnerbaum und Zuordnung der Notizbücher.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Library {
/// Alle Ordner als Pfade
pub folders: BTreeSet<String>,
/// Ordner je Notizbuch-Kennung; Notizbücher ohne Eintrag liegen auf oberster Ebene
pub notebooks: BTreeMap<String, String>,
}
/// Letzter Teil eines Ordnerpfads (der angezeigte Name).
pub fn folder_name(path: &str) -> &str {
path.rsplit('/').next().unwrap_or(path)
}
/// Übergeordneter Ordner (oberste Ebene für Ordner der obersten Ebene).
pub fn parent_folder(path: &str) -> &str {
path.rsplit_once('/').map_or(ROOT, |(parent, _)| parent)
}
/// Pfad des Unterordners `name` in `parent`.
fn join(parent: &str, name: &str) -> String {
if parent.is_empty() {
name.to_string()
} else {
format!("{parent}/{name}")
}
}
/// `path` liegt in `folder` oder ist `folder` selbst.
fn is_within(path: &str, folder: &str) -> bool {
path == folder
|| path
.strip_prefix(folder)
.is_some_and(|rest| rest.starts_with('/'))
}
/// Bereinigt einen Ordnernamen; Fehlermeldung für die Oberfläche bei ungültigen Namen.
pub fn validate_name(name: &str) -> Result<String, String> {
let name = name.trim();
if name.is_empty() {
return Err("Der Ordnername ist leer".to_string());
}
if name.contains('/') {
return Err("Ordnernamen dürfen kein „/“ enthalten".to_string());
}
if name.chars().count() > MAX_NAME_CHARS {
return Err(format!(
"Der Ordnername ist zu lang (höchstens {MAX_NAME_CHARS} Zeichen)"
));
}
Ok(name.to_string())
}
impl Library {
/// Ort von `library.json` im Basisordner der Notizbücher.
pub fn file_path(base_dir: &Path) -> PathBuf {
base_dir.join("library.json")
}
/// Lädt die Ordner; fehlt die Datei oder ist sie beschädigt, gibt es keine Ordner.
pub fn load(base_dir: &Path) -> Self {
let path = Self::file_path(base_dir);
let mut library: Self = match std::fs::read_to_string(&path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
eprintln!(
"[Ordner] '{}' ist ungültig ({e}), alle Notizbücher auf oberster Ebene.",
path.display()
);
Self::default()
}),
Err(_) => Self::default(),
};
library.repair();
library
}
/// Speichert die Ordner atomar.
pub fn save(&self, base_dir: &Path) -> Result<(), StorageError> {
storage::atomic_write_json(&Self::file_path(base_dir), self)
}
/// Macht eine (evtl. von Hand bearbeitete) Datei stimmig: leere oder ungültige Namen
/// fallen weg, jeder benutzte Ordner und seine übergeordneten Ordner existieren.
fn repair(&mut self) {
let valid = |path: &str| {
!path.is_empty()
&& path
.split('/')
.all(|name| validate_name(name).as_deref() == Ok(name))
};
self.folders.retain(|f| valid(f));
self.notebooks.retain(|_, f| f.is_empty() || valid(f));
let used: Vec<String> = self
.folders
.iter()
.chain(self.notebooks.values())
.cloned()
.collect();
for mut folder in used {
while !folder.is_empty() {
let parent = parent_folder(&folder).to_string();
self.folders.insert(folder);
folder = parent;
}
}
}
/// Ordner, in dem das Notizbuch liegt.
pub fn folder_of(&self, notebook_id: &str) -> &str {
self.notebooks.get(notebook_id).map_or(ROOT, String::as_str)
}
/// Direkte Unterordner von `parent` (vollständige Pfade, nach Name sortiert).
pub fn subfolders(&self, parent: &str) -> Vec<&str> {
let mut list: Vec<&str> = self
.folders
.iter()
.map(String::as_str)
.filter(|f| parent_folder(f) == parent)
.collect();
list.sort_by_key(|f| folder_name(f).to_lowercase());
list
}
/// Alle Ordner in Baumreihenfolge (Eltern vor Kindern, Geschwister nach Name).
pub fn all_folders(&self) -> Vec<&str> {
let mut out = Vec::with_capacity(self.folders.len());
let mut stack: Vec<&str> = self.subfolders(ROOT).into_iter().rev().collect();
while let Some(folder) = stack.pop() {
out.push(folder);
stack.extend(self.subfolders(folder).into_iter().rev());
}
out
}
/// Legt in `parent` den Ordner `name` an und gibt seinen Pfad zurück.
pub fn create_folder(&mut self, parent: &str, name: &str) -> Result<String, String> {
let name = validate_name(name)?;
if !parent.is_empty() && !self.folders.contains(parent) {
return Err("Der übergeordnete Ordner existiert nicht mehr".to_string());
}
let path = join(parent, &name);
if !self.folders.insert(path.clone()) {
return Err(format!("Den Ordner „{name}“ gibt es hier schon"));
}
Ok(path)
}
/// Benennt einen Ordner um (mitsamt Unterordnern und Zuordnungen); gibt den neuen Pfad
/// zurück.
pub fn rename_folder(&mut self, path: &str, new_name: &str) -> Result<String, String> {
let new_name = validate_name(new_name)?;
if !self.folders.contains(path) {
return Err("Den Ordner gibt es nicht mehr".to_string());
}
let new_path = join(parent_folder(path), &new_name);
if new_path == path {
return Ok(new_path);
}
if self.folders.contains(&new_path) {
return Err(format!("Den Ordner „{new_name}“ gibt es hier schon"));
}
self.move_contents(path, &new_path);
Ok(new_path)
}
/// Löscht einen Ordner. Seine Notizbücher und Unterordner rücken eine Ebene nach oben
/// (gleichnamige Unterordner werden zusammengelegt) – gelöscht wird kein Notizbuch.
pub fn delete_folder(&mut self, path: &str) {
if path.is_empty() || !self.folders.remove(path) {
return;
}
let parent = parent_folder(path).to_string();
let prefix = format!("{path}/");
let moved: Vec<String> = self
.folders
.iter()
.filter(|f| f.starts_with(&prefix))
.cloned()
.collect();
for folder in moved {
self.folders.remove(&folder);
self.folders.insert(join(&parent, &folder[prefix.len()..]));
}
for folder in self.notebooks.values_mut() {
if folder == path {
*folder = parent.clone();
} else if let Some(rest) = folder.strip_prefix(&prefix) {
*folder = join(&parent, rest);
}
}
self.notebooks.retain(|_, f| !f.is_empty());
}
/// Verschiebt einen Ordner samt Inhalt nach `new_path`.
fn move_contents(&mut self, path: &str, new_path: &str) {
let renamed = |f: &str| format!("{new_path}{}", &f[path.len()..]);
let moved: Vec<String> = self
.folders
.iter()
.filter(|f| is_within(f, path))
.cloned()
.collect();
for folder in moved {
self.folders.remove(&folder);
self.folders.insert(renamed(&folder));
}
for folder in self.notebooks.values_mut() {
if is_within(folder, path) {
*folder = renamed(folder);
}
}
}
/// 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`]).
pub fn move_notebook(&mut self, notebook_id: &str, folder: &str) -> Result<(), String> {
if folder.is_empty() {
self.notebooks.remove(notebook_id);
return Ok(());
}
if !self.folders.contains(folder) {
return Err("Den Ordner gibt es nicht mehr".to_string());
}
self.notebooks
.insert(notebook_id.to_string(), folder.to_string());
Ok(())
}
/// Wie viele der Notizbücher `ids` in `folder` liegen, Unterordner eingeschlossen.
pub fn count_within<'a>(&self, folder: &str, ids: impl IntoIterator<Item = &'a str>) -> usize {
ids.into_iter()
.filter(|id| is_within(self.folder_of(id), folder))
.count()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn library() -> Library {
let mut lib = Library::default();
let sem3 = lib.create_folder(ROOT, "3. Semester").unwrap();
let mathe = lib.create_folder(&sem3, "Mathe").unwrap();
lib.create_folder(&sem3, "Physik").unwrap();
lib.move_notebook("analysis", &mathe).unwrap();
lib.move_notebook("uebung", &sem3).unwrap();
lib
}
#[test]
fn test_paths_and_names() {
assert_eq!(folder_name("3. Semester/Mathe"), "Mathe");
assert_eq!(folder_name("Mathe"), "Mathe");
assert_eq!(parent_folder("3. Semester/Mathe"), "3. Semester");
assert_eq!(parent_folder("Mathe"), ROOT);
assert_eq!(validate_name(" Mathe ").unwrap(), "Mathe");
assert!(validate_name(" ").is_err());
assert!(validate_name("a/b").is_err());
assert!(validate_name(&"x".repeat(81)).is_err());
}
#[test]
fn test_create_and_list_folders() {
let mut lib = library();
assert_eq!(lib.subfolders(ROOT), ["3. Semester"]);
assert_eq!(
lib.subfolders("3. Semester"),
["3. Semester/Mathe", "3. Semester/Physik"]
);
assert_eq!(
lib.all_folders(),
["3. Semester", "3. Semester/Mathe", "3. Semester/Physik"]
);
assert_eq!(lib.folder_of("analysis"), "3. Semester/Mathe");
assert_eq!(lib.folder_of("unbekannt"), ROOT);
// Doppelte Namen und fehlende Eltern werden abgelehnt
assert!(lib.create_folder("3. Semester", " Mathe ").is_err());
assert!(lib.create_folder("Gibt es nicht", "X").is_err());
// Gleicher Name in einem anderen Ordner ist erlaubt
lib.create_folder(ROOT, "4. Semester").unwrap();
assert!(lib.create_folder("4. Semester", "Mathe").is_ok());
assert_eq!(
lib.count_within("3. Semester", ["analysis", "uebung", "lose"]),
2
);
}
#[test]
fn test_rename_folder_keeps_contents() {
let mut lib = library();
let new = lib.rename_folder("3. Semester", "WS 25-26").unwrap();
assert_eq!(new, "WS 25-26");
assert_eq!(lib.folder_of("analysis"), "WS 25-26/Mathe");
assert_eq!(lib.folder_of("uebung"), "WS 25-26");
assert!(lib.folders.contains("WS 25-26/Physik"));
assert!(!lib.folders.iter().any(|f| f.starts_with("3. Semester")));
// Ein Ordner mit ähnlichem Namen bleibt unberührt
let mut lib = library();
lib.create_folder(ROOT, "3. Semester alt").unwrap();
lib.rename_folder("3. Semester", "Drei").unwrap();
assert!(lib.folders.contains("3. Semester alt"));
// Kollision
assert!(lib.rename_folder("Drei", "3. Semester alt").is_err());
}
#[test]
fn test_delete_folder_moves_contents_up() {
let mut lib = library();
lib.delete_folder("3. Semester");
assert_eq!(lib.folder_of("analysis"), "Mathe");
assert_eq!(lib.folder_of("uebung"), ROOT);
assert!(!lib.notebooks.contains_key("uebung"));
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]
fn test_move_notebook() {
let mut lib = library();
lib.move_notebook("uebung", "3. Semester/Physik").unwrap();
assert_eq!(lib.folder_of("uebung"), "3. Semester/Physik");
lib.move_notebook("uebung", ROOT).unwrap();
assert_eq!(lib.folder_of("uebung"), ROOT);
assert!(lib.move_notebook("uebung", "Gibt es nicht").is_err());
}
#[test]
fn test_load_save_and_repair() {
let dir = tempfile::tempdir().unwrap();
// Fehlende Datei: keine Ordner
assert_eq!(Library::load(dir.path()), Library::default());
let lib = library();
lib.save(dir.path()).unwrap();
assert_eq!(Library::load(dir.path()), lib);
// Beschädigt: alles auf oberster Ebene statt Absturz
std::fs::write(Library::file_path(dir.path()), b"{ kaputt").unwrap();
assert_eq!(Library::load(dir.path()), Library::default());
// Von Hand bearbeitet: fehlende Elternordner werden ergänzt, Unsinn fällt weg
std::fs::write(
Library::file_path(dir.path()),
br#"{"folders": ["A/B", "", "C//D"], "notebooks": {"x": "E/F", "y": "/kaputt"}}"#,
)
.unwrap();
let lib = Library::load(dir.path());
assert_eq!(lib.all_folders(), ["A", "A/B", "E", "E/F"]);
assert_eq!(lib.folder_of("x"), "E/F");
assert_eq!(lib.folder_of("y"), ROOT);
}
}
+606 -84
View File
File diff suppressed because it is too large Load Diff
+294
View File
@@ -0,0 +1,294 @@
//! Bildschirmtastatur unter Wayland anfordern.
//!
//! GNOME (mutter) blendet die Bildschirmtastatur nicht schon beim ersten `enable` eines
//! Texteingabefelds (`zwp_text_input_v3`) ein, sondern erst, wenn ein bereits aktives Feld
//! `enable` erneut meldet – so macht es GTK beim Antippen. winit meldet `enable` nur einmal,
//! sobald egui ein Textfeld fokussiert. Daher schickt ink-notes über ein eigenes
//! text-input-Objekt auf derselben Wayland-Verbindung ein weiteres `enable`. Die getippten
//! Zeichen kommen weiterhin über winit an.
//!
//! Die Rücktaste der GNOME-Bildschirmtastatur löscht über `delete_surrounding_text` im zuvor
//! gemeldeten Umgebungstext; ohne Umgebungstext passiert nichts, und winit wertet das Löschen
//! ohnehin nicht aus. Daher meldet das eigene Objekt einen Platzhalter als Umgebungstext und
//! übersetzt die Löschwünsche in Tastendrücke (Rücktaste/Entf) für egui.
use std::ffi::c_void;
use wayland_client::{
delegate_noop,
protocol::{wl_registry, wl_seat},
Connection, Dispatch, EventQueue, QueueHandle,
};
use wayland_protocols::wp::text_input::zv3::client::{
zwp_text_input_manager_v3::ZwpTextInputManagerV3,
zwp_text_input_v3::{self, ZwpTextInputV3},
};
/// Platzhalter-Umgebungstext: ein Zeichen vor der Schreibmarke, damit die Rücktaste der
/// Bildschirmtastatur immer etwas zu löschen hat (ein Byte = ein Zeichen)
const SURROUNDING_PLACEHOLDER: &str = " ";
/// Wandelt Texteingaben ohne Vorschau (Preedit) in normale Texteingaben um.
///
/// Die Bildschirmtastatur schickt jedes Zeichen als fertigen IME-Text, ohne vorher eine Vorschau
/// zu melden. egui 0.27 fügt solche Texte nur ein, solange die Schreibmarke dort steht, wo die
/// letzte Vorschau begann – also nur das erste Zeichen. `composing` merkt sich über Frames hinweg,
/// ob gerade eine Vorschau läuft; deren Abschluss bleibt bei egui.
pub fn plain_ime_commits_to_text(events: &mut [egui::Event], composing: &mut bool) {
for event in events {
match event {
egui::Event::CompositionStart => *composing = true,
egui::Event::CompositionEnd(text) => {
if !*composing && !text.is_empty() && !text.contains(['\n', '\r']) {
*event = egui::Event::Text(std::mem::take(text));
}
*composing = false;
}
_ => {}
}
}
}
/// Tastendrücke für egui, die `back` Zeichen vor und `forward` Zeichen nach der Schreibmarke
/// löschen
pub fn deletion_key_events(back: u32, forward: u32) -> Vec<egui::Event> {
let presses = std::iter::repeat_n(egui::Key::Backspace, back as usize)
.chain(std::iter::repeat_n(egui::Key::Delete, forward as usize));
presses
.flat_map(|key| {
[true, false].map(|pressed| egui::Event::Key {
key,
physical_key: None,
pressed,
repeat: false,
modifiers: egui::Modifiers::NONE,
})
})
.collect()
}
/// Entscheidet, ob die Bildschirmtastatur angefordert wird: sobald ein Textfeld den Fokus
/// bekommt und erneut bei jedem Antippen des fokussierten Felds (falls die Tastatur inzwischen
/// zugeklappt wurde).
pub fn should_request(was_active: bool, active: bool, pressed: bool) -> bool {
active && (!was_active || pressed)
}
#[derive(Default)]
struct State {
seat: Option<wl_seat::WlSeat>,
manager: Option<ZwpTextInputManagerV3>,
text_input: Option<ZwpTextInputV3>,
/// Von der Bildschirmtastatur gewünschtes Löschen (Zeichen vor/nach der Schreibmarke)
delete_back: u32,
delete_forward: u32,
}
/// Eigenes text-input-Objekt, mit dem ink-notes die Bildschirmtastatur anfordert
pub struct KeyboardRequester {
conn: Connection,
queue: EventQueue<State>,
state: State,
}
impl KeyboardRequester {
/// Hängt sich an die Wayland-Verbindung des Fensters.
///
/// # Safety
/// `wl_display` muss auf die `wl_display` des Fensters zeigen und gültig bleiben, solange
/// der `KeyboardRequester` lebt.
pub unsafe fn new(wl_display: *mut c_void) -> Self {
// Safety: an den Aufrufer weitergereicht
let backend =
unsafe { wayland_backend::client::Backend::from_foreign_display(wl_display.cast()) };
let conn = Connection::from_backend(backend);
let queue = conn.new_event_queue();
conn.display().get_registry(&queue.handle(), ());
let _ = conn.flush();
Self {
conn,
queue,
state: State::default(),
}
}
/// Verarbeitet eingetroffene Ereignisse; einmal pro Frame aufrufen.
pub fn pump(&mut self) {
let _ = self.queue.dispatch_pending(&mut self.state);
if self.state.text_input.is_none() {
if let (Some(manager), Some(seat)) = (&self.state.manager, &self.state.seat) {
self.state.text_input =
Some(manager.get_text_input(seat, &self.queue.handle(), ()));
}
}
}
/// Bittet den Compositor, die Bildschirmtastatur einzublenden. Muss nach dem `enable` von
/// winit kommen (also nach `handle_platform_output`).
pub fn request(&mut self) {
self.pump();
if let Some(text_input) = &self.state.text_input {
text_input.enable();
self.send_surrounding_placeholder();
}
}
/// Tastendrücke für das seit dem letzten Aufruf gewünschte Löschen (vor `begin_frame`
/// aufrufen und an die egui-Eingabe anhängen)
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
self.pump();
// Den Platzhalter hier nicht neu melden: Die Tastatur löscht bei jeder neuen Meldung
// erneut, solange die Rücktaste gedrückt ist – ein kurzes Antippen löschte sonst doppelt.
let back = std::mem::take(&mut self.state.delete_back);
let forward = std::mem::take(&mut self.state.delete_forward);
deletion_key_events(back, forward)
}
fn send_surrounding_placeholder(&self) {
if let Some(text_input) = &self.state.text_input {
let len = SURROUNDING_PLACEHOLDER.len() as i32;
text_input.set_surrounding_text(SURROUNDING_PLACEHOLDER.to_owned(), len, len);
text_input.commit();
let _ = self.conn.flush();
}
}
}
impl Dispatch<wl_registry::WlRegistry, ()> for State {
fn event(
state: &mut Self,
registry: &wl_registry::WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
if let wl_registry::Event::Global {
name, interface, ..
} = event
{
match interface.as_str() {
"wl_seat" if state.seat.is_none() => {
state.seat = Some(registry.bind(name, 1, qh, ()));
}
"zwp_text_input_manager_v3" if state.manager.is_none() => {
state.manager = Some(registry.bind(name, 1, qh, ()));
}
_ => {}
}
}
}
}
delegate_noop!(State: ignore wl_seat::WlSeat);
delegate_noop!(State: ZwpTextInputManagerV3);
impl Dispatch<ZwpTextInputV3, ()> for State {
fn event(
state: &mut Self,
_: &ZwpTextInputV3,
event: zwp_text_input_v3::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
// Getippter Text kommt über winit; hier nur das Löschen
if let zwp_text_input_v3::Event::DeleteSurroundingText {
before_length,
after_length,
} = event
{
state.delete_back += before_length;
state.delete_forward += after_length;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn requests_when_text_field_gets_focus() {
assert!(should_request(false, true, false));
assert!(should_request(false, true, true));
}
#[test]
fn requests_again_when_focused_field_is_tapped() {
assert!(should_request(true, true, true));
assert!(!should_request(true, true, false));
}
#[test]
fn commits_without_preedit_become_text() {
let mut composing = false;
let mut events = vec![
egui::Event::CompositionEnd("h".into()),
egui::Event::CompositionEnd("i".into()),
];
plain_ime_commits_to_text(&mut events, &mut composing);
assert_eq!(
events,
vec![egui::Event::Text("h".into()), egui::Event::Text("i".into())]
);
assert!(!composing);
}
#[test]
fn commits_after_preedit_stay_with_egui() {
let mut composing = false;
let mut events = vec![
egui::Event::CompositionStart,
egui::Event::CompositionUpdate("ni".into()),
];
plain_ime_commits_to_text(&mut events, &mut composing);
assert!(composing);
// Abschluss erst im nächsten Frame
let mut events = vec![egui::Event::CompositionEnd("你".into())];
plain_ime_commits_to_text(&mut events, &mut composing);
assert_eq!(events, vec![egui::Event::CompositionEnd("你".into())]);
assert!(!composing);
}
#[test]
fn line_breaks_and_empty_commits_are_left_alone() {
let mut composing = false;
let mut events = vec![
egui::Event::CompositionEnd("\n".into()),
egui::Event::CompositionEnd(String::new()),
];
let before = events.clone();
plain_ime_commits_to_text(&mut events, &mut composing);
assert_eq!(events, before);
}
#[test]
fn deletions_become_key_presses() {
let events = deletion_key_events(2, 1);
let keys: Vec<_> = events
.iter()
.map(|e| match e {
egui::Event::Key { key, pressed, .. } => (*key, *pressed),
other => panic!("unerwartet: {other:?}"),
})
.collect();
assert_eq!(
keys,
vec![
(egui::Key::Backspace, true),
(egui::Key::Backspace, false),
(egui::Key::Backspace, true),
(egui::Key::Backspace, false),
(egui::Key::Delete, true),
(egui::Key::Delete, false),
]
);
assert!(deletion_key_events(0, 0).is_empty());
}
#[test]
fn no_request_without_focused_text_field() {
assert!(!should_request(false, false, true));
assert!(!should_request(true, false, true));
}
}
+52
View File
@@ -233,6 +233,58 @@ mod tests {
assert!(clear.height() as f32 / clear.width() as f32 - PAGE_HEIGHT / PAGE_WIDTH < 0.01);
}
#[test]
fn test_highlighter_is_translucent_in_export() {
// Schwarze Linie, darüber ein gelber Textmarker, der hin und zurück läuft
let mut page = page_with_line();
page.strokes[0].color = [0.0, 0.0, 0.0, 1.0];
for v in &mut page.strokes[0].vertices {
v.color = [0.0, 0.0, 0.0, 1.0];
}
let color = [1.0, 0.95, 0.1, 0.35];
let samples: Vec<StylusSample> = (0..=40)
.map(|i| {
let x = if i <= 20 { i } else { 40 - i } as f32;
StylusSample {
position: glam::Vec2::new(500.0 + x * 20.0, 1000.0 + (i as f32 * 0.5)),
pressure: 0.6,
timestamp: i as f64 * 0.01,
}
})
.collect();
let vertices = crate::storage::reconstruct_stroke_vertices(
&samples,
color,
60.0,
Default::default(),
true,
);
let mut stroke = Stroke::new(8, samples, vertices, color);
stroke.width = 60.0;
stroke.tool = crate::document::StrokeTool::Highlighter;
page.strokes.push(stroke);
let png = decode(
&page_png(&page, None, ImageBackground::Paper, PatternColor::default()).unwrap(),
);
let px_per_unit = png.width() as f32 / PAGE_WIDTH;
let at = |x: f32, y: f32| {
png.get_pixel((x * px_per_unit) as u32, (y * px_per_unit) as u32)
.0
};
// Die schwarze Linie bleibt unter dem Textmarker sichtbar
let line = at(700.0, 1000.0);
assert!(line[0] < 110 && line[1] < 110, "Linie verdeckt: {line:?}");
// Neben der Linie: einmal gelb getönt, auch wo der Strich sich selbst überlappt
let tint = at(700.0, 1020.0);
assert!(tint[2] < 200 && tint[0] > 200, "kein Textmarker: {tint:?}");
let expected_blue = 255.0 * (1.0 - 0.35) + 0.1 * 255.0 * 0.35;
assert!(
(tint[2] as f32 - expected_blue).abs() < 25.0,
"Textmarker mehrfach gedeckt: {tint:?}, erwartet Blau ≈ {expected_blue}"
);
}
#[test]
fn test_export_uses_pattern_color() {
// Kariertes Blatt: das kräftige Muster ist im Export dunkler als das helle
+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)]
mod tests {
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]
fn test_choose_display_backend() {
// WSL mit X11-Display: X11 (Wayland unter WSLg bricht ab)
+20 -2
View File
@@ -203,13 +203,19 @@ impl SelectionState {
pub fn paste_from_clipboard(
&mut self,
page: &mut Page,
at: Option<Vec2>,
next_id_gen: &mut impl FnMut() -> u64,
) -> Vec<u64> {
if self.clipboard.is_empty() {
return Vec::new();
}
let offset = Vec2::new(30.0, 30.0); // ca. 3 mm Versatz nach rechts-unten
// Mit `at` (lange gedrückt → Einfügen) landet die Mitte der Objekte dort, sonst
// ca. 3 mm nach rechts unten versetzt
let offset = match (at, Page::selection_aabb(&self.clipboard)) {
(Some(at), Some(aabb)) => at - (aabb.min + aabb.max) / 2.0,
_ => Vec2::new(30.0, 30.0),
};
let mut new_strokes = Vec::with_capacity(self.clipboard.len());
let mut new_ids = Vec::with_capacity(self.clipboard.len());
@@ -457,7 +463,7 @@ mod tests {
// Paste
let mut next_id = 100;
let new_ids = sel.paste_from_clipboard(&mut page, &mut || {
let new_ids = sel.paste_from_clipboard(&mut page, None, &mut || {
next_id += 1;
next_id
});
@@ -465,6 +471,18 @@ mod tests {
assert_eq!(page.strokes.len(), 3);
assert_eq!(sel.selected_ids, new_ids);
// An einer Stelle einfügen: die Mitte der Kopie liegt dort
let target = Vec2::new(1000.0, 1500.0);
let placed = sel.paste_from_clipboard(&mut page, Some(target), &mut || {
next_id += 1;
next_id
});
let aabb = sel.get_selection_aabb(&page).unwrap();
assert_eq!(sel.selected_ids, placed);
assert!(((aabb.min + aabb.max) / 2.0 - target).length() < 0.01);
sel.delete_selected(&mut page);
sel.selected_ids = new_ids.clone();
// Delete
let deleted = sel.delete_selected(&mut page);
assert_eq!(deleted.len(), 1);
+214
View File
@@ -73,6 +73,130 @@ impl Default for PressureCurve {
}
}
/// Stift-Vorlage: Farbe und Stärke eines der Stifte in der Werkzeugleiste. Ändert man Farbe
/// oder Stärke, während ein Stift gewählt ist, merkt sich die Vorlage die neuen Werte.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct PenPreset {
pub color: [f32; 4],
pub width_mm: f32,
}
impl PenPreset {
pub const WIDTH_RANGE: std::ops::RangeInclusive<f32> = 0.15..=5.0;
/// Begrenzt Werte aus einer (evtl. von Hand bearbeiteten) Einstellungsdatei; Stifte sind
/// immer deckend.
pub fn sanitized(&self, fallback: PenPreset) -> Self {
let color = sanitized_color(self.color, [0.0, 0.0, 0.0, 1.0]);
let width_mm = if self.width_mm.is_finite() {
self.width_mm
.clamp(*Self::WIDTH_RANGE.start(), *Self::WIDTH_RANGE.end())
} else {
fallback.width_mm
};
Self { color, width_mm }
}
}
/// Anzahl der Stifte in der Werkzeugleiste.
pub const PEN_PRESET_COUNT: usize = 4;
/// Standardstifte: Schwarz, Blau und Rot fein, Grün etwas kräftiger.
pub const DEFAULT_PEN_PRESETS: [PenPreset; PEN_PRESET_COUNT] = [
PenPreset {
color: [0.12, 0.12, 0.14, 1.0],
width_mm: 0.5,
},
PenPreset {
color: [0.15, 0.35, 0.85, 1.0],
width_mm: 0.5,
},
PenPreset {
color: [0.85, 0.20, 0.20, 1.0],
width_mm: 0.5,
},
PenPreset {
color: [0.18, 0.65, 0.35, 1.0],
width_mm: 0.8,
},
];
/// Farben des Textmarkers in der Werkzeugleiste (halbtransparent).
pub const DEFAULT_HIGHLIGHTER_COLORS: [[f32; 4]; 4] = [
[1.0, 0.95, 0.1, 0.35],
[0.2, 0.9, 0.3, 0.35],
[0.25, 0.7, 1.0, 0.35],
[1.0, 0.3, 0.65, 0.35],
];
/// Farben des Text-Werkzeugs in der Werkzeugleiste.
pub const DEFAULT_TEXT_COLORS: [[f32; 4]; 5] = [
[0.12, 0.12, 0.14, 1.0],
[0.15, 0.35, 0.85, 1.0],
[0.85, 0.20, 0.20, 1.0],
[0.18, 0.65, 0.35, 1.0],
[0.45, 0.45, 0.48, 1.0],
];
/// Begrenzt eine Farbe aus der Einstellungsdatei auf 0–1; die Deckkraft kommt immer aus
/// `fallback` (Stift und Text deckend, Textmarker halbtransparent).
fn sanitized_color(color: [f32; 4], fallback: [f32; 4]) -> [f32; 4] {
let mut out = fallback;
if color.iter().all(|c| c.is_finite()) {
for (o, c) in out.iter_mut().zip(color).take(3) {
*o = c.clamp(0.0, 1.0);
}
}
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.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@@ -88,6 +212,16 @@ pub struct UiSettings {
pub pressure_curve: PressureCurve,
/// Seiten durchgehend untereinander anzeigen (sonst einzeln blättern)
pub continuous_scroll: bool,
/// Stifte der Werkzeugleiste und der gewählte davon
pub pen_presets: [PenPreset; PEN_PRESET_COUNT],
pub active_pen: usize,
/// Eigene Farben von Textmarker und Text-Werkzeug und die gewählte davon
pub highlighter_colors: [[f32; 4]; 4],
pub active_highlighter: usize,
pub text_colors: [[f32; 4]; 5],
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 {
@@ -99,6 +233,13 @@ impl Default for UiSettings {
pattern_color: PatternColor::default(),
pressure_curve: PressureCurve::default(),
continuous_scroll: true,
pen_presets: DEFAULT_PEN_PRESETS,
active_pen: 0,
highlighter_colors: DEFAULT_HIGHLIGHTER_COLORS,
active_highlighter: 0,
text_colors: DEFAULT_TEXT_COLORS,
active_text_color: 0,
stylus_buttons: Vec::new(),
}
}
}
@@ -126,6 +267,27 @@ impl UiSettings {
Err(_) => Self::default(),
};
settings.pressure_curve = settings.pressure_curve.sanitized();
for (preset, fallback) in settings.pen_presets.iter_mut().zip(DEFAULT_PEN_PRESETS) {
*preset = preset.sanitized(fallback);
}
settings.active_pen = settings.active_pen.min(PEN_PRESET_COUNT - 1);
for (color, fallback) in settings
.highlighter_colors
.iter_mut()
.zip(DEFAULT_HIGHLIGHTER_COLORS)
{
*color = sanitized_color(*color, fallback);
}
settings.active_highlighter = settings
.active_highlighter
.min(settings.highlighter_colors.len() - 1);
for (color, fallback) in settings.text_colors.iter_mut().zip(DEFAULT_TEXT_COLORS) {
*color = sanitized_color(*color, fallback);
}
settings.active_text_color = settings
.active_text_color
.min(settings.text_colors.len() - 1);
settings.stylus_buttons.truncate(MAX_STYLUS_BUTTONS);
settings
}
@@ -157,6 +319,25 @@ mod tests {
threshold: 0.1,
},
continuous_scroll: false,
pen_presets: [PenPreset {
color: [0.5, 0.2, 0.7, 1.0],
width_mm: 1.5,
}; PEN_PRESET_COUNT],
active_pen: 2,
highlighter_colors: [[0.9, 0.5, 0.1, 0.35]; 4],
active_highlighter: 3,
text_colors: [[0.3, 0.1, 0.6, 1.0]; 5],
active_text_color: 4,
stylus_buttons: vec![
StylusButton {
id: 17,
action: StylusAction::Select,
},
StylusButton {
id: 42,
action: StylusAction::Nothing,
},
],
};
settings.save_to(&path).unwrap();
assert_eq!(UiSettings::load_from(&path), settings);
@@ -193,6 +374,39 @@ mod tests {
let curve = UiSettings::load_from(&path).pressure_curve;
assert_eq!(curve.exponent, 3.0);
assert_eq!(curve.threshold, 0.0);
// Ohne Stifte: die Standardstifte; unsinnige Stifte werden begrenzt
assert_eq!(settings.pen_presets, DEFAULT_PEN_PRESETS);
assert_eq!(settings.active_pen, 0);
std::fs::write(
&path,
br#"{"active_pen": 9, "pen_presets": [
{"color": [2.0, -1.0, 0.5, 0.2], "width_mm": 99.0},
{"color": [0.1, 0.2, 0.3, 1.0], "width_mm": 0.0},
{"color": [0.1, 0.2, 0.3, 1.0], "width_mm": 1.0},
{"color": [0.1, 0.2, 0.3, 1.0], "width_mm": 1.0}
]}"#,
)
.unwrap();
let settings = UiSettings::load_from(&path);
assert_eq!(settings.active_pen, PEN_PRESET_COUNT - 1);
assert_eq!(settings.pen_presets[0].color, [1.0, 0.0, 0.5, 1.0]);
assert_eq!(settings.pen_presets[0].width_mm, 5.0);
assert_eq!(settings.pen_presets[1].width_mm, 0.15);
// Eigene Farben: Textmarker bleibt halbtransparent, Text deckend
std::fs::write(
&path,
br#"{"highlighter_colors": [[0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0],
[0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0]], "active_highlighter": 7,
"text_colors": [[5.0, 0.2, 0.3, 0.1], [0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0],
[0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0]]}"#,
)
.unwrap();
let settings = UiSettings::load_from(&path);
assert_eq!(settings.highlighter_colors[0], [0.1, 0.2, 0.3, 0.35]);
assert_eq!(settings.active_highlighter, 3);
assert_eq!(settings.text_colors[0], [1.0, 0.2, 0.3, 1.0]);
}
#[test]
+37 -11
View File
@@ -30,25 +30,50 @@ fn vs_main(in: VertexInput) -> VertexOutput {
return out;
}
// Linearer Farbwert (Papier, Muster) -> sRGB: Gezeichnet wird ohne sRGB-Umrechnung der GPU
// (siehe `render_format` in canvas.rs), gemischt wird also wie im PDF in sRGB
fn linear_to_srgb(c: vec3<f32>) -> vec3<f32> {
let low = c * 12.92;
let high = 1.055 * pow(c, vec3<f32>(1.0 / 2.4)) - vec3<f32>(0.055);
return select(high, low, c <= vec3<f32>(0.0031308));
}
// Papier: Farbe linear
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
return in.color;
return vec4<f32>(linear_to_srgb(clamp(in.color.rgb, vec3<f32>(0.0), vec3<f32>(1.0))), in.color.a);
}
// Striche: bei dunklem Papier wird die HSL-Helligkeit dunkler Farben gespiegelt
// (L -> 1 - L, Farbton und Sättigung bleiben). Entspricht `background::adapt_ink_color`.
// Striche: Tintenfarben sind sRGB-Werte wie im Export und in der Oberfläche. Bei dunklem
// Papier wird die HSL-Helligkeit dunkler Farben gespiegelt (L -> 1 - L, Farbton und
// Sättigung bleiben). Entspricht `background::adapt_ink_color`.
@fragment
fn fs_ink(in: VertexOutput) -> @location(0) vec4<f32> {
if (camera.ink_params.x < 0.5) {
return in.color;
var rgb = clamp(in.color.rgb, vec3<f32>(0.0), vec3<f32>(1.0));
if (camera.ink_params.x >= 0.5) {
let max_c = max(max(rgb.r, rgb.g), rgb.b);
let min_c = min(min(rgb.r, rgb.g), rgb.b);
let lift = max(1.0 - max_c - min_c, 0.0);
rgb = min(rgb + vec3<f32>(lift), vec3<f32>(1.0));
}
let max_c = max(max(in.color.r, in.color.g), in.color.b);
let min_c = min(min(in.color.r, in.color.g), in.color.b);
let lift = max(1.0 - max_c - min_c, 0.0);
return vec4<f32>(in.color.rgb + vec3<f32>(lift), in.color.a);
return vec4<f32>(rgb, in.color.a);
}
// Eingefügte Bilder: texturierte Vierecke unter der Tinte
// Bildschirmfüllendes Dreieck ohne Farbe (setzt den Stencil-Puffer zurück)
@vertex
fn vs_fullscreen(@builtin(vertex_index) index: u32) -> @builtin(position) vec4<f32> {
let x = f32((index << 1u) & 2u) * 2.0 - 1.0;
let y = f32(index & 2u) * 2.0 - 1.0;
return vec4<f32>(x, y, 0.0, 1.0);
}
@fragment
fn fs_none() -> @location(0) vec4<f32> {
return vec4<f32>(0.0);
}
// Eingefügte Bilder: texturierte Vierecke unter der Tinte (Texturen enthalten sRGB-Werte
// und werden unverändert übernommen)
struct ImageVertexInput {
@location(0) position: vec2<f32>,
@location(1) uv: vec2<f32>,
@@ -156,5 +181,6 @@ fn fs_pattern(in: PatternOutput) -> @location(0) vec4<f32> {
coverage = clamp(radius_px + 0.5 - d, 0.0, 1.0);
}
}
return vec4<f32>(in.color.rgb, in.color.a * coverage * fade);
let rgb = linear_to_srgb(clamp(in.color.rgb, vec3<f32>(0.0), vec3<f32>(1.0)));
return vec4<f32>(rgb, in.color.a * coverage * fade);
}
+4 -4
View File
@@ -348,7 +348,7 @@ const MAX_TEXTURE_SIDE: f32 = 4096.0;
const MAX_TEXTURE_PIXELS: f32 = 16.0 * 1024.0 * 1024.0;
/// Rastert ein Textfeld mit `px_per_unit` Pixeln je Seiteneinheit (begrenzt auf eine
/// handhabbare Texturgröße). `color` ist linear wie die Tinte; die Pixel sind sRGB.
/// handhabbare Texturgröße). `color` ist sRGB wie die Tinte, ebenso die Pixel.
pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage {
let layout = text.layout();
let size = glam::Vec2::new(text.wrap_width.max(text.size), layout.height());
@@ -383,7 +383,7 @@ pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage
}
}
let rgb = [0, 1, 2].map(|i| egui::ecolor::gamma_u8_from_linear_f32(color[i]));
let rgb = [0, 1, 2].map(|i| (color[i].clamp(0.0, 1.0) * 255.0).round() as u8);
let alpha = color[3].clamp(0.0, 1.0);
let mut pixels = Vec::with_capacity(coverage.len() * 4);
for c in coverage {
@@ -476,8 +476,8 @@ mod tests {
assert_eq!(image.height as f32, text.layout().height().ceil());
let opaque = image.pixels.chunks(4).filter(|p| p[3] > 200).count();
assert!(opaque > 100, "{opaque}");
// Farbe ist sRGB-kodiert
assert!(image.pixels.chunks(4).all(|p| p[0] > 230 && p[1] < 130));
// Die Farbe ist schon sRGB und wird unverändert übernommen
assert!(image.pixels.chunks(4).all(|p| p[..3] == [217, 51, 51]));
}
#[test]
+1324 -199
View File
File diff suppressed because it is too large Load Diff
+18 -19
View File
@@ -3,20 +3,18 @@
//! Berechnet die affine Transformation zwischen Bildschirmkoordinaten (Window-Pixel),
//! virtuellen DIN-A4-Seitenkoordinaten ([0, 2100] x [0, 2970]) und dem GPU-NDC-Raum ([-1, 1]).
use crate::document::{PageViewport, UNITS_PER_MM};
use crate::document::PageViewport;
use glam::{Mat4, Vec2, Vec4};
/// Minimaler und maximaler Zoomfaktor.
pub const MIN_ZOOM: f32 = 0.25;
pub const MAX_ZOOM: f32 = 10.0;
/// Abstand zwischen zwei Seiten in der durchgehenden Ansicht (8 mm).
pub const PAGE_GAP: f32 = 8.0 * UNITS_PER_MM;
/// Höchstens so viele Nachbarseiten werden gleichzeitig dargestellt.
pub const MAX_NEIGHBOR_PAGES: usize = 12;
/// Durchgehende Ansicht: Die Seiten hängen mit [`PAGE_GAP`] Abstand untereinander und sind
/// waagerecht zueinander zentriert. Lage jeder Seite (Ecke oben links) in Koordinaten der
/// Durchgehende Ansicht: Die Seiten hängen lückenlos untereinander (die Grenze markiert eine
/// Linie der Oberfläche) und sind waagerecht zueinander zentriert. Lage jeder Seite (Ecke oben links) in Koordinaten der
/// aktuellen Seite.
#[derive(Debug, Clone, PartialEq)]
pub struct PageStack {
@@ -33,11 +31,11 @@ impl PageStack {
let mut origins = vec![Vec2::ZERO; sizes.len()];
let x = |i: usize| (width - sizes[i].x) / 2.0;
for i in current + 1..sizes.len() {
let y = origins[i - 1].y + sizes[i - 1].y + PAGE_GAP;
let y = origins[i - 1].y + sizes[i - 1].y;
origins[i] = Vec2::new(x(i), y);
}
for i in (0..current).rev() {
let y = origins[i + 1].y - PAGE_GAP - sizes[i].y;
let y = origins[i + 1].y - sizes[i].y;
origins[i] = Vec2::new(x(i), y);
}
Self {
@@ -55,7 +53,7 @@ impl PageStack {
self.sizes.is_empty()
}
/// Seite, auf deren Höhe `y` liegt; `None` im Zwischenraum oder außerhalb.
/// Seite, auf deren Höhe `y` liegt (an der Grenze die untere); `None` außerhalb.
pub fn page_at(&self, y: f32) -> Option<usize> {
// Seiten liegen nach Index sortiert untereinander
let i = self.origins.partition_point(|o| o.y <= y).checked_sub(1)?;
@@ -312,7 +310,7 @@ mod tests {
use super::*;
const A4: Vec2 = crate::document::A4_SIZE;
const PITCH: f32 = A4.y + PAGE_GAP;
const PITCH: f32 = A4.y;
fn a4_stack(count: usize, current: usize) -> PageStack {
PageStack::new(vec![A4; count], current)
@@ -322,10 +320,11 @@ mod tests {
fn test_continuous_layout() {
let stack = a4_stack(5, 1);
assert_eq!(stack.page_at(100.0), Some(1));
assert_eq!(stack.page_at(A4.y + PAGE_GAP / 2.0), None);
// Lückenlos: direkt unter dem Seitenende beginnt die nächste Seite
assert_eq!(stack.page_at(A4.y - 1.0), Some(1));
assert_eq!(stack.page_at(A4.y + 1.0), Some(2));
assert_eq!(stack.page_at(PITCH + 5.0), Some(2));
assert_eq!(stack.page_at(-5.0), None);
assert_eq!(stack.page_at(-PAGE_GAP - 5.0), Some(0));
assert_eq!(stack.page_at(-5.0), Some(0));
assert_eq!(stack.page_at(-PITCH - 5.0), None, "vor der ersten Seite");
// Ende von Seite 2 und Anfang von Seite 3 sichtbar (aktuell: Seite 2 von 5)
@@ -339,7 +338,7 @@ mod tests {
.is_empty());
// Hinter der letzten Seite: keine weiteren
assert_eq!(
a4_stack(5, 3).visible_neighbors(-PITCH, PITCH * 4.0),
a4_stack(5, 3).visible_neighbors(-PITCH + 1.0, PITCH * 4.0),
vec![2, 4]
);
// Weit herausgezoomt: auf die nächstgelegenen Seiten begrenzt
@@ -352,13 +351,13 @@ mod tests {
fn test_stack_of_different_page_sizes() {
let slide = Vec2::new(2970.0, 1671.0);
let stack = PageStack::new(vec![slide, A4, slide], 1);
// Folien waagerecht zur A4-Seite zentriert, mit Abstand darüber und darunter
assert_eq!(stack.origins[0], Vec2::new(-435.0, -PAGE_GAP - slide.y));
assert_eq!(stack.origins[2], Vec2::new(-435.0, A4.y + PAGE_GAP));
assert_eq!(stack.page_at(A4.y + PAGE_GAP + 10.0), Some(2));
// Folien waagerecht zur A4-Seite zentriert, direkt darüber und darunter
assert_eq!(stack.origins[0], Vec2::new(-435.0, -slide.y));
assert_eq!(stack.origins[2], Vec2::new(-435.0, A4.y));
assert_eq!(stack.page_at(A4.y + 10.0), Some(2));
let (min, max) = stack.bounds();
assert_eq!(min, Vec2::new(-435.0, -PAGE_GAP - slide.y));
assert_eq!(max, Vec2::new(2535.0, A4.y + PAGE_GAP + slide.y));
assert_eq!(min, Vec2::new(-435.0, -slide.y));
assert_eq!(max, Vec2::new(2535.0, A4.y + slide.y));
}
#[test]