Compare commits

...
19 Commits
Author SHA1 Message Date
haraldandClaude Opus 5.5 616fc57666 chore(release): bump version to 0.3.7
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m48s
Ellipsen- und Pfeilerkennung, spitze Pfeilspitzen, Dateiformat-Version 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:42:24 +02:00
haraldandClaude Opus 5.5 d150fd1883 feat(geometry): Ellipsen- und Pfeilerkennung
Ellipse
- Achsen aus der Hauptkomponentenanalyse des gleichmäßig abgetasteten Strichs,
  Halbachsen aus der Ausdehnung (2.–98. Perzentil); fast runde Formen
  (Achsenverhältnis ≥ 0,85) bleiben Kreise; leicht schiefe werden gerade
- konkurriert im Formvergleich mit Kreis, Rechteck, Dreieck, Raute, Vieleck

Pfeil / Doppelpfeil
- in einem Zug gezeichnet: langer Schaft, an einem oder beiden Enden
  Widerhaken auf beiden Seiten (10°–80° schräg nach hinten); T-Enden und
  Zickzack sind keine Pfeile
- Schaft wird über fast gerade weiterlaufende Punkte verlängert (die
  Vereinfachung teilt ihn sonst kurz vor der Spitze)
- Spitzen über die gemeinsame arrow_head-Geometrie, Schaft endet an der Basis

Außerdem: Drehen/Skalieren/Verschieben, PDF-Export, Namen für die
Einrast-Meldung; Dateiformat-Version 3, damit ältere App-Versionen das
Notizbuch sauber ablehnen statt Seiten mit neuen Formen für beschädigt zu
halten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:28:17 +02:00
haraldandClaude Opus 5.5 f9ede537ed fix(geometry): spitze Pfeilspitzen beim Koordinatensystem
Die Achsenlinien liefen in voller Strichbreite bis zur Spitze und überdeckten
sie; zudem war die Spitze klein und breit (~67° Öffnung).

- Gemeinsame Pfeilspitzen-Geometrie (arrow_head) für Bildschirm und PDF:
  ~4 mm lang (dicke Linien länger, höchstens 1/5 der Achse), ~40° Öffnung
- Achsen enden an der Basis der Spitze
- PDF-Export nutzt dieselben Maße (vorher eigene, abweichende Werte)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:14:06 +02:00
haraldandClaude Opus 5.5 9571e2121c chore(release): bump version to 0.3.6
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m51s
Unter WSL standardmäßig X11 statt Wayland.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:50:24 +02:00
haraldandClaude Opus 5.5 4a7627e461 fix(wsl): unter WSL standardmäßig X11 statt Wayland verwenden
Unter WSLg trennt der Wayland-Compositor die Verbindung, kurz nachdem die App
ein Bild über Vulkan übergibt ("Connection reset by peer", ExitFailure). Über
X11 (Xwayland) läuft die App stabil – deshalb funktionierte sie als root, wo
der Wayland-Socket nicht gefunden wird und winit auf X11 ausweicht.

- WSL-Erkennung (WSL_DISTRO_NAME/WSL_INTEROP, /proc/sys/kernel/osrelease)
- Unter WSL mit gesetztem DISPLAY wird die Ereignisschleife mit X11 erzeugt
- INK_NOTES_DISPLAY=x11|wayland übersteuert die Wahl

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:42:21 +02:00
haraldandClaude Opus 5.5 cb11b1f828 chore(release): bump version to 0.3.5
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m34s
Kein Absturz mehr ohne nutzbare Grafikkarte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:17:49 +02:00
haraldandClaude Opus 5.5 89005f718e fix(grafik): kein Absturz ohne nutzbare Darstellungsmodi
Ohne Zugriff auf die Grafikkarte (z. B. Benutzer nicht in der Gruppe render)
lieferte EGL/Zink zunächst Darstellungsmodi, später eine leere Liste; die
nächste Neueinrichtung der Surface ließ wgpu abstürzen ("Requested present
mode Mailbox is not in the list of supported present modes: []").

- Adapter der Reihe nach prüfen (GPU, dann Software-Renderer); nur Adapter
  mit Formaten und Darstellungsmodi für das Fenster werden genommen
- Darstellungsmodus: Mailbox, sonst Fifo, sonst der erste angebotene
- Bei Größenänderung/verlorener Surface Fähigkeiten neu abfragen statt blind
  neu einzurichten; ohne Modi wird nicht eingerichtet (einmalige Meldung)
- Ohne nutzbaren Adapter verständliche Meldung (Gruppe render, /dev/dri,
  Software-Renderer, WGPU_BACKEND) statt Panic; WGPU_BACKEND wird beachtet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:14:43 +02:00
haraldandClaude Opus 5.5 b5113cefa2 chore(release): bump version to 0.3.4
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m14s
Formerkennung: Rechtecke statt Kreise, Freiformen bleiben unverändert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:21:00 +02:00
haraldandClaude Opus 5.5 88ea7d2d53 fix(geometry): Freiformen bleiben unverändert, Vielecke werden erkannt
- Eine erkannte Form ersetzt den Strich nur noch, wenn sie ihn gut beschreibt
  (mittlere Abweichung ≤ 4 % der Strichdiagonale, gemessen gegen den
  Linienzug der Form). Vorher wurde fast jeder geschlossene Strich umgewandelt
  (Herz → Kreis, Stern/Spirale → Oktaeder, Wolke → Dreieck).
- Vielecke verlangen echte Ecken: Die Richtungsänderung muss sich an den Ecken
  konzentrieren; glatte Ovale werden so nicht mehr zu Sechs-/Achtecken.
- Vielecke konkurrieren mit Kreis, Dreieck, Rechteck und Raute (gewichtet:
  Rechteck vor Raute/Vieleck, Vieleck vor Kreis); überzählige Punkte der
  Vereinfachung (Strichende vor dem Start, Punkte auf einer Geraden) entfallen.
- Tests: Freiformen bleiben unverändert, Fünf-/Sechs-/Achtecke, Ovale;
  jeder Einzeldetektor-Test prüft zusätzlich die vollständige Erkennung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:19:57 +02:00
haraldandClaude Opus 5.5 7345c00568 fix(geometry): Rechtecke und Quadrate werden nicht mehr als Kreis erkannt
- Kreis, Dreieck, Rechteck und Raute konkurrieren; gewählt wird die Form,
  deren Kontur am besten zu den Punkten passt (RMS-Abweichung). Vorher gewann
  der Kreis immer, weil auch Quadrate nur ~10 % Radiusschwankung haben, und
  die Dreieckserkennung griff gestreckte Rechtecke ab.
- Rechteckerkennung über das kleinste umschließende Rechteck (alle Drehwinkel,
  Ausreißer-robust), Kanten auf den Median der Punkte; verlangt gezeichnete
  Ecken und enge Passung, damit Ovale und Kritzeleien keine Rechtecke werden.
  Die alte Variante schnitt bei kleinen/zittrigen Strichen die Ecken ab.
- extract_k_corners wählte die geradesten statt der markantesten Punkte
  (Sortierung verkehrt herum); betrifft auch Dreieck und Raute.
- Rechteck wird der Raute vorgezogen, wenn es fast so gut passt.
- Oktaeder nur noch, wenn keine geschlossene Form gut passt.
- Tests mit nachgebildeten Freihandstrichen: Rechtecke in vielen Größen,
  Seitenverhältnissen und Drehungen, mit runden Ecken, schiefen Kanten und
  Überstrich; Kreise, Dreiecke; Ellipsen und Freiformen nie als Rechteck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:05:37 +02:00
haraldandClaude Opus 5.5 f76fad92be chore(release): bump version to 0.3.3
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m10s
Behebt Datenverlust nach Neustart auf einer späteren Seite (v0.2.3 – v0.3.2); Seitenauswahl mit Vorschaubildern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:32:58 +02:00
haraldandClaude Opus 5.5 2c60a03460 feat(ui): Vorschaubilder in der Seitenauswahl
- Kacheln zeigen eine verkleinerte A4-Vorschau jeder Seite (Striche als
  Linien, Textmarker halbtransparent, volle Seiten auf ~1500 Punkte
  ausgedünnt), Seitenzahl darunter, aktuelle Seite blau umrandet
- Noch nicht geladene Seiten werden bei offener Seitenauswahl nachgeladen
  (UiAction::LoadAllPages)
- Kachelgröße über PAGE_TILE (vorher feste Werte)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:03:08 +02:00
haraldandClaude Opus 5.5 b6580f197d fix(storage): Inhalt der zuletzt geöffneten Seite nach Neustart nicht überschreiben
Beim Start wurde nur die erste Seite geladen. War zuletzt eine spätere Seite
offen, war sie nach dem Neustart ein leerer, ungeladener Platzhalter: Das
Blatt erschien leer, und Zeichnen/Radieren/Hintergrund wechseln speicherte
den Platzhalter und überschrieb den bisherigen Inhalt der Seite.

- load_notebook lädt zusätzlich die aktuelle Seite
- Autosave schreibt nie ungeladene Platzhalter (Schutz ging beim Merge des
  Storage-Branches verloren)
- Regressionstests für beide Fälle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:03:08 +02:00
haraldandClaude Opus 5.5 22e35f2e77 chore(release): bump version to 0.3.2
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m33s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:40:44 +02:00
haraldandClaude Opus 5.5 a77e6cde90 feat(ui): einheitliche, deckende Dialoge und fingertaugliche Popover
- Dialoge (Notizbücher, Updates, Warnfenster) mit gemeinsamem deckendem
  Rahmen, ohne doppelte Titelleiste, zentriert neben der Werkzeugleiste;
  Schließen-Knopf 36 px, größere Knöpfe, Eingabefelder und Schieberegler
- Seitenauswahl als Popover neben dem Seitenknopf mit Kachelraster
  (56 × 48 px) und „Neue Seite anhängen“
- Immer nur ein Dialog oder Popover gleichzeitig offen (close_overlays);
  Seitenauswahl schließt bei Tippen daneben
- Stift-/Marker-Popover: Farbtupfer 30 px, Stärke-Vorschau 40 px hoch,
  breiterer Schieberegler, deckender Rahmen
- Auswahl-Überlagerung und Griff-Trefferprüfung rechnen mit der
  Display-Skalierung (lag bei Skalierung ≠ 1 neben den Strichen);
  Trefferradius der Griffe 18 Punkte für Finger

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:51:48 +02:00
haraldandClaude Opus 5.5 40eb9a666f chore(release): bump version to 0.3.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 10m58s
Behebt: In v0.3.0 ließ sich auf dem Blatt nicht zeichnen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:16:54 +02:00
haraldandClaude Opus 5.5 85d1ba1647 fix(ui): Zeichnen auf dem Blatt wieder möglich; Farben und Zeichen korrigiert
Regression aus v0.3.0: Die UI-Trefferprüfung (egui layer_id_at) wertete eine
bildschirmfüllende egui-Ebene der Ordnung Background als UI. Dadurch galt jeder
Punkt auf dem Blatt als Oberfläche, und Maus, Stift und Touch zeichneten nicht.
Background-Ebenen zählen jetzt nicht mehr als UI; die Prüfung liegt in ui.rs
und ist durch einen Test abgedeckt (Blattpunkte, Leiste, Skalierung 1 und 2).

Außerdem:
- 25 ungültige vormultiplizierte Farben (RGB > Alpha) auf unmultipliziert
  umgestellt: Knöpfe in Dialogen waren weiße Kästen mit unlesbarer Schrift,
  die Auswahlfüllung war deckend statt durchscheinend
- Zeichen ohne Glyphe in den egui-Schriften ersetzt (✓ ✕ ● ↑ ↓, Variantenzeichen
  U+FE0F hinter ⚠)
- Tests finden künftig ungültige Farben und nicht darstellbare Zeichen in
  ui.rs, selection.rs und main.rs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:12:56 +02:00
haraldandClaude Opus 5.5 b03faedd27 chore(release): bump version to 0.3.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 11m42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:26:27 +02:00
haraldandClaude Opus 5.5 f17e50ec32 feat(ui): senkrechte Werkzeugleiste und Stift-/Touch-Bedienung der UI
Werkzeugleiste
- Schmale senkrechte Leiste statt ~30 Elemente in einer Zeile; seitlich oben
  angedockt (links, im ☰-Menü auf rechts umschaltbar, gespeichert in
  ~/.config/ink-notes/ui.json)
- Symbol-Knöpfe 40 px, auf niedrigen Fenstern schrittweise bis 28 px; Größe
  richtet sich nach dem höchsten Fall, damit Knöpfe beim Werkzeugwechsel
  nicht springen (fester Platz für die Farbpalette)
- Selten genutztes (PDF, Hintergrund, Zoom, Finger-Zeichnen, Leistenseite,
  Updates, Speicherstatus) im ☰-Menü; Popover öffnen zur Blattseite hin
- Blatt wird im freien Bereich neben der Leiste zentriert
- Symbole ohne Glyphe in den egui-Schriften (🖍 ⌫ ⬚ ◄ ► ● ✕ ▾) ersetzt bzw.
  selbst gezeichnet; Hover-Farbe korrigiert (falsch vormultipliziert)

Eingabe
- UI-Trefferprüfung in logischen Punkten (vorher physische Pixel gegen
  egui-Koordinaten: bei Skalierung ≠ 1 trafen Stift und Touch die Leiste nicht)
  und über egui::Context::layer_id_at für alle egui-Bereiche
- Stift-Ereignisse über egui_input_mut() statt ctx.input_mut() (wurden vom
  nächsten Frame verworfen); Stiftbewegungen für Hover/Ziehen weitergereicht,
  Stift bleibt vom Aufsetzen bis Abheben bei der UI
- Berührungen, die auf der UI beginnen, bleiben bis zum Abheben bei egui
- egui-Neuzeichenwünsche (repaint_delay) werden beachtet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:16:51 +02:00
15 changed files with 4105 additions and 1427 deletions
Generated
+1 -1
View File
@@ -1048,7 +1048,7 @@ dependencies = [
[[package]] [[package]]
name = "ink-notes" name = "ink-notes"
version = "0.2.3" version = "0.3.7"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"egui", "egui",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "ink-notes" name = "ink-notes"
version = "0.2.3" version = "0.3.7"
edition = "2021" edition = "2021"
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux" description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes" repository = "https://gitea.pansi.eu/harald/ink-notes"
+8 -1
View File
@@ -3,10 +3,17 @@
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04). Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
### Highlights: ### Highlights:
- **Neue Formen**: Ellipsen sowie in einem Zug gezeichnete Pfeile und Doppelpfeile werden erkannt (Dateiformat-Version 3: ältere Versionen öffnen solche Notizbücher nicht)
- **Stabiler Start**: Kein Absturz mehr, wenn die Grafikkarte nicht nutzbar ist (z. B. fehlende Rechte auf /dev/dri); Software-Renderer als Ausweichlösung und verständliche Fehlermeldung
- **WSL (WSLg)**: Läuft unter WSL automatisch über X11 statt Wayland, da WSLg die Wayland-Verbindung abbricht; übersteuerbar mit `INK_NOTES_DISPLAY=x11|wayland`
- **Wichtige Fehlerbehebung**: Nach einem Neustart auf einer späteren Seite konnte Weiterschreiben den bisherigen Inhalt dieser Seite überschreiben (betrifft v0.2.3 – v0.3.2)
- **Neue Werkzeugleiste**: Schmale senkrechte Leiste mit Symbol-Knöpfen, links oder rechts andockbar; selten genutzte Funktionen im ☰-Menü, das Blatt wird daneben zentriert
- **Einheitliche Dialoge**: Deckende Fenster ohne doppelte Titel, Seitenauswahl mit Vorschaubildern, größere Bedienelemente in allen Popovern
- **UI per Stift und Touch bedienbar**: Werkzeugleiste, Popover und Dialoge reagieren jetzt auch auf Stift und Finger, auch bei Display-Skalierung
- **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Natives Wayland-Tablet-Protokoll mit präziser Druck- und Neigungserfassung - **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Natives Wayland-Tablet-Protokoll mit präziser Druck- und Neigungserfassung
- **Touchscreen Multi-Touch**: Stufenloses 2-Finger Pinch-to-Zoom und Panning mit umschaltbarem Finger-Zeichenmodus - **Touchscreen Multi-Touch**: Stufenloses 2-Finger Pinch-to-Zoom und Panning mit umschaltbarem Finger-Zeichenmodus
- **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation) - **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation)
- **Geometrie- und Formerkennung**: Geraden, Rechtecke, Kreise, Rauten, Polygone & Koordinatensysteme (Draw-and-Hold) - **Geometrie- und Formerkennung**: Geraden, Pfeile & Doppelpfeile, Rechtecke, Kreise, Ellipsen, Dreiecke, Rauten, Vielecke & Koordinatensysteme mit spitzen Pfeilspitzen (Draw-and-Hold); Freiformen bleiben unverändert
- **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave, Papierkorb und fortlaufenden Backups - **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave, Papierkorb und fortlaufenden Backups
- **Datensicherheit**: Versioniertes Dateiformat mit Migration, Quarantäne beschädigter Seiten, Schutz vor Doppelöffnung und Crash-getestetem Speichern - **Datensicherheit**: Versioniertes Dateiformat mit Migration, Quarantäne beschädigter Seiten, Schutz vor Doppelöffnung und Crash-getestetem Speichern
- **In-App Updater**: Automatische Versionsprüfung und sichere Atomar-Aktualisierung via Gitea Releases – mit Pflicht-Signaturprüfung (Minisign) und durchgängigem HTTPS - **In-App Updater**: Automatische Versionsprüfung und sichere Atomar-Aktualisierung via Gitea Releases – mit Pflicht-Signaturprüfung (Minisign) und durchgängigem HTTPS
+6 -1
View File
@@ -312,7 +312,12 @@ impl AutosaveController {
/// Markiert eine Seite als geändert und startet/erneuert das Debounce-Fenster. /// Markiert eine Seite als geändert und startet/erneuert das Debounce-Fenster.
pub fn mark_page_dirty(&mut self, page: &Page, manifest: &NotebookManifest) { pub fn mark_page_dirty(&mut self, page: &Page, manifest: &NotebookManifest) {
if self.read_only || page.persist == crate::document::PersistState::ReadOnlyAfterLoadError { // Nicht geladene Platzhalter nie schreiben: Sie sind leer und würden den Inhalt
// der Seitendatei überschreiben
if self.read_only
|| !page.is_loaded
|| page.persist == crate::document::PersistState::ReadOnlyAfterLoadError
{
return; return;
} }
let page_data = PageData::from(page); let page_data = PageData::from(page);
+121 -35
View File
@@ -87,13 +87,43 @@ pub fn spline_vertices_to_ndc(
} }
/// Verwaltet den GPU-Zustand, Surface, Pipelines und Buffer für das Inking. /// Verwaltet den GPU-Zustand, Surface, Pipelines und Buffer für das Inking.
/// Meldung, wenn kein Grafikadapter das Fenster darstellen kann.
pub const NO_USABLE_GPU_MESSAGE: &str = "Keine nutzbare Grafikschnittstelle gefunden.\n\
Häufigste Ursache: Der Benutzer darf nicht auf die Grafikkarte zugreifen (/dev/dri).\n\
Prüfen mit `ls -l /dev/dri` und `groups`; ggf. `sudo usermod -aG render,video $USER`\n\
und neu anmelden. Alternativ einen Software-Renderer installieren (Paket mesa-vulkan-drivers)\n\
oder die Schnittstelle festlegen, z. B. `WGPU_BACKEND=vulkan ink-notes`.";
/// Wählt den Darstellungsmodus: Mailbox (geringste Latenz), sonst Fifo, sonst den ersten
/// angebotenen. `None`, wenn keiner angeboten wird. `INK_FORCE_FIFO` erzwingt Fifo.
pub fn choose_present_mode(available: &[wgpu::PresentMode]) -> Option<wgpu::PresentMode> {
let force_fifo = std::env::var("INK_FORCE_FIFO").is_ok();
let preferred: &[wgpu::PresentMode] = if force_fifo {
&[wgpu::PresentMode::Fifo]
} else {
&[wgpu::PresentMode::Mailbox, wgpu::PresentMode::Fifo]
};
preferred
.iter()
.copied()
.find(|m| available.contains(m))
.or_else(|| available.first().copied())
}
pub struct CanvasState { pub struct CanvasState {
pub surface: wgpu::Surface<'static>, pub surface: wgpu::Surface<'static>,
/// Verwendeter Grafikadapter (für erneute Abfrage der Surface-Fähigkeiten bei Größenänderung)
adapter: wgpu::Adapter,
/// Meldung „keine Darstellungsmodi“ wurde bereits ausgegeben
presentation_warned: bool,
pub device: wgpu::Device, pub device: wgpu::Device,
pub queue: wgpu::Queue, pub queue: wgpu::Queue,
pub config: wgpu::SurfaceConfiguration, pub config: wgpu::SurfaceConfiguration,
pub size: PhysicalSize<u32>, pub size: PhysicalSize<u32>,
pub viewport: PageViewport, pub viewport: PageViewport,
/// Von seitlicher UI belegte Breite links/rechts in physischen Pixeln
ui_inset_left: f32,
ui_inset_right: f32,
pub camera: crate::viewport::ViewportState, pub camera: crate::viewport::ViewportState,
// Pipelines // Pipelines
@@ -146,23 +176,47 @@ impl CanvasState {
let viewport = let viewport =
PageViewport::calculate(size.width as f32, size.height as f32, Self::VIEWPORT_MARGIN); PageViewport::calculate(size.width as f32, size.height as f32, Self::VIEWPORT_MARGIN);
// 1. WGPU Instance & Surface // 1. WGPU Instance & Surface (Grafikschnittstelle per WGPU_BACKEND wählbar, z. B. "vulkan")
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: wgpu::Backends::all(), backends: wgpu::util::backend_bits_from_env().unwrap_or(wgpu::Backends::all()),
..Default::default() ..Default::default()
}); });
let surface = instance.create_surface(window.clone())?; let surface = instance.create_surface(window.clone())?;
// 2. Adapter auswählen (hohe Performance bevorzugt) // 2. Adapter auswählen: zuerst die GPU, dann der Software-Renderer (z. B. lavapipe).
let adapter = instance // Ein Adapter taugt nur, wenn er für dieses Fenster Formate und Darstellungsmodi
.request_adapter(&wgpu::RequestAdapterOptions { // anbietet – fehlt z. B. der Zugriff auf /dev/dri, ist die Liste leer.
power_preference: wgpu::PowerPreference::HighPerformance, let mut chosen = None;
compatible_surface: Some(&surface), for force_fallback_adapter in [false, true] {
force_fallback_adapter: false, let Some(adapter) = instance
}) .request_adapter(&wgpu::RequestAdapterOptions {
.await power_preference: wgpu::PowerPreference::HighPerformance,
.ok_or("Kein passender GPU-Adapter gefunden")?; compatible_surface: Some(&surface),
force_fallback_adapter,
})
.await
else {
continue;
};
let info = adapter.get_info();
let caps = surface.get_capabilities(&adapter);
match choose_present_mode(&caps.present_modes) {
Some(mode) if !caps.formats.is_empty() && !caps.alpha_modes.is_empty() => {
eprintln!(
"[Grafik] Adapter: {} ({:?}), Darstellungsmodus {:?}",
info.name, info.backend, mode
);
chosen = Some((adapter, caps, mode));
break;
}
_ => eprintln!(
"[Grafik] Adapter {} ({:?}) bietet für das Fenster keine Darstellung an, versuche den nächsten.",
info.name, info.backend
),
}
}
let (adapter, surface_caps, present_mode) = chosen.ok_or(NO_USABLE_GPU_MESSAGE)?;
// 3. Device & Queue erstellen // 3. Device & Queue erstellen
let (device, queue) = adapter let (device, queue) = adapter
@@ -176,8 +230,7 @@ impl CanvasState {
) )
.await?; .await?;
// 4. Zero-Latency-Pufferung: Mailbox mit Fallback Fifo & Frame-Latency 1 // 4. Zero-Latency-Pufferung (Mailbox, sonst Fifo) & Frame-Latency 1
let surface_caps = surface.get_capabilities(&adapter);
let surface_format = surface_caps let surface_format = surface_caps
.formats .formats
.iter() .iter()
@@ -185,20 +238,6 @@ impl CanvasState {
.find(|f| f.is_srgb()) .find(|f| f.is_srgb())
.unwrap_or(surface_caps.formats[0]); .unwrap_or(surface_caps.formats[0]);
let present_mode = if std::env::var("INK_FORCE_FIFO").is_ok() {
eprintln!("Erzwinge PresentMode::Fifo (via INK_FORCE_FIFO)");
wgpu::PresentMode::Fifo
} else if surface_caps
.present_modes
.contains(&wgpu::PresentMode::Mailbox)
{
eprintln!("Verwende Zero-Latency PresentMode::Mailbox");
wgpu::PresentMode::Mailbox
} else {
eprintln!("Mailbox nicht verfügbar, Fallback auf PresentMode::Fifo");
wgpu::PresentMode::Fifo
};
let config = wgpu::SurfaceConfiguration { let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT, usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format: surface_format, format: surface_format,
@@ -366,11 +405,15 @@ impl CanvasState {
Ok(Self { Ok(Self {
surface, surface,
adapter,
presentation_warned: false,
device, device,
queue, queue,
config, config,
size, size,
viewport, viewport,
ui_inset_left: 0.0,
ui_inset_right: 0.0,
strip_pipeline, strip_pipeline,
triangle_pipeline, triangle_pipeline,
camera_buffer, camera_buffer,
@@ -418,21 +461,54 @@ impl CanvasState {
); );
} }
/// Setzt die von seitlicher UI belegten Streifen (physische Pixel), damit die Seite
/// im freien Bereich zentriert wird. Gibt `true` zurück, wenn sich etwas geändert hat.
pub fn set_ui_insets(&mut self, left: f32, right: f32) -> bool {
if (self.ui_inset_left - left).abs() < 0.5 && (self.ui_inset_right - right).abs() < 0.5 {
return false;
}
self.ui_inset_left = left;
self.ui_inset_right = right;
self.recalculate_viewport();
true
}
fn recalculate_viewport(&mut self) {
self.viewport = PageViewport::calculate_with_insets(
self.size.width as f32,
self.size.height as f32,
Self::VIEWPORT_MARGIN,
self.ui_inset_left,
self.ui_inset_right,
);
self.update_uniforms();
}
/// Passt die Surface und Projektionsmatrizen an eine neue Fenstergröße an. /// Passt die Surface und Projektionsmatrizen an eine neue Fenstergröße an.
pub fn resize(&mut self, new_size: PhysicalSize<u32>) { pub fn resize(&mut self, new_size: PhysicalSize<u32>) {
if new_size.width > 0 && new_size.height > 0 { if new_size.width > 0 && new_size.height > 0 {
self.size = new_size; self.size = new_size;
self.config.width = new_size.width; self.config.width = new_size.width;
self.config.height = new_size.height; self.config.height = new_size.height;
// Fähigkeiten neu abfragen: Ein nicht (mehr) angebotener Modus führt in wgpu zum Absturz
let caps = self.surface.get_capabilities(&self.adapter);
if !caps.present_modes.contains(&self.config.present_mode) {
match choose_present_mode(&caps.present_modes) {
Some(mode) => self.config.present_mode = mode,
None => {
if !self.presentation_warned {
eprintln!(
"[Grafik] Fenster kann derzeit nicht dargestellt werden (keine Darstellungsmodi). {}",
NO_USABLE_GPU_MESSAGE
);
self.presentation_warned = true;
}
return;
}
}
}
self.surface.configure(&self.device, &self.config); self.surface.configure(&self.device, &self.config);
self.recalculate_viewport();
self.viewport = PageViewport::calculate(
new_size.width as f32,
new_size.height as f32,
Self::VIEWPORT_MARGIN,
);
self.update_uniforms();
} }
} }
@@ -837,6 +913,16 @@ impl CanvasState {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn test_choose_present_mode() {
use wgpu::PresentMode::{Fifo, Immediate, Mailbox};
assert_eq!(choose_present_mode(&[Fifo, Mailbox]), Some(Mailbox));
assert_eq!(choose_present_mode(&[Fifo, Immediate]), Some(Fifo));
assert_eq!(choose_present_mode(&[Immediate]), Some(Immediate));
// Keine Darstellungsmodi (z. B. kein Zugriff auf /dev/dri): kein Absturz, sondern None
assert_eq!(choose_present_mode(&[]), None);
}
#[test] #[test]
fn test_pixel_to_ndc_mapping() { fn test_pixel_to_ndc_mapping() {
let width = 800.0; let width = 800.0;
+35 -2
View File
@@ -772,7 +772,21 @@ pub struct PageViewport {
impl PageViewport { impl PageViewport {
/// Berechnet die zentrierte DIN-A4-Projektionsfläche unter Beibehaltung des 210x297-Seitenverhältnisses. /// Berechnet die zentrierte DIN-A4-Projektionsfläche unter Beibehaltung des 210x297-Seitenverhältnisses.
pub fn calculate(window_width: f32, window_height: f32, margin: f32) -> Self { pub fn calculate(window_width: f32, window_height: f32, margin: f32) -> Self {
let avail_w = (window_width - 2.0 * margin).max(10.0); Self::calculate_with_insets(window_width, window_height, margin, 0.0, 0.0)
}
/// Wie [`Self::calculate`], aber ohne die von seitlicher UI (z. B. Werkzeugleiste)
/// belegten Streifen links/rechts: Die Seite wird im verbleibenden Bereich zentriert.
pub fn calculate_with_insets(
window_width: f32,
window_height: f32,
margin: f32,
inset_left: f32,
inset_right: f32,
) -> Self {
let inset_left = inset_left.clamp(0.0, window_width * 0.5);
let inset_right = inset_right.clamp(0.0, window_width * 0.5);
let avail_w = (window_width - inset_left - inset_right - 2.0 * margin).max(10.0);
let avail_h = (window_height - 2.0 * margin).max(10.0); let avail_h = (window_height - 2.0 * margin).max(10.0);
let window_ratio = avail_w / avail_h; let window_ratio = avail_w / avail_h;
@@ -790,7 +804,7 @@ impl PageViewport {
(w, h) (w, h)
}; };
let page_x = (window_width - page_w) / 2.0; let page_x = inset_left + (window_width - inset_left - inset_right - page_w) / 2.0;
let page_y = (window_height - page_h) / 2.0; let page_y = (window_height - page_h) / 2.0;
Self { Self {
@@ -931,6 +945,25 @@ mod tests {
assert_eq!(nb.current_page_index, 0); assert_eq!(nb.current_page_index, 0);
} }
#[test]
fn test_viewport_insets_center_page_in_free_area() {
// Breites Fenster: Seite wird durch die Höhe begrenzt und im freien Bereich zentriert
let plain = PageViewport::calculate(1600.0, 900.0, 20.0);
let inset = PageViewport::calculate_with_insets(1600.0, 900.0, 20.0, 120.0, 0.0);
assert!((inset.page_width - plain.page_width).abs() < 0.01);
let free_center = 120.0 + (1600.0 - 120.0) / 2.0;
assert!((inset.page_x + inset.page_width / 2.0 - free_center).abs() < 0.01);
// Hohes Fenster: Breite begrenzt, Seite wird schmaler und bleibt rechts der Leiste
let narrow = PageViewport::calculate_with_insets(600.0, 1200.0, 20.0, 100.0, 0.0);
assert!(narrow.page_x >= 100.0 + 20.0 - 0.01);
assert!(narrow.page_x + narrow.page_width <= 600.0 - 20.0 + 0.01);
// Leiste rechts
let right = PageViewport::calculate_with_insets(600.0, 1200.0, 20.0, 0.0, 100.0);
assert!(right.page_x + right.page_width <= 500.0 - 20.0 + 0.01);
}
#[test] #[test]
fn test_page_viewport_projection() { fn test_page_viewport_projection() {
let vp = PageViewport::calculate(1600.0, 900.0, 20.0); let vp = PageViewport::calculate(1600.0, 900.0, 20.0);
+121 -65
View File
@@ -329,6 +329,59 @@ fn render_recognized_shape(
layer.add_polygon(poly); layer.add_polygon(poly);
} }
RecognizedShape::Ellipse {
center,
radius_x,
radius_y,
rotation,
} => {
let ring = (0..72)
.map(|i| {
let t = i as f32 / 72.0 * std::f32::consts::TAU;
let p =
crate::geometry::ellipse_point(*center, *radius_x, *radius_y, *rotation, t);
let (x, y) = page_to_pdf_mm(p.x, p.y);
(Point::new(Mm(x), Mm(y)), false)
})
.collect();
layer.add_polygon(Polygon {
rings: vec![ring],
mode: PaintMode::Stroke,
winding_order: WindingOrder::NonZero,
});
}
RecognizedShape::Arrow { start, end, double } => {
// Gleiche Spitzen wie auf dem Bildschirm; der Schaft endet an ihrer Basis
const PDF_SHAPE_HALF_WIDTH: f32 = 2.65;
let shaft_len = (*end - *start).length();
let dir = (*end - *start).normalize_or_zero();
let end_head = crate::geometry::arrow_head(*end, dir, PDF_SHAPE_HALF_WIDTH, shaft_len);
let start_head =
crate::geometry::arrow_head(*start, -dir, PDF_SHAPE_HALF_WIDTH, shaft_len);
let line_start = if *double { start_head.0 } else { *start };
let to_pdf = |p: glam::Vec2| {
let (x, y) = page_to_pdf_mm(p.x, p.y);
(Point::new(Mm(x), Mm(y)), false)
};
layer.add_line(Line {
points: vec![to_pdf(line_start), to_pdf(end_head.0)],
is_closed: false,
});
layer.set_fill_color(stroke_color);
let mut heads = vec![(*end, end_head)];
if *double {
heads.push((*start, start_head));
}
for (tip, (_, left, right)) in heads {
layer.add_polygon(Polygon {
rings: vec![vec![to_pdf(tip), to_pdf(left), to_pdf(right)]],
mode: PaintMode::Fill,
winding_order: WindingOrder::NonZero,
});
}
}
RecognizedShape::Rectangle { corners } => { RecognizedShape::Rectangle { corners } => {
let mut ring = Vec::with_capacity(4); let mut ring = Vec::with_capacity(4);
for c in corners { for c in corners {
@@ -412,76 +465,58 @@ fn render_recognized_shape(
y_axis_end, y_axis_end,
with_arrows, with_arrows,
} => { } => {
// Gleiche Pfeilspitzen wie auf dem Bildschirm; Achsen enden an der Basis der Spitze
// (Linienstärke 1,5 pt ≈ 5,3 Seiteneinheiten, halbe Breite ≈ 2,65)
const PDF_SHAPE_HALF_WIDTH: f32 = 2.65;
let y_dir = (*y_axis_end - *origin).normalize_or_zero();
let x_dir = (*x_axis_end - *origin).normalize_or_zero();
let y_arrow = crate::geometry::arrow_head(
*y_axis_end,
y_dir,
PDF_SHAPE_HALF_WIDTH,
(*y_axis_end - *origin).length(),
);
let x_arrow = crate::geometry::arrow_head(
*x_axis_end,
x_dir,
PDF_SHAPE_HALF_WIDTH,
(*x_axis_end - *origin).length(),
);
let (y_line_end, x_line_end) = if *with_arrows {
(y_arrow.0, x_arrow.0)
} else {
(*y_axis_end, *x_axis_end)
};
let (ox, oy) = page_to_pdf_mm(origin.x, origin.y); let (ox, oy) = page_to_pdf_mm(origin.x, origin.y);
let (xx, xy) = page_to_pdf_mm(x_axis_end.x, x_axis_end.y); for end in [y_line_end, x_line_end] {
let (yx, yy) = page_to_pdf_mm(y_axis_end.x, y_axis_end.y); let (ex, ey) = page_to_pdf_mm(end.x, end.y);
layer.add_line(Line {
// Achse 1 (Y) points: vec![
let line_y = Line { (Point::new(Mm(ox), Mm(oy)), false),
points: vec![ (Point::new(Mm(ex), Mm(ey)), false),
(Point::new(Mm(ox), Mm(oy)), false), ],
(Point::new(Mm(yx), Mm(yy)), false), is_closed: false,
], });
is_closed: false, }
};
layer.add_line(line_y);
// Achse 2 (X)
let line_x = Line {
points: vec![
(Point::new(Mm(ox), Mm(oy)), false),
(Point::new(Mm(xx), Mm(xy)), false),
],
is_closed: false,
};
layer.add_line(line_x);
// Pfeilspitzen als gefüllte Dreiecke // Pfeilspitzen als gefüllte Dreiecke
if *with_arrows { if *with_arrows {
layer.set_fill_color(stroke_color); layer.set_fill_color(stroke_color);
for (tip, (_, left, right)) in [(*y_axis_end, y_arrow), (*x_axis_end, x_arrow)] {
let y_dir = (*y_axis_end - *origin).normalize_or_zero(); let ring = [tip, left, right]
let arrow_len_page = 25.0; .iter()
let arrow_w_page = 16.0; .map(|p| {
let (px, py) = page_to_pdf_mm(p.x, p.y);
let base_y = *y_axis_end - y_dir * arrow_len_page; (Point::new(Mm(px), Mm(py)), false)
let left_y = base_y + glam::Vec2::new(-y_dir.y, y_dir.x) * (arrow_w_page * 0.5); })
let right_y = base_y - glam::Vec2::new(-y_dir.y, y_dir.x) * (arrow_w_page * 0.5); .collect();
layer.add_polygon(Polygon {
let (tx, ty) = page_to_pdf_mm(y_axis_end.x, y_axis_end.y); rings: vec![ring],
let (lx, ly) = page_to_pdf_mm(left_y.x, left_y.y); mode: PaintMode::Fill,
let (rx, ry) = page_to_pdf_mm(right_y.x, right_y.y); winding_order: WindingOrder::NonZero,
});
let arrow_poly_y = Polygon { }
rings: vec![vec![
(Point::new(Mm(tx), Mm(ty)), false),
(Point::new(Mm(lx), Mm(ly)), false),
(Point::new(Mm(rx), Mm(ry)), false),
]],
mode: PaintMode::Fill,
winding_order: WindingOrder::NonZero,
};
layer.add_polygon(arrow_poly_y);
let x_dir = (*x_axis_end - *origin).normalize_or_zero();
let base_x = *x_axis_end - x_dir * arrow_len_page;
let left_x = base_x + glam::Vec2::new(-x_dir.y, x_dir.x) * (arrow_w_page * 0.5);
let right_x = base_x - glam::Vec2::new(-x_dir.y, x_dir.x) * (arrow_w_page * 0.5);
let (tx_x, ty_x) = page_to_pdf_mm(x_axis_end.x, x_axis_end.y);
let (lx_x, ly_x) = page_to_pdf_mm(left_x.x, left_x.y);
let (rx_x, ry_x) = page_to_pdf_mm(right_x.x, right_x.y);
let arrow_poly_x = Polygon {
rings: vec![vec![
(Point::new(Mm(tx_x), Mm(ty_x)), false),
(Point::new(Mm(lx_x), Mm(ly_x)), false),
(Point::new(Mm(rx_x), Mm(ry_x)), false),
]],
mode: PaintMode::Fill,
winding_order: WindingOrder::NonZero,
};
layer.add_polygon(arrow_poly_x);
} }
} }
} }
@@ -838,6 +873,27 @@ mod tests {
); );
page.add_stroke(coords_stroke); page.add_stroke(coords_stroke);
let ellipse_stroke = Stroke::new(40, vec![], vec![], [0.1, 0.5, 0.5, 1.0]).with_shape(
crate::geometry::RecognizedShape::Ellipse {
center: glam::Vec2::new(1400.0, 600.0),
radius_x: 300.0,
radius_y: 150.0,
rotation: 0.4,
},
);
page.add_stroke(ellipse_stroke);
for (id, double) in [(41, false), (42, true)] {
let arrow_stroke = Stroke::new(id, vec![], vec![], [0.2, 0.2, 0.2, 1.0]).with_shape(
crate::geometry::RecognizedShape::Arrow {
start: glam::Vec2::new(1200.0, 1800.0 + id as f32 * 40.0),
end: glam::Vec2::new(1800.0, 1800.0 + id as f32 * 40.0),
double,
},
);
page.add_stroke(arrow_stroke);
}
let tri_stroke = Stroke::new(5, vec![], vec![], [0.6, 0.2, 0.7, 1.0]).with_shape( let tri_stroke = Stroke::new(5, vec![], vec![], [0.6, 0.2, 0.7, 1.0]).with_shape(
crate::geometry::RecognizedShape::Triangle { crate::geometry::RecognizedShape::Triangle {
corners: [ corners: [
+1373 -145
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -8,7 +8,9 @@ pub mod export;
pub mod filter; pub mod filter;
pub mod geometry; pub mod geometry;
pub mod history; pub mod history;
pub mod platform;
pub mod selection; pub mod selection;
pub mod settings;
pub mod spline; pub mod spline;
pub mod storage; pub mod storage;
pub mod ui; pub mod ui;
+172 -50
View File
@@ -9,8 +9,9 @@ use winit::{
event::{ event::{
ElementState, Event, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent, ElementState, Event, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent,
}, },
event_loop::{ControlFlow, EventLoop}, event_loop::{ControlFlow, EventLoopBuilder},
keyboard::{Key, ModifiersState, NamedKey}, keyboard::{Key, ModifiersState, NamedKey},
platform::{wayland::EventLoopBuilderExtWayland, x11::EventLoopBuilderExtX11},
window::{Window, WindowBuilder}, window::{Window, WindowBuilder},
}; };
@@ -20,9 +21,11 @@ use ink_notes::canvas::CanvasState;
use ink_notes::document::{Notebook, PageBackground}; use ink_notes::document::{Notebook, PageBackground};
use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy}; use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy};
use ink_notes::geometry::{self, RecognizedShape}; use ink_notes::geometry::{self, RecognizedShape};
use ink_notes::platform::{self, DisplayBackend};
use ink_notes::selection::{ use ink_notes::selection::{
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState, render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
}; };
use ink_notes::settings::{SidebarSide, UiSettings};
use ink_notes::storage::{self, NotebookManifest}; use ink_notes::storage::{self, NotebookManifest};
use ink_notes::ui::{self, ActiveTool, UiAction, UiState}; use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
use ink_notes::updater::UpdaterController; use ink_notes::updater::UpdaterController;
@@ -136,7 +139,20 @@ fn commit_stroke_to_notebook(
} }
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
let event_loop = EventLoop::new()?; // Anzeigesystem wählen: Unter WSL (WSLg) bricht Wayland ab, X11 (Xwayland) läuft stabil
let mut event_loop_builder = EventLoopBuilder::new();
match platform::display_backend_from_env() {
DisplayBackend::X11 => {
eprintln!("[Anzeige] Verwende X11 (unter WSL Standard; übersteuerbar mit INK_NOTES_DISPLAY=wayland)");
event_loop_builder.with_x11();
}
DisplayBackend::Wayland => {
eprintln!("[Anzeige] Verwende Wayland (INK_NOTES_DISPLAY=wayland)");
event_loop_builder.with_wayland();
}
DisplayBackend::Automatic => {}
}
let event_loop = event_loop_builder.build()?;
let window = Arc::new( let window = Arc::new(
WindowBuilder::new() WindowBuilder::new()
.with_title("ink-notes (Latenzoptimiertes Linux-Inking)") .with_title("ink-notes (Latenzoptimiertes Linux-Inking)")
@@ -144,7 +160,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.build(&event_loop)?, .build(&event_loop)?,
); );
let mut canvas = pollster::block_on(CanvasState::new(window.clone()))?; let mut canvas = match pollster::block_on(CanvasState::new(window.clone())) {
Ok(canvas) => canvas,
Err(err) => {
eprintln!("[Grafik-Fehler] {err}");
std::process::exit(1);
}
};
let mut tablet_manager = match octotablet::builder::Builder::default().build_shared(&window) { let mut tablet_manager = match octotablet::builder::Builder::default().build_shared(&window) {
Ok(mgr) => { Ok(mgr) => {
@@ -230,6 +252,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
); );
let mut ui_state = UiState::new(); let mut ui_state = UiState::new();
// Gespeicherte UI-Einstellungen (Seite der Werkzeugleiste)
let ui_settings_path = UiSettings::default_path();
let mut ui_settings = ui_settings_path
.as_deref()
.map(UiSettings::load_from)
.unwrap_or_default();
ui_state.sidebar_side = ui_settings.sidebar_side;
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;
@@ -247,7 +276,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
Err(storage::StorageError::AlreadyLocked(msg)) => { Err(storage::StorageError::AlreadyLocked(msg)) => {
eprintln!("[Storage-Lock] {msg}"); eprintln!("[Storage-Lock] {msg}");
autosave.set_read_only(true); autosave.set_read_only(true);
ui_state.set_export_message("⚠️ In anderer Instanz geöffnet (Schreibschutz)"); ui_state.set_export_message("⚠ In anderer Instanz geöffnet (Schreibschutz)");
None None
} }
Err(e) => { Err(e) => {
@@ -275,6 +304,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut active_touches: std::collections::HashMap<u64, (f32, f32)> = let mut active_touches: std::collections::HashMap<u64, (f32, f32)> =
std::collections::HashMap::new(); std::collections::HashMap::new();
let mut pinch_prev_distance: Option<f32> = None; let mut pinch_prev_distance: Option<f32> = None;
// Berührungen, die auf der UI begonnen haben, gehören bis zum Abheben egui
let mut ui_touch_ids: std::collections::HashSet<u64> = std::collections::HashSet::new();
let mut pinch_prev_center: Option<(f32, f32)> = None; let mut pinch_prev_center: Option<(f32, f32)> = None;
let mut is_pinch_gesturing = false; let mut is_pinch_gesturing = false;
let mut is_touch_drawing = false; let mut is_touch_drawing = false;
@@ -287,6 +318,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut stylus_tool_id: Option<octotablet::tool::ID> = None; let mut stylus_tool_id: Option<octotablet::tool::ID> = None;
let mut stylus_barrel_pressed = false; let mut stylus_barrel_pressed = false;
let mut stylus_screen_pos = (0.0_f32, 0.0_f32); let mut stylus_screen_pos = (0.0_f32, 0.0_f32);
// Stift hat auf der UI aufgesetzt: alle Ereignisse bis zum Abheben gehen an egui
let mut stylus_ui_capture = false;
// Von egui angeforderter Zeitpunkt für das nächste Neuzeichnen (Layout-Vermessung, Tooltips)
let mut egui_repaint_at: Option<Instant> = None;
// Viewport-Pan & Zoom-Zustand // Viewport-Pan & Zoom-Zustand
let mut is_panning = false; let mut is_panning = false;
@@ -379,6 +414,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
octotablet::events::ToolEvent::Out octotablet::events::ToolEvent::Out
| octotablet::events::ToolEvent::Removed => { | octotablet::events::ToolEvent::Removed => {
is_stylus_in_proximity = false; is_stylus_in_proximity = false;
if stylus_ui_capture {
stylus_ui_capture = false;
let (sx, sy) = stylus_screen_pos;
let input = egui_winit_state.egui_input_mut();
input.events.push(egui::Event::PointerButton {
pos: egui::pos2(sx / scale, sy / scale),
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::default(),
});
input.events.push(egui::Event::PointerGone);
window.request_redraw();
}
if stylus_tool_id == Some(tool.id()) { if stylus_tool_id == Some(tool.id()) {
stylus_tool_id = None; stylus_tool_id = None;
if is_stylus_drawing { if is_stylus_drawing {
@@ -426,6 +474,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
current_pressure = p; current_pressure = p;
} }
// Stiftbewegung an egui weitergeben (Hover, Schieberegler ziehen),
// solange nicht auf dem Blatt gezeichnet oder radiert wird
if stylus_ui_capture || (!is_stylus_drawing && !is_erasing) {
let ui_pos = egui::pos2(sx / scale, sy / scale);
egui_winit_state
.egui_input_mut()
.events
.push(egui::Event::PointerMoved(ui_pos));
if stylus_ui_capture
|| ui::is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale)
{
window.request_redraw();
}
}
if stylus_ui_capture {
continue;
}
if is_erasing { if is_erasing {
if let Some(page_pt) = canvas.screen_to_page(sx, sy) { if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
let page = notebook.current_page_mut(); let page = notebook.current_page_mut();
@@ -472,28 +538,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
stylus_tool_id = Some(tool.id()); stylus_tool_id = Some(tool.id());
let (sx, sy) = stylus_screen_pos; let (sx, sy) = stylus_screen_pos;
let is_stylus_in_ui = egui_ctx.wants_pointer_input() if ui::is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale) {
|| ui_state.is_point_in_ui(sx, sy) // In die Eingabe-Warteschlange des NÄCHSTEN egui-Frames
|| (ui_state.show_page_jump_popup // (egui_ctx.input_mut würde vom nächsten Frame überschrieben)
&& egui_ctx.is_pointer_over_area()) let ui_pos = egui::pos2(sx / scale, sy / scale);
|| (ui_state.is_document_menu_open let input = egui_winit_state.egui_input_mut();
&& egui_ctx.is_pointer_over_area()) input.events.push(egui::Event::PointerMoved(ui_pos));
|| (ui_state.is_update_dialog_open input.events.push(egui::Event::PointerButton {
&& egui_ctx.is_pointer_over_area()); pos: ui_pos,
button: egui::PointerButton::Primary,
if is_stylus_in_ui { pressed: true,
egui_ctx.input_mut(|i| { modifiers: egui::Modifiers::default(),
i.events.push(egui::Event::PointerMoved(egui::pos2(
sx / scale,
sy / scale,
)));
i.events.push(egui::Event::PointerButton {
pos: egui::pos2(sx / scale, sy / scale),
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::default(),
});
}); });
stylus_ui_capture = true;
window.request_redraw(); window.request_redraw();
continue; continue;
} }
@@ -546,24 +603,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
octotablet::events::ToolEvent::Up => { octotablet::events::ToolEvent::Up => {
let (sx, sy) = stylus_screen_pos; let (sx, sy) = stylus_screen_pos;
let is_stylus_in_ui = egui_ctx.wants_pointer_input() if stylus_ui_capture {
|| ui_state.is_point_in_ui(sx, sy) stylus_ui_capture = false;
|| (ui_state.show_page_jump_popup egui_winit_state.egui_input_mut().events.push(
&& egui_ctx.is_pointer_over_area()) egui::Event::PointerButton {
|| (ui_state.is_document_menu_open
&& egui_ctx.is_pointer_over_area())
|| (ui_state.is_update_dialog_open
&& egui_ctx.is_pointer_over_area());
if is_stylus_in_ui {
egui_ctx.input_mut(|i| {
i.events.push(egui::Event::PointerButton {
pos: egui::pos2(sx / scale, sy / scale), pos: egui::pos2(sx / scale, sy / scale),
button: egui::PointerButton::Primary, button: egui::PointerButton::Primary,
pressed: false, pressed: false,
modifiers: egui::Modifiers::default(), modifiers: egui::Modifiers::default(),
}); },
}); );
window.request_redraw(); window.request_redraw();
continue; continue;
} }
@@ -633,6 +682,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
} }
// Von egui angefordertes, verzögertes Neuzeichnen (Tooltips, Animationen)
if let Some(at) = egui_repaint_at {
if Instant::now() >= at {
egui_repaint_at = None;
window.request_redraw();
}
}
// Bei anstehenden Änderungen, aktivem Toast, Stylus in Proximity oder aktivem Zeichnen regelmäßig aufwachen // Bei anstehenden Änderungen, aktivem Toast, Stylus in Proximity oder aktivem Zeichnen regelmäßig aufwachen
if new_status == AutosaveStatus::Pending if new_status == AutosaveStatus::Pending
|| new_status == AutosaveStatus::Saving || new_status == AutosaveStatus::Saving
@@ -642,6 +699,8 @@ 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 {
target.set_control_flow(ControlFlow::WaitUntil(at));
} else { } else {
target.set_control_flow(ControlFlow::Wait); target.set_control_flow(ControlFlow::Wait);
} }
@@ -657,9 +716,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 2. Prüfen, ob das UI Pointer-Interaktionen beansprucht (Event-Isolation) // 2. Prüfen, ob das UI Pointer-Interaktionen beansprucht (Event-Isolation)
// Konsumiert Klicks innerhalb der abgerundeten Pill-Toolbar, des Popovers oder offener Menüs // Konsumiert Klicks innerhalb der abgerundeten Pill-Toolbar, des Popovers oder offener Menüs
let is_pointer_in_ui = egui_ctx.wants_pointer_input() let is_pointer_in_ui = egui_ctx.wants_pointer_input()
|| ui_state.is_point_in_ui(cursor_screen_pos.0, cursor_screen_pos.1) || ui::is_screen_point_in_ui(
|| (ui_state.show_page_jump_popup && egui_ctx.is_pointer_over_area()) &egui_ctx,
|| (ui_state.is_document_menu_open && egui_ctx.is_pointer_over_area()); &ui_state,
cursor_screen_pos,
window.scale_factor() as f32,
);
match event { match event {
WindowEvent::CloseRequested => { WindowEvent::CloseRequested => {
@@ -686,6 +748,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
is_erasing = false; is_erasing = false;
is_mouse_drawing = false; is_mouse_drawing = false;
active_touches.clear(); active_touches.clear();
ui_touch_ids.clear();
stylus_ui_capture = false;
is_pinch_gesturing = false; is_pinch_gesturing = false;
pinch_prev_distance = None; pinch_prev_distance = None;
pinch_prev_center = None; pinch_prev_center = None;
@@ -1137,7 +1201,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 2. Objektauswahl & Transformation im Select-Modus // 2. Objektauswahl & Transformation im Select-Modus
if ui_state.active_tool == ActiveTool::Select { if ui_state.active_tool == ActiveTool::Select {
let screen_vec = Vec2::new(sx, sy); let screen_vec = Vec2::new(sx, sy);
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec); let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec, egui_ctx.pixels_per_point());
match hit { match hit {
SelectionHandleHit::RotateHandle { center_page } => { SelectionHandleHit::RotateHandle { center_page } => {
if let Some(page_pt) = canvas.screen_to_page(sx, sy) { if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
@@ -1469,15 +1533,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
return; return;
} }
// Berührungen der UI (egui-winit hat sie bereits an egui weitergegeben)
if ui_touch_ids.contains(&touch.id) {
if matches!(touch.phase, TouchPhase::Ended | TouchPhase::Cancelled) {
ui_touch_ids.remove(&touch.id);
}
window.request_redraw();
return;
}
match touch.phase { match touch.phase {
TouchPhase::Started => { TouchPhase::Started => {
// Event-Isolation für Touch-Eingaben // Event-Isolation für Touch-Eingaben
let is_touch_in_ui = egui_ctx.wants_pointer_input() let is_touch_in_ui = ui::is_screen_point_in_ui(
|| ui_state.is_point_in_ui(tx, ty) &egui_ctx,
|| (ui_state.show_page_jump_popup && egui_ctx.is_pointer_over_area()) &ui_state,
|| (ui_state.is_document_menu_open && egui_ctx.is_pointer_over_area()) (tx, ty),
|| (ui_state.is_update_dialog_open && egui_ctx.is_pointer_over_area()); window.scale_factor() as f32,
);
if is_touch_in_ui { if is_touch_in_ui {
ui_touch_ids.insert(touch.id);
window.request_redraw();
return; return;
} }
@@ -1525,7 +1601,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
} else if ui_state.active_tool == ActiveTool::Select { } else if ui_state.active_tool == ActiveTool::Select {
let screen_vec = Vec2::new(tx, ty); let screen_vec = Vec2::new(tx, ty);
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec); let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec, egui_ctx.pixels_per_point());
match hit { match hit {
SelectionHandleHit::RotateHandle { center_page } => { SelectionHandleHit::RotateHandle { center_page } => {
if let Some(page_pt) = canvas.screen_to_page(tx, ty) { if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
@@ -1807,6 +1883,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
updater.update(); updater.update();
let actions = ui::render_ui(&egui_ctx, &mut ui_state, &notebook, autosave.status(), &updater); let actions = ui::render_ui(&egui_ctx, &mut ui_state, &notebook, autosave.status(), &updater);
// Seite neben der Werkzeugleiste zentrieren (Leistenbreite in physischen Pixeln)
let reserved = ui_state.sidebar_reserved_width() * window.scale_factor() as f32;
let (inset_left, inset_right) = match ui_state.sidebar_side {
SidebarSide::Left => (reserved, 0.0),
SidebarSide::Right => (0.0, reserved),
};
if canvas.set_ui_insets(inset_left, inset_right) {
window.request_redraw();
}
// 4. UI-Aktionen verarbeiten // 4. UI-Aktionen verarbeiten
for action in actions { for action in actions {
match action { match action {
@@ -2003,6 +2089,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
UiAction::LoadAllPages => {
// Für die Vorschaubilder der Seitenauswahl; Fehler werden wie beim
// Blättern behandelt (Ersatzseite, Warnung beim Öffnen der Seite)
for idx in 0..notebook.pages.len() {
if !notebook.pages[idx].is_loaded {
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, idx);
}
}
window.request_redraw();
}
UiAction::GoToPage(idx) => { UiAction::GoToPage(idx) => {
if idx < notebook.pages.len() { if idx < notebook.pages.len() {
selection_state.clear(); selection_state.clear();
@@ -2053,7 +2149,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
eprintln!("[Storage-Lock] {msg}"); eprintln!("[Storage-Lock] {msg}");
_notebook_lock = None; _notebook_lock = None;
autosave.set_read_only(true); autosave.set_read_only(true);
ui_state.set_export_message("⚠️ In anderer Instanz geöffnet (Schreibschutz)"); ui_state.set_export_message("⚠ In anderer Instanz geöffnet (Schreibschutz)");
} }
Err(e) => { Err(e) => {
eprintln!("[Storage-Lock] Konnte Lock nicht erwerben: {e}"); eprintln!("[Storage-Lock] Konnte Lock nicht erwerben: {e}");
@@ -2312,6 +2408,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
} }
} }
UiAction::SetSidebarSide(side) => {
ui_settings.sidebar_side = side;
if let Some(path) = ui_settings_path.as_deref() {
if let Err(err) = ui_settings.save_to(path) {
eprintln!("[Einstellungen] Konnte '{}' nicht speichern: {err}", path.display());
ui_state.set_export_message(format!("Einstellung nicht gespeichert: {err}"));
}
}
window.request_redraw();
}
UiAction::ForceExit => { UiAction::ForceExit => {
println!("[Shutdown] Beenden erzwungen trotz ungespeicherter Änderungen."); println!("[Shutdown] Beenden erzwungen trotz ungespeicherter Änderungen.");
target.exit(); target.exit();
@@ -2323,6 +2429,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let full_output = egui_ctx.end_frame(); let full_output = egui_ctx.end_frame();
egui_winit_state.handle_platform_output(&window, full_output.platform_output); egui_winit_state.handle_platform_output(&window, full_output.platform_output);
// egui braucht Folge-Frames, z. B. um neu angeordnete Bereiche zu vermessen
// (die Werkzeugleiste wird erst im 2.–3. Frame korrekt zentriert)
let repaint_delay = full_output
.viewport_output
.get(&egui::ViewportId::ROOT)
.map(|v| v.repaint_delay)
.unwrap_or(Duration::MAX);
if repaint_delay.is_zero() {
egui_repaint_at = None;
window.request_redraw();
} else if repaint_delay < Duration::from_secs(10) {
egui_repaint_at = Some(Instant::now() + repaint_delay);
} else {
egui_repaint_at = None;
}
let clipped_primitives = egui_ctx.tessellate(full_output.shapes, egui_ctx.pixels_per_point()); let clipped_primitives = egui_ctx.tessellate(full_output.shapes, egui_ctx.pixels_per_point());
let screen_descriptor = egui_wgpu::ScreenDescriptor { let screen_descriptor = egui_wgpu::ScreenDescriptor {
size_in_pixels: [canvas.size.width, canvas.size.height], size_in_pixels: [canvas.size.width, canvas.size.height],
+96
View File
@@ -0,0 +1,96 @@
//! Erkennung der Laufzeitumgebung und Wahl des Anzeigesystems (X11 oder Wayland).
//!
//! Unter WSL (WSLg) trennt der Wayland-Compositor die Verbindung, sobald die App ein Bild
//! über Vulkan übergibt ("Connection reset by peer"). Über X11 (Xwayland) läuft sie stabil,
//! daher wird unter WSL standardmäßig X11 verwendet. `INK_NOTES_DISPLAY=x11|wayland`
//! übersteuert die Wahl.
/// Anzeigesystem, mit dem die Ereignisschleife erzeugt wird.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisplayBackend {
/// Automatische Wahl von winit (Wayland, falls verfügbar, sonst X11)
Automatic,
X11,
Wayland,
}
/// Wählt das Anzeigesystem.
///
/// - `override_value`: Inhalt von `INK_NOTES_DISPLAY` (`x11` oder `wayland`, sonst ignoriert)
/// - `is_wsl`: läuft unter WSL
/// - `has_x11_display`: `DISPLAY` ist gesetzt (X11/Xwayland erreichbar)
pub fn choose_display_backend(
override_value: Option<&str>,
is_wsl: bool,
has_x11_display: bool,
) -> DisplayBackend {
match override_value
.map(|v| v.trim().to_ascii_lowercase())
.as_deref()
{
Some("x11") => return DisplayBackend::X11,
Some("wayland") => return DisplayBackend::Wayland,
_ => {}
}
if is_wsl && has_x11_display {
DisplayBackend::X11
} else {
DisplayBackend::Automatic
}
}
/// Läuft das Programm unter WSL (Windows-Subsystem für Linux)?
pub fn is_wsl() -> bool {
if std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some() {
return true;
}
std::fs::read_to_string("/proc/sys/kernel/osrelease")
.map(|release| release.to_ascii_lowercase().contains("microsoft"))
.unwrap_or(false)
}
/// Wahl anhand der aktuellen Umgebung.
pub fn display_backend_from_env() -> DisplayBackend {
choose_display_backend(
std::env::var("INK_NOTES_DISPLAY").ok().as_deref(),
is_wsl(),
std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_choose_display_backend() {
// WSL mit X11-Display: X11 (Wayland unter WSLg bricht ab)
assert_eq!(
choose_display_backend(None, true, true),
DisplayBackend::X11
);
// WSL ohne X11-Display: winit entscheiden lassen
assert_eq!(
choose_display_backend(None, true, false),
DisplayBackend::Automatic
);
// Natives Linux: winit entscheiden lassen
assert_eq!(
choose_display_backend(None, false, true),
DisplayBackend::Automatic
);
// Übersteuerung
assert_eq!(
choose_display_backend(Some("wayland"), true, true),
DisplayBackend::Wayland
);
assert_eq!(
choose_display_backend(Some(" X11 "), false, true),
DisplayBackend::X11
);
assert_eq!(
choose_display_backend(Some("quatsch"), true, true),
DisplayBackend::X11
);
}
}
+28 -12
View File
@@ -115,12 +115,15 @@ impl SelectionState {
Page::selection_aabb(&strokes) Page::selection_aabb(&strokes)
} }
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten durch. /// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten
/// (physische Pixel) durch. Abstände und Trefferradien sind in logischen Punkten festgelegt
/// und werden mit `pixels_per_point` skaliert, damit sie zur gezeichneten Überlagerung passen.
pub fn hit_test_handles( pub fn hit_test_handles(
&self, &self,
page: &Page, page: &Page,
canvas: &CanvasState, canvas: &CanvasState,
screen_pos: Vec2, screen_pos: Vec2,
pixels_per_point: f32,
) -> SelectionHandleHit { ) -> SelectionHandleHit {
let Some(aabb) = self.get_selection_aabb(page) else { let Some(aabb) = self.get_selection_aabb(page) else {
return SelectionHandleHit::None; return SelectionHandleHit::None;
@@ -134,9 +137,10 @@ impl SelectionState {
let screen_center = (screen_min + screen_max) * 0.5; let screen_center = (screen_min + screen_max) * 0.5;
let page_center = (aabb.min + aabb.max) * 0.5; let page_center = (aabb.min + aabb.max) * 0.5;
// 1. Drehgriff testen (26 px oberhalb des oberen Randes) // 1. Drehgriff testen (oberhalb des oberen Randes, wie gezeichnet)
let rot_screen = Vec2::new(screen_center.x, screen_min.y - 26.0); let ppp = pixels_per_point;
if (screen_pos - rot_screen).length() <= 14.0 { let rot_screen = Vec2::new(screen_center.x, screen_min.y - ROTATE_HANDLE_OFFSET * ppp);
if (screen_pos - rot_screen).length() <= HANDLE_HIT_RADIUS * ppp {
return SelectionHandleHit::RotateHandle { return SelectionHandleHit::RotateHandle {
center_page: page_center, center_page: page_center,
}; };
@@ -164,7 +168,7 @@ impl SelectionState {
]; ];
for i in 0..4 { for i in 0..4 {
if (screen_pos - corners_screen[i]).length() <= 12.0 { if (screen_pos - corners_screen[i]).length() <= HANDLE_HIT_RADIUS * ppp {
return SelectionHandleHit::CornerHandle { return SelectionHandleHit::CornerHandle {
corner_idx: i, corner_idx: i,
fixed_corner_page: fixed_corners_page[i], fixed_corner_page: fixed_corners_page[i],
@@ -273,6 +277,16 @@ impl SelectionState {
} }
/// Rendert das Auswahl-Overlay (Bounding Box, Ecken, Dreh-Griff, Marquee Box) über egui. /// Rendert das Auswahl-Overlay (Bounding Box, Ecken, Dreh-Griff, Marquee Box) über egui.
/// Abstand des Drehgriffs über dem Auswahlrahmen (logische Punkte).
const ROTATE_HANDLE_OFFSET: f32 = 24.0;
/// Trefferradius der Griffe (logische Punkte), groß genug für Finger.
const HANDLE_HIT_RADIUS: f32 = 18.0;
/// Wandelt Fensterpixel (physisch) in egui-Koordinaten (logische Punkte) um.
fn to_ui(screen: [f32; 2], pixels_per_point: f32) -> egui::Pos2 {
egui::pos2(screen[0] / pixels_per_point, screen[1] / pixels_per_point)
}
pub fn render_selection_overlay( pub fn render_selection_overlay(
ctx: &egui::Context, ctx: &egui::Context,
selection: &SelectionState, selection: &SelectionState,
@@ -283,6 +297,8 @@ pub fn render_selection_overlay(
egui::Order::Foreground, egui::Order::Foreground,
egui::Id::new("selection_overlay_layer"), egui::Id::new("selection_overlay_layer"),
)); ));
// Fensterpixel sind physisch, egui zeichnet in logischen Punkten
let ppp = ctx.pixels_per_point();
// 1. Wenn aktuell ein Auswahlrahmen gezogen wird (Marquee Box) // 1. Wenn aktuell ein Auswahlrahmen gezogen wird (Marquee Box)
if let DragMode::BoxSelect { if let DragMode::BoxSelect {
@@ -290,8 +306,8 @@ pub fn render_selection_overlay(
current_page, current_page,
} = selection.drag_mode } = selection.drag_mode
{ {
let p1 = canvas.page_to_screen(start_page); let p1 = to_ui(canvas.page_to_screen(start_page), ppp);
let p2 = canvas.page_to_screen(current_page); let p2 = to_ui(canvas.page_to_screen(current_page), ppp);
let min_x = p1[0].min(p2[0]); let min_x = p1[0].min(p2[0]);
let min_y = p1[1].min(p2[1]); let min_y = p1[1].min(p2[1]);
let max_x = p1[0].max(p2[0]); let max_x = p1[0].max(p2[0]);
@@ -303,14 +319,14 @@ pub fn render_selection_overlay(
painter.rect_filled( painter.rect_filled(
rect, rect,
egui::Rounding::same(4.0_f32), egui::Rounding::same(4.0_f32),
egui::Color32::from_rgba_premultiplied(40, 120, 240, 35), egui::Color32::from_rgba_unmultiplied(40, 120, 240, 35),
); );
painter.rect_stroke( painter.rect_stroke(
rect, rect,
egui::Rounding::same(4.0_f32), egui::Rounding::same(4.0_f32),
egui::Stroke::new( egui::Stroke::new(
1.5_f32, 1.5_f32,
egui::Color32::from_rgba_premultiplied(80, 160, 255, 220), egui::Color32::from_rgba_unmultiplied(80, 160, 255, 220),
), ),
); );
return; return;
@@ -321,8 +337,8 @@ pub fn render_selection_overlay(
return; return;
}; };
let p_min = canvas.page_to_screen(aabb.min); let p_min = to_ui(canvas.page_to_screen(aabb.min), ppp);
let p_max = canvas.page_to_screen(aabb.max); let p_max = to_ui(canvas.page_to_screen(aabb.max), ppp);
let screen_min = egui::pos2(p_min[0].min(p_max[0]), p_min[1].min(p_max[1])); let screen_min = egui::pos2(p_min[0].min(p_max[0]), p_min[1].min(p_max[1]));
let screen_max = egui::pos2(p_min[0].max(p_max[0]), p_min[1].max(p_max[1])); let screen_max = egui::pos2(p_min[0].max(p_max[0]), p_min[1].max(p_max[1]));
@@ -338,7 +354,7 @@ pub fn render_selection_overlay(
// Dreh-Stängel (Stalk) und Drehgriff // Dreh-Stängel (Stalk) und Drehgriff
let top_center = egui::pos2(rect.center().x, rect.min.y); let top_center = egui::pos2(rect.center().x, rect.min.y);
let rot_handle = egui::pos2(rect.center().x, rect.min.y - 24.0_f32); let rot_handle = egui::pos2(rect.center().x, rect.min.y - ROTATE_HANDLE_OFFSET);
painter.line_segment( painter.line_segment(
[top_center, rot_handle], [top_center, rot_handle],
+84
View File
@@ -0,0 +1,84 @@
//! Persistente UI-Einstellungen (`$XDG_CONFIG_HOME/ink-notes/ui.json`).
//!
//! Fehlende oder unlesbare Dateien führen zu Standardwerten, damit die App immer startet.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::storage::{self, StorageError};
/// Fensterseite, an der die Werkzeugleiste angedockt ist.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum SidebarSide {
/// Links (Standard: die Schreibhand von Rechtshändern verdeckt die Leiste nicht)
#[default]
Left,
/// Rechts (für Linkshänder)
Right,
}
/// Benutzereinstellungen der Oberfläche.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct UiSettings {
pub sidebar_side: SidebarSide,
}
impl UiSettings {
/// Standardpfad gemäß XDG-Spezifikation.
pub fn default_path() -> Option<PathBuf> {
let base = match std::env::var("XDG_CONFIG_HOME") {
Ok(dir) if !dir.trim().is_empty() => PathBuf::from(dir),
_ => PathBuf::from(std::env::var("HOME").ok()?).join(".config"),
};
Some(base.join("ink-notes").join("ui.json"))
}
/// Lädt die Einstellungen; bei fehlender oder beschädigter Datei gelten Standardwerte.
pub fn load_from(path: &Path) -> Self {
match std::fs::read_to_string(path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
eprintln!(
"[Einstellungen] '{}' ist ungültig ({e}), verwende Standardwerte.",
path.display()
);
Self::default()
}),
Err(_) => Self::default(),
}
}
/// Speichert die Einstellungen atomar.
pub fn save_to(&self, path: &Path) -> Result<(), StorageError> {
storage::atomic_write_json(path, self)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_settings_round_trip_and_fallbacks() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ink-notes").join("ui.json");
// Fehlende Datei -> Standard (links)
assert_eq!(UiSettings::load_from(&path).sidebar_side, SidebarSide::Left);
let settings = UiSettings {
sidebar_side: SidebarSide::Right,
};
settings.save_to(&path).unwrap();
assert_eq!(UiSettings::load_from(&path), settings);
// Beschädigte Datei -> Standard statt Absturz
std::fs::write(&path, b"{ kaputt").unwrap();
assert_eq!(UiSettings::load_from(&path), UiSettings::default());
// Unbekannte / fehlende Felder werden toleriert
std::fs::write(&path, br#"{"zukunft": 1}"#).unwrap();
assert_eq!(UiSettings::load_from(&path), UiSettings::default());
}
}
+117 -2
View File
@@ -124,8 +124,12 @@ impl StrokeData {
} }
} }
/// Aktuelle Dateiformat-Version von ink-notes (Version 2: Stroke-Treue mit tool, width, finalized). /// Aktuelle Dateiformat-Version von ink-notes.
pub const CURRENT_FORMAT_VERSION: u32 = 2; /// - Version 2: Stroke-Treue mit tool, width, finalized
/// - Version 3: neue Formen (Ellipse, Pfeil). Ältere App-Versionen kennen sie nicht und würden
/// betroffene Seiten für beschädigt halten; mit Version 3 lehnen sie das Notizbuch stattdessen
/// sauber als „neuere Version“ ab.
pub const CURRENT_FORMAT_VERSION: u32 = 3;
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0). /// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
pub fn legacy_format_version() -> u32 { pub fn legacy_format_version() -> u32 {
@@ -697,6 +701,15 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest)
); );
notebook.load_warnings = load_warnings; notebook.load_warnings = load_warnings;
// Die zuletzt geöffnete Seite sofort laden: Sie ist nach dem Start die aktuelle Seite,
// und ein leerer Platzhalter würde beim Weiterschreiben ihren Inhalt überschreiben.
let current = notebook.current_page_index;
if !notebook.pages[current].is_loaded {
if let Err(e) = ensure_page_loaded(notebook_dir, &mut notebook, current) {
eprintln!("[Storage] Aktuelle Seite {current} konnte nicht geladen werden: {e}");
}
}
Ok((notebook, manifest)) Ok((notebook, manifest))
} }
@@ -2167,4 +2180,106 @@ mod tests {
assert_eq!(loaded.pages[1].background, PageBackground::Lined); assert_eq!(loaded.pages[1].background, PageBackground::Lined);
assert!(crate::export::generate_pdf_document(&loaded, Default::default()).is_ok()); assert!(crate::export::generate_pdf_document(&loaded, Default::default()).is_ok());
} }
#[test]
fn test_restart_on_later_page_keeps_its_content() {
// Regression: Beim Start wurde nur Seite 1 geladen; war zuletzt eine spätere Seite
// offen, war sie ein leerer Platzhalter, und Weiterschreiben überschrieb ihren Inhalt.
let temp_dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest, dir) =
create_new_notebook(temp_dir.path(), "Neustart").unwrap();
notebook.add_page(PageBackground::Lined);
notebook.pages[1] = page_with_one_stroke(notebook.pages[1].id, PageBackground::Lined);
notebook.current_page_index = 1;
save_notebook(&dir, &notebook, &mut manifest).unwrap();
let (mut loaded, mut loaded_manifest) = load_notebook(&dir).unwrap();
assert_eq!(loaded.current_page_index, 1);
assert!(
loaded.current_page().is_loaded,
"aktuelle Seite muss geladen sein"
);
assert_eq!(loaded.current_page().strokes.len(), 1);
// Weiterschreiben und speichern darf den bisherigen Inhalt nicht verlieren
let extra = page_with_one_stroke(99, PageBackground::Lined).strokes[0].clone();
loaded.current_page_mut().strokes.push(extra);
let mut autosave = AutosaveController::new(dir.clone(), Duration::from_secs(60));
autosave.mark_dirty(&loaded, &mut loaded_manifest);
autosave.flush().unwrap();
autosave.shutdown().unwrap();
let (reloaded, _) = load_notebook(&dir).unwrap();
assert_eq!(reloaded.current_page().strokes.len(), 2);
}
#[test]
fn test_autosave_never_writes_unloaded_placeholder() {
let temp_dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest, dir) =
create_new_notebook(temp_dir.path(), "Platzhalter").unwrap();
notebook.add_page(PageBackground::Lined);
notebook.pages[1] = page_with_one_stroke(notebook.pages[1].id, PageBackground::Lined);
save_notebook(&dir, &notebook, &mut manifest).unwrap();
let page_file = dir.join(format!("page_{}.json", notebook.pages[1].id));
let before = fs::read(&page_file).unwrap();
// Platzhalter (nicht geladen) als aktuelle Seite markieren und speichern
let placeholder = Page::new_unloaded(notebook.pages[1].id, PageBackground::Lined);
let mut autosave = AutosaveController::new(dir.clone(), Duration::from_secs(60));
autosave.mark_page_dirty(&placeholder, &manifest);
autosave.flush().unwrap();
autosave.shutdown().unwrap();
assert_eq!(
fs::read(&page_file).unwrap(),
before,
"Seitendatei wurde überschrieben"
);
}
#[test]
fn test_new_shapes_round_trip_through_page_file() {
let temp_dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest, dir) =
create_new_notebook(temp_dir.path(), "Neue Formen").unwrap();
let shapes = [
crate::geometry::RecognizedShape::Ellipse {
center: glam::Vec2::new(500.0, 600.0),
radius_x: 250.0,
radius_y: 120.0,
rotation: 0.3,
},
crate::geometry::RecognizedShape::Arrow {
start: glam::Vec2::new(100.0, 100.0),
end: glam::Vec2::new(700.0, 300.0),
double: true,
},
];
for (i, shape) in shapes.iter().enumerate() {
notebook
.current_page_mut()
.strokes
.push(crate::geometry::shape_to_stroke(
shape.clone(),
i as u64 + 1,
[0.0, 0.0, 0.0, 1.0],
5.0,
));
}
save_notebook(&dir, &notebook, &mut manifest).unwrap();
assert_eq!(
load_manifest(&dir).unwrap().format_version,
CURRENT_FORMAT_VERSION
);
let (reloaded, _) = load_notebook(&dir).unwrap();
let loaded: Vec<_> = reloaded
.current_page()
.strokes
.iter()
.map(|s| s.shape.clone())
.collect();
assert_eq!(loaded, shapes.iter().cloned().map(Some).collect::<Vec<_>>());
}
} }
+1940 -1112
View File
File diff suppressed because it is too large Load Diff