Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36a1a3cd2e | ||
|
|
70317caeb5 | ||
|
|
f9a6f8524d | ||
|
|
4a4492d034 | ||
|
|
bb9dbb52d0 | ||
|
|
568714d727 | ||
|
|
c87f4102d7 | ||
|
|
3890b6f182 | ||
|
|
5115e217c2 | ||
|
|
904b36e5f7 | ||
|
|
80d319099f | ||
|
|
ee5257d744 | ||
|
|
fa1ab5ab8f |
Generated
+1
-1
@@ -1393,7 +1393,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ink-notes"
|
||||
version = "0.12.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"arboard",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ink-notes"
|
||||
version = "0.12.0"
|
||||
version = "0.17.0"
|
||||
edition = "2021"
|
||||
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
||||
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.
|
||||
|
||||
@@ -11,7 +18,8 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
|
||||
## Funktionen
|
||||
|
||||
### Schreiben und Zeichnen
|
||||
- **Stift** mit Druckstärke, vier Strichstärken (0,3–1,2 mm) und fünf Farben; **Textmarker** (halbtransparent, 4–8 mm) in vier Farben.
|
||||
- **Vier Stifte** in der Werkzeugleiste, jeder mit eigener Farbe und Stärke (0,15–5 mm, mit Druckstärke). Ändert man Farbe oder Stärke, merkt sich der gewählte Stift das – auch über einen Neustart hinweg. **Textmarker** (halbtransparent, 4–8 mm) in vier Farben; innerhalb eines Strichs wird er nicht dunkler, erst ein neuer Strich deckt stärker.
|
||||
- **Eigene Farben:** Jede Farbe in der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen – gewählte Farbe erneut antippen, eine Vorgabe oder mit 🎨 einen freien Farbton wählen. Die Leiste merkt sich die Farben. Am Bildschirm erscheinen sie genauso wie im PDF- und Bildexport.
|
||||
- **Glättung in zwei Stufen:** Während des Schreibens glättet ein 1€-Filter das Zittern, die Linie erscheint sofort. Beim Absetzen wird der Strich nachbearbeitet: Ecken (Knicke über 65°) bleiben erhalten, der Druckverlauf wird geglättet, überflüssige Punkte entfallen, die Enden werden rund.
|
||||
- **Druckkurve** im ☰-Menü an den eigenen Stift anpassen (weicher/härter, Schwelle gegen Grundrauschen).
|
||||
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Stiftrückseite und Seitentaste des Stifts radieren ebenfalls.
|
||||
@@ -31,18 +39,21 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
|
||||
### Text, Bilder und PDFs
|
||||
- **Text-Werkzeug** (`T`): ins Blatt tippen und schreiben – normale oder Code-Schrift (feste Zeichenbreite, Tab rückt ein), 8–36 pt, Zeilenumbruch am Feldrand. Vorhandenen Text antippen zum Ändern, `Esc` beendet. Textfelder lassen sich wie Bilder verschieben, drehen und skalieren. In anderen Programmen kopierter Text wird mit `Strg + V` zum Textfeld.
|
||||
- **Bilder** (PNG, JPEG, GIF, WebP, BMP) über `Strg + I`, aus der Zwischenablage oder per Ziehen ins Fenster einfügen.
|
||||
- **Einfügen per Finger:** Lange (½ s) mit dem Finger aufs Blatt drücken öffnet ein Menü „Einfügen“ – Screenshot, kopierter Text oder kopierte Objekte landen genau an dieser Stelle.
|
||||
- **PDF importieren** (☰-Menü): Jede PDF-Seite wird eine Notizbuchseite mit der PDF-Seite als Hintergrund. Hochformat-Seiten werden DIN A4, Querformat-Seiten (z. B. Folien) bekommen eine Querformat-Seite ohne leeren Streifen darunter. Beim Hineinzoomen werden PDF-Seiten schärfer nachgerendert.
|
||||
|
||||
### Seiten und Ansicht
|
||||
- **Hintergründe:** blanko, kariert (5 mm), liniert (7,5 mm), Punktraster (5 mm). Die **Farbe von Karos, Linien und Punkten** ist wählbar (hell, mittel, kräftig, blau, grün) und gilt auch im Export.
|
||||
- **Papierfarbe** der Anzeige: weiß, creme, dunkelgrau, schwarz; auf dunklem Papier werden dunkle Stiftfarben auf Wunsch aufgehellt. Gespeichert und exportiert wird immer auf weißem Papier.
|
||||
- **Durchgehendes Scrollen:** Seiten hängen untereinander; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite. Abschaltbar (dann Seite für Seite blättern).
|
||||
- **Durchgehendes Scrollen:** Seiten hängen lückenlos untereinander, eine dunkle Linie markiert die Grenze; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite. Abschaltbar (dann Seite für Seite blättern, mit Pfeilen in der Werkzeugleiste).
|
||||
- **Seiten einfügen und löschen:** ➕ in der Werkzeugleiste fügt nach der aktuellen Seite eine leere Seite ein. Löschen im ☰-Menü (mit Rückfrage); die zuletzt gelöschte Seite lässt sich dort wiederherstellen, solange die App läuft.
|
||||
- **Zoom** von 25 % bis 1000 % (`Strg + Mausrad`, zwei Finger, `Strg + +/-/0`), Verschieben mit mittlerer Maustaste, `Leertaste + Ziehen` oder einem Finger.
|
||||
- **Seiten** anhängen oder direkt nach der aktuellen Seite einfügen, **Seitentitel** vergeben; die Seitenübersicht zeigt Vorschaubilder oder ein Inhaltsverzeichnis. Seitentitel werden im PDF zu Lesezeichen.
|
||||
- **Werkzeugleiste** links oder rechts andocken (für Links- und Rechtshänder).
|
||||
|
||||
### Notizbücher und Speichern
|
||||
- Mehrere **Notizbücher** anlegen, umbenennen, wechseln und löschen (gelöschte landen zunächst im Papierkorb und lassen sich wiederherstellen).
|
||||
- **Ordner** für Notizbücher, beliebig verschachtelt (z. B. 3. Semester › Mathe): anlegen, umbenennen, löschen (der Inhalt rückt eine Ebene nach oben) und Notizbücher mit 📂 verschieben. Neue Notizbücher landen im gerade geöffneten Ordner.
|
||||
- **Automatisches Speichern** im Hintergrund: 1,5 Sekunden nach der letzten Änderung, bei ununterbrochenem Schreiben spätestens alle 10 Sekunden. Zeichnen wird dabei nicht unterbrochen.
|
||||
- Jede Datei wird erst vollständig in eine temporäre Datei geschrieben und dann ersetzt – ein Absturz beim Speichern hinterlässt keine halbe Datei.
|
||||
- **Beim Schließen** (Fenster oder `Strg + Q`) und beim Wechsel des Notizbuchs wird alles Ausstehende geschrieben, bevor es weitergeht. Schlägt das fehl, bleibt die App offen und bietet Wiederholen oder eine Notfall-Sicherung an.
|
||||
@@ -152,6 +163,7 @@ src/
|
||||
├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen
|
||||
├── history.rs # Rückgängig/Wiederholen
|
||||
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb
|
||||
├── library.rs # Ordner der Notizbücher (library.json)
|
||||
├── autosave.rs # Speichern im Hintergrund
|
||||
├── export.rs # PDF-Export (printpdf)
|
||||
├── page_export.rs # Seite als PNG/SVG, Auswahl als Bild
|
||||
|
||||
@@ -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 |
@@ -93,7 +93,7 @@ $$\vec{p}_{\text{neu}} = \vec{c} - (\vec{c} - \vec{p}_{\text{alt}}) \cdot \frac{
|
||||
- Im Shader wird jeder Punkt mit `transform_scale` und `transform_offset` (aus `CameraUniforms`) direkt in Gerätekoordinaten umgerechnet.
|
||||
|
||||
### 3.3 Durchgehende Ansicht
|
||||
Alle Koordinaten beziehen sich auf die **aktuelle Seite**. `PageStack` (`viewport.rs`) legt die übrigen Seiten mit 8 mm Abstand darunter bzw. darüber, waagerecht zur aktuellen Seite zentriert. Jede sichtbare Nachbarseite wird mit einer eigenen Kamera gezeichnet, die um die Lage der Seite verschoben ist (höchstens 12 Nachbarseiten).
|
||||
Alle Koordinaten beziehen sich auf die **aktuelle Seite**. `PageStack` (`viewport.rs`) legt die übrigen Seiten lückenlos darunter bzw. darüber (die Grenze zeichnet die Oberfläche als dunkle Linie), waagerecht zur aktuellen Seite zentriert. Jede sichtbare Nachbarseite wird mit einer eigenen Kamera gezeichnet, die um die Lage der Seite verschoben ist (höchstens 12 Nachbarseiten).
|
||||
|
||||
Nach dem Scrollen wird die Seite in der Fenstermitte zur aktuellen Seite; die Kamera wird dabei so verschoben, dass sich das Bild nicht bewegt. Während eines Strichs, einer Auswahl, beim Ziehen des Lineals oder beim Schreiben von Text wird nicht gewechselt. Wer auf einer sichtbaren Nachbarseite zu zeichnen beginnt, wechselt zuerst auf diese Seite.
|
||||
|
||||
@@ -105,7 +105,9 @@ Ein Frame besteht aus einem Render-Durchgang auf die Fensterfläche (`canvas.rs`
|
||||
|
||||
1. Für jede sichtbare Nachbarseite: Papier und Muster, Bilder/PDF-Seite/Text, Tinte.
|
||||
2. Für die aktuelle Seite: Papier, Muster (Karo, Linien, Punkte), Bildebene, gespeicherte Striche, Live-Strich.
|
||||
3. Die `egui`-Oberfläche: Werkzeugleiste, Menüs, Auswahlrahmen und Griffe, Lineal, Textbearbeitung.
|
||||
3. Die `egui`-Oberfläche: Werkzeugleiste, Menüs, Seitengrenzen, Auswahlrahmen und Griffe, Lineal, Textbearbeitung.
|
||||
|
||||
Jeder Strich ist ein eigener Draw-Call mit eigener Nummer im Stencil-Puffer: Ein Pixel, das der Strich schon gefärbt hat, wird kein zweites Mal gefärbt. So bleibt halbtransparente Tinte (Textmarker) gleichmäßig, auch wo sich der Strich selbst überlappt (Anfangskappe, Kehren, langsames Hin- und Herfahren); erst ein neuer Strich deckt stärker. Nach 255 Strichen setzt ein bildschirmfüllendes Dreieck den Stencil-Puffer zurück.
|
||||
|
||||
### 4.1 Bildebene
|
||||
`image_layer.rs` zeichnet texturierte Vierecke unter der Tinte: importierte PDF-Seiten, eingefügte Bilder und Textfelder. Texturen werden beim ersten Bedarf geladen, bekommen Mipmaps und werden freigegeben, wenn mehr als 384 MB belegt sind.
|
||||
@@ -113,7 +115,7 @@ Ein Frame besteht aus einem Render-Durchgang auf die Fensterfläche (`canvas.rs`
|
||||
- **Textfelder** werden mit `ab_glyph` gerastert, in einer Auflösung, die dem Zoom folgt (in Vierteln einer Zweierpotenz, höchstens 8 Pixel je Einheit). Der Texturschlüssel enthält einen Hash von Inhalt, Schrift und Farbe; jede Änderung erzeugt eine neue Textur.
|
||||
|
||||
### 4.2 Papier- und Musterfarben
|
||||
Papierfarbe (`PaperTheme`) und Musterfarbe (`PatternColor`) stehen in `background.rs`. Alle Farben sind linear; die Fensterfläche ist sRGB, die Umrechnung übernimmt die GPU. Auf dunklem Papier kann der Shader dunkle Tinte aufhellen (`ink_params` in `CameraUniforms`), gespeichert werden immer die Originalfarben.
|
||||
Papierfarbe (`PaperTheme`) und Musterfarbe (`PatternColor`) stehen in `background.rs`; sie sind linear angegeben und werden im Shader nach sRGB umgerechnet. Tintenfarben (Striche, Text) sind sRGB-Werte – dieselben Zahlen landen im PDF und in den Farbpunkten der Oberfläche. Gezeichnet wird ohne sRGB-Umrechnung der GPU (`render_format`), Texturen sind `Rgba8Unorm`: So wird halbtransparente Tinte wie im PDF in sRGB gemischt und sieht am Bildschirm genauso aus wie im Export. Auf dunklem Papier kann der Shader dunkle Tinte aufhellen (`ink_params` in `CameraUniforms`), gespeichert werden immer die Originalfarben.
|
||||
|
||||
---
|
||||
|
||||
@@ -177,7 +179,7 @@ Jede Seite hat einen eigenen Verlauf (`history.rs`): Hinzufügen, Entfernen, Ers
|
||||
2. **PDF** (nur im Notfall): importierte PDF-Seite als Bild, falls sie sich nicht als Vektor übernehmen lässt.
|
||||
3. **Images:** eingefügte Bilder (höchstens 300 dpi).
|
||||
4. **Text:** Textfelder als echter Text mit eingebetteter Schrift (durchsuchbar, kopierbar). Jedes Feld liegt in einem eigenen Koordinatensystem, das eine Transformationsmatrix auf seine Ecken abbildet.
|
||||
5. **Ink:** Striche als gefüllte Polygone, Formen als Pfade.
|
||||
5. **Ink:** Striche als gefüllte Polygone, Formen als Pfade. Halbtransparente Striche (Textmarker) bekommen einen Grafikzustand mit Deckkraft (`ca`/`CA`); printpdf kann solche Zustände nicht anlegen, deshalb trägt `add_alpha_states` sie nach dem Speichern mit `lopdf` in die Seitenressourcen ein. Jeder Strich ist ein einziger Pfad und deckt sich daher nicht selbst doppelt.
|
||||
|
||||
Importierte PDF-Seiten werden anschließend als Form-XObject unter die Seite gelegt (`pdf_merge.rs`), bleiben also Vektorgrafik. Seitentitel werden zu Lesezeichen.
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die
|
||||
```text
|
||||
~/.local/share/ink-notes/ ($XDG_DATA_HOME/ink-notes)
|
||||
├── notebooks/
|
||||
│ ├── library.json Ordner der Notizbücher (siehe unten)
|
||||
│ ├── <notizbuch-id>/
|
||||
│ │ ├── manifest.json Metadaten und Seitenreihenfolge
|
||||
│ │ ├── manifest.json.bak Kopie des vorherigen Manifests
|
||||
@@ -29,6 +30,21 @@ Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die
|
||||
- **Assets** sind nach dem SHA-256-Hash ihres Inhalts benannt; dasselbe Bild wird nur einmal gespeichert. Bilder werden als PNG oder (unverändert) JPEG abgelegt, PDFs unverändert.
|
||||
- Alle Dateien werden mit Rechten `0600`, Ordner mit `0700` angelegt.
|
||||
|
||||
### Ordner (`library.json`)
|
||||
Ordner wie „3. Semester › Mathe“ sind rein logisch: Die Notizbücher bleiben in ihren Verzeichnissen, `library.json` hält nur fest, welcher Ordner welches Notizbuch enthält. Umbenennen, Verschieben 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`)
|
||||
@@ -142,6 +158,9 @@ Ein Absturz während des Speicherns hinterlässt deshalb entweder die alte oder
|
||||
- Lässt sich eine Seite nicht lesen, wird sie nach `page_<id>.json.corrupt-<zeitstempel>` verschoben (nicht gelöscht) und die Seite schreibgeschützt angezeigt.
|
||||
- Ist `manifest.json` beschädigt oder fehlt, wird `manifest.json.bak` verwendet.
|
||||
|
||||
### Gelöschte Seiten
|
||||
Eine gelöschte Seite verschwindet aus `manifest.json`; ihre Datei `page_<id>.json` bleibt im Ordner liegen. Seitenkennungen werden nie wiederverwendet (`next_page_id`), die Datei kann also mit keiner neuen Seite kollidieren. Wird die Seite in derselben Sitzung wiederhergestellt, schreibt die App sie neu.
|
||||
|
||||
### Neuere Versionen
|
||||
Dateien mit einer höheren `format_version` als der unterstützten werden nicht geöffnet – so kann eine ältere App-Version keine Inhalte verlieren, die sie nicht kennt.
|
||||
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
|
||||
|
||||
### Highlights:
|
||||
- **Einfügen per Finger**: Lange (½ s) mit dem Finger aufs Blatt drücken öffnet ein Menü „Einfügen“ – Screenshot, kopierter Text oder kopierte Objekte landen genau an dieser Stelle (wie in Rnote)
|
||||
- **Eigene Farben**: Jede Farbe der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen – erneut antippen, Vorgabe oder mit 🎨 frei wählen; die Leiste merkt sich die Farben. Farben am Bildschirm wie im PDF-/Bildexport (Schwarz ist schwarz, Textmarker mischt wie im PDF); Karos und Linien im PDF so hell wie am Bildschirm
|
||||
- **Beta-Feedback II**: Vier Stifte mit eigener Farbe und Stärke, die sich Änderungen merken; Textmarker wird innerhalb eines Strichs nicht dunkler und ist jetzt auch im PDF-/PNG-/SVG-Export durchsichtig; Seiten lückenlos untereinander mit Trennlinie; neue Seite per ➕ in der Werkzeugleiste; Seite löschen im ☰-Menü (wiederherstellbar); Koordinatensystem ohne Loch am Ursprung
|
||||
- **Fehlerbehebungen**: Umbenennen und Löschen in der Notizbuchverwaltung reagieren wieder; Karos, Linien und Punkte werden pro Pixel berechnet und liegen in jedem Zoom gleichmäßig (mindestens ein Pixel breit, kantengeglättet)
|
||||
- **Strichqualität**: Kleine Schleifen wie in „o“, „a“ oder „e“ bleiben rund (der fertige Strich entsteht aus den ungefilterten Stiftpunkten); Strichkanten werden kantengeglättet (4× Multisampling), runde Strichenden sind feiner unterteilt
|
||||
- **Musterfarbe**: Karos, Linien und Punkte in hell, mittel, kräftig, blau oder grün (☰-Menü, gilt auch im Export); Punkte des Punktrasters am Bildschirm so groß wie im Export (0,4 mm). README und Dokumentation überarbeitet
|
||||
- **Beta-Feedback**: Querformat-PDFs (z. B. PowerPoint-Folien) bekommen Querformat-Seiten ohne leeren Streifen (Dateiformat-Version 8); neue Seiten direkt nach der aktuellen einfügen; Auswahlwerkzeug funktioniert mit dem Stift; Zoom bis 1000 % mit schärfer nachgerenderten PDF-Seiten
|
||||
- **Text-Werkzeug**: Mit T ins Blatt tippen und schreiben – normale oder Code-Schrift, 8–36 pt, Zeilenumbruch am Feldrand; Textfelder verschieben, drehen und skalieren wie Bilder; im PDF echter, durchsuchbarer Text; kopierter Text wird mit Strg+V zum Textfeld (Dateiformat-Version 7)
|
||||
|
||||
+94
-128
@@ -71,7 +71,8 @@ pub const LINED_SPACING: f32 = 75.0;
|
||||
/// Papierfarbe der Anzeige. Betrifft nur die Darstellung am Bildschirm; gespeicherte
|
||||
/// Strichfarben und der PDF-Export bleiben unverändert (weißes Papier).
|
||||
///
|
||||
/// Alle Farben sind linear (die Oberfläche ist sRGB, die Umrechnung macht die GPU).
|
||||
/// Papier- und Musterfarben sind linear (der Shader rechnet sie in sRGB um); Tintenfarben
|
||||
/// dagegen sind sRGB-Werte wie im Export.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PaperTheme {
|
||||
@@ -138,7 +139,24 @@ impl PaperTheme {
|
||||
PaperTheme::DarkGray => (0.004, 0.004, 0.005),
|
||||
PaperTheme::Black => (0.03, 0.03, 0.035),
|
||||
};
|
||||
wgpu::Color { r, g, b, a: 1.0 }
|
||||
// Linear angegeben; gezeichnet wird ohne sRGB-Umrechnung
|
||||
let srgb = |c: f64| f64::from(linear_to_srgb(c as f32));
|
||||
wgpu::Color {
|
||||
r: srgb(r),
|
||||
g: srgb(g),
|
||||
b: srgb(b),
|
||||
a: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Linearer Farbanteil (Papier, Muster) als sRGB-Wert (Tinte, Export).
|
||||
pub fn linear_to_srgb(c: f32) -> f32 {
|
||||
let c = c.clamp(0.0, 1.0);
|
||||
if c <= 0.003_130_8 {
|
||||
c * 12.92
|
||||
} else {
|
||||
1.055 * c.powf(1.0 / 2.4) - 0.055
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,115 +183,70 @@ pub fn generate_paper_quad(color: [f32; 4], size: glam::Vec2) -> Vec<InkVertex>
|
||||
]
|
||||
}
|
||||
|
||||
/// Fügt ein Quad (2 Dreiecke) zur Vertexliste hinzu.
|
||||
#[inline]
|
||||
fn push_quad(
|
||||
vertices: &mut Vec<InkVertex>,
|
||||
color: [f32; 4],
|
||||
x_min: f32,
|
||||
y_min: f32,
|
||||
x_max: f32,
|
||||
y_max: f32,
|
||||
) {
|
||||
vertices.push(InkVertex::new(x_min, y_min, color));
|
||||
vertices.push(InkVertex::new(x_max, y_min, color));
|
||||
vertices.push(InkVertex::new(x_min, y_max, color));
|
||||
vertices.push(InkVertex::new(x_min, y_max, color));
|
||||
vertices.push(InkVertex::new(x_max, y_min, color));
|
||||
vertices.push(InkVertex::new(x_max, y_max, color));
|
||||
/// Oberer und unterer Rand ohne Linien bei liniertem Papier (15 mm).
|
||||
pub const LINED_MARGIN: f32 = 150.0;
|
||||
|
||||
/// Ecke des Musterrechtecks: Das Muster selbst berechnet der Fragment-Shader (`fs_pattern`)
|
||||
/// für jedes Bildschirmpixel. So liegen Linien und Punkte in jedem Zoom exakt gleichmäßig,
|
||||
/// sind mindestens ein Pixel breit und kantengeglättet – vorher gerasterte Haarlinien
|
||||
/// fielen je nach Lage auf das Pixelraster und wirkten unregelmäßig.
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct PatternVertex {
|
||||
/// Seitenkoordinate
|
||||
pub position: [f32; 2],
|
||||
pub color: [f32; 4],
|
||||
/// Art (1 = Karo, 2 = Linien, 3 = Punkte), Abstand, Linienstärke, Punktgröße
|
||||
pub params: [f32; 4],
|
||||
/// Seitenbreite, Seitenhöhe, Rand oben/unten bei Linien, ungenutzt
|
||||
pub bounds: [f32; 4],
|
||||
}
|
||||
|
||||
/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund in `color` auf
|
||||
/// einem Blatt der Größe `size`.
|
||||
pub fn generate_background_mesh(
|
||||
impl PatternVertex {
|
||||
const ATTRIBUTES: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![
|
||||
0 => Float32x2,
|
||||
1 => Float32x4,
|
||||
2 => Float32x4,
|
||||
3 => Float32x4
|
||||
];
|
||||
|
||||
pub fn desc() -> wgpu::VertexBufferLayout<'static> {
|
||||
wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<PatternVertex>() as wgpu::BufferAddress,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &Self::ATTRIBUTES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rechteck über das ganze Blatt der Größe `size`, auf dem der Shader das Muster
|
||||
/// `background` in `color` zeichnet; leer bei blankem Papier.
|
||||
pub fn generate_pattern_quad(
|
||||
background: PageBackground,
|
||||
color: [f32; 4],
|
||||
size: glam::Vec2,
|
||||
) -> Vec<InkVertex> {
|
||||
let (page_width, page_height) = (size.x, size.y);
|
||||
match background {
|
||||
PageBackground::Blank => Vec::new(),
|
||||
PageBackground::Grid5mm => {
|
||||
let mut vertices = Vec::with_capacity(1200);
|
||||
let half_t = LINE_THICKNESS / 2.0;
|
||||
|
||||
// Vertikale Linien alle 5 mm
|
||||
let mut x = GRID_5MM_SPACING;
|
||||
while x < page_width {
|
||||
push_quad(
|
||||
&mut vertices,
|
||||
color,
|
||||
x - half_t,
|
||||
0.0,
|
||||
x + half_t,
|
||||
page_height,
|
||||
);
|
||||
x += GRID_5MM_SPACING;
|
||||
}
|
||||
|
||||
// Horizontale Linien alle 5 mm
|
||||
let mut y = GRID_5MM_SPACING;
|
||||
while y < page_height {
|
||||
push_quad(
|
||||
&mut vertices,
|
||||
color,
|
||||
0.0,
|
||||
y - half_t,
|
||||
page_width,
|
||||
y + half_t,
|
||||
);
|
||||
y += GRID_5MM_SPACING;
|
||||
}
|
||||
|
||||
vertices
|
||||
}
|
||||
PageBackground::Lined => {
|
||||
let mut vertices = Vec::with_capacity(600);
|
||||
let half_t = LINE_THICKNESS / 2.0;
|
||||
|
||||
// Oberer und unterer Randabstand (z. B. 15 mm)
|
||||
let margin_top = 150.0;
|
||||
let margin_bottom = 150.0;
|
||||
|
||||
let mut y = margin_top;
|
||||
while y <= page_height - margin_bottom {
|
||||
push_quad(
|
||||
&mut vertices,
|
||||
color,
|
||||
0.0,
|
||||
y - half_t,
|
||||
page_width,
|
||||
y + half_t,
|
||||
);
|
||||
y += LINED_SPACING;
|
||||
}
|
||||
|
||||
vertices
|
||||
}
|
||||
PageBackground::DotGrid => {
|
||||
let mut vertices = Vec::with_capacity(15000);
|
||||
let half_d = DOT_SIZE / 2.0;
|
||||
|
||||
let mut y = GRID_5MM_SPACING;
|
||||
while y < page_height {
|
||||
let mut x = GRID_5MM_SPACING;
|
||||
while x < page_width {
|
||||
push_quad(
|
||||
&mut vertices,
|
||||
color,
|
||||
x - half_d,
|
||||
y - half_d,
|
||||
x + half_d,
|
||||
y + half_d,
|
||||
);
|
||||
x += GRID_5MM_SPACING;
|
||||
}
|
||||
y += GRID_5MM_SPACING;
|
||||
}
|
||||
|
||||
vertices
|
||||
}
|
||||
}
|
||||
) -> Vec<PatternVertex> {
|
||||
let params = match background {
|
||||
PageBackground::Blank => return Vec::new(),
|
||||
PageBackground::Grid5mm => [1.0, GRID_5MM_SPACING, LINE_THICKNESS, DOT_SIZE],
|
||||
PageBackground::Lined => [2.0, LINED_SPACING, LINE_THICKNESS, DOT_SIZE],
|
||||
PageBackground::DotGrid => [3.0, GRID_5MM_SPACING, LINE_THICKNESS, DOT_SIZE],
|
||||
};
|
||||
let bounds = [size.x, size.y, LINED_MARGIN, 0.0];
|
||||
let corner = |x: f32, y: f32| PatternVertex {
|
||||
position: [x, y],
|
||||
color,
|
||||
params,
|
||||
bounds,
|
||||
};
|
||||
vec![
|
||||
corner(0.0, 0.0),
|
||||
corner(size.x, 0.0),
|
||||
corner(0.0, size.y),
|
||||
corner(0.0, size.y),
|
||||
corner(size.x, 0.0),
|
||||
corner(size.x, size.y),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -323,32 +296,25 @@ mod tests {
|
||||
const PATTERN: [f32; 4] = [0.5, 0.5, 0.5, 1.0];
|
||||
|
||||
#[test]
|
||||
fn test_background_meshes() {
|
||||
let blank = generate_background_mesh(PageBackground::Blank, PATTERN, A4_SIZE);
|
||||
assert!(blank.is_empty());
|
||||
|
||||
let grid = generate_background_mesh(PageBackground::Grid5mm, PATTERN, A4_SIZE);
|
||||
assert!(!grid.is_empty());
|
||||
assert_eq!(
|
||||
grid.len() % 6,
|
||||
0,
|
||||
"Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)"
|
||||
);
|
||||
|
||||
let lined = generate_background_mesh(PageBackground::Lined, PATTERN, A4_SIZE);
|
||||
assert!(!lined.is_empty());
|
||||
assert_eq!(lined.len() % 6, 0);
|
||||
|
||||
let dots = generate_background_mesh(PageBackground::DotGrid, PATTERN, A4_SIZE);
|
||||
assert!(!dots.is_empty());
|
||||
assert_eq!(dots.len() % 6, 0);
|
||||
fn test_pattern_quads() {
|
||||
assert!(generate_pattern_quad(PageBackground::Blank, PATTERN, A4_SIZE).is_empty());
|
||||
for (background, kind) in [
|
||||
(PageBackground::Grid5mm, 1.0),
|
||||
(PageBackground::Lined, 2.0),
|
||||
(PageBackground::DotGrid, 3.0),
|
||||
] {
|
||||
let quad = generate_pattern_quad(background, PATTERN, A4_SIZE);
|
||||
assert_eq!(quad.len(), 6);
|
||||
assert!(quad
|
||||
.iter()
|
||||
.all(|v| v.params[0] == kind && v.color == PATTERN));
|
||||
assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]);
|
||||
assert_eq!(quad[0].bounds[..2], [PAGE_WIDTH, PAGE_HEIGHT]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_background_mesh_uses_theme_color() {
|
||||
let dark = PaperTheme::Black.pattern_color();
|
||||
let mesh = generate_background_mesh(PageBackground::Lined, dark, A4_SIZE);
|
||||
assert!(mesh.iter().all(|v| v.color == dark));
|
||||
let quad = generate_paper_quad(PaperTheme::Cream.paper_color(), A4_SIZE);
|
||||
assert!(quad
|
||||
.iter()
|
||||
|
||||
+233
-44
@@ -12,7 +12,9 @@ use std::sync::Arc;
|
||||
use winit::dpi::PhysicalSize;
|
||||
use winit::window::Window;
|
||||
|
||||
use crate::background::{generate_background_mesh, generate_paper_quad, PaperTheme, PatternColor};
|
||||
use crate::background::{
|
||||
generate_paper_quad, generate_pattern_quad, PaperTheme, PatternColor, PatternVertex,
|
||||
};
|
||||
use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample};
|
||||
use crate::filter::OneEuroFilter;
|
||||
use crate::spline::{self, ControlPoint, SplineConfig};
|
||||
@@ -150,8 +152,9 @@ struct NeighborPage {
|
||||
offset: glam::Vec2,
|
||||
camera_buffer: wgpu::Buffer,
|
||||
camera_bind_group: wgpu::BindGroup,
|
||||
background_buffer: Option<wgpu::Buffer>,
|
||||
background_vertex_count: u32,
|
||||
/// Papier (Dreiecke) und Muster (Rechteck für `fs_pattern`)
|
||||
paper_buffer: wgpu::Buffer,
|
||||
pattern_buffer: Option<wgpu::Buffer>,
|
||||
vertex_buffer: Option<wgpu::Buffer>,
|
||||
stroke_ranges: Vec<Range<u32>>,
|
||||
}
|
||||
@@ -163,6 +166,10 @@ pub struct CanvasState {
|
||||
/// Multisampling der Zeichenfläche (1 = aus) und das zugehörige Zwischenbild
|
||||
msaa_samples: u32,
|
||||
msaa_view: Option<wgpu::TextureView>,
|
||||
/// Stencil-Puffer: Jeder Strich deckt ein Pixel nur einmal, damit sich halbtransparente
|
||||
/// Tinte (Textmarker) dort, wo der Strich sich selbst überlappt, nicht verdunkelt
|
||||
stencil_view: wgpu::TextureView,
|
||||
stencil_reset_pipeline: wgpu::RenderPipeline,
|
||||
/// Meldung „keine Darstellungsmodi“ wurde bereits ausgegeben
|
||||
presentation_warned: bool,
|
||||
pub device: wgpu::Device,
|
||||
@@ -187,9 +194,10 @@ pub struct CanvasState {
|
||||
|
||||
// Statische Hintergrund-Geometrie
|
||||
pub paper_buffer: wgpu::Buffer,
|
||||
/// Musterrechteck der aktuellen Seite (6 Ecken, leer bei blankem Papier)
|
||||
pub background_buffer: wgpu::Buffer,
|
||||
pub background_vertex_capacity: usize,
|
||||
pub background_vertex_count: u32,
|
||||
pattern_pipeline: wgpu::RenderPipeline,
|
||||
pub current_background: PageBackground,
|
||||
/// Papierfarbe der Anzeige
|
||||
pub paper_theme: PaperTheme,
|
||||
@@ -246,7 +254,6 @@ pub struct CanvasState {
|
||||
impl CanvasState {
|
||||
const INITIAL_LIVE_CAPACITY: usize = 4096;
|
||||
const INITIAL_COMMITTED_CAPACITY: usize = 65536;
|
||||
const INITIAL_BACKGROUND_CAPACITY: usize = 16384;
|
||||
pub const VIEWPORT_MARGIN: f32 = 24.0;
|
||||
|
||||
/// Initialisiert den CanvasState für das übergebene Winit-Fenster.
|
||||
@@ -309,13 +316,20 @@ impl CanvasState {
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 4. Geringe Bildverzögerung (Mailbox, sonst Fifo) & Frame-Latency 1
|
||||
// 4. Geringe Bildverzögerung (Mailbox, sonst Fifo) & Frame-Latency 1.
|
||||
// Gezeichnet wird ohne sRGB-Umrechnung (siehe `render_format`); bietet die Fläche
|
||||
// nur sRGB-Formate an, wird eine Ansicht ohne sRGB verwendet.
|
||||
let surface_format = surface_caps
|
||||
.formats
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|f| f.is_srgb())
|
||||
.find(|f| !f.is_srgb())
|
||||
.unwrap_or(surface_caps.formats[0]);
|
||||
let view_formats = if surface_format.is_srgb() {
|
||||
vec![surface_format.remove_srgb_suffix()]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
@@ -324,14 +338,14 @@ impl CanvasState {
|
||||
height: size.height.max(1),
|
||||
present_mode,
|
||||
alpha_mode: surface_caps.alpha_modes[0],
|
||||
view_formats: vec![],
|
||||
view_formats,
|
||||
desired_maximum_frame_latency: 1, // Minimiert Eingabelatenz auf 1 Frame
|
||||
};
|
||||
surface.configure(&device, &config);
|
||||
|
||||
// Kantenglättung der Zeichenfläche (4× Multisampling), falls das Format es erlaubt
|
||||
let msaa_samples = if adapter
|
||||
.get_texture_format_features(config.format)
|
||||
.get_texture_format_features(render_format(&config))
|
||||
.flags
|
||||
.sample_count_supported(MSAA_SAMPLES)
|
||||
{
|
||||
@@ -340,6 +354,7 @@ impl CanvasState {
|
||||
1
|
||||
};
|
||||
let msaa_view = create_msaa_view(&device, &config, msaa_samples);
|
||||
let stencil_view = create_stencil_view(&device, &config, msaa_samples);
|
||||
|
||||
// 5. Shader & Bind Group Layout
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
@@ -406,7 +421,7 @@ impl CanvasState {
|
||||
module: &shader,
|
||||
entry_point: "fs_ink",
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format,
|
||||
format: render_format(&config),
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
@@ -420,7 +435,7 @@ impl CanvasState {
|
||||
polygon_mode: wgpu::PolygonMode::Fill,
|
||||
conservative: false,
|
||||
},
|
||||
depth_stencil: None,
|
||||
depth_stencil: Some(stroke_stencil()),
|
||||
multisample: wgpu::MultisampleState {
|
||||
count: msaa_samples,
|
||||
..Default::default()
|
||||
@@ -428,6 +443,34 @@ impl CanvasState {
|
||||
multiview: None,
|
||||
});
|
||||
|
||||
// Setzt den Stencil-Puffer zurück, wenn die Strichnummern überlaufen
|
||||
let stencil_reset_pipeline =
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Stencil Reset Pipeline"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: "vs_fullscreen",
|
||||
buffers: &[],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: "fs_none",
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: render_format(&config),
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::empty(),
|
||||
})],
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: Some(stencil_reset()),
|
||||
multisample: wgpu::MultisampleState {
|
||||
count: msaa_samples,
|
||||
..Default::default()
|
||||
},
|
||||
multiview: None,
|
||||
});
|
||||
|
||||
// TriangleList-Pipeline (für Hintergrundpapier und Muster)
|
||||
let triangle_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Background TriangleList Pipeline"),
|
||||
@@ -441,7 +484,7 @@ impl CanvasState {
|
||||
module: &shader,
|
||||
entry_point: "fs_main",
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format,
|
||||
format: render_format(&config),
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
@@ -455,7 +498,7 @@ impl CanvasState {
|
||||
polygon_mode: wgpu::PolygonMode::Fill,
|
||||
conservative: false,
|
||||
},
|
||||
depth_stencil: None,
|
||||
depth_stencil: Some(passive_stencil()),
|
||||
multisample: wgpu::MultisampleState {
|
||||
count: msaa_samples,
|
||||
..Default::default()
|
||||
@@ -475,19 +518,46 @@ impl CanvasState {
|
||||
queue.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper_quad));
|
||||
|
||||
let current_background = PageBackground::Grid5mm;
|
||||
let bg_mesh = generate_background_mesh(
|
||||
let bg_mesh = generate_pattern_quad(
|
||||
current_background,
|
||||
paper_theme.pattern_color(),
|
||||
crate::document::A4_SIZE,
|
||||
);
|
||||
let background_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Background Mesh Buffer"),
|
||||
size: (Self::INITIAL_BACKGROUND_CAPACITY * std::mem::size_of::<InkVertex>()) as u64,
|
||||
label: Some("Pattern Quad Buffer"),
|
||||
size: (6 * std::mem::size_of::<PatternVertex>()) as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
queue.write_buffer(&background_buffer, 0, bytemuck::cast_slice(&bg_mesh));
|
||||
|
||||
// Karo, Linien und Punkte: pro Pixel im Shader berechnet
|
||||
let pattern_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Pattern Pipeline"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: "vs_pattern",
|
||||
buffers: &[PatternVertex::desc()],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: "fs_pattern",
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: render_format(&config),
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: Some(passive_stencil()),
|
||||
multisample: wgpu::MultisampleState {
|
||||
count: msaa_samples,
|
||||
..Default::default()
|
||||
},
|
||||
multiview: None,
|
||||
});
|
||||
|
||||
// 7. GPU-Puffer für Live- und Committed-Geometrie
|
||||
let live_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Live Stroke Vertex Buffer"),
|
||||
@@ -507,18 +577,20 @@ impl CanvasState {
|
||||
&device,
|
||||
&shader,
|
||||
&bind_group_layout,
|
||||
config.format,
|
||||
render_format(&config),
|
||||
msaa_samples,
|
||||
);
|
||||
|
||||
// 8. egui Renderer für das schwebende UI initialisieren
|
||||
let egui_renderer = egui_wgpu::Renderer::new(&device, config.format, None, 1);
|
||||
let egui_renderer = egui_wgpu::Renderer::new(&device, render_format(&config), None, 1);
|
||||
|
||||
Ok(Self {
|
||||
surface,
|
||||
adapter,
|
||||
msaa_samples,
|
||||
msaa_view,
|
||||
stencil_view,
|
||||
stencil_reset_pipeline,
|
||||
presentation_warned: false,
|
||||
device,
|
||||
queue,
|
||||
@@ -535,8 +607,8 @@ impl CanvasState {
|
||||
current_stroke_width_mm: 0.5,
|
||||
paper_buffer,
|
||||
background_buffer,
|
||||
background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY,
|
||||
background_vertex_count: bg_mesh.len() as u32,
|
||||
pattern_pipeline,
|
||||
current_background,
|
||||
paper_theme,
|
||||
adapt_ink: false,
|
||||
@@ -668,6 +740,7 @@ impl CanvasState {
|
||||
}
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
self.msaa_view = create_msaa_view(&self.device, &self.config, self.msaa_samples);
|
||||
self.stencil_view = create_stencil_view(&self.device, &self.config, self.msaa_samples);
|
||||
self.recalculate_viewport();
|
||||
}
|
||||
}
|
||||
@@ -838,23 +911,12 @@ impl CanvasState {
|
||||
|
||||
/// Erzeugt das Muster für Hintergrund und Papierfarbe neu und lädt es auf die GPU.
|
||||
fn upload_background_mesh(&mut self) {
|
||||
let mesh = generate_background_mesh(
|
||||
let mesh = generate_pattern_quad(
|
||||
self.current_background,
|
||||
self.pattern.color(self.paper_theme),
|
||||
self.page_size,
|
||||
);
|
||||
self.background_vertex_count = mesh.len() as u32;
|
||||
|
||||
if mesh.len() > self.background_vertex_capacity {
|
||||
self.background_vertex_capacity = mesh.len() * 2;
|
||||
self.background_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Background Mesh Buffer (Reallocated)"),
|
||||
size: (self.background_vertex_capacity * std::mem::size_of::<InkVertex>()) as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
}
|
||||
|
||||
if !mesh.is_empty() {
|
||||
self.queue
|
||||
.write_buffer(&self.background_buffer, 0, bytemuck::cast_slice(&mesh));
|
||||
@@ -962,20 +1024,39 @@ impl CanvasState {
|
||||
buffer
|
||||
})
|
||||
};
|
||||
// Papier und Muster in einem Puffer (Nachbarseiten können anders groß sein)
|
||||
let mut background = generate_paper_quad(self.paper_theme.paper_color(), page.size);
|
||||
background.extend(generate_background_mesh(
|
||||
// Eigenes Papier und Muster (Nachbarseiten können anders groß sein)
|
||||
let paper = generate_paper_quad(self.paper_theme.paper_color(), page.size);
|
||||
let pattern = generate_pattern_quad(
|
||||
page.background,
|
||||
self.pattern.color(self.paper_theme),
|
||||
page.size,
|
||||
));
|
||||
);
|
||||
let paper_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Neighbor Paper Buffer"),
|
||||
size: std::mem::size_of_val(paper.as_slice()) as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
self.queue
|
||||
.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper));
|
||||
let pattern_buffer = (!pattern.is_empty()).then(|| {
|
||||
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Neighbor Pattern Buffer"),
|
||||
size: std::mem::size_of_val(pattern.as_slice()) as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
self.queue
|
||||
.write_buffer(&buffer, 0, bytemuck::cast_slice(&pattern));
|
||||
buffer
|
||||
});
|
||||
NeighborPage {
|
||||
fingerprint,
|
||||
offset: glam::Vec2::ZERO,
|
||||
camera_buffer,
|
||||
camera_bind_group,
|
||||
background_buffer: vertex_buffer_with("Neighbor Background Buffer", &background),
|
||||
background_vertex_count: background.len() as u32,
|
||||
paper_buffer,
|
||||
pattern_buffer,
|
||||
vertex_buffer: vertex_buffer_with("Neighbor Strokes Buffer", &page.vertex_cache),
|
||||
stroke_ranges: page.stroke_ranges.clone(),
|
||||
}
|
||||
@@ -1266,9 +1347,10 @@ impl CanvasState {
|
||||
}
|
||||
|
||||
let output = self.surface.get_current_texture()?;
|
||||
let view = output
|
||||
.texture
|
||||
.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
let view = output.texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
format: Some(render_format(&self.config)),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let mut encoder = self
|
||||
.device
|
||||
@@ -1302,18 +1384,29 @@ impl CanvasState {
|
||||
},
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &self.stencil_view,
|
||||
depth_ops: None,
|
||||
stencil_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0),
|
||||
store: wgpu::StoreOp::Discard,
|
||||
}),
|
||||
}),
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
});
|
||||
let mut stencil_ref = 0;
|
||||
|
||||
// 0. Nachbarseiten der durchgehenden Ansicht (je mit eigener Kamera)
|
||||
for (i, neighbor) in self.neighbors.iter().enumerate() {
|
||||
render_pass.set_bind_group(0, &neighbor.camera_bind_group, &[]);
|
||||
render_pass.set_pipeline(&self.triangle_pipeline);
|
||||
if let Some(buffer) = &neighbor.background_buffer {
|
||||
render_pass.set_vertex_buffer(0, neighbor.paper_buffer.slice(..));
|
||||
render_pass.draw(0..6, 0..1);
|
||||
if let Some(buffer) = &neighbor.pattern_buffer {
|
||||
render_pass.set_pipeline(&self.pattern_pipeline);
|
||||
render_pass.set_vertex_buffer(0, buffer.slice(..));
|
||||
render_pass.draw(0..neighbor.background_vertex_count, 0..1);
|
||||
render_pass.draw(0..6, 0..1);
|
||||
}
|
||||
self.image_layer.draw(&mut render_pass, i + 1);
|
||||
if let Some(buffer) = &neighbor.vertex_buffer {
|
||||
@@ -1321,6 +1414,7 @@ impl CanvasState {
|
||||
render_pass.set_vertex_buffer(0, buffer.slice(..));
|
||||
for range in &neighbor.stroke_ranges {
|
||||
if range.end > range.start {
|
||||
self.next_stroke_stencil(&mut render_pass, &mut stencil_ref);
|
||||
render_pass.draw(range.clone(), 0..1);
|
||||
}
|
||||
}
|
||||
@@ -1337,6 +1431,7 @@ impl CanvasState {
|
||||
|
||||
// 2. Hintergrundmuster (5mm-Gitter, liniert oder Punktraster mit Vertex-Farbe)
|
||||
if self.background_vertex_count > 0 {
|
||||
render_pass.set_pipeline(&self.pattern_pipeline);
|
||||
render_pass.set_vertex_buffer(0, self.background_buffer.slice(..));
|
||||
render_pass.draw(0..self.background_vertex_count, 0..1);
|
||||
}
|
||||
@@ -1351,6 +1446,7 @@ impl CanvasState {
|
||||
render_pass.set_vertex_buffer(0, self.committed_vertex_buffer.slice(..));
|
||||
for range in &self.committed_strokes {
|
||||
if range.end > range.start {
|
||||
self.next_stroke_stencil(&mut render_pass, &mut stencil_ref);
|
||||
render_pass.draw(range.clone(), 0..1);
|
||||
}
|
||||
}
|
||||
@@ -1359,6 +1455,7 @@ impl CanvasState {
|
||||
// 5. Temporäre Live-Geometrie des aktuellen Strichs rendern (Zero Latency)
|
||||
if self.live_vertex_count > 0 {
|
||||
render_pass.set_vertex_buffer(0, self.live_vertex_buffer.slice(..));
|
||||
self.next_stroke_stencil(&mut render_pass, &mut stencil_ref);
|
||||
render_pass.draw(0..self.live_vertex_count, 0..1);
|
||||
}
|
||||
}
|
||||
@@ -1395,6 +1492,20 @@ impl CanvasState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gibt dem nächsten Strich eine eigene Stencil-Nummer: Die Strich-Pipeline zeichnet nur
|
||||
/// Pixel, die noch nicht diese Nummer tragen. Nach 255 Strichen wird der Stencil-Puffer
|
||||
/// zurückgesetzt, damit kein späterer Strich die Nummer eines früheren wiederfindet.
|
||||
fn next_stroke_stencil<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>, stencil_ref: &mut u32) {
|
||||
if *stencil_ref == u32::from(u8::MAX) {
|
||||
pass.set_pipeline(&self.stencil_reset_pipeline);
|
||||
pass.draw(0..3, 0..1);
|
||||
pass.set_pipeline(&self.strip_pipeline);
|
||||
*stencil_ref = 0;
|
||||
}
|
||||
*stencil_ref += 1;
|
||||
pass.set_stencil_reference(*stencil_ref);
|
||||
}
|
||||
|
||||
/// Rendert nur die Canvas-Fläche ohne UI (z. B. für Headless-Tests).
|
||||
pub fn render_canvas_only(&mut self) -> Result<(), wgpu::SurfaceError> {
|
||||
let dummy_delta = egui::TexturesDelta::default();
|
||||
@@ -1406,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.
|
||||
const MSAA_SAMPLES: u32 = 4;
|
||||
|
||||
@@ -1427,7 +1546,7 @@ fn create_msaa_view(
|
||||
mip_level_count: 1,
|
||||
sample_count: samples,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: config.format,
|
||||
format: render_format(config),
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
})
|
||||
@@ -1435,6 +1554,76 @@ fn create_msaa_view(
|
||||
})
|
||||
}
|
||||
|
||||
/// Format des Stencil-Puffers der Zeichenfläche.
|
||||
const STENCIL_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth24PlusStencil8;
|
||||
|
||||
/// Stencil-Puffer in Fenstergröße (mit derselben Abtastzahl wie die Zeichenfläche).
|
||||
fn create_stencil_view(
|
||||
device: &wgpu::Device,
|
||||
config: &wgpu::SurfaceConfiguration,
|
||||
samples: u32,
|
||||
) -> wgpu::TextureView {
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Stroke Stencil Buffer"),
|
||||
size: wgpu::Extent3d {
|
||||
width: config.width.max(1),
|
||||
height: config.height.max(1),
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: samples,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: STENCIL_FORMAT,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&wgpu::TextureViewDescriptor::default())
|
||||
}
|
||||
|
||||
/// Stencil-Zustand mit gleicher Prüfung und Aktion für Vorder- und Rückseiten.
|
||||
fn stencil_state(
|
||||
compare: wgpu::CompareFunction,
|
||||
pass_op: wgpu::StencilOperation,
|
||||
) -> wgpu::DepthStencilState {
|
||||
let face = wgpu::StencilFaceState {
|
||||
compare,
|
||||
fail_op: wgpu::StencilOperation::Keep,
|
||||
depth_fail_op: wgpu::StencilOperation::Keep,
|
||||
pass_op,
|
||||
};
|
||||
wgpu::DepthStencilState {
|
||||
format: STENCIL_FORMAT,
|
||||
depth_write_enabled: false,
|
||||
depth_compare: wgpu::CompareFunction::Always,
|
||||
stencil: wgpu::StencilState {
|
||||
front: face,
|
||||
back: face,
|
||||
read_mask: 0xff,
|
||||
write_mask: 0xff,
|
||||
},
|
||||
bias: wgpu::DepthBiasState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pipelines ohne Strichnummer (Papier, Muster, Bilder) lassen den Stencil-Puffer unberührt.
|
||||
pub(crate) fn passive_stencil() -> wgpu::DepthStencilState {
|
||||
stencil_state(wgpu::CompareFunction::Always, wgpu::StencilOperation::Keep)
|
||||
}
|
||||
|
||||
/// Striche: nur Pixel, die die eigene Nummer noch nicht tragen, und diese dann markieren.
|
||||
fn stroke_stencil() -> wgpu::DepthStencilState {
|
||||
stencil_state(
|
||||
wgpu::CompareFunction::NotEqual,
|
||||
wgpu::StencilOperation::Replace,
|
||||
)
|
||||
}
|
||||
|
||||
/// Zurücksetzen: überall 0 eintragen.
|
||||
fn stencil_reset() -> wgpu::DepthStencilState {
|
||||
stencil_state(wgpu::CompareFunction::Always, wgpu::StencilOperation::Zero)
|
||||
}
|
||||
|
||||
/// Zu rasterndes Textfeld.
|
||||
struct TextSpec {
|
||||
text: crate::text::TextBox,
|
||||
|
||||
@@ -1217,6 +1217,29 @@ impl Notebook {
|
||||
index
|
||||
}
|
||||
|
||||
/// Entfernt die aktuelle Seite und gibt sie zurück; danach ist die folgende Seite aktuell
|
||||
/// (bzw. die vorige, wenn es die letzte war). Die einzige Seite eines Notizbuchs bleibt
|
||||
/// stehen (`None`).
|
||||
pub fn remove_current_page(&mut self) -> Option<Page> {
|
||||
if self.pages.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let page = self.pages.remove(self.current_page_index);
|
||||
self.load_warnings.remove(&page.id);
|
||||
self.current_page_index = self.current_page_index.min(self.pages.len() - 1);
|
||||
Some(page)
|
||||
}
|
||||
|
||||
/// Setzt eine mit [`Self::remove_current_page`] entfernte Seite wieder an `index` ein
|
||||
/// (höchstens ans Ende) und macht sie zur aktuellen Seite; gibt ihren Index zurück.
|
||||
pub fn restore_page(&mut self, index: usize, page: Page) -> usize {
|
||||
let index = index.min(self.pages.len());
|
||||
self.next_page_id = self.next_page_id.max(page.id + 1);
|
||||
self.pages.insert(index, page);
|
||||
self.current_page_index = index;
|
||||
index
|
||||
}
|
||||
|
||||
/// Durchgehende Ansicht: Reicht ein neuer Strich auf der letzten Seite bis in deren
|
||||
/// unteres Fünftel ([`AUTO_APPEND_FRACTION`]), wird darunter eine leere Seite mit gleichem
|
||||
/// Hintergrund angehängt. `true`, wenn eine Seite angehängt wurde.
|
||||
@@ -1982,6 +2005,39 @@ mod tests {
|
||||
assert!(new.pdf_background.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_and_restore_current_page() {
|
||||
let mut nb = Notebook::new();
|
||||
// Die einzige Seite bleibt
|
||||
assert!(nb.remove_current_page().is_none());
|
||||
nb.add_page(PageBackground::Lined);
|
||||
nb.add_page(PageBackground::DotGrid);
|
||||
let ids: Vec<u64> = nb.pages.iter().map(|p| p.id).collect();
|
||||
|
||||
// Mittlere Seite: danach ist die folgende aktuell
|
||||
nb.current_page_index = 1;
|
||||
let removed = nb.remove_current_page().unwrap();
|
||||
assert_eq!(removed.id, ids[1]);
|
||||
assert_eq!(
|
||||
nb.pages.iter().map(|p| p.id).collect::<Vec<_>>(),
|
||||
[ids[0], ids[2]]
|
||||
);
|
||||
assert_eq!(nb.current_page_index, 1);
|
||||
|
||||
// Wiederherstellen an der alten Stelle
|
||||
assert_eq!(nb.restore_page(1, removed), 1);
|
||||
assert_eq!(nb.pages.iter().map(|p| p.id).collect::<Vec<_>>(), ids);
|
||||
assert_eq!(nb.current_page_index, 1);
|
||||
|
||||
// Letzte Seite: danach ist die vorige aktuell
|
||||
nb.current_page_index = 2;
|
||||
let last = nb.remove_current_page().unwrap();
|
||||
assert_eq!(last.id, ids[2]);
|
||||
assert_eq!(nb.current_page_index, 1);
|
||||
// Neue Seiten bekommen keine schon vergebene Kennung
|
||||
assert!(nb.next_page_id > ids[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append_page_when_writing_at_bottom_of_last_page() {
|
||||
let mut nb = Notebook::new();
|
||||
|
||||
+108
-15
@@ -180,7 +180,7 @@ pub fn export_notebook_to_bytes(
|
||||
pattern: PatternColor,
|
||||
) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||
let doc = generate_pdf_document(notebook, strategy, assets, pattern)?;
|
||||
let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?;
|
||||
let bytes = add_alpha_states(fix_text_encoding(doc.save_to_bytes()?, notebook)?, notebook)?;
|
||||
|
||||
// Importierte PDF-Seiten als Vektorgrafik unterlegen
|
||||
let underlays = pdf_underlays(notebook);
|
||||
@@ -206,7 +206,7 @@ pub fn export_notebook_to_bytes(
|
||||
raster.insert(underlay.target_page);
|
||||
}
|
||||
let doc = generate_pdf_document_with(notebook, strategy, assets, pattern, &raster)?;
|
||||
let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?;
|
||||
let bytes = add_alpha_states(fix_text_encoding(doc.save_to_bytes()?, notebook)?, notebook)?;
|
||||
let remaining: Vec<_> = underlays
|
||||
.into_iter()
|
||||
.filter(|u| !raster.contains(&u.target_page))
|
||||
@@ -247,6 +247,77 @@ fn fix_text_encoding(bytes: Vec<u8>, notebook: &Notebook) -> Result<Vec<u8>, Box
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Name des Grafikzustands für halbtransparente Tinte mit Deckkraft `alpha` (auf Prozent
|
||||
/// gerundet); `None` für deckende Farben.
|
||||
fn alpha_state_name(alpha: f32) -> Option<String> {
|
||||
let percent = (alpha.clamp(0.0, 1.0) * 100.0).round() as u32;
|
||||
(percent < 100).then(|| format!("InkAlpha{percent}"))
|
||||
}
|
||||
|
||||
/// printpdf kann keine Grafikzustände mit Deckkraft anlegen: Die Tintenebene verweist mit
|
||||
/// `gs` auf die Namen aus [`alpha_state_name`], die Zustände selbst (`ca`/`CA`) werden hier
|
||||
/// in die Ressourcen jeder Seite eingetragen. Ohne halbtransparente Striche bleibt das PDF
|
||||
/// unverändert.
|
||||
fn add_alpha_states(bytes: Vec<u8>, notebook: &Notebook) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||
use printpdf::lopdf::{Dictionary, Object};
|
||||
|
||||
let mut states: Vec<(String, f32)> = notebook
|
||||
.pages
|
||||
.iter()
|
||||
.flat_map(|p| &p.strokes)
|
||||
.filter(|s| !s.is_object())
|
||||
.filter_map(|s| alpha_state_name(s.color[3]).map(|name| (name, s.color[3])))
|
||||
.collect();
|
||||
if states.is_empty() {
|
||||
return Ok(bytes);
|
||||
}
|
||||
states.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
states.dedup_by(|a, b| a.0 == b.0);
|
||||
|
||||
let mut doc = printpdf::lopdf::Document::load_mem(&bytes)?;
|
||||
let page_ids: Vec<_> = doc.get_pages().into_values().collect();
|
||||
for page_id in page_ids {
|
||||
let resources = dict_object_id(&mut doc, page_id, b"Resources")?;
|
||||
let ext_g_state = dict_object_id(&mut doc, resources, b"ExtGState")?;
|
||||
let dict = doc.get_object_mut(ext_g_state)?.as_dict_mut()?;
|
||||
for (name, alpha) in &states {
|
||||
let alpha = (alpha.clamp(0.0, 1.0) * 100.0).round() / 100.0;
|
||||
let mut state = Dictionary::new();
|
||||
state.set("Type", Object::Name(b"ExtGState".to_vec()));
|
||||
state.set("ca", Object::Real(alpha));
|
||||
state.set("CA", Object::Real(alpha));
|
||||
dict.set(name.as_bytes().to_vec(), Object::Dictionary(state));
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
doc.save_to(&mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Kennung des Wörterbuchs unter `key` im Objekt `owner`; ein eingebettetes oder fehlendes
|
||||
/// Wörterbuch wird dafür zu einem eigenen Objekt.
|
||||
fn dict_object_id(
|
||||
doc: &mut printpdf::lopdf::Document,
|
||||
owner: printpdf::lopdf::ObjectId,
|
||||
key: &[u8],
|
||||
) -> Result<printpdf::lopdf::ObjectId, Box<dyn Error>> {
|
||||
use printpdf::lopdf::{Dictionary, Object};
|
||||
|
||||
let entry = doc.get_object(owner)?.as_dict()?.get(key).ok().cloned();
|
||||
if let Some(Object::Reference(id)) = entry {
|
||||
return Ok(id);
|
||||
}
|
||||
let dict = match entry {
|
||||
Some(Object::Dictionary(dict)) => dict,
|
||||
_ => Dictionary::new(),
|
||||
};
|
||||
let id = doc.add_object(dict);
|
||||
doc.get_object_mut(owner)?
|
||||
.as_dict_mut()?
|
||||
.set(key.to_vec(), Object::Reference(id));
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// UTF-8-Text als PDF-Textstring in UTF-16BE mit BOM; `None` bei reinem ASCII oder
|
||||
/// ungültigem UTF-8.
|
||||
fn utf16be_text(raw: &[u8]) -> Option<Vec<u8>> {
|
||||
@@ -374,8 +445,10 @@ fn render_background_layer(
|
||||
) {
|
||||
let background = page.background;
|
||||
let (Mm(page_width_mm), Mm(page_height_mm)) = pdf_page_mm(page);
|
||||
// Exportiert wird immer auf weißem Papier
|
||||
let [r, g, b, _] = pattern.color(crate::background::PaperTheme::White);
|
||||
// Exportiert wird immer auf weißem Papier; die Musterfarbe ist linear, PDF erwartet sRGB
|
||||
let [r, g, b, _] = pattern
|
||||
.color(crate::background::PaperTheme::White)
|
||||
.map(crate::background::linear_to_srgb);
|
||||
let grid_color = Color::Rgb(Rgb::new(r, g, b, None));
|
||||
layer.set_outline_color(grid_color.clone());
|
||||
layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt)
|
||||
@@ -468,6 +541,17 @@ fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy:
|
||||
if stroke.is_object() {
|
||||
continue;
|
||||
}
|
||||
// Halbtransparente Tinte (Textmarker): Deckkraft über einen Grafikzustand, den
|
||||
// `add_alpha_states` anlegt. Jeder Strich ist ein einziger Pfad und deckt daher jede
|
||||
// Stelle nur einmal, auch wo er sich selbst überlappt.
|
||||
let alpha_state = alpha_state_name(stroke.color[3]);
|
||||
if let Some(name) = &alpha_state {
|
||||
layer.save_graphics_state();
|
||||
layer.add_operation(printpdf::lopdf::content::Operation::new(
|
||||
"gs",
|
||||
vec![printpdf::lopdf::Object::Name(name.as_bytes().to_vec())],
|
||||
));
|
||||
}
|
||||
if let Some(ref shape) = stroke.shape {
|
||||
render_recognized_shape(layer, shape, stroke.color);
|
||||
} else {
|
||||
@@ -480,6 +564,9 @@ fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy:
|
||||
}
|
||||
}
|
||||
}
|
||||
if alpha_state.is_some() {
|
||||
layer.restore_graphics_state();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -773,6 +860,10 @@ fn render_recognized_shape(
|
||||
let stroke_color = Color::Rgb(Rgb::new(color[0], color[1], color[2], None));
|
||||
layer.set_outline_color(stroke_color.clone());
|
||||
layer.set_outline_thickness(1.5); // ca. 0.5 mm Standard-Liniendicke
|
||||
// Wie auf dem Bildschirm: spitze Ecken und stumpfe Enden (der Grafikzustand kann noch
|
||||
// runde Verbindungen von einem vorherigen Freihandstrich enthalten)
|
||||
layer.set_line_cap_style(printpdf::LineCapStyle::Butt);
|
||||
layer.set_line_join_style(printpdf::LineJoinStyle::Miter);
|
||||
|
||||
match shape {
|
||||
RecognizedShape::Line { start, end } => {
|
||||
@@ -966,17 +1057,19 @@ fn render_recognized_shape(
|
||||
(*y_axis_end, *x_axis_end)
|
||||
};
|
||||
|
||||
let (ox, oy) = page_to_pdf_mm(origin.x, origin.y);
|
||||
for end in [y_line_end, x_line_end] {
|
||||
let (ex, ey) = page_to_pdf_mm(end.x, end.y);
|
||||
layer.add_line(Line {
|
||||
points: vec![
|
||||
(Point::new(Mm(ox), Mm(oy)), false),
|
||||
(Point::new(Mm(ex), Mm(ey)), false),
|
||||
],
|
||||
is_closed: false,
|
||||
});
|
||||
}
|
||||
// Ein einziger Linienzug, damit die Ecke am Ursprung als Linienverbindung
|
||||
// geschlossen gezeichnet wird statt als zwei stumpfe Enden
|
||||
let points = [y_line_end, *origin, x_line_end]
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let (px, py) = page_to_pdf_mm(p.x, p.y);
|
||||
(Point::new(Mm(px), Mm(py)), false)
|
||||
})
|
||||
.collect();
|
||||
layer.add_line(Line {
|
||||
points,
|
||||
is_closed: false,
|
||||
});
|
||||
|
||||
// Pfeilspitzen als gefüllte Dreiecke
|
||||
if *with_arrows {
|
||||
|
||||
+36
-7
@@ -1924,16 +1924,21 @@ pub fn shape_to_mesh(
|
||||
(*y_axis_end, *x_axis_end)
|
||||
};
|
||||
|
||||
// Beide Achsen beginnen um die halbe Linienbreite hinter dem Ursprung, damit die
|
||||
// äußere Ecke geschlossen ist (sonst bleibt dort eine quadratische Kerbe)
|
||||
let y_start = *origin - y_dir * half_w;
|
||||
let x_start = *origin - x_dir * half_w;
|
||||
|
||||
// 1. Y-Achse (vom Ursprung zur Spitze)
|
||||
let y_norm = Vec2::new(-y_dir.y, y_dir.x) * half_w;
|
||||
vertices.push(InkVertex::new(
|
||||
origin.x + y_norm.x,
|
||||
origin.y + y_norm.y,
|
||||
y_start.x + y_norm.x,
|
||||
y_start.y + y_norm.y,
|
||||
color,
|
||||
));
|
||||
vertices.push(InkVertex::new(
|
||||
origin.x - y_norm.x,
|
||||
origin.y - y_norm.y,
|
||||
y_start.x - y_norm.x,
|
||||
y_start.y - y_norm.y,
|
||||
color,
|
||||
));
|
||||
vertices.push(InkVertex::new(
|
||||
@@ -1950,15 +1955,15 @@ pub fn shape_to_mesh(
|
||||
// Degenerierte Dreiecke für den Übergang zur X-Achse
|
||||
let x_norm = Vec2::new(-x_dir.y, x_dir.x) * half_w;
|
||||
let last_v = *vertices.last().unwrap();
|
||||
let next_v = InkVertex::new(origin.x + x_norm.x, origin.y + x_norm.y, color);
|
||||
let next_v = InkVertex::new(x_start.x + x_norm.x, x_start.y + x_norm.y, color);
|
||||
vertices.push(last_v);
|
||||
vertices.push(next_v);
|
||||
|
||||
// 2. X-Achse
|
||||
vertices.push(next_v);
|
||||
vertices.push(InkVertex::new(
|
||||
origin.x - x_norm.x,
|
||||
origin.y - x_norm.y,
|
||||
x_start.x - x_norm.x,
|
||||
x_start.y - x_norm.y,
|
||||
color,
|
||||
));
|
||||
vertices.push(InkVertex::new(
|
||||
@@ -2195,6 +2200,30 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_coordinate_system_corner_is_closed() {
|
||||
// Die Achsen treffen sich ohne Kerbe: Die äußere Ecke des Ursprungs ist ein Eckpunkt
|
||||
// der Achsenbänder (Issue #15)
|
||||
let origin = Vec2::new(300.0, 900.0);
|
||||
let shape = RecognizedShape::CoordinateSystem {
|
||||
origin,
|
||||
x_axis_end: Vec2::new(900.0, 900.0),
|
||||
y_axis_end: Vec2::new(300.0, 300.0),
|
||||
with_arrows: true,
|
||||
};
|
||||
let width = 30.0;
|
||||
let (_, vertices) = shape_to_mesh(&shape, width, [0.0, 0.0, 0.0, 1.0]);
|
||||
let corner = origin + Vec2::new(-width / 2.0, width / 2.0);
|
||||
let nearest = vertices
|
||||
.iter()
|
||||
.map(|v| (Vec2::from(v.position) - corner).length())
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
assert!(
|
||||
nearest < 1e-3,
|
||||
"äußere Ecke fehlt, nächster Punkt {nearest}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_triangle() {
|
||||
let mut points = Vec::new();
|
||||
|
||||
+3
-2
@@ -151,7 +151,7 @@ impl ImageLayer {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
depth_stencil: Some(crate::canvas::passive_stencil()),
|
||||
multisample: wgpu::MultisampleState {
|
||||
count: sample_count,
|
||||
..Default::default()
|
||||
@@ -363,7 +363,8 @@ impl ImageLayer {
|
||||
mip_level_count: levels.len() as u32,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8UnormSrgb,
|
||||
// sRGB-Werte ohne Umrechnung: gezeichnet wird in sRGB (siehe canvas::render_format)
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod filter;
|
||||
pub mod geometry;
|
||||
pub mod history;
|
||||
pub mod image_layer;
|
||||
pub mod library;
|
||||
pub mod page_export;
|
||||
pub mod pdf_doc;
|
||||
pub mod pdf_merge;
|
||||
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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_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);
|
||||
}
|
||||
}
|
||||
+254
-29
@@ -24,6 +24,7 @@ use ink_notes::document::{Notebook, PageBackground};
|
||||
use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy};
|
||||
use ink_notes::geometry::{self, RecognizedShape};
|
||||
use ink_notes::image_layer::TextureSource;
|
||||
use ink_notes::library::Library;
|
||||
use ink_notes::pdf_doc::PdfRenderService;
|
||||
use ink_notes::platform::{self, DisplayBackend};
|
||||
use ink_notes::selection::{
|
||||
@@ -34,6 +35,36 @@ use ink_notes::storage::{self, NotebookManifest};
|
||||
use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
|
||||
use ink_notes::updater::UpdaterController;
|
||||
|
||||
/// 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.
|
||||
/// 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) {
|
||||
@@ -581,6 +612,7 @@ fn selection_release(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn insert_text_on_page(
|
||||
content: &str,
|
||||
at: Option<Vec2>,
|
||||
canvas: &mut CanvasState,
|
||||
notebook: &mut Notebook,
|
||||
manifest: &mut NotebookManifest,
|
||||
@@ -588,10 +620,13 @@ fn insert_text_on_page(
|
||||
selection_state: &mut SelectionState,
|
||||
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(
|
||||
notebook.current_page().id,
|
||||
Vec2::new((center.x - 600.0).max(150.0), center.y),
|
||||
position,
|
||||
notebook.current_page().size.x,
|
||||
ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT,
|
||||
ui_state.text_font,
|
||||
@@ -794,9 +829,11 @@ fn visible_page_center(canvas: &CanvasState) -> Vec2 {
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
fn paste(
|
||||
at: Option<Vec2>,
|
||||
clipboard: &mut ClipboardBridge,
|
||||
storage_dir: &Path,
|
||||
canvas: &mut CanvasState,
|
||||
@@ -820,7 +857,7 @@ fn paste(
|
||||
image.bytes.into_owned(),
|
||||
) {
|
||||
Ok(imported) => {
|
||||
let center = visible_page_center(canvas);
|
||||
let center = at.unwrap_or_else(|| visible_page_center(canvas));
|
||||
insert_image_on_page(
|
||||
imported,
|
||||
center,
|
||||
@@ -844,6 +881,7 @@ fn paste(
|
||||
{
|
||||
insert_text_on_page(
|
||||
&text,
|
||||
at,
|
||||
canvas,
|
||||
notebook,
|
||||
manifest,
|
||||
@@ -874,17 +912,20 @@ fn paste(
|
||||
}
|
||||
}
|
||||
let mut next_id = notebook.next_stroke_id;
|
||||
let new_ids = selection_state.paste_from_clipboard(notebook.current_page_mut(), &mut || {
|
||||
let id = next_id;
|
||||
next_id += 1;
|
||||
id
|
||||
});
|
||||
let new_ids =
|
||||
selection_state.paste_from_clipboard(notebook.current_page_mut(), at, &mut || {
|
||||
let id = next_id;
|
||||
next_id += 1;
|
||||
id
|
||||
});
|
||||
notebook.next_stroke_id = next_id;
|
||||
if !new_ids.is_empty() {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
manifest.next_stroke_id = notebook.next_stroke_id;
|
||||
autosave.mark_dirty(notebook, manifest);
|
||||
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 +1058,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
"[Storage] Erstelle neues Notizbuch in: {}",
|
||||
storage_dir.display()
|
||||
);
|
||||
(
|
||||
Notebook::new(),
|
||||
NotebookManifest::new(&initial_id, &initial_title),
|
||||
)
|
||||
let notebook = Notebook::new();
|
||||
let mut manifest = 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
|
||||
@@ -1058,14 +1106,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
ui_state.pattern_color = ui_settings.pattern_color;
|
||||
ui_state.pressure_curve = ui_settings.pressure_curve;
|
||||
ui_state.continuous_scroll = ui_settings.continuous_scroll;
|
||||
ui_state.pen_presets = ui_settings.pen_presets;
|
||||
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.document_title = manifest.title.clone();
|
||||
ui_state.available_notebooks = available_notebooks;
|
||||
ui_state.library = Library::load(&storage_base_dir);
|
||||
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);
|
||||
}
|
||||
let mut selection_state = SelectionState::new();
|
||||
// Zuletzt gelöschte Seite (Notizbuch-Kennung, Index, Seite) zum Wiederherstellen in dieser
|
||||
// Sitzung
|
||||
let mut last_deleted_page: Option<(String, usize, ink_notes::document::Page)> = None;
|
||||
let mut clipboard = ClipboardBridge::new();
|
||||
// PDF-Seiten werden im Hintergrund gerendert und wecken danach den Event-Loop
|
||||
let mut pdf_renderer = {
|
||||
@@ -1117,6 +1176,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut is_pinch_gesturing = false;
|
||||
let mut is_touch_drawing = 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);
|
||||
|
||||
// Stylus / Digitizer Zustand (Octotablet / Wayland Tablet Protocol)
|
||||
@@ -1551,6 +1612,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)
|
||||
if let Some(at) = egui_repaint_at {
|
||||
if Instant::now() >= at {
|
||||
@@ -1568,7 +1650,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|| is_stylus_in_proximity
|
||||
{
|
||||
target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8)));
|
||||
} else if let Some(at) = egui_repaint_at {
|
||||
} else if let Some(at) = [egui_repaint_at, long_press.deadline()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.min()
|
||||
{
|
||||
target.set_control_flow(ControlFlow::WaitUntil(at));
|
||||
} else {
|
||||
target.set_control_flow(ControlFlow::Wait);
|
||||
@@ -1777,7 +1863,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Einfügen: Strg + V
|
||||
Key::Character(c) if c.eq_ignore_ascii_case("v") && ctrl => {
|
||||
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,
|
||||
);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
@@ -2366,6 +2452,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
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 {
|
||||
// Multi-Touch Geste: Sobald 2+ Finger aufliegen, Inking sofort abbrechen und zoomen/pannen
|
||||
if is_touch_drawing {
|
||||
@@ -2432,6 +2531,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
TouchPhase::Moved => {
|
||||
active_touches.insert(touch.id, (tx, ty));
|
||||
long_press.moved(touch.id, (tx, ty), window.scale_factor() as f32);
|
||||
|
||||
if active_touches.len() >= 2 {
|
||||
let pts: Vec<(f32, f32)> = active_touches.values().copied().take(2).collect();
|
||||
@@ -2500,6 +2600,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
TouchPhase::Ended | TouchPhase::Cancelled => {
|
||||
active_touches.remove(&touch.id);
|
||||
long_press.cancel();
|
||||
|
||||
if active_touches.is_empty() {
|
||||
is_pinch_gesturing = false;
|
||||
@@ -2609,6 +2710,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// 3. Toolbar & Widgets rendern
|
||||
updater.update();
|
||||
ui_state.page_view = Some(page_view(&canvas, window.scale_factor() as f32));
|
||||
ui_state.restorable_page = last_deleted_page
|
||||
.as_ref()
|
||||
.filter(|(id, _, _)| *id == manifest.id)
|
||||
.map(|(_, index, _)| index + 1);
|
||||
let actions = ui::render_ui(&egui_ctx, &mut ui_state, ¬ebook, autosave.status(), &updater);
|
||||
canvas.ruler = ui_state.ruler;
|
||||
|
||||
@@ -2699,9 +2804,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
UiAction::PasteSelected => {
|
||||
UiAction::PasteSelected | UiAction::PasteAt(_) => {
|
||||
let at = match action {
|
||||
UiAction::PasteAt(at) => Some(at),
|
||||
_ => None,
|
||||
};
|
||||
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,
|
||||
);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
@@ -2825,6 +2934,42 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
);
|
||||
ui_state.set_export_message(format!("Seite {} eingefügt", index + 1));
|
||||
}
|
||||
UiAction::DeleteCurrentPage => {
|
||||
let index = notebook.current_page_index;
|
||||
if let Some(page) = notebook.remove_current_page() {
|
||||
selection_state.clear();
|
||||
let curr = notebook.current_page_index;
|
||||
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr);
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
manifest.sync_from_notebook(¬ebook);
|
||||
autosave.mark_manifest_dirty(&manifest);
|
||||
last_deleted_page = Some((manifest.id.clone(), index, page));
|
||||
ui_state.set_export_message(format!(
|
||||
"Seite {} gelöscht – im Menü ☰ wiederherstellbar",
|
||||
index + 1
|
||||
));
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
UiAction::RestoreDeletedPage => {
|
||||
if let Some((id, index, page)) = last_deleted_page.take() {
|
||||
if id == manifest.id {
|
||||
selection_state.clear();
|
||||
let index = notebook.restore_page(index, page);
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
manifest.sync_from_notebook(¬ebook);
|
||||
// Die Seitendatei neu schreiben (sie kann inzwischen fehlen)
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_manifest_dirty(&manifest);
|
||||
ui_state.set_export_message(format!("Seite {} wiederhergestellt", index + 1));
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
} else {
|
||||
last_deleted_page = Some((id, index, page));
|
||||
}
|
||||
}
|
||||
}
|
||||
UiAction::LoadAllPages => {
|
||||
// Für die Vorschaubilder der Seitenauswahl; Fehler werden wie beim
|
||||
// Blättern behandelt (Ersatzseite, Warnung beim Öffnen der Seite)
|
||||
@@ -2863,8 +3008,60 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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::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 => {
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::OpenNotebook(id) => {
|
||||
@@ -2900,7 +3097,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
selection_state.clear();
|
||||
ui_state.current_notebook_id = manifest.id.clone();
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||
ui_state.is_document_menu_open = false;
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
@@ -2917,6 +3114,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
UiAction::ImportPdf(path) => {
|
||||
// Neben das gerade geöffnete Notizbuch
|
||||
let folder = ui_state.library.folder_of(&manifest.id).to_string();
|
||||
let title = path
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
@@ -2926,6 +3125,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.and_then(|bytes| storage::create_notebook_from_pdf(&storage_base_dir, &title, bytes));
|
||||
match result {
|
||||
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();
|
||||
_notebook_lock = storage::NotebookLock::acquire(&new_dir).ok();
|
||||
autosave.set_read_only(_notebook_lock.is_none());
|
||||
@@ -2936,7 +3139,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
selection_state.clear();
|
||||
ui_state.current_notebook_id = manifest.id.clone();
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||
canvas.reset_view();
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
@@ -2953,10 +3156,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::CreateNotebook(title) => {
|
||||
UiAction::CreateNotebook { title, folder } => {
|
||||
let _ = autosave.flush();
|
||||
match storage::create_new_notebook(&storage_base_dir, &title) {
|
||||
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();
|
||||
autosave.set_read_only(_notebook_lock.is_none());
|
||||
autosave.switch_notebook_dir(new_dir.clone());
|
||||
@@ -2966,7 +3173,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
selection_state.clear();
|
||||
ui_state.current_notebook_id = manifest.id.clone();
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||
ui_state.is_document_menu_open = false;
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
@@ -2985,7 +3192,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
match storage::rename_notebook(&target_dir, &mut manifest, &new_title) {
|
||||
Ok(()) => {
|
||||
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);
|
||||
autosave.mark_manifest_dirty(&manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
@@ -3002,7 +3209,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Ok(content) = std::fs::read_to_string(&manifest_path) {
|
||||
if let Ok(mut other_manifest) = serde_json::from_str::<NotebookManifest>(&content) {
|
||||
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));
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -3027,7 +3234,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
match storage::delete_notebook(&target_dir, &storage_base_dir) {
|
||||
Ok(trash_path) => {
|
||||
ui_state.last_deleted_notebook = Some((trash_path, deleted_title));
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||
if is_current {
|
||||
if let Some(first) = ui_state
|
||||
.available_notebooks
|
||||
@@ -3065,7 +3272,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
selection_state.clear();
|
||||
ui_state.current_notebook_id = manifest.id.clone();
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
ui_state.set_export_message("Standard-Notizbuch erstellt".to_string());
|
||||
@@ -3085,7 +3292,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some((trash_path, _title)) = ui_state.last_deleted_notebook.take() {
|
||||
match storage::restore_notebook_from_trash(&storage_base_dir, &trash_path) {
|
||||
Ok(_restored_dir) => {
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||
ui_state.set_export_message("Notizbuch wiederhergestellt".to_string());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -3100,7 +3307,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let target_dir = storage_base_dir.join(&id);
|
||||
match storage::restore_manifest_from_backup(&target_dir) {
|
||||
Ok(()) => {
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||
ui_state.set_export_message("Manifest aus Backup wiederhergestellt".to_string());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -3216,6 +3423,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
|
||||
// Stifte und eigene Farben: geänderte Farbe oder Stärke gehört zum gewählten
|
||||
// Stift bzw. zur gewählten Farbe. Gespeichert wird erst nach dem Loslassen
|
||||
// (nicht bei jedem Schritt eines Schiebereglers oder Farbwählers).
|
||||
ui_state.sync_tool_colors();
|
||||
if !egui_ctx.is_using_pointer() {
|
||||
let mut updated = ui_settings.clone();
|
||||
updated.pen_presets = ui_state.pen_presets;
|
||||
updated.active_pen = ui_state.active_pen;
|
||||
updated.highlighter_colors = ui_state.highlighter_colors;
|
||||
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
|
||||
let finish_text = ui_state.text_edit.as_ref().is_some_and(|edit| {
|
||||
edit.finish
|
||||
|
||||
@@ -233,6 +233,58 @@ mod tests {
|
||||
assert!(clear.height() as f32 / clear.width() as f32 - PAGE_HEIGHT / PAGE_WIDTH < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_highlighter_is_translucent_in_export() {
|
||||
// Schwarze Linie, darüber ein gelber Textmarker, der hin und zurück läuft
|
||||
let mut page = page_with_line();
|
||||
page.strokes[0].color = [0.0, 0.0, 0.0, 1.0];
|
||||
for v in &mut page.strokes[0].vertices {
|
||||
v.color = [0.0, 0.0, 0.0, 1.0];
|
||||
}
|
||||
let color = [1.0, 0.95, 0.1, 0.35];
|
||||
let samples: Vec<StylusSample> = (0..=40)
|
||||
.map(|i| {
|
||||
let x = if i <= 20 { i } else { 40 - i } as f32;
|
||||
StylusSample {
|
||||
position: glam::Vec2::new(500.0 + x * 20.0, 1000.0 + (i as f32 * 0.5)),
|
||||
pressure: 0.6,
|
||||
timestamp: i as f64 * 0.01,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let vertices = crate::storage::reconstruct_stroke_vertices(
|
||||
&samples,
|
||||
color,
|
||||
60.0,
|
||||
Default::default(),
|
||||
true,
|
||||
);
|
||||
let mut stroke = Stroke::new(8, samples, vertices, color);
|
||||
stroke.width = 60.0;
|
||||
stroke.tool = crate::document::StrokeTool::Highlighter;
|
||||
page.strokes.push(stroke);
|
||||
|
||||
let png = decode(
|
||||
&page_png(&page, None, ImageBackground::Paper, PatternColor::default()).unwrap(),
|
||||
);
|
||||
let px_per_unit = png.width() as f32 / PAGE_WIDTH;
|
||||
let at = |x: f32, y: f32| {
|
||||
png.get_pixel((x * px_per_unit) as u32, (y * px_per_unit) as u32)
|
||||
.0
|
||||
};
|
||||
// Die schwarze Linie bleibt unter dem Textmarker sichtbar
|
||||
let line = at(700.0, 1000.0);
|
||||
assert!(line[0] < 110 && line[1] < 110, "Linie verdeckt: {line:?}");
|
||||
// Neben der Linie: einmal gelb getönt, auch wo der Strich sich selbst überlappt
|
||||
let tint = at(700.0, 1020.0);
|
||||
assert!(tint[2] < 200 && tint[0] > 200, "kein Textmarker: {tint:?}");
|
||||
let expected_blue = 255.0 * (1.0 - 0.35) + 0.1 * 255.0 * 0.35;
|
||||
assert!(
|
||||
(tint[2] as f32 - expected_blue).abs() < 25.0,
|
||||
"Textmarker mehrfach gedeckt: {tint:?}, erwartet Blau ≈ {expected_blue}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_export_uses_pattern_color() {
|
||||
// Kariertes Blatt: das kräftige Muster ist im Export dunkler als das helle
|
||||
|
||||
+20
-2
@@ -203,13 +203,19 @@ impl SelectionState {
|
||||
pub fn paste_from_clipboard(
|
||||
&mut self,
|
||||
page: &mut Page,
|
||||
at: Option<Vec2>,
|
||||
next_id_gen: &mut impl FnMut() -> u64,
|
||||
) -> Vec<u64> {
|
||||
if self.clipboard.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let offset = Vec2::new(30.0, 30.0); // ca. 3 mm Versatz nach rechts-unten
|
||||
// Mit `at` (lange gedrückt → Einfügen) landet die Mitte der Objekte dort, sonst
|
||||
// ca. 3 mm nach rechts unten versetzt
|
||||
let offset = match (at, Page::selection_aabb(&self.clipboard)) {
|
||||
(Some(at), Some(aabb)) => at - (aabb.min + aabb.max) / 2.0,
|
||||
_ => Vec2::new(30.0, 30.0),
|
||||
};
|
||||
let mut new_strokes = Vec::with_capacity(self.clipboard.len());
|
||||
let mut new_ids = Vec::with_capacity(self.clipboard.len());
|
||||
|
||||
@@ -457,7 +463,7 @@ mod tests {
|
||||
|
||||
// Paste
|
||||
let mut next_id = 100;
|
||||
let new_ids = sel.paste_from_clipboard(&mut page, &mut || {
|
||||
let new_ids = sel.paste_from_clipboard(&mut page, None, &mut || {
|
||||
next_id += 1;
|
||||
next_id
|
||||
});
|
||||
@@ -465,6 +471,18 @@ mod tests {
|
||||
assert_eq!(page.strokes.len(), 3);
|
||||
assert_eq!(sel.selected_ids, new_ids);
|
||||
|
||||
// An einer Stelle einfügen: die Mitte der Kopie liegt dort
|
||||
let target = Vec2::new(1000.0, 1500.0);
|
||||
let placed = sel.paste_from_clipboard(&mut page, Some(target), &mut || {
|
||||
next_id += 1;
|
||||
next_id
|
||||
});
|
||||
let aabb = sel.get_selection_aabb(&page).unwrap();
|
||||
assert_eq!(sel.selected_ids, placed);
|
||||
assert!(((aabb.min + aabb.max) / 2.0 - target).length() < 0.01);
|
||||
sel.delete_selected(&mut page);
|
||||
sel.selected_ids = new_ids.clone();
|
||||
|
||||
// Delete
|
||||
let deleted = sel.delete_selected(&mut page);
|
||||
assert_eq!(deleted.len(), 1);
|
||||
|
||||
+153
@@ -73,6 +73,83 @@ impl Default for PressureCurve {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stift-Vorlage: Farbe und Stärke eines der Stifte in der Werkzeugleiste. Ändert man Farbe
|
||||
/// oder Stärke, während ein Stift gewählt ist, merkt sich die Vorlage die neuen Werte.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PenPreset {
|
||||
pub color: [f32; 4],
|
||||
pub width_mm: f32,
|
||||
}
|
||||
|
||||
impl PenPreset {
|
||||
pub const WIDTH_RANGE: std::ops::RangeInclusive<f32> = 0.15..=5.0;
|
||||
|
||||
/// Begrenzt Werte aus einer (evtl. von Hand bearbeiteten) Einstellungsdatei; Stifte sind
|
||||
/// immer deckend.
|
||||
pub fn sanitized(&self, fallback: PenPreset) -> Self {
|
||||
let color = sanitized_color(self.color, [0.0, 0.0, 0.0, 1.0]);
|
||||
let width_mm = if self.width_mm.is_finite() {
|
||||
self.width_mm
|
||||
.clamp(*Self::WIDTH_RANGE.start(), *Self::WIDTH_RANGE.end())
|
||||
} else {
|
||||
fallback.width_mm
|
||||
};
|
||||
Self { color, width_mm }
|
||||
}
|
||||
}
|
||||
|
||||
/// Anzahl der Stifte in der Werkzeugleiste.
|
||||
pub const PEN_PRESET_COUNT: usize = 4;
|
||||
|
||||
/// Standardstifte: Schwarz, Blau und Rot fein, Grün etwas kräftiger.
|
||||
pub const DEFAULT_PEN_PRESETS: [PenPreset; PEN_PRESET_COUNT] = [
|
||||
PenPreset {
|
||||
color: [0.12, 0.12, 0.14, 1.0],
|
||||
width_mm: 0.5,
|
||||
},
|
||||
PenPreset {
|
||||
color: [0.15, 0.35, 0.85, 1.0],
|
||||
width_mm: 0.5,
|
||||
},
|
||||
PenPreset {
|
||||
color: [0.85, 0.20, 0.20, 1.0],
|
||||
width_mm: 0.5,
|
||||
},
|
||||
PenPreset {
|
||||
color: [0.18, 0.65, 0.35, 1.0],
|
||||
width_mm: 0.8,
|
||||
},
|
||||
];
|
||||
|
||||
/// Farben des Textmarkers in der Werkzeugleiste (halbtransparent).
|
||||
pub const DEFAULT_HIGHLIGHTER_COLORS: [[f32; 4]; 4] = [
|
||||
[1.0, 0.95, 0.1, 0.35],
|
||||
[0.2, 0.9, 0.3, 0.35],
|
||||
[0.25, 0.7, 1.0, 0.35],
|
||||
[1.0, 0.3, 0.65, 0.35],
|
||||
];
|
||||
|
||||
/// Farben des Text-Werkzeugs in der Werkzeugleiste.
|
||||
pub const DEFAULT_TEXT_COLORS: [[f32; 4]; 5] = [
|
||||
[0.12, 0.12, 0.14, 1.0],
|
||||
[0.15, 0.35, 0.85, 1.0],
|
||||
[0.85, 0.20, 0.20, 1.0],
|
||||
[0.18, 0.65, 0.35, 1.0],
|
||||
[0.45, 0.45, 0.48, 1.0],
|
||||
];
|
||||
|
||||
/// Begrenzt eine Farbe aus der Einstellungsdatei auf 0–1; die Deckkraft kommt immer aus
|
||||
/// `fallback` (Stift und Text deckend, Textmarker halbtransparent).
|
||||
fn sanitized_color(color: [f32; 4], fallback: [f32; 4]) -> [f32; 4] {
|
||||
let mut out = fallback;
|
||||
if color.iter().all(|c| c.is_finite()) {
|
||||
for (o, c) in out.iter_mut().zip(color).take(3) {
|
||||
*o = c.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Benutzereinstellungen der Oberfläche.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -88,6 +165,14 @@ pub struct UiSettings {
|
||||
pub pressure_curve: PressureCurve,
|
||||
/// Seiten durchgehend untereinander anzeigen (sonst einzeln blättern)
|
||||
pub continuous_scroll: bool,
|
||||
/// Stifte der Werkzeugleiste und der gewählte davon
|
||||
pub pen_presets: [PenPreset; PEN_PRESET_COUNT],
|
||||
pub active_pen: usize,
|
||||
/// Eigene Farben von Textmarker und Text-Werkzeug und die gewählte davon
|
||||
pub highlighter_colors: [[f32; 4]; 4],
|
||||
pub active_highlighter: usize,
|
||||
pub text_colors: [[f32; 4]; 5],
|
||||
pub active_text_color: usize,
|
||||
}
|
||||
|
||||
impl Default for UiSettings {
|
||||
@@ -99,6 +184,12 @@ impl Default for UiSettings {
|
||||
pattern_color: PatternColor::default(),
|
||||
pressure_curve: PressureCurve::default(),
|
||||
continuous_scroll: true,
|
||||
pen_presets: DEFAULT_PEN_PRESETS,
|
||||
active_pen: 0,
|
||||
highlighter_colors: DEFAULT_HIGHLIGHTER_COLORS,
|
||||
active_highlighter: 0,
|
||||
text_colors: DEFAULT_TEXT_COLORS,
|
||||
active_text_color: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,6 +217,26 @@ impl UiSettings {
|
||||
Err(_) => Self::default(),
|
||||
};
|
||||
settings.pressure_curve = settings.pressure_curve.sanitized();
|
||||
for (preset, fallback) in settings.pen_presets.iter_mut().zip(DEFAULT_PEN_PRESETS) {
|
||||
*preset = preset.sanitized(fallback);
|
||||
}
|
||||
settings.active_pen = settings.active_pen.min(PEN_PRESET_COUNT - 1);
|
||||
for (color, fallback) in settings
|
||||
.highlighter_colors
|
||||
.iter_mut()
|
||||
.zip(DEFAULT_HIGHLIGHTER_COLORS)
|
||||
{
|
||||
*color = sanitized_color(*color, fallback);
|
||||
}
|
||||
settings.active_highlighter = settings
|
||||
.active_highlighter
|
||||
.min(settings.highlighter_colors.len() - 1);
|
||||
for (color, fallback) in settings.text_colors.iter_mut().zip(DEFAULT_TEXT_COLORS) {
|
||||
*color = sanitized_color(*color, fallback);
|
||||
}
|
||||
settings.active_text_color = settings
|
||||
.active_text_color
|
||||
.min(settings.text_colors.len() - 1);
|
||||
settings
|
||||
}
|
||||
|
||||
@@ -157,6 +268,15 @@ mod tests {
|
||||
threshold: 0.1,
|
||||
},
|
||||
continuous_scroll: false,
|
||||
pen_presets: [PenPreset {
|
||||
color: [0.5, 0.2, 0.7, 1.0],
|
||||
width_mm: 1.5,
|
||||
}; PEN_PRESET_COUNT],
|
||||
active_pen: 2,
|
||||
highlighter_colors: [[0.9, 0.5, 0.1, 0.35]; 4],
|
||||
active_highlighter: 3,
|
||||
text_colors: [[0.3, 0.1, 0.6, 1.0]; 5],
|
||||
active_text_color: 4,
|
||||
};
|
||||
settings.save_to(&path).unwrap();
|
||||
assert_eq!(UiSettings::load_from(&path), settings);
|
||||
@@ -193,6 +313,39 @@ mod tests {
|
||||
let curve = UiSettings::load_from(&path).pressure_curve;
|
||||
assert_eq!(curve.exponent, 3.0);
|
||||
assert_eq!(curve.threshold, 0.0);
|
||||
|
||||
// Ohne Stifte: die Standardstifte; unsinnige Stifte werden begrenzt
|
||||
assert_eq!(settings.pen_presets, DEFAULT_PEN_PRESETS);
|
||||
assert_eq!(settings.active_pen, 0);
|
||||
std::fs::write(
|
||||
&path,
|
||||
br#"{"active_pen": 9, "pen_presets": [
|
||||
{"color": [2.0, -1.0, 0.5, 0.2], "width_mm": 99.0},
|
||||
{"color": [0.1, 0.2, 0.3, 1.0], "width_mm": 0.0},
|
||||
{"color": [0.1, 0.2, 0.3, 1.0], "width_mm": 1.0},
|
||||
{"color": [0.1, 0.2, 0.3, 1.0], "width_mm": 1.0}
|
||||
]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let settings = UiSettings::load_from(&path);
|
||||
assert_eq!(settings.active_pen, PEN_PRESET_COUNT - 1);
|
||||
assert_eq!(settings.pen_presets[0].color, [1.0, 0.0, 0.5, 1.0]);
|
||||
assert_eq!(settings.pen_presets[0].width_mm, 5.0);
|
||||
assert_eq!(settings.pen_presets[1].width_mm, 0.15);
|
||||
|
||||
// Eigene Farben: Textmarker bleibt halbtransparent, Text deckend
|
||||
std::fs::write(
|
||||
&path,
|
||||
br#"{"highlighter_colors": [[0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0],
|
||||
[0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0]], "active_highlighter": 7,
|
||||
"text_colors": [[5.0, 0.2, 0.3, 0.1], [0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0],
|
||||
[0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0]]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let settings = UiSettings::load_from(&path);
|
||||
assert_eq!(settings.highlighter_colors[0], [0.1, 0.2, 0.3, 0.35]);
|
||||
assert_eq!(settings.active_highlighter, 3);
|
||||
assert_eq!(settings.text_colors[0], [1.0, 0.2, 0.3, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+117
-10
@@ -30,25 +30,50 @@ fn vs_main(in: VertexInput) -> VertexOutput {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Linearer Farbwert (Papier, Muster) -> sRGB: Gezeichnet wird ohne sRGB-Umrechnung der GPU
|
||||
// (siehe `render_format` in canvas.rs), gemischt wird also wie im PDF in sRGB
|
||||
fn linear_to_srgb(c: vec3<f32>) -> vec3<f32> {
|
||||
let low = c * 12.92;
|
||||
let high = 1.055 * pow(c, vec3<f32>(1.0 / 2.4)) - vec3<f32>(0.055);
|
||||
return select(high, low, c <= vec3<f32>(0.0031308));
|
||||
}
|
||||
|
||||
// Papier: Farbe linear
|
||||
@fragment
|
||||
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
return in.color;
|
||||
return vec4<f32>(linear_to_srgb(clamp(in.color.rgb, vec3<f32>(0.0), vec3<f32>(1.0))), in.color.a);
|
||||
}
|
||||
|
||||
// Striche: bei dunklem Papier wird die HSL-Helligkeit dunkler Farben gespiegelt
|
||||
// (L -> 1 - L, Farbton und Sättigung bleiben). Entspricht `background::adapt_ink_color`.
|
||||
// Striche: Tintenfarben sind sRGB-Werte wie im Export und in der Oberfläche. Bei dunklem
|
||||
// Papier wird die HSL-Helligkeit dunkler Farben gespiegelt (L -> 1 - L, Farbton und
|
||||
// Sättigung bleiben). Entspricht `background::adapt_ink_color`.
|
||||
@fragment
|
||||
fn fs_ink(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
if (camera.ink_params.x < 0.5) {
|
||||
return in.color;
|
||||
var rgb = clamp(in.color.rgb, vec3<f32>(0.0), vec3<f32>(1.0));
|
||||
if (camera.ink_params.x >= 0.5) {
|
||||
let max_c = max(max(rgb.r, rgb.g), rgb.b);
|
||||
let min_c = min(min(rgb.r, rgb.g), rgb.b);
|
||||
let lift = max(1.0 - max_c - min_c, 0.0);
|
||||
rgb = min(rgb + vec3<f32>(lift), vec3<f32>(1.0));
|
||||
}
|
||||
let max_c = max(max(in.color.r, in.color.g), in.color.b);
|
||||
let min_c = min(min(in.color.r, in.color.g), in.color.b);
|
||||
let lift = max(1.0 - max_c - min_c, 0.0);
|
||||
return vec4<f32>(in.color.rgb + vec3<f32>(lift), in.color.a);
|
||||
return vec4<f32>(rgb, in.color.a);
|
||||
}
|
||||
|
||||
// Eingefügte Bilder: texturierte Vierecke unter der Tinte
|
||||
// Bildschirmfüllendes Dreieck ohne Farbe (setzt den Stencil-Puffer zurück)
|
||||
@vertex
|
||||
fn vs_fullscreen(@builtin(vertex_index) index: u32) -> @builtin(position) vec4<f32> {
|
||||
let x = f32((index << 1u) & 2u) * 2.0 - 1.0;
|
||||
let y = f32(index & 2u) * 2.0 - 1.0;
|
||||
return vec4<f32>(x, y, 0.0, 1.0);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_none() -> @location(0) vec4<f32> {
|
||||
return vec4<f32>(0.0);
|
||||
}
|
||||
|
||||
// Eingefügte Bilder: texturierte Vierecke unter der Tinte (Texturen enthalten sRGB-Werte
|
||||
// und werden unverändert übernommen)
|
||||
struct ImageVertexInput {
|
||||
@location(0) position: vec2<f32>,
|
||||
@location(1) uv: vec2<f32>,
|
||||
@@ -77,3 +102,85 @@ fn vs_image(in: ImageVertexInput) -> ImageVertexOutput {
|
||||
fn fs_image(in: ImageVertexOutput) -> @location(0) vec4<f32> {
|
||||
return textureSample(image_texture, image_sampler, in.uv);
|
||||
}
|
||||
|
||||
// Karo, Linien und Punkte: pro Pixel aus der Seitenkoordinate berechnet, damit die Abstände
|
||||
// in jedem Zoom gleichmäßig bleiben. Linien und Punkte sind mindestens ein Pixel breit und
|
||||
// kantengeglättet; werden die Abstände sehr klein, blendet das Muster aus statt zu flimmern.
|
||||
struct PatternInput {
|
||||
@location(0) position: vec2<f32>,
|
||||
@location(1) color: vec4<f32>,
|
||||
// Art (1 Karo, 2 Linien, 3 Punkte), Abstand, Linienstärke, Punktgröße
|
||||
@location(2) params: vec4<f32>,
|
||||
// Seitenbreite, Seitenhöhe, Rand oben/unten bei Linien
|
||||
@location(3) bounds: vec4<f32>,
|
||||
};
|
||||
|
||||
struct PatternOutput {
|
||||
@builtin(position) clip_position: vec4<f32>,
|
||||
@location(0) page: vec2<f32>,
|
||||
@location(1) color: vec4<f32>,
|
||||
@location(2) params: vec4<f32>,
|
||||
@location(3) bounds: vec4<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_pattern(in: PatternInput) -> PatternOutput {
|
||||
var out: PatternOutput;
|
||||
let ndc = in.position * camera.transform_scale + camera.transform_offset;
|
||||
out.clip_position = vec4<f32>(ndc, 0.0, 1.0);
|
||||
out.page = in.position;
|
||||
out.color = in.color;
|
||||
out.params = in.params;
|
||||
out.bounds = in.bounds;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Deckung einer Linie bei `line` für die Koordinate `c` (`upp`: Seiteneinheiten je Pixel).
|
||||
fn line_coverage(c: f32, line: f32, half_width: f32, upp: f32) -> f32 {
|
||||
let half_px = max(half_width / upp, 0.5);
|
||||
let d = abs(c - line) / upp;
|
||||
return clamp(half_px + 0.5 - d, 0.0, 1.0);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_pattern(in: PatternOutput) -> @location(0) vec4<f32> {
|
||||
let p = in.page;
|
||||
let kind = in.params.x;
|
||||
let spacing = in.params.y;
|
||||
let upp = max(max(fwidth(p.x), fwidth(p.y)), 1e-4);
|
||||
let size = in.bounds.xy;
|
||||
// Unter 3 Pixel Abstand ausblenden (ganz herausgezoomt)
|
||||
let fade = clamp((spacing / upp - 3.0) / 3.0, 0.0, 1.0);
|
||||
|
||||
var coverage = 0.0;
|
||||
if (kind < 1.5) {
|
||||
// Karo: senkrechte und waagerechte Linien bei k · Abstand, nicht am Blattrand
|
||||
let k = round(p / spacing);
|
||||
let line = k * spacing;
|
||||
if (k.x >= 1.0 && line.x < size.x - 0.01) {
|
||||
coverage = max(coverage, line_coverage(p.x, line.x, in.params.z * 0.5, upp));
|
||||
}
|
||||
if (k.y >= 1.0 && line.y < size.y - 0.01) {
|
||||
coverage = max(coverage, line_coverage(p.y, line.y, in.params.z * 0.5, upp));
|
||||
}
|
||||
} else if (kind < 2.5) {
|
||||
// Linien: waagerecht ab dem oberen Rand bis zum unteren Rand
|
||||
let margin = in.bounds.z;
|
||||
let k = round((p.y - margin) / spacing);
|
||||
let line = margin + k * spacing;
|
||||
if (k >= 0.0 && line <= size.y - margin + 0.01) {
|
||||
coverage = line_coverage(p.y, line, in.params.z * 0.5, upp);
|
||||
}
|
||||
} else {
|
||||
// Punkte: an den Kreuzungen des Karos
|
||||
let k = round(p / spacing);
|
||||
let point = k * spacing;
|
||||
if (all(k >= vec2<f32>(1.0)) && point.x < size.x - 0.01 && point.y < size.y - 0.01) {
|
||||
let radius_px = max(in.params.w * 0.5 / upp, 0.6);
|
||||
let d = length(p - point) / upp;
|
||||
coverage = clamp(radius_px + 0.5 - d, 0.0, 1.0);
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
/// Rastert ein Textfeld mit `px_per_unit` Pixeln je Seiteneinheit (begrenzt auf eine
|
||||
/// handhabbare Texturgröße). `color` ist linear wie die Tinte; die Pixel sind sRGB.
|
||||
/// handhabbare Texturgröße). `color` ist sRGB wie die Tinte, ebenso die Pixel.
|
||||
pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage {
|
||||
let layout = text.layout();
|
||||
let size = glam::Vec2::new(text.wrap_width.max(text.size), layout.height());
|
||||
@@ -383,7 +383,7 @@ pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage
|
||||
}
|
||||
}
|
||||
|
||||
let rgb = [0, 1, 2].map(|i| egui::ecolor::gamma_u8_from_linear_f32(color[i]));
|
||||
let rgb = [0, 1, 2].map(|i| (color[i].clamp(0.0, 1.0) * 255.0).round() as u8);
|
||||
let alpha = color[3].clamp(0.0, 1.0);
|
||||
let mut pixels = Vec::with_capacity(coverage.len() * 4);
|
||||
for c in coverage {
|
||||
@@ -476,8 +476,8 @@ mod tests {
|
||||
assert_eq!(image.height as f32, text.layout().height().ceil());
|
||||
let opaque = image.pixels.chunks(4).filter(|p| p[3] > 200).count();
|
||||
assert!(opaque > 100, "{opaque}");
|
||||
// Farbe ist sRGB-kodiert
|
||||
assert!(image.pixels.chunks(4).all(|p| p[0] > 230 && p[1] < 130));
|
||||
// Die Farbe ist schon sRGB und wird unverändert übernommen
|
||||
assert!(image.pixels.chunks(4).all(|p| p[..3] == [217, 51, 51]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+18
-19
@@ -3,20 +3,18 @@
|
||||
//! Berechnet die affine Transformation zwischen Bildschirmkoordinaten (Window-Pixel),
|
||||
//! virtuellen DIN-A4-Seitenkoordinaten ([0, 2100] x [0, 2970]) und dem GPU-NDC-Raum ([-1, 1]).
|
||||
|
||||
use crate::document::{PageViewport, UNITS_PER_MM};
|
||||
use crate::document::PageViewport;
|
||||
use glam::{Mat4, Vec2, Vec4};
|
||||
|
||||
/// Minimaler und maximaler Zoomfaktor.
|
||||
pub const MIN_ZOOM: f32 = 0.25;
|
||||
pub const MAX_ZOOM: f32 = 10.0;
|
||||
|
||||
/// Abstand zwischen zwei Seiten in der durchgehenden Ansicht (8 mm).
|
||||
pub const PAGE_GAP: f32 = 8.0 * UNITS_PER_MM;
|
||||
/// Höchstens so viele Nachbarseiten werden gleichzeitig dargestellt.
|
||||
pub const MAX_NEIGHBOR_PAGES: usize = 12;
|
||||
|
||||
/// Durchgehende Ansicht: Die Seiten hängen mit [`PAGE_GAP`] Abstand untereinander und sind
|
||||
/// waagerecht zueinander zentriert. Lage jeder Seite (Ecke oben links) in Koordinaten der
|
||||
/// Durchgehende Ansicht: Die Seiten hängen lückenlos untereinander (die Grenze markiert eine
|
||||
/// Linie der Oberfläche) und sind waagerecht zueinander zentriert. Lage jeder Seite (Ecke oben links) in Koordinaten der
|
||||
/// aktuellen Seite.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PageStack {
|
||||
@@ -33,11 +31,11 @@ impl PageStack {
|
||||
let mut origins = vec![Vec2::ZERO; sizes.len()];
|
||||
let x = |i: usize| (width - sizes[i].x) / 2.0;
|
||||
for i in current + 1..sizes.len() {
|
||||
let y = origins[i - 1].y + sizes[i - 1].y + PAGE_GAP;
|
||||
let y = origins[i - 1].y + sizes[i - 1].y;
|
||||
origins[i] = Vec2::new(x(i), y);
|
||||
}
|
||||
for i in (0..current).rev() {
|
||||
let y = origins[i + 1].y - PAGE_GAP - sizes[i].y;
|
||||
let y = origins[i + 1].y - sizes[i].y;
|
||||
origins[i] = Vec2::new(x(i), y);
|
||||
}
|
||||
Self {
|
||||
@@ -55,7 +53,7 @@ impl PageStack {
|
||||
self.sizes.is_empty()
|
||||
}
|
||||
|
||||
/// Seite, auf deren Höhe `y` liegt; `None` im Zwischenraum oder außerhalb.
|
||||
/// Seite, auf deren Höhe `y` liegt (an der Grenze die untere); `None` außerhalb.
|
||||
pub fn page_at(&self, y: f32) -> Option<usize> {
|
||||
// Seiten liegen nach Index sortiert untereinander
|
||||
let i = self.origins.partition_point(|o| o.y <= y).checked_sub(1)?;
|
||||
@@ -312,7 +310,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
const A4: Vec2 = crate::document::A4_SIZE;
|
||||
const PITCH: f32 = A4.y + PAGE_GAP;
|
||||
const PITCH: f32 = A4.y;
|
||||
|
||||
fn a4_stack(count: usize, current: usize) -> PageStack {
|
||||
PageStack::new(vec![A4; count], current)
|
||||
@@ -322,10 +320,11 @@ mod tests {
|
||||
fn test_continuous_layout() {
|
||||
let stack = a4_stack(5, 1);
|
||||
assert_eq!(stack.page_at(100.0), Some(1));
|
||||
assert_eq!(stack.page_at(A4.y + PAGE_GAP / 2.0), None);
|
||||
// Lückenlos: direkt unter dem Seitenende beginnt die nächste Seite
|
||||
assert_eq!(stack.page_at(A4.y - 1.0), Some(1));
|
||||
assert_eq!(stack.page_at(A4.y + 1.0), Some(2));
|
||||
assert_eq!(stack.page_at(PITCH + 5.0), Some(2));
|
||||
assert_eq!(stack.page_at(-5.0), None);
|
||||
assert_eq!(stack.page_at(-PAGE_GAP - 5.0), Some(0));
|
||||
assert_eq!(stack.page_at(-5.0), Some(0));
|
||||
assert_eq!(stack.page_at(-PITCH - 5.0), None, "vor der ersten Seite");
|
||||
|
||||
// Ende von Seite 2 und Anfang von Seite 3 sichtbar (aktuell: Seite 2 von 5)
|
||||
@@ -339,7 +338,7 @@ mod tests {
|
||||
.is_empty());
|
||||
// Hinter der letzten Seite: keine weiteren
|
||||
assert_eq!(
|
||||
a4_stack(5, 3).visible_neighbors(-PITCH, PITCH * 4.0),
|
||||
a4_stack(5, 3).visible_neighbors(-PITCH + 1.0, PITCH * 4.0),
|
||||
vec![2, 4]
|
||||
);
|
||||
// Weit herausgezoomt: auf die nächstgelegenen Seiten begrenzt
|
||||
@@ -352,13 +351,13 @@ mod tests {
|
||||
fn test_stack_of_different_page_sizes() {
|
||||
let slide = Vec2::new(2970.0, 1671.0);
|
||||
let stack = PageStack::new(vec![slide, A4, slide], 1);
|
||||
// Folien waagerecht zur A4-Seite zentriert, mit Abstand darüber und darunter
|
||||
assert_eq!(stack.origins[0], Vec2::new(-435.0, -PAGE_GAP - slide.y));
|
||||
assert_eq!(stack.origins[2], Vec2::new(-435.0, A4.y + PAGE_GAP));
|
||||
assert_eq!(stack.page_at(A4.y + PAGE_GAP + 10.0), Some(2));
|
||||
// Folien waagerecht zur A4-Seite zentriert, direkt darüber und darunter
|
||||
assert_eq!(stack.origins[0], Vec2::new(-435.0, -slide.y));
|
||||
assert_eq!(stack.origins[2], Vec2::new(-435.0, A4.y));
|
||||
assert_eq!(stack.page_at(A4.y + 10.0), Some(2));
|
||||
let (min, max) = stack.bounds();
|
||||
assert_eq!(min, Vec2::new(-435.0, -PAGE_GAP - slide.y));
|
||||
assert_eq!(max, Vec2::new(2535.0, A4.y + PAGE_GAP + slide.y));
|
||||
assert_eq!(min, Vec2::new(-435.0, -slide.y));
|
||||
assert_eq!(max, Vec2::new(2535.0, A4.y + slide.y));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user