Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61c0c903a4 | ||
|
|
680fa96f32 | ||
|
|
eb0a801921 | ||
|
|
76ecdac081 | ||
|
|
ea9ee6c0d7 | ||
|
|
d1f0b46fac | ||
|
|
82a297f395 | ||
|
|
e33062fe0a | ||
|
|
36a1a3cd2e | ||
|
|
70317caeb5 | ||
|
|
f9a6f8524d | ||
|
|
4a4492d034 | ||
|
|
bb9dbb52d0 | ||
|
|
568714d727 | ||
|
|
c87f4102d7 | ||
|
|
5115e217c2 |
Generated
+1
-1
@@ -1393,7 +1393,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.14.0"
|
version = "0.20.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ab_glyph",
|
"ab_glyph",
|
||||||
"arboard",
|
"arboard",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.14.0"
|
version = "0.20.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
||||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||||
|
|||||||
@@ -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.
|
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.
|
||||||
|
|
||||||
@@ -12,14 +19,16 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
|
|||||||
|
|
||||||
### Schreiben und Zeichnen
|
### Schreiben und Zeichnen
|
||||||
- **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.
|
- **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.
|
- **Glättung in zwei Stufen:** Während des Schreibens glättet ein 1€-Filter das Zittern, die Linie erscheint sofort. Beim Absetzen wird der Strich nachbearbeitet: Ecken (Knicke über 65°) bleiben erhalten, der Druckverlauf wird geglättet, überflüssige Punkte entfallen, die Enden werden rund.
|
||||||
- **Druckkurve** im ☰-Menü an den eigenen Stift anpassen (weicher/härter, Schwelle gegen Grundrauschen).
|
- **Druckkurve** im ☰-Menü an den eigenen Stift anpassen (weicher/härter, Schwelle gegen Grundrauschen).
|
||||||
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Stiftrückseite und Seitentaste des Stifts radieren ebenfalls.
|
- **Tasten am Stift** im ☰-Menü belegen: Radieren (Standard), Auswählen, Textmarker, Stift oder nichts – gilt, solange die Taste gedrückt ist. Jede Taste erscheint dort nach dem ersten Drücken.
|
||||||
|
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Die Stiftrückseite radiert ebenfalls.
|
||||||
- **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade.
|
- **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade.
|
||||||
|
|
||||||
### Formerkennung
|
### Formerkennung
|
||||||
- **Formen-Werkzeug** (`S`/`F`) oder **Halten** am Strichende (über 400 ms stillhalten): Die Skizze wird durch eine saubere Form ersetzt.
|
- **Formen-Werkzeug** (`S`/`F`) oder **Halten** am Strichende (über 400 ms stillhalten): Die Skizze wird durch eine saubere Form ersetzt.
|
||||||
- Erkannt werden Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Vielecke, Koordinatensysteme und Oktaeder (Skizze als 3D-Körper).
|
- Erkannt werden Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Vielecke, Koordinatensysteme (L-Form), Achsenkreuze (ein „+“ in einem Zug, Pfeilspitzen an allen vier Enden) und Oktaeder (Skizze als 3D-Körper).
|
||||||
- Die Erkennung arbeitet rein geometrisch (Bogenlänge, Douglas-Peucker, Kreis-Fit), ohne Machine Learning. Nicht alles wird erkannt – was nicht passt, bleibt Handschrift.
|
- Die Erkennung arbeitet rein geometrisch (Bogenlänge, Douglas-Peucker, Kreis-Fit), ohne Machine Learning. Nicht alles wird erkannt – was nicht passt, bleibt Handschrift.
|
||||||
|
|
||||||
### Auswählen und Bearbeiten
|
### Auswählen und Bearbeiten
|
||||||
@@ -31,6 +40,7 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
|
|||||||
### Text, Bilder und PDFs
|
### 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.
|
- **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.
|
- **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.
|
- **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
|
### Seiten und Ansicht
|
||||||
@@ -44,6 +54,7 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
|
|||||||
|
|
||||||
### Notizbücher und Speichern
|
### Notizbücher und Speichern
|
||||||
- Mehrere **Notizbücher** anlegen, umbenennen, wechseln und löschen (gelöschte landen zunächst im Papierkorb und lassen sich wiederherstellen).
|
- Mehrere **Notizbücher** anlegen, umbenennen, wechseln und löschen (gelöschte landen zunächst im Papierkorb und lassen sich wiederherstellen).
|
||||||
|
- **Ordner** für Notizbücher, beliebig verschachtelt (z. B. 3. Semester › Mathe): anlegen, umbenennen, löschen (der Inhalt rückt eine Ebene nach oben) und Notizbücher wie Ordner (samt Inhalt) mit 📂 verschieben. Neue Notizbücher landen im gerade geöffneten Ordner.
|
||||||
- **Automatisches Speichern** im Hintergrund: 1,5 Sekunden nach der letzten Änderung, bei ununterbrochenem Schreiben spätestens alle 10 Sekunden. Zeichnen wird dabei nicht unterbrochen.
|
- **Automatisches Speichern** im Hintergrund: 1,5 Sekunden nach der letzten Änderung, bei ununterbrochenem Schreiben spätestens alle 10 Sekunden. Zeichnen wird dabei nicht unterbrochen.
|
||||||
- Jede Datei wird erst vollständig in eine temporäre Datei geschrieben und dann ersetzt – ein Absturz beim Speichern hinterlässt keine halbe Datei.
|
- Jede Datei wird erst vollständig in eine temporäre Datei geschrieben und dann ersetzt – ein Absturz beim Speichern hinterlässt keine halbe Datei.
|
||||||
- **Beim Schließen** (Fenster oder `Strg + Q`) und beim Wechsel des Notizbuchs wird alles Ausstehende geschrieben, bevor es weitergeht. Schlägt das fehl, bleibt die App offen und bietet Wiederholen oder eine Notfall-Sicherung an.
|
- **Beim Schließen** (Fenster oder `Strg + Q`) und beim Wechsel des Notizbuchs wird alles Ausstehende geschrieben, bevor es weitergeht. Schlägt das fehl, bleibt die App offen und bietet Wiederholen oder eine Notfall-Sicherung an.
|
||||||
@@ -153,6 +164,7 @@ src/
|
|||||||
├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen
|
├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen
|
||||||
├── history.rs # Rückgängig/Wiederholen
|
├── history.rs # Rückgängig/Wiederholen
|
||||||
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb
|
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb
|
||||||
|
├── library.rs # Ordner der Notizbücher (library.json)
|
||||||
├── autosave.rs # Speichern im Hintergrund
|
├── autosave.rs # Speichern im Hintergrund
|
||||||
├── export.rs # PDF-Export (printpdf)
|
├── export.rs # PDF-Export (printpdf)
|
||||||
├── page_export.rs # Seite als PNG/SVG, Auswahl als Bild
|
├── page_export.rs # Seite als PNG/SVG, Auswahl als Bild
|
||||||
|
|||||||
@@ -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 & 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- & 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 & 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 & 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 & atomar
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.2 KiB |
@@ -115,7 +115,7 @@ Jeder Strich ist ein eigener Draw-Call mit eigener Nummer im Stencil-Puffer: Ein
|
|||||||
- **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.
|
- **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
|
### 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -136,12 +136,13 @@ Jede Seite hat einen eigenen Verlauf (`history.rs`): Hinzufügen, Entfernen, Ers
|
|||||||
|
|
||||||
## 6. Formerkennung
|
## 6. Formerkennung
|
||||||
|
|
||||||
`geometry.rs` erkennt Formen rein geometrisch, ohne Machine Learning. Geschlossene Formen (Kreis, Ellipse, Dreieck, Rechteck, Raute) treten gegeneinander an; gewählt wird die, deren Umriss am besten zu den Punkten passt. Danach folgen Vielecke, Pfeile, Koordinatensysteme und Linien. Übernommen wird eine Form nur, wenn der Strich im Mittel höchstens 4 % seiner Diagonale von ihr abweicht – sonst bleibt er Handschrift.
|
`geometry.rs` erkennt Formen rein geometrisch, ohne Machine Learning. Zuerst wird auf ein Achsenkreuz geprüft (alle Teilstücke liegen auf einer waagerechten und einer senkrechten Linie, beide reichen auf beiden Seiten über den Schnittpunkt hinaus). Geschlossene Formen (Kreis, Ellipse, Dreieck, Rechteck, Raute) treten gegeneinander an; gewählt wird die, deren Umriss am besten zu den Punkten passt. Danach folgen Vielecke, Pfeile, Koordinatensysteme und Linien. Übernommen wird eine Form nur, wenn der Strich im Mittel höchstens 4 % seiner Diagonale von ihr abweicht – sonst bleibt er Handschrift.
|
||||||
|
|
||||||
- **Linie:** Verhältnis von Abstand Anfang–Ende zur Strichlänge über 0,92.
|
- **Linie:** Verhältnis von Abstand Anfang–Ende zur Strichlänge über 0,92.
|
||||||
- **Kreis, Ellipse:** Kreis- bzw. Ellipsen-Fit über die Punkte.
|
- **Kreis, Ellipse:** Kreis- bzw. Ellipsen-Fit über die Punkte.
|
||||||
- **Rechteck, Dreieck, Raute, Vieleck, Pfeil:** Analyse der mit Douglas-Peucker vereinfachten Kontur (Eckenzahl, Winkel, Seitenverhältnisse).
|
- **Rechteck, Dreieck, Raute, Vieleck, Pfeil:** Analyse der mit Douglas-Peucker vereinfachten Kontur (Eckenzahl, Winkel, Seitenverhältnisse).
|
||||||
- **Koordinatensystem:** zwei etwa rechtwinklige Achsen; der Winkel wird auf 90° gesetzt.
|
- **Koordinatensystem:** zwei etwa rechtwinklige Achsen; der Winkel wird auf 90° gesetzt.
|
||||||
|
- **Achsenkreuz:** ein „+“ in einem Zug; die Achsen werden waagerecht und senkrecht ausgerichtet und bekommen an allen vier Enden Pfeilspitzen.
|
||||||
- **Oktaeder:** Skizze einer Doppelpyramide als 3D-Kantenmodell; nur, wenn kein einfacher Umriss passt.
|
- **Oktaeder:** Skizze einer Doppelpyramide als 3D-Kantenmodell; nur, wenn kein einfacher Umriss passt.
|
||||||
|
|
||||||
**Halten am Strichende:** Bleibt der Stift über 400 ms innerhalb von 3 Pixeln ruhig, wird die erkannte Form schon vor dem Absetzen angezeigt und beim Absetzen übernommen. Mit dem Formen-Werkzeug wird jeder Strich beim Absetzen geprüft.
|
**Halten am Strichende:** Bleibt der Stift über 400 ms innerhalb von 3 Pixeln ruhig, wird die erkannte Form schon vor dem Absetzen angezeigt und beim Absetzen übernommen. Mit dem Formen-Werkzeug wird jeder Strich beim Absetzen geprüft.
|
||||||
|
|||||||
+17
-1
@@ -9,6 +9,7 @@ Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die
|
|||||||
```text
|
```text
|
||||||
~/.local/share/ink-notes/ ($XDG_DATA_HOME/ink-notes)
|
~/.local/share/ink-notes/ ($XDG_DATA_HOME/ink-notes)
|
||||||
├── notebooks/
|
├── notebooks/
|
||||||
|
│ ├── library.json Ordner der Notizbücher (siehe unten)
|
||||||
│ ├── <notizbuch-id>/
|
│ ├── <notizbuch-id>/
|
||||||
│ │ ├── manifest.json Metadaten und Seitenreihenfolge
|
│ │ ├── manifest.json Metadaten und Seitenreihenfolge
|
||||||
│ │ ├── manifest.json.bak Kopie des vorherigen Manifests
|
│ │ ├── 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.
|
- **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.
|
- 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`)
|
## 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]] } }
|
"shape": { "Rectangle": { "corners": [[500, 600], [800, 600], [800, 800], [500, 800]] } }
|
||||||
```
|
```
|
||||||
|
|
||||||
Mögliche Formen: `Line { start, end }`, `Arrow { start, end, double }`, `Circle { center, radius }`, `Ellipse { center, radius_x, radius_y, rotation }`, `Rectangle { corners }`, `Triangle { corners }`, `Rhombus { corners }`, `Polygon { vertices }`, `CoordinateSystem { origin, x_axis_end, y_axis_end, with_arrows }`, `Octahedron { … }`. Punkte sind `[x, y]`.
|
Mögliche Formen: `Line { start, end }`, `Arrow { start, end, double }`, `Circle { center, radius }`, `Ellipse { center, radius_x, radius_y, rotation }`, `Rectangle { corners }`, `Triangle { corners }`, `Rhombus { corners }`, `Polygon { vertices }`, `CoordinateSystem { origin, x_axis_end, y_axis_end, with_arrows, negative_ends? }`, `Octahedron { … }`. Punkte sind `[x, y]`. Beim Achsenkreuz enthält `negative_ends` die Enden der x- und der y-Achse auf der anderen Seite des Ursprungs; ältere Versionen ignorieren das Feld und zeigen ein L-förmiges Koordinatensystem.
|
||||||
|
|
||||||
**Bilder** haben `image` (Dateiname in `assets/`); die vier `samples` sind die Ecken oben links, oben rechts, unten rechts, unten links:
|
**Bilder** haben `image` (Dateiname in `assets/`); die vier `samples` sind die Ecken oben links, oben rechts, unten rechts, unten links:
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,13 @@
|
|||||||
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
|
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
|
||||||
|
|
||||||
### Highlights:
|
### Highlights:
|
||||||
|
- **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)
|
- **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
|
- **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
|
- **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
@@ -71,7 +71,8 @@ pub const LINED_SPACING: f32 = 75.0;
|
|||||||
/// Papierfarbe der Anzeige. Betrifft nur die Darstellung am Bildschirm; gespeicherte
|
/// Papierfarbe der Anzeige. Betrifft nur die Darstellung am Bildschirm; gespeicherte
|
||||||
/// Strichfarben und der PDF-Export bleiben unverändert (weißes Papier).
|
/// 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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum PaperTheme {
|
pub enum PaperTheme {
|
||||||
@@ -138,7 +139,24 @@ impl PaperTheme {
|
|||||||
PaperTheme::DarkGray => (0.004, 0.004, 0.005),
|
PaperTheme::DarkGray => (0.004, 0.004, 0.005),
|
||||||
PaperTheme::Black => (0.03, 0.03, 0.035),
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+30
-14
@@ -316,13 +316,20 @@ impl CanvasState {
|
|||||||
)
|
)
|
||||||
.await?;
|
.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
|
let surface_format = surface_caps
|
||||||
.formats
|
.formats
|
||||||
.iter()
|
.iter()
|
||||||
.copied()
|
.copied()
|
||||||
.find(|f| f.is_srgb())
|
.find(|f| !f.is_srgb())
|
||||||
.unwrap_or(surface_caps.formats[0]);
|
.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 {
|
let config = wgpu::SurfaceConfiguration {
|
||||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||||
@@ -331,14 +338,14 @@ impl CanvasState {
|
|||||||
height: size.height.max(1),
|
height: size.height.max(1),
|
||||||
present_mode,
|
present_mode,
|
||||||
alpha_mode: surface_caps.alpha_modes[0],
|
alpha_mode: surface_caps.alpha_modes[0],
|
||||||
view_formats: vec![],
|
view_formats,
|
||||||
desired_maximum_frame_latency: 1, // Minimiert Eingabelatenz auf 1 Frame
|
desired_maximum_frame_latency: 1, // Minimiert Eingabelatenz auf 1 Frame
|
||||||
};
|
};
|
||||||
surface.configure(&device, &config);
|
surface.configure(&device, &config);
|
||||||
|
|
||||||
// Kantenglättung der Zeichenfläche (4× Multisampling), falls das Format es erlaubt
|
// Kantenglättung der Zeichenfläche (4× Multisampling), falls das Format es erlaubt
|
||||||
let msaa_samples = if adapter
|
let msaa_samples = if adapter
|
||||||
.get_texture_format_features(config.format)
|
.get_texture_format_features(render_format(&config))
|
||||||
.flags
|
.flags
|
||||||
.sample_count_supported(MSAA_SAMPLES)
|
.sample_count_supported(MSAA_SAMPLES)
|
||||||
{
|
{
|
||||||
@@ -414,7 +421,7 @@ impl CanvasState {
|
|||||||
module: &shader,
|
module: &shader,
|
||||||
entry_point: "fs_ink",
|
entry_point: "fs_ink",
|
||||||
targets: &[Some(wgpu::ColorTargetState {
|
targets: &[Some(wgpu::ColorTargetState {
|
||||||
format: config.format,
|
format: render_format(&config),
|
||||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||||
write_mask: wgpu::ColorWrites::ALL,
|
write_mask: wgpu::ColorWrites::ALL,
|
||||||
})],
|
})],
|
||||||
@@ -450,7 +457,7 @@ impl CanvasState {
|
|||||||
module: &shader,
|
module: &shader,
|
||||||
entry_point: "fs_none",
|
entry_point: "fs_none",
|
||||||
targets: &[Some(wgpu::ColorTargetState {
|
targets: &[Some(wgpu::ColorTargetState {
|
||||||
format: config.format,
|
format: render_format(&config),
|
||||||
blend: None,
|
blend: None,
|
||||||
write_mask: wgpu::ColorWrites::empty(),
|
write_mask: wgpu::ColorWrites::empty(),
|
||||||
})],
|
})],
|
||||||
@@ -477,7 +484,7 @@ impl CanvasState {
|
|||||||
module: &shader,
|
module: &shader,
|
||||||
entry_point: "fs_main",
|
entry_point: "fs_main",
|
||||||
targets: &[Some(wgpu::ColorTargetState {
|
targets: &[Some(wgpu::ColorTargetState {
|
||||||
format: config.format,
|
format: render_format(&config),
|
||||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||||
write_mask: wgpu::ColorWrites::ALL,
|
write_mask: wgpu::ColorWrites::ALL,
|
||||||
})],
|
})],
|
||||||
@@ -537,7 +544,7 @@ impl CanvasState {
|
|||||||
module: &shader,
|
module: &shader,
|
||||||
entry_point: "fs_pattern",
|
entry_point: "fs_pattern",
|
||||||
targets: &[Some(wgpu::ColorTargetState {
|
targets: &[Some(wgpu::ColorTargetState {
|
||||||
format: config.format,
|
format: render_format(&config),
|
||||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||||
write_mask: wgpu::ColorWrites::ALL,
|
write_mask: wgpu::ColorWrites::ALL,
|
||||||
})],
|
})],
|
||||||
@@ -570,12 +577,12 @@ impl CanvasState {
|
|||||||
&device,
|
&device,
|
||||||
&shader,
|
&shader,
|
||||||
&bind_group_layout,
|
&bind_group_layout,
|
||||||
config.format,
|
render_format(&config),
|
||||||
msaa_samples,
|
msaa_samples,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 8. egui Renderer für das schwebende UI initialisieren
|
// 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 {
|
Ok(Self {
|
||||||
surface,
|
surface,
|
||||||
@@ -1340,9 +1347,10 @@ impl CanvasState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let output = self.surface.get_current_texture()?;
|
let output = self.surface.get_current_texture()?;
|
||||||
let view = output
|
let view = output.texture.create_view(&wgpu::TextureViewDescriptor {
|
||||||
.texture
|
format: Some(render_format(&self.config)),
|
||||||
.create_view(&wgpu::TextureViewDescriptor::default());
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
let mut encoder = self
|
let mut encoder = self
|
||||||
.device
|
.device
|
||||||
@@ -1509,6 +1517,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.
|
/// Multisampling der Zeichenfläche gegen gezackte Strichkanten.
|
||||||
const MSAA_SAMPLES: u32 = 4;
|
const MSAA_SAMPLES: u32 = 4;
|
||||||
|
|
||||||
@@ -1530,7 +1546,7 @@ fn create_msaa_view(
|
|||||||
mip_level_count: 1,
|
mip_level_count: 1,
|
||||||
sample_count: samples,
|
sample_count: samples,
|
||||||
dimension: wgpu::TextureDimension::D2,
|
dimension: wgpu::TextureDimension::D2,
|
||||||
format: config.format,
|
format: render_format(config),
|
||||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||||
view_formats: &[],
|
view_formats: &[],
|
||||||
})
|
})
|
||||||
|
|||||||
+58
-2
@@ -445,8 +445,10 @@ fn render_background_layer(
|
|||||||
) {
|
) {
|
||||||
let background = page.background;
|
let background = page.background;
|
||||||
let (Mm(page_width_mm), Mm(page_height_mm)) = pdf_page_mm(page);
|
let (Mm(page_width_mm), Mm(page_height_mm)) = pdf_page_mm(page);
|
||||||
// Exportiert wird immer auf weißem Papier
|
// Exportiert wird immer auf weißem Papier; die Musterfarbe ist linear, PDF erwartet sRGB
|
||||||
let [r, g, b, _] = pattern.color(crate::background::PaperTheme::White);
|
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));
|
let grid_color = Color::Rgb(Rgb::new(r, g, b, None));
|
||||||
layer.set_outline_color(grid_color.clone());
|
layer.set_outline_color(grid_color.clone());
|
||||||
layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt)
|
layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt)
|
||||||
@@ -1031,6 +1033,59 @@ fn render_recognized_shape(
|
|||||||
x_axis_end,
|
x_axis_end,
|
||||||
y_axis_end,
|
y_axis_end,
|
||||||
with_arrows,
|
with_arrows,
|
||||||
|
negative_ends: Some([x_axis_start, y_axis_start]),
|
||||||
|
} => {
|
||||||
|
// Achsenkreuz: zwei durchgehende Achsen, Pfeilspitzen an allen vier Enden
|
||||||
|
const PDF_SHAPE_HALF_WIDTH: f32 = 2.65;
|
||||||
|
let to_pdf = |p: &glam::Vec2| {
|
||||||
|
let (px, py) = page_to_pdf_mm(p.x, p.y);
|
||||||
|
(Point::new(Mm(px), Mm(py)), false)
|
||||||
|
};
|
||||||
|
let mut heads = Vec::new();
|
||||||
|
for (neg, pos) in [(*x_axis_start, *x_axis_end), (*y_axis_start, *y_axis_end)] {
|
||||||
|
let dir = (pos - neg).normalize_or_zero();
|
||||||
|
let pos_head = crate::geometry::arrow_head(
|
||||||
|
pos,
|
||||||
|
dir,
|
||||||
|
PDF_SHAPE_HALF_WIDTH,
|
||||||
|
(pos - *origin).length(),
|
||||||
|
);
|
||||||
|
let neg_head = crate::geometry::arrow_head(
|
||||||
|
neg,
|
||||||
|
-dir,
|
||||||
|
PDF_SHAPE_HALF_WIDTH,
|
||||||
|
(neg - *origin).length(),
|
||||||
|
);
|
||||||
|
let (from, to) = if *with_arrows {
|
||||||
|
(neg_head.0, pos_head.0)
|
||||||
|
} else {
|
||||||
|
(neg, pos)
|
||||||
|
};
|
||||||
|
layer.add_line(Line {
|
||||||
|
points: [from, to].iter().map(to_pdf).collect(),
|
||||||
|
is_closed: false,
|
||||||
|
});
|
||||||
|
heads.push((pos, pos_head));
|
||||||
|
heads.push((neg, neg_head));
|
||||||
|
}
|
||||||
|
if *with_arrows {
|
||||||
|
layer.set_fill_color(stroke_color);
|
||||||
|
for (tip, (_, left, right)) in heads {
|
||||||
|
layer.add_polygon(Polygon {
|
||||||
|
rings: vec![[tip, left, right].iter().map(to_pdf).collect()],
|
||||||
|
mode: PaintMode::Fill,
|
||||||
|
winding_order: WindingOrder::NonZero,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RecognizedShape::CoordinateSystem {
|
||||||
|
origin,
|
||||||
|
x_axis_end,
|
||||||
|
y_axis_end,
|
||||||
|
with_arrows,
|
||||||
|
negative_ends: None,
|
||||||
} => {
|
} => {
|
||||||
// Gleiche Pfeilspitzen wie auf dem Bildschirm; Achsen enden an der Basis der Spitze
|
// Gleiche Pfeilspitzen wie auf dem Bildschirm; Achsen enden an der Basis der Spitze
|
||||||
// (Linienstärke 1,5 pt ≈ 5,3 Seiteneinheiten, halbe Breite ≈ 2,65)
|
// (Linienstärke 1,5 pt ≈ 5,3 Seiteneinheiten, halbe Breite ≈ 2,65)
|
||||||
@@ -1448,6 +1503,7 @@ mod tests {
|
|||||||
x_axis_end: glam::Vec2::new(800.0, 1500.0),
|
x_axis_end: glam::Vec2::new(800.0, 1500.0),
|
||||||
y_axis_end: glam::Vec2::new(300.0, 1000.0),
|
y_axis_end: glam::Vec2::new(300.0, 1000.0),
|
||||||
with_arrows: true,
|
with_arrows: true,
|
||||||
|
negative_ends: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
page.add_stroke(coords_stroke);
|
page.add_stroke(coords_stroke);
|
||||||
|
|||||||
+342
-1
@@ -59,6 +59,11 @@ pub enum RecognizedShape {
|
|||||||
x_axis_end: Vec2,
|
x_axis_end: Vec2,
|
||||||
y_axis_end: Vec2,
|
y_axis_end: Vec2,
|
||||||
with_arrows: bool,
|
with_arrows: bool,
|
||||||
|
/// Achsenkreuz: Enden der x- und der y-Achse auf der anderen Seite des Ursprungs
|
||||||
|
/// (Pfeilspitzen an allen vier Enden). Fehlt es, ist es das L-förmige
|
||||||
|
/// Koordinatensystem; ältere Versionen ignorieren das Feld und zeigen das L.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
negative_ends: Option<[Vec2; 2]>,
|
||||||
},
|
},
|
||||||
Triangle {
|
Triangle {
|
||||||
/// 3 Eckpunkte in umlaufender Reihenfolge.
|
/// 3 Eckpunkte in umlaufender Reihenfolge.
|
||||||
@@ -105,6 +110,10 @@ impl RecognizedShape {
|
|||||||
Self::Arrow { double: false, .. } => "Pfeil",
|
Self::Arrow { double: false, .. } => "Pfeil",
|
||||||
Self::Arrow { double: true, .. } => "Doppelpfeil",
|
Self::Arrow { double: true, .. } => "Doppelpfeil",
|
||||||
Self::Rectangle { .. } => "Rechteck",
|
Self::Rectangle { .. } => "Rechteck",
|
||||||
|
Self::CoordinateSystem {
|
||||||
|
negative_ends: Some(_),
|
||||||
|
..
|
||||||
|
} => "Achsenkreuz",
|
||||||
Self::CoordinateSystem { .. } => "Koordinatensystem",
|
Self::CoordinateSystem { .. } => "Koordinatensystem",
|
||||||
Self::Triangle { .. } => "Dreieck",
|
Self::Triangle { .. } => "Dreieck",
|
||||||
Self::Rhombus { .. } => "Raute (Rhombus)",
|
Self::Rhombus { .. } => "Raute (Rhombus)",
|
||||||
@@ -146,11 +155,15 @@ impl RecognizedShape {
|
|||||||
origin,
|
origin,
|
||||||
x_axis_end,
|
x_axis_end,
|
||||||
y_axis_end,
|
y_axis_end,
|
||||||
|
negative_ends,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
*origin += delta;
|
*origin += delta;
|
||||||
*x_axis_end += delta;
|
*x_axis_end += delta;
|
||||||
*y_axis_end += delta;
|
*y_axis_end += delta;
|
||||||
|
for end in negative_ends.iter_mut().flatten() {
|
||||||
|
*end += delta;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Self::Polygon { vertices } => {
|
Self::Polygon { vertices } => {
|
||||||
for v in vertices {
|
for v in vertices {
|
||||||
@@ -203,11 +216,15 @@ impl RecognizedShape {
|
|||||||
origin: o,
|
origin: o,
|
||||||
x_axis_end,
|
x_axis_end,
|
||||||
y_axis_end,
|
y_axis_end,
|
||||||
|
negative_ends,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
*o = rot(*o);
|
*o = rot(*o);
|
||||||
*x_axis_end = rot(*x_axis_end);
|
*x_axis_end = rot(*x_axis_end);
|
||||||
*y_axis_end = rot(*y_axis_end);
|
*y_axis_end = rot(*y_axis_end);
|
||||||
|
for end in negative_ends.iter_mut().flatten() {
|
||||||
|
*end = rot(*end);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Self::Polygon { vertices } => {
|
Self::Polygon { vertices } => {
|
||||||
for v in vertices {
|
for v in vertices {
|
||||||
@@ -267,11 +284,15 @@ impl RecognizedShape {
|
|||||||
origin: o,
|
origin: o,
|
||||||
x_axis_end,
|
x_axis_end,
|
||||||
y_axis_end,
|
y_axis_end,
|
||||||
|
negative_ends,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
*o = sc(*o);
|
*o = sc(*o);
|
||||||
*x_axis_end = sc(*x_axis_end);
|
*x_axis_end = sc(*x_axis_end);
|
||||||
*y_axis_end = sc(*y_axis_end);
|
*y_axis_end = sc(*y_axis_end);
|
||||||
|
for end in negative_ends.iter_mut().flatten() {
|
||||||
|
*end = sc(*end);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Self::Polygon { vertices } => {
|
Self::Polygon { vertices } => {
|
||||||
for v in vertices {
|
for v in vertices {
|
||||||
@@ -1394,6 +1415,87 @@ pub fn detect_coordinate_system(points: &[Vec2]) -> Option<RecognizedShape> {
|
|||||||
x_axis_end: x_end,
|
x_axis_end: x_end,
|
||||||
y_axis_end: y_end,
|
y_axis_end: y_end,
|
||||||
with_arrows: true,
|
with_arrows: true,
|
||||||
|
negative_ends: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Achsenkreuz in einem Strich („+“, z. B. waagerecht hin und zurück, dann senkrecht): Alle
|
||||||
|
/// Teilstücke liegen auf einer waagerechten und einer senkrechten Linie, und beide reichen
|
||||||
|
/// auf beiden Seiten deutlich über den Schnittpunkt hinaus. Ergebnis wie beim
|
||||||
|
/// Koordinatensystem achsenparallel, mit Pfeilspitzen an allen vier Enden.
|
||||||
|
pub fn detect_axis_cross(points: &[Vec2]) -> Option<RecognizedShape> {
|
||||||
|
if points.len() < 8 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let aabb = Aabb::from_points(points);
|
||||||
|
let diagonal = (aabb.max - aabb.min).length();
|
||||||
|
if diagonal < 60.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let simplified = ramer_douglas_peucker(points, (diagonal * 0.05).clamp(8.0, 40.0));
|
||||||
|
|
||||||
|
// Teilstücke nach Richtung: waagerecht oder senkrecht (höchstens 25° schief); kurze
|
||||||
|
// Stücke (Umkehrpunkte) zählen nicht, schräge lange Stücke schließen ein Kreuz aus
|
||||||
|
let max_slope = 25.0_f32.to_radians().tan();
|
||||||
|
let (mut horizontal, mut vertical) = ((0.0, 0.0), (0.0, 0.0));
|
||||||
|
for w in simplified.windows(2) {
|
||||||
|
let d = w[1] - w[0];
|
||||||
|
let len = d.length();
|
||||||
|
if len < diagonal * 0.08 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mid = (w[0] + w[1]) * 0.5;
|
||||||
|
if d.y.abs() <= d.x.abs() * max_slope {
|
||||||
|
horizontal = (horizontal.0 + mid.y * len, horizontal.1 + len);
|
||||||
|
} else if d.x.abs() <= d.y.abs() * max_slope {
|
||||||
|
vertical = (vertical.0 + mid.x * len, vertical.1 + len);
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if horizontal.1 == 0.0 || vertical.1 == 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let origin = Vec2::new(vertical.0 / vertical.1, horizontal.0 / horizontal.1);
|
||||||
|
|
||||||
|
// Jeder Punkt liegt auf einer der beiden Linien
|
||||||
|
let tolerance = (diagonal * 0.08).max(10.0);
|
||||||
|
let (mut x_min, mut x_max) = (f32::INFINITY, f32::NEG_INFINITY);
|
||||||
|
let (mut y_min, mut y_max) = (f32::INFINITY, f32::NEG_INFINITY);
|
||||||
|
for p in points {
|
||||||
|
let on_horizontal = (p.y - origin.y).abs() <= tolerance;
|
||||||
|
let on_vertical = (p.x - origin.x).abs() <= tolerance;
|
||||||
|
if !on_horizontal && !on_vertical {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if on_horizontal {
|
||||||
|
x_min = x_min.min(p.x);
|
||||||
|
x_max = x_max.max(p.x);
|
||||||
|
}
|
||||||
|
if on_vertical {
|
||||||
|
y_min = y_min.min(p.y);
|
||||||
|
y_max = y_max.max(p.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vier Arme: jeder mindestens ein Fünftel seiner Achse und deutlich länger als die Toleranz
|
||||||
|
let arm_ok = |arm: f32, axis: f32| arm >= axis * 0.2 && arm >= tolerance * 1.5;
|
||||||
|
let (width, height) = (x_max - x_min, y_max - y_min);
|
||||||
|
if !(arm_ok(origin.x - x_min, width)
|
||||||
|
&& arm_ok(x_max - origin.x, width)
|
||||||
|
&& arm_ok(origin.y - y_min, height)
|
||||||
|
&& arm_ok(y_max - origin.y, height))
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// x nach rechts, y nach oben (auf dem Blatt: kleineres y)
|
||||||
|
Some(RecognizedShape::CoordinateSystem {
|
||||||
|
origin,
|
||||||
|
x_axis_end: Vec2::new(x_max, origin.y),
|
||||||
|
y_axis_end: Vec2::new(origin.x, y_min),
|
||||||
|
with_arrows: true,
|
||||||
|
negative_ends: Some([Vec2::new(x_min, origin.y), Vec2::new(origin.x, y_max)]),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1423,7 +1525,10 @@ pub fn detect_shape(points: &[StylusSample]) -> Option<RecognizedShape> {
|
|||||||
// Kandidaten in Prioritätsreihenfolge; 3. offene Formen (Koordinatensystem, Gerade) zuletzt.
|
// Kandidaten in Prioritätsreihenfolge; 3. offene Formen (Koordinatensystem, Gerade) zuletzt.
|
||||||
// Übernommen wird der erste, der den Strich wirklich beschreibt. Passt keiner, bleibt der
|
// Übernommen wird der erste, der den Strich wirklich beschreibt. Passt keiner, bleibt der
|
||||||
// Strich unverändert (vorher wurde fast jede geschlossene Kritzelei zu einer Form).
|
// Strich unverändert (vorher wurde fast jede geschlossene Kritzelei zu einer Form).
|
||||||
let candidates: [&dyn Fn() -> Option<RecognizedShape>; 6] = [
|
let candidates: [&dyn Fn() -> Option<RecognizedShape>; 7] = [
|
||||||
|
// Das Achsenkreuz zuerst: Seine Prüfung ist streng, und ein hin und her gezeichnetes
|
||||||
|
// „+“ könnte sonst als Vieleck durchgehen
|
||||||
|
&|| detect_axis_cross(&pts),
|
||||||
&|| {
|
&|| {
|
||||||
if closed_fits_well {
|
if closed_fits_well {
|
||||||
None
|
None
|
||||||
@@ -1908,6 +2013,51 @@ pub fn shape_to_mesh(
|
|||||||
x_axis_end,
|
x_axis_end,
|
||||||
y_axis_end,
|
y_axis_end,
|
||||||
with_arrows,
|
with_arrows,
|
||||||
|
negative_ends: Some([x_axis_start, y_axis_start]),
|
||||||
|
} => {
|
||||||
|
// Achsenkreuz: zwei durchgehende Achsen, Pfeilspitzen an allen vier Enden
|
||||||
|
let mut vertices = Vec::new();
|
||||||
|
for (neg, pos) in [(*x_axis_start, *x_axis_end), (*y_axis_start, *y_axis_end)] {
|
||||||
|
let dir = (pos - neg).normalize_or_zero();
|
||||||
|
let pos_head = arrow_head(pos, dir, half_w, (pos - *origin).length());
|
||||||
|
let neg_head = arrow_head(neg, -dir, half_w, (neg - *origin).length());
|
||||||
|
let (from, to) = if *with_arrows {
|
||||||
|
(neg_head.0, pos_head.0)
|
||||||
|
} else {
|
||||||
|
(neg, pos)
|
||||||
|
};
|
||||||
|
push_band(&mut vertices, from, to, half_w, color);
|
||||||
|
if *with_arrows {
|
||||||
|
for (tip, (_, left, right)) in [(pos, pos_head), (neg, neg_head)] {
|
||||||
|
push_triangle(&mut vertices, tip, left, right, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Abtastpunkte als Linienzug ohne Diagonalen (jeder Arm vom Ursprung aus)
|
||||||
|
let samples = [
|
||||||
|
*x_axis_start,
|
||||||
|
*origin,
|
||||||
|
*x_axis_end,
|
||||||
|
*origin,
|
||||||
|
*y_axis_end,
|
||||||
|
*origin,
|
||||||
|
*y_axis_start,
|
||||||
|
]
|
||||||
|
.map(|position| StylusSample {
|
||||||
|
position,
|
||||||
|
pressure: 0.5,
|
||||||
|
timestamp: 0.0,
|
||||||
|
})
|
||||||
|
.to_vec();
|
||||||
|
(samples, vertices)
|
||||||
|
}
|
||||||
|
|
||||||
|
RecognizedShape::CoordinateSystem {
|
||||||
|
origin,
|
||||||
|
x_axis_end,
|
||||||
|
y_axis_end,
|
||||||
|
with_arrows,
|
||||||
|
negative_ends: None,
|
||||||
} => {
|
} => {
|
||||||
let mut vertices = Vec::new();
|
let mut vertices = Vec::new();
|
||||||
let mut samples = Vec::new();
|
let mut samples = Vec::new();
|
||||||
@@ -2012,6 +2162,40 @@ pub fn shape_to_mesh(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hängt ein gerades Band der halben Breite `half_w` von `a` nach `b` an einen Triangle-Strip
|
||||||
|
/// an (über degenerierte Dreiecke, falls er schon Vertizes hat).
|
||||||
|
fn push_band(vertices: &mut Vec<InkVertex>, a: Vec2, b: Vec2, half_w: f32, color: [f32; 4]) {
|
||||||
|
let dir = (b - a).normalize_or_zero();
|
||||||
|
let norm = Vec2::new(-dir.y, dir.x) * half_w;
|
||||||
|
let first = InkVertex::new(a.x + norm.x, a.y + norm.y, color);
|
||||||
|
if let Some(&last) = vertices.last() {
|
||||||
|
vertices.push(last);
|
||||||
|
vertices.push(first);
|
||||||
|
}
|
||||||
|
vertices.push(first);
|
||||||
|
vertices.push(InkVertex::new(a.x - norm.x, a.y - norm.y, color));
|
||||||
|
vertices.push(InkVertex::new(b.x + norm.x, b.y + norm.y, color));
|
||||||
|
vertices.push(InkVertex::new(b.x - norm.x, b.y - norm.y, color));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hängt ein Dreieck (Pfeilspitze) an einen Triangle-Strip an.
|
||||||
|
fn push_triangle(
|
||||||
|
vertices: &mut Vec<InkVertex>,
|
||||||
|
tip: Vec2,
|
||||||
|
left: Vec2,
|
||||||
|
right: Vec2,
|
||||||
|
color: [f32; 4],
|
||||||
|
) {
|
||||||
|
let tip_v = InkVertex::new(tip.x, tip.y, color);
|
||||||
|
if let Some(&last) = vertices.last() {
|
||||||
|
vertices.push(last);
|
||||||
|
vertices.push(tip_v);
|
||||||
|
}
|
||||||
|
vertices.push(tip_v);
|
||||||
|
vertices.push(InkVertex::new(left.x, left.y, color));
|
||||||
|
vertices.push(InkVertex::new(right.x, right.y, color));
|
||||||
|
}
|
||||||
|
|
||||||
/// Erzeugt einen neuen `Stroke` aus einer erkannten Form.
|
/// Erzeugt einen neuen `Stroke` aus einer erkannten Form.
|
||||||
pub fn shape_to_stroke(
|
pub fn shape_to_stroke(
|
||||||
shape: RecognizedShape,
|
shape: RecognizedShape,
|
||||||
@@ -2210,6 +2394,7 @@ mod tests {
|
|||||||
x_axis_end: Vec2::new(900.0, 900.0),
|
x_axis_end: Vec2::new(900.0, 900.0),
|
||||||
y_axis_end: Vec2::new(300.0, 300.0),
|
y_axis_end: Vec2::new(300.0, 300.0),
|
||||||
with_arrows: true,
|
with_arrows: true,
|
||||||
|
negative_ends: None,
|
||||||
};
|
};
|
||||||
let width = 30.0;
|
let width = 30.0;
|
||||||
let (_, vertices) = shape_to_mesh(&shape, width, [0.0, 0.0, 0.0, 1.0]);
|
let (_, vertices) = shape_to_mesh(&shape, width, [0.0, 0.0, 0.0, 1.0]);
|
||||||
@@ -2479,12 +2664,167 @@ mod tests {
|
|||||||
Some(RecognizedShape::Triangle { .. }) => "Dreieck",
|
Some(RecognizedShape::Triangle { .. }) => "Dreieck",
|
||||||
Some(RecognizedShape::Rhombus { .. }) => "Raute",
|
Some(RecognizedShape::Rhombus { .. }) => "Raute",
|
||||||
Some(RecognizedShape::Polygon { .. }) => "Polygon",
|
Some(RecognizedShape::Polygon { .. }) => "Polygon",
|
||||||
|
Some(RecognizedShape::CoordinateSystem {
|
||||||
|
negative_ends: Some(_),
|
||||||
|
..
|
||||||
|
}) => "Achsenkreuz",
|
||||||
Some(RecognizedShape::CoordinateSystem { .. }) => "Koordinatensystem",
|
Some(RecognizedShape::CoordinateSystem { .. }) => "Koordinatensystem",
|
||||||
Some(RecognizedShape::Octahedron { .. }) => "Oktaeder",
|
Some(RecognizedShape::Octahedron { .. }) => "Oktaeder",
|
||||||
None => "nichts",
|
None => "nichts",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_axis_cross_in_one_stroke() {
|
||||||
|
let c = Vec2::new(800.0, 900.0);
|
||||||
|
let v = |x: f32, y: f32| c + Vec2::new(x, y);
|
||||||
|
// Verschiedene Zeichenreihenfolgen eines „+“ in einem Zug
|
||||||
|
let crosses: [(&str, Vec<Vec2>); 4] = [
|
||||||
|
(
|
||||||
|
"waagerecht hin, zurück zur Mitte, senkrecht",
|
||||||
|
vec![
|
||||||
|
v(-200.0, 0.0),
|
||||||
|
v(200.0, 0.0),
|
||||||
|
v(0.0, 0.0),
|
||||||
|
v(0.0, -150.0),
|
||||||
|
v(0.0, 150.0),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"senkrecht zuerst",
|
||||||
|
vec![
|
||||||
|
v(0.0, -180.0),
|
||||||
|
v(0.0, 180.0),
|
||||||
|
v(0.0, 0.0),
|
||||||
|
v(-220.0, 0.0),
|
||||||
|
v(220.0, 0.0),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Arme ungleich lang",
|
||||||
|
vec![
|
||||||
|
v(-100.0, 0.0),
|
||||||
|
v(320.0, 0.0),
|
||||||
|
v(0.0, 0.0),
|
||||||
|
v(0.0, 250.0),
|
||||||
|
v(0.0, -120.0),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Mitte zuerst, alle Arme hin und zurück",
|
||||||
|
vec![
|
||||||
|
v(0.0, 0.0),
|
||||||
|
v(200.0, 0.0),
|
||||||
|
v(0.0, 0.0),
|
||||||
|
v(0.0, -200.0),
|
||||||
|
v(0.0, 0.0),
|
||||||
|
v(-200.0, 0.0),
|
||||||
|
v(0.0, 0.0),
|
||||||
|
v(0.0, 200.0),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (seed, (name, outline)) in crosses.iter().enumerate() {
|
||||||
|
let samples = freehand(outline, false, seed as f32);
|
||||||
|
let shape = detect_shape(&samples);
|
||||||
|
assert_eq!(kind(&shape), "Achsenkreuz", "{name}");
|
||||||
|
let Some(RecognizedShape::CoordinateSystem {
|
||||||
|
origin,
|
||||||
|
x_axis_end,
|
||||||
|
y_axis_end,
|
||||||
|
negative_ends: Some([x_start, y_start]),
|
||||||
|
with_arrows: true,
|
||||||
|
}) = shape
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
assert!((origin - c).length() < 8.0, "{name}: Ursprung {origin}");
|
||||||
|
// Achsenparallel, x nach rechts, y nach oben
|
||||||
|
assert_eq!(x_axis_end.y, origin.y);
|
||||||
|
assert_eq!(x_start.y, origin.y);
|
||||||
|
assert_eq!(y_axis_end.x, origin.x);
|
||||||
|
assert!(x_axis_end.x > origin.x && x_start.x < origin.x, "{name}");
|
||||||
|
assert!(y_axis_end.y < origin.y && y_start.y > origin.y, "{name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keine Kreuze: L-Form, T-Form, einzelne Linie, Rechteck
|
||||||
|
let not_crosses: [(&str, Vec<Vec2>, bool); 4] = [
|
||||||
|
("L", vec![v(0.0, -200.0), v(0.0, 0.0), v(250.0, 0.0)], false),
|
||||||
|
(
|
||||||
|
"T",
|
||||||
|
vec![v(-200.0, 0.0), v(200.0, 0.0), v(0.0, 0.0), v(0.0, 200.0)],
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
("Linie", vec![v(-200.0, 0.0), v(200.0, 0.0)], false),
|
||||||
|
(
|
||||||
|
"Rechteck",
|
||||||
|
vec![
|
||||||
|
v(-200.0, -100.0),
|
||||||
|
v(200.0, -100.0),
|
||||||
|
v(200.0, 100.0),
|
||||||
|
v(-200.0, 100.0),
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (seed, (name, outline, closed)) in not_crosses.iter().enumerate() {
|
||||||
|
let samples = freehand(outline, *closed, seed as f32 + 10.0);
|
||||||
|
let points: Vec<Vec2> = samples.iter().map(|s| s.position).collect();
|
||||||
|
assert!(
|
||||||
|
detect_axis_cross(&points).is_none(),
|
||||||
|
"{name} ist kein Kreuz"
|
||||||
|
);
|
||||||
|
assert_ne!(kind(&detect_shape(&samples)), "Achsenkreuz", "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_axis_cross_mesh_and_transforms() {
|
||||||
|
let mut shape = RecognizedShape::CoordinateSystem {
|
||||||
|
origin: Vec2::new(500.0, 500.0),
|
||||||
|
x_axis_end: Vec2::new(800.0, 500.0),
|
||||||
|
y_axis_end: Vec2::new(500.0, 300.0),
|
||||||
|
with_arrows: true,
|
||||||
|
negative_ends: Some([Vec2::new(300.0, 500.0), Vec2::new(500.0, 700.0)]),
|
||||||
|
};
|
||||||
|
assert_eq!(shape.name(), "Achsenkreuz");
|
||||||
|
let (samples, vertices) = shape_to_mesh(&shape, 10.0, [0.0, 0.0, 0.0, 1.0]);
|
||||||
|
// Alle vier Pfeilspitzen sind Eckpunkte des Netzes
|
||||||
|
for tip in [
|
||||||
|
(800.0, 500.0),
|
||||||
|
(500.0, 300.0),
|
||||||
|
(300.0, 500.0),
|
||||||
|
(500.0, 700.0),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
vertices
|
||||||
|
.iter()
|
||||||
|
.any(|v| (Vec2::from(v.position) - Vec2::from(tip)).length() < 1e-3),
|
||||||
|
"Spitze {tip:?} fehlt"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Die Form passt zu sich selbst gezeichnet (Abtastpunkte ohne Diagonalen)
|
||||||
|
let points: Vec<Vec2> = samples.iter().map(|s| s.position).collect();
|
||||||
|
assert!(shape_fit_error(&points, &shape).unwrap() < 1e-3);
|
||||||
|
// Verschieben bewegt auch die negativen Enden
|
||||||
|
shape.translate(Vec2::new(10.0, 20.0));
|
||||||
|
let RecognizedShape::CoordinateSystem {
|
||||||
|
negative_ends: Some([x_start, y_start]),
|
||||||
|
..
|
||||||
|
} = shape
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
assert_eq!(x_start, Vec2::new(310.0, 520.0));
|
||||||
|
assert_eq!(y_start, Vec2::new(510.0, 720.0));
|
||||||
|
// Ältere Dateien ohne das Feld bleiben L-Koordinatensysteme; neue Dateien enthalten es
|
||||||
|
let json = serde_json::to_string(&shape).unwrap();
|
||||||
|
assert!(json.contains("negative_ends"));
|
||||||
|
let old = r#"{"CoordinateSystem":{"origin":[0.0,0.0],"x_axis_end":[1.0,0.0],"y_axis_end":[0.0,-1.0],"with_arrows":true}}"#;
|
||||||
|
let parsed: RecognizedShape = serde_json::from_str(old).unwrap();
|
||||||
|
assert_eq!(parsed.name(), "Koordinatensystem");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_detect_shape_freehand_classification() {
|
fn test_detect_shape_freehand_classification() {
|
||||||
let c = Vec2::new(1000.0, 1400.0);
|
let c = Vec2::new(1000.0, 1400.0);
|
||||||
@@ -2884,6 +3224,7 @@ mod tests {
|
|||||||
x_axis_end: x_end,
|
x_axis_end: x_end,
|
||||||
y_axis_end: y_end,
|
y_axis_end: y_end,
|
||||||
with_arrows: true,
|
with_arrows: true,
|
||||||
|
negative_ends: None,
|
||||||
};
|
};
|
||||||
let (_, vertices) = shape_to_mesh(&shape, 5.0, [0.0, 0.0, 0.0, 1.0]);
|
let (_, vertices) = shape_to_mesh(&shape, 5.0, [0.0, 0.0, 0.0, 1.0]);
|
||||||
let (y_base, ..) = arrow_head(y_end, Vec2::new(0.0, -1.0), 2.5, 200.0);
|
let (y_base, ..) = arrow_head(y_end, Vec2::new(0.0, -1.0), 2.5, 200.0);
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
use crate::document::Stroke;
|
use crate::document::Stroke;
|
||||||
|
|
||||||
/// Eine rückgängig machbare Benutzeraktion auf einer Seite.
|
/// Eine rückgängig machbare Benutzeraktion auf einer Seite.
|
||||||
|
// Der Verlauf hält nur wenige hundert Einträge; die Größe des Ersetzen-Eintrags (zwei Striche)
|
||||||
|
// fällt nicht ins Gewicht, eine Box würde nur die Aufrufe umständlicher machen
|
||||||
|
#[allow(clippy::large_enum_variant)]
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum HistoryAction {
|
pub enum HistoryAction {
|
||||||
/// Ein Strich wurde neu gezeichnet.
|
/// Ein Strich wurde neu gezeichnet.
|
||||||
|
|||||||
+2
-1
@@ -363,7 +363,8 @@ impl ImageLayer {
|
|||||||
mip_level_count: levels.len() as u32,
|
mip_level_count: levels.len() as u32,
|
||||||
sample_count: 1,
|
sample_count: 1,
|
||||||
dimension: wgpu::TextureDimension::D2,
|
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,
|
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||||
view_formats: &[],
|
view_formats: &[],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub mod filter;
|
|||||||
pub mod geometry;
|
pub mod geometry;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod image_layer;
|
pub mod image_layer;
|
||||||
|
pub mod library;
|
||||||
pub mod page_export;
|
pub mod page_export;
|
||||||
pub mod pdf_doc;
|
pub mod pdf_doc;
|
||||||
pub mod pdf_merge;
|
pub mod pdf_merge;
|
||||||
|
|||||||
+441
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+330
-74
@@ -24,16 +24,75 @@ use ink_notes::document::{Notebook, PageBackground};
|
|||||||
use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy};
|
use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy};
|
||||||
use ink_notes::geometry::{self, RecognizedShape};
|
use ink_notes::geometry::{self, RecognizedShape};
|
||||||
use ink_notes::image_layer::TextureSource;
|
use ink_notes::image_layer::TextureSource;
|
||||||
|
use ink_notes::library::Library;
|
||||||
use ink_notes::pdf_doc::PdfRenderService;
|
use ink_notes::pdf_doc::PdfRenderService;
|
||||||
use ink_notes::platform::{self, DisplayBackend};
|
use ink_notes::platform::{self, DisplayBackend};
|
||||||
use ink_notes::selection::{
|
use ink_notes::selection::{
|
||||||
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
|
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
|
||||||
};
|
};
|
||||||
use ink_notes::settings::{SidebarSide, UiSettings};
|
use ink_notes::settings::{
|
||||||
|
SidebarSide, StylusAction, StylusButton, UiSettings, MAX_STYLUS_BUTTONS,
|
||||||
|
};
|
||||||
use ink_notes::storage::{self, NotebookManifest};
|
use ink_notes::storage::{self, NotebookManifest};
|
||||||
use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
|
use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
|
||||||
use ink_notes::updater::UpdaterController;
|
use ink_notes::updater::UpdaterController;
|
||||||
|
|
||||||
|
/// Wählt `tool` und stellt Farbe und Stärke des Live-Strichs passend ein.
|
||||||
|
fn activate_tool(tool: ActiveTool, ui_state: &mut UiState, canvas: &mut CanvasState) {
|
||||||
|
ui_state.active_tool = tool;
|
||||||
|
match tool {
|
||||||
|
ActiveTool::Pen | ActiveTool::Shape => {
|
||||||
|
canvas.set_stroke_color(ui_state.pen_color);
|
||||||
|
canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm);
|
||||||
|
}
|
||||||
|
ActiveTool::Highlighter => {
|
||||||
|
canvas.set_stroke_color(ui_state.highlighter_color);
|
||||||
|
canvas.set_stroke_width_mm(ui_state.highlighter_stroke_width_mm);
|
||||||
|
}
|
||||||
|
ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kennung einer Stifttaste, die über Programmstarts hinweg gleich bleibt (die Tablet-
|
||||||
|
/// Schnittstelle liefert nur eine undurchsichtige, aber vergleichbare Kennung).
|
||||||
|
fn stylus_button_key(id: &octotablet::tool::ButtonID) -> u64 {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
// DefaultHasher::new() verwendet feste Schlüssel: gleiche Taste, gleicher Wert
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
id.hash(&mut hasher);
|
||||||
|
hasher.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liest Notizbücher und Ordner für den Dokumenten-Manager neu ein.
|
||||||
|
fn refresh_notebooks(base_dir: &Path, ui_state: &mut UiState) {
|
||||||
|
ui_state.available_notebooks = storage::list_notebooks(base_dir);
|
||||||
|
ui_state.library = Library::load(base_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ändert die Ordner (`library.json`) und liest sie neu ein; Fehler erscheinen als Meldung.
|
||||||
|
/// Gibt bei Erfolg das Ergebnis von `change` zurück.
|
||||||
|
fn change_library<T>(
|
||||||
|
base_dir: &Path,
|
||||||
|
ui_state: &mut UiState,
|
||||||
|
change: impl FnOnce(&mut Library) -> Result<T, String>,
|
||||||
|
) -> Option<T> {
|
||||||
|
let mut library = Library::load(base_dir);
|
||||||
|
let result = change(&mut library).and_then(|value| {
|
||||||
|
library
|
||||||
|
.save(base_dir)
|
||||||
|
.map(|()| value)
|
||||||
|
.map_err(|e| format!("Ordner nicht gespeichert: {e}"))
|
||||||
|
});
|
||||||
|
ui_state.library = Library::load(base_dir);
|
||||||
|
match result {
|
||||||
|
Ok(value) => Some(value),
|
||||||
|
Err(message) => {
|
||||||
|
ui_state.set_export_message(message);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Aktualisiert den Fenstertitel als minimales On-Screen-Display (OSD) inklusive Autosave-Status.
|
/// Aktualisiert den Fenstertitel als minimales On-Screen-Display (OSD) inklusive Autosave-Status.
|
||||||
/// Speichert die UI-Einstellungen; ein Fehler wird gemeldet, bricht aber nichts ab.
|
/// Speichert die UI-Einstellungen; ein Fehler wird gemeldet, bricht aber nichts ab.
|
||||||
fn save_ui_settings(settings: &UiSettings, path: Option<&std::path::Path>, ui_state: &mut UiState) {
|
fn save_ui_settings(settings: &UiSettings, path: Option<&std::path::Path>, ui_state: &mut UiState) {
|
||||||
@@ -581,6 +640,7 @@ fn selection_release(
|
|||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn insert_text_on_page(
|
fn insert_text_on_page(
|
||||||
content: &str,
|
content: &str,
|
||||||
|
at: Option<Vec2>,
|
||||||
canvas: &mut CanvasState,
|
canvas: &mut CanvasState,
|
||||||
notebook: &mut Notebook,
|
notebook: &mut Notebook,
|
||||||
manifest: &mut NotebookManifest,
|
manifest: &mut NotebookManifest,
|
||||||
@@ -588,10 +648,13 @@ fn insert_text_on_page(
|
|||||||
selection_state: &mut SelectionState,
|
selection_state: &mut SelectionState,
|
||||||
ui_state: &mut UiState,
|
ui_state: &mut UiState,
|
||||||
) {
|
) {
|
||||||
let center = visible_page_center(canvas);
|
let position = at.unwrap_or_else(|| {
|
||||||
|
let center = visible_page_center(canvas);
|
||||||
|
Vec2::new((center.x - 600.0).max(150.0), center.y)
|
||||||
|
});
|
||||||
let mut edit = ink_notes::text::TextEditing::new_at(
|
let mut edit = ink_notes::text::TextEditing::new_at(
|
||||||
notebook.current_page().id,
|
notebook.current_page().id,
|
||||||
Vec2::new((center.x - 600.0).max(150.0), center.y),
|
position,
|
||||||
notebook.current_page().size.x,
|
notebook.current_page().size.x,
|
||||||
ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT,
|
ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT,
|
||||||
ui_state.text_font,
|
ui_state.text_font,
|
||||||
@@ -794,9 +857,11 @@ fn visible_page_center(canvas: &CanvasState) -> Vec2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Strg+V bzw. „Einfügen“: ein neu kopiertes Systembild hat Vorrang, sonst werden die intern
|
/// Strg+V bzw. „Einfügen“: ein neu kopiertes Systembild hat Vorrang, sonst werden die intern
|
||||||
/// kopierten Objekte eingefügt. Gibt die Meldung für die Statuszeile zurück.
|
/// kopierten Objekte eingefügt. `at`: Stelle auf der Seite (lange gedrückt → Einfügen), sonst
|
||||||
|
/// die Mitte des sichtbaren Bereichs.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn paste(
|
fn paste(
|
||||||
|
at: Option<Vec2>,
|
||||||
clipboard: &mut ClipboardBridge,
|
clipboard: &mut ClipboardBridge,
|
||||||
storage_dir: &Path,
|
storage_dir: &Path,
|
||||||
canvas: &mut CanvasState,
|
canvas: &mut CanvasState,
|
||||||
@@ -820,7 +885,7 @@ fn paste(
|
|||||||
image.bytes.into_owned(),
|
image.bytes.into_owned(),
|
||||||
) {
|
) {
|
||||||
Ok(imported) => {
|
Ok(imported) => {
|
||||||
let center = visible_page_center(canvas);
|
let center = at.unwrap_or_else(|| visible_page_center(canvas));
|
||||||
insert_image_on_page(
|
insert_image_on_page(
|
||||||
imported,
|
imported,
|
||||||
center,
|
center,
|
||||||
@@ -844,6 +909,7 @@ fn paste(
|
|||||||
{
|
{
|
||||||
insert_text_on_page(
|
insert_text_on_page(
|
||||||
&text,
|
&text,
|
||||||
|
at,
|
||||||
canvas,
|
canvas,
|
||||||
notebook,
|
notebook,
|
||||||
manifest,
|
manifest,
|
||||||
@@ -874,17 +940,20 @@ fn paste(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut next_id = notebook.next_stroke_id;
|
let mut next_id = notebook.next_stroke_id;
|
||||||
let new_ids = selection_state.paste_from_clipboard(notebook.current_page_mut(), &mut || {
|
let new_ids =
|
||||||
let id = next_id;
|
selection_state.paste_from_clipboard(notebook.current_page_mut(), at, &mut || {
|
||||||
next_id += 1;
|
let id = next_id;
|
||||||
id
|
next_id += 1;
|
||||||
});
|
id
|
||||||
|
});
|
||||||
notebook.next_stroke_id = next_id;
|
notebook.next_stroke_id = next_id;
|
||||||
if !new_ids.is_empty() {
|
if !new_ids.is_empty() {
|
||||||
canvas.sync_page(notebook.current_page_mut());
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
manifest.next_stroke_id = notebook.next_stroke_id;
|
manifest.next_stroke_id = notebook.next_stroke_id;
|
||||||
autosave.mark_dirty(notebook, manifest);
|
autosave.mark_dirty(notebook, manifest);
|
||||||
ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len()));
|
ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len()));
|
||||||
|
} else {
|
||||||
|
ui_state.set_export_message("Die Zwischenablage ist leer".to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1017,10 +1086,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
"[Storage] Erstelle neues Notizbuch in: {}",
|
"[Storage] Erstelle neues Notizbuch in: {}",
|
||||||
storage_dir.display()
|
storage_dir.display()
|
||||||
);
|
);
|
||||||
(
|
let notebook = Notebook::new();
|
||||||
Notebook::new(),
|
let mut manifest = NotebookManifest::new(&initial_id, &initial_title);
|
||||||
NotebookManifest::new(&initial_id, &initial_title),
|
// Ganz neu (z. B. erster Start): sofort speichern, sonst steht es bis zur ersten
|
||||||
)
|
// Änderung ohne Manifest als „beschädigt“ in der Notizbuchliste. Ein vorhandener
|
||||||
|
// Ordner ohne Manifest bleibt unangetastet.
|
||||||
|
if !storage_dir.exists() {
|
||||||
|
if let Err(err) = storage::save_notebook(&storage_dir, ¬ebook, &mut manifest) {
|
||||||
|
eprintln!("[Storage] Neues Notizbuch nicht gespeichert: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(notebook, manifest)
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2. Asynchronen Autosave-Controller mit 1.5s Debounce starten
|
// 2. Asynchronen Autosave-Controller mit 1.5s Debounce starten
|
||||||
@@ -1059,20 +1135,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
ui_state.pressure_curve = ui_settings.pressure_curve;
|
ui_state.pressure_curve = ui_settings.pressure_curve;
|
||||||
ui_state.continuous_scroll = ui_settings.continuous_scroll;
|
ui_state.continuous_scroll = ui_settings.continuous_scroll;
|
||||||
ui_state.pen_presets = ui_settings.pen_presets;
|
ui_state.pen_presets = ui_settings.pen_presets;
|
||||||
|
ui_state.stylus_buttons = ui_settings.stylus_buttons.clone();
|
||||||
ui_state.select_pen(ui_settings.active_pen);
|
ui_state.select_pen(ui_settings.active_pen);
|
||||||
|
ui_state.highlighter_colors = ui_settings.highlighter_colors;
|
||||||
|
ui_state.select_highlighter_color(ui_settings.active_highlighter);
|
||||||
|
ui_state.text_colors = ui_settings.text_colors;
|
||||||
|
ui_state.active_text_color = ui_settings.active_text_color;
|
||||||
|
ui_state.text_color = ui_settings.text_colors[ui_settings.active_text_color];
|
||||||
ui_state.current_notebook_id = manifest.id.clone();
|
ui_state.current_notebook_id = manifest.id.clone();
|
||||||
ui_state.document_title = manifest.title.clone();
|
ui_state.document_title = manifest.title.clone();
|
||||||
ui_state.available_notebooks = available_notebooks;
|
ui_state.available_notebooks = available_notebooks;
|
||||||
|
ui_state.library = Library::load(&storage_base_dir);
|
||||||
if let Some(warning) = startup_warning {
|
if let Some(warning) = startup_warning {
|
||||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||||
ui_state.set_export_message(warning);
|
ui_state.set_export_message(warning);
|
||||||
}
|
}
|
||||||
let mut selection_state = SelectionState::new();
|
let mut selection_state = SelectionState::new();
|
||||||
// Zuletzt gelöschte Seite (Notizbuch-Kennung, Index, Seite) zum Wiederherstellen in dieser
|
// Zuletzt gelöschte Seite (Notizbuch-Kennung, Index, Seite) zum Wiederherstellen in dieser
|
||||||
// Sitzung
|
// Sitzung
|
||||||
let mut last_deleted_page: Option<(String, usize, ink_notes::document::Page)> = None;
|
let mut last_deleted_page: Option<(String, usize, ink_notes::document::Page)> = None;
|
||||||
// Stifte geändert, aber noch nicht gespeichert
|
|
||||||
let mut pen_presets_dirty = false;
|
|
||||||
let mut clipboard = ClipboardBridge::new();
|
let mut clipboard = ClipboardBridge::new();
|
||||||
// PDF-Seiten werden im Hintergrund gerendert und wecken danach den Event-Loop
|
// PDF-Seiten werden im Hintergrund gerendert und wecken danach den Event-Loop
|
||||||
let mut pdf_renderer = {
|
let mut pdf_renderer = {
|
||||||
@@ -1124,6 +1205,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let mut is_pinch_gesturing = false;
|
let mut is_pinch_gesturing = false;
|
||||||
let mut is_touch_drawing = false;
|
let mut is_touch_drawing = false;
|
||||||
let mut is_touch_panning = false;
|
let mut is_touch_panning = false;
|
||||||
|
// Langes Drücken mit einem Finger öffnet das Kontextmenü
|
||||||
|
let mut long_press = ui::LongPress::default();
|
||||||
let mut touch_pan_last_pos = (0.0_f32, 0.0_f32);
|
let mut touch_pan_last_pos = (0.0_f32, 0.0_f32);
|
||||||
|
|
||||||
// Stylus / Digitizer Zustand (Octotablet / Wayland Tablet Protocol)
|
// Stylus / Digitizer Zustand (Octotablet / Wayland Tablet Protocol)
|
||||||
@@ -1132,7 +1215,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let mut is_stylus_selecting = false;
|
let mut is_stylus_selecting = false;
|
||||||
let mut is_stylus_in_proximity = false;
|
let mut is_stylus_in_proximity = false;
|
||||||
let mut stylus_tool_id: Option<octotablet::tool::ID> = None;
|
let mut stylus_tool_id: Option<octotablet::tool::ID> = None;
|
||||||
let mut stylus_barrel_pressed = false;
|
// Gedrückte Stifttaste (Kennung, Belegung) und das Werkzeug, das während eines Strichs mit
|
||||||
|
// einer Werkzeug-Taste (Auswählen, Textmarker, Stift) vertreten wird
|
||||||
|
let mut stylus_held: Option<(u64, StylusAction)> = None;
|
||||||
|
let mut tool_before_button: Option<ActiveTool> = None;
|
||||||
let mut stylus_screen_pos = (0.0_f32, 0.0_f32);
|
let mut stylus_screen_pos = (0.0_f32, 0.0_f32);
|
||||||
// Stift hat auf der UI aufgesetzt: alle Ereignisse bis zum Abheben gehen an egui
|
// Stift hat auf der UI aufgesetzt: alle Ereignisse bis zum Abheben gehen an egui
|
||||||
let mut stylus_ui_capture = false;
|
let mut stylus_ui_capture = false;
|
||||||
@@ -1253,6 +1339,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
if let Ok(tablet_events) = mgr.pump() {
|
if let Ok(tablet_events) = mgr.pump() {
|
||||||
let scale = window.scale_factor() as f32;
|
let scale = window.scale_factor() as f32;
|
||||||
let now = start_time.elapsed().as_secs_f64();
|
let now = start_time.elapsed().as_secs_f64();
|
||||||
|
// Aufsetzen erst mit der Position desselben Frames verarbeiten
|
||||||
|
let tablet_events = ink_notes::platform::down_after_pose(
|
||||||
|
tablet_events,
|
||||||
|
|e| matches!(e, octotablet::events::Event::Tool { event: octotablet::events::ToolEvent::Down, .. }),
|
||||||
|
|e| matches!(e, octotablet::events::Event::Tool { event: octotablet::events::ToolEvent::Pose(_), .. }),
|
||||||
|
);
|
||||||
|
|
||||||
for t_event in tablet_events {
|
for t_event in tablet_events {
|
||||||
if let octotablet::events::Event::Tool { tool, event } = t_event {
|
if let octotablet::events::Event::Tool { tool, event } = t_event {
|
||||||
@@ -1303,23 +1395,45 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state);
|
selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state);
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
|
if let Some(tool) = tool_before_button.take() {
|
||||||
|
activate_tool(tool, &mut ui_state, &mut canvas);
|
||||||
|
}
|
||||||
|
stylus_held = None;
|
||||||
|
ui_state.pressed_stylus_button = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
octotablet::events::ToolEvent::Button { pressed, .. } => {
|
octotablet::events::ToolEvent::Button { button_id, pressed } => {
|
||||||
stylus_barrel_pressed = pressed;
|
// Tasten werden beim ersten Drücken gelernt (Standard: Radieren)
|
||||||
if pressed && is_stylus_drawing {
|
let key = stylus_button_key(&button_id);
|
||||||
is_stylus_drawing = false;
|
let mut index = ui_state.stylus_buttons.iter().position(|b| b.id == key);
|
||||||
is_drawing = false;
|
if index.is_none() && pressed && ui_state.stylus_buttons.len() < MAX_STYLUS_BUTTONS {
|
||||||
canvas.cancel_stroke();
|
ui_state.stylus_buttons.push(StylusButton { id: key, action: StylusAction::Erase });
|
||||||
is_erasing = true;
|
index = Some(ui_state.stylus_buttons.len() - 1);
|
||||||
window.request_redraw();
|
ui_settings.stylus_buttons = ui_state.stylus_buttons.clone();
|
||||||
} else if !pressed
|
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||||
&& is_erasing
|
|
||||||
&& !tool_is_hardware_eraser
|
|
||||||
&& ui_state.active_tool != ActiveTool::Eraser
|
|
||||||
{
|
|
||||||
is_erasing = false;
|
|
||||||
}
|
}
|
||||||
|
let action = index.map_or(StylusAction::Erase, |i| ui_state.stylus_buttons[i].action);
|
||||||
|
if pressed {
|
||||||
|
stylus_held = Some((key, action));
|
||||||
|
ui_state.pressed_stylus_button = index;
|
||||||
|
if action == StylusAction::Erase && is_stylus_drawing {
|
||||||
|
is_stylus_drawing = false;
|
||||||
|
is_drawing = false;
|
||||||
|
canvas.cancel_stroke();
|
||||||
|
is_erasing = true;
|
||||||
|
}
|
||||||
|
} else if stylus_held.is_some_and(|(held, _)| held == key) {
|
||||||
|
stylus_held = None;
|
||||||
|
ui_state.pressed_stylus_button = None;
|
||||||
|
if action == StylusAction::Erase
|
||||||
|
&& is_erasing
|
||||||
|
&& !tool_is_hardware_eraser
|
||||||
|
&& ui_state.active_tool != ActiveTool::Eraser
|
||||||
|
{
|
||||||
|
is_erasing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
octotablet::events::ToolEvent::Pose(pose) => {
|
octotablet::events::ToolEvent::Pose(pose) => {
|
||||||
is_stylus_in_proximity = true;
|
is_stylus_in_proximity = true;
|
||||||
@@ -1417,8 +1531,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
focus_page_at!((sx, sy));
|
focus_page_at!((sx, sy));
|
||||||
|
// Werkzeug-Taste gedrückt: für diesen Strich dieses Werkzeug
|
||||||
|
let button_action = stylus_held.map(|(_, action)| action);
|
||||||
|
let button_tool = match button_action {
|
||||||
|
Some(StylusAction::Select) => Some(ActiveTool::Select),
|
||||||
|
Some(StylusAction::Highlighter) => Some(ActiveTool::Highlighter),
|
||||||
|
Some(StylusAction::Pen) => Some(ActiveTool::Pen),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(tool) = button_tool
|
||||||
|
.filter(|&t| !tool_is_hardware_eraser && t != ui_state.active_tool)
|
||||||
|
{
|
||||||
|
tool_before_button = Some(ui_state.active_tool);
|
||||||
|
activate_tool(tool, &mut ui_state, &mut canvas);
|
||||||
|
}
|
||||||
let should_erase = tool_is_hardware_eraser
|
let should_erase = tool_is_hardware_eraser
|
||||||
|| stylus_barrel_pressed
|
|| button_action == Some(StylusAction::Erase)
|
||||||
|| ui_state.active_tool == ActiveTool::Eraser;
|
|| ui_state.active_tool == ActiveTool::Eraser;
|
||||||
|
|
||||||
if !should_erase && ui_state.active_tool == ActiveTool::Text {
|
if !should_erase && ui_state.active_tool == ActiveTool::Text {
|
||||||
@@ -1496,6 +1624,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let restore_tool = tool_before_button.take();
|
||||||
|
|
||||||
if is_stylus_drawing {
|
if is_stylus_drawing {
|
||||||
is_stylus_drawing = false;
|
is_stylus_drawing = false;
|
||||||
@@ -1530,6 +1659,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
snapped_shape,
|
snapped_shape,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Nach einem Strich mit Werkzeug-Taste wieder das gewählte Werkzeug
|
||||||
|
if let Some(tool) = restore_tool {
|
||||||
|
activate_tool(tool, &mut ui_state, &mut canvas);
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -1558,6 +1692,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Langes Drücken mit dem Finger: Kontextmenü zum Einfügen an dieser Stelle
|
||||||
|
if let Some(pos) = long_press.fire(Instant::now()) {
|
||||||
|
if is_touch_drawing {
|
||||||
|
is_touch_drawing = false;
|
||||||
|
is_drawing = false;
|
||||||
|
canvas.cancel_stroke();
|
||||||
|
}
|
||||||
|
is_touch_panning = false;
|
||||||
|
focus_page_at!(pos);
|
||||||
|
if let Some(page_pt) = canvas.screen_to_page(pos.0, pos.1) {
|
||||||
|
let scale = window.scale_factor() as f32;
|
||||||
|
ui_state.close_overlays();
|
||||||
|
// Leicht versetzt, damit der Finger das Menü nicht verdeckt
|
||||||
|
ui_state.context_menu = Some(ui::ContextMenu {
|
||||||
|
screen: egui::pos2(pos.0 / scale + 12.0, pos.1 / scale + 12.0),
|
||||||
|
page: page_pt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
|
||||||
// Von egui angefordertes, verzögertes Neuzeichnen (Tooltips, Animationen)
|
// Von egui angefordertes, verzögertes Neuzeichnen (Tooltips, Animationen)
|
||||||
if let Some(at) = egui_repaint_at {
|
if let Some(at) = egui_repaint_at {
|
||||||
if Instant::now() >= at {
|
if Instant::now() >= at {
|
||||||
@@ -1575,7 +1730,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|| is_stylus_in_proximity
|
|| is_stylus_in_proximity
|
||||||
{
|
{
|
||||||
target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8)));
|
target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8)));
|
||||||
} else if let Some(at) = egui_repaint_at {
|
} else if let Some(at) = [egui_repaint_at, long_press.deadline()]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.min()
|
||||||
|
{
|
||||||
target.set_control_flow(ControlFlow::WaitUntil(at));
|
target.set_control_flow(ControlFlow::WaitUntil(at));
|
||||||
} else {
|
} else {
|
||||||
target.set_control_flow(ControlFlow::Wait);
|
target.set_control_flow(ControlFlow::Wait);
|
||||||
@@ -1784,7 +1943,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// Einfügen: Strg + V
|
// Einfügen: Strg + V
|
||||||
Key::Character(c) if c.eq_ignore_ascii_case("v") && ctrl => {
|
Key::Character(c) if c.eq_ignore_ascii_case("v") && ctrl => {
|
||||||
paste(
|
paste(
|
||||||
&mut clipboard, &storage_dir, &mut canvas, &mut notebook, &mut manifest,
|
None, &mut clipboard, &storage_dir, &mut canvas, &mut notebook, &mut manifest,
|
||||||
&mut autosave, &mut selection_state, &mut ui_state,
|
&mut autosave, &mut selection_state, &mut ui_state,
|
||||||
);
|
);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
@@ -2373,6 +2532,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
active_touches.insert(touch.id, (tx, ty));
|
active_touches.insert(touch.id, (tx, ty));
|
||||||
|
|
||||||
|
// Langes Drücken: nur beim Verschieben oder Zeichnen mit dem Finger, nicht
|
||||||
|
// beim Radieren, Auswählen oder Text (dort hält man auch mal still)
|
||||||
|
if active_touches.len() == 1
|
||||||
|
&& !is_pinch_gesturing
|
||||||
|
&& (!ui_state.touch_draw_enabled
|
||||||
|
|| ui_state.active_tool.is_drawing()
|
||||||
|
|| ui_state.active_tool == ActiveTool::Shape)
|
||||||
|
{
|
||||||
|
long_press.start(touch.id, (tx, ty), Instant::now());
|
||||||
|
} else {
|
||||||
|
long_press.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
if active_touches.len() >= 2 {
|
if active_touches.len() >= 2 {
|
||||||
// Multi-Touch Geste: Sobald 2+ Finger aufliegen, Inking sofort abbrechen und zoomen/pannen
|
// Multi-Touch Geste: Sobald 2+ Finger aufliegen, Inking sofort abbrechen und zoomen/pannen
|
||||||
if is_touch_drawing {
|
if is_touch_drawing {
|
||||||
@@ -2439,6 +2611,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
TouchPhase::Moved => {
|
TouchPhase::Moved => {
|
||||||
active_touches.insert(touch.id, (tx, ty));
|
active_touches.insert(touch.id, (tx, ty));
|
||||||
|
long_press.moved(touch.id, (tx, ty), window.scale_factor() as f32);
|
||||||
|
|
||||||
if active_touches.len() >= 2 {
|
if active_touches.len() >= 2 {
|
||||||
let pts: Vec<(f32, f32)> = active_touches.values().copied().take(2).collect();
|
let pts: Vec<(f32, f32)> = active_touches.values().copied().take(2).collect();
|
||||||
@@ -2507,6 +2680,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
TouchPhase::Ended | TouchPhase::Cancelled => {
|
TouchPhase::Ended | TouchPhase::Cancelled => {
|
||||||
active_touches.remove(&touch.id);
|
active_touches.remove(&touch.id);
|
||||||
|
long_press.cancel();
|
||||||
|
|
||||||
if active_touches.is_empty() {
|
if active_touches.is_empty() {
|
||||||
is_pinch_gesturing = false;
|
is_pinch_gesturing = false;
|
||||||
@@ -2637,22 +2811,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
for action in actions {
|
for action in actions {
|
||||||
match action {
|
match action {
|
||||||
UiAction::SetTool(tool) => {
|
UiAction::SetTool(tool) => {
|
||||||
ui_state.active_tool = tool;
|
activate_tool(tool, &mut ui_state, &mut canvas);
|
||||||
match tool {
|
|
||||||
ActiveTool::Pen => {
|
|
||||||
canvas.set_stroke_color(ui_state.pen_color);
|
|
||||||
canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm);
|
|
||||||
}
|
|
||||||
ActiveTool::Highlighter => {
|
|
||||||
canvas.set_stroke_color(ui_state.highlighter_color);
|
|
||||||
canvas.set_stroke_width_mm(ui_state.highlighter_stroke_width_mm);
|
|
||||||
}
|
|
||||||
ActiveTool::Shape => {
|
|
||||||
canvas.set_stroke_color(ui_state.pen_color);
|
|
||||||
canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm);
|
|
||||||
}
|
|
||||||
ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {}
|
|
||||||
}
|
|
||||||
update_window_title(&window, ¬ebook, &manifest, tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, tool, autosave.status());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -2710,9 +2869,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
UiAction::PasteSelected => {
|
UiAction::PasteSelected | UiAction::PasteAt(_) => {
|
||||||
|
let at = match action {
|
||||||
|
UiAction::PasteAt(at) => Some(at),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
paste(
|
paste(
|
||||||
&mut clipboard, &storage_dir, &mut canvas, &mut notebook, &mut manifest,
|
at, &mut clipboard, &storage_dir, &mut canvas, &mut notebook, &mut manifest,
|
||||||
&mut autosave, &mut selection_state, &mut ui_state,
|
&mut autosave, &mut selection_state, &mut ui_state,
|
||||||
);
|
);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
@@ -2910,8 +3073,81 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UiAction::CreateFolder { parent, name } => {
|
||||||
|
if let Some(path) = change_library(&storage_base_dir, &mut ui_state, |library| {
|
||||||
|
library.create_folder(&parent, &name)
|
||||||
|
}) {
|
||||||
|
ui_state.set_export_message(format!(
|
||||||
|
"Ordner „{}“ angelegt",
|
||||||
|
ink_notes::library::folder_name(&path)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
UiAction::RenameFolder { path, new_name } => {
|
||||||
|
if let Some(new_path) = change_library(&storage_base_dir, &mut ui_state, |library| {
|
||||||
|
library.rename_folder(&path, &new_name)
|
||||||
|
}) {
|
||||||
|
// Der gezeigte Ordner kann darin liegen
|
||||||
|
let shown = &ui_state.notebook_folder;
|
||||||
|
if shown == &path || shown.starts_with(&format!("{path}/")) {
|
||||||
|
ui_state.notebook_folder = format!("{new_path}{}", &shown[path.len()..]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
UiAction::DeleteFolder(path) => {
|
||||||
|
if change_library(&storage_base_dir, &mut ui_state, |library| {
|
||||||
|
library.delete_folder(&path);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
ui_state.set_export_message(format!(
|
||||||
|
"Ordner „{}“ gelöscht, sein Inhalt liegt eine Ebene höher",
|
||||||
|
ink_notes::library::folder_name(&path)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
UiAction::MoveFolder { path, new_parent } => {
|
||||||
|
if let Some(new_path) = change_library(&storage_base_dir, &mut ui_state, |library| {
|
||||||
|
library.move_folder(&path, &new_parent)
|
||||||
|
}) {
|
||||||
|
// Der gezeigte Ordner kann darin liegen
|
||||||
|
let shown = &ui_state.notebook_folder;
|
||||||
|
if shown == &path || shown.starts_with(&format!("{path}/")) {
|
||||||
|
ui_state.notebook_folder = format!("{new_path}{}", &shown[path.len()..]);
|
||||||
|
}
|
||||||
|
let target = if new_parent.is_empty() {
|
||||||
|
"die oberste Ebene".to_string()
|
||||||
|
} else {
|
||||||
|
format!("„{}“", ink_notes::library::folder_name(&new_parent))
|
||||||
|
};
|
||||||
|
ui_state.set_export_message(format!(
|
||||||
|
"Ordner „{}“ verschoben in {target}",
|
||||||
|
ink_notes::library::folder_name(&path)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
UiAction::MoveNotebook { id, folder } => {
|
||||||
|
if change_library(&storage_base_dir, &mut ui_state, |library| {
|
||||||
|
library.move_notebook(&id, &folder)
|
||||||
|
})
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
let target = if folder.is_empty() {
|
||||||
|
"die oberste Ebene".to_string()
|
||||||
|
} else {
|
||||||
|
format!("„{}“", ink_notes::library::folder_name(&folder))
|
||||||
|
};
|
||||||
|
ui_state.set_export_message(format!("Verschoben in {target}"));
|
||||||
|
}
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
UiAction::RefreshNotebooks => {
|
UiAction::RefreshNotebooks => {
|
||||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
UiAction::OpenNotebook(id) => {
|
UiAction::OpenNotebook(id) => {
|
||||||
@@ -2947,7 +3183,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
selection_state.clear();
|
selection_state.clear();
|
||||||
ui_state.current_notebook_id = manifest.id.clone();
|
ui_state.current_notebook_id = manifest.id.clone();
|
||||||
ui_state.document_title = manifest.title.clone();
|
ui_state.document_title = manifest.title.clone();
|
||||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||||
ui_state.is_document_menu_open = false;
|
ui_state.is_document_menu_open = false;
|
||||||
canvas.sync_page(notebook.current_page_mut());
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
@@ -2964,6 +3200,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
UiAction::ImportPdf(path) => {
|
UiAction::ImportPdf(path) => {
|
||||||
|
// Neben das gerade geöffnete Notizbuch
|
||||||
|
let folder = ui_state.library.folder_of(&manifest.id).to_string();
|
||||||
let title = path
|
let title = path
|
||||||
.file_stem()
|
.file_stem()
|
||||||
.map(|s| s.to_string_lossy().into_owned())
|
.map(|s| s.to_string_lossy().into_owned())
|
||||||
@@ -2973,6 +3211,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.and_then(|bytes| storage::create_notebook_from_pdf(&storage_base_dir, &title, bytes));
|
.and_then(|bytes| storage::create_notebook_from_pdf(&storage_base_dir, &title, bytes));
|
||||||
match result {
|
match result {
|
||||||
Ok((new_nb, new_manifest, new_dir)) => {
|
Ok((new_nb, new_manifest, new_dir)) => {
|
||||||
|
let id = new_manifest.id.clone();
|
||||||
|
change_library(&storage_base_dir, &mut ui_state, |library| {
|
||||||
|
library.move_notebook(&id, &folder)
|
||||||
|
});
|
||||||
let _ = autosave.flush();
|
let _ = autosave.flush();
|
||||||
_notebook_lock = storage::NotebookLock::acquire(&new_dir).ok();
|
_notebook_lock = storage::NotebookLock::acquire(&new_dir).ok();
|
||||||
autosave.set_read_only(_notebook_lock.is_none());
|
autosave.set_read_only(_notebook_lock.is_none());
|
||||||
@@ -2983,7 +3225,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
selection_state.clear();
|
selection_state.clear();
|
||||||
ui_state.current_notebook_id = manifest.id.clone();
|
ui_state.current_notebook_id = manifest.id.clone();
|
||||||
ui_state.document_title = manifest.title.clone();
|
ui_state.document_title = manifest.title.clone();
|
||||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||||
canvas.reset_view();
|
canvas.reset_view();
|
||||||
canvas.sync_page(notebook.current_page_mut());
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
@@ -3000,10 +3242,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
UiAction::CreateNotebook(title) => {
|
UiAction::CreateNotebook { title, folder } => {
|
||||||
let _ = autosave.flush();
|
let _ = autosave.flush();
|
||||||
match storage::create_new_notebook(&storage_base_dir, &title) {
|
match storage::create_new_notebook(&storage_base_dir, &title) {
|
||||||
Ok((new_nb, new_manifest, new_dir)) => {
|
Ok((new_nb, new_manifest, new_dir)) => {
|
||||||
|
let id = new_manifest.id.clone();
|
||||||
|
change_library(&storage_base_dir, &mut ui_state, |library| {
|
||||||
|
library.move_notebook(&id, &folder)
|
||||||
|
});
|
||||||
_notebook_lock = storage::NotebookLock::acquire(&new_dir).ok();
|
_notebook_lock = storage::NotebookLock::acquire(&new_dir).ok();
|
||||||
autosave.set_read_only(_notebook_lock.is_none());
|
autosave.set_read_only(_notebook_lock.is_none());
|
||||||
autosave.switch_notebook_dir(new_dir.clone());
|
autosave.switch_notebook_dir(new_dir.clone());
|
||||||
@@ -3013,7 +3259,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
selection_state.clear();
|
selection_state.clear();
|
||||||
ui_state.current_notebook_id = manifest.id.clone();
|
ui_state.current_notebook_id = manifest.id.clone();
|
||||||
ui_state.document_title = manifest.title.clone();
|
ui_state.document_title = manifest.title.clone();
|
||||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||||
ui_state.is_document_menu_open = false;
|
ui_state.is_document_menu_open = false;
|
||||||
canvas.sync_page(notebook.current_page_mut());
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
@@ -3032,7 +3278,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
match storage::rename_notebook(&target_dir, &mut manifest, &new_title) {
|
match storage::rename_notebook(&target_dir, &mut manifest, &new_title) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
ui_state.document_title = manifest.title.clone();
|
ui_state.document_title = manifest.title.clone();
|
||||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||||
manifest.sync_from_notebook(¬ebook);
|
manifest.sync_from_notebook(¬ebook);
|
||||||
autosave.mark_manifest_dirty(&manifest);
|
autosave.mark_manifest_dirty(&manifest);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
@@ -3049,7 +3295,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
if let Ok(content) = std::fs::read_to_string(&manifest_path) {
|
if let Ok(content) = std::fs::read_to_string(&manifest_path) {
|
||||||
if let Ok(mut other_manifest) = serde_json::from_str::<NotebookManifest>(&content) {
|
if let Ok(mut other_manifest) = serde_json::from_str::<NotebookManifest>(&content) {
|
||||||
let _ = storage::rename_notebook(&target_dir, &mut other_manifest, &new_title);
|
let _ = storage::rename_notebook(&target_dir, &mut other_manifest, &new_title);
|
||||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||||
ui_state.set_export_message(format!("Umbenannt in '{}'", new_title));
|
ui_state.set_export_message(format!("Umbenannt in '{}'", new_title));
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -3074,7 +3320,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
match storage::delete_notebook(&target_dir, &storage_base_dir) {
|
match storage::delete_notebook(&target_dir, &storage_base_dir) {
|
||||||
Ok(trash_path) => {
|
Ok(trash_path) => {
|
||||||
ui_state.last_deleted_notebook = Some((trash_path, deleted_title));
|
ui_state.last_deleted_notebook = Some((trash_path, deleted_title));
|
||||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||||
if is_current {
|
if is_current {
|
||||||
if let Some(first) = ui_state
|
if let Some(first) = ui_state
|
||||||
.available_notebooks
|
.available_notebooks
|
||||||
@@ -3112,7 +3358,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
selection_state.clear();
|
selection_state.clear();
|
||||||
ui_state.current_notebook_id = manifest.id.clone();
|
ui_state.current_notebook_id = manifest.id.clone();
|
||||||
ui_state.document_title = manifest.title.clone();
|
ui_state.document_title = manifest.title.clone();
|
||||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||||
canvas.sync_page(notebook.current_page_mut());
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
ui_state.set_export_message("Standard-Notizbuch erstellt".to_string());
|
ui_state.set_export_message("Standard-Notizbuch erstellt".to_string());
|
||||||
@@ -3132,7 +3378,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
if let Some((trash_path, _title)) = ui_state.last_deleted_notebook.take() {
|
if let Some((trash_path, _title)) = ui_state.last_deleted_notebook.take() {
|
||||||
match storage::restore_notebook_from_trash(&storage_base_dir, &trash_path) {
|
match storage::restore_notebook_from_trash(&storage_base_dir, &trash_path) {
|
||||||
Ok(_restored_dir) => {
|
Ok(_restored_dir) => {
|
||||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||||
ui_state.set_export_message("Notizbuch wiederhergestellt".to_string());
|
ui_state.set_export_message("Notizbuch wiederhergestellt".to_string());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -3147,7 +3393,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let target_dir = storage_base_dir.join(&id);
|
let target_dir = storage_base_dir.join(&id);
|
||||||
match storage::restore_manifest_from_backup(&target_dir) {
|
match storage::restore_manifest_from_backup(&target_dir) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||||
ui_state.set_export_message("Manifest aus Backup wiederhergestellt".to_string());
|
ui_state.set_export_message("Manifest aus Backup wiederhergestellt".to_string());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -3252,6 +3498,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
|
UiAction::SaveStylusButtons => {
|
||||||
|
ui_settings.stylus_buttons = ui_state.stylus_buttons.clone();
|
||||||
|
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||||
|
}
|
||||||
UiAction::SavePressureCurve(curve) => {
|
UiAction::SavePressureCurve(curve) => {
|
||||||
ui_settings.pressure_curve = curve;
|
ui_settings.pressure_curve = curve;
|
||||||
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||||
@@ -3263,16 +3513,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stifte: geänderte Farbe oder Stärke gehört zum gewählten Stift. Gespeichert
|
// Stifte und eigene Farben: geänderte Farbe oder Stärke gehört zum gewählten
|
||||||
// wird erst nach dem Loslassen (nicht bei jedem Schritt eines Schiebereglers).
|
// Stift bzw. zur gewählten Farbe. Gespeichert wird erst nach dem Loslassen
|
||||||
if ui_state.sync_pen_preset() || ui_settings.active_pen != ui_state.active_pen {
|
// (nicht bei jedem Schritt eines Schiebereglers oder Farbwählers).
|
||||||
pen_presets_dirty = true;
|
ui_state.sync_tool_colors();
|
||||||
ui_settings.active_pen = ui_state.active_pen;
|
if !egui_ctx.is_using_pointer() {
|
||||||
}
|
let mut updated = ui_settings.clone();
|
||||||
if pen_presets_dirty && !egui_ctx.is_using_pointer() {
|
updated.pen_presets = ui_state.pen_presets;
|
||||||
pen_presets_dirty = false;
|
updated.active_pen = ui_state.active_pen;
|
||||||
ui_settings.pen_presets = ui_state.pen_presets;
|
updated.highlighter_colors = ui_state.highlighter_colors;
|
||||||
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
updated.active_highlighter = ui_state.active_highlighter;
|
||||||
|
updated.text_colors = ui_state.text_colors;
|
||||||
|
updated.active_text_color = ui_state.active_text_color;
|
||||||
|
if updated != ui_settings {
|
||||||
|
ui_settings = updated;
|
||||||
|
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Textbearbeitung beenden: Esc, anderes Werkzeug oder andere Seite
|
// Textbearbeitung beenden: Esc, anderes Werkzeug oder andere Seite
|
||||||
|
|||||||
@@ -58,10 +58,62 @@ pub fn display_backend_from_env() -> DisplayBackend {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bringt die Ereignisse eines Tablet-Frames in eine brauchbare Reihenfolge: octotablet meldet
|
||||||
|
/// das Aufsetzen des Stifts (`Down`) vor der Position desselben Frames (`Pose`). Bis dahin
|
||||||
|
/// gälte noch die alte Position – kam der Stift schnell heran (Annähern und Aufsetzen im selben
|
||||||
|
/// Frame), läge sie ganz woanders: Ein Tippen auf einen Knopf ginge daneben, ein Strich begänne
|
||||||
|
/// mit einer Linie von der alten Stelle. Hier folgt jedes `Down` auf die nächste `Pose`, spätestens
|
||||||
|
/// vor dem nächsten anderen Ereignis.
|
||||||
|
pub fn down_after_pose<T>(
|
||||||
|
events: impl IntoIterator<Item = T>,
|
||||||
|
is_down: impl Fn(&T) -> bool,
|
||||||
|
is_pose: impl Fn(&T) -> bool,
|
||||||
|
) -> Vec<T> {
|
||||||
|
let mut ordered = Vec::new();
|
||||||
|
let mut deferred = None;
|
||||||
|
for event in events {
|
||||||
|
if is_down(&event) {
|
||||||
|
if let Some(down) = deferred.replace(event) {
|
||||||
|
ordered.push(down);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let pose = is_pose(&event);
|
||||||
|
if !pose {
|
||||||
|
ordered.extend(deferred.take());
|
||||||
|
}
|
||||||
|
ordered.push(event);
|
||||||
|
if pose {
|
||||||
|
ordered.extend(deferred.take());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ordered.extend(deferred);
|
||||||
|
ordered
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_down_follows_pose_of_its_frame() {
|
||||||
|
let order = |events: &str| {
|
||||||
|
down_after_pose(events.chars(), |c| *c == 'D', |c| *c == 'P')
|
||||||
|
.into_iter()
|
||||||
|
.collect::<String>()
|
||||||
|
};
|
||||||
|
// In, Down, Pose, Button, Frame -> Down erst mit der neuen Position
|
||||||
|
assert_eq!(order("IDPBF"), "IPDBF");
|
||||||
|
// Down ohne Pose im Frame: vor dem Frame-Ende
|
||||||
|
assert_eq!(order("DF"), "DF");
|
||||||
|
assert_eq!(order("DBF"), "DBF");
|
||||||
|
// Am Ende ohne weiteres Ereignis
|
||||||
|
assert_eq!(order("PD"), "PD");
|
||||||
|
// Andere Ereignisse bleiben unverändert
|
||||||
|
assert_eq!(order("PFPFUF"), "PFPFUF");
|
||||||
|
assert_eq!(order("IDPFPFUF"), "IPDFPFUF");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_choose_display_backend() {
|
fn test_choose_display_backend() {
|
||||||
// WSL mit X11-Display: X11 (Wayland unter WSLg bricht ab)
|
// WSL mit X11-Display: X11 (Wayland unter WSLg bricht ab)
|
||||||
|
|||||||
+20
-2
@@ -203,13 +203,19 @@ impl SelectionState {
|
|||||||
pub fn paste_from_clipboard(
|
pub fn paste_from_clipboard(
|
||||||
&mut self,
|
&mut self,
|
||||||
page: &mut Page,
|
page: &mut Page,
|
||||||
|
at: Option<Vec2>,
|
||||||
next_id_gen: &mut impl FnMut() -> u64,
|
next_id_gen: &mut impl FnMut() -> u64,
|
||||||
) -> Vec<u64> {
|
) -> Vec<u64> {
|
||||||
if self.clipboard.is_empty() {
|
if self.clipboard.is_empty() {
|
||||||
return Vec::new();
|
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_strokes = Vec::with_capacity(self.clipboard.len());
|
||||||
let mut new_ids = Vec::with_capacity(self.clipboard.len());
|
let mut new_ids = Vec::with_capacity(self.clipboard.len());
|
||||||
|
|
||||||
@@ -457,7 +463,7 @@ mod tests {
|
|||||||
|
|
||||||
// Paste
|
// Paste
|
||||||
let mut next_id = 100;
|
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 += 1;
|
||||||
next_id
|
next_id
|
||||||
});
|
});
|
||||||
@@ -465,6 +471,18 @@ mod tests {
|
|||||||
assert_eq!(page.strokes.len(), 3);
|
assert_eq!(page.strokes.len(), 3);
|
||||||
assert_eq!(sel.selected_ids, new_ids);
|
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
|
// Delete
|
||||||
let deleted = sel.delete_selected(&mut page);
|
let deleted = sel.delete_selected(&mut page);
|
||||||
assert_eq!(deleted.len(), 1);
|
assert_eq!(deleted.len(), 1);
|
||||||
|
|||||||
+134
-7
@@ -87,13 +87,7 @@ impl PenPreset {
|
|||||||
/// Begrenzt Werte aus einer (evtl. von Hand bearbeiteten) Einstellungsdatei; Stifte sind
|
/// Begrenzt Werte aus einer (evtl. von Hand bearbeiteten) Einstellungsdatei; Stifte sind
|
||||||
/// immer deckend.
|
/// immer deckend.
|
||||||
pub fn sanitized(&self, fallback: PenPreset) -> Self {
|
pub fn sanitized(&self, fallback: PenPreset) -> Self {
|
||||||
let mut color = fallback.color;
|
let color = sanitized_color(self.color, [0.0, 0.0, 0.0, 1.0]);
|
||||||
if self.color.iter().all(|c| c.is_finite()) {
|
|
||||||
for (out, c) in color.iter_mut().zip(self.color).take(3) {
|
|
||||||
*out = c.clamp(0.0, 1.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
color[3] = 1.0;
|
|
||||||
let width_mm = if self.width_mm.is_finite() {
|
let width_mm = if self.width_mm.is_finite() {
|
||||||
self.width_mm
|
self.width_mm
|
||||||
.clamp(*Self::WIDTH_RANGE.start(), *Self::WIDTH_RANGE.end())
|
.clamp(*Self::WIDTH_RANGE.start(), *Self::WIDTH_RANGE.end())
|
||||||
@@ -127,6 +121,82 @@ pub const DEFAULT_PEN_PRESETS: [PenPreset; PEN_PRESET_COUNT] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// 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.
|
/// Benutzereinstellungen der Oberfläche.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -145,6 +215,13 @@ pub struct UiSettings {
|
|||||||
/// Stifte der Werkzeugleiste und der gewählte davon
|
/// Stifte der Werkzeugleiste und der gewählte davon
|
||||||
pub pen_presets: [PenPreset; PEN_PRESET_COUNT],
|
pub pen_presets: [PenPreset; PEN_PRESET_COUNT],
|
||||||
pub active_pen: usize,
|
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 {
|
impl Default for UiSettings {
|
||||||
@@ -158,6 +235,11 @@ impl Default for UiSettings {
|
|||||||
continuous_scroll: true,
|
continuous_scroll: true,
|
||||||
pen_presets: DEFAULT_PEN_PRESETS,
|
pen_presets: DEFAULT_PEN_PRESETS,
|
||||||
active_pen: 0,
|
active_pen: 0,
|
||||||
|
highlighter_colors: DEFAULT_HIGHLIGHTER_COLORS,
|
||||||
|
active_highlighter: 0,
|
||||||
|
text_colors: DEFAULT_TEXT_COLORS,
|
||||||
|
active_text_color: 0,
|
||||||
|
stylus_buttons: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,6 +271,23 @@ impl UiSettings {
|
|||||||
*preset = preset.sanitized(fallback);
|
*preset = preset.sanitized(fallback);
|
||||||
}
|
}
|
||||||
settings.active_pen = settings.active_pen.min(PEN_PRESET_COUNT - 1);
|
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
|
settings
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +324,20 @@ mod tests {
|
|||||||
width_mm: 1.5,
|
width_mm: 1.5,
|
||||||
}; PEN_PRESET_COUNT],
|
}; PEN_PRESET_COUNT],
|
||||||
active_pen: 2,
|
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();
|
settings.save_to(&path).unwrap();
|
||||||
assert_eq!(UiSettings::load_from(&path), settings);
|
assert_eq!(UiSettings::load_from(&path), settings);
|
||||||
@@ -280,6 +393,20 @@ mod tests {
|
|||||||
assert_eq!(settings.pen_presets[0].color, [1.0, 0.0, 0.5, 1.0]);
|
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[0].width_mm, 5.0);
|
||||||
assert_eq!(settings.pen_presets[1].width_mm, 0.15);
|
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]
|
#[test]
|
||||||
|
|||||||
+26
-13
@@ -30,22 +30,33 @@ fn vs_main(in: VertexInput) -> VertexOutput {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@fragment
|
// Linearer Farbwert (Papier, Muster) -> sRGB: Gezeichnet wird ohne sRGB-Umrechnung der GPU
|
||||||
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
// (siehe `render_format` in canvas.rs), gemischt wird also wie im PDF in sRGB
|
||||||
return in.color;
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Striche: bei dunklem Papier wird die HSL-Helligkeit dunkler Farben gespiegelt
|
// Papier: Farbe linear
|
||||||
// (L -> 1 - L, Farbton und Sättigung bleiben). Entspricht `background::adapt_ink_color`.
|
@fragment
|
||||||
|
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||||
|
return vec4<f32>(linear_to_srgb(clamp(in.color.rgb, vec3<f32>(0.0), vec3<f32>(1.0))), in.color.a);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
@fragment
|
||||||
fn fs_ink(in: VertexOutput) -> @location(0) vec4<f32> {
|
fn fs_ink(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||||
if (camera.ink_params.x < 0.5) {
|
var rgb = clamp(in.color.rgb, vec3<f32>(0.0), vec3<f32>(1.0));
|
||||||
return in.color;
|
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);
|
return vec4<f32>(rgb, in.color.a);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bildschirmfüllendes Dreieck ohne Farbe (setzt den Stencil-Puffer zurück)
|
// Bildschirmfüllendes Dreieck ohne Farbe (setzt den Stencil-Puffer zurück)
|
||||||
@@ -61,7 +72,8 @@ fn fs_none() -> @location(0) vec4<f32> {
|
|||||||
return vec4<f32>(0.0);
|
return vec4<f32>(0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eingefügte Bilder: texturierte Vierecke unter der Tinte
|
// Eingefügte Bilder: texturierte Vierecke unter der Tinte (Texturen enthalten sRGB-Werte
|
||||||
|
// und werden unverändert übernommen)
|
||||||
struct ImageVertexInput {
|
struct ImageVertexInput {
|
||||||
@location(0) position: vec2<f32>,
|
@location(0) position: vec2<f32>,
|
||||||
@location(1) uv: vec2<f32>,
|
@location(1) uv: vec2<f32>,
|
||||||
@@ -169,5 +181,6 @@ fn fs_pattern(in: PatternOutput) -> @location(0) vec4<f32> {
|
|||||||
coverage = clamp(radius_px + 0.5 - d, 0.0, 1.0);
|
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
@@ -348,7 +348,7 @@ const MAX_TEXTURE_SIDE: f32 = 4096.0;
|
|||||||
const MAX_TEXTURE_PIXELS: f32 = 16.0 * 1024.0 * 1024.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
|
/// 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 {
|
pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage {
|
||||||
let layout = text.layout();
|
let layout = text.layout();
|
||||||
let size = glam::Vec2::new(text.wrap_width.max(text.size), layout.height());
|
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 alpha = color[3].clamp(0.0, 1.0);
|
||||||
let mut pixels = Vec::with_capacity(coverage.len() * 4);
|
let mut pixels = Vec::with_capacity(coverage.len() * 4);
|
||||||
for c in coverage {
|
for c in coverage {
|
||||||
@@ -476,8 +476,8 @@ mod tests {
|
|||||||
assert_eq!(image.height as f32, text.layout().height().ceil());
|
assert_eq!(image.height as f32, text.layout().height().ceil());
|
||||||
let opaque = image.pixels.chunks(4).filter(|p| p[3] > 200).count();
|
let opaque = image.pixels.chunks(4).filter(|p| p[3] > 200).count();
|
||||||
assert!(opaque > 100, "{opaque}");
|
assert!(opaque > 100, "{opaque}");
|
||||||
// Farbe ist sRGB-kodiert
|
// Die Farbe ist schon sRGB und wird unverändert übernommen
|
||||||
assert!(image.pixels.chunks(4).all(|p| p[0] > 230 && p[1] < 130));
|
assert!(image.pixels.chunks(4).all(|p| p[..3] == [217, 51, 51]));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user