Compare commits

..
Author SHA1 Message Date
haraldandClaude Opus 5.5 f9a6f8524d feat: Lange drücken zum Einfügen (Issue #16)
- Ein Finger, der eine halbe Sekunde still auf dem Blatt liegt, öffnet ein
  Kontextmenü mit „Einfügen“ und „Bild aus Datei …“ (wie in Rnote)
- Bild, Text oder kopierte Objekte aus der Zwischenablage landen an der
  gedrückten Stelle (Strg+V weiterhin in der Mitte)
- Meldung, wenn die Zwischenablage leer ist

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:51:39 +02:00
haraldandClaude Opus 5.5 568714d727 Merge origin/main (Banner-Logo) nach v0.15.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:04:02 +02:00
haraldandClaude Opus 5.5 c87f4102d7 chore(release): bump version to 0.15.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 7m47s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:03:39 +02:00
harald 5115e217c2 docs: Banner-Logo hinzugefügt und in README.md eingebunden 2026-10-08 13:37:13 +02:00
16 changed files with 924 additions and 181 deletions
Generated
+1 -1
View File
@@ -1393,7 +1393,7 @@ dependencies = [
[[package]] [[package]]
name = "ink-notes" name = "ink-notes"
version = "0.14.0" version = "0.16.0"
dependencies = [ dependencies = [
"ab_glyph", "ab_glyph",
"arboard", "arboard",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "ink-notes" name = "ink-notes"
version = "0.14.0" version = "0.16.0"
edition = "2021" edition = "2021"
description = "Notizbuch-App für Stift, Touch und Maus unter Linux" description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes" repository = "https://gitea.pansi.eu/harald/ink-notes"
+11 -2
View File
@@ -1,6 +1,13 @@
# ink-notes <p align="center">
<img src="assets/ink-notes-banner.svg" alt="ink-notes Banner" width="100%">
</p>
Eine Notizbuch-App für Linux zum Schreiben mit Stift, Finger oder Maus – geschrieben in **Rust** mit **wgpu**, **winit** und **egui**. <p align="center">
<strong>Eine moderne Notizbuch-App für Linux zum Schreiben mit Stift, Touch und Maus</strong><br>
Geschrieben in <strong>Rust</strong> mit <strong>wgpu</strong>, <strong>winit</strong> und <strong>egui</strong>.
</p>
---
ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen, kann PDFs zum Beschriften importieren, Bilder und Textfelder einfügen und alles wieder als PDF, PNG oder SVG exportieren. Gespeichert wird automatisch im Hintergrund. ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen, kann PDFs zum Beschriften importieren, Bilder und Textfelder einfügen und alles wieder als PDF, PNG oder SVG exportieren. Gespeichert wird automatisch im Hintergrund.
@@ -12,6 +19,7 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
### Schreiben und Zeichnen ### Schreiben und Zeichnen
- **Vier Stifte** in der Werkzeugleiste, jeder mit eigener Farbe und Stärke (0,15–5 mm, mit Druckstärke). Ändert man Farbe oder Stärke, merkt sich der gewählte Stift das – auch über einen Neustart hinweg. **Textmarker** (halbtransparent, 4–8 mm) in vier Farben; innerhalb eines Strichs wird er nicht dunkler, erst ein neuer Strich deckt stärker. - **Vier Stifte** in der Werkzeugleiste, jeder mit eigener Farbe und Stärke (0,15–5 mm, mit Druckstärke). Ändert man Farbe oder Stärke, merkt sich der gewählte Stift das – auch über einen Neustart hinweg. **Textmarker** (halbtransparent, 4–8 mm) in vier Farben; innerhalb eines Strichs wird er nicht dunkler, erst ein neuer Strich deckt stärker.
- **Eigene Farben:** Jede Farbe in der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen – gewählte Farbe erneut antippen, eine Vorgabe oder mit 🎨 einen freien Farbton wählen. Die Leiste merkt sich die Farben. Am Bildschirm erscheinen sie genauso wie im PDF- und Bildexport.
- **Glättung in zwei Stufen:** Während des Schreibens glättet ein 1€-Filter das Zittern, die Linie erscheint sofort. Beim Absetzen wird der Strich nachbearbeitet: Ecken (Knicke über 65°) bleiben erhalten, der Druckverlauf wird geglättet, überflüssige Punkte entfallen, die Enden werden rund. - **Glättung in zwei Stufen:** Während des Schreibens glättet ein 1€-Filter das Zittern, die Linie erscheint sofort. Beim Absetzen wird der Strich nachbearbeitet: Ecken (Knicke über 65°) bleiben erhalten, der Druckverlauf wird geglättet, überflüssige Punkte entfallen, die Enden werden rund.
- **Druckkurve** im ☰-Menü an den eigenen Stift anpassen (weicher/härter, Schwelle gegen Grundrauschen). - **Druckkurve** im ☰-Menü an den eigenen Stift anpassen (weicher/härter, Schwelle gegen Grundrauschen).
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Stiftrückseite und Seitentaste des Stifts radieren ebenfalls. - **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Stiftrückseite und Seitentaste des Stifts radieren ebenfalls.
@@ -31,6 +39,7 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
### Text, Bilder und PDFs ### Text, Bilder und PDFs
- **Text-Werkzeug** (`T`): ins Blatt tippen und schreiben – normale oder Code-Schrift (feste Zeichenbreite, Tab rückt ein), 8–36 pt, Zeilenumbruch am Feldrand. Vorhandenen Text antippen zum Ändern, `Esc` beendet. Textfelder lassen sich wie Bilder verschieben, drehen und skalieren. In anderen Programmen kopierter Text wird mit `Strg + V` zum Textfeld. - **Text-Werkzeug** (`T`): ins Blatt tippen und schreiben – normale oder Code-Schrift (feste Zeichenbreite, Tab rückt ein), 8–36 pt, Zeilenumbruch am Feldrand. Vorhandenen Text antippen zum Ändern, `Esc` beendet. Textfelder lassen sich wie Bilder verschieben, drehen und skalieren. In anderen Programmen kopierter Text wird mit `Strg + V` zum Textfeld.
- **Bilder** (PNG, JPEG, GIF, WebP, BMP) über `Strg + I`, aus der Zwischenablage oder per Ziehen ins Fenster einfügen. - **Bilder** (PNG, JPEG, GIF, WebP, BMP) über `Strg + I`, aus der Zwischenablage oder per Ziehen ins Fenster einfügen.
- **Einfügen per Finger:** Lange (½ s) mit dem Finger aufs Blatt drücken öffnet ein Menü „Einfügen“ – Screenshot, kopierter Text oder kopierte Objekte landen genau an dieser Stelle.
- **PDF importieren** (☰-Menü): Jede PDF-Seite wird eine Notizbuchseite mit der PDF-Seite als Hintergrund. Hochformat-Seiten werden DIN A4, Querformat-Seiten (z. B. Folien) bekommen eine Querformat-Seite ohne leeren Streifen darunter. Beim Hineinzoomen werden PDF-Seiten schärfer nachgerendert. - **PDF importieren** (☰-Menü): Jede PDF-Seite wird eine Notizbuchseite mit der PDF-Seite als Hintergrund. Hochformat-Seiten werden DIN A4, Querformat-Seiten (z. B. Folien) bekommen eine Querformat-Seite ohne leeren Streifen darunter. Beim Hineinzoomen werden PDF-Seiten schärfer nachgerendert.
### Seiten und Ansicht ### Seiten und Ansicht
+178
View File
@@ -0,0 +1,178 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 360" width="1200" height="360">
<defs>
<!-- Haupt-Hintergrund-Verlauf -->
<linearGradient id="bannerBg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0f1420"/>
<stop offset="40%" stop-color="#141a29"/>
<stop offset="100%" stop-color="#090b12"/>
</linearGradient>
<!-- Subtiles Ambient Glow -->
<radialGradient id="ambientGlow" cx="15%" cy="50%" r="45%">
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.22"/>
<stop offset="70%" stop-color="#1d4ed8" stop-opacity="0.05"/>
<stop offset="100%" stop-color="#1d4ed8" stop-opacity="0"/>
</radialGradient>
<radialGradient id="ambientGlowRight" cx="85%" cy="30%" r="40%">
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.10"/>
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
</radialGradient>
<!-- Icon Hintergrund-Verlauf -->
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e2638"/>
<stop offset="50%" stop-color="#141824"/>
<stop offset="100%" stop-color="#0c0e17"/>
</linearGradient>
<!-- Stiftkörper-Verlauf -->
<linearGradient id="penBody" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3b82f6"/>
<stop offset="100%" stop-color="#1d4ed8"/>
</linearGradient>
<!-- Goldene Feder-Spitze -->
<linearGradient id="nibGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#fbbf24"/>
<stop offset="50%" stop-color="#f59e0b"/>
<stop offset="100%" stop-color="#d97706"/>
</linearGradient>
<!-- Tintenspur-Verlauf -->
<linearGradient id="inkGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#60a5fa" stop-opacity="0.95"/>
<stop offset="50%" stop-color="#3b82f6" stop-opacity="0.85"/>
<stop offset="100%" stop-color="#2563eb" stop-opacity="0.4"/>
</linearGradient>
<!-- Text Ink Gradient -->
<linearGradient id="textInkGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#60a5fa"/>
<stop offset="100%" stop-color="#38bdf8"/>
</linearGradient>
<!-- Leucht-Effekt -->
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="8" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
<filter id="iconShadow" x="-10%" y="-10%" width="120%" height="130%">
<feDropShadow dx="0" dy="12" stdDeviation="16" flood-color="#000000" flood-opacity="0.6"/>
</filter>
<!-- Gitter-Muster für Hintergrund -->
<pattern id="gridPattern" width="30" height="30" patternUnits="userSpaceOnUse">
<path d="M 30 0 L 0 0 0 30" fill="none" stroke="#94a3b8" stroke-width="0.8" opacity="0.07"/>
</pattern>
</defs>
<!-- Banner-Rahmen & Hintergrund -->
<rect width="1200" height="360" rx="20" fill="url(#bannerBg)" stroke="#27324b" stroke-width="2"/>
<!-- Hintergrund-Gitter (Notizbuch-Raster) -->
<rect width="1200" height="360" rx="20" fill="url(#gridPattern)"/>
<!-- Subtile Glows -->
<rect width="1200" height="360" rx="20" fill="url(#ambientGlow)"/>
<rect width="1200" height="360" rx="20" fill="url(#ambientGlowRight)"/>
<!-- ==================== LINKER BEREICH: APP-ICON ==================== -->
<g transform="translate(65, 55) scale(0.488)" filter="url(#iconShadow)">
<!-- App-Icon Hintergrund (Squircle) -->
<rect x="24" y="24" width="464" height="464" rx="104" ry="104" fill="url(#bgGrad)" stroke="#384260" stroke-width="4"/>
<!-- DIN-A4 Dokumenten-Gitter im Icon -->
<g opacity="0.14" stroke="#94a3b8" stroke-width="2">
<line x1="80" y1="160" x2="432" y2="160" />
<line x1="80" y1="220" x2="432" y2="220" />
<line x1="80" y1="280" x2="432" y2="280" />
<line x1="80" y1="340" x2="432" y2="340" />
<line x1="80" y1="400" x2="432" y2="400" />
<line x1="140" y1="100" x2="140" y2="430" />
<line x1="200" y1="100" x2="200" y2="430" />
<line x1="260" y1="100" x2="260" y2="430" />
<line x1="320" y1="100" x2="320" y2="430" />
<line x1="380" y1="100" x2="380" y2="430" />
</g>
<!-- Tintenspur -->
<path d="M 110 390 C 180 430, 260 380, 240 310 C 220 240, 280 190, 340 220"
fill="none" stroke="url(#inkGrad)" stroke-width="26" stroke-linecap="round" filter="url(#glow)"/>
<path d="M 110 390 C 180 430, 260 380, 240 310 C 220 240, 280 190, 340 220"
fill="none" stroke="#93c5fd" stroke-width="12" stroke-linecap="round"/>
<!-- Stylus / Füllfederhalter -->
<g transform="translate(240, 110) rotate(45)">
<rect x="-24" y="-140" width="48" height="150" rx="8" fill="url(#penBody)" stroke="#60a5fa" stroke-width="3"/>
<rect x="-22" y="10" width="44" height="26" rx="4" fill="#1e293b" stroke="#cbd5e1" stroke-width="2"/>
<rect x="-25" y="6" width="50" height="6" fill="#f8fafc"/>
<path d="M -20 36 L 20 36 L 12 85 L 0 115 L -12 85 Z" fill="url(#nibGrad)" stroke="#fef08a" stroke-width="2"/>
<line x1="0" y1="46" x2="0" y2="108" stroke="#78350f" stroke-width="3" stroke-linecap="round"/>
<circle cx="0" cy="65" r="4" fill="#78350f"/>
</g>
<!-- Glanzpunkt -->
<circle cx="110" cy="390" r="14" fill="#60a5fa" filter="url(#glow)"/>
<circle cx="110" cy="390" r="8" fill="#ffffff"/>
</g>
<!-- ==================== RECHTER BEREICH: TYPOGRAFIE & BADGES ==================== -->
<g transform="translate(360, 0)">
<!-- Version & Platform Pill -->
<g transform="translate(0, 68)">
<rect width="254" height="30" rx="15" fill="#1e293b" stroke="#334155" stroke-width="1.2"/>
<circle cx="16" cy="15" r="4" fill="#22c55e"/>
<text x="28" y="20" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="600" fill="#cbd5e1" letter-spacing="0.5">
v0.14.0 • LINUX • RUST &amp; WGPU
</text>
</g>
<!-- Haupttitel: ink-notes -->
<text x="0" y="162" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="800" font-size="64" letter-spacing="-1">
<tspan fill="url(#textInkGrad)">ink</tspan><tspan fill="#f8fafc">-notes</tspan>
</text>
<!-- Subtitle / Tagline -->
<text x="0" y="206" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="400" font-size="22" fill="#94a3b8">
Notizbuch- &amp; PDF-Annotations-App für Stift, Touch und Maus
</text>
<!-- Feature Pills / Highlights -->
<g transform="translate(0, 248)">
<!-- Pill 1: Niedrige Latenz -->
<g transform="translate(0, 0)">
<rect width="186" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
⚡ Schnelles wgpu-Inking
</text>
</g>
<!-- Pill 2: Stylus mit Druck -->
<g transform="translate(198, 0)">
<rect width="190" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
🖊️ Druck &amp; Palm Rejection
</text>
</g>
<!-- Pill 3: Formerkennung -->
<g transform="translate(400, 0)">
<rect width="182" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
📐 Formenerkennung &amp; Lineal
</text>
</g>
<!-- Pill 4: 100% lokal -->
<g transform="translate(594, 0)">
<rect width="176" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
🔒 100% lokal &amp; atomar
</text>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.2 KiB

+1 -1
View File
@@ -115,7 +115,7 @@ Jeder Strich ist ein eigener Draw-Call mit eigener Nummer im Stencil-Puffer: Ein
- **Textfelder** werden mit `ab_glyph` gerastert, in einer Auflösung, die dem Zoom folgt (in Vierteln einer Zweierpotenz, höchstens 8 Pixel je Einheit). Der Texturschlüssel enthält einen Hash von Inhalt, Schrift und Farbe; jede Änderung erzeugt eine neue Textur. - **Textfelder** werden mit `ab_glyph` gerastert, in einer Auflösung, die dem Zoom folgt (in Vierteln einer Zweierpotenz, höchstens 8 Pixel je Einheit). Der Texturschlüssel enthält einen Hash von Inhalt, Schrift und Farbe; jede Änderung erzeugt eine neue Textur.
### 4.2 Papier- und Musterfarben ### 4.2 Papier- und Musterfarben
Papierfarbe (`PaperTheme`) und Musterfarbe (`PatternColor`) stehen in `background.rs`. Alle Farben sind linear; die Fensterfläche ist sRGB, die Umrechnung übernimmt die GPU. Auf dunklem Papier kann der Shader dunkle Tinte aufhellen (`ink_params` in `CameraUniforms`), gespeichert werden immer die Originalfarben. Papierfarbe (`PaperTheme`) und Musterfarbe (`PatternColor`) stehen in `background.rs`; sie sind linear angegeben und werden im Shader nach sRGB umgerechnet. Tintenfarben (Striche, Text) sind sRGB-Werte – dieselben Zahlen landen im PDF und in den Farbpunkten der Oberfläche. Gezeichnet wird ohne sRGB-Umrechnung der GPU (`render_format`), Texturen sind `Rgba8Unorm`: So wird halbtransparente Tinte wie im PDF in sRGB gemischt und sieht am Bildschirm genauso aus wie im Export. Auf dunklem Papier kann der Shader dunkle Tinte aufhellen (`ink_params` in `CameraUniforms`), gespeichert werden immer die Originalfarben.
--- ---
+2
View File
@@ -3,6 +3,8 @@
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04). Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
### Highlights: ### Highlights:
- **Eigene Farben**: Jede Farbe der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen – erneut antippen, Vorgabe oder mit 🎨 frei wählen; die Leiste merkt sich die Farben. Farben am Bildschirm wie im PDF-/Bildexport (Schwarz ist schwarz, Textmarker mischt wie im PDF); Karos und Linien im PDF so hell wie am Bildschirm
- **Beta-Feedback II**: Vier Stifte mit eigener Farbe und Stärke, die sich Änderungen merken; Textmarker wird innerhalb eines Strichs nicht dunkler und ist jetzt auch im PDF-/PNG-/SVG-Export durchsichtig; Seiten lückenlos untereinander mit Trennlinie; neue Seite per ➕ in der Werkzeugleiste; Seite löschen im ☰-Menü (wiederherstellbar); Koordinatensystem ohne Loch am Ursprung
- **Fehlerbehebungen**: Umbenennen und Löschen in der Notizbuchverwaltung reagieren wieder; Karos, Linien und Punkte werden pro Pixel berechnet und liegen in jedem Zoom gleichmäßig (mindestens ein Pixel breit, kantengeglättet) - **Fehlerbehebungen**: Umbenennen und Löschen in der Notizbuchverwaltung reagieren wieder; Karos, Linien und Punkte werden pro Pixel berechnet und liegen in jedem Zoom gleichmäßig (mindestens ein Pixel breit, kantengeglättet)
- **Strichqualität**: Kleine Schleifen wie in „o“, „a“ oder „e“ bleiben rund (der fertige Strich entsteht aus den ungefilterten Stiftpunkten); Strichkanten werden kantengeglättet (4× Multisampling), runde Strichenden sind feiner unterteilt - **Strichqualität**: Kleine Schleifen wie in „o“, „a“ oder „e“ bleiben rund (der fertige Strich entsteht aus den ungefilterten Stiftpunkten); Strichkanten werden kantengeglättet (4× Multisampling), runde Strichenden sind feiner unterteilt
- **Musterfarbe**: Karos, Linien und Punkte in hell, mittel, kräftig, blau oder grün (☰-Menü, gilt auch im Export); Punkte des Punktrasters am Bildschirm so groß wie im Export (0,4 mm). README und Dokumentation überarbeitet - **Musterfarbe**: Karos, Linien und Punkte in hell, mittel, kräftig, blau oder grün (☰-Menü, gilt auch im Export); Punkte des Punktrasters am Bildschirm so groß wie im Export (0,4 mm). README und Dokumentation überarbeitet
+20 -2
View File
@@ -71,7 +71,8 @@ pub const LINED_SPACING: f32 = 75.0;
/// Papierfarbe der Anzeige. Betrifft nur die Darstellung am Bildschirm; gespeicherte /// Papierfarbe der Anzeige. Betrifft nur die Darstellung am Bildschirm; gespeicherte
/// Strichfarben und der PDF-Export bleiben unverändert (weißes Papier). /// Strichfarben und der PDF-Export bleiben unverändert (weißes Papier).
/// ///
/// Alle Farben sind linear (die Oberfläche ist sRGB, die Umrechnung macht die GPU). /// Papier- und Musterfarben sind linear (der Shader rechnet sie in sRGB um); Tintenfarben
/// dagegen sind sRGB-Werte wie im Export.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum PaperTheme { pub enum PaperTheme {
@@ -138,7 +139,24 @@ impl PaperTheme {
PaperTheme::DarkGray => (0.004, 0.004, 0.005), PaperTheme::DarkGray => (0.004, 0.004, 0.005),
PaperTheme::Black => (0.03, 0.03, 0.035), PaperTheme::Black => (0.03, 0.03, 0.035),
}; };
wgpu::Color { r, g, b, a: 1.0 } // Linear angegeben; gezeichnet wird ohne sRGB-Umrechnung
let srgb = |c: f64| f64::from(linear_to_srgb(c as f32));
wgpu::Color {
r: srgb(r),
g: srgb(g),
b: srgb(b),
a: 1.0,
}
}
}
/// Linearer Farbanteil (Papier, Muster) als sRGB-Wert (Tinte, Export).
pub fn linear_to_srgb(c: f32) -> f32 {
let c = c.clamp(0.0, 1.0);
if c <= 0.003_130_8 {
c * 12.92
} else {
1.055 * c.powf(1.0 / 2.4) - 0.055
} }
} }
+30 -14
View File
@@ -316,13 +316,20 @@ impl CanvasState {
) )
.await?; .await?;
// 4. Geringe Bildverzögerung (Mailbox, sonst Fifo) & Frame-Latency 1 // 4. Geringe Bildverzögerung (Mailbox, sonst Fifo) & Frame-Latency 1.
// Gezeichnet wird ohne sRGB-Umrechnung (siehe `render_format`); bietet die Fläche
// nur sRGB-Formate an, wird eine Ansicht ohne sRGB verwendet.
let surface_format = surface_caps let surface_format = surface_caps
.formats .formats
.iter() .iter()
.copied() .copied()
.find(|f| f.is_srgb()) .find(|f| !f.is_srgb())
.unwrap_or(surface_caps.formats[0]); .unwrap_or(surface_caps.formats[0]);
let view_formats = if surface_format.is_srgb() {
vec![surface_format.remove_srgb_suffix()]
} else {
vec![]
};
let config = wgpu::SurfaceConfiguration { let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT, usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
@@ -331,14 +338,14 @@ impl CanvasState {
height: size.height.max(1), height: size.height.max(1),
present_mode, present_mode,
alpha_mode: surface_caps.alpha_modes[0], alpha_mode: surface_caps.alpha_modes[0],
view_formats: vec![], view_formats,
desired_maximum_frame_latency: 1, // Minimiert Eingabelatenz auf 1 Frame desired_maximum_frame_latency: 1, // Minimiert Eingabelatenz auf 1 Frame
}; };
surface.configure(&device, &config); surface.configure(&device, &config);
// Kantenglättung der Zeichenfläche (4× Multisampling), falls das Format es erlaubt // Kantenglättung der Zeichenfläche (4× Multisampling), falls das Format es erlaubt
let msaa_samples = if adapter let msaa_samples = if adapter
.get_texture_format_features(config.format) .get_texture_format_features(render_format(&config))
.flags .flags
.sample_count_supported(MSAA_SAMPLES) .sample_count_supported(MSAA_SAMPLES)
{ {
@@ -414,7 +421,7 @@ impl CanvasState {
module: &shader, module: &shader,
entry_point: "fs_ink", entry_point: "fs_ink",
targets: &[Some(wgpu::ColorTargetState { targets: &[Some(wgpu::ColorTargetState {
format: config.format, format: render_format(&config),
blend: Some(wgpu::BlendState::ALPHA_BLENDING), blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL, write_mask: wgpu::ColorWrites::ALL,
})], })],
@@ -450,7 +457,7 @@ impl CanvasState {
module: &shader, module: &shader,
entry_point: "fs_none", entry_point: "fs_none",
targets: &[Some(wgpu::ColorTargetState { targets: &[Some(wgpu::ColorTargetState {
format: config.format, format: render_format(&config),
blend: None, blend: None,
write_mask: wgpu::ColorWrites::empty(), write_mask: wgpu::ColorWrites::empty(),
})], })],
@@ -477,7 +484,7 @@ impl CanvasState {
module: &shader, module: &shader,
entry_point: "fs_main", entry_point: "fs_main",
targets: &[Some(wgpu::ColorTargetState { targets: &[Some(wgpu::ColorTargetState {
format: config.format, format: render_format(&config),
blend: Some(wgpu::BlendState::ALPHA_BLENDING), blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL, write_mask: wgpu::ColorWrites::ALL,
})], })],
@@ -537,7 +544,7 @@ impl CanvasState {
module: &shader, module: &shader,
entry_point: "fs_pattern", entry_point: "fs_pattern",
targets: &[Some(wgpu::ColorTargetState { targets: &[Some(wgpu::ColorTargetState {
format: config.format, format: render_format(&config),
blend: Some(wgpu::BlendState::ALPHA_BLENDING), blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL, write_mask: wgpu::ColorWrites::ALL,
})], })],
@@ -570,12 +577,12 @@ impl CanvasState {
&device, &device,
&shader, &shader,
&bind_group_layout, &bind_group_layout,
config.format, render_format(&config),
msaa_samples, msaa_samples,
); );
// 8. egui Renderer für das schwebende UI initialisieren // 8. egui Renderer für das schwebende UI initialisieren
let egui_renderer = egui_wgpu::Renderer::new(&device, config.format, None, 1); let egui_renderer = egui_wgpu::Renderer::new(&device, render_format(&config), None, 1);
Ok(Self { Ok(Self {
surface, surface,
@@ -1340,9 +1347,10 @@ impl CanvasState {
} }
let output = self.surface.get_current_texture()?; let output = self.surface.get_current_texture()?;
let view = output let view = output.texture.create_view(&wgpu::TextureViewDescriptor {
.texture format: Some(render_format(&self.config)),
.create_view(&wgpu::TextureViewDescriptor::default()); ..Default::default()
});
let mut encoder = self let mut encoder = self
.device .device
@@ -1509,6 +1517,14 @@ impl CanvasState {
} }
} }
/// Format, in das gezeichnet wird: ohne sRGB-Umrechnung. Die Farben werden also in sRGB
/// gemischt – wie im PDF-Export, damit halbtransparente Tinte (Textmarker) am Bildschirm
/// genauso aussieht. Tintenfarben sind sRGB-Werte, Papier- und Musterfarben rechnet der
/// Shader aus linearen Werten um.
fn render_format(config: &wgpu::SurfaceConfiguration) -> wgpu::TextureFormat {
config.format.remove_srgb_suffix()
}
/// Multisampling der Zeichenfläche gegen gezackte Strichkanten. /// Multisampling der Zeichenfläche gegen gezackte Strichkanten.
const MSAA_SAMPLES: u32 = 4; const MSAA_SAMPLES: u32 = 4;
@@ -1530,7 +1546,7 @@ fn create_msaa_view(
mip_level_count: 1, mip_level_count: 1,
sample_count: samples, sample_count: samples,
dimension: wgpu::TextureDimension::D2, dimension: wgpu::TextureDimension::D2,
format: config.format, format: render_format(config),
usage: wgpu::TextureUsages::RENDER_ATTACHMENT, usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[], view_formats: &[],
}) })
+4 -2
View File
@@ -445,8 +445,10 @@ fn render_background_layer(
) { ) {
let background = page.background; let background = page.background;
let (Mm(page_width_mm), Mm(page_height_mm)) = pdf_page_mm(page); let (Mm(page_width_mm), Mm(page_height_mm)) = pdf_page_mm(page);
// Exportiert wird immer auf weißem Papier // Exportiert wird immer auf weißem Papier; die Musterfarbe ist linear, PDF erwartet sRGB
let [r, g, b, _] = pattern.color(crate::background::PaperTheme::White); let [r, g, b, _] = pattern
.color(crate::background::PaperTheme::White)
.map(crate::background::linear_to_srgb);
let grid_color = Color::Rgb(Rgb::new(r, g, b, None)); let grid_color = Color::Rgb(Rgb::new(r, g, b, None));
layer.set_outline_color(grid_color.clone()); layer.set_outline_color(grid_color.clone());
layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt) layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt)
+2 -1
View File
@@ -363,7 +363,8 @@ impl ImageLayer {
mip_level_count: levels.len() as u32, mip_level_count: levels.len() as u32,
sample_count: 1, sample_count: 1,
dimension: wgpu::TextureDimension::D2, dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb, // sRGB-Werte ohne Umrechnung: gezeichnet wird in sRGB (siehe canvas::render_format)
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[], view_formats: &[],
}); });
+90 -25
View File
@@ -581,6 +581,7 @@ fn selection_release(
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn insert_text_on_page( fn insert_text_on_page(
content: &str, content: &str,
at: Option<Vec2>,
canvas: &mut CanvasState, canvas: &mut CanvasState,
notebook: &mut Notebook, notebook: &mut Notebook,
manifest: &mut NotebookManifest, manifest: &mut NotebookManifest,
@@ -588,10 +589,13 @@ fn insert_text_on_page(
selection_state: &mut SelectionState, selection_state: &mut SelectionState,
ui_state: &mut UiState, ui_state: &mut UiState,
) { ) {
let center = visible_page_center(canvas); let position = at.unwrap_or_else(|| {
let center = visible_page_center(canvas);
Vec2::new((center.x - 600.0).max(150.0), center.y)
});
let mut edit = ink_notes::text::TextEditing::new_at( let mut edit = ink_notes::text::TextEditing::new_at(
notebook.current_page().id, notebook.current_page().id,
Vec2::new((center.x - 600.0).max(150.0), center.y), position,
notebook.current_page().size.x, notebook.current_page().size.x,
ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT, ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT,
ui_state.text_font, ui_state.text_font,
@@ -794,9 +798,11 @@ fn visible_page_center(canvas: &CanvasState) -> Vec2 {
} }
/// Strg+V bzw. „Einfügen“: ein neu kopiertes Systembild hat Vorrang, sonst werden die intern /// Strg+V bzw. „Einfügen“: ein neu kopiertes Systembild hat Vorrang, sonst werden die intern
/// kopierten Objekte eingefügt. Gibt die Meldung für die Statuszeile zurück. /// kopierten Objekte eingefügt. `at`: Stelle auf der Seite (lange gedrückt → Einfügen), sonst
/// die Mitte des sichtbaren Bereichs.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn paste( fn paste(
at: Option<Vec2>,
clipboard: &mut ClipboardBridge, clipboard: &mut ClipboardBridge,
storage_dir: &Path, storage_dir: &Path,
canvas: &mut CanvasState, canvas: &mut CanvasState,
@@ -820,7 +826,7 @@ fn paste(
image.bytes.into_owned(), image.bytes.into_owned(),
) { ) {
Ok(imported) => { Ok(imported) => {
let center = visible_page_center(canvas); let center = at.unwrap_or_else(|| visible_page_center(canvas));
insert_image_on_page( insert_image_on_page(
imported, imported,
center, center,
@@ -844,6 +850,7 @@ fn paste(
{ {
insert_text_on_page( insert_text_on_page(
&text, &text,
at,
canvas, canvas,
notebook, notebook,
manifest, manifest,
@@ -874,17 +881,20 @@ fn paste(
} }
} }
let mut next_id = notebook.next_stroke_id; let mut next_id = notebook.next_stroke_id;
let new_ids = selection_state.paste_from_clipboard(notebook.current_page_mut(), &mut || { let new_ids =
let id = next_id; selection_state.paste_from_clipboard(notebook.current_page_mut(), at, &mut || {
next_id += 1; let id = next_id;
id next_id += 1;
}); id
});
notebook.next_stroke_id = next_id; notebook.next_stroke_id = next_id;
if !new_ids.is_empty() { if !new_ids.is_empty() {
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.next_stroke_id = notebook.next_stroke_id; manifest.next_stroke_id = notebook.next_stroke_id;
autosave.mark_dirty(notebook, manifest); autosave.mark_dirty(notebook, manifest);
ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len())); ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len()));
} else {
ui_state.set_export_message("Die Zwischenablage ist leer".to_string());
} }
} }
@@ -1060,6 +1070,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
ui_state.continuous_scroll = ui_settings.continuous_scroll; ui_state.continuous_scroll = ui_settings.continuous_scroll;
ui_state.pen_presets = ui_settings.pen_presets; ui_state.pen_presets = ui_settings.pen_presets;
ui_state.select_pen(ui_settings.active_pen); ui_state.select_pen(ui_settings.active_pen);
ui_state.highlighter_colors = ui_settings.highlighter_colors;
ui_state.select_highlighter_color(ui_settings.active_highlighter);
ui_state.text_colors = ui_settings.text_colors;
ui_state.active_text_color = ui_settings.active_text_color;
ui_state.text_color = ui_settings.text_colors[ui_settings.active_text_color];
ui_state.current_notebook_id = manifest.id.clone(); ui_state.current_notebook_id = manifest.id.clone();
ui_state.document_title = manifest.title.clone(); ui_state.document_title = manifest.title.clone();
ui_state.available_notebooks = available_notebooks; ui_state.available_notebooks = available_notebooks;
@@ -1071,8 +1086,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Zuletzt gelöschte Seite (Notizbuch-Kennung, Index, Seite) zum Wiederherstellen in dieser // Zuletzt gelöschte Seite (Notizbuch-Kennung, Index, Seite) zum Wiederherstellen in dieser
// Sitzung // Sitzung
let mut last_deleted_page: Option<(String, usize, ink_notes::document::Page)> = None; let mut last_deleted_page: Option<(String, usize, ink_notes::document::Page)> = None;
// Stifte geändert, aber noch nicht gespeichert
let mut pen_presets_dirty = false;
let mut clipboard = ClipboardBridge::new(); let mut clipboard = ClipboardBridge::new();
// PDF-Seiten werden im Hintergrund gerendert und wecken danach den Event-Loop // PDF-Seiten werden im Hintergrund gerendert und wecken danach den Event-Loop
let mut pdf_renderer = { let mut pdf_renderer = {
@@ -1124,6 +1137,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut is_pinch_gesturing = false; let mut is_pinch_gesturing = false;
let mut is_touch_drawing = false; let mut is_touch_drawing = false;
let mut is_touch_panning = false; let mut is_touch_panning = false;
// Langes Drücken mit einem Finger öffnet das Kontextmenü
let mut long_press = ui::LongPress::default();
let mut touch_pan_last_pos = (0.0_f32, 0.0_f32); let mut touch_pan_last_pos = (0.0_f32, 0.0_f32);
// Stylus / Digitizer Zustand (Octotablet / Wayland Tablet Protocol) // Stylus / Digitizer Zustand (Octotablet / Wayland Tablet Protocol)
@@ -1558,6 +1573,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
} }
// Langes Drücken mit dem Finger: Kontextmenü zum Einfügen an dieser Stelle
if let Some(pos) = long_press.fire(Instant::now()) {
if is_touch_drawing {
is_touch_drawing = false;
is_drawing = false;
canvas.cancel_stroke();
}
is_touch_panning = false;
focus_page_at!(pos);
if let Some(page_pt) = canvas.screen_to_page(pos.0, pos.1) {
let scale = window.scale_factor() as f32;
ui_state.close_overlays();
// Leicht versetzt, damit der Finger das Menü nicht verdeckt
ui_state.context_menu = Some(ui::ContextMenu {
screen: egui::pos2(pos.0 / scale + 12.0, pos.1 / scale + 12.0),
page: page_pt,
});
}
window.request_redraw();
}
// Von egui angefordertes, verzögertes Neuzeichnen (Tooltips, Animationen) // Von egui angefordertes, verzögertes Neuzeichnen (Tooltips, Animationen)
if let Some(at) = egui_repaint_at { if let Some(at) = egui_repaint_at {
if Instant::now() >= at { if Instant::now() >= at {
@@ -1575,7 +1611,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|| is_stylus_in_proximity || is_stylus_in_proximity
{ {
target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8))); target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8)));
} else if let Some(at) = egui_repaint_at { } else if let Some(at) = [egui_repaint_at, long_press.deadline()]
.into_iter()
.flatten()
.min()
{
target.set_control_flow(ControlFlow::WaitUntil(at)); target.set_control_flow(ControlFlow::WaitUntil(at));
} else { } else {
target.set_control_flow(ControlFlow::Wait); target.set_control_flow(ControlFlow::Wait);
@@ -1784,7 +1824,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Einfügen: Strg + V // Einfügen: Strg + V
Key::Character(c) if c.eq_ignore_ascii_case("v") && ctrl => { Key::Character(c) if c.eq_ignore_ascii_case("v") && ctrl => {
paste( paste(
&mut clipboard, &storage_dir, &mut canvas, &mut notebook, &mut manifest, None, &mut clipboard, &storage_dir, &mut canvas, &mut notebook, &mut manifest,
&mut autosave, &mut selection_state, &mut ui_state, &mut autosave, &mut selection_state, &mut ui_state,
); );
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
@@ -2373,6 +2413,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
active_touches.insert(touch.id, (tx, ty)); active_touches.insert(touch.id, (tx, ty));
// Langes Drücken: nur beim Verschieben oder Zeichnen mit dem Finger, nicht
// beim Radieren, Auswählen oder Text (dort hält man auch mal still)
if active_touches.len() == 1
&& !is_pinch_gesturing
&& (!ui_state.touch_draw_enabled
|| ui_state.active_tool.is_drawing()
|| ui_state.active_tool == ActiveTool::Shape)
{
long_press.start(touch.id, (tx, ty), Instant::now());
} else {
long_press.cancel();
}
if active_touches.len() >= 2 { if active_touches.len() >= 2 {
// Multi-Touch Geste: Sobald 2+ Finger aufliegen, Inking sofort abbrechen und zoomen/pannen // Multi-Touch Geste: Sobald 2+ Finger aufliegen, Inking sofort abbrechen und zoomen/pannen
if is_touch_drawing { if is_touch_drawing {
@@ -2439,6 +2492,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
TouchPhase::Moved => { TouchPhase::Moved => {
active_touches.insert(touch.id, (tx, ty)); active_touches.insert(touch.id, (tx, ty));
long_press.moved(touch.id, (tx, ty), window.scale_factor() as f32);
if active_touches.len() >= 2 { if active_touches.len() >= 2 {
let pts: Vec<(f32, f32)> = active_touches.values().copied().take(2).collect(); let pts: Vec<(f32, f32)> = active_touches.values().copied().take(2).collect();
@@ -2507,6 +2561,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
TouchPhase::Ended | TouchPhase::Cancelled => { TouchPhase::Ended | TouchPhase::Cancelled => {
active_touches.remove(&touch.id); active_touches.remove(&touch.id);
long_press.cancel();
if active_touches.is_empty() { if active_touches.is_empty() {
is_pinch_gesturing = false; is_pinch_gesturing = false;
@@ -2710,9 +2765,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
window.request_redraw(); window.request_redraw();
} }
} }
UiAction::PasteSelected => { UiAction::PasteSelected | UiAction::PasteAt(_) => {
let at = match action {
UiAction::PasteAt(at) => Some(at),
_ => None,
};
paste( paste(
&mut clipboard, &storage_dir, &mut canvas, &mut notebook, &mut manifest, at, &mut clipboard, &storage_dir, &mut canvas, &mut notebook, &mut manifest,
&mut autosave, &mut selection_state, &mut ui_state, &mut autosave, &mut selection_state, &mut ui_state,
); );
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
@@ -3263,16 +3322,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
} }
// Stifte: geänderte Farbe oder Stärke gehört zum gewählten Stift. Gespeichert // Stifte und eigene Farben: geänderte Farbe oder Stärke gehört zum gewählten
// wird erst nach dem Loslassen (nicht bei jedem Schritt eines Schiebereglers). // Stift bzw. zur gewählten Farbe. Gespeichert wird erst nach dem Loslassen
if ui_state.sync_pen_preset() || ui_settings.active_pen != ui_state.active_pen { // (nicht bei jedem Schritt eines Schiebereglers oder Farbwählers).
pen_presets_dirty = true; ui_state.sync_tool_colors();
ui_settings.active_pen = ui_state.active_pen; if !egui_ctx.is_using_pointer() {
} let mut updated = ui_settings.clone();
if pen_presets_dirty && !egui_ctx.is_using_pointer() { updated.pen_presets = ui_state.pen_presets;
pen_presets_dirty = false; updated.active_pen = ui_state.active_pen;
ui_settings.pen_presets = ui_state.pen_presets; updated.highlighter_colors = ui_state.highlighter_colors;
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); updated.active_highlighter = ui_state.active_highlighter;
updated.text_colors = ui_state.text_colors;
updated.active_text_color = ui_state.active_text_color;
if updated != ui_settings {
ui_settings = updated;
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
}
} }
// Textbearbeitung beenden: Esc, anderes Werkzeug oder andere Seite // Textbearbeitung beenden: Esc, anderes Werkzeug oder andere Seite
+20 -2
View File
@@ -203,13 +203,19 @@ impl SelectionState {
pub fn paste_from_clipboard( pub fn paste_from_clipboard(
&mut self, &mut self,
page: &mut Page, page: &mut Page,
at: Option<Vec2>,
next_id_gen: &mut impl FnMut() -> u64, next_id_gen: &mut impl FnMut() -> u64,
) -> Vec<u64> { ) -> Vec<u64> {
if self.clipboard.is_empty() { if self.clipboard.is_empty() {
return Vec::new(); return Vec::new();
} }
let offset = Vec2::new(30.0, 30.0); // ca. 3 mm Versatz nach rechts-unten // Mit `at` (lange gedrückt → Einfügen) landet die Mitte der Objekte dort, sonst
// ca. 3 mm nach rechts unten versetzt
let offset = match (at, Page::selection_aabb(&self.clipboard)) {
(Some(at), Some(aabb)) => at - (aabb.min + aabb.max) / 2.0,
_ => Vec2::new(30.0, 30.0),
};
let mut new_strokes = Vec::with_capacity(self.clipboard.len()); let mut new_strokes = Vec::with_capacity(self.clipboard.len());
let mut new_ids = Vec::with_capacity(self.clipboard.len()); let mut new_ids = Vec::with_capacity(self.clipboard.len());
@@ -457,7 +463,7 @@ mod tests {
// Paste // Paste
let mut next_id = 100; let mut next_id = 100;
let new_ids = sel.paste_from_clipboard(&mut page, &mut || { let new_ids = sel.paste_from_clipboard(&mut page, None, &mut || {
next_id += 1; next_id += 1;
next_id next_id
}); });
@@ -465,6 +471,18 @@ mod tests {
assert_eq!(page.strokes.len(), 3); assert_eq!(page.strokes.len(), 3);
assert_eq!(sel.selected_ids, new_ids); assert_eq!(sel.selected_ids, new_ids);
// An einer Stelle einfügen: die Mitte der Kopie liegt dort
let target = Vec2::new(1000.0, 1500.0);
let placed = sel.paste_from_clipboard(&mut page, Some(target), &mut || {
next_id += 1;
next_id
});
let aabb = sel.get_selection_aabb(&page).unwrap();
assert_eq!(sel.selected_ids, placed);
assert!(((aabb.min + aabb.max) / 2.0 - target).length() < 0.01);
sel.delete_selected(&mut page);
sel.selected_ids = new_ids.clone();
// Delete // Delete
let deleted = sel.delete_selected(&mut page); let deleted = sel.delete_selected(&mut page);
assert_eq!(deleted.len(), 1); assert_eq!(deleted.len(), 1);
+73 -7
View File
@@ -87,13 +87,7 @@ impl PenPreset {
/// Begrenzt Werte aus einer (evtl. von Hand bearbeiteten) Einstellungsdatei; Stifte sind /// Begrenzt Werte aus einer (evtl. von Hand bearbeiteten) Einstellungsdatei; Stifte sind
/// immer deckend. /// immer deckend.
pub fn sanitized(&self, fallback: PenPreset) -> Self { pub fn sanitized(&self, fallback: PenPreset) -> Self {
let mut color = fallback.color; let color = sanitized_color(self.color, [0.0, 0.0, 0.0, 1.0]);
if self.color.iter().all(|c| c.is_finite()) {
for (out, c) in color.iter_mut().zip(self.color).take(3) {
*out = c.clamp(0.0, 1.0);
}
}
color[3] = 1.0;
let width_mm = if self.width_mm.is_finite() { let width_mm = if self.width_mm.is_finite() {
self.width_mm self.width_mm
.clamp(*Self::WIDTH_RANGE.start(), *Self::WIDTH_RANGE.end()) .clamp(*Self::WIDTH_RANGE.start(), *Self::WIDTH_RANGE.end())
@@ -127,6 +121,35 @@ pub const DEFAULT_PEN_PRESETS: [PenPreset; PEN_PRESET_COUNT] = [
}, },
]; ];
/// Farben des Textmarkers in der Werkzeugleiste (halbtransparent).
pub const DEFAULT_HIGHLIGHTER_COLORS: [[f32; 4]; 4] = [
[1.0, 0.95, 0.1, 0.35],
[0.2, 0.9, 0.3, 0.35],
[0.25, 0.7, 1.0, 0.35],
[1.0, 0.3, 0.65, 0.35],
];
/// Farben des Text-Werkzeugs in der Werkzeugleiste.
pub const DEFAULT_TEXT_COLORS: [[f32; 4]; 5] = [
[0.12, 0.12, 0.14, 1.0],
[0.15, 0.35, 0.85, 1.0],
[0.85, 0.20, 0.20, 1.0],
[0.18, 0.65, 0.35, 1.0],
[0.45, 0.45, 0.48, 1.0],
];
/// Begrenzt eine Farbe aus der Einstellungsdatei auf 0–1; die Deckkraft kommt immer aus
/// `fallback` (Stift und Text deckend, Textmarker halbtransparent).
fn sanitized_color(color: [f32; 4], fallback: [f32; 4]) -> [f32; 4] {
let mut out = fallback;
if color.iter().all(|c| c.is_finite()) {
for (o, c) in out.iter_mut().zip(color).take(3) {
*o = c.clamp(0.0, 1.0);
}
}
out
}
/// Benutzereinstellungen der Oberfläche. /// Benutzereinstellungen der Oberfläche.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
@@ -145,6 +168,11 @@ pub struct UiSettings {
/// Stifte der Werkzeugleiste und der gewählte davon /// Stifte der Werkzeugleiste und der gewählte davon
pub pen_presets: [PenPreset; PEN_PRESET_COUNT], pub pen_presets: [PenPreset; PEN_PRESET_COUNT],
pub active_pen: usize, pub active_pen: usize,
/// Eigene Farben von Textmarker und Text-Werkzeug und die gewählte davon
pub highlighter_colors: [[f32; 4]; 4],
pub active_highlighter: usize,
pub text_colors: [[f32; 4]; 5],
pub active_text_color: usize,
} }
impl Default for UiSettings { impl Default for UiSettings {
@@ -158,6 +186,10 @@ impl Default for UiSettings {
continuous_scroll: true, continuous_scroll: true,
pen_presets: DEFAULT_PEN_PRESETS, pen_presets: DEFAULT_PEN_PRESETS,
active_pen: 0, active_pen: 0,
highlighter_colors: DEFAULT_HIGHLIGHTER_COLORS,
active_highlighter: 0,
text_colors: DEFAULT_TEXT_COLORS,
active_text_color: 0,
} }
} }
} }
@@ -189,6 +221,22 @@ impl UiSettings {
*preset = preset.sanitized(fallback); *preset = preset.sanitized(fallback);
} }
settings.active_pen = settings.active_pen.min(PEN_PRESET_COUNT - 1); settings.active_pen = settings.active_pen.min(PEN_PRESET_COUNT - 1);
for (color, fallback) in settings
.highlighter_colors
.iter_mut()
.zip(DEFAULT_HIGHLIGHTER_COLORS)
{
*color = sanitized_color(*color, fallback);
}
settings.active_highlighter = settings
.active_highlighter
.min(settings.highlighter_colors.len() - 1);
for (color, fallback) in settings.text_colors.iter_mut().zip(DEFAULT_TEXT_COLORS) {
*color = sanitized_color(*color, fallback);
}
settings.active_text_color = settings
.active_text_color
.min(settings.text_colors.len() - 1);
settings settings
} }
@@ -225,6 +273,10 @@ mod tests {
width_mm: 1.5, width_mm: 1.5,
}; PEN_PRESET_COUNT], }; PEN_PRESET_COUNT],
active_pen: 2, active_pen: 2,
highlighter_colors: [[0.9, 0.5, 0.1, 0.35]; 4],
active_highlighter: 3,
text_colors: [[0.3, 0.1, 0.6, 1.0]; 5],
active_text_color: 4,
}; };
settings.save_to(&path).unwrap(); settings.save_to(&path).unwrap();
assert_eq!(UiSettings::load_from(&path), settings); assert_eq!(UiSettings::load_from(&path), settings);
@@ -280,6 +332,20 @@ mod tests {
assert_eq!(settings.pen_presets[0].color, [1.0, 0.0, 0.5, 1.0]); assert_eq!(settings.pen_presets[0].color, [1.0, 0.0, 0.5, 1.0]);
assert_eq!(settings.pen_presets[0].width_mm, 5.0); assert_eq!(settings.pen_presets[0].width_mm, 5.0);
assert_eq!(settings.pen_presets[1].width_mm, 0.15); assert_eq!(settings.pen_presets[1].width_mm, 0.15);
// Eigene Farben: Textmarker bleibt halbtransparent, Text deckend
std::fs::write(
&path,
br#"{"highlighter_colors": [[0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0],
[0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0]], "active_highlighter": 7,
"text_colors": [[5.0, 0.2, 0.3, 0.1], [0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0],
[0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0]]}"#,
)
.unwrap();
let settings = UiSettings::load_from(&path);
assert_eq!(settings.highlighter_colors[0], [0.1, 0.2, 0.3, 0.35]);
assert_eq!(settings.active_highlighter, 3);
assert_eq!(settings.text_colors[0], [1.0, 0.2, 0.3, 1.0]);
} }
#[test] #[test]
+26 -13
View File
@@ -30,22 +30,33 @@ fn vs_main(in: VertexInput) -> VertexOutput {
return out; return out;
} }
@fragment // Linearer Farbwert (Papier, Muster) -> sRGB: Gezeichnet wird ohne sRGB-Umrechnung der GPU
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> { // (siehe `render_format` in canvas.rs), gemischt wird also wie im PDF in sRGB
return in.color; fn linear_to_srgb(c: vec3<f32>) -> vec3<f32> {
let low = c * 12.92;
let high = 1.055 * pow(c, vec3<f32>(1.0 / 2.4)) - vec3<f32>(0.055);
return select(high, low, c <= vec3<f32>(0.0031308));
} }
// Striche: bei dunklem Papier wird die HSL-Helligkeit dunkler Farben gespiegelt // Papier: Farbe linear
// (L -> 1 - L, Farbton und Sättigung bleiben). Entspricht `background::adapt_ink_color`. @fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
return vec4<f32>(linear_to_srgb(clamp(in.color.rgb, vec3<f32>(0.0), vec3<f32>(1.0))), in.color.a);
}
// Striche: Tintenfarben sind sRGB-Werte wie im Export und in der Oberfläche. Bei dunklem
// Papier wird die HSL-Helligkeit dunkler Farben gespiegelt (L -> 1 - L, Farbton und
// Sättigung bleiben). Entspricht `background::adapt_ink_color`.
@fragment @fragment
fn fs_ink(in: VertexOutput) -> @location(0) vec4<f32> { fn fs_ink(in: VertexOutput) -> @location(0) vec4<f32> {
if (camera.ink_params.x < 0.5) { var rgb = clamp(in.color.rgb, vec3<f32>(0.0), vec3<f32>(1.0));
return in.color; if (camera.ink_params.x >= 0.5) {
let max_c = max(max(rgb.r, rgb.g), rgb.b);
let min_c = min(min(rgb.r, rgb.g), rgb.b);
let lift = max(1.0 - max_c - min_c, 0.0);
rgb = min(rgb + vec3<f32>(lift), vec3<f32>(1.0));
} }
let max_c = max(max(in.color.r, in.color.g), in.color.b); return vec4<f32>(rgb, in.color.a);
let min_c = min(min(in.color.r, in.color.g), in.color.b);
let lift = max(1.0 - max_c - min_c, 0.0);
return vec4<f32>(in.color.rgb + vec3<f32>(lift), in.color.a);
} }
// Bildschirmfüllendes Dreieck ohne Farbe (setzt den Stencil-Puffer zurück) // Bildschirmfüllendes Dreieck ohne Farbe (setzt den Stencil-Puffer zurück)
@@ -61,7 +72,8 @@ fn fs_none() -> @location(0) vec4<f32> {
return vec4<f32>(0.0); return vec4<f32>(0.0);
} }
// Eingefügte Bilder: texturierte Vierecke unter der Tinte // Eingefügte Bilder: texturierte Vierecke unter der Tinte (Texturen enthalten sRGB-Werte
// und werden unverändert übernommen)
struct ImageVertexInput { struct ImageVertexInput {
@location(0) position: vec2<f32>, @location(0) position: vec2<f32>,
@location(1) uv: vec2<f32>, @location(1) uv: vec2<f32>,
@@ -169,5 +181,6 @@ fn fs_pattern(in: PatternOutput) -> @location(0) vec4<f32> {
coverage = clamp(radius_px + 0.5 - d, 0.0, 1.0); coverage = clamp(radius_px + 0.5 - d, 0.0, 1.0);
} }
} }
return vec4<f32>(in.color.rgb, in.color.a * coverage * fade); let rgb = linear_to_srgb(clamp(in.color.rgb, vec3<f32>(0.0), vec3<f32>(1.0)));
return vec4<f32>(rgb, in.color.a * coverage * fade);
} }
+4 -4
View File
@@ -348,7 +348,7 @@ const MAX_TEXTURE_SIDE: f32 = 4096.0;
const MAX_TEXTURE_PIXELS: f32 = 16.0 * 1024.0 * 1024.0; const MAX_TEXTURE_PIXELS: f32 = 16.0 * 1024.0 * 1024.0;
/// Rastert ein Textfeld mit `px_per_unit` Pixeln je Seiteneinheit (begrenzt auf eine /// Rastert ein Textfeld mit `px_per_unit` Pixeln je Seiteneinheit (begrenzt auf eine
/// handhabbare Texturgröße). `color` ist linear wie die Tinte; die Pixel sind sRGB. /// handhabbare Texturgröße). `color` ist sRGB wie die Tinte, ebenso die Pixel.
pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage { pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage {
let layout = text.layout(); let layout = text.layout();
let size = glam::Vec2::new(text.wrap_width.max(text.size), layout.height()); let size = glam::Vec2::new(text.wrap_width.max(text.size), layout.height());
@@ -383,7 +383,7 @@ pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage
} }
} }
let rgb = [0, 1, 2].map(|i| egui::ecolor::gamma_u8_from_linear_f32(color[i])); let rgb = [0, 1, 2].map(|i| (color[i].clamp(0.0, 1.0) * 255.0).round() as u8);
let alpha = color[3].clamp(0.0, 1.0); let alpha = color[3].clamp(0.0, 1.0);
let mut pixels = Vec::with_capacity(coverage.len() * 4); let mut pixels = Vec::with_capacity(coverage.len() * 4);
for c in coverage { for c in coverage {
@@ -476,8 +476,8 @@ mod tests {
assert_eq!(image.height as f32, text.layout().height().ceil()); assert_eq!(image.height as f32, text.layout().height().ceil());
let opaque = image.pixels.chunks(4).filter(|p| p[3] > 200).count(); let opaque = image.pixels.chunks(4).filter(|p| p[3] > 200).count();
assert!(opaque > 100, "{opaque}"); assert!(opaque > 100, "{opaque}");
// Farbe ist sRGB-kodiert // Die Farbe ist schon sRGB und wird unverändert übernommen
assert!(image.pixels.chunks(4).all(|p| p[0] > 230 && p[1] < 130)); assert!(image.pixels.chunks(4).all(|p| p[..3] == [217, 51, 51]));
} }
#[test] #[test]
+461 -106
View File
@@ -19,7 +19,8 @@ use crate::document::{
use crate::file_picker::{FilePicker, PickerKind}; use crate::file_picker::{FilePicker, PickerKind};
use crate::ruler::{Ruler, RulerHit}; use crate::ruler::{Ruler, RulerHit};
use crate::settings::{ use crate::settings::{
PenPreset, PressureCurve, SidebarSide, DEFAULT_PEN_PRESETS, PEN_PRESET_COUNT, PenPreset, PressureCurve, SidebarSide, DEFAULT_HIGHLIGHTER_COLORS, DEFAULT_PEN_PRESETS,
DEFAULT_TEXT_COLORS, PEN_PRESET_COUNT,
}; };
use crate::storage::NotebookSummary; use crate::storage::NotebookSummary;
use crate::text::{TextEditing, TextFont, TEXT_SIZES_PT}; use crate::text::{TextEditing, TextFont, TEXT_SIZES_PT};
@@ -125,6 +126,59 @@ impl PageView {
} }
} }
/// Erkennt langes Drücken mit einem Finger: still liegen (höchstens [`Self::SLOP`] logische
/// Punkte Bewegung) für [`Self::DURATION`]. Positionen in physischen Pixeln.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct LongPress {
pending: Option<(u64, Instant, (f32, f32))>,
}
impl LongPress {
pub const DURATION: std::time::Duration = std::time::Duration::from_millis(500);
pub const SLOP: f32 = 10.0;
/// Finger `id` hat bei `pos` aufgesetzt.
pub fn start(&mut self, id: u64, pos: (f32, f32), now: Instant) {
self.pending = Some((id, now, pos));
}
/// Bewegung: zu weit (oder ein anderer Finger) bricht ab.
pub fn moved(&mut self, id: u64, pos: (f32, f32), scale: f32) {
if let Some((pending, _, start)) = self.pending {
if pending != id || (pos.0 - start.0).hypot(pos.1 - start.1) > Self::SLOP * scale {
self.pending = None;
}
}
}
/// Finger abgehoben, zweiter Finger oder sonstiger Abbruch.
pub fn cancel(&mut self) {
self.pending = None;
}
/// Wann ausgelöst wird (zum Aufwecken der Ereignisschleife).
pub fn deadline(&self) -> Option<Instant> {
self.pending.map(|(_, started, _)| started + Self::DURATION)
}
/// Ist die Zeit um, wird einmal die Stelle des Drückens geliefert.
pub fn fire(&mut self, now: Instant) -> Option<(f32, f32)> {
let (_, started, pos) = self.pending?;
(now >= started + Self::DURATION).then(|| {
self.pending = None;
pos
})
}
}
/// Kontextmenü nach langem Drücken auf das Blatt.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ContextMenu {
/// Bildschirmposition (logische Punkte) und Stelle auf der aktuellen Seite
pub screen: egui::Pos2,
pub page: glam::Vec2,
}
/// Laufende Bedienung des Lineals. /// Laufende Bedienung des Lineals.
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub enum RulerDrag { pub enum RulerDrag {
@@ -169,6 +223,8 @@ pub enum UiAction {
DeleteSelected, DeleteSelected,
CopySelected, CopySelected,
PasteSelected, PasteSelected,
/// Zwischenablage an dieser Stelle der aktuellen Seite einfügen (Kontextmenü)
PasteAt(glam::Vec2),
RotateSelected, RotateSelected,
// Dokumenten- & Notizbuch-Verwaltung // Dokumenten- & Notizbuch-Verwaltung
OpenNotebook(String), OpenNotebook(String),
@@ -266,6 +322,20 @@ pub struct UiState {
/// `pen_stroke_width_mm` gehören immer zum gewählten Stift /// `pen_stroke_width_mm` gehören immer zum gewählten Stift
pub pen_presets: [PenPreset; PEN_PRESET_COUNT], pub pen_presets: [PenPreset; PEN_PRESET_COUNT],
pub active_pen: usize, pub active_pen: usize,
/// Eigene Farben des Textmarkers in der Werkzeugleiste und die gewählte;
/// `highlighter_color` gehört immer zur gewählten
pub highlighter_colors: [[f32; 4]; 4],
pub active_highlighter: usize,
/// Eigene Farben des Text-Werkzeugs und die zuletzt angetippte (ein geöffnetes Textfeld
/// kann eine andere Farbe haben)
pub text_colors: [[f32; 4]; 5],
pub active_text_color: usize,
/// Popover „Textfarbe“ ist offen
pub is_text_color_menu_open: bool,
/// Farbwähler in den Farb-Popovern aufgeklappt
pub show_color_picker: bool,
/// Kontextmenü nach langem Drücken (Einfügen an dieser Stelle)
pub context_menu: Option<ContextMenu>,
/// Rückfrage „Seite löschen?“ im ☰-Menü ist offen /// Rückfrage „Seite löschen?“ im ☰-Menü ist offen
pub confirm_delete_page: bool, pub confirm_delete_page: bool,
/// Seitenzahl der zuletzt gelöschten Seite, solange sie wiederhergestellt werden kann /// Seitenzahl der zuletzt gelöschten Seite, solange sie wiederhergestellt werden kann
@@ -349,6 +419,13 @@ impl Default for UiState {
last_deleted_notebook: None, last_deleted_notebook: None,
pen_presets: DEFAULT_PEN_PRESETS, pen_presets: DEFAULT_PEN_PRESETS,
active_pen: 0, active_pen: 0,
highlighter_colors: DEFAULT_HIGHLIGHTER_COLORS,
active_highlighter: 0,
text_colors: DEFAULT_TEXT_COLORS,
active_text_color: 0,
is_text_color_menu_open: false,
show_color_picker: false,
context_menu: None,
confirm_delete_page: false, confirm_delete_page: false,
restorable_page: None, restorable_page: None,
shutdown_error: None, shutdown_error: None,
@@ -391,6 +468,8 @@ impl UiState {
/// sich nie mehrere überlagern. /// sich nie mehrere überlagern.
pub fn close_overlays(&mut self) { pub fn close_overlays(&mut self) {
self.confirm_delete_page = false; self.confirm_delete_page = false;
self.context_menu = None;
self.is_text_color_menu_open = false;
self.is_pen_menu_open = false; self.is_pen_menu_open = false;
self.is_highlighter_menu_open = false; self.is_highlighter_menu_open = false;
self.is_more_menu_open = false; self.is_more_menu_open = false;
@@ -500,9 +579,36 @@ impl UiState {
self.pen_stroke_width_mm = preset.width_mm; self.pen_stroke_width_mm = preset.width_mm;
} }
/// Wählt die eigene Textmarker-Farbe `index`.
pub fn select_highlighter_color(&mut self, index: usize) {
self.active_highlighter = index.min(self.highlighter_colors.len() - 1);
self.highlighter_color = self.highlighter_colors[self.active_highlighter];
}
/// Setzt die Textfarbe (auch für ein gerade bearbeitetes Textfeld).
pub fn set_text_color(&mut self, color: [f32; 4]) {
self.text_color = color;
if let Some(edit) = &mut self.text_edit {
edit.color = color;
edit.focus = true;
}
}
/// Übernimmt geänderte Farbe oder Stärke des Stifts in die Vorlage des gewählten Stifts
/// und die Textmarker-Farbe in die gewählte eigene Farbe; `true`, wenn sich etwas
/// geändert hat (dann speichern).
pub fn sync_tool_colors(&mut self) -> bool {
let index = self
.active_highlighter
.min(self.highlighter_colors.len() - 1);
let highlighter_changed = self.highlighter_colors[index] != self.highlighter_color;
self.highlighter_colors[index] = self.highlighter_color;
self.sync_pen_preset() | highlighter_changed
}
/// Übernimmt geänderte Farbe oder Stärke des Stifts in die Vorlage des gewählten Stifts; /// Übernimmt geänderte Farbe oder Stärke des Stifts in die Vorlage des gewählten Stifts;
/// `true`, wenn sie sich geändert hat (dann speichern). /// `true`, wenn sie sich geändert hat (dann speichern).
pub fn sync_pen_preset(&mut self) -> bool { fn sync_pen_preset(&mut self) -> bool {
let current = PenPreset { let current = PenPreset {
color: self.pen_color, color: self.pen_color,
width_mm: self.pen_stroke_width_mm, width_mm: self.pen_stroke_width_mm,
@@ -587,7 +693,8 @@ impl UiState {
/// - ODER innerhalb des geöffneten Einstellungs-Popovers /// - ODER innerhalb des geöffneten Einstellungs-Popovers
/// - ODER innerhalb geöffneter Modalfenster (Dokumenten-Manager, Update-Center) /// - ODER innerhalb geöffneter Modalfenster (Dokumenten-Manager, Update-Center)
pub fn is_point_in_ui(&self, px: f32, py: f32) -> bool { pub fn is_point_in_ui(&self, px: f32, py: f32) -> bool {
if self.is_point_inside_toolbar(px, py) { // Offenes Kontextmenü: ein Tippen daneben schließt es nur
if self.context_menu.is_some() || self.is_point_inside_toolbar(px, py) {
return true; return true;
} }
// Lineal: Fläche und Dreh-Griff bedient die Oberfläche, die Kanten bleiben zum Zeichnen // Lineal: Fläche und Dreh-Griff bedient die Oberfläche, die Kanten bleiben zum Zeichnen
@@ -930,6 +1037,67 @@ fn autosave_status_style(status: AutosaveStatus) -> (egui::Color32, &'static str
} }
/// Zeichnet einen runden Farbauswahl-Knopf (Color Swatch) mit anpassbarem Durchmesser. /// Zeichnet einen runden Farbauswahl-Knopf (Color Swatch) mit anpassbarem Durchmesser.
/// Farbwahl in den Popovern: Vorgaben als Farbpunkte, dahinter 🎨 für einen frei
/// wählbaren Farbton (aufklappbar). Die Deckkraft von `color` bleibt erhalten (Textmarker
/// halbtransparent). `true`, wenn sich die Farbe geändert hat.
fn color_editor(
ui: &mut egui::Ui,
presets: &[ColorPreset],
color: &mut [f32; 4],
picker_open: &mut bool,
) -> bool {
let mut changed = false;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(8.0, 0.0);
for &(preset, label) in presets {
let is_sel = preset[..3]
.iter()
.zip(&color[..3])
.all(|(a, b)| (a - b).abs() < 0.01);
if color_circle_swatch(ui, preset, is_sel, label, 30.0).clicked() {
*color = [preset[0], preset[1], preset[2], color[3]];
changed = true;
}
}
if ui
.add(egui::SelectableLabel::new(
*picker_open,
egui::RichText::new("🎨").size(18.0),
))
.on_hover_text("Eigene Farbe wählen")
.clicked()
{
*picker_open = !*picker_open;
}
});
if *picker_open {
ui.add_space(6.0);
let [r, g, b] = [0, 1, 2].map(|i| (color[i].clamp(0.0, 1.0) * 255.0).round() as u8);
let mut picked = egui::Color32::from_rgb(r, g, b);
let picked_changed = ui
.scope(|ui| {
ui.spacing_mut().slider_width = 170.0;
egui::color_picker::color_picker_color32(
ui,
&mut picked,
egui::color_picker::Alpha::Opaque,
)
})
.inner;
if picked_changed {
let [r, g, b, _] = picked.to_array();
*color = [
r as f32 / 255.0,
g as f32 / 255.0,
b as f32 / 255.0,
color[3],
];
changed = true;
}
}
changed
}
/// Stift der Werkzeugleiste: ein Strich in Farbe und (angedeuteter) Stärke auf einem /// Stift der Werkzeugleiste: ein Strich in Farbe und (angedeuteter) Stärke auf einem
/// Stück Papier, damit auch schwarze Tinte auf der dunklen Leiste sichtbar ist. /// Stück Papier, damit auch schwarze Tinte auf der dunklen Leiste sichtbar ist.
fn pen_preset_swatch( fn pen_preset_swatch(
@@ -1195,7 +1363,7 @@ fn text_edit_overlay(ctx: &egui::Context, state: &mut UiState) {
let height_px = font_size * ctx.pixels_per_point() * edit.text.font.height_per_em(); let height_px = font_size * ctx.pixels_per_point() * edit.text.font.height_per_em();
let rounding = height_px.round().max(1.0) / height_px; let rounding = height_px.round().max(1.0) / height_px;
let width = (edit.text.wrap_width.max(edit.text.size) * sx * view.scale * rounding).max(8.0); let width = (edit.text.wrap_width.max(edit.text.size) * sx * view.scale * rounding).max(8.0);
let color = linear_color32(if adapt { let color = color32_from(if adapt {
adapt_ink_color(edit.color) adapt_ink_color(edit.color)
} else { } else {
edit.color edit.color
@@ -1566,6 +1734,47 @@ fn delete_page_buttons(
} }
} }
/// Kontextmenü nach langem Drücken auf das Blatt: Einfügen an dieser Stelle. Ein Tippen
/// daneben oder Esc schließt es.
fn context_menu(ctx: &egui::Context, state: &mut UiState, actions: &mut Vec<UiAction>) {
let Some(menu) = state.context_menu else {
return;
};
let response = egui::Area::new(egui::Id::new("canvas_context_menu"))
.fixed_pos(menu.screen)
.constrain(true)
.order(egui::Order::Foreground)
.show(ctx, |ui| {
popover_frame().show(ui, |ui| {
touch_friendly_spacing(ui);
ui.set_width(220.0);
if ui
.add_sized([220.0, 36.0], egui::Button::new("📋 Einfügen"))
.on_hover_text(
"Bild, Text oder kopierte Objekte aus der Zwischenablage hier einfügen",
)
.clicked()
{
actions.push(UiAction::PasteAt(menu.page));
state.context_menu = None;
}
if ui
.add_sized([220.0, 36.0], egui::Button::new("🖼 Bild aus Datei …"))
.clicked()
{
state.open_image_picker();
}
})
});
let rect = response.response.rect;
let pressed_outside = ctx.input(|i| {
i.pointer.any_pressed() && i.pointer.press_origin().is_some_and(|p| !rect.contains(p))
});
if pressed_outside || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
state.context_menu = None;
}
}
/// Durchgehende Ansicht: dunkle Linie an jeder Grenze zweier Seiten (die Seiten liegen /// Durchgehende Ansicht: dunkle Linie an jeder Grenze zweier Seiten (die Seiten liegen
/// lückenlos untereinander). Unter allen Fenstern der Oberfläche, über der Tinte. /// lückenlos untereinander). Unter allen Fenstern der Oberfläche, über der Tinte.
fn page_separators(ctx: &egui::Context, state: &UiState, notebook: &Notebook) { fn page_separators(ctx: &egui::Context, state: &UiState, notebook: &Notebook) {
@@ -1998,9 +2207,11 @@ pub fn render_ui(
page_separators(ctx, state, notebook); page_separators(ctx, state, notebook);
ruler_overlay(ctx, state); ruler_overlay(ctx, state);
text_edit_overlay(ctx, state); text_edit_overlay(ctx, state);
context_menu(ctx, state, &mut actions);
let mut pen_btn_rect = None; let mut pen_btn_rect = None;
let mut high_btn_rect = None; let mut high_btn_rect = None;
let mut text_color_btn_rect = None;
let mut more_btn_rect = None; let mut more_btn_rect = None;
let mut page_btn_rect = None; let mut page_btn_rect = None;
@@ -2208,26 +2419,11 @@ pub fn render_ui(
// --- Stifte bzw. Farben des aktiven Werkzeugs --- // --- Stifte bzw. Farben des aktiven Werkzeugs ---
let pens = matches!(state.active_tool, ActiveTool::Pen | ActiveTool::Shape); let pens = matches!(state.active_tool, ActiveTool::Pen | ActiveTool::Shape);
let palette: Option<(&[ColorPreset], [f32; 4])> =
match state.active_tool {
ActiveTool::Pen | ActiveTool::Shape => None,
ActiveTool::Select => {
Some((&PEN_COLOR_PRESETS[..], state.pen_color))
}
ActiveTool::Highlighter => Some((
&HIGHLIGHTER_COLOR_PRESETS[..],
state.highlighter_color,
)),
ActiveTool::Text => {
Some((&PEN_COLOR_PRESETS[..], state.text_color))
}
ActiveTool::Eraser => None,
};
// Fester Platz für die längste Palette, damit die Knöpfe darunter // Fester Platz für die längste Palette, damit die Knöpfe darunter
// beim Werkzeugwechsel nicht springen // beim Werkzeugwechsel nicht springen
sidebar_divider(ui, btn); sidebar_divider(ui, btn);
let swatch = UiState::sidebar_swatch_size(btn); let swatch = UiState::sidebar_swatch_size(btn);
let mut shown = palette.map_or(0, |(presets, _)| presets.len()); let mut shown = 0;
if pens { if pens {
shown = PEN_PRESET_COUNT; shown = PEN_PRESET_COUNT;
for i in 0..PEN_PRESET_COUNT { for i in 0..PEN_PRESET_COUNT {
@@ -2267,29 +2463,74 @@ pub fn render_ui(
} }
} }
} }
if let Some((presets, current)) = palette { // Eigene Farben von Textmarker und Text: antippen wählt, erneut
for &(color, label) in presets { // tippen passt die Farbe an. Die Auswahl wird in die Farben der
let is_sel = (current[0] - color[0]).abs() < 0.05 // Stifte umgefärbt.
&& (current[1] - color[1]).abs() < 0.05 let tool = state.active_tool;
&& (current[2] - color[2]).abs() < 0.05; let slots: Option<Vec<[f32; 4]>> = match tool {
if color_circle_swatch(ui, color, is_sel, label, swatch) ActiveTool::Highlighter => Some(state.highlighter_colors.to_vec()),
.clicked() ActiveTool::Text => Some(state.text_colors.to_vec()),
{ ActiveTool::Select => {
match state.active_tool { Some(state.pen_presets.iter().map(|p| p.color).collect())
ActiveTool::Highlighter => { }
state.highlighter_color = color _ => None,
} };
ActiveTool::Text => { if let Some(colors) = slots {
state.text_color = color; shown = colors.len();
if let Some(edit) = &mut state.text_edit { for (i, color) in colors.into_iter().enumerate() {
edit.color = color; let selected = match tool {
edit.focus = true; ActiveTool::Highlighter => i == state.active_highlighter,
} ActiveTool::Text => {
} i == state.active_text_color
// Auswahl umfärben, der Stift bleibt && state.text_color == color
_ => {}
} }
actions.push(UiAction::SetColor(color)); _ => false,
};
let tooltip = match tool {
ActiveTool::Select => {
format!("Auswahl in der Farbe von Stift {} umfärben", i + 1)
}
_ if selected => format!(
"Farbe {} – erneut tippen, um sie anzupassen",
i + 1
),
_ => format!("Farbe {}", i + 1),
};
let response =
color_circle_swatch(ui, color, selected, &tooltip, swatch);
if selected {
// Das Farb-Popover erscheint neben der gewählten Farbe
match tool {
ActiveTool::Highlighter => high_btn_rect = Some(response.rect),
_ => text_color_btn_rect = Some(response.rect),
}
}
if !response.clicked() {
continue;
}
match tool {
ActiveTool::Highlighter if selected => {
let open = !state.is_highlighter_menu_open;
state.close_overlays();
state.is_highlighter_menu_open = open;
}
ActiveTool::Highlighter => {
state.close_overlays();
state.select_highlighter_color(i);
actions.push(UiAction::SetColor(color));
}
ActiveTool::Text if selected => {
let open = !state.is_text_color_menu_open;
state.close_overlays();
state.is_text_color_menu_open = open;
}
ActiveTool::Text => {
state.close_overlays();
state.active_text_color = i;
state.set_text_color(color);
actions.push(UiAction::SetColor(color));
}
_ => actions.push(UiAction::SetColor(color)),
} }
} }
} }
@@ -2565,36 +2806,14 @@ pub fn render_ui(
.color(egui::Color32::from_gray(160)), .color(egui::Color32::from_gray(160)),
); );
ui.add_space(3.0); ui.add_space(3.0);
ui.horizontal(|ui| { if color_editor(
ui.spacing_mut().item_spacing = egui::vec2(8.0, 0.0); ui,
for (color, label) in PEN_COLOR_PRESETS { &PEN_COLOR_PRESETS,
let is_sel = (state.pen_color[0] - color[0]).abs() < 0.05 &mut state.pen_color,
&& (state.pen_color[1] - color[1]).abs() < 0.05 &mut state.show_color_picker,
&& (state.pen_color[2] - color[2]).abs() < 0.05; ) {
if color_circle_swatch(ui, color, is_sel, label, 30.0).clicked() { actions.push(UiAction::SetColor(state.pen_color));
state.pen_color = color; }
actions.push(UiAction::SetColor(color));
}
}
// RGBA-Colorpicker
let mut egui_color = egui::Rgba::from_rgba_premultiplied(
state.pen_color[0],
state.pen_color[1],
state.pen_color[2],
state.pen_color[3],
);
if egui::color_picker::color_edit_button_rgba(
ui,
&mut egui_color,
egui::color_picker::Alpha::Opaque,
)
.changed()
{
state.pen_color = egui_color.to_array();
actions.push(UiAction::SetColor(state.pen_color));
}
});
ui.add_space(10.0); ui.add_space(10.0);
@@ -2662,10 +2881,13 @@ pub fn render_ui(
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label( ui.label(
egui::RichText::new("Textmarker-Konfiguration") egui::RichText::new(format!(
.strong() "Textmarker – Farbe {}",
.color(egui::Color32::WHITE) state.active_highlighter + 1
.size(12.5), ))
.strong()
.color(egui::Color32::WHITE)
.size(12.5),
); );
ui.with_layout( ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center), egui::Layout::right_to_left(egui::Align::Center),
@@ -2681,41 +2903,20 @@ pub fn render_ui(
// Zeile 1: Farbauswahl // Zeile 1: Farbauswahl
ui.label( ui.label(
egui::RichText::new("MARKER-FARBE") egui::RichText::new("FARBE")
.size(10.5) .size(10.5)
.strong() .strong()
.color(egui::Color32::from_gray(160)), .color(egui::Color32::from_gray(160)),
); );
ui.add_space(3.0); ui.add_space(3.0);
ui.horizontal(|ui| { if color_editor(
ui.spacing_mut().item_spacing = egui::vec2(8.0, 0.0); ui,
for (color, label) in HIGHLIGHTER_COLOR_PRESETS { &HIGHLIGHTER_COLOR_PRESETS,
let is_sel = (state.highlighter_color[0] - color[0]).abs() < 0.05 &mut state.highlighter_color,
&& (state.highlighter_color[1] - color[1]).abs() < 0.05 &mut state.show_color_picker,
&& (state.highlighter_color[2] - color[2]).abs() < 0.05; ) {
if color_circle_swatch(ui, color, is_sel, label, 30.0).clicked() { actions.push(UiAction::SetColor(state.highlighter_color));
state.highlighter_color = color; }
actions.push(UiAction::SetColor(color));
}
}
let mut egui_color = egui::Rgba::from_rgba_premultiplied(
state.highlighter_color[0],
state.highlighter_color[1],
state.highlighter_color[2],
state.highlighter_color[3],
);
if egui::color_picker::color_edit_button_rgba(
ui,
&mut egui_color,
egui::color_picker::Alpha::OnlyBlend,
)
.changed()
{
state.highlighter_color = egui_color.to_array();
actions.push(UiAction::SetColor(state.highlighter_color));
}
});
ui.add_space(10.0); ui.add_space(10.0);
@@ -2779,6 +2980,59 @@ pub fn render_ui(
popover_rect = Some(pop_response.inner); popover_rect = Some(pop_response.inner);
} }
} else if state.is_text_color_menu_open {
if let Some(btn_rect) = text_color_btn_rect {
let (popover_pos, pivot) =
popover_placement(state.sidebar_side, area_response.inner, btn_rect);
let pop_response = egui::Area::new(egui::Id::new("text_color_popover"))
.fixed_pos(popover_pos)
.pivot(pivot)
.constrain(true)
.order(egui::Order::Foreground)
.show(ctx, |ui| {
let frame = popover_frame().show(ui, |ui| {
ui.set_width(320.0);
touch_friendly_spacing(ui);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(format!(
"Textfarbe {}",
state.active_text_color + 1
))
.strong()
.color(egui::Color32::WHITE)
.size(12.5),
);
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if close_button(ui).clicked() {
state.is_text_color_menu_open = false;
}
},
);
});
ui.add_space(8.0);
let slot = state.active_text_color.min(state.text_colors.len() - 1);
let mut color = state.text_colors[slot];
if color_editor(
ui,
&PEN_COLOR_PRESETS,
&mut color,
&mut state.show_color_picker,
) {
state.text_colors[slot] = color;
state.set_text_color(color);
actions.push(UiAction::SetColor(color));
}
});
frame.response.rect
});
popover_rect = Some(pop_response.inner);
} else {
// Die Farbe ist nicht mehr zu sehen (anderes Werkzeug)
state.is_text_color_menu_open = false;
}
} }
// 2b. ☰-MENÜ (selten genutzte Funktionen), öffnet sich nach oben zur Blattseite hin // 2b. ☰-MENÜ (selten genutzte Funktionen), öffnet sich nach oben zur Blattseite hin
@@ -3223,6 +3477,7 @@ pub fn render_ui(
// Automatisches Schließen der Popover bei Klick außerhalb von Leiste und Popover // Automatisches Schließen der Popover bei Klick außerhalb von Leiste und Popover
if (state.is_pen_menu_open if (state.is_pen_menu_open
|| state.is_highlighter_menu_open || state.is_highlighter_menu_open
|| state.is_text_color_menu_open
|| state.is_more_menu_open || state.is_more_menu_open
|| state.show_page_jump_popup) || state.show_page_jump_popup)
&& ctx.input(|i| i.pointer.any_pressed()) && ctx.input(|i| i.pointer.any_pressed())
@@ -3233,6 +3488,7 @@ pub fn render_ui(
if !in_toolbar && !in_popover { if !in_toolbar && !in_popover {
state.is_pen_menu_open = false; state.is_pen_menu_open = false;
state.is_highlighter_menu_open = false; state.is_highlighter_menu_open = false;
state.is_text_color_menu_open = false;
state.is_more_menu_open = false; state.is_more_menu_open = false;
state.show_page_jump_popup = false; state.show_page_jump_popup = false;
} }
@@ -4542,6 +4798,44 @@ mod tests {
assert!(state.ruler.is_none()); assert!(state.ruler.is_none());
} }
#[test]
fn test_custom_colors_remember_changes() {
let mut state = UiState::new();
assert!(!state.sync_tool_colors(), "Ausgangszustand ist gespeichert");
// Stift 2 wählen und umfärben: der Stift merkt sich die Farbe
state.select_pen(1);
state.pen_color = [0.5, 0.1, 0.6, 1.0];
assert!(state.sync_tool_colors());
assert_eq!(state.pen_presets[1].color, [0.5, 0.1, 0.6, 1.0]);
state.select_pen(0);
state.select_pen(1);
assert_eq!(state.pen_color, [0.5, 0.1, 0.6, 1.0]);
// Textmarker-Farbe 3 anpassen
state.select_highlighter_color(2);
state.highlighter_color = [0.9, 0.5, 0.1, 0.35];
assert!(state.sync_tool_colors());
assert_eq!(state.highlighter_colors[2], [0.9, 0.5, 0.1, 0.35]);
assert_eq!(state.highlighter_colors[0], DEFAULT_HIGHLIGHTER_COLORS[0]);
assert!(!state.sync_tool_colors());
// Textfarbe gilt auch für das gerade bearbeitete Feld
state.text_edit = Some(TextEditing::new_at(
1,
glam::Vec2::new(100.0, 100.0),
PAGE_WIDTH,
50.0,
TextFont::Sans,
[0.0, 0.0, 0.0, 1.0],
));
state.set_text_color([0.2, 0.6, 0.3, 1.0]);
assert_eq!(
state.text_edit.as_ref().unwrap().color,
[0.2, 0.6, 0.3, 1.0]
);
}
#[test] #[test]
fn test_stroke_tool_follows_active_tool() { fn test_stroke_tool_follows_active_tool() {
assert_eq!(ActiveTool::Pen.stroke_tool(false), StrokeTool::Pen); assert_eq!(ActiveTool::Pen.stroke_tool(false), StrokeTool::Pen);
@@ -4663,6 +4957,67 @@ mod tests {
actions actions
} }
#[test]
fn test_long_press_detection() {
let t0 = Instant::now();
let ms = |n| t0 + std::time::Duration::from_millis(n);
let mut press = LongPress::default();
assert_eq!(press.fire(ms(1000)), None);
// Stillhalten (kleines Zittern erlaubt): nach 500 ms genau einmal
press.start(1, (100.0, 100.0), t0);
press.moved(1, (106.0, 103.0), 1.0);
assert_eq!(press.deadline(), Some(ms(500)));
assert_eq!(press.fire(ms(499)), None);
assert_eq!(press.fire(ms(500)), Some((100.0, 100.0)));
assert_eq!(press.fire(ms(900)), None);
// Verschieben bricht ab; bei 2× Skalierung zählt der Abstand in logischen Punkten
press.start(1, (100.0, 100.0), t0);
press.moved(1, (115.0, 100.0), 2.0);
assert!(press.deadline().is_some());
press.moved(1, (125.0, 100.0), 2.0);
assert_eq!(press.fire(ms(600)), None);
// Abheben oder ein anderer Finger brechen ab
press.start(1, (100.0, 100.0), t0);
press.cancel();
assert_eq!(press.fire(ms(600)), None);
press.start(1, (100.0, 100.0), t0);
press.moved(2, (100.0, 100.0), 1.0);
assert_eq!(press.fire(ms(600)), None);
}
#[test]
fn test_context_menu_pastes_at_pressed_point() {
let notebook = Notebook::new();
let mut state = UiState::new();
let at = glam::Vec2::new(800.0, 1200.0);
state.context_menu = Some(ContextMenu {
screen: egui::pos2(600.0, 400.0),
page: at,
});
let ctx = render_frames(&mut state, &notebook, 3);
// Solange es offen ist, gehört jeder Punkt der Oberfläche (Tippen daneben schließt)
assert!(state.is_point_in_ui(1000.0, 700.0));
// „Einfügen“ ist der erste Eintrag
let actions = click_at(&ctx, &mut state, &notebook, egui::pos2(700.0, 425.0));
assert!(actions.contains(&UiAction::PasteAt(at)), "{actions:?}");
assert!(state.context_menu.is_none());
// Tippen daneben schließt das Menü ohne Aktion
state.context_menu = Some(ContextMenu {
screen: egui::pos2(600.0, 400.0),
page: at,
});
let ctx = render_frames(&mut state, &notebook, 3);
let actions = click_at(&ctx, &mut state, &notebook, egui::pos2(1000.0, 700.0));
assert!(state.context_menu.is_none());
assert!(!actions.iter().any(|a| matches!(a, UiAction::PasteAt(_))));
assert!(!state.is_point_in_ui(1000.0, 700.0));
}
#[test] #[test]
fn test_only_one_dialog_or_popover_is_open() { fn test_only_one_dialog_or_popover_is_open() {
let notebook = Notebook::new(); let notebook = Notebook::new();