diff --git a/Cargo.lock b/Cargo.lock index 622e3fc..bf8e8ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1395,6 +1395,7 @@ dependencies = [ name = "ink-notes" version = "0.9.0" dependencies = [ + "ab_glyph", "arboard", "bytemuck", "egui", diff --git a/Cargo.toml b/Cargo.toml index 249953d..67319c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,9 @@ fs2 = "0.4" hayro = "0.8" hayro-svg = "0.8" +# Text-Werkzeug: Schriftmetriken und Rasterung (Schriften aus egui) +ab_glyph = "0.2" + [profile.dev] opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus diff --git a/README.md b/README.md index 676f660..124ea59 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü - **Stift (Pen):** Feine, variable Strichstärken (0.3 mm bis 1.2 mm) mit Notiz-Farben (Schwarz, Blau, Rot, Grün, Grau). - **Textmarker (Highlighter):** Breite, halbtransparente Striche (4.0 mm bis 8.0 mm) mit Multiplikations-Blending. - **Radierer (Eraser):** Segment- und abstandsbasierte Löschung einzelner Striche auf dem DIN-A4-Canvas. + - **Text (`T`):** Ins Blatt tippen und mit der Tastatur schreiben – für Überschriften, Code-Schnipsel oder kopierten Text. Normale Schrift oder Code-Schrift (feste Zeichenbreite, Tab rückt ein), 8 bis 36 pt, Farben der Stiftpalette; Zeilen werden am rechten Rand des Textfelds umbrochen. Vorhandenen Text antippen zum Ändern, `Esc` beendet. Textfelder lassen sich wie Bilder verschieben, drehen und skalieren; im PDF-Export ist der Text echt (durchsuchbar und kopierbar). Aus anderen Programmen kopierter Text wird mit `Strg + V` zum Textfeld. ### 🔍 Stufenloser Zoom & Pan (Kamera) - **Viewport-Transformation:** 2D-Projektionsmatrix im WGSL-Vertex-Shader. @@ -225,6 +226,7 @@ cargo run --release | **Zoom zurücksetzen (100 %)** | `Strg + 0` | | **Zoom In / Out** | `Strg + +` / `Strg + -` | | **Auswahlwerkzeug (Lasso)** | `V` | +| **Text-Werkzeug** | `T`, dann ins Blatt klicken; `Esc` beendet die Eingabe | | **Auswahl um 90° drehen** | `R` | | **Kopieren & Einfügen** | `Strg + C` / `Strg + V` | | **Auswahl löschen** | `Entf` oder `Backspace` | diff --git a/src/canvas.rs b/src/canvas.rs index a2de37d..d3c6a7d 100644 --- a/src/canvas.rs +++ b/src/canvas.rs @@ -6,6 +6,7 @@ //! - NDC-Transformation der 2D-Vektor-Meshes über Vertex-Shader-Uniforms. //! - Alpha-Blending (`wgpu::BlendState::ALPHA_BLENDING`). +use std::collections::HashMap; use std::ops::Range; use std::sync::Arc; use winit::dpi::PhysicalSize; @@ -186,6 +187,13 @@ pub struct CanvasState { pub paper_theme: PaperTheme, /// Dunkle Tinte auf dunklem Papier aufhellen pub adapt_ink: bool, + /// Textfelder der sichtbaren Seiten nach Texturschlüssel (für die Rasterung) + text_specs: HashMap, + /// Gerade bearbeitetes Textfeld der aktuellen Seite: nicht zeichnen (die Bearbeitung + /// zeigt es) + hidden_text: Option, + /// Auflösung der Texttexturen in Pixeln je Seiteneinheit + text_resolution: f32, // Pipeline-Trennung: Live-Strich (aktiver Stroke) pub live_vertex_buffer: wgpu::Buffer, @@ -491,6 +499,9 @@ impl CanvasState { current_background, paper_theme, adapt_ink: false, + text_specs: HashMap::new(), + hidden_text: None, + text_resolution: 1.0, live_vertex_buffer, live_vertex_capacity: Self::INITIAL_LIVE_CAPACITY, live_vertex_count: 0, @@ -789,7 +800,8 @@ impl CanvasState { // Die aktuelle Seite kann sich jetzt ändern: als Nachbarseite später neu hochladen self.neighbors.retain(|n| n.fingerprint.page_id != page.id); self.set_background(page.background); - self.image_layer.set_quads(page_image_quads(page)); + let quads = self.page_image_quads(page, self.hidden_text); + self.image_layer.set_quads(quads); self.committed_vertex_count = page.vertex_cache.len() as u32; self.committed_strokes = page.stroke_ranges.clone(); @@ -836,7 +848,7 @@ impl CanvasState { bytemuck::cast_slice(&[self.camera_uniforms(offset)]), ); self.neighbors.push(neighbor); - image_groups.push(page_image_quads(page)); + image_groups.push(self.page_image_quads(page, None)); } self.image_layer.set_neighbor_quads(image_groups); } @@ -890,7 +902,74 @@ impl CanvasState { &mut self, load: &mut dyn FnMut(&str) -> crate::image_layer::TextureSource, ) { - self.image_layer.prepare(&self.device, &self.queue, load); + // Text schärfer rastern, sobald weiter hineingezoomt wird (und gröber beim Herauszoomen) + let resolution = text_resolution( + self.viewport.page_width / crate::document::PAGE_WIDTH * self.camera.zoom, + ); + if resolution != self.text_resolution { + self.text_resolution = resolution; + self.image_layer + .forget_textures(|key| key.starts_with(TEXT_KEY_PREFIX)); + } + let specs = &self.text_specs; + self.image_layer + .prepare(&self.device, &self.queue, &mut |key| match specs.get(key) { + Some(spec) => crate::image_layer::TextureSource::Ready(crate::text::rasterize( + &spec.text, + spec.color, + resolution * spec.scale, + )), + None => load(key), + }); + let layer = &self.image_layer; + self.text_specs.retain(|key, _| layer.uses(key)); + } + + /// Blendet das gerade bearbeitete Textfeld aus (wirksam ab dem nächsten `sync_page`). + pub fn set_hidden_text(&mut self, id: Option) { + self.hidden_text = id; + } + + /// Bilder einer Seite für die Bildebene: zuerst die PDF-Seite, darüber die eingefügten + /// Bilder und Textfelder (außer `hidden`) in ihrer Reihenfolge. + fn page_image_quads( + &mut self, + page: &Page, + hidden: Option, + ) -> Vec<(String, [glam::Vec2; 4])> { + let adapt = self.adapt_ink && self.paper_theme.is_dark(); + let mut quads: Vec<_> = page + .pdf_background + .iter() + .map(|pdf| (pdf.key(), pdf.corners())) + .collect(); + for stroke in &page.strokes { + let Some(corners) = stroke.object_corners() else { + continue; + }; + if let Some(asset) = &stroke.image { + quads.push((asset.clone(), corners)); + } else if let Some(text) = &stroke.text { + if Some(stroke.id) == hidden { + continue; + } + let color = if adapt { + crate::background::adapt_ink_color(stroke.color) + } else { + stroke.color + }; + let key = text_key(text, color); + let scale = ((corners[1] - corners[0]).length() / text.wrap_width.max(text.size)) + .max((corners[3] - corners[0]).length() / text.layout().height()); + self.text_specs.entry(key.clone()).or_insert(TextSpec { + text: text.clone(), + color, + scale, + }); + quads.push((key, corners)); + } + } + quads } /// Wird noch eine PDF-Seite im Hintergrund gerendert? @@ -1205,23 +1284,53 @@ impl CanvasState { } } -/// Bilder einer Seite für die Bildebene: zuerst die PDF-Seite, darüber die eingefügten Bilder. -fn page_image_quads(page: &Page) -> Vec<(String, [glam::Vec2; 4])> { - page.pdf_background - .iter() - .map(|pdf| (pdf.key(), pdf.corners())) - .chain( - page.strokes - .iter() - .filter_map(|s| Some((s.image.clone()?, s.image_corners()?))), - ) - .collect() +/// Zu rasterndes Textfeld. +struct TextSpec { + text: crate::text::TextBox, + /// Angezeigte (ggf. aufgehellte) Farbe + color: [f32; 4], + /// Vergrößerung des Felds durch Skalieren der Auswahl + scale: f32, +} + +/// Schlüssel der Texttexturen beginnen hiermit. +const TEXT_KEY_PREFIX: &str = "text:"; + +/// Texturschlüssel eines Textfelds: ändert sich mit Inhalt, Satz und Farbe. +fn text_key(text: &crate::text::TextBox, color: [f32; 4]) -> String { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + text.content.hash(&mut hasher); + text.font.hash(&mut hasher); + for v in [text.size, text.wrap_width].into_iter().chain(color) { + v.to_bits().hash(&mut hasher); + } + format!("{TEXT_KEY_PREFIX}{:016x}", hasher.finish()) +} + +/// Auflösung der Texttexturen für `screen_px_per_unit` Bildschirmpixel je Seiteneinheit: +/// auf Viertel-Zweierpotenzen aufgerundet (⅛ bis 4). So wird nicht bei jedem Zoomschritt neu +/// gerastert, und die Textur ist höchstens ein Fünftel größer als dargestellt – stärker +/// verkleinert wirkt die dünne Schrift blass. +fn text_resolution(screen_px_per_unit: f32) -> f32 { + let steps = (screen_px_per_unit.max(1e-3).log2() * 4.0).ceil(); + 2.0_f32.powf(steps / 4.0).clamp(0.125, 4.0) } #[cfg(test)] mod tests { use super::*; + #[test] + fn test_text_resolution_is_quantized_and_never_below_screen() { + for px in [0.2_f32, 0.27, 0.5, 1.3, 3.9] { + let r = text_resolution(px); + assert!(r >= px - 1e-4 && r < px * 1.2 + 1e-4, "{px} → {r}"); + } + assert_eq!(text_resolution(0.27), text_resolution(0.28)); + assert_eq!(text_resolution(100.0), 4.0); + } + #[test] fn test_choose_present_mode() { use wgpu::PresentMode::{Fifo, Immediate, Mailbox}; diff --git a/src/document.rs b/src/document.rs index 00d2daa..e81e383 100644 --- a/src/document.rs +++ b/src/document.rs @@ -371,6 +371,9 @@ pub struct Stroke { /// Bildecken oben links, oben rechts, unten rechts, unten links; Verschieben, Drehen und /// Skalieren wirken so automatisch auch auf Bilder. Bilder liegen immer unter der Tinte. pub image: Option, + /// Textfeld (Text-Werkzeug); die vier Punkte sind wie bei Bildern die Ecken des Felds. + /// Textfelder liegen wie Bilder unter der Tinte. + pub text: Option, } impl Stroke { @@ -404,6 +407,7 @@ impl Stroke { width: 5.0, finalized: true, image: None, + text: None, } } @@ -423,11 +427,48 @@ impl Stroke { stroke } + /// Erzeugt ein Textfeld mit den Ecken oben links, oben rechts, unten rechts, unten links. + pub fn new_text( + id: u64, + text: crate::text::TextBox, + corners: [glam::Vec2; 4], + color: [f32; 4], + ) -> Self { + let mut stroke = Self::new_image(id, String::new(), corners); + stroke.image = None; + stroke.color = color; + stroke.text = Some(text); + stroke + } + /// Ist dieses Objekt ein Bild? pub fn is_image(&self) -> bool { self.image.is_some() } + /// Ist dieses Objekt ein Textfeld? + pub fn is_text(&self) -> bool { + self.text.is_some() + } + + /// Bild oder Textfeld (Objekt mit vier Ecken statt Tinte)? + pub fn is_object(&self) -> bool { + self.is_image() || self.is_text() + } + + /// Ecken eines Bildes oder Textfelds (oben links, oben rechts, unten rechts, unten links). + pub fn object_corners(&self) -> Option<[glam::Vec2; 4]> { + if !self.is_object() || self.points.len() < 4 { + return None; + } + Some([ + self.points[0].position, + self.points[1].position, + self.points[2].position, + self.points[3].position, + ]) + } + /// Bildecken (oben links, oben rechts, unten rechts, unten links), falls ein Bild. pub fn image_corners(&self) -> Option<[glam::Vec2; 4]> { if self.image.is_none() || self.points.len() < 4 { @@ -474,9 +515,9 @@ impl Stroke { } /// Berechnet den minimalen euklidischen Abstand eines Punktes zu diesem Strich. - /// Bei Bildern ist der Abstand innerhalb der Bildfläche 0. + /// Bei Bildern und Textfeldern ist der Abstand innerhalb der Fläche 0. pub fn distance_to_point(&self, p: glam::Vec2) -> f32 { - if let Some(corners) = self.image_corners() { + if let Some(corners) = self.object_corners() { if point_in_convex_quad(p, &corners) { return 0.0; } @@ -731,7 +772,8 @@ impl Page { } /// Findet den nächsten Strich an Position `pos` innerhalb eines maximalen Suchradius. - /// Tinte hat Vorrang vor Bildern darunter, damit Notizen auf einem Bild auswählbar bleiben. + /// Tinte hat Vorrang vor Bildern und Textfeldern darunter, damit Notizen darauf + /// auswählbar bleiben. pub fn find_stroke_at(&self, pos: glam::Vec2, max_dist: f32) -> Option<&Stroke> { let mut best: Option<(&Stroke, (bool, f32))> = None; for s in &self.strokes { @@ -740,7 +782,7 @@ impl Page { } let d = s.distance_to_point(pos); if d <= max_dist { - let key = (s.is_image(), d); + let key = (s.is_object(), d); if best.is_none_or(|(_, best_key)| key < best_key) { best = Some((s, key)); } @@ -783,7 +825,7 @@ impl Page { let mut hit_indices = Vec::new(); for (i, stroke) in self.strokes.iter().enumerate() { - if !stroke.is_image() + if !stroke.is_object() && mode.erases(stroke.tool) && stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius) { @@ -811,8 +853,8 @@ impl Page { /// Teilradierer: entfernt den Teil aller Striche im Kreis um `eraser_pos`. Getroffene /// Striche werden durch ihre übrig gebliebenen Stücke ersetzt (neue IDs aus `next_id`), - /// Formen als Ganzes entfernt, Bilder nie. Aufeinanderfolgende Schnitte eines Radierzugs - /// bilden einen gemeinsamen Undo-Schritt. Gibt `true` zurück, wenn sich etwas geändert hat. + /// Formen als Ganzes entfernt, Bilder und Textfelder nie. Aufeinanderfolgende Schnitte + /// eines Radierzugs bilden einen gemeinsamen Undo-Schritt. Gibt `true` zurück, wenn sich etwas geändert hat. pub fn erase_partial_at( &mut self, eraser_pos: glam::Vec2, @@ -824,7 +866,7 @@ impl Page { let mut result = Vec::with_capacity(self.strokes.len()); for stroke in std::mem::take(&mut self.strokes) { let reach = eraser_radius + stroke.width / 2.0; - if stroke.is_image() || !stroke.aabb.expanded(reach).contains_point(eraser_pos) { + if stroke.is_object() || !stroke.aabb.expanded(reach).contains_point(eraser_pos) { result.push(stroke); continue; } @@ -900,8 +942,8 @@ impl Page { } /// Findet die IDs aller Striche, die die gegebene Auswahlbox schneiden oder darin liegen. - /// Bilder werden nur ausgewählt, wenn sie ganz in der Box liegen – sonst würde jede - /// Auswahl von Notizen auf einem Bild das Bild mitnehmen. + /// Bilder und Textfelder werden nur ausgewählt, wenn sie ganz in der Box liegen – sonst + /// würde jede Auswahl von Notizen auf einem Bild das Bild mitnehmen. pub fn find_strokes_in_box(&self, box_min: glam::Vec2, box_max: glam::Vec2) -> Vec { let sel_box = Aabb::new( glam::Vec2::new(box_min.x.min(box_max.x), box_min.y.min(box_max.y)), @@ -910,7 +952,7 @@ impl Page { self.strokes .iter() .filter(|s| { - if s.is_image() { + if s.is_object() { sel_box.contains_point(s.aabb.min) && sel_box.contains_point(s.aabb.max) } else { s.aabb.intersects(&sel_box) diff --git a/src/export.rs b/src/export.rs index 78ea9b5..2c5cef8 100644 --- a/src/export.rs +++ b/src/export.rs @@ -2,13 +2,15 @@ //! //! Features: //! - Hochformat DIN A4 (210 mm x 297 mm) -//! - Layer pro Seite: "Background", "Images" (nur bei eingefügten Bildern) und "Ink" +//! - Layer pro Seite: "Background", "Images" (nur bei eingefügten Bildern), "Text" (nur bei +//! Textfeldern; echter, durchsuchbarer Text mit eingebetteter Schrift) und "Ink" //! - Y-Achsen-Spiegelung (PDF-Ursprung unten links vs. Bildschirmursprung oben links) //! - Vektor-Hintergründe (Kariert 5mm, Liniert, Punktraster, Blanko) //! - Zwei Export-Strategien: //! - `FilledPolygon`: Glatte gefüllte Polygone aus den Triangle-Strip-Hüllen (variable Pinselstärke) //! - `BezierCurve`: Glatte kubische Bézier-Kurven via `printpdf::Line` +use std::collections::HashMap; use std::error::Error; use std::fs::File; use std::io::BufWriter; @@ -22,6 +24,9 @@ use crate::background::{GRID_5MM_SPACING, LINED_SPACING}; use crate::document::{ Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH, }; +use ab_glyph::Font as _; + +use crate::text::TextFont; /// Bereinigt einen Titel für die Verwendung in Dateinamen (entfernt illegale Zeichen). pub fn sanitize_filename_component(title: &str) -> String { @@ -291,6 +296,7 @@ fn generate_pdf_document_with( let title = "ink-notes Notebook"; let (doc, page1_idx, layer1_bg) = PdfDocument::new(title, Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background"); + let fonts = embed_text_fonts(&doc, notebook)?; // Seitentitel werden zu Lesezeichen (Inhaltsverzeichnis im PDF-Betrachter) if let Some(title) = ¬ebook.pages[0].title { @@ -307,6 +313,9 @@ fn generate_pdf_document_with( if notebook.pages[0].strokes.iter().any(Stroke::is_image) { render_image_layer(&page1_ref.add_layer("Images"), ¬ebook.pages[0], assets); } + if notebook.pages[0].strokes.iter().any(Stroke::is_text) { + render_text_layer(&page1_ref.add_layer("Text"), ¬ebook.pages[0], &fonts); + } let layer1_ink_ref = page1_ref.add_layer("Ink"); render_ink_layer(&layer1_ink_ref, ¬ebook.pages[0], strategy); @@ -326,6 +335,9 @@ fn generate_pdf_document_with( if page.strokes.iter().any(Stroke::is_image) { render_image_layer(&page_ref.add_layer("Images"), page, assets); } + if page.strokes.iter().any(Stroke::is_text) { + render_text_layer(&page_ref.add_layer("Text"), page, &fonts); + } let layer_ink_ref = page_ref.add_layer("Ink"); render_ink_layer(&layer_ink_ref, page, strategy); @@ -425,7 +437,7 @@ fn render_background_layer(layer: &printpdf::PdfLayerReference, background: Page /// Rendert alle Striche einer Seite auf Layer 2 ("Ink"). fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy: ExportStrategy) { for stroke in &page.strokes { - if stroke.is_image() { + if stroke.is_object() { continue; } if let Some(ref shape) = stroke.shape { @@ -563,6 +575,80 @@ fn render_image_layer( } } +/// Bettet die von Textfeldern des Notizbuchs verwendeten Schriften ein. +fn embed_text_fonts( + doc: &PdfDocumentReference, + notebook: &Notebook, +) -> Result, Box> { + let mut fonts = HashMap::new(); + let used = notebook + .pages + .iter() + .flat_map(|p| &p.strokes) + .filter_map(|s| s.text.as_ref().map(|t| t.font)); + for font in used { + if let std::collections::hash_map::Entry::Vacant(entry) = fonts.entry(font) { + entry.insert(doc.add_external_font(font.bytes())?); + } + } + Ok(fonts) +} + +/// Rendert die Textfelder einer Seite als echten Text: Jedes Feld wird in seinem eigenen +/// Koordinatensystem (eine Einheit = eine Seiteneinheit, y nach oben) gesetzt, das eine +/// Transformationsmatrix auf seine Ecken abbildet – so stimmen Drehung und Skalierung. +fn render_text_layer( + layer: &printpdf::PdfLayerReference, + page: &Page, + fonts: &HashMap, +) { + for stroke in &page.strokes { + let (Some(text), Some(corners)) = (&stroke.text, stroke.object_corners()) else { + continue; + }; + let Some(font) = fonts.get(&text.font) else { + continue; + }; + let layout = text.layout(); + let size = glam::Vec2::new(text.wrap_width.max(text.size), layout.height()); + let (origin, [right, down]) = crate::text::frame_axes(&corners, size); + // Seite → PDF-Punkte: x · k, Höhe − y · k + let k = A4_WIDTH_MM / PAGE_WIDTH * 72.0 / 25.4; + let o = page_to_pdf_pt(origin); + let matrix = [right.x * k, -right.y * k, -down.x * k, down.y * k, o.x, o.y]; + layer.save_graphics_state(); + layer.set_ctm(printpdf::CurTransMat::Raw(matrix)); + layer.set_fill_color(Color::Rgb(Rgb::new( + stroke.color[0], + stroke.color[1], + stroke.color[2], + None, + ))); + layer.begin_text_section(); + layer.set_font(font, text.size); + for (i, line) in layout.lines.iter().enumerate() { + if line.text.trim().is_empty() { + continue; + } + layer.set_text_matrix(printpdf::TextMatrix::Translate( + printpdf::Pt(0.0), + printpdf::Pt(-layout.baseline(i)), + )); + // Glyphen samt Unterschneidung wie auf dem Bildschirm (Abstände in 1/1000 Em) + let face = text.font.face(); + let per_em = 1000.0 / face.units_per_em().unwrap_or(1000.0); + let mut previous: Option = None; + layer.write_positioned_codepoints(line.glyphs.iter().map(|&(id, _)| { + let kern = previous.map_or(0.0, |p| face.kern_unscaled(p, id)); + previous = Some(id); + ((-kern * per_em).round() as i64, id.0) + })); + } + layer.end_text_section(); + layer.restore_graphics_state(); + } +} + /// Graue 1×1-Fläche für fehlende Bilder. fn placeholder_xobject() -> printpdf::ImageXObject { rgb_xobject(1, 1, vec![200, 200, 200]) diff --git a/src/image_layer.rs b/src/image_layer.rs index 6e17b9b..8cd863d 100644 --- a/src/image_layer.rs +++ b/src/image_layer.rs @@ -264,6 +264,18 @@ impl ImageLayer { } } + /// Wird die Textur `key` von einer sichtbaren Seite verwendet? + pub fn uses(&self, key: &str) -> bool { + self.all_quads().any(|(k, _)| k == key) + } + + /// Gibt alle Texturen frei, deren Schlüssel `matches` erfüllt (sie werden beim nächsten + /// `prepare` neu angefordert). + pub fn forget_textures(&mut self, mut matches: impl FnMut(&str) -> bool) { + self.textures.retain(|key, _| !matches(key)); + self.texture_bytes = self.textures.values().map(|(_, b)| b).sum(); + } + /// Fehlen noch Texturen sichtbarer Seiten (z. B. PDF-Seite im Hintergrund-Rendering)? pub fn is_waiting(&self) -> bool { self.all_quads() diff --git a/src/lib.rs b/src/lib.rs index 77f0a34..3707a17 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ pub mod selection; pub mod settings; pub mod spline; pub mod storage; +pub mod text; pub mod ui; pub mod updater; pub mod viewport; diff --git a/src/main.rs b/src/main.rs index fde72d7..9dca5c9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -253,13 +253,15 @@ fn page_to_focus( (!grabs_selection).then_some(index) } -/// Zwischenablage: interne Objekte (Strg+C in ink-notes) und Bilder aus der -/// Systemzwischenablage (Screenshots, aus anderen Programmen kopierte Bilder). +/// Zwischenablage: interne Objekte (Strg+C in ink-notes), Bilder aus der +/// Systemzwischenablage (Screenshots, aus anderen Programmen kopierte Bilder) und Text. struct ClipboardBridge { system: Option, /// Prüfsumme des Systembildes zum Zeitpunkt des letzten internen Kopierens: Strg+V fügt /// das Systembild nur ein, wenn es danach kopiert wurde (die jüngste Kopie gewinnt) image_hash_at_copy: Option<[u8; 32]>, + /// Text der Systemzwischenablage zum Zeitpunkt des letzten internen Kopierens (wie oben) + text_at_copy: Option, /// Notizbuch, aus dem kopiert wurde (für Bilder beim Einfügen in ein anderes Notizbuch) source_dir: Option, } @@ -269,6 +271,7 @@ impl ClipboardBridge { Self { system: None, image_hash_at_copy: None, + text_at_copy: None, source_dir: None, } } @@ -290,9 +293,20 @@ impl ClipboardBridge { Some((image.to_owned_img(), hash)) } - /// Merkt sich beim internen Kopieren das aktuelle Systembild und das Quell-Notizbuch. + /// Text in der Systemzwischenablage (nicht nur Leerraum). + fn system_text(&mut self) -> Option { + if self.system.is_none() { + self.system = arboard::Clipboard::new().ok(); + } + let text = self.system.as_mut()?.get_text().ok()?; + (!text.trim().is_empty()).then_some(text) + } + + /// Merkt sich beim internen Kopieren das aktuelle Systembild, den Text und das + /// Quell-Notizbuch. fn note_internal_copy(&mut self, storage_dir: &Path) { self.image_hash_at_copy = self.system_image().map(|(_, hash)| hash); + self.text_at_copy = self.system_text(); self.source_dir = Some(storage_dir.to_path_buf()); } @@ -368,6 +382,124 @@ fn insert_image_on_page( )); } +/// Fügt `content` als Textfeld (Schrift des Text-Werkzeugs) in der Mitte des sichtbaren +/// Bereichs ein, wählt es aus und schaltet auf das Auswahlwerkzeug zum Verschieben. +#[allow(clippy::too_many_arguments)] +fn insert_text_on_page( + content: &str, + canvas: &mut CanvasState, + notebook: &mut Notebook, + manifest: &mut NotebookManifest, + autosave: &mut AutosaveController, + selection_state: &mut SelectionState, + ui_state: &mut UiState, +) { + let center = visible_page_center(canvas); + let mut edit = ink_notes::text::TextEditing::new_at( + notebook.current_page().id, + Vec2::new((center.x - 600.0).max(150.0), center.y), + ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT, + ui_state.text_font, + ui_state.text_color, + ); + edit.text.content = content.replace("\r\n", "\n").replace('\r', "\n"); + let id = notebook.next_stroke_id(); + let Some(stroke) = edit.to_stroke(id) else { + return; + }; + let page = notebook.current_page_mut(); + page.add_stroke(stroke); + canvas.sync_page(page); + manifest.next_stroke_id = notebook.next_stroke_id; + autosave.mark_dirty(notebook, manifest); + selection_state.select_single(id); + ui_state.active_tool = ActiveTool::Select; + ui_state.set_export_message("Text eingefügt – mit dem Text-Werkzeug (T) änderbar"); +} + +/// Beendet eine laufende Textbearbeitung: übernimmt das Feld (neu, geändert oder – wenn +/// geleert – entfernt) als einen Undo-Schritt auf seiner Seite. +fn finish_text_edit( + canvas: &mut CanvasState, + notebook: &mut Notebook, + manifest: &mut NotebookManifest, + autosave: &mut AutosaveController, + ui_state: &mut UiState, +) { + let Some(edit) = ui_state.text_edit.take() else { + return; + }; + canvas.set_hidden_text(None); + if let Some(index) = notebook.pages.iter().position(|p| p.id == edit.page_id) { + if edit.is_changed() && !notebook.pages[index].is_readonly() { + let id = match &edit.original { + Some(original) => original.id, + None => notebook.next_stroke_id(), + }; + manifest.next_stroke_id = notebook.next_stroke_id; + let page = &mut notebook.pages[index]; + match (edit.original.clone(), edit.to_stroke(id)) { + (None, Some(stroke)) => page.add_stroke(stroke), + (Some(before), Some(after)) => { + page.commit_modified_strokes(vec![before], vec![after]) + } + (Some(before), None) => { + page.remove_strokes_by_ids(&[before.id]); + } + (None, None) => {} + } + manifest.sync_from_notebook(notebook); + autosave.mark_page_dirty(¬ebook.pages[index], manifest); + } + } + canvas.sync_page(notebook.current_page_mut()); +} + +/// Text-Werkzeug: Tippen auf das Blatt beendet eine laufende Bearbeitung und öffnet das +/// Textfeld unter `page_pt` zum Bearbeiten – oder ein neues an dieser Stelle. +fn text_tool_click( + page_pt: Vec2, + canvas: &mut CanvasState, + notebook: &mut Notebook, + manifest: &mut NotebookManifest, + autosave: &mut AutosaveController, + ui_state: &mut UiState, +) { + finish_text_edit(canvas, notebook, manifest, autosave, ui_state); + let page = notebook.current_page(); + if page.is_readonly() { + return; + } + let hit = page + .strokes + .iter() + .rev() + .find(|s| s.is_text() && s.distance_to_point(page_pt) <= 0.0); + let edit = match hit { + Some(stroke) => { + let edit = ink_notes::text::TextEditing::of_stroke(page.id, stroke); + if let Some(edit) = &edit { + // Die Schriftknöpfe zeigen die Schrift des Felds und ändern sie + ui_state.text_size_pt = + (edit.text.size / ink_notes::text::UNITS_PER_PT * 2.0).round() / 2.0; + ui_state.text_font = edit.text.font; + ui_state.text_color = edit.color; + canvas.set_hidden_text(Some(stroke.id)); + } + edit + } + None => Some(ink_notes::text::TextEditing::new_at( + page.id, + page_pt, + ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT, + ui_state.text_font, + ui_state.text_color, + )), + }; + ui_state.text_edit = edit; + canvas.sync_page(notebook.current_page_mut()); +} + /// Textur für einen Schlüssel der Bildebene: `#` rendert der Hintergrund-Thread, /// alles andere ist eine Bilddatei im Asset-Ordner. fn texture_for( @@ -503,6 +635,23 @@ fn paste( } } + // Aus anderen Programmen kopierter Text wird ein Textfeld + if let Some(text) = clipboard.system_text() { + if selection_state.clipboard.is_empty() || clipboard.text_at_copy.as_deref() != Some(&text) + { + insert_text_on_page( + &text, + canvas, + notebook, + manifest, + autosave, + selection_state, + ui_state, + ); + return; + } + } + // Bilder aus einem anderen Notizbuch mitnehmen if let Some(source) = clipboard .source_dir @@ -739,10 +888,10 @@ fn main() -> Result<(), Box> { }; // Initiale Seite und Werkzeugeinstellungen mit Canvas synchronisieren + canvas.set_paper_theme(ui_state.paper_theme, ui_state.adapt_ink_colors); canvas.sync_page(notebook.current_page_mut()); canvas.set_stroke_color(ui_state.pen_color); canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm); - canvas.set_paper_theme(ui_state.paper_theme, ui_state.adapt_ink_colors); let start_time = Instant::now(); let mut cursor_screen_pos = (0.0_f32, 0.0_f32); @@ -801,7 +950,7 @@ fn main() -> Result<(), Box> { println!("=== ink-notes (Multi-Color & Objektauswahl/Transformation) bereit ==="); println!("Toolbar:"); - println!(" Stift / Textmarker / Formen (Auto-Snap) / Radierer / Auswählen (Lasso)"); + println!(" Stift / Textmarker / Formen (Auto-Snap) / Radierer / Auswählen (Lasso) / Text"); println!(" Farbpalette (Multicolor), Strichbreite, Undo/Redo, Zoom, Navigation & PDF-Export"); println!("Objektauswahl & Transformation:"); println!(" V : Auswahlwerkzeug (Lasso) umschalten"); @@ -813,6 +962,10 @@ fn main() -> Result<(), Box> { println!(" Palette-Klick : Ausgewählte Objekte direkt umfärben"); println!(" Strg + C / V : Ausgewählte Objekte kopieren & einfügen"); println!(" Entf/Backspace : Ausgewählte Objekte löschen (Undo mit Strg+Z)"); + println!("Text:"); + println!(" T : Text-Werkzeug umschalten"); + println!(" Klick ins Blatt: Neues Textfeld (vorhandenes Textfeld: bearbeiten)"); + println!(" Esc : Textbearbeitung beenden"); println!("Tastatur- & Maus-Shortcuts:"); println!(" Mausrad : Scrollen (durchgehende Ansicht), sonst Zoom"); println!(" Strg + Mausrad : Stufenloser Zoom zentriert auf Cursor"); @@ -1044,7 +1197,13 @@ fn main() -> Result<(), Box> { || stylus_barrel_pressed || ui_state.active_tool == ActiveTool::Eraser; - if should_erase { + if !should_erase && ui_state.active_tool == ActiveTool::Text { + if let Some(page_pt) = canvas.screen_to_page(sx, sy) { + text_tool_click(page_pt, &mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + } else if should_erase { is_erasing = true; if let Some(page_pt) = canvas.screen_to_page(sx, sy) { if notebook.erase_at(page_pt, @@ -1206,6 +1365,7 @@ fn main() -> Result<(), Box> { match event { WindowEvent::CloseRequested => { + finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state); println!("[Shutdown] Sichere alle ungespeicherten Daten auf Festplatte..."); match autosave.shutdown() { Ok(()) => { @@ -1288,6 +1448,18 @@ fn main() -> Result<(), Box> { return; } + // Offenes Textfeld ohne Fokus (z. B. nach einem Klick in die Leiste): + // Tasten nicht als Kürzel deuten, sondern das Feld wieder fokussieren + if let Some(edit) = &mut ui_state.text_edit { + if key_state == ElementState::Pressed + && !matches!(logical_key, Key::Named(NamedKey::Escape)) + { + edit.focus = true; + window.request_redraw(); + return; + } + } + if let Key::Named(NamedKey::Space) = &logical_key { is_space_pressed = key_state == ElementState::Pressed; if !is_space_pressed && is_panning { @@ -1322,8 +1494,10 @@ fn main() -> Result<(), Box> { ui_state.is_update_dialog_open = false; ui_state.show_updater_config = false; changed = true; - } - else if !selection_state.is_empty() { + } else if let Some(edit) = &mut ui_state.text_edit { + edit.finish = true; + changed = true; + } else if !selection_state.is_empty() { selection_state.clear(); changed = true; } @@ -1334,6 +1508,7 @@ fn main() -> Result<(), Box> { // Undo: Strg + Z Key::Character(c) if c.eq_ignore_ascii_case("z") && ctrl && !shift => { + finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state); if notebook.current_page_mut().undo() { selection_state.clear(); canvas.sync_page(notebook.current_page_mut()); @@ -1348,6 +1523,7 @@ fn main() -> Result<(), Box> { if (c.eq_ignore_ascii_case("y") && ctrl) || (c.eq_ignore_ascii_case("z") && ctrl && shift) => { + finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state); if notebook.current_page_mut().redo() { selection_state.clear(); canvas.sync_page(notebook.current_page_mut()); @@ -1438,7 +1614,7 @@ fn main() -> Result<(), Box> { // Radierer umschalten: E Key::Character(c) if c.eq_ignore_ascii_case("e") && !ctrl => { ui_state.active_tool = match ui_state.active_tool { - ActiveTool::Pen | ActiveTool::Highlighter | ActiveTool::Shape | ActiveTool::Select => ActiveTool::Eraser, + ActiveTool::Pen | ActiveTool::Highlighter | ActiveTool::Shape | ActiveTool::Select | ActiveTool::Text => ActiveTool::Eraser, ActiveTool::Eraser => ActiveTool::Pen, }; ui_state.is_pen_menu_open = false; @@ -1630,6 +1806,19 @@ fn main() -> Result<(), Box> { window.request_redraw(); } + // Text-Werkzeug umschalten: T + Key::Character(c) if c.eq_ignore_ascii_case("t") && !ctrl => { + ui_state.active_tool = if ui_state.active_tool == ActiveTool::Text { + ActiveTool::Pen + } else { + ActiveTool::Text + }; + ui_state.is_pen_menu_open = false; + ui_state.is_highlighter_menu_open = false; + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + // Formen-Werkzeug umschalten: S (ohne Strg) oder F Key::Character(c) if (c.eq_ignore_ascii_case("s") || c.eq_ignore_ascii_case("f")) && !ctrl => { ui_state.active_tool = if ui_state.active_tool == ActiveTool::Shape { @@ -1643,6 +1832,7 @@ fn main() -> Result<(), Box> { // Beenden: Strg + Q Key::Character(c) if c.eq_ignore_ascii_case("q") && ctrl => { + finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state); println!("[Shutdown] Sichere alle ungespeicherten Daten auf Festplatte..."); match autosave.shutdown() { Ok(()) => { @@ -1700,6 +1890,16 @@ fn main() -> Result<(), Box> { } else if button == MouseButton::Left { let (sx, sy) = cursor_screen_pos; + // Text-Werkzeug: Textfeld öffnen + if ui_state.active_tool == ActiveTool::Text { + if let Some(page_pt) = canvas.screen_to_page(sx, sy) { + text_tool_click(page_pt, &mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + return; + } + // 2. Objektauswahl & Transformation im Select-Modus if ui_state.active_tool == ActiveTool::Select { let screen_vec = Vec2::new(sx, sy); @@ -2119,6 +2319,12 @@ fn main() -> Result<(), Box> { window.request_redraw(); } } + } else if ui_state.active_tool == ActiveTool::Text { + if let Some(page_pt) = canvas.screen_to_page(tx, ty) { + text_tool_click(page_pt, &mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } } else if ui_state.active_tool == ActiveTool::Select { let screen_vec = Vec2::new(tx, ty); let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec, egui_ctx.pixels_per_point()); @@ -2409,7 +2615,8 @@ fn main() -> Result<(), Box> { || is_erasing || !selection_state.is_empty() || selection_state.drag_mode != DragMode::None - || ui_state.ruler_drag.is_some(); + || ui_state.ruler_drag.is_some() + || ui_state.text_edit.is_some(); if scrolled && !busy { last_scroll = Some(scroll); let target = canvas @@ -2474,7 +2681,7 @@ fn main() -> Result<(), Box> { canvas.set_stroke_color(ui_state.pen_color); canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm); } - ActiveTool::Eraser | ActiveTool::Select => {} + ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {} } update_window_title(&window, ¬ebook, &manifest, tool, autosave.status()); window.request_redraw(); @@ -2495,7 +2702,7 @@ fn main() -> Result<(), Box> { ui_state.highlighter_color = color; canvas.set_stroke_color(color); } - ActiveTool::Eraser | ActiveTool::Select => {} + ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {} } window.request_redraw(); } @@ -2509,7 +2716,7 @@ fn main() -> Result<(), Box> { ui_state.highlighter_stroke_width_mm = width; canvas.set_stroke_width_mm(width); } - ActiveTool::Eraser | ActiveTool::Select => {} + ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {} } window.request_redraw(); } @@ -2579,6 +2786,7 @@ fn main() -> Result<(), Box> { window.request_redraw(); } UiAction::Undo => { + finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state); if notebook.current_page_mut().undo() { selection_state.clear(); canvas.sync_page(notebook.current_page_mut()); @@ -2588,6 +2796,7 @@ fn main() -> Result<(), Box> { } } UiAction::Redo => { + finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state); if notebook.current_page_mut().redo() { selection_state.clear(); canvas.sync_page(notebook.current_page_mut()); @@ -3031,6 +3240,7 @@ fn main() -> Result<(), Box> { } UiAction::SetPaperTheme { theme, adapt_ink } => { canvas.set_paper_theme(theme, adapt_ink); + canvas.sync_page(notebook.current_page_mut()); ui_settings.paper_theme = theme; ui_settings.adapt_ink_colors = adapt_ink; save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); @@ -3052,6 +3262,18 @@ fn main() -> Result<(), Box> { } } + // Textbearbeitung beenden: Esc, anderes Werkzeug oder andere Seite + let finish_text = ui_state.text_edit.as_ref().is_some_and(|edit| { + edit.finish + || ui_state.active_tool != ActiveTool::Text + || edit.page_id != notebook.current_page().id + }); + if finish_text { + finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + // 4. egui Frame beenden & Primitives tessellieren let full_output = egui_ctx.end_frame(); egui_winit_state.handle_platform_output(&window, full_output.platform_output); diff --git a/src/page_export.rs b/src/page_export.rs index 9cd96ea..97f2bff 100644 --- a/src/page_export.rs +++ b/src/page_export.rs @@ -215,6 +215,87 @@ mod tests { assert!(svg.trim_end().ends_with("")); } + /// Begrenzungsrechteck (x0, y0, x1, y1) der deckenden Pixel. + fn ink_bounds(width: u32, pixels: &[u8]) -> (u32, u32, u32, u32) { + let mut b = (u32::MAX, u32::MAX, 0, 0); + for (i, p) in pixels.as_chunks::<4>().0.iter().enumerate() { + if p[3] > 128 { + let (x, y) = (i as u32 % width, i as u32 / width); + b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y)); + } + } + b + } + + fn page_with_text() -> (Page, crate::text::TextBox) { + let text = crate::text::TextBox::new( + "Hallo Welt\nzweite Zeile".into(), + 100.0, + crate::text::TextFont::Sans, + 1200.0, + ); + let size = glam::Vec2::new(1200.0, text.layout().height()); + let corners = crate::text::frame_corners( + glam::Vec2::new(300.0, 400.0), + [glam::Vec2::X, glam::Vec2::Y], + size, + ); + let mut page = Page::new(1, PageBackground::Grid5mm); + page.strokes.push(Stroke::new_text( + 3, + text.clone(), + corners, + [0.0, 0.0, 0.0, 1.0], + )); + (page, text) + } + + #[test] + fn test_exported_text_matches_screen_rendering() { + let (page, text) = page_with_text(); + let png = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap()); + let px_per_unit = png.width() as f32 / PAGE_WIDTH; + let exported = ink_bounds(png.width(), png.as_raw()); + + // Dieselbe Stelle, wie sie die Bildschirmtextur zeigt + let screen = crate::text::rasterize(&text, [0.0, 0.0, 0.0, 1.0], px_per_unit); + let local = ink_bounds(screen.width, &screen.pixels); + let (ox, oy) = ((300.0 * px_per_unit) as u32, (400.0 * px_per_unit) as u32); + let expected = (local.0 + ox, local.1 + oy, local.2 + ox, local.3 + oy); + for (a, b) in [ + (exported.0, expected.0), + (exported.1, expected.1), + (exported.2, expected.2), + (exported.3, expected.3), + ] { + assert!( + a.abs_diff(b) <= 3, + "PDF {exported:?} ≠ Bildschirm {expected:?}" + ); + } + } + + #[test] + fn test_exported_text_follows_rotation() { + let (mut page, _) = page_with_text(); + let png = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap()); + let straight = ink_bounds(png.width(), png.as_raw()); + + let center = page.strokes[0].aabb.min; + page.strokes[0].rotate(center, std::f32::consts::FRAC_PI_2); + let png = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap()); + let rotated = ink_bounds(png.width(), png.as_raw()); + // 90° im Uhrzeigersinn: Breite und Höhe tauschen, Text liegt links vom Drehpunkt + let (w, h) = (straight.2 - straight.0, straight.3 - straight.1); + let (rw, rh) = (rotated.2 - rotated.0, rotated.3 - rotated.1); + assert!( + rw.abs_diff(h) <= 4 && rh.abs_diff(w) <= 4, + "{straight:?} → {rotated:?}" + ); + let px_per_unit = png.width() as f32 / PAGE_WIDTH; + assert!((rotated.2 as f32) < 300.0 * px_per_unit + 2.0); + } + #[test] fn test_selection_image_is_cropped() { let page = page_with_line(); diff --git a/src/storage.rs b/src/storage.rs index 9440104..224ba1c 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -73,6 +73,9 @@ pub struct StrokeData { /// Eingefügtes Bild (Name im Asset-Ordner); die Samples sind dann die vier Bildecken #[serde(default, skip_serializing_if = "Option::is_none")] pub image: Option, + /// Textfeld; die Samples sind dann die vier Ecken des Felds + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text: Option, } impl From<&Stroke> for StrokeData { @@ -86,6 +89,7 @@ impl From<&Stroke> for StrokeData { width: stroke.width, finalized: stroke.finalized, image: stroke.image.clone(), + text: stroke.text.clone(), } } } @@ -100,6 +104,14 @@ impl StrokeData { .copied() .map(StylusSample::from) .collect(); + if let (Some(text), [a, b, c, d, ..]) = (&self.text, stylus_samples.as_slice()) { + return Stroke::new_text( + self.id, + text.clone(), + [a.position, b.position, c.position, d.position], + self.color, + ); + } if let Some(asset) = &self.image { if let [a, b, c, d, ..] = stylus_samples.as_slice() { let mut stroke = Stroke::new_image( @@ -153,7 +165,8 @@ impl StrokeData { /// verlieren. /// - Version 5: importierte PDF-Seiten als Seitenhintergrund (`pdf_background`). /// - Version 6: Seitentitel (`title` in Seite und Manifest). -pub const CURRENT_FORMAT_VERSION: u32 = 6; +/// - Version 7: Textfelder (`text` am Strich). +pub const CURRENT_FORMAT_VERSION: u32 = 7; /// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0). pub fn legacy_format_version() -> u32 { @@ -2479,6 +2492,39 @@ mod tests { assert!(dir.join("assets").join(&imported.asset).exists()); } + #[test] + fn test_text_boxes_round_trip_through_page_file() { + let temp_dir = tempfile::tempdir().unwrap(); + let (mut notebook, mut manifest, dir) = + create_new_notebook(temp_dir.path(), "Mit Text").unwrap(); + let text = crate::text::TextBox::new( + "Überschrift\n\tfn main() {}".into(), + 42.0, + crate::text::TextFont::Mono, + 900.0, + ); + let corners = [ + glam::Vec2::new(100.0, 100.0), + glam::Vec2::new(1000.0, 100.0), + glam::Vec2::new(1000.0, 220.0), + glam::Vec2::new(100.0, 220.0), + ]; + notebook.current_page_mut().strokes.push(Stroke::new_text( + 8, + text.clone(), + corners, + [0.15, 0.35, 0.85, 1.0], + )); + save_notebook(&dir, ¬ebook, &mut manifest).unwrap(); + + let (reloaded, _) = load_notebook(&dir).unwrap(); + let stroke = &reloaded.current_page().strokes[0]; + assert_eq!(stroke.text.as_ref(), Some(&text)); + assert_eq!(stroke.object_corners(), Some(corners)); + assert_eq!(stroke.color, [0.15, 0.35, 0.85, 1.0]); + assert!(stroke.is_text() && !stroke.is_image() && stroke.vertices.is_empty()); + } + #[test] fn test_create_notebook_from_pdf() { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/src/text.rs b/src/text.rs new file mode 100644 index 0000000..22f2eb9 --- /dev/null +++ b/src/text.rs @@ -0,0 +1,541 @@ +//! Textfelder (Text-Werkzeug): Datenmodell, Zeilenumbruch und Rasterung. +//! +//! Ein Textfeld ist ein Objekt mit vier Ecken wie ein Bild (siehe `Stroke::text`). Der Text +//! wird in einem eigenen Koordinatensystem gesetzt – Breite = Umbruchbreite, Höhe = Zeilen × +//! Zeilenhöhe, in Seiteneinheiten – und dieses Rechteck auf die Ecken abgebildet. So wirken +//! Verschieben, Drehen und Skalieren der Auswahl ohne Sonderfälle auf Text. +//! +//! Bildschirm (Textur), PDF-Export (eingebettete Schrift, durchsuchbar) und Bearbeitung (egui) +//! verwenden dieselben Schriften aus egui und denselben Zeilenumbruch. + +use std::sync::OnceLock; + +use ab_glyph::{Font, FontRef, GlyphId, PxScale}; +use serde::{Deserialize, Serialize}; + +use crate::assets::RgbaImage; +use crate::document::{Stroke, PAGE_WIDTH, UNITS_PER_MM}; + +/// Schriftart eines Textfelds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +pub enum TextFont { + /// Proportionalschrift (Ubuntu Light) für Überschriften und Notizen + #[default] + Sans, + /// Festbreitenschrift (Hack) für Code + Mono, +} + +impl TextFont { + pub fn label(self) -> &'static str { + match self { + TextFont::Sans => "Normal", + TextFont::Mono => "Code", + } + } + + /// Name der Schrift in den egui-Standardschriften. + fn egui_name(self) -> &'static str { + match self { + TextFont::Sans => "Ubuntu-Light", + TextFont::Mono => "Hack", + } + } + + /// egui-Schriftfamilie, deren erste Schrift diese ist (für die Bearbeitung). + pub fn egui_family(self) -> egui::FontFamily { + match self { + TextFont::Sans => egui::FontFamily::Proportional, + TextFont::Mono => egui::FontFamily::Monospace, + } + } + + /// TrueType-Daten der Schrift (aus den in egui eingebauten Schriften). + pub fn bytes(self) -> &'static [u8] { + static DEFINITIONS: OnceLock = OnceLock::new(); + let definitions = DEFINITIONS.get_or_init(egui::FontDefinitions::default); + &definitions.font_data[self.egui_name()].font + } + + pub fn face(self) -> &'static FontRef<'static> { + static SANS: OnceLock> = OnceLock::new(); + static MONO: OnceLock> = OnceLock::new(); + let cell = match self { + TextFont::Sans => &SANS, + TextFont::Mono => &MONO, + }; + cell.get_or_init(|| { + FontRef::try_from_slice(self.bytes()).expect("eingebaute egui-Schrift ist gültig") + }) + } + + fn units_per_em(self) -> f32 { + self.face().units_per_em().unwrap_or(1000.0) + } + + /// Höhe von Ober- bis Unterlänge je Em: ab_glyph skaliert auf diese Höhe, PDF, egui und + /// dieses Modul messen die Schriftgröße als Em. + pub fn height_per_em(self) -> f32 { + let face = self.face(); + (face.ascent_unscaled() - face.descent_unscaled()) / self.units_per_em() + } +} + +/// Seiteneinheiten je typografischem Punkt. +pub const UNITS_PER_PT: f32 = 25.4 / 72.0 * UNITS_PER_MM; +/// Wählbare Schriftgrößen in Punkt. +pub const TEXT_SIZES_PT: [f32; 7] = [8.0, 10.0, 12.0, 14.0, 18.0, 24.0, 36.0]; +pub const DEFAULT_TEXT_SIZE_PT: f32 = 12.0; +/// Kleinste Umbruchbreite eines neuen Textfelds (3 cm). +pub const MIN_WRAP_WIDTH: f32 = 300.0; +/// Abstand neuer Textfelder zum rechten Blattrand (1 cm). +pub const RIGHT_MARGIN: f32 = 100.0; +/// Ein Tabulator wird durch so viele Leerzeichen ersetzt. +const TAB_WIDTH: usize = 4; + +/// Inhalt und Satz eines Textfelds. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TextBox { + pub content: String, + /// Schriftgröße (Em) in Seiteneinheiten + pub size: f32, + #[serde(default)] + pub font: TextFont, + /// Umbruchbreite in Seiteneinheiten (Breite des ungedrehten, unskalierten Felds) + pub wrap_width: f32, +} + +/// Eine gesetzte Zeile: Zeichen (ohne Zeichen, die die Schrift nicht kennt) und ihre +/// Glyphen mit x-Position. +#[derive(Debug, Clone, PartialEq)] +pub struct LaidLine { + pub text: String, + pub glyphs: Vec<(GlyphId, f32)>, + pub width: f32, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct TextLayout { + pub lines: Vec, + /// Oberlänge (Abstand Zeilenoberkante → Grundlinie) + pub ascent: f32, + pub line_height: f32, +} + +impl TextLayout { + /// Höhe des Felds; ein leeres Feld ist eine Zeile hoch. + pub fn height(&self) -> f32 { + self.lines.len().max(1) as f32 * self.line_height + } + + /// Grundlinie der Zeile `i` (von der Oberkante des Felds). + pub fn baseline(&self, i: usize) -> f32 { + i as f32 * self.line_height + self.ascent + } +} + +impl TextBox { + pub fn new(content: String, size: f32, font: TextFont, wrap_width: f32) -> Self { + Self { + content, + size, + font, + wrap_width, + } + } + + /// Größe des ungedrehten, unskalierten Felds in Seiteneinheiten. + pub fn box_size(&self) -> glam::Vec2 { + glam::Vec2::new(self.wrap_width.max(self.size), self.layout().height()) + } + + /// Setzt den Text mit Zeilenumbruch an Leerzeichen; zu lange Wörter werden zeichenweise + /// umbrochen, Zeilenumbrüche im Text bleiben erhalten. + pub fn layout(&self) -> TextLayout { + let face = self.font.face(); + let em = self.size / self.font.units_per_em(); + let line_height = + (face.ascent_unscaled() - face.descent_unscaled() + face.line_gap_unscaled()) * em; + let width = self.wrap_width.max(self.size); + + let mut lines = Vec::new(); + for paragraph in self.content.split('\n') { + let mut chars: Vec<(char, GlyphId, f32)> = paragraph + .chars() + .flat_map(|c| { + let (c, n) = if c == '\t' { (' ', TAB_WIDTH) } else { (c, 1) }; + std::iter::repeat_n(c, n) + }) + .filter_map(|c| { + let id = face.glyph_id(c); + (id.0 != 0).then(|| (c, id, face.h_advance_unscaled(id) * em)) + }) + .collect(); + // Unterschneidung (wie egui) dem vorderen Zeichen des Paars zurechnen + for i in 1..chars.len() { + chars[i - 1].2 += face.kern_unscaled(chars[i - 1].1, chars[i].1) * em; + } + let mut start = 0; + let mut x = 0.0; + // Letztes Leerzeichen der Zeile (möglicher Umbruch) + let mut last_space: Option = None; + for i in 0..chars.len() { + let (c, _, advance) = chars[i]; + if c != ' ' && x + advance > width && i > start { + let end = match last_space { + Some(space) if space > start => space, + _ => i, + }; + lines.push(laid_line(&chars[start..end])); + start = end; + while start < i && chars[start].0 == ' ' { + start += 1; + } + x = chars[start..i].iter().map(|g| g.2).sum(); + last_space = None; + } + x += advance; + if c == ' ' { + last_space = Some(i); + } + } + lines.push(laid_line(&chars[start..])); + } + // Leere Zeilen am Ende zählen mit (Platz für den Cursor), ein leerer Text ist eine Zeile + TextLayout { + lines, + ascent: face.ascent_unscaled() * em, + line_height, + } + } +} + +fn laid_line(chars: &[(char, GlyphId, f32)]) -> LaidLine { + let mut x = 0.0; + let mut glyphs = Vec::with_capacity(chars.len()); + let mut width: f32 = 0.0; + for &(c, id, advance) in chars { + glyphs.push((id, x)); + x += advance; + if c != ' ' { + width = x; + } + } + LaidLine { + text: chars.iter().map(|g| g.0).collect(), + glyphs, + width, + } +} + +/// Ecken eines Felds der Größe `size` mit Ursprung (oben links) `origin` und den Achsen +/// `axes` (Seitenvektor je Einheit nach rechts bzw. unten): oben links, oben rechts, +/// unten rechts, unten links. +pub fn frame_corners( + origin: glam::Vec2, + axes: [glam::Vec2; 2], + size: glam::Vec2, +) -> [glam::Vec2; 4] { + let right = axes[0] * size.x; + let down = axes[1] * size.y; + [origin, origin + right, origin + right + down, origin + down] +} + +/// Ursprung und Achsen (je Einheit) eines Felds der Größe `size` mit den Ecken `corners`. +pub fn frame_axes(corners: &[glam::Vec2; 4], size: glam::Vec2) -> (glam::Vec2, [glam::Vec2; 2]) { + let [tl, tr, _, bl] = *corners; + ( + tl, + [(tr - tl) / size.x.max(1e-3), (bl - tl) / size.y.max(1e-3)], + ) +} + +/// Laufende Bearbeitung eines Textfelds (neu oder vorhanden). +#[derive(Debug, Clone, PartialEq)] +pub struct TextEditing { + /// Seite, auf der das Feld liegt + pub page_id: u64, + /// Bearbeitetes vorhandenes Feld im Zustand vor der Bearbeitung; `None` bei einem neuen + pub original: Option, + /// Ecke oben links und Achsen (Seitenvektor je Einheit nach rechts bzw. unten) + pub origin: glam::Vec2, + pub axes: [glam::Vec2; 2], + pub text: TextBox, + pub color: [f32; 4], + /// Beim nächsten Bild den Tastaturfokus auf das Feld setzen + pub focus: bool, + /// Bearbeitung beenden (Esc) + pub finish: bool, +} + +impl TextEditing { + /// Neues, leeres Feld: die erste Zeile mittig auf `pos`, umbrochen 1 cm vor dem rechten + /// Blattrand (mindestens 3 cm breit). + pub fn new_at( + page_id: u64, + pos: glam::Vec2, + size: f32, + font: TextFont, + color: [f32; 4], + ) -> Self { + let right = PAGE_WIDTH - RIGHT_MARGIN; + let x = pos.x.min(right - MIN_WRAP_WIDTH).max(0.0); + let text = TextBox::new(String::new(), size, font, (right - x).max(MIN_WRAP_WIDTH)); + let line_height = text.layout().line_height; + Self { + page_id, + original: None, + origin: glam::Vec2::new(x, (pos.y - line_height / 2.0).max(0.0)), + axes: [glam::Vec2::X, glam::Vec2::Y], + text, + color, + focus: true, + finish: false, + } + } + + /// Bearbeitung des vorhandenen Textfelds `stroke`. + pub fn of_stroke(page_id: u64, stroke: &Stroke) -> Option { + let text = stroke.text.clone()?; + let corners = stroke.object_corners()?; + let (origin, axes) = frame_axes(&corners, text.box_size()); + Some(Self { + page_id, + original: Some(stroke.clone()), + origin, + axes, + text, + color: stroke.color, + focus: true, + finish: false, + }) + } + + /// Wurde gegenüber dem Ausgangszustand etwas geändert? + pub fn is_changed(&self) -> bool { + match &self.original { + Some(original) => { + original.text.as_ref() != Some(&self.text) || original.color != self.color + } + None => !self.is_empty(), + } + } + + /// Enthält das Feld nur Leerraum? + pub fn is_empty(&self) -> bool { + self.text.content.trim().is_empty() + } + + /// Ergebnis als Textfeld mit der ID `id`; die Höhe passt sich dem Text an, Lage, + /// Drehung und Skalierung bleiben. `None` für ein leeres Feld. + pub fn to_stroke(&self, id: u64) -> Option { + if self.is_empty() { + return None; + } + let corners = frame_corners(self.origin, self.axes, self.text.box_size()); + Some(Stroke::new_text(id, self.text.clone(), corners, self.color)) + } +} + +/// Exponent für die Kantendeckung der Glyphen (wie egui, damit Bearbeitung und Anzeige +/// gleich kräftig wirken). +const COVERAGE_GAMMA: f32 = 0.55; + +/// Größte Kantenlänge einer Texttextur in Pixeln. +const MAX_TEXTURE_SIDE: f32 = 4096.0; +/// Größte Pixelzahl einer Texttextur (64 MB RGBA). +const MAX_TEXTURE_PIXELS: f32 = 16.0 * 1024.0 * 1024.0; + +/// Rastert ein Textfeld mit `px_per_unit` Pixeln je Seiteneinheit (begrenzt auf eine +/// handhabbare Texturgröße). `color` ist linear wie die Tinte; die Pixel sind sRGB. +pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage { + let layout = text.layout(); + let size = glam::Vec2::new(text.wrap_width.max(text.size), layout.height()); + let scale = px_per_unit + .min(MAX_TEXTURE_SIDE / size.x) + .min(MAX_TEXTURE_SIDE / size.y) + .min((MAX_TEXTURE_PIXELS / (size.x * size.y)).sqrt()) + .max(1e-3); + let width = ((size.x * scale).ceil() as u32).max(1); + let height = ((size.y * scale).ceil() as u32).max(1); + + let face = text.font.face(); + // ab_glyph misst die Skalierung als Höhe Ober- bis Unterlänge + let px_scale = PxScale::from(text.size * scale * text.font.height_per_em()); + let mut coverage = vec![0.0_f32; (width * height) as usize]; + for (i, line) in layout.lines.iter().enumerate() { + let baseline = layout.baseline(i) * scale; + for &(id, x) in &line.glyphs { + let glyph = id.with_scale_and_position(px_scale, ab_glyph::point(x * scale, baseline)); + let Some(outlined) = face.outline_glyph(glyph) else { + continue; + }; + let bounds = outlined.px_bounds(); + outlined.draw(|gx, gy, c| { + let px = bounds.min.x as i64 + gx as i64; + let py = bounds.min.y as i64 + gy as i64; + if px >= 0 && py >= 0 && (px as u32) < width && (py as u32) < height { + let cell = &mut coverage[(py as u32 * width + px as u32) as usize]; + *cell = (*cell + c).min(1.0); + } + }); + } + } + + let rgb = [0, 1, 2].map(|i| egui::ecolor::gamma_u8_from_linear_f32(color[i])); + let alpha = color[3].clamp(0.0, 1.0); + let mut pixels = Vec::with_capacity(coverage.len() * 4); + for c in coverage { + pixels.extend_from_slice(&rgb); + // Wie egui: Deckung anheben, sonst wirkt die dünne Schrift verkleinert blass + pixels.push((c.powf(COVERAGE_GAMMA) * alpha * 255.0).round() as u8); + } + RgbaImage { + width, + height, + pixels, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample(content: &str, wrap_width: f32) -> TextBox { + TextBox::new(content.to_string(), 50.0, TextFont::Sans, wrap_width) + } + + #[test] + fn test_fonts_load() { + for font in [TextFont::Sans, TextFont::Mono] { + assert!(font.face().glyph_id('ä').0 != 0); + let factor = font.height_per_em(); + assert!((1.0..1.4).contains(&factor), "{factor}"); + } + } + + #[test] + fn test_layout_wraps_at_spaces() { + let text = sample("eins zwei drei vier fünf sechs", 400.0); + let layout = text.layout(); + assert!(layout.lines.len() > 1); + for line in &layout.lines { + assert!(line.width <= 400.0 + 1e-3, "{line:?}"); + assert!(!line.text.starts_with(' ') && !line.text.ends_with(' ')); + } + let words: Vec = layout.lines.iter().map(|l| l.text.clone()).collect(); + assert_eq!(words.join(" "), "eins zwei drei vier fünf sechs"); + } + + #[test] + fn test_layout_breaks_long_words_and_keeps_newlines() { + let text = sample("Donaudampfschifffahrtsgesellschaft\n\nende", 300.0); + let layout = text.layout(); + assert!(layout.lines.iter().all(|l| l.width <= 300.0 + 1e-3)); + let joined: String = layout.lines.iter().map(|l| l.text.as_str()).collect(); + assert_eq!(joined, "Donaudampfschifffahrtsgesellschaftende"); + // Leere Zeile bleibt erhalten + assert!(layout.lines.iter().any(|l| l.text.is_empty())); + assert_eq!(layout.lines.last().unwrap().text, "ende"); + } + + #[test] + fn test_empty_text_is_one_line_high() { + let layout = sample("", 500.0).layout(); + assert!((layout.height() - layout.line_height).abs() < 1e-3); + assert!(layout.line_height > 50.0); + } + + #[test] + fn test_tabs_become_spaces_and_indent_is_kept() { + let text = TextBox::new("\tx".into(), 50.0, TextFont::Mono, 2000.0); + let line = &text.layout().lines[0]; + assert_eq!(line.text, " x"); + // Festbreitenschrift: x steht vier Zeichenbreiten weiter + let advance = line.glyphs[1].1; + assert!((line.glyphs[4].1 - 4.0 * advance).abs() < 1e-3); + } + + #[test] + fn test_frame_round_trip() { + let size = glam::Vec2::new(400.0, 120.0); + let axes = [glam::Vec2::new(0.0, 2.0), glam::Vec2::new(-2.0, 0.0)]; + let corners = frame_corners(glam::Vec2::new(100.0, 50.0), axes, size); + let (origin, back) = frame_axes(&corners, size); + assert_eq!(origin, glam::Vec2::new(100.0, 50.0)); + assert!((back[0] - axes[0]).length() < 1e-4 && (back[1] - axes[1]).length() < 1e-4); + } + + #[test] + fn test_rasterize_draws_ink_in_color() { + let text = sample("Hallo", 400.0); + let image = rasterize(&text, [0.85, 0.2, 0.2, 1.0], 1.0); + assert_eq!(image.width, 400); + assert_eq!(image.height as f32, text.layout().height().ceil()); + let opaque = image.pixels.chunks(4).filter(|p| p[3] > 200).count(); + assert!(opaque > 100, "{opaque}"); + // Farbe ist sRGB-kodiert + assert!(image.pixels.chunks(4).all(|p| p[0] > 230 && p[1] < 130)); + } + + #[test] + fn test_editing_new_text_box() { + let edit = TextEditing::new_at( + 1, + glam::Vec2::new(500.0, 800.0), + 50.0, + TextFont::Sans, + [0.0; 4], + ); + assert!((edit.text.wrap_width - (PAGE_WIDTH - RIGHT_MARGIN - 500.0)).abs() < 1e-3); + assert!(edit.origin.y < 800.0); + assert!(!edit.is_changed()); + assert!(edit.to_stroke(9).is_none(), "leeres Feld wird verworfen"); + + // Ganz rechts: Feld wird nach links versetzt, damit es mindestens 3 cm breit ist + let edge = TextEditing::new_at( + 1, + glam::Vec2::new(2050.0, 100.0), + 50.0, + TextFont::Sans, + [0.0; 4], + ); + assert!((edge.text.wrap_width - MIN_WRAP_WIDTH).abs() < 1e-3); + assert!(edge.origin.x + MIN_WRAP_WIDTH <= PAGE_WIDTH); + } + + #[test] + fn test_editing_keeps_transform_and_grows_with_text() { + let mut edit = TextEditing::new_at( + 1, + glam::Vec2::new(400.0, 400.0), + 50.0, + TextFont::Sans, + [0.0; 4], + ); + edit.text.content = "eine Zeile".into(); + let mut stroke = edit.to_stroke(5).unwrap(); + assert!(stroke.is_text() && stroke.is_object()); + stroke.rotate(glam::Vec2::new(400.0, 400.0), 0.5); + stroke.scale(glam::Vec2::new(400.0, 400.0), glam::Vec2::new(2.0, 2.0)); + let before = stroke.object_corners().unwrap(); + + let mut edit = TextEditing::of_stroke(1, &stroke).unwrap(); + assert!(!edit.is_changed()); + edit.text.content = "eine Zeile\nzwei Zeilen".into(); + assert!(edit.is_changed()); + let after = edit.to_stroke(5).unwrap().object_corners().unwrap(); + // Obere Kante bleibt, die Seitenkanten werden in ihrer Richtung doppelt so lang + assert!((after[0] - before[0]).length() < 1e-2 && (after[1] - before[1]).length() < 1e-2); + let (old_side, new_side) = (before[3] - before[0], after[3] - after[0]); + assert!((new_side.length() / old_side.length() - 2.0).abs() < 1e-3); + assert!(old_side.normalize().dot(new_side.normalize()) > 0.9999); + } + + #[test] + fn test_rasterize_limits_texture_size() { + let text = sample("x", 2000.0); + let image = rasterize(&text, [0.0, 0.0, 0.0, 1.0], 100.0); + assert!(image.width as f32 <= MAX_TEXTURE_SIDE); + } +} diff --git a/src/ui.rs b/src/ui.rs index 9189435..3370d4f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -20,6 +20,7 @@ use crate::file_picker::{FilePicker, PickerKind}; use crate::ruler::{Ruler, RulerHit}; use crate::settings::{PressureCurve, SidebarSide}; use crate::storage::NotebookSummary; +use crate::text::{TextEditing, TextFont, TEXT_SIZES_PT}; /// Aktives Werkzeug für das Zeichnen oder Bearbeiten. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -29,6 +30,8 @@ pub enum ActiveTool { Eraser, Shape, Select, + /// Textfelder schreiben und bearbeiten + Text, } impl ActiveTool { @@ -39,6 +42,7 @@ impl ActiveTool { ActiveTool::Eraser => "Radierer (Eraser)", ActiveTool::Shape => "Formen (Shape)", ActiveTool::Select => "Auswahl / Lasso (Select)", + ActiveTool::Text => "Text (T)", } } @@ -264,6 +268,12 @@ pub struct UiState { pub adapt_ink_colors: bool, /// Seiten hängen untereinander und werden durchgehend gescrollt pub continuous_scroll: bool, + /// Text-Werkzeug: Farbe, Schriftgröße (Punkt) und Schriftart neuer Textfelder + pub text_color: [f32; 4], + pub text_size_pt: f32, + pub text_font: TextFont, + /// Laufende Bearbeitung eines Textfelds + pub text_edit: Option, /// Druckkurve des Stifts pub pressure_curve: PressureCurve, /// Zuletzt gemessener Rohdruck des Stifts (Anzeige in der Druckkurve) @@ -326,6 +336,10 @@ impl Default for UiState { paper_theme: PaperTheme::White, adapt_ink_colors: true, continuous_scroll: true, + text_color: PEN_COLOR_PRESETS[0].0, + text_size_pt: crate::text::DEFAULT_TEXT_SIZE_PT, + text_font: TextFont::Sans, + text_edit: None, pressure_curve: PressureCurve::default(), last_raw_pressure: None, file_picker: None, @@ -422,9 +436,10 @@ 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 { - let select = tool == ActiveTool::Select; - // Notizbücher, 5 Werkzeuge, Undo/Redo, 3× Seiten, Menü (+ 4 Auswahl-Aktionen) - let buttons = 12 + if select { 4 } else { 0 }; + 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 }; let dividers = 5 + usize::from(select); let spacing = 3.0; let needed = |b: f32| { @@ -456,6 +471,7 @@ impl UiState { match self.active_tool { ActiveTool::Pen | ActiveTool::Shape | ActiveTool::Select => self.pen_color, ActiveTool::Highlighter => self.highlighter_color, + ActiveTool::Text => self.text_color, ActiveTool::Eraser => [0.0, 0.0, 0.0, 0.0], } } @@ -463,7 +479,9 @@ impl UiState { /// Gibt die aktuelle Strichbreite in mm zurück. pub fn current_stroke_width_mm(&self) -> f32 { match self.active_tool { - ActiveTool::Pen | ActiveTool::Shape | ActiveTool::Select => self.pen_stroke_width_mm, + ActiveTool::Pen | ActiveTool::Shape | ActiveTool::Select | ActiveTool::Text => { + self.pen_stroke_width_mm + } ActiveTool::Highlighter => self.highlighter_stroke_width_mm, ActiveTool::Eraser => 3.0, } @@ -951,7 +969,33 @@ pub fn page_thumbnail_shapes( let total: usize = page.strokes.iter().map(|s| s.points.len()).sum(); let step = total.div_ceil(THUMBNAIL_POINT_BUDGET).max(1); - for stroke in page.strokes.iter().filter(|s| !s.is_image()) { + // Textfelder: je Zeile ein Strich in Textfarbe auf Höhe der Kleinbuchstaben + for stroke in &page.strokes { + let (Some(text), Some(corners)) = (&stroke.text, stroke.object_corners()) else { + continue; + }; + let ink = if adapt { + adapt_ink_color(stroke.color) + } else { + stroke.color + }; + let color = color32_from(ink).gamma_multiply(0.7); + let layout = text.layout(); + let (origin, [right, down]) = crate::text::frame_axes(&corners, text.box_size()); + let width = (text.size * 0.45 * down.length() * scale).clamp(0.6, 6.0); + for (i, line) in layout.lines.iter().enumerate() { + if line.width <= 0.0 { + continue; + } + let start = origin + down * (layout.baseline(i) - text.size * 0.25); + shapes.push(egui::Shape::line_segment( + [to_screen(start), to_screen(start + right * line.width)], + egui::Stroke::new(width, color), + )); + } + } + + for stroke in page.strokes.iter().filter(|s| !s.is_object()) { let ink = if adapt { adapt_ink_color(stroke.color) } else { @@ -979,6 +1023,126 @@ pub fn page_thumbnail_shapes( shapes } +/// Knöpfe für Schriftart und -größe des Text-Werkzeugs. Änderungen gelten auch für das +/// gerade bearbeitete Textfeld. +fn text_style_buttons(ui: &mut egui::Ui, btn: f32, state: &mut UiState) { + let size = state.text_size_pt; + let smaller = TEXT_SIZES_PT + .iter() + .rev() + .copied() + .find(|&s| s < size - 0.01); + let larger = TEXT_SIZES_PT.iter().copied().find(|&s| s > size + 0.01); + let smaller_tip = format!("Kleinere Schrift (jetzt {size} pt)"); + let larger_tip = format!("Größere Schrift (jetzt {size} pt)"); + enum Change { + Font(TextFont), + /// Neue Größe; `None`: keine kleinere bzw. größere mehr + Size(Option), + } + let buttons = [ + ( + SidebarIcon::Text("Aa"), + "Normale Schrift", + Change::Font(TextFont::Sans), + ), + ( + SidebarIcon::Text("{ }"), + "Code-Schrift (feste Zeichenbreite, Tab rückt ein)", + Change::Font(TextFont::Mono), + ), + (SidebarIcon::Text("A−"), &smaller_tip, Change::Size(smaller)), + (SidebarIcon::Text("A+"), &larger_tip, Change::Size(larger)), + ]; + for (icon, tooltip, change) in buttons { + let (active, enabled) = match change { + Change::Font(font) => (state.text_font == font, true), + Change::Size(new_size) => (false, new_size.is_some()), + }; + if !sidebar_button(ui, btn, icon, active, enabled, None, tooltip).clicked() { + continue; + } + match change { + Change::Font(font) => state.text_font = font, + Change::Size(Some(new_size)) => { + state.text_size_pt = new_size; + state.set_export_message(format!("Schriftgröße {new_size} pt")); + } + Change::Size(None) => {} + } + if let Some(edit) = &mut state.text_edit { + edit.text.font = state.text_font; + edit.text.size = state.text_size_pt * crate::text::UNITS_PER_PT; + edit.focus = true; + } + } +} + +/// Bearbeitung eines Textfelds: egui-Textfeld an der Stelle und in der Größe des Felds auf +/// dem Blatt (ungedreht), mit derselben Schrift. Esc beendet die Bearbeitung. +fn text_edit_overlay(ctx: &egui::Context, state: &mut UiState) { + let adapt = state.adapt_ink_colors && state.paper_theme.is_dark(); + let (Some(view), Some(edit)) = (state.page_view, state.text_edit.as_mut()) else { + return; + }; + let [sx, sy] = edit.axes.map(|a| a.length()); + let font_size = (edit.text.size * sy * view.scale).clamp(2.0, 400.0); + // egui rundet die Glyphenhöhe auf ganze Pixel: die Umbruchbreite im selben Verhältnis + // anpassen, damit die Zeilen an denselben Stellen umbrechen wie auf dem Blatt + let height_px = font_size * ctx.pixels_per_point() * edit.text.font.height_per_em(); + let rounding = height_px.round().max(1.0) / height_px; + let width = (edit.text.wrap_width.max(edit.text.size) * sx * view.scale * rounding).max(8.0); + let color = linear_color32(if adapt { + adapt_ink_color(edit.color) + } else { + edit.color + }); + egui::Area::new(egui::Id::new("text_edit_area")) + .fixed_pos(view.to_screen(edit.origin)) + .order(egui::Order::Middle) + .show(ctx, |ui| { + egui::Frame::none() + .stroke(egui::Stroke::new( + 1.0_f32, + egui::Color32::from_rgba_unmultiplied(70, 130, 230, 200), + )) + .show(ui, |ui| { + let response = ui.add( + egui::TextEdit::multiline(&mut edit.text.content) + .id(egui::Id::new("text_edit_field")) + .frame(false) + .margin(egui::vec2(0.0, 0.0)) + .font(egui::FontId::new(font_size, edit.text.font.egui_family())) + .text_color(color) + .desired_width(width) + .desired_rows(1) + .lock_focus(true), + ); + if edit.focus { + response.request_focus(); + edit.focus = false; + } + // egui 0.27 zählt Enter in einem fokussierten Feld als Klick und fordert + // den Fokus neu an – dabei geht die Tab-Sperre verloren und Tab würde + // danach den Fokus wegnehmen statt einzurücken + ui.memory_mut(|m| { + m.set_focus_lock_filter( + response.id, + egui::EventFilter { + tab: true, + horizontal_arrows: true, + vertical_arrows: true, + escape: false, + }, + ) + }); + }); + }); + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + edit.finish = true; + } +} + /// Linearer Farbwert (wie auf der GPU) als egui-Farbe (sRGB). fn linear_color32(color: [f32; 4]) -> egui::Color32 { egui::Rgba::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]).into() @@ -1594,6 +1758,7 @@ pub fn render_ui( let mut actions = std::mem::take(&mut state.queued_actions); state.screen_size = ctx.screen_rect().size(); ruler_overlay(ctx, state); + text_edit_overlay(ctx, state); let mut pen_btn_rect = None; let mut high_btn_rect = None; @@ -1760,6 +1925,12 @@ pub fn render_ui( SidebarIcon::Lasso, "Auswählen: verschieben, drehen, skalieren, kopieren (V)", ), + ( + ActiveTool::Text, + SidebarIcon::Glyph("T"), + "Text (T): ins Blatt tippen und schreiben, vorhandenen Text \ + antippen zum Ändern; Esc beendet", + ), ] { if sidebar_button( ui, @@ -1806,6 +1977,9 @@ pub fn render_ui( &HIGHLIGHTER_COLOR_PRESETS[..], state.highlighter_color, )), + ActiveTool::Text => { + Some((&PEN_COLOR_PRESETS[..], state.text_color)) + } ActiveTool::Eraser => None, }; // Fester Platz für die längste Palette, damit die Knöpfe darunter @@ -1821,10 +1995,18 @@ pub fn render_ui( if color_circle_swatch(ui, color, is_sel, label, swatch) .clicked() { - if state.active_tool == ActiveTool::Highlighter { - state.highlighter_color = color; - } else { - state.pen_color = color; + match state.active_tool { + ActiveTool::Highlighter => { + state.highlighter_color = color + } + ActiveTool::Text => { + state.text_color = color; + if let Some(edit) = &mut state.text_edit { + edit.color = color; + edit.focus = true; + } + } + _ => state.pen_color = color, } actions.push(UiAction::SetColor(color)); } @@ -1901,6 +2083,12 @@ pub fn render_ui( } } + // --- Schrift des Text-Werkzeugs --- + if state.active_tool == ActiveTool::Text { + sidebar_divider(ui, btn); + text_style_buttons(ui, btn, state); + } + sidebar_divider(ui, btn); // --- Undo & Redo --- @@ -3561,6 +3749,79 @@ mod tests { ctx } + #[test] + fn test_text_edit_overlay_takes_typing_and_escape() { + let notebook = Notebook::new(); + let mut state = UiState::new(); + state.active_tool = ActiveTool::Text; + state.page_view = Some(PageView { + origin: egui::pos2(300.0, 50.0), + scale: 0.3, + }); + state.text_edit = Some(TextEditing::new_at( + notebook.current_page().id, + glam::Vec2::new(200.0, 300.0), + 42.0, + TextFont::Sans, + [0.0, 0.0, 0.0, 1.0], + )); + let ctx = egui::Context::default(); + let updater = crate::updater::UpdaterController::new(); + let frame = |state: &mut UiState, events: Vec| { + ctx.begin_frame(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1280.0, 850.0), + )), + events, + ..Default::default() + }); + let _ = render_ui(&ctx, state, ¬ebook, AutosaveStatus::Saved, &updater); + let _ = ctx.end_frame(); + }; + frame(&mut state, Vec::new()); + assert!(ctx.wants_keyboard_input(), "Textfeld hat den Fokus"); + frame(&mut state, vec![egui::Event::Text("Hallo".into())]); + assert_eq!(state.text_edit.as_ref().unwrap().text.content, "Hallo"); + // Zeilenumbruch und Tab (Einrücken) bleiben im Textfeld + frame( + &mut state, + vec![egui::Event::Key { + key: egui::Key::Enter, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::default(), + }], + ); + frame( + &mut state, + vec![egui::Event::Key { + key: egui::Key::Tab, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::default(), + }], + ); + frame(&mut state, vec![egui::Event::Text("x".into())]); + assert_eq!(state.text_edit.as_ref().unwrap().text.content, "Hallo\n\tx"); + + frame( + &mut state, + vec![egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::default(), + }], + ); + let edit = state.text_edit.as_ref().unwrap(); + assert!(edit.finish); + assert_eq!(edit.text.content, "Hallo\n\tx"); + } + #[test] fn test_sidebar_is_vertical_and_docked_to_chosen_side() { let notebook = Notebook::new(); @@ -3687,6 +3948,7 @@ mod tests { ActiveTool::Shape, ActiveTool::Eraser, ActiveTool::Select, + ActiveTool::Text, ] { assert!( found.contains(&tool), @@ -3705,6 +3967,7 @@ mod tests { ActiveTool::Shape, ActiveTool::Eraser, ActiveTool::Select, + ActiveTool::Text, ] { let mut state = UiState::new(); state.active_tool = tool;