From 3890b6f182529c19d86c89f33e89b39ff74d90f1 Mon Sep 17 00:00:00 2001 From: Harald Date: Thu, 8 Oct 2026 15:55:32 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Stifte,=20Seiten=20l=C3=B6schen,=20l?= =?UTF-8?q?=C3=BCckenlose=20Seiten,=20sauberer=20Textmarker=20(Issues=20#1?= =?UTF-8?q?1,=20#12,=20#14,=20#15,=20#17,=20#19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #15: Koordinatensystem ohne Kerbe am Ursprung (Achsen beginnen eine halbe Linienbreite hinter dem Ursprung; im PDF ein Linienzug mit spitzer Ecke) - #17: Jeder Strich deckt ein Pixel nur einmal (Stencil-Puffer), der Textmarker wird innerhalb eines Strichs nicht dunkler. Im PDF-Export ist der Textmarker jetzt halbtransparent statt deckend. - #11: ➕ in der Werkzeugleiste fügt nach der aktuellen Seite eine Seite ein - #12: Seite löschen im ☰-Menü mit Rückfrage, in der Sitzung wiederherstellbar - #14: Seiten liegen lückenlos untereinander, eine dunkle Linie markiert die Grenze - #19: Vier Stifte mit eigener Farbe und Stärke, die sich Änderungen merken (ui.json); Blätterpfeile nur noch in der Einzelseitenansicht Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +- docs/ARCHITECTURE.md | 8 +- docs/FILE_FORMAT.md | 3 + src/canvas.rs | 139 +++++++++++++++++++- src/document.rs | 56 ++++++++ src/export.rs | 117 +++++++++++++++-- src/geometry.rs | 43 +++++- src/image_layer.rs | 2 +- src/main.rs | 59 +++++++++ src/page_export.rs | 52 ++++++++ src/settings.rs | 87 +++++++++++++ src/shader.wgsl | 13 ++ src/ui.rs | 303 ++++++++++++++++++++++++++++++++++++++----- src/viewport.rs | 37 +++--- 14 files changed, 844 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index 0657646..3ebf0bb 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11). ## Funktionen ### Schreiben und Zeichnen -- **Stift** mit Druckstärke, vier Strichstärken (0,3–1,2 mm) und fünf Farben; **Textmarker** (halbtransparent, 4–8 mm) in vier Farben. +- **Vier Stifte** in der Werkzeugleiste, jeder mit eigener Farbe und Stärke (0,15–5 mm, mit Druckstärke). Ändert man Farbe oder Stärke, merkt sich der gewählte Stift das – auch über einen Neustart hinweg. **Textmarker** (halbtransparent, 4–8 mm) in vier Farben; innerhalb eines Strichs wird er nicht dunkler, erst ein neuer Strich deckt stärker. - **Glättung in zwei Stufen:** Während des Schreibens glättet ein 1€-Filter das Zittern, die Linie erscheint sofort. Beim Absetzen wird der Strich nachbearbeitet: Ecken (Knicke über 65°) bleiben erhalten, der Druckverlauf wird geglättet, überflüssige Punkte entfallen, die Enden werden rund. - **Druckkurve** im ☰-Menü an den eigenen Stift anpassen (weicher/härter, Schwelle gegen Grundrauschen). - **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Stiftrückseite und Seitentaste des Stifts radieren ebenfalls. @@ -36,7 +36,8 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11). ### Seiten und Ansicht - **Hintergründe:** blanko, kariert (5 mm), liniert (7,5 mm), Punktraster (5 mm). Die **Farbe von Karos, Linien und Punkten** ist wählbar (hell, mittel, kräftig, blau, grün) und gilt auch im Export. - **Papierfarbe** der Anzeige: weiß, creme, dunkelgrau, schwarz; auf dunklem Papier werden dunkle Stiftfarben auf Wunsch aufgehellt. Gespeichert und exportiert wird immer auf weißem Papier. -- **Durchgehendes Scrollen:** Seiten hängen untereinander; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite. Abschaltbar (dann Seite für Seite blättern). +- **Durchgehendes Scrollen:** Seiten hängen lückenlos untereinander, eine dunkle Linie markiert die Grenze; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite. Abschaltbar (dann Seite für Seite blättern, mit Pfeilen in der Werkzeugleiste). +- **Seiten einfügen und löschen:** ➕ in der Werkzeugleiste fügt nach der aktuellen Seite eine leere Seite ein. Löschen im ☰-Menü (mit Rückfrage); die zuletzt gelöschte Seite lässt sich dort wiederherstellen, solange die App läuft. - **Zoom** von 25 % bis 1000 % (`Strg + Mausrad`, zwei Finger, `Strg + +/-/0`), Verschieben mit mittlerer Maustaste, `Leertaste + Ziehen` oder einem Finger. - **Seiten** anhängen oder direkt nach der aktuellen Seite einfügen, **Seitentitel** vergeben; die Seitenübersicht zeigt Vorschaubilder oder ein Inhaltsverzeichnis. Seitentitel werden im PDF zu Lesezeichen. - **Werkzeugleiste** links oder rechts andocken (für Links- und Rechtshänder). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dcf6c02..71c764b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -93,7 +93,7 @@ $$\vec{p}_{\text{neu}} = \vec{c} - (\vec{c} - \vec{p}_{\text{alt}}) \cdot \frac{ - Im Shader wird jeder Punkt mit `transform_scale` und `transform_offset` (aus `CameraUniforms`) direkt in Gerätekoordinaten umgerechnet. ### 3.3 Durchgehende Ansicht -Alle Koordinaten beziehen sich auf die **aktuelle Seite**. `PageStack` (`viewport.rs`) legt die übrigen Seiten mit 8 mm Abstand darunter bzw. darüber, waagerecht zur aktuellen Seite zentriert. Jede sichtbare Nachbarseite wird mit einer eigenen Kamera gezeichnet, die um die Lage der Seite verschoben ist (höchstens 12 Nachbarseiten). +Alle Koordinaten beziehen sich auf die **aktuelle Seite**. `PageStack` (`viewport.rs`) legt die übrigen Seiten lückenlos darunter bzw. darüber (die Grenze zeichnet die Oberfläche als dunkle Linie), waagerecht zur aktuellen Seite zentriert. Jede sichtbare Nachbarseite wird mit einer eigenen Kamera gezeichnet, die um die Lage der Seite verschoben ist (höchstens 12 Nachbarseiten). Nach dem Scrollen wird die Seite in der Fenstermitte zur aktuellen Seite; die Kamera wird dabei so verschoben, dass sich das Bild nicht bewegt. Während eines Strichs, einer Auswahl, beim Ziehen des Lineals oder beim Schreiben von Text wird nicht gewechselt. Wer auf einer sichtbaren Nachbarseite zu zeichnen beginnt, wechselt zuerst auf diese Seite. @@ -105,7 +105,9 @@ Ein Frame besteht aus einem Render-Durchgang auf die Fensterfläche (`canvas.rs` 1. Für jede sichtbare Nachbarseite: Papier und Muster, Bilder/PDF-Seite/Text, Tinte. 2. Für die aktuelle Seite: Papier, Muster (Karo, Linien, Punkte), Bildebene, gespeicherte Striche, Live-Strich. -3. Die `egui`-Oberfläche: Werkzeugleiste, Menüs, Auswahlrahmen und Griffe, Lineal, Textbearbeitung. +3. Die `egui`-Oberfläche: Werkzeugleiste, Menüs, Seitengrenzen, Auswahlrahmen und Griffe, Lineal, Textbearbeitung. + +Jeder Strich ist ein eigener Draw-Call mit eigener Nummer im Stencil-Puffer: Ein Pixel, das der Strich schon gefärbt hat, wird kein zweites Mal gefärbt. So bleibt halbtransparente Tinte (Textmarker) gleichmäßig, auch wo sich der Strich selbst überlappt (Anfangskappe, Kehren, langsames Hin- und Herfahren); erst ein neuer Strich deckt stärker. Nach 255 Strichen setzt ein bildschirmfüllendes Dreieck den Stencil-Puffer zurück. ### 4.1 Bildebene `image_layer.rs` zeichnet texturierte Vierecke unter der Tinte: importierte PDF-Seiten, eingefügte Bilder und Textfelder. Texturen werden beim ersten Bedarf geladen, bekommen Mipmaps und werden freigegeben, wenn mehr als 384 MB belegt sind. @@ -177,7 +179,7 @@ Jede Seite hat einen eigenen Verlauf (`history.rs`): Hinzufügen, Entfernen, Ers 2. **PDF** (nur im Notfall): importierte PDF-Seite als Bild, falls sie sich nicht als Vektor übernehmen lässt. 3. **Images:** eingefügte Bilder (höchstens 300 dpi). 4. **Text:** Textfelder als echter Text mit eingebetteter Schrift (durchsuchbar, kopierbar). Jedes Feld liegt in einem eigenen Koordinatensystem, das eine Transformationsmatrix auf seine Ecken abbildet. -5. **Ink:** Striche als gefüllte Polygone, Formen als Pfade. +5. **Ink:** Striche als gefüllte Polygone, Formen als Pfade. Halbtransparente Striche (Textmarker) bekommen einen Grafikzustand mit Deckkraft (`ca`/`CA`); printpdf kann solche Zustände nicht anlegen, deshalb trägt `add_alpha_states` sie nach dem Speichern mit `lopdf` in die Seitenressourcen ein. Jeder Strich ist ein einziger Pfad und deckt sich daher nicht selbst doppelt. Importierte PDF-Seiten werden anschließend als Form-XObject unter die Seite gelegt (`pdf_merge.rs`), bleiben also Vektorgrafik. Seitentitel werden zu Lesezeichen. diff --git a/docs/FILE_FORMAT.md b/docs/FILE_FORMAT.md index c0e1108..e5e9fca 100644 --- a/docs/FILE_FORMAT.md +++ b/docs/FILE_FORMAT.md @@ -142,6 +142,9 @@ Ein Absturz während des Speicherns hinterlässt deshalb entweder die alte oder - Lässt sich eine Seite nicht lesen, wird sie nach `page_.json.corrupt-` verschoben (nicht gelöscht) und die Seite schreibgeschützt angezeigt. - Ist `manifest.json` beschädigt oder fehlt, wird `manifest.json.bak` verwendet. +### Gelöschte Seiten +Eine gelöschte Seite verschwindet aus `manifest.json`; ihre Datei `page_.json` bleibt im Ordner liegen. Seitenkennungen werden nie wiederverwendet (`next_page_id`), die Datei kann also mit keiner neuen Seite kollidieren. Wird die Seite in derselben Sitzung wiederhergestellt, schreibt die App sie neu. + ### Neuere Versionen Dateien mit einer höheren `format_version` als der unterstützten werden nicht geöffnet – so kann eine ältere App-Version keine Inhalte verlieren, die sie nicht kennt. diff --git a/src/canvas.rs b/src/canvas.rs index 502ba90..cce6122 100644 --- a/src/canvas.rs +++ b/src/canvas.rs @@ -166,6 +166,10 @@ pub struct CanvasState { /// Multisampling der Zeichenfläche (1 = aus) und das zugehörige Zwischenbild msaa_samples: u32, msaa_view: Option, + /// Stencil-Puffer: Jeder Strich deckt ein Pixel nur einmal, damit sich halbtransparente + /// Tinte (Textmarker) dort, wo der Strich sich selbst überlappt, nicht verdunkelt + stencil_view: wgpu::TextureView, + stencil_reset_pipeline: wgpu::RenderPipeline, /// Meldung „keine Darstellungsmodi“ wurde bereits ausgegeben presentation_warned: bool, pub device: wgpu::Device, @@ -343,6 +347,7 @@ impl CanvasState { 1 }; let msaa_view = create_msaa_view(&device, &config, msaa_samples); + let stencil_view = create_stencil_view(&device, &config, msaa_samples); // 5. Shader & Bind Group Layout let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { @@ -423,7 +428,7 @@ impl CanvasState { polygon_mode: wgpu::PolygonMode::Fill, conservative: false, }, - depth_stencil: None, + depth_stencil: Some(stroke_stencil()), multisample: wgpu::MultisampleState { count: msaa_samples, ..Default::default() @@ -431,6 +436,34 @@ impl CanvasState { multiview: None, }); + // Setzt den Stencil-Puffer zurück, wenn die Strichnummern überlaufen + let stencil_reset_pipeline = + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Stencil Reset Pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: "vs_fullscreen", + buffers: &[], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: "fs_none", + targets: &[Some(wgpu::ColorTargetState { + format: config.format, + blend: None, + write_mask: wgpu::ColorWrites::empty(), + })], + }), + primitive: wgpu::PrimitiveState::default(), + depth_stencil: Some(stencil_reset()), + multisample: wgpu::MultisampleState { + count: msaa_samples, + ..Default::default() + }, + multiview: None, + }); + // TriangleList-Pipeline (für Hintergrundpapier und Muster) let triangle_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("Background TriangleList Pipeline"), @@ -458,7 +491,7 @@ impl CanvasState { polygon_mode: wgpu::PolygonMode::Fill, conservative: false, }, - depth_stencil: None, + depth_stencil: Some(passive_stencil()), multisample: wgpu::MultisampleState { count: msaa_samples, ..Default::default() @@ -510,7 +543,7 @@ impl CanvasState { })], }), primitive: wgpu::PrimitiveState::default(), - depth_stencil: None, + depth_stencil: Some(passive_stencil()), multisample: wgpu::MultisampleState { count: msaa_samples, ..Default::default() @@ -549,6 +582,8 @@ impl CanvasState { adapter, msaa_samples, msaa_view, + stencil_view, + stencil_reset_pipeline, presentation_warned: false, device, queue, @@ -698,6 +733,7 @@ impl CanvasState { } self.surface.configure(&self.device, &self.config); self.msaa_view = create_msaa_view(&self.device, &self.config, self.msaa_samples); + self.stencil_view = create_stencil_view(&self.device, &self.config, self.msaa_samples); self.recalculate_viewport(); } } @@ -1340,10 +1376,18 @@ impl CanvasState { }, }, })], - depth_stencil_attachment: None, + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.stencil_view, + depth_ops: None, + stencil_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(0), + store: wgpu::StoreOp::Discard, + }), + }), timestamp_writes: None, occlusion_query_set: None, }); + let mut stencil_ref = 0; // 0. Nachbarseiten der durchgehenden Ansicht (je mit eigener Kamera) for (i, neighbor) in self.neighbors.iter().enumerate() { @@ -1362,6 +1406,7 @@ impl CanvasState { render_pass.set_vertex_buffer(0, buffer.slice(..)); for range in &neighbor.stroke_ranges { if range.end > range.start { + self.next_stroke_stencil(&mut render_pass, &mut stencil_ref); render_pass.draw(range.clone(), 0..1); } } @@ -1393,6 +1438,7 @@ impl CanvasState { render_pass.set_vertex_buffer(0, self.committed_vertex_buffer.slice(..)); for range in &self.committed_strokes { if range.end > range.start { + self.next_stroke_stencil(&mut render_pass, &mut stencil_ref); render_pass.draw(range.clone(), 0..1); } } @@ -1401,6 +1447,7 @@ impl CanvasState { // 5. Temporäre Live-Geometrie des aktuellen Strichs rendern (Zero Latency) if self.live_vertex_count > 0 { render_pass.set_vertex_buffer(0, self.live_vertex_buffer.slice(..)); + self.next_stroke_stencil(&mut render_pass, &mut stencil_ref); render_pass.draw(0..self.live_vertex_count, 0..1); } } @@ -1437,6 +1484,20 @@ impl CanvasState { Ok(()) } + /// Gibt dem nächsten Strich eine eigene Stencil-Nummer: Die Strich-Pipeline zeichnet nur + /// Pixel, die noch nicht diese Nummer tragen. Nach 255 Strichen wird der Stencil-Puffer + /// zurückgesetzt, damit kein späterer Strich die Nummer eines früheren wiederfindet. + fn next_stroke_stencil<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>, stencil_ref: &mut u32) { + if *stencil_ref == u32::from(u8::MAX) { + pass.set_pipeline(&self.stencil_reset_pipeline); + pass.draw(0..3, 0..1); + pass.set_pipeline(&self.strip_pipeline); + *stencil_ref = 0; + } + *stencil_ref += 1; + pass.set_stencil_reference(*stencil_ref); + } + /// Rendert nur die Canvas-Fläche ohne UI (z. B. für Headless-Tests). pub fn render_canvas_only(&mut self) -> Result<(), wgpu::SurfaceError> { let dummy_delta = egui::TexturesDelta::default(); @@ -1477,6 +1538,76 @@ fn create_msaa_view( }) } +/// Format des Stencil-Puffers der Zeichenfläche. +const STENCIL_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth24PlusStencil8; + +/// Stencil-Puffer in Fenstergröße (mit derselben Abtastzahl wie die Zeichenfläche). +fn create_stencil_view( + device: &wgpu::Device, + config: &wgpu::SurfaceConfiguration, + samples: u32, +) -> wgpu::TextureView { + device + .create_texture(&wgpu::TextureDescriptor { + label: Some("Stroke Stencil Buffer"), + size: wgpu::Extent3d { + width: config.width.max(1), + height: config.height.max(1), + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: samples, + dimension: wgpu::TextureDimension::D2, + format: STENCIL_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }) + .create_view(&wgpu::TextureViewDescriptor::default()) +} + +/// Stencil-Zustand mit gleicher Prüfung und Aktion für Vorder- und Rückseiten. +fn stencil_state( + compare: wgpu::CompareFunction, + pass_op: wgpu::StencilOperation, +) -> wgpu::DepthStencilState { + let face = wgpu::StencilFaceState { + compare, + fail_op: wgpu::StencilOperation::Keep, + depth_fail_op: wgpu::StencilOperation::Keep, + pass_op, + }; + wgpu::DepthStencilState { + format: STENCIL_FORMAT, + depth_write_enabled: false, + depth_compare: wgpu::CompareFunction::Always, + stencil: wgpu::StencilState { + front: face, + back: face, + read_mask: 0xff, + write_mask: 0xff, + }, + bias: wgpu::DepthBiasState::default(), + } +} + +/// Pipelines ohne Strichnummer (Papier, Muster, Bilder) lassen den Stencil-Puffer unberührt. +pub(crate) fn passive_stencil() -> wgpu::DepthStencilState { + stencil_state(wgpu::CompareFunction::Always, wgpu::StencilOperation::Keep) +} + +/// Striche: nur Pixel, die die eigene Nummer noch nicht tragen, und diese dann markieren. +fn stroke_stencil() -> wgpu::DepthStencilState { + stencil_state( + wgpu::CompareFunction::NotEqual, + wgpu::StencilOperation::Replace, + ) +} + +/// Zurücksetzen: überall 0 eintragen. +fn stencil_reset() -> wgpu::DepthStencilState { + stencil_state(wgpu::CompareFunction::Always, wgpu::StencilOperation::Zero) +} + /// Zu rasterndes Textfeld. struct TextSpec { text: crate::text::TextBox, diff --git a/src/document.rs b/src/document.rs index 541885c..6e6380b 100644 --- a/src/document.rs +++ b/src/document.rs @@ -1217,6 +1217,29 @@ impl Notebook { index } + /// Entfernt die aktuelle Seite und gibt sie zurück; danach ist die folgende Seite aktuell + /// (bzw. die vorige, wenn es die letzte war). Die einzige Seite eines Notizbuchs bleibt + /// stehen (`None`). + pub fn remove_current_page(&mut self) -> Option { + if self.pages.len() < 2 { + return None; + } + let page = self.pages.remove(self.current_page_index); + self.load_warnings.remove(&page.id); + self.current_page_index = self.current_page_index.min(self.pages.len() - 1); + Some(page) + } + + /// Setzt eine mit [`Self::remove_current_page`] entfernte Seite wieder an `index` ein + /// (höchstens ans Ende) und macht sie zur aktuellen Seite; gibt ihren Index zurück. + pub fn restore_page(&mut self, index: usize, page: Page) -> usize { + let index = index.min(self.pages.len()); + self.next_page_id = self.next_page_id.max(page.id + 1); + self.pages.insert(index, page); + self.current_page_index = index; + index + } + /// Durchgehende Ansicht: Reicht ein neuer Strich auf der letzten Seite bis in deren /// unteres Fünftel ([`AUTO_APPEND_FRACTION`]), wird darunter eine leere Seite mit gleichem /// Hintergrund angehängt. `true`, wenn eine Seite angehängt wurde. @@ -1982,6 +2005,39 @@ mod tests { assert!(new.pdf_background.is_none()); } + #[test] + fn test_remove_and_restore_current_page() { + let mut nb = Notebook::new(); + // Die einzige Seite bleibt + assert!(nb.remove_current_page().is_none()); + nb.add_page(PageBackground::Lined); + nb.add_page(PageBackground::DotGrid); + let ids: Vec = nb.pages.iter().map(|p| p.id).collect(); + + // Mittlere Seite: danach ist die folgende aktuell + nb.current_page_index = 1; + let removed = nb.remove_current_page().unwrap(); + assert_eq!(removed.id, ids[1]); + assert_eq!( + nb.pages.iter().map(|p| p.id).collect::>(), + [ids[0], ids[2]] + ); + assert_eq!(nb.current_page_index, 1); + + // Wiederherstellen an der alten Stelle + assert_eq!(nb.restore_page(1, removed), 1); + assert_eq!(nb.pages.iter().map(|p| p.id).collect::>(), ids); + assert_eq!(nb.current_page_index, 1); + + // Letzte Seite: danach ist die vorige aktuell + nb.current_page_index = 2; + let last = nb.remove_current_page().unwrap(); + assert_eq!(last.id, ids[2]); + assert_eq!(nb.current_page_index, 1); + // Neue Seiten bekommen keine schon vergebene Kennung + assert!(nb.next_page_id > ids[2]); + } + #[test] fn test_append_page_when_writing_at_bottom_of_last_page() { let mut nb = Notebook::new(); diff --git a/src/export.rs b/src/export.rs index 29a6f8b..bb9e707 100644 --- a/src/export.rs +++ b/src/export.rs @@ -180,7 +180,7 @@ pub fn export_notebook_to_bytes( pattern: PatternColor, ) -> Result, Box> { let doc = generate_pdf_document(notebook, strategy, assets, pattern)?; - let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?; + let bytes = add_alpha_states(fix_text_encoding(doc.save_to_bytes()?, notebook)?, notebook)?; // Importierte PDF-Seiten als Vektorgrafik unterlegen let underlays = pdf_underlays(notebook); @@ -206,7 +206,7 @@ pub fn export_notebook_to_bytes( raster.insert(underlay.target_page); } let doc = generate_pdf_document_with(notebook, strategy, assets, pattern, &raster)?; - let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?; + let bytes = add_alpha_states(fix_text_encoding(doc.save_to_bytes()?, notebook)?, notebook)?; let remaining: Vec<_> = underlays .into_iter() .filter(|u| !raster.contains(&u.target_page)) @@ -247,6 +247,77 @@ fn fix_text_encoding(bytes: Vec, notebook: &Notebook) -> Result, Box Ok(out) } +/// Name des Grafikzustands für halbtransparente Tinte mit Deckkraft `alpha` (auf Prozent +/// gerundet); `None` für deckende Farben. +fn alpha_state_name(alpha: f32) -> Option { + let percent = (alpha.clamp(0.0, 1.0) * 100.0).round() as u32; + (percent < 100).then(|| format!("InkAlpha{percent}")) +} + +/// printpdf kann keine Grafikzustände mit Deckkraft anlegen: Die Tintenebene verweist mit +/// `gs` auf die Namen aus [`alpha_state_name`], die Zustände selbst (`ca`/`CA`) werden hier +/// in die Ressourcen jeder Seite eingetragen. Ohne halbtransparente Striche bleibt das PDF +/// unverändert. +fn add_alpha_states(bytes: Vec, notebook: &Notebook) -> Result, Box> { + use printpdf::lopdf::{Dictionary, Object}; + + let mut states: Vec<(String, f32)> = notebook + .pages + .iter() + .flat_map(|p| &p.strokes) + .filter(|s| !s.is_object()) + .filter_map(|s| alpha_state_name(s.color[3]).map(|name| (name, s.color[3]))) + .collect(); + if states.is_empty() { + return Ok(bytes); + } + states.sort_by(|a, b| a.0.cmp(&b.0)); + states.dedup_by(|a, b| a.0 == b.0); + + let mut doc = printpdf::lopdf::Document::load_mem(&bytes)?; + let page_ids: Vec<_> = doc.get_pages().into_values().collect(); + for page_id in page_ids { + let resources = dict_object_id(&mut doc, page_id, b"Resources")?; + let ext_g_state = dict_object_id(&mut doc, resources, b"ExtGState")?; + let dict = doc.get_object_mut(ext_g_state)?.as_dict_mut()?; + for (name, alpha) in &states { + let alpha = (alpha.clamp(0.0, 1.0) * 100.0).round() / 100.0; + let mut state = Dictionary::new(); + state.set("Type", Object::Name(b"ExtGState".to_vec())); + state.set("ca", Object::Real(alpha)); + state.set("CA", Object::Real(alpha)); + dict.set(name.as_bytes().to_vec(), Object::Dictionary(state)); + } + } + let mut out = Vec::new(); + doc.save_to(&mut out)?; + Ok(out) +} + +/// Kennung des Wörterbuchs unter `key` im Objekt `owner`; ein eingebettetes oder fehlendes +/// Wörterbuch wird dafür zu einem eigenen Objekt. +fn dict_object_id( + doc: &mut printpdf::lopdf::Document, + owner: printpdf::lopdf::ObjectId, + key: &[u8], +) -> Result> { + use printpdf::lopdf::{Dictionary, Object}; + + let entry = doc.get_object(owner)?.as_dict()?.get(key).ok().cloned(); + if let Some(Object::Reference(id)) = entry { + return Ok(id); + } + let dict = match entry { + Some(Object::Dictionary(dict)) => dict, + _ => Dictionary::new(), + }; + let id = doc.add_object(dict); + doc.get_object_mut(owner)? + .as_dict_mut()? + .set(key.to_vec(), Object::Reference(id)); + Ok(id) +} + /// UTF-8-Text als PDF-Textstring in UTF-16BE mit BOM; `None` bei reinem ASCII oder /// ungültigem UTF-8. fn utf16be_text(raw: &[u8]) -> Option> { @@ -468,6 +539,17 @@ fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy: if stroke.is_object() { continue; } + // Halbtransparente Tinte (Textmarker): Deckkraft über einen Grafikzustand, den + // `add_alpha_states` anlegt. Jeder Strich ist ein einziger Pfad und deckt daher jede + // Stelle nur einmal, auch wo er sich selbst überlappt. + let alpha_state = alpha_state_name(stroke.color[3]); + if let Some(name) = &alpha_state { + layer.save_graphics_state(); + layer.add_operation(printpdf::lopdf::content::Operation::new( + "gs", + vec![printpdf::lopdf::Object::Name(name.as_bytes().to_vec())], + )); + } if let Some(ref shape) = stroke.shape { render_recognized_shape(layer, shape, stroke.color); } else { @@ -480,6 +562,9 @@ fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy: } } } + if alpha_state.is_some() { + layer.restore_graphics_state(); + } } } @@ -773,6 +858,10 @@ fn render_recognized_shape( let stroke_color = Color::Rgb(Rgb::new(color[0], color[1], color[2], None)); layer.set_outline_color(stroke_color.clone()); layer.set_outline_thickness(1.5); // ca. 0.5 mm Standard-Liniendicke + // Wie auf dem Bildschirm: spitze Ecken und stumpfe Enden (der Grafikzustand kann noch + // runde Verbindungen von einem vorherigen Freihandstrich enthalten) + layer.set_line_cap_style(printpdf::LineCapStyle::Butt); + layer.set_line_join_style(printpdf::LineJoinStyle::Miter); match shape { RecognizedShape::Line { start, end } => { @@ -966,17 +1055,19 @@ fn render_recognized_shape( (*y_axis_end, *x_axis_end) }; - let (ox, oy) = page_to_pdf_mm(origin.x, origin.y); - for end in [y_line_end, x_line_end] { - let (ex, ey) = page_to_pdf_mm(end.x, end.y); - layer.add_line(Line { - points: vec![ - (Point::new(Mm(ox), Mm(oy)), false), - (Point::new(Mm(ex), Mm(ey)), false), - ], - is_closed: false, - }); - } + // Ein einziger Linienzug, damit die Ecke am Ursprung als Linienverbindung + // geschlossen gezeichnet wird statt als zwei stumpfe Enden + let points = [y_line_end, *origin, x_line_end] + .iter() + .map(|p| { + let (px, py) = page_to_pdf_mm(p.x, p.y); + (Point::new(Mm(px), Mm(py)), false) + }) + .collect(); + layer.add_line(Line { + points, + is_closed: false, + }); // Pfeilspitzen als gefüllte Dreiecke if *with_arrows { diff --git a/src/geometry.rs b/src/geometry.rs index b30666e..19cc04e 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -1924,16 +1924,21 @@ pub fn shape_to_mesh( (*y_axis_end, *x_axis_end) }; + // Beide Achsen beginnen um die halbe Linienbreite hinter dem Ursprung, damit die + // äußere Ecke geschlossen ist (sonst bleibt dort eine quadratische Kerbe) + let y_start = *origin - y_dir * half_w; + let x_start = *origin - x_dir * half_w; + // 1. Y-Achse (vom Ursprung zur Spitze) let y_norm = Vec2::new(-y_dir.y, y_dir.x) * half_w; vertices.push(InkVertex::new( - origin.x + y_norm.x, - origin.y + y_norm.y, + y_start.x + y_norm.x, + y_start.y + y_norm.y, color, )); vertices.push(InkVertex::new( - origin.x - y_norm.x, - origin.y - y_norm.y, + y_start.x - y_norm.x, + y_start.y - y_norm.y, color, )); vertices.push(InkVertex::new( @@ -1950,15 +1955,15 @@ pub fn shape_to_mesh( // Degenerierte Dreiecke für den Übergang zur X-Achse let x_norm = Vec2::new(-x_dir.y, x_dir.x) * half_w; let last_v = *vertices.last().unwrap(); - let next_v = InkVertex::new(origin.x + x_norm.x, origin.y + x_norm.y, color); + let next_v = InkVertex::new(x_start.x + x_norm.x, x_start.y + x_norm.y, color); vertices.push(last_v); vertices.push(next_v); // 2. X-Achse vertices.push(next_v); vertices.push(InkVertex::new( - origin.x - x_norm.x, - origin.y - x_norm.y, + x_start.x - x_norm.x, + x_start.y - x_norm.y, color, )); vertices.push(InkVertex::new( @@ -2195,6 +2200,30 @@ mod tests { } } + #[test] + fn test_coordinate_system_corner_is_closed() { + // Die Achsen treffen sich ohne Kerbe: Die äußere Ecke des Ursprungs ist ein Eckpunkt + // der Achsenbänder (Issue #15) + let origin = Vec2::new(300.0, 900.0); + let shape = RecognizedShape::CoordinateSystem { + origin, + x_axis_end: Vec2::new(900.0, 900.0), + y_axis_end: Vec2::new(300.0, 300.0), + with_arrows: true, + }; + let width = 30.0; + let (_, vertices) = shape_to_mesh(&shape, width, [0.0, 0.0, 0.0, 1.0]); + let corner = origin + Vec2::new(-width / 2.0, width / 2.0); + let nearest = vertices + .iter() + .map(|v| (Vec2::from(v.position) - corner).length()) + .fold(f32::INFINITY, f32::min); + assert!( + nearest < 1e-3, + "äußere Ecke fehlt, nächster Punkt {nearest}" + ); + } + #[test] fn test_detect_triangle() { let mut points = Vec::new(); diff --git a/src/image_layer.rs b/src/image_layer.rs index ac47334..bd1c79b 100644 --- a/src/image_layer.rs +++ b/src/image_layer.rs @@ -151,7 +151,7 @@ impl ImageLayer { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, - depth_stencil: None, + depth_stencil: Some(crate::canvas::passive_stencil()), multisample: wgpu::MultisampleState { count: sample_count, ..Default::default() diff --git a/src/main.rs b/src/main.rs index b228474..1f2ec0d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1058,6 +1058,8 @@ fn main() -> Result<(), Box> { ui_state.pattern_color = ui_settings.pattern_color; ui_state.pressure_curve = ui_settings.pressure_curve; ui_state.continuous_scroll = ui_settings.continuous_scroll; + ui_state.pen_presets = ui_settings.pen_presets; + ui_state.select_pen(ui_settings.active_pen); ui_state.current_notebook_id = manifest.id.clone(); ui_state.document_title = manifest.title.clone(); ui_state.available_notebooks = available_notebooks; @@ -1066,6 +1068,11 @@ fn main() -> Result<(), Box> { ui_state.set_export_message(warning); } let mut selection_state = SelectionState::new(); + // Zuletzt gelöschte Seite (Notizbuch-Kennung, Index, Seite) zum Wiederherstellen in dieser + // Sitzung + let mut last_deleted_page: Option<(String, usize, ink_notes::document::Page)> = None; + // Stifte geändert, aber noch nicht gespeichert + let mut pen_presets_dirty = false; let mut clipboard = ClipboardBridge::new(); // PDF-Seiten werden im Hintergrund gerendert und wecken danach den Event-Loop let mut pdf_renderer = { @@ -2609,6 +2616,10 @@ fn main() -> Result<(), Box> { // 3. Toolbar & Widgets rendern updater.update(); ui_state.page_view = Some(page_view(&canvas, window.scale_factor() as f32)); + ui_state.restorable_page = last_deleted_page + .as_ref() + .filter(|(id, _, _)| *id == manifest.id) + .map(|(_, index, _)| index + 1); let actions = ui::render_ui(&egui_ctx, &mut ui_state, ¬ebook, autosave.status(), &updater); canvas.ruler = ui_state.ruler; @@ -2825,6 +2836,42 @@ fn main() -> Result<(), Box> { ); ui_state.set_export_message(format!("Seite {} eingefügt", index + 1)); } + UiAction::DeleteCurrentPage => { + let index = notebook.current_page_index; + if let Some(page) = notebook.remove_current_page() { + selection_state.clear(); + let curr = notebook.current_page_index; + let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr); + canvas.sync_page(notebook.current_page_mut()); + manifest.sync_from_notebook(¬ebook); + autosave.mark_manifest_dirty(&manifest); + last_deleted_page = Some((manifest.id.clone(), index, page)); + ui_state.set_export_message(format!( + "Seite {} gelöscht – im Menü ☰ wiederherstellbar", + index + 1 + )); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + } + UiAction::RestoreDeletedPage => { + if let Some((id, index, page)) = last_deleted_page.take() { + if id == manifest.id { + selection_state.clear(); + let index = notebook.restore_page(index, page); + canvas.sync_page(notebook.current_page_mut()); + manifest.sync_from_notebook(¬ebook); + // Die Seitendatei neu schreiben (sie kann inzwischen fehlen) + autosave.mark_page_dirty(notebook.current_page(), &manifest); + autosave.mark_manifest_dirty(&manifest); + ui_state.set_export_message(format!("Seite {} wiederhergestellt", index + 1)); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } else { + last_deleted_page = Some((id, index, page)); + } + } + } UiAction::LoadAllPages => { // Für die Vorschaubilder der Seitenauswahl; Fehler werden wie beim // Blättern behandelt (Ersatzseite, Warnung beim Öffnen der Seite) @@ -3216,6 +3263,18 @@ fn main() -> Result<(), Box> { } } + // Stifte: geänderte Farbe oder Stärke gehört zum gewählten Stift. Gespeichert + // wird erst nach dem Loslassen (nicht bei jedem Schritt eines Schiebereglers). + if ui_state.sync_pen_preset() || ui_settings.active_pen != ui_state.active_pen { + pen_presets_dirty = true; + ui_settings.active_pen = ui_state.active_pen; + } + if pen_presets_dirty && !egui_ctx.is_using_pointer() { + pen_presets_dirty = false; + ui_settings.pen_presets = ui_state.pen_presets; + save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); + } + // Textbearbeitung beenden: Esc, anderes Werkzeug oder andere Seite let finish_text = ui_state.text_edit.as_ref().is_some_and(|edit| { edit.finish diff --git a/src/page_export.rs b/src/page_export.rs index 53946b1..396d762 100644 --- a/src/page_export.rs +++ b/src/page_export.rs @@ -233,6 +233,58 @@ mod tests { assert!(clear.height() as f32 / clear.width() as f32 - PAGE_HEIGHT / PAGE_WIDTH < 0.01); } + #[test] + fn test_highlighter_is_translucent_in_export() { + // Schwarze Linie, darüber ein gelber Textmarker, der hin und zurück läuft + let mut page = page_with_line(); + page.strokes[0].color = [0.0, 0.0, 0.0, 1.0]; + for v in &mut page.strokes[0].vertices { + v.color = [0.0, 0.0, 0.0, 1.0]; + } + let color = [1.0, 0.95, 0.1, 0.35]; + let samples: Vec = (0..=40) + .map(|i| { + let x = if i <= 20 { i } else { 40 - i } as f32; + StylusSample { + position: glam::Vec2::new(500.0 + x * 20.0, 1000.0 + (i as f32 * 0.5)), + pressure: 0.6, + timestamp: i as f64 * 0.01, + } + }) + .collect(); + let vertices = crate::storage::reconstruct_stroke_vertices( + &samples, + color, + 60.0, + Default::default(), + true, + ); + let mut stroke = Stroke::new(8, samples, vertices, color); + stroke.width = 60.0; + stroke.tool = crate::document::StrokeTool::Highlighter; + page.strokes.push(stroke); + + let png = decode( + &page_png(&page, None, ImageBackground::Paper, PatternColor::default()).unwrap(), + ); + let px_per_unit = png.width() as f32 / PAGE_WIDTH; + let at = |x: f32, y: f32| { + png.get_pixel((x * px_per_unit) as u32, (y * px_per_unit) as u32) + .0 + }; + // Die schwarze Linie bleibt unter dem Textmarker sichtbar + let line = at(700.0, 1000.0); + assert!(line[0] < 110 && line[1] < 110, "Linie verdeckt: {line:?}"); + // Neben der Linie: einmal gelb getönt, auch wo der Strich sich selbst überlappt + let tint = at(700.0, 1020.0); + assert!(tint[2] < 200 && tint[0] > 200, "kein Textmarker: {tint:?}"); + let expected_blue = 255.0 * (1.0 - 0.35) + 0.1 * 255.0 * 0.35; + assert!( + (tint[2] as f32 - expected_blue).abs() < 25.0, + "Textmarker mehrfach gedeckt: {tint:?}, erwartet Blau ≈ {expected_blue}" + ); + } + #[test] fn test_export_uses_pattern_color() { // Kariertes Blatt: das kräftige Muster ist im Export dunkler als das helle diff --git a/src/settings.rs b/src/settings.rs index add653f..50908f0 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -73,6 +73,60 @@ impl Default for PressureCurve { } } +/// Stift-Vorlage: Farbe und Stärke eines der Stifte in der Werkzeugleiste. Ändert man Farbe +/// oder Stärke, während ein Stift gewählt ist, merkt sich die Vorlage die neuen Werte. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct PenPreset { + pub color: [f32; 4], + pub width_mm: f32, +} + +impl PenPreset { + pub const WIDTH_RANGE: std::ops::RangeInclusive = 0.15..=5.0; + + /// Begrenzt Werte aus einer (evtl. von Hand bearbeiteten) Einstellungsdatei; Stifte sind + /// immer deckend. + pub fn sanitized(&self, fallback: PenPreset) -> Self { + let mut color = fallback.color; + 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() { + self.width_mm + .clamp(*Self::WIDTH_RANGE.start(), *Self::WIDTH_RANGE.end()) + } else { + fallback.width_mm + }; + Self { color, width_mm } + } +} + +/// Anzahl der Stifte in der Werkzeugleiste. +pub const PEN_PRESET_COUNT: usize = 4; + +/// Standardstifte: Schwarz, Blau und Rot fein, Grün etwas kräftiger. +pub const DEFAULT_PEN_PRESETS: [PenPreset; PEN_PRESET_COUNT] = [ + PenPreset { + color: [0.12, 0.12, 0.14, 1.0], + width_mm: 0.5, + }, + PenPreset { + color: [0.15, 0.35, 0.85, 1.0], + width_mm: 0.5, + }, + PenPreset { + color: [0.85, 0.20, 0.20, 1.0], + width_mm: 0.5, + }, + PenPreset { + color: [0.18, 0.65, 0.35, 1.0], + width_mm: 0.8, + }, +]; + /// Benutzereinstellungen der Oberfläche. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -88,6 +142,9 @@ pub struct UiSettings { pub pressure_curve: PressureCurve, /// Seiten durchgehend untereinander anzeigen (sonst einzeln blättern) pub continuous_scroll: bool, + /// Stifte der Werkzeugleiste und der gewählte davon + pub pen_presets: [PenPreset; PEN_PRESET_COUNT], + pub active_pen: usize, } impl Default for UiSettings { @@ -99,6 +156,8 @@ impl Default for UiSettings { pattern_color: PatternColor::default(), pressure_curve: PressureCurve::default(), continuous_scroll: true, + pen_presets: DEFAULT_PEN_PRESETS, + active_pen: 0, } } } @@ -126,6 +185,10 @@ impl UiSettings { Err(_) => Self::default(), }; settings.pressure_curve = settings.pressure_curve.sanitized(); + for (preset, fallback) in settings.pen_presets.iter_mut().zip(DEFAULT_PEN_PRESETS) { + *preset = preset.sanitized(fallback); + } + settings.active_pen = settings.active_pen.min(PEN_PRESET_COUNT - 1); settings } @@ -157,6 +220,11 @@ mod tests { threshold: 0.1, }, continuous_scroll: false, + pen_presets: [PenPreset { + color: [0.5, 0.2, 0.7, 1.0], + width_mm: 1.5, + }; PEN_PRESET_COUNT], + active_pen: 2, }; settings.save_to(&path).unwrap(); assert_eq!(UiSettings::load_from(&path), settings); @@ -193,6 +261,25 @@ mod tests { let curve = UiSettings::load_from(&path).pressure_curve; assert_eq!(curve.exponent, 3.0); assert_eq!(curve.threshold, 0.0); + + // Ohne Stifte: die Standardstifte; unsinnige Stifte werden begrenzt + assert_eq!(settings.pen_presets, DEFAULT_PEN_PRESETS); + assert_eq!(settings.active_pen, 0); + std::fs::write( + &path, + br#"{"active_pen": 9, "pen_presets": [ + {"color": [2.0, -1.0, 0.5, 0.2], "width_mm": 99.0}, + {"color": [0.1, 0.2, 0.3, 1.0], "width_mm": 0.0}, + {"color": [0.1, 0.2, 0.3, 1.0], "width_mm": 1.0}, + {"color": [0.1, 0.2, 0.3, 1.0], "width_mm": 1.0} + ]}"#, + ) + .unwrap(); + let settings = UiSettings::load_from(&path); + assert_eq!(settings.active_pen, PEN_PRESET_COUNT - 1); + assert_eq!(settings.pen_presets[0].color, [1.0, 0.0, 0.5, 1.0]); + assert_eq!(settings.pen_presets[0].width_mm, 5.0); + assert_eq!(settings.pen_presets[1].width_mm, 0.15); } #[test] diff --git a/src/shader.wgsl b/src/shader.wgsl index 8fb1278..7cfa3f8 100644 --- a/src/shader.wgsl +++ b/src/shader.wgsl @@ -48,6 +48,19 @@ fn fs_ink(in: VertexOutput) -> @location(0) vec4 { return vec4(in.color.rgb + vec3(lift), in.color.a); } +// Bildschirmfüllendes Dreieck ohne Farbe (setzt den Stencil-Puffer zurück) +@vertex +fn vs_fullscreen(@builtin(vertex_index) index: u32) -> @builtin(position) vec4 { + let x = f32((index << 1u) & 2u) * 2.0 - 1.0; + let y = f32(index & 2u) * 2.0 - 1.0; + return vec4(x, y, 0.0, 1.0); +} + +@fragment +fn fs_none() -> @location(0) vec4 { + return vec4(0.0); +} + // Eingefügte Bilder: texturierte Vierecke unter der Tinte struct ImageVertexInput { @location(0) position: vec2, diff --git a/src/ui.rs b/src/ui.rs index d22441f..7ebe336 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -18,7 +18,9 @@ use crate::document::{ }; use crate::file_picker::{FilePicker, PickerKind}; use crate::ruler::{Ruler, RulerHit}; -use crate::settings::{PressureCurve, SidebarSide}; +use crate::settings::{ + PenPreset, PressureCurve, SidebarSide, DEFAULT_PEN_PRESETS, PEN_PRESET_COUNT, +}; use crate::storage::NotebookSummary; use crate::text::{TextEditing, TextFont, TEXT_SIZES_PT}; @@ -152,6 +154,10 @@ pub enum UiAction { PreviousPage, NextPage, AddPage, + /// Aktuelle Seite löschen (nach Rückfrage in der Oberfläche) + DeleteCurrentPage, + /// Zuletzt gelöschte Seite wiederherstellen + RestoreDeletedPage, GoToPage(usize), /// Alle noch nicht geladenen Seiten laden (für die Vorschaubilder der Seitenauswahl) LoadAllPages, @@ -256,6 +262,14 @@ pub struct UiState { pub touch_draw_enabled: bool, /// Zuletzt gelöschtes Notizbuch für Rückgängig während der Sitzung (Papierkorb-Pfad, Titel) pub last_deleted_notebook: Option<(std::path::PathBuf, String)>, + /// Stifte der Werkzeugleiste (Farbe und Stärke) und der gewählte davon; `pen_color` und + /// `pen_stroke_width_mm` gehören immer zum gewählten Stift + pub pen_presets: [PenPreset; PEN_PRESET_COUNT], + pub active_pen: usize, + /// Rückfrage „Seite löschen?“ im ☰-Menü ist offen + pub confirm_delete_page: bool, + /// Seitenzahl der zuletzt gelöschten Seite, solange sie wiederhergestellt werden kann + pub restorable_page: Option, /// Fehler beim Flushen vor dem Beenden pub shutdown_error: Option, /// Fensterseite der Werkzeugleiste @@ -333,6 +347,10 @@ impl Default for UiState { update_modal_rect: None, touch_draw_enabled: false, last_deleted_notebook: None, + pen_presets: DEFAULT_PEN_PRESETS, + active_pen: 0, + confirm_delete_page: false, + restorable_page: None, shutdown_error: None, sidebar_side: SidebarSide::Left, is_more_menu_open: false, @@ -372,6 +390,7 @@ impl UiState { /// Schließt alle Popover und Dialoge. Wird vor dem Öffnen eines neuen aufgerufen, damit /// sich nie mehrere überlagern. pub fn close_overlays(&mut self) { + self.confirm_delete_page = false; self.is_pen_menu_open = false; self.is_highlighter_menu_open = false; self.is_more_menu_open = false; @@ -439,12 +458,14 @@ impl UiState { } /// Größte Knopfgröße (40 px, schrittweise bis 28 px), bei der die Leiste mit allen Elementen - /// des aktiven Werkzeugs in `max_height` passt. - pub fn sidebar_button_size(tool: ActiveTool, max_height: f32) -> f32 { + /// des aktiven Werkzeugs in `max_height` passt. `continuous`: durchgehende Ansicht (ohne + /// Blätterpfeile). + pub fn sidebar_button_size(tool: ActiveTool, max_height: f32, continuous: bool) -> f32 { let select = matches!(tool, ActiveTool::Select | ActiveTool::Text); - // Notizbücher, 6 Werkzeuge, Undo/Redo, 3× Seiten, Menü (+ 4 Auswahl-Aktionen bzw. - // Schriftknöpfe) - let buttons = 13 + if select { 4 } else { 0 }; + // Notizbücher, 6 Werkzeuge, Undo/Redo, Seitenzahl und neue Seite (einzeln blättern: + // dazu zwei Pfeile), Menü (+ 4 Auswahl-Aktionen bzw. Schriftknöpfe) + let page_buttons = if continuous { 2 } else { 4 }; + let buttons = 10 + page_buttons + if select { 4 } else { 0 }; let dividers = 5 + usize::from(select); let spacing = 3.0; let needed = |b: f32| { @@ -471,6 +492,29 @@ impl UiState { Self::default() } + /// Wählt Stift `index`: Farbe und Stärke kommen aus seiner Vorlage. + pub fn select_pen(&mut self, index: usize) { + self.active_pen = index.min(PEN_PRESET_COUNT - 1); + let preset = self.pen_presets[self.active_pen]; + self.pen_color = preset.color; + self.pen_stroke_width_mm = preset.width_mm; + } + + /// Ü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). + pub fn sync_pen_preset(&mut self) -> bool { + let current = PenPreset { + color: self.pen_color, + width_mm: self.pen_stroke_width_mm, + }; + let preset = &mut self.pen_presets[self.active_pen.min(PEN_PRESET_COUNT - 1)]; + if *preset == current { + return false; + } + *preset = current; + true + } + /// Gibt die aktuelle Farbe des gewählten Werkzeugs zurück. pub fn current_color(&self) -> [f32; 4] { match self.active_tool { @@ -886,6 +930,50 @@ fn autosave_status_style(status: AutosaveStatus) -> (egui::Color32, &'static str } /// Zeichnet einen runden Farbauswahl-Knopf (Color Swatch) mit anpassbarem Durchmesser. +/// 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. +fn pen_preset_swatch( + ui: &mut egui::Ui, + preset: PenPreset, + is_selected: bool, + tooltip: &str, + size: f32, +) -> egui::Response { + let (rect, response) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::click()); + if ui.is_rect_visible(rect) { + let paper = rect.shrink(2.0); + ui.painter().rect_filled( + paper, + egui::Rounding::same(5.0), + egui::Color32::from_gray(245), + ); + let [r, g, b, _] = preset.color.map(|c| (c.clamp(0.0, 1.0) * 255.0) as u8); + let width = (1.0 + preset.width_mm * 2.5).clamp(1.5, paper.height() * 0.35); + let inset = 3.0 + width / 2.0; + ui.painter().line_segment( + [ + egui::pos2(paper.left() + inset, paper.bottom() - inset), + egui::pos2(paper.right() - inset, paper.top() + inset), + ], + egui::Stroke::new(width, egui::Color32::from_rgb(r, g, b)), + ); + if is_selected { + ui.painter().rect_stroke( + rect.shrink(0.5), + egui::Rounding::same(7.0), + egui::Stroke::new(2.0_f32, egui::Color32::WHITE), + ); + } else if response.hovered() { + ui.painter().rect_stroke( + rect.shrink(1.0), + egui::Rounding::same(7.0), + egui::Stroke::new(1.0_f32, egui::Color32::from_white_alpha(150)), + ); + } + } + response.on_hover_text(tooltip) +} + fn color_circle_swatch( ui: &mut egui::Ui, color: [f32; 4], @@ -1414,6 +1502,98 @@ fn pressure_curve_editor(ui: &mut egui::Ui, state: &mut UiState) -> Option, +) { + let number = notebook.current_page_index + 1; + if state.confirm_delete_page { + ui.label( + egui::RichText::new(format!("Seite {number} mit allem Inhalt löschen?")) + .color(egui::Color32::from_rgb(255, 170, 160)), + ); + ui.horizontal(|ui| { + let half = (ui.available_width() - ui.spacing().item_spacing.x) / 2.0; + if ui + .add_sized( + [half, 30.0], + egui::Button::new(egui::RichText::new("Löschen").color(egui::Color32::WHITE)) + .fill(egui::Color32::from_rgb(170, 50, 45)), + ) + .clicked() + { + actions.push(UiAction::DeleteCurrentPage); + state.confirm_delete_page = false; + } + if ui + .add_sized([half, 30.0], egui::Button::new("Abbrechen")) + .clicked() + { + state.confirm_delete_page = false; + } + }); + } else { + let only_page = notebook.pages.len() < 2; + let response = ui + .add_enabled_ui(!only_page, |ui| { + ui.add_sized( + [ui.available_width(), 30.0], + egui::Button::new(format!("🗑 Seite {number} löschen …")), + ) + }) + .inner + .on_hover_text("Löscht die aktuelle Seite; danach im Menü wiederherstellbar") + .on_disabled_hover_text( + "Die einzige Seite eines Notizbuchs kann nicht gelöscht werden", + ); + if response.clicked() { + state.confirm_delete_page = true; + } + } + if let Some(number) = state.restorable_page { + if ui + .add_sized( + [ui.available_width(), 30.0], + egui::Button::new(format!("⟲ Gelöschte Seite {number} wiederherstellen")), + ) + .clicked() + { + actions.push(UiAction::RestoreDeletedPage); + } + } +} + +/// Durchgehende Ansicht: dunkle Linie an jeder Grenze zweier Seiten (die Seiten liegen +/// lückenlos untereinander). Unter allen Fenstern der Oberfläche, über der Tinte. +fn page_separators(ctx: &egui::Context, state: &UiState, notebook: &Notebook) { + let Some(view) = state.page_view.filter(|_| state.continuous_scroll) else { + return; + }; + let stack = notebook.page_stack(); + let screen = ctx.screen_rect(); + let color = if state.paper_theme.is_dark() { + egui::Color32::from_white_alpha(140) + } else { + egui::Color32::from_black_alpha(200) + }; + let painter = ctx.layer_painter(egui::LayerId::background()); + for i in 1..stack.len() { + let y = stack.origins[i].y; + let left = stack.origins[i - 1].x.min(stack.origins[i].x); + let right = (stack.origins[i - 1].x + stack.sizes[i - 1].x) + .max(stack.origins[i].x + stack.sizes[i].x); + let a = view.to_screen(glam::Vec2::new(left, y)); + let b = view.to_screen(glam::Vec2::new(right, y)); + if a.y < screen.top() - 1.0 || a.y > screen.bottom() + 1.0 { + continue; + } + painter.line_segment([a, b], egui::Stroke::new(1.5_f32, color)); + } +} + /// Zeichnet das Lineal über dem Blatt und bedient Verschieben und Drehen. fn ruler_overlay(ctx: &egui::Context, state: &mut UiState) { let (Some(mut ruler), Some(view)) = (state.ruler, state.page_view) else { @@ -1815,6 +1995,7 @@ pub fn render_ui( ) -> Vec { let mut actions = std::mem::take(&mut state.queued_actions); state.screen_size = ctx.screen_rect().size(); + page_separators(ctx, state, notebook); ruler_overlay(ctx, state); text_edit_overlay(ctx, state); @@ -1840,7 +2021,7 @@ pub fn render_ui( let max_height = (ctx.screen_rect().height() - 2.0 * UiState::SIDEBAR_MARGIN - 12.0).max(120.0); // Größe nach dem höchsten Fall (Auswahl mit Aktionen), damit Knöpfe beim // Werkzeugwechsel weder schrumpfen noch verrutschen - let btn = UiState::sidebar_button_size(ActiveTool::Select, max_height); + let btn = UiState::sidebar_button_size(ActiveTool::Select, max_height, state.continuous_scroll); let area_response = egui::Area::new(egui::Id::new("floating_toolbar_area")) .anchor(anchor, offset) @@ -2025,10 +2206,12 @@ pub fn render_ui( state.toggle_ruler(); } - // --- Farben des aktiven Werkzeugs --- + // --- Stifte bzw. Farben des aktiven Werkzeugs --- + let pens = matches!(state.active_tool, ActiveTool::Pen | ActiveTool::Shape); let palette: Option<(&[ColorPreset], [f32; 4])> = match state.active_tool { - ActiveTool::Pen | ActiveTool::Shape | ActiveTool::Select => { + ActiveTool::Pen | ActiveTool::Shape => None, + ActiveTool::Select => { Some((&PEN_COLOR_PRESETS[..], state.pen_color)) } ActiveTool::Highlighter => Some(( @@ -2044,7 +2227,46 @@ pub fn render_ui( // beim Werkzeugwechsel nicht springen sidebar_divider(ui, btn); let swatch = UiState::sidebar_swatch_size(btn); - let shown = palette.map_or(0, |(presets, _)| presets.len()); + let mut shown = palette.map_or(0, |(presets, _)| presets.len()); + if pens { + shown = PEN_PRESET_COUNT; + for i in 0..PEN_PRESET_COUNT { + let preset = state.pen_presets[i]; + let selected = i == state.active_pen; + let tooltip = if selected { + format!( + "Stift {} ({:.2} mm) – erneut tippen für Farbe & Stärke; \ + Änderungen merkt sich der Stift", + i + 1, + preset.width_mm + ) + } else { + format!("Stift {} ({:.2} mm)", i + 1, preset.width_mm) + }; + let response = + pen_preset_swatch(ui, preset, selected, &tooltip, swatch); + if selected { + // Die Einstellungen erscheinen neben dem gewählten Stift + pen_btn_rect = Some(response.rect); + } + if response.clicked() { + state.is_highlighter_menu_open = false; + state.is_more_menu_open = false; + if selected { + let open = !state.is_pen_menu_open; + state.close_overlays(); + state.is_pen_menu_open = open; + } else { + state.is_pen_menu_open = false; + state.select_pen(i); + actions.push(UiAction::SetColor(state.pen_color)); + actions.push(UiAction::SetStrokeWidthMm( + state.pen_stroke_width_mm, + )); + } + } + } + } if let Some((presets, current)) = palette { for &(color, label) in presets { let is_sel = (current[0] - color[0]).abs() < 0.05 @@ -2064,7 +2286,8 @@ pub fn render_ui( edit.focus = true; } } - _ => state.pen_color = color, + // Auswahl umfärben, der Stift bleibt + _ => {} } actions.push(UiAction::SetColor(color)); } @@ -2181,16 +2404,20 @@ pub fn render_ui( sidebar_divider(ui, btn); // --- Seitennavigation --- - if sidebar_button( - ui, - btn, - SidebarIcon::Glyph("⏶"), - false, - notebook.current_page_index > 0, - None, - "Vorherige Seite (Bild auf)", - ) - .clicked() + // In der durchgehenden Ansicht wird gescrollt; Blätterpfeile gibt + // es nur in der Einzelseitenansicht + let paging = !state.continuous_scroll; + if paging + && sidebar_button( + ui, + btn, + SidebarIcon::Glyph("⏶"), + false, + notebook.current_page_index > 0, + None, + "Vorherige Seite (Bild auf)", + ) + .clicked() { actions.push(UiAction::PreviousPage); } @@ -2214,23 +2441,33 @@ pub fn render_ui( state.close_overlays(); state.show_page_jump_popup = open; } - let is_last = notebook.current_page_index + 1 >= notebook.pages.len(); + if paging + && sidebar_button( + ui, + btn, + SidebarIcon::Glyph("⏷"), + false, + notebook.current_page_index + 1 < notebook.pages.len(), + None, + "Nächste Seite (Bild ab)", + ) + .clicked() + { + actions.push(UiAction::NextPage); + } if sidebar_button( ui, btn, - SidebarIcon::Glyph("⏷"), + SidebarIcon::Glyph("➕"), false, true, None, - if is_last { - "Neue Seite anhängen (Bild ab / N)" - } else { - "Nächste Seite (Bild ab / N)" - }, + "Neue Seite nach dieser einfügen", ) .clicked() { - actions.push(UiAction::NextPage); + state.close_overlays(); + actions.push(UiAction::AddPage); } sidebar_divider(ui, btn); @@ -2303,7 +2540,7 @@ pub fn render_ui( // Titelzeile mit Schließen-Button ui.horizontal(|ui| { ui.label( - egui::RichText::new("Stift-Konfiguration") + egui::RichText::new(format!("Stift {}", state.active_pen + 1)) .strong() .color(egui::Color32::WHITE) .size(12.5), @@ -2625,6 +2862,7 @@ pub fn render_ui( { actions.push(UiAction::AddPage); } + delete_page_buttons(ui, state, notebook, &mut actions); if ui .add_sized([ui.available_width(), 30.0], egui::Button::new("🖼 Bild einfügen … (Strg+I)")) .on_hover_text("Auch per Strg+V aus der Zwischenablage oder durch Ziehen einer Bilddatei ins Fenster") @@ -2978,6 +3216,9 @@ pub fn render_ui( } state.popover_rect = popover_rect; + if !state.is_more_menu_open { + state.confirm_delete_page = false; + } // Automatisches Schließen der Popover bei Klick außerhalb von Leiste und Popover if (state.is_pen_menu_open @@ -4151,7 +4392,7 @@ mod tests { } // Große Fenster behalten die volle Knopfgröße assert_eq!( - UiState::sidebar_button_size(ActiveTool::Select, 1000.0), + UiState::sidebar_button_size(ActiveTool::Select, 1000.0, true), 40.0 ); } diff --git a/src/viewport.rs b/src/viewport.rs index 2c3b67e..58860f3 100644 --- a/src/viewport.rs +++ b/src/viewport.rs @@ -3,20 +3,18 @@ //! Berechnet die affine Transformation zwischen Bildschirmkoordinaten (Window-Pixel), //! virtuellen DIN-A4-Seitenkoordinaten ([0, 2100] x [0, 2970]) und dem GPU-NDC-Raum ([-1, 1]). -use crate::document::{PageViewport, UNITS_PER_MM}; +use crate::document::PageViewport; use glam::{Mat4, Vec2, Vec4}; /// Minimaler und maximaler Zoomfaktor. pub const MIN_ZOOM: f32 = 0.25; pub const MAX_ZOOM: f32 = 10.0; -/// Abstand zwischen zwei Seiten in der durchgehenden Ansicht (8 mm). -pub const PAGE_GAP: f32 = 8.0 * UNITS_PER_MM; /// Höchstens so viele Nachbarseiten werden gleichzeitig dargestellt. pub const MAX_NEIGHBOR_PAGES: usize = 12; -/// Durchgehende Ansicht: Die Seiten hängen mit [`PAGE_GAP`] Abstand untereinander und sind -/// waagerecht zueinander zentriert. Lage jeder Seite (Ecke oben links) in Koordinaten der +/// Durchgehende Ansicht: Die Seiten hängen lückenlos untereinander (die Grenze markiert eine +/// Linie der Oberfläche) und sind waagerecht zueinander zentriert. Lage jeder Seite (Ecke oben links) in Koordinaten der /// aktuellen Seite. #[derive(Debug, Clone, PartialEq)] pub struct PageStack { @@ -33,11 +31,11 @@ impl PageStack { let mut origins = vec![Vec2::ZERO; sizes.len()]; let x = |i: usize| (width - sizes[i].x) / 2.0; for i in current + 1..sizes.len() { - let y = origins[i - 1].y + sizes[i - 1].y + PAGE_GAP; + let y = origins[i - 1].y + sizes[i - 1].y; origins[i] = Vec2::new(x(i), y); } for i in (0..current).rev() { - let y = origins[i + 1].y - PAGE_GAP - sizes[i].y; + let y = origins[i + 1].y - sizes[i].y; origins[i] = Vec2::new(x(i), y); } Self { @@ -55,7 +53,7 @@ impl PageStack { self.sizes.is_empty() } - /// Seite, auf deren Höhe `y` liegt; `None` im Zwischenraum oder außerhalb. + /// Seite, auf deren Höhe `y` liegt (an der Grenze die untere); `None` außerhalb. pub fn page_at(&self, y: f32) -> Option { // Seiten liegen nach Index sortiert untereinander let i = self.origins.partition_point(|o| o.y <= y).checked_sub(1)?; @@ -312,7 +310,7 @@ mod tests { use super::*; const A4: Vec2 = crate::document::A4_SIZE; - const PITCH: f32 = A4.y + PAGE_GAP; + const PITCH: f32 = A4.y; fn a4_stack(count: usize, current: usize) -> PageStack { PageStack::new(vec![A4; count], current) @@ -322,10 +320,11 @@ mod tests { fn test_continuous_layout() { let stack = a4_stack(5, 1); assert_eq!(stack.page_at(100.0), Some(1)); - assert_eq!(stack.page_at(A4.y + PAGE_GAP / 2.0), None); + // Lückenlos: direkt unter dem Seitenende beginnt die nächste Seite + assert_eq!(stack.page_at(A4.y - 1.0), Some(1)); + assert_eq!(stack.page_at(A4.y + 1.0), Some(2)); assert_eq!(stack.page_at(PITCH + 5.0), Some(2)); - assert_eq!(stack.page_at(-5.0), None); - assert_eq!(stack.page_at(-PAGE_GAP - 5.0), Some(0)); + assert_eq!(stack.page_at(-5.0), Some(0)); assert_eq!(stack.page_at(-PITCH - 5.0), None, "vor der ersten Seite"); // Ende von Seite 2 und Anfang von Seite 3 sichtbar (aktuell: Seite 2 von 5) @@ -339,7 +338,7 @@ mod tests { .is_empty()); // Hinter der letzten Seite: keine weiteren assert_eq!( - a4_stack(5, 3).visible_neighbors(-PITCH, PITCH * 4.0), + a4_stack(5, 3).visible_neighbors(-PITCH + 1.0, PITCH * 4.0), vec![2, 4] ); // Weit herausgezoomt: auf die nächstgelegenen Seiten begrenzt @@ -352,13 +351,13 @@ mod tests { fn test_stack_of_different_page_sizes() { let slide = Vec2::new(2970.0, 1671.0); let stack = PageStack::new(vec![slide, A4, slide], 1); - // Folien waagerecht zur A4-Seite zentriert, mit Abstand darüber und darunter - assert_eq!(stack.origins[0], Vec2::new(-435.0, -PAGE_GAP - slide.y)); - assert_eq!(stack.origins[2], Vec2::new(-435.0, A4.y + PAGE_GAP)); - assert_eq!(stack.page_at(A4.y + PAGE_GAP + 10.0), Some(2)); + // Folien waagerecht zur A4-Seite zentriert, direkt darüber und darunter + assert_eq!(stack.origins[0], Vec2::new(-435.0, -slide.y)); + assert_eq!(stack.origins[2], Vec2::new(-435.0, A4.y)); + assert_eq!(stack.page_at(A4.y + 10.0), Some(2)); let (min, max) = stack.bounds(); - assert_eq!(min, Vec2::new(-435.0, -PAGE_GAP - slide.y)); - assert_eq!(max, Vec2::new(2535.0, A4.y + PAGE_GAP + slide.y)); + assert_eq!(min, Vec2::new(-435.0, -slide.y)); + assert_eq!(max, Vec2::new(2535.0, A4.y + slide.y)); } #[test]