From 3e2b627d6adb9c03a43a04f69c8df76f857fcb7a Mon Sep 17 00:00:00 2001 From: Harald Date: Wed, 7 Oct 2026 15:04:59 +0200 Subject: [PATCH] feat: Textmarker-Radierer, Papierfarben/Dunkelmodus und Druckkurve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Radierer: Modus „Nur Textmarker“ (gilt auch für Stiftrückseite und Seitentaste) - Striche speichern jetzt ihr Werkzeug; bisher wurde jeder Strich als "pen" gespeichert. Ältere Textmarker werden an der Halbtransparenz erkannt. - Papierfarben Weiß/Creme/Dunkelgrau/Schwarz (nur Anzeige); auf dunklem Papier hellt der Shader dunkle Tinte auf (HSL-Helligkeit gespiegelt) - Einstellbare Druckkurve (weich/hart, Mindestdruck) mit Vorschau - Einstellungen in ui.json; ☰-Menü scrollt bei kleinen Fenstern - Test prüft den WGSL-Shader mit naga Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + Cargo.toml | 2 + src/background.rs | 231 ++++++++++++++++++++++++++++------ src/canvas.rs | 94 +++++++++++--- src/document.rs | 95 +++++++++++++- src/main.rs | 66 +++++++--- src/settings.rs | 152 ++++++++++++++++++++++- src/shader.wgsl | 15 +++ src/storage.rs | 36 +++++- src/ui.rs | 309 +++++++++++++++++++++++++++++++++++++++++++--- 10 files changed, 904 insertions(+), 97 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 70fc746..6e28bf2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1057,6 +1057,7 @@ dependencies = [ "fs2", "glam", "minisign-verify", + "naga", "octotablet", "pollster", "printpdf", diff --git a/Cargo.toml b/Cargo.toml index b018c11..f4f839f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,3 +53,5 @@ strip = true [dev-dependencies] tempfile = "3.27.0" +# Prüft den WGSL-Shader in den Tests (dieselbe Version wie in wgpu 0.19) +naga = { version = "0.19", features = ["wgsl-in"] } diff --git a/src/background.rs b/src/background.rs index ca215b7..bbdcb2f 100644 --- a/src/background.rs +++ b/src/background.rs @@ -1,5 +1,7 @@ //! Vektor-Generierung für DIN-A4-Seitenhintergründe (Kariert, Liniert, Punktraster, Blanko). +use serde::{Deserialize, Serialize}; + use crate::canvas::InkVertex; use crate::document::{PageBackground, PAGE_HEIGHT, PAGE_WIDTH}; @@ -13,43 +15,122 @@ pub const GRID_5MM_SPACING: f32 = 50.0; /// Zeilenabstand für liniertes Papier (7.5 mm = 75 Einheiten). pub const LINED_SPACING: f32 = 75.0; -/// Hellgraue Farbe für Hintergrundmuster (#E0E0E0). -pub const BACKGROUND_PATTERN_COLOR: [f32; 4] = [0.88, 0.88, 0.88, 1.0]; -/// Papierweiß für das DIN-A4-Blatt (#FFFFFF). -pub const PAPER_COLOR: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; -/// Schreibtisch-Hintergrundfarbe (Letterbox-Bereich, #28282D). -pub const DESK_COLOR: wgpu::Color = wgpu::Color { - r: 0.16, - g: 0.16, - b: 0.18, - a: 1.0, -}; +/// Papierfarbe der Anzeige. Betrifft nur die Darstellung am Bildschirm; gespeicherte +/// Strichfarben und der PDF-Export bleiben unverändert (weißes Papier). +/// +/// Alle Farben sind linear (die Oberfläche ist sRGB, die Umrechnung macht die GPU). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PaperTheme { + #[default] + White, + /// Cremefarben (augenschonend, wie Recyclingpapier) + Cream, + /// Dunkelgrau (Nachtmodus) + DarkGray, + /// Schwarz (OLED) + Black, +} -/// Erzeugt die 6 Vertizes (2 Dreiecke) für das weiße DIN-A4-Papierblatt. -pub fn generate_paper_quad() -> Vec { +impl PaperTheme { + pub const ALL: [PaperTheme; 4] = [ + PaperTheme::White, + PaperTheme::Cream, + PaperTheme::DarkGray, + PaperTheme::Black, + ]; + + pub fn label(self) -> &'static str { + match self { + PaperTheme::White => "Weiß", + PaperTheme::Cream => "Creme", + PaperTheme::DarkGray => "Dunkelgrau", + PaperTheme::Black => "Schwarz", + } + } + + /// Dunkles Papier: dunkle Tinte wird bei Bedarf aufgehellt. + pub fn is_dark(self) -> bool { + matches!(self, PaperTheme::DarkGray | PaperTheme::Black) + } + + /// Farbe des Blatts. + pub fn paper_color(self) -> [f32; 4] { + match self { + PaperTheme::White => [1.0, 1.0, 1.0, 1.0], + // sRGB #FBF5E6 + PaperTheme::Cream => [0.965, 0.913, 0.791, 1.0], + // sRGB #2B2B2E + PaperTheme::DarkGray => [0.024, 0.024, 0.027, 1.0], + PaperTheme::Black => [0.0, 0.0, 0.0, 1.0], + } + } + + /// Farbe von Karos, Linien und Punkten. + pub fn pattern_color(self) -> [f32; 4] { + match self { + PaperTheme::White => [0.88, 0.88, 0.88, 1.0], + PaperTheme::Cream => [0.78, 0.72, 0.60, 1.0], + // sRGB #48484D bzw. #38383C + PaperTheme::DarkGray => [0.064, 0.064, 0.074, 1.0], + PaperTheme::Black => [0.040, 0.040, 0.045, 1.0], + } + } + + /// Schreibtisch-Hintergrund um das Blatt; bei dunklem Papier noch dunkler, damit sich + /// das Blatt abhebt. + pub fn desk_color(self) -> wgpu::Color { + let (r, g, b) = match self { + PaperTheme::White | PaperTheme::Cream => (0.16, 0.16, 0.18), + PaperTheme::DarkGray => (0.004, 0.004, 0.005), + PaperTheme::Black => (0.03, 0.03, 0.035), + }; + wgpu::Color { r, g, b, a: 1.0 } + } +} + +/// Hellt dunkle Tinte für dunkles Papier auf: die HSL-Helligkeit L wird zu 1 − L gespiegelt, +/// Farbton und Sättigung bleiben erhalten. Farben mit L ≥ 0.5 (Rot, Blau, Textmarker) +/// bleiben unverändert. Muss mit `fs_ink` in `shader.wgsl` übereinstimmen. +pub fn adapt_ink_color(color: [f32; 4]) -> [f32; 4] { + let max = color[0].max(color[1]).max(color[2]); + let min = color[0].min(color[1]).min(color[2]); + let lift = (1.0 - max - min).max(0.0); + [color[0] + lift, color[1] + lift, color[2] + lift, color[3]] +} + +/// Erzeugt die 6 Vertizes (2 Dreiecke) für das DIN-A4-Papierblatt. +pub fn generate_paper_quad(color: [f32; 4]) -> Vec { vec![ - InkVertex::new(0.0, 0.0, PAPER_COLOR), - InkVertex::new(PAGE_WIDTH, 0.0, PAPER_COLOR), - InkVertex::new(0.0, PAGE_HEIGHT, PAPER_COLOR), - InkVertex::new(0.0, PAGE_HEIGHT, PAPER_COLOR), - InkVertex::new(PAGE_WIDTH, 0.0, PAPER_COLOR), - InkVertex::new(PAGE_WIDTH, PAGE_HEIGHT, PAPER_COLOR), + InkVertex::new(0.0, 0.0, color), + InkVertex::new(PAGE_WIDTH, 0.0, color), + InkVertex::new(0.0, PAGE_HEIGHT, color), + InkVertex::new(0.0, PAGE_HEIGHT, color), + InkVertex::new(PAGE_WIDTH, 0.0, color), + InkVertex::new(PAGE_WIDTH, PAGE_HEIGHT, color), ] } /// Fügt ein Quad (2 Dreiecke) zur Vertexliste hinzu. #[inline] -fn push_quad(vertices: &mut Vec, x_min: f32, y_min: f32, x_max: f32, y_max: f32) { - vertices.push(InkVertex::new(x_min, y_min, BACKGROUND_PATTERN_COLOR)); - vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR)); - vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR)); - vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR)); - vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR)); - vertices.push(InkVertex::new(x_max, y_max, BACKGROUND_PATTERN_COLOR)); +fn push_quad( + vertices: &mut Vec, + color: [f32; 4], + x_min: f32, + y_min: f32, + x_max: f32, + y_max: f32, +) { + vertices.push(InkVertex::new(x_min, y_min, color)); + vertices.push(InkVertex::new(x_max, y_min, color)); + vertices.push(InkVertex::new(x_min, y_max, color)); + vertices.push(InkVertex::new(x_min, y_max, color)); + vertices.push(InkVertex::new(x_max, y_min, color)); + vertices.push(InkVertex::new(x_max, y_max, color)); } -/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund. -pub fn generate_background_mesh(background: PageBackground) -> Vec { +/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund in `color`. +pub fn generate_background_mesh(background: PageBackground, color: [f32; 4]) -> Vec { match background { PageBackground::Blank => Vec::new(), PageBackground::Grid5mm => { @@ -59,14 +140,28 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec { // Vertikale Linien alle 5 mm let mut x = GRID_5MM_SPACING; while x < PAGE_WIDTH { - push_quad(&mut vertices, x - half_t, 0.0, x + half_t, PAGE_HEIGHT); + push_quad( + &mut vertices, + color, + x - half_t, + 0.0, + x + half_t, + PAGE_HEIGHT, + ); x += GRID_5MM_SPACING; } // Horizontale Linien alle 5 mm let mut y = GRID_5MM_SPACING; while y < PAGE_HEIGHT { - push_quad(&mut vertices, 0.0, y - half_t, PAGE_WIDTH, y + half_t); + push_quad( + &mut vertices, + color, + 0.0, + y - half_t, + PAGE_WIDTH, + y + half_t, + ); y += GRID_5MM_SPACING; } @@ -82,7 +177,14 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec { let mut y = margin_top; while y <= PAGE_HEIGHT - margin_bottom { - push_quad(&mut vertices, 0.0, y - half_t, PAGE_WIDTH, y + half_t); + push_quad( + &mut vertices, + color, + 0.0, + y - half_t, + PAGE_WIDTH, + y + half_t, + ); y += LINED_SPACING; } @@ -98,6 +200,7 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec { while x < PAGE_WIDTH { push_quad( &mut vertices, + color, x - half_d, y - half_d, x + half_d, @@ -119,18 +222,20 @@ mod tests { #[test] fn test_paper_quad_generation() { - let quad = generate_paper_quad(); + let quad = generate_paper_quad(PaperTheme::White.paper_color()); assert_eq!(quad.len(), 6); assert_eq!(quad[0].position, [0.0, 0.0]); assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]); } + const PATTERN: [f32; 4] = [0.5, 0.5, 0.5, 1.0]; + #[test] fn test_background_meshes() { - let blank = generate_background_mesh(PageBackground::Blank); + let blank = generate_background_mesh(PageBackground::Blank, PATTERN); assert!(blank.is_empty()); - let grid = generate_background_mesh(PageBackground::Grid5mm); + let grid = generate_background_mesh(PageBackground::Grid5mm, PATTERN); assert!(!grid.is_empty()); assert_eq!( grid.len() % 6, @@ -138,12 +243,66 @@ mod tests { "Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)" ); - let lined = generate_background_mesh(PageBackground::Lined); + let lined = generate_background_mesh(PageBackground::Lined, PATTERN); assert!(!lined.is_empty()); assert_eq!(lined.len() % 6, 0); - let dots = generate_background_mesh(PageBackground::DotGrid); + let dots = generate_background_mesh(PageBackground::DotGrid, PATTERN); assert!(!dots.is_empty()); assert_eq!(dots.len() % 6, 0); } + + #[test] + fn test_background_mesh_uses_theme_color() { + let dark = PaperTheme::Black.pattern_color(); + let mesh = generate_background_mesh(PageBackground::Lined, dark); + assert!(mesh.iter().all(|v| v.color == dark)); + let quad = generate_paper_quad(PaperTheme::Cream.paper_color()); + assert!(quad + .iter() + .all(|v| v.color == PaperTheme::Cream.paper_color())); + } + + #[test] + fn test_adapt_ink_color() { + let close = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5); + // Schwarz wird Weiß, Deckkraft bleibt + assert!(close( + adapt_ink_color([0.0, 0.0, 0.0, 0.7]), + [1.0, 1.0, 1.0, 0.7] + )); + // Notiz-Schwarz wird hell + let ink = adapt_ink_color([0.12, 0.12, 0.14, 1.0]); + assert!(ink[0] > 0.85 && ink[2] > 0.85); + // Dunkelblau wird Hellblau: Farbton bleibt (Blau bleibt der stärkste Kanal) + let blue = adapt_ink_color([0.0, 0.0, 0.5, 1.0]); + assert!(close(blue, [0.5, 0.5, 1.0, 1.0])); + // Helle und gesättigte Farben bleiben unverändert (Rot, Universitäts-Blau, Textmarker) + for c in [ + [0.85, 0.20, 0.20, 1.0], + [0.15, 0.35, 0.85, 1.0], + [1.0, 0.95, 0.1, 0.35], + [1.0, 1.0, 1.0, 1.0], + ] { + assert!(close(adapt_ink_color(c), c), "{c:?} wurde verändert"); + } + // Ergebnis ist nie heller als Weiß + for c in [[0.0, 0.3, 0.1, 1.0], [0.45, 0.45, 0.48, 1.0]] { + assert!(adapt_ink_color(c)[..3].iter().all(|&x| x <= 1.0 + 1e-6)); + } + } + + #[test] + fn test_paper_themes_contrast() { + for theme in PaperTheme::ALL { + let paper = theme.paper_color()[0]; + let pattern = theme.pattern_color()[0]; + assert!( + (paper - pattern).abs() > 0.03, + "{theme:?}: Muster hebt sich nicht vom Papier ab" + ); + // Helles Papier ↔ dunkles Papier + assert_eq!(theme.is_dark(), paper < 0.5, "{theme:?}"); + } + } } diff --git a/src/canvas.rs b/src/canvas.rs index 883db69..1e6d766 100644 --- a/src/canvas.rs +++ b/src/canvas.rs @@ -11,7 +11,7 @@ use std::sync::Arc; use winit::dpi::PhysicalSize; use winit::window::Window; -use crate::background::{generate_background_mesh, generate_paper_quad, DESK_COLOR}; +use crate::background::{generate_background_mesh, generate_paper_quad, PaperTheme}; use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample}; use crate::filter::OneEuroFilter; use crate::spline::{self, ControlPoint, SplineConfig}; @@ -58,6 +58,8 @@ impl InkVertex { pub struct CameraUniforms { pub transform_scale: [f32; 2], pub transform_offset: [f32; 2], + /// x: 1.0 = dunkle Tinte aufhellen (Shader `fs_ink`), y–w: ungenutzt (Ausrichtung) + pub ink_params: [f32; 4], } /// Transformiert einen Pixel-Koordinatenpunkt in den Normalized Device Coordinate (NDC) Raum [-1.0, 1.0]. @@ -135,7 +137,6 @@ pub struct CanvasState { pub camera_bind_group: wgpu::BindGroup, pub current_ink_color: [f32; 4], pub current_stroke_width_mm: f32, - pub current_tool: crate::document::StrokeTool, // Statische Hintergrund-Geometrie pub paper_buffer: wgpu::Buffer, @@ -143,6 +144,10 @@ pub struct CanvasState { pub background_vertex_capacity: usize, pub background_vertex_count: u32, pub current_background: PageBackground, + /// Papierfarbe der Anzeige + pub paper_theme: PaperTheme, + /// Dunkle Tinte auf dunklem Papier aufhellen + pub adapt_ink: bool, // Pipeline-Trennung: Live-Strich (aktiver Stroke) pub live_vertex_buffer: wgpu::Buffer, @@ -276,6 +281,7 @@ impl CanvasState { let camera_uniforms = CameraUniforms { transform_scale: scale, transform_offset: offset, + ink_params: [0.0; 4], }; let camera_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Camera Uniform Buffer"), @@ -312,7 +318,7 @@ impl CanvasState { }, fragment: Some(wgpu::FragmentState { module: &shader, - entry_point: "fs_main", + entry_point: "fs_ink", targets: &[Some(wgpu::ColorTargetState { format: config.format, blend: Some(wgpu::BlendState::ALPHA_BLENDING), @@ -366,7 +372,8 @@ impl CanvasState { }); // 6. Statische Buffer - let paper_quad = generate_paper_quad(); + let paper_theme = PaperTheme::default(); + let paper_quad = generate_paper_quad(paper_theme.paper_color()); let paper_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Paper Quad Buffer"), size: (paper_quad.len() * std::mem::size_of::()) as u64, @@ -376,7 +383,7 @@ impl CanvasState { queue.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper_quad)); let current_background = PageBackground::Grid5mm; - let bg_mesh = generate_background_mesh(current_background); + let bg_mesh = generate_background_mesh(current_background, paper_theme.pattern_color()); let background_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Background Mesh Buffer"), size: (Self::INITIAL_BACKGROUND_CAPACITY * std::mem::size_of::()) as u64, @@ -420,12 +427,13 @@ impl CanvasState { camera_bind_group, current_ink_color: default_ink_color, current_stroke_width_mm: 0.5, - current_tool: crate::document::StrokeTool::Pen, paper_buffer, background_buffer, background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY, background_vertex_count: bg_mesh.len() as u32, current_background, + paper_theme, + adapt_ink: false, live_vertex_buffer, live_vertex_capacity: Self::INITIAL_LIVE_CAPACITY, live_vertex_count: 0, @@ -453,6 +461,16 @@ impl CanvasState { let camera_uniforms = CameraUniforms { transform_scale: scale, transform_offset: offset, + ink_params: [ + if self.adapt_ink && self.paper_theme.is_dark() { + 1.0 + } else { + 0.0 + }, + 0.0, + 0.0, + 0.0, + ], }; self.queue.write_buffer( &self.camera_buffer, @@ -587,9 +605,19 @@ impl CanvasState { SplineConfig::from_width_units(width_mm * crate::document::UNITS_PER_MM); } - /// Setzt das aktive Zeichenwerkzeug für den Stift. - pub fn set_tool(&mut self, tool: crate::document::StrokeTool) { - self.current_tool = tool; + /// Setzt die Papierfarbe und ob dunkle Tinte auf dunklem Papier aufgehellt wird. + pub fn set_paper_theme(&mut self, theme: PaperTheme, adapt_ink: bool) { + self.adapt_ink = adapt_ink; + if self.paper_theme != theme { + self.paper_theme = theme; + self.queue.write_buffer( + &self.paper_buffer, + 0, + bytemuck::cast_slice(&generate_paper_quad(theme.paper_color())), + ); + self.upload_background_mesh(); + } + self.update_uniforms(); } /// Setzt den Seitenhintergrund und aktualisiert bei Bedarf den Vertex-Puffer. @@ -598,7 +626,13 @@ impl CanvasState { return; } self.current_background = background; - let mesh = generate_background_mesh(background); + self.upload_background_mesh(); + } + + /// Erzeugt das Muster für Hintergrund und Papierfarbe neu und lädt es auf die GPU. + fn upload_background_mesh(&mut self) { + let mesh = + generate_background_mesh(self.current_background, self.paper_theme.pattern_color()); self.background_vertex_count = mesh.len() as u32; if mesh.len() > self.background_vertex_capacity { @@ -728,7 +762,6 @@ impl CanvasState { &self.spline_config, ); let mut stroke = stroke; - stroke.tool = self.current_tool; stroke.width = self.current_stroke_width_mm * crate::document::UNITS_PER_MM; stroke.finalized = true; @@ -824,7 +857,7 @@ impl CanvasState { view: &view, resolve_target: None, ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(DESK_COLOR), // Dunkler Tischhintergrund + load: wgpu::LoadOp::Clear(self.paper_theme.desk_color()), // Tischhintergrund store: wgpu::StoreOp::Store, }, })], @@ -836,7 +869,7 @@ impl CanvasState { // 2D-Kamera Uniforms (Scale & Offset) binden render_pass.set_bind_group(0, &self.camera_bind_group, &[]); - // 1. Weißes DIN-A4-Blatt zeichnen (Farbe ist direkt im Paper-Quad Vertex hinterlegt) + // 1. DIN-A4-Blatt in Papierfarbe zeichnen (Farbe ist direkt im Paper-Quad Vertex hinterlegt) render_pass.set_pipeline(&self.triangle_pipeline); render_pass.set_vertex_buffer(0, self.paper_buffer.slice(..)); render_pass.draw(0..6, 0..1); @@ -948,7 +981,40 @@ mod tests { #[test] fn test_camera_uniforms() { let _u = CameraUniforms::default(); - assert_eq!(std::mem::size_of::(), 16); + // Muss dem WGSL-Layout entsprechen: vec2 + vec2 + vec4 + assert_eq!(std::mem::size_of::(), 32); + } + + #[test] + fn test_shader_is_valid_and_matches_uniforms() { + // Ein Shaderfehler fiele sonst erst beim Programmstart auf + let module = naga::front::wgsl::parse_str(include_str!("shader.wgsl")) + .unwrap_or_else(|e| panic!("WGSL-Fehler: {e}")); + naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::empty(), + ) + .validate(&module) + .unwrap_or_else(|e| panic!("Shader ungültig: {e:?}")); + + for name in ["vs_main", "fs_main", "fs_ink"] { + assert!( + module.entry_points.iter().any(|ep| ep.name == name), + "Einstiegspunkt {name} fehlt" + ); + } + let camera = module + .types + .iter() + .find(|(_, ty)| ty.name.as_deref() == Some("CameraUniforms")) + .expect("CameraUniforms fehlt im Shader") + .1; + match camera.inner { + naga::TypeInner::Struct { span, .. } => { + assert_eq!(span as usize, std::mem::size_of::()) + } + ref other => panic!("CameraUniforms ist kein Struct: {other:?}"), + } } #[test] diff --git a/src/document.rs b/src/document.rs index d75972b..548dd3e 100644 --- a/src/document.rs +++ b/src/document.rs @@ -113,6 +113,39 @@ pub enum StrokeTool { Shape, } +impl StrokeTool { + /// Werkzeug eines gespeicherten Strichs. Bis v0.3.7 wurde das Werkzeug nicht erfasst und + /// jeder Strich als `pen` gespeichert; Textmarker erkennt man dann an der + /// Halbtransparenz (der Stift ist immer deckend), Formen an der erkannten Form. + pub fn resolve_stored(stored: StrokeTool, color: [f32; 4], has_shape: bool) -> StrokeTool { + match stored { + StrokeTool::Pen if has_shape => StrokeTool::Shape, + StrokeTool::Pen if color[3] < 0.99 => StrokeTool::Highlighter, + other => other, + } + } +} + +/// Welche Striche der Radierer entfernt. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum EraserMode { + /// Alle getroffenen Striche + #[default] + All, + /// Nur Textmarker – die Handschrift darunter bleibt erhalten + HighlighterOnly, +} + +impl EraserMode { + /// Darf ein Strich dieses Werkzeugs radiert werden? + pub fn erases(self, tool: StrokeTool) -> bool { + match self { + EraserMode::All => true, + EraserMode::HighlighterOnly => tool == StrokeTool::Highlighter, + } + } +} + /// Ein einzelner Vektor-Strich auf einer Seite. #[derive(Debug, Clone, PartialEq)] pub struct Stroke { @@ -469,7 +502,8 @@ impl Page { } } - /// Radiert alle Striche an der Position `eraser_pos` mit Radius `eraser_radius`. + /// Radiert alle Striche an der Position `eraser_pos` mit Radius `eraser_radius`, + /// die `mode` zulässt. /// /// Gibt die gelöschten Striche zurück (falls welche getroffen wurden). pub fn erase_at( @@ -477,11 +511,14 @@ impl Page { eraser_pos: glam::Vec2, eraser_radius: f32, max_stroke_radius: f32, + mode: EraserMode, ) -> Vec { let mut hit_indices = Vec::new(); for (i, stroke) in self.strokes.iter().enumerate() { - if stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius) { + if mode.erases(stroke.tool) + && stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius) + { hit_indices.push(i); } } @@ -1040,7 +1077,7 @@ mod tests { assert_eq!(page.strokes.len(), 2); // Radieren bei (30, 12): Trifft nur s1 - let removed = page.erase_at(glam::Vec2::new(30.0, 12.0), 10.0, 2.0); + let removed = page.erase_at(glam::Vec2::new(30.0, 12.0), 10.0, 2.0, EraserMode::All); assert_eq!(removed.len(), 1); assert_eq!(removed[0].id, 1); assert_eq!(page.strokes.len(), 1); @@ -1056,6 +1093,58 @@ mod tests { assert_eq!(page.strokes[0].id, 2); } + #[test] + fn test_erase_highlighter_only_keeps_handwriting() { + let mut page = Page::new(1, PageBackground::Grid5mm); + let line = |id: u64, tool: StrokeTool| { + let mut stroke = Stroke::new( + id, + vec![ + StylusSample { + position: glam::Vec2::new(10.0, 10.0), + pressure: 0.5, + timestamp: 0.0, + }, + StylusSample { + position: glam::Vec2::new(50.0, 10.0), + pressure: 0.5, + timestamp: 0.01, + }, + ], + vec![ + InkVertex::new(10.0, 10.0, [0.0, 0.0, 0.0, 1.0]), + InkVertex::new(50.0, 10.0, [0.0, 0.0, 0.0, 1.0]), + ], + [0.0, 0.0, 0.0, 1.0], + ); + stroke.tool = tool; + stroke + }; + // Handschrift, Form und Textmarker liegen übereinander + page.add_stroke(line(1, StrokeTool::Pen)); + page.add_stroke(line(2, StrokeTool::Highlighter)); + page.add_stroke(line(3, StrokeTool::Shape)); + + let at = glam::Vec2::new(30.0, 10.0); + let removed = page.erase_at(at, 10.0, 2.0, EraserMode::HighlighterOnly); + assert_eq!(removed.iter().map(|s| s.id).collect::>(), vec![2]); + assert_eq!( + page.strokes.iter().map(|s| s.id).collect::>(), + vec![1, 3] + ); + // Kein Textmarker mehr da: nichts wird entfernt, kein Undo-Eintrag + assert!(page + .erase_at(at, 10.0, 2.0, EraserMode::HighlighterOnly) + .is_empty()); + assert!(page.undo()); + assert_eq!(page.strokes.len(), 3); + + // Normaler Radierer entfernt alles + let removed = page.erase_at(at, 10.0, 2.0, EraserMode::All); + assert_eq!(removed.len(), 3); + assert!(page.strokes.is_empty()); + } + #[test] fn test_page_isolation() { let mut nb = Notebook::new(); diff --git a/src/main.rs b/src/main.rs index 802dc8e..3a8bdf9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,19 @@ use ink_notes::ui::{self, ActiveTool, UiAction, UiState}; use ink_notes::updater::UpdaterController; /// Aktualisiert den Fenstertitel als minimales On-Screen-Display (OSD) inklusive Autosave-Status. +/// Speichert die UI-Einstellungen; ein Fehler wird gemeldet, bricht aber nichts ab. +fn save_ui_settings(settings: &UiSettings, path: Option<&std::path::Path>, ui_state: &mut UiState) { + if let Some(path) = path { + if let Err(err) = settings.save_to(path) { + eprintln!( + "[Einstellungen] Konnte '{}' nicht speichern: {err}", + path.display() + ); + ui_state.set_export_message(format!("Einstellung nicht gespeichert: {err}")); + } + } +} + fn update_window_title( window: &Window, notebook: &Notebook, @@ -121,7 +134,8 @@ fn commit_stroke_to_notebook( canvas.finish_stroke(new_id) }; - if let Some(stroke) = stroke_opt { + if let Some(mut stroke) = stroke_opt { + stroke.tool = ui_state.active_tool.stroke_tool(stroke.shape.is_some()); let page = notebook.current_page_mut(); page.add_stroke(stroke); canvas.sync_page(page); @@ -259,6 +273,9 @@ fn main() -> Result<(), Box> { .map(UiSettings::load_from) .unwrap_or_default(); ui_state.sidebar_side = ui_settings.sidebar_side; + ui_state.paper_theme = ui_settings.paper_theme; + ui_state.adapt_ink_colors = ui_settings.adapt_ink_colors; + ui_state.pressure_curve = ui_settings.pressure_curve; ui_state.current_notebook_id = manifest.id.clone(); ui_state.document_title = manifest.title.clone(); ui_state.available_notebooks = available_notebooks; @@ -290,6 +307,7 @@ fn main() -> Result<(), Box> { 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); @@ -471,7 +489,8 @@ fn main() -> Result<(), Box> { cursor_screen_pos = (sx, sy); if let Some(p) = pose.pressure.get() { - current_pressure = p; + current_pressure = ui_state.pressure_curve.apply(p); + ui_state.last_raw_pressure = Some(p); } // Stiftbewegung an egui weitergeben (Hover, Schieberegler ziehen), @@ -498,8 +517,7 @@ fn main() -> Result<(), Box> { let removed = page.erase_at( page_pt, eraser_radius, - stroke_max_radius, - ); + stroke_max_radius, ui_state.eraser_mode); if !removed.is_empty() { canvas.sync_page(page); autosave.mark_dirty(¬ebook, &mut manifest); @@ -566,8 +584,7 @@ fn main() -> Result<(), Box> { let removed = page.erase_at( page_pt, eraser_radius, - stroke_max_radius, - ); + stroke_max_radius, ui_state.eraser_mode); if !removed.is_empty() { canvas.sync_page(page); autosave.mark_dirty(¬ebook, &mut manifest); @@ -1187,7 +1204,7 @@ fn main() -> Result<(), Box> { let (sx, sy) = cursor_screen_pos; if let Some(page_pt) = canvas.screen_to_page(sx, sy) { let page = notebook.current_page_mut(); - let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius); + let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode); if !removed.is_empty() { canvas.sync_page(page); autosave.mark_dirty(¬ebook, &mut manifest); @@ -1455,7 +1472,7 @@ fn main() -> Result<(), Box> { } else if is_erasing { if let Some(page_pt) = canvas.screen_to_page(sx, sy) { let page = notebook.current_page_mut(); - let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius); + let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode); if !removed.is_empty() { canvas.sync_page(page); autosave.mark_dirty(¬ebook, &mut manifest); @@ -1516,15 +1533,18 @@ fn main() -> Result<(), Box> { let now = start_time.elapsed().as_secs_f64(); let tx = touch.location.x as f32; let ty = touch.location.y as f32; - let pressure = match touch.force { - Some(winit::event::Force::Normalized(f)) => f as f32, + let raw_pressure = match touch.force { + Some(winit::event::Force::Normalized(f)) => Some(f as f32), Some(winit::event::Force::Calibrated { force, max_possible_force, .. - }) => (force / max_possible_force).clamp(0.0, 1.0) as f32, - None => 0.5, + }) => Some((force / max_possible_force).clamp(0.0, 1.0) as f32), + None => None, }; + // Druckkurve nur bei echtem Druck; Finger ohne Druck zeichnen mittelstark + let pressure = + raw_pressure.map_or(0.5, |p| ui_state.pressure_curve.apply(p)); let is_eraser_action = ui_state.active_tool == ActiveTool::Eraser; @@ -1591,7 +1611,7 @@ fn main() -> Result<(), Box> { is_erasing = true; if let Some(page_pt) = canvas.screen_to_page(tx, ty) { let page = notebook.current_page_mut(); - let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius); + let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode); if !removed.is_empty() { canvas.sync_page(page); autosave.mark_dirty(¬ebook, &mut manifest); @@ -1732,7 +1752,7 @@ fn main() -> Result<(), Box> { } else if is_erasing { if let Some(page_pt) = canvas.screen_to_page(tx, ty) { let page = notebook.current_page_mut(); - let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius); + let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode); if !removed.is_empty() { canvas.sync_page(page); autosave.mark_dirty(¬ebook, &mut manifest); @@ -2410,14 +2430,20 @@ fn main() -> Result<(), Box> { } UiAction::SetSidebarSide(side) => { ui_settings.sidebar_side = side; - if let Some(path) = ui_settings_path.as_deref() { - if let Err(err) = ui_settings.save_to(path) { - eprintln!("[Einstellungen] Konnte '{}' nicht speichern: {err}", path.display()); - ui_state.set_export_message(format!("Einstellung nicht gespeichert: {err}")); - } - } + save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); window.request_redraw(); } + UiAction::SetPaperTheme { theme, adapt_ink } => { + canvas.set_paper_theme(theme, adapt_ink); + 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); + window.request_redraw(); + } + UiAction::SavePressureCurve(curve) => { + ui_settings.pressure_curve = curve; + save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); + } UiAction::ForceExit => { println!("[Shutdown] Beenden erzwungen trotz ungespeicherter Änderungen."); target.exit(); diff --git a/src/settings.rs b/src/settings.rs index 9202a33..4ba5884 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -6,6 +6,7 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; +use crate::background::PaperTheme; use crate::storage::{self, StorageError}; /// Fensterseite, an der die Werkzeugleiste angedockt ist. @@ -18,11 +19,82 @@ pub enum SidebarSide { Right, } +/// Druckkennlinie des Stifts: passt die sehr unterschiedlichen Druckbereiche verschiedener +/// Digitizer (Wacom EMR, MPP, …) an das Schreibgefühl an. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct PressureCurve { + /// Exponent der Kennlinie: < 1 weich (schon leichter Druck ergibt kräftige Linien), + /// > 1 hart (erst fester Druck ergibt kräftige Linien) + pub exponent: f32, + /// Rohdruck unterhalb dieser Schwelle zählt als kein Druck (dünnste Linie); + /// unterdrückt das Grundrauschen empfindlicher Stifte + pub threshold: f32, +} + +impl PressureCurve { + pub const EXPONENT_RANGE: std::ops::RangeInclusive = 0.3..=3.0; + pub const THRESHOLD_RANGE: std::ops::RangeInclusive = 0.0..=0.5; + + /// Rechnet den Rohdruck des Stifts (0–1) in den Druck für die Strichbreite um. + pub fn apply(&self, raw: f32) -> f32 { + let curve = self.sanitized(); + if !raw.is_finite() { + return 0.0; + } + let normalized = ((raw - curve.threshold) / (1.0 - curve.threshold)).clamp(0.0, 1.0); + normalized.powf(curve.exponent) + } + + /// Begrenzt Werte aus einer (evtl. von Hand bearbeiteten) Einstellungsdatei auf den + /// gültigen Bereich. + pub fn sanitized(&self) -> Self { + let default = Self::default(); + let clamp = |v: f32, range: &std::ops::RangeInclusive, fallback: f32| { + if v.is_finite() { + v.clamp(*range.start(), *range.end()) + } else { + fallback + } + }; + Self { + exponent: clamp(self.exponent, &Self::EXPONENT_RANGE, default.exponent), + threshold: clamp(self.threshold, &Self::THRESHOLD_RANGE, default.threshold), + } + } +} + +impl Default for PressureCurve { + fn default() -> Self { + Self { + exponent: 1.0, + threshold: 0.0, + } + } +} + /// Benutzereinstellungen der Oberfläche. -#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct UiSettings { pub sidebar_side: SidebarSide, + /// Papierfarbe der Anzeige + pub paper_theme: PaperTheme, + /// Dunkle Stiftfarben auf dunklem Papier aufhellen + pub adapt_ink_colors: bool, + /// Druckkennlinie des Stifts + pub pressure_curve: PressureCurve, +} + +impl Default for UiSettings { + fn default() -> Self { + Self { + sidebar_side: SidebarSide::default(), + paper_theme: PaperTheme::default(), + adapt_ink_colors: true, + pressure_curve: PressureCurve::default(), + } + } } impl UiSettings { @@ -37,7 +109,7 @@ impl UiSettings { /// Lädt die Einstellungen; bei fehlender oder beschädigter Datei gelten Standardwerte. pub fn load_from(path: &Path) -> Self { - match std::fs::read_to_string(path) { + let mut settings: Self = match std::fs::read_to_string(path) { Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| { eprintln!( "[Einstellungen] '{}' ist ungültig ({e}), verwende Standardwerte.", @@ -46,7 +118,9 @@ impl UiSettings { Self::default() }), Err(_) => Self::default(), - } + }; + settings.pressure_curve = settings.pressure_curve.sanitized(); + settings } /// Speichert die Einstellungen atomar. @@ -69,6 +143,12 @@ mod tests { let settings = UiSettings { sidebar_side: SidebarSide::Right, + paper_theme: PaperTheme::Black, + adapt_ink_colors: false, + pressure_curve: PressureCurve { + exponent: 1.8, + threshold: 0.1, + }, }; settings.save_to(&path).unwrap(); assert_eq!(UiSettings::load_from(&path), settings); @@ -81,4 +161,70 @@ mod tests { std::fs::write(&path, br#"{"zukunft": 1}"#).unwrap(); assert_eq!(UiSettings::load_from(&path), UiSettings::default()); } + + #[test] + fn test_old_settings_file_keeps_new_defaults() { + // ui.json aus v0.3.x kennt nur die Seite der Werkzeugleiste + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ui.json"); + std::fs::write(&path, br#"{"sidebar_side": "Right"}"#).unwrap(); + let settings = UiSettings::load_from(&path); + assert_eq!(settings.sidebar_side, SidebarSide::Right); + assert_eq!(settings.paper_theme, PaperTheme::White); + assert!(settings.adapt_ink_colors); + assert_eq!(settings.pressure_curve, PressureCurve::default()); + + // Unsinnige Werte aus einer von Hand bearbeiteten Datei werden begrenzt + std::fs::write( + &path, + br#"{"pressure_curve": {"exponent": 50.0, "threshold": -3.0}}"#, + ) + .unwrap(); + let curve = UiSettings::load_from(&path).pressure_curve; + assert_eq!(curve.exponent, 3.0); + assert_eq!(curve.threshold, 0.0); + } + + #[test] + fn test_pressure_curve() { + // Standard: unverändert + let linear = PressureCurve::default(); + for p in [0.0, 0.25, 0.5, 1.0] { + assert!((linear.apply(p) - p).abs() < 1e-6); + } + // Weich: halber Druck ergibt mehr als halbe Breite, hart: weniger + let soft = PressureCurve { + exponent: 0.5, + threshold: 0.0, + }; + let hard = PressureCurve { + exponent: 2.0, + threshold: 0.0, + }; + assert!(soft.apply(0.5) > 0.6); + assert!(hard.apply(0.5) < 0.3); + // Endpunkte bleiben fest + for curve in [soft, hard] { + assert_eq!(curve.apply(0.0), 0.0); + assert!((curve.apply(1.0) - 1.0).abs() < 1e-6); + } + // Schwelle: darunter kein Druck, darüber auf den vollen Bereich gestreckt + let gated = PressureCurve { + exponent: 1.0, + threshold: 0.2, + }; + assert_eq!(gated.apply(0.15), 0.0); + assert!((gated.apply(0.6) - 0.5).abs() < 1e-6); + assert!((gated.apply(1.0) - 1.0).abs() < 1e-6); + // Monoton steigend + let mut last = -1.0; + for i in 0..=100 { + let v = hard.apply(i as f32 / 100.0); + assert!(v >= last); + last = v; + } + // Ungültige Eingaben ergeben nie NaN + assert_eq!(linear.apply(f32::NAN), 0.0); + assert_eq!(linear.apply(7.0), 1.0); + } } diff --git a/src/shader.wgsl b/src/shader.wgsl index c1ddd8f..5274d02 100644 --- a/src/shader.wgsl +++ b/src/shader.wgsl @@ -14,6 +14,8 @@ struct VertexOutput { struct CameraUniforms { transform_scale: vec2, transform_offset: vec2, + // x: 1.0 = dunkle Tinte für dunkles Papier aufhellen (siehe `adapt_ink_color`) + ink_params: vec4, }; @group(0) @binding(0) @@ -32,3 +34,16 @@ fn vs_main(in: VertexInput) -> VertexOutput { fn fs_main(in: VertexOutput) -> @location(0) vec4 { return in.color; } + +// Striche: bei dunklem Papier wird die HSL-Helligkeit dunkler Farben gespiegelt +// (L -> 1 - L, Farbton und Sättigung bleiben). Entspricht `background::adapt_ink_color`. +@fragment +fn fs_ink(in: VertexOutput) -> @location(0) vec4 { + if (camera.ink_params.x < 0.5) { + return in.color; + } + let max_c = max(max(in.color.r, in.color.g), in.color.b); + let min_c = min(min(in.color.r, in.color.g), in.color.b); + let lift = max(1.0 - max_c - min_c, 0.0); + return vec4(in.color.rgb + vec3(lift), in.color.a); +} diff --git a/src/storage.rs b/src/storage.rs index de32ed1..661aab1 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -117,7 +117,11 @@ impl StrokeData { }; let mut stroke = Stroke::new(self.id, stylus_samples, vertices, self.color); stroke.shape = self.shape.clone(); - stroke.tool = self.tool; + stroke.tool = crate::document::StrokeTool::resolve_stored( + self.tool, + self.color, + self.shape.is_some(), + ); stroke.width = width; stroke.finalized = self.finalized; stroke @@ -2282,4 +2286,34 @@ mod tests { .collect(); assert_eq!(loaded, shapes.iter().cloned().map(Some).collect::>()); } + + #[test] + fn test_stroke_tool_of_old_files_is_recovered() { + // Bis v0.3.7 wurde jeder Strich als "pen" gespeichert + let json = r#"{"id": 1, "color": [1.0, 0.95, 0.1, 0.35], "tool": "pen", + "samples": [{"x": 10.0, "y": 10.0, "p": 0.5, "t": 0.0}, + {"x": 90.0, "y": 10.0, "p": 0.5, "t": 0.1}]}"#; + let data: StrokeData = serde_json::from_str(json).unwrap(); + let highlighter = data.to_stroke(); + assert_eq!(highlighter.tool, crate::document::StrokeTool::Highlighter); + + let mut pen = data.clone(); + pen.color = [0.12, 0.12, 0.14, 1.0]; + assert_eq!(pen.to_stroke().tool, crate::document::StrokeTool::Pen); + + let mut shape = pen.clone(); + shape.shape = Some(crate::geometry::RecognizedShape::Line { + start: glam::Vec2::new(10.0, 10.0), + end: glam::Vec2::new(90.0, 10.0), + }); + assert_eq!(shape.to_stroke().tool, crate::document::StrokeTool::Shape); + + // Ausdrücklich gespeicherte Werkzeuge bleiben unverändert + let mut explicit = data.clone(); + explicit.tool = crate::document::StrokeTool::Shape; + assert_eq!( + explicit.to_stroke().tool, + crate::document::StrokeTool::Shape + ); + } } diff --git a/src/ui.rs b/src/ui.rs index dc6a035..937cf25 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -4,16 +4,19 @@ //! (links oder rechts, umschaltbar): //! - Symbol-Knöpfe mit 40 px Kantenlänge (stift- und fingertauglich), Tooltips mit Kürzeln //! - Werkzeuge, Farben des aktiven Werkzeugs, Auswahl-Aktionen, Undo/Redo, Seitennavigation -//! - Selten genutzte Funktionen (PDF, Hintergrund, Zoom, Touch-Modus, Leistenseite, -//! Updates, Speicherstatus) im ☰-Menü +//! - Selten genutzte Funktionen (PDF, Hintergrund, Papierfarbe, Zoom, Touch-Modus, +//! Druckkurve, Leistenseite, Updates, Speicherstatus) im ☰-Menü //! - Popover (Stift/Marker/Menü) öffnen sich zur Blattseite hin //! - Hit-Testing für Event-Transparenz außerhalb der Leiste und der Popover use std::time::Instant; use crate::autosave::AutosaveStatus; -use crate::document::{Notebook, Page, PageBackground, StrokeTool, PAGE_HEIGHT, PAGE_WIDTH}; -use crate::settings::SidebarSide; +use crate::background::{adapt_ink_color, PaperTheme}; +use crate::document::{ + EraserMode, Notebook, Page, PageBackground, StrokeTool, PAGE_HEIGHT, PAGE_WIDTH, +}; +use crate::settings::{PressureCurve, SidebarSide}; use crate::storage::NotebookSummary; /// Aktives Werkzeug für das Zeichnen oder Bearbeiten. @@ -40,6 +43,15 @@ impl ActiveTool { pub fn is_drawing(&self) -> bool { matches!(self, ActiveTool::Pen | ActiveTool::Highlighter) } + + /// Werkzeug, das ein mit diesem Werkzeug gezeichneter Strich erhält. + pub fn stroke_tool(&self, has_shape: bool) -> StrokeTool { + match self { + _ if has_shape => StrokeTool::Shape, + ActiveTool::Highlighter => StrokeTool::Highlighter, + _ => StrokeTool::Pen, + } + } } /// Farbvorgabe: RGBA-Farbe und Bezeichnung. @@ -132,6 +144,13 @@ pub enum UiAction { UnlockCorruptedPage(u64), /// Werkzeugleiste an die andere Fensterseite verlegen (wird gespeichert) SetSidebarSide(SidebarSide), + /// Papierfarbe der Anzeige und Aufhellen dunkler Tinte (wird gespeichert) + SetPaperTheme { + theme: PaperTheme, + adapt_ink: bool, + }, + /// Druckkurve speichern (gilt während des Ziehens bereits über `UiState::pressure_curve`) + SavePressureCurve(PressureCurve), // Beenden-Sicherheit bei Fehlschlag RetryShutdown, ExportRecoveryAndExit, @@ -183,6 +202,16 @@ pub struct UiState { pub sidebar_side: SidebarSide, /// Öffnungszustand des ☰-Menüs pub is_more_menu_open: bool, + /// Welche Striche der Radierer entfernt (gilt auch für Stiftrückseite und Seitentaste) + pub eraser_mode: EraserMode, + /// Papierfarbe der Anzeige + pub paper_theme: PaperTheme, + /// Dunkle Tinte auf dunklem Papier aufhellen + pub adapt_ink_colors: bool, + /// Druckkurve des Stifts + pub pressure_curve: PressureCurve, + /// Zuletzt gemessener Rohdruck des Stifts (Anzeige in der Druckkurve) + pub last_raw_pressure: Option, } impl Default for UiState { @@ -218,6 +247,11 @@ impl Default for UiState { shutdown_error: None, sidebar_side: SidebarSide::Left, is_more_menu_open: false, + eraser_mode: EraserMode::All, + paper_theme: PaperTheme::White, + adapt_ink_colors: true, + pressure_curve: PressureCurve::default(), + last_raw_pressure: None, } } } @@ -706,14 +740,21 @@ const PAGE_TILE: egui::Vec2 = egui::vec2(66.0, 66.0 * PAGE_HEIGHT / PAGE_WIDTH + /// Höchstzahl gezeichneter Punkte pro Vorschau (dünnt volle Seiten aus). const THUMBNAIL_POINT_BUDGET: usize = 1500; -/// Zeichenbefehle für die verkleinerte Vorschau einer Seite in `rect` (A4-Format). -/// Striche werden als Linien gezeichnet und bei vielen Punkten gleichmäßig ausgedünnt. -pub fn page_thumbnail_shapes(page: &Page, rect: egui::Rect) -> Vec { +/// Zeichenbefehle für die verkleinerte Vorschau einer Seite in `rect` (A4-Format) in der +/// Papierfarbe `paper`. Striche werden als Linien gezeichnet und bei vielen Punkten +/// gleichmäßig ausgedünnt; `adapt_ink` hellt dunkle Tinte wie auf dem Blatt auf. +pub fn page_thumbnail_shapes( + page: &Page, + rect: egui::Rect, + paper: PaperTheme, + adapt_ink: bool, +) -> Vec { let mut shapes = vec![egui::Shape::rect_filled( rect, egui::Rounding::same(3.0), - egui::Color32::WHITE, + linear_color32(paper.paper_color()), )]; + let adapt = adapt_ink && paper.is_dark(); let scale = rect.width() / PAGE_WIDTH; let to_screen = |p: glam::Vec2| rect.min + egui::vec2(p.x * scale, p.y * scale); @@ -721,7 +762,12 @@ pub fn page_thumbnail_shapes(page: &Page, rect: egui::Rect) -> Vec let step = total.div_ceil(THUMBNAIL_POINT_BUDGET).max(1); for stroke in &page.strokes { - let mut color = color32_from(stroke.color); + let ink = if adapt { + adapt_ink_color(stroke.color) + } else { + stroke.color + }; + let mut color = color32_from(ink); if stroke.tool == StrokeTool::Highlighter { color = color.gamma_multiply(0.45); } @@ -743,9 +789,15 @@ pub fn page_thumbnail_shapes(page: &Page, rect: egui::Rect) -> Vec shapes } +/// 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() +} + /// Kachel der Seitenauswahl: Vorschau der Seite mit Seitenzahl darunter. fn page_thumbnail( ui: &mut egui::Ui, + state: &UiState, page: &Page, number: usize, is_current: bool, @@ -757,7 +809,12 @@ fn page_thumbnail( egui::vec2(PAGE_TILE.x, PAGE_TILE.x * PAGE_HEIGHT / PAGE_WIDTH), ); let painter = ui.painter(); - painter.extend(page_thumbnail_shapes(page, thumb)); + painter.extend(page_thumbnail_shapes( + page, + thumb, + state.paper_theme, + state.adapt_ink_colors, + )); if !page.is_loaded { painter.text( thumb.center(), @@ -790,6 +847,112 @@ fn page_thumbnail( response.on_hover_text(format!("Seite {number}")) } +/// Auswahlknopf für eine Papierfarbe: Farbmuster und Name. +fn paper_theme_button(ui: &mut egui::Ui, theme: PaperTheme, selected: bool) -> egui::Response { + let response = ui.add_sized( + [115.0, 28.0], + egui::SelectableLabel::new(selected, format!(" {}", theme.label())), + ); + let swatch = egui::Rect::from_center_size( + egui::pos2(response.rect.min.x + 14.0, response.rect.center().y), + egui::vec2(14.0, 14.0), + ); + ui.painter().rect_filled( + swatch, + egui::Rounding::same(3.0), + linear_color32(theme.paper_color()), + ); + ui.painter().rect_stroke( + swatch, + egui::Rounding::same(3.0), + egui::Stroke::new(1.0_f32, egui::Color32::from_gray(120)), + ); + response +} + +/// Schieberegler für die Druckkurve mit Vorschau der Kennlinie. Änderungen gelten sofort +/// (`state.pressure_curve`); zurückgegeben wird die Kurve, sobald sie gespeichert werden soll +/// (Regler losgelassen oder per Tastatur geändert). +fn pressure_curve_editor(ui: &mut egui::Ui, state: &mut UiState) -> Option { + let mut save = false; + let mut track = |response: egui::Response| { + if response.drag_stopped() || (response.changed() && !response.dragged()) { + save = true; + } + }; + + ui.add_space(4.0); + ui.label("Druckkurve des Stifts"); + let (graph, _) = + ui.allocate_exact_size(egui::vec2(ui.available_width(), 56.0), egui::Sense::hover()); + let painter = ui.painter_at(graph); + painter.rect_filled( + graph, + egui::Rounding::same(4.0), + egui::Color32::from_black_alpha(90), + ); + let to_screen = |raw: f32, out: f32| { + egui::pos2( + graph.min.x + 6.0 + raw * (graph.width() - 12.0), + graph.max.y - 6.0 - out * (graph.height() - 12.0), + ) + }; + painter.line_segment( + [to_screen(0.0, 0.0), to_screen(1.0, 1.0)], + egui::Stroke::new(1.0_f32, egui::Color32::from_white_alpha(40)), + ); + let curve = state.pressure_curve; + let points: Vec = (0..=48) + .map(|i| { + let raw = i as f32 / 48.0; + to_screen(raw, curve.apply(raw)) + }) + .collect(); + painter.add(egui::Shape::line( + points, + egui::Stroke::new(2.0_f32, egui::Color32::from_rgb(110, 165, 255)), + )); + if let Some(raw) = state.last_raw_pressure { + let raw = raw.clamp(0.0, 1.0); + painter.circle_filled( + to_screen(raw, curve.apply(raw)), + 4.0, + egui::Color32::from_rgb(255, 200, 80), + ); + } + + ui.horizontal(|ui| { + ui.label("weich"); + ui.spacing_mut().slider_width = (ui.available_width() - 40.0).max(60.0); + track(ui.add( + egui::Slider::new(&mut state.pressure_curve.exponent, PressureCurve::EXPONENT_RANGE) + .logarithmic(true) + .show_value(false), + ).on_hover_text("Weich: schon leichter Druck ergibt kräftige Linien.\nHart: erst fester Druck ergibt kräftige Linien.")); + ui.label("hart"); + }); + ui.horizontal(|ui| { + ui.label("Mindestdruck"); + ui.spacing_mut().slider_width = (ui.available_width() - 60.0).max(60.0); + track(ui.add( + egui::Slider::new(&mut state.pressure_curve.threshold, PressureCurve::THRESHOLD_RANGE) + .custom_formatter(|v, _| format!("{:.0} %", v * 100.0)) + .custom_parser(|s| s.trim().trim_end_matches('%').trim().parse::().ok().map(|v| v / 100.0)), + ).on_hover_text("Leichterer Druck zählt als kein Druck (dünnste Linie).\nHilft bei Stiften, die schon beim Schweben Druck melden.")); + }); + if ui + .add_enabled( + state.pressure_curve != PressureCurve::default(), + egui::Button::new("Druckkurve zurücksetzen"), + ) + .clicked() + { + state.pressure_curve = PressureCurve::default(); + save = true; + } + save.then_some(state.pressure_curve) +} + /// Rahmen der Popover neben der Werkzeugleiste. fn popover_frame() -> egui::Frame { egui::Frame::none() @@ -1085,7 +1248,7 @@ pub fn render_ui( ( ActiveTool::Eraser, SidebarIcon::Eraser, - "Radierer (E oder rechte Maustaste)", + "Radierer (E oder rechte Maustaste) – darunter: alles oder nur Textmarker", ), ( ActiveTool::Select, @@ -1146,10 +1309,44 @@ pub fn render_ui( } } } - let missing = UiState::MAX_PALETTE_LEN.saturating_sub(shown); - if missing > 0 { - let spacing = ui.spacing().item_spacing.y; - ui.add_space(missing as f32 * (swatch + spacing)); + let spacing = ui.spacing().item_spacing.y; + let mut used = shown as f32 * (swatch + spacing); + // Radierer: statt Farben die Radier-Modi im selben Platz + if state.active_tool == ActiveTool::Eraser { + for (mode, icon, indicator, tooltip) in [ + ( + EraserMode::All, + SidebarIcon::Text("Alle"), + None, + "Alle Striche radieren", + ), + ( + EraserMode::HighlighterOnly, + SidebarIcon::Glyph("🖊"), + Some(state.highlighter_color), + "Nur Textmarker radieren – Handschrift darunter bleibt", + ), + ] { + if sidebar_button( + ui, + btn, + icon, + state.eraser_mode == mode, + true, + indicator, + tooltip, + ) + .clicked() + { + state.eraser_mode = mode; + } + used += btn + spacing; + } + } + let reserved = + UiState::MAX_PALETTE_LEN as f32 * (swatch + spacing); + if reserved > used { + ui.add_space(reserved - used); } // --- Aktionen für die Auswahl --- @@ -1623,6 +1820,10 @@ pub fn render_ui( ); }); + // Bei kleinen Fenstern scrollen statt abschneiden + egui::ScrollArea::vertical() + .max_height((ctx.screen_rect().height() - 90.0).max(150.0)) + .show(ui, |ui| { section(ui, "DOKUMENT"); if ui .add_sized([ui.available_width(), 30.0], egui::Button::new("📄 Als PDF exportieren (Strg+E)")) @@ -1668,6 +1869,48 @@ pub fn render_ui( } }); + section(ui, "PAPIERFARBE (NUR ANZEIGE)"); + let mut paper_changed = false; + egui::Grid::new("more_menu_paper") + .num_columns(2) + .spacing([6.0, 6.0]) + .show(ui, |ui| { + for (i, theme) in PaperTheme::ALL.into_iter().enumerate() { + if paper_theme_button(ui, theme, state.paper_theme == theme) + .clicked() + && state.paper_theme != theme + { + state.paper_theme = theme; + paper_changed = true; + } + if i % 2 == 1 { + ui.end_row(); + } + } + }); + if ui + .add_enabled( + state.paper_theme.is_dark(), + egui::Checkbox::new( + &mut state.adapt_ink_colors, + "Dunkle Stiftfarben aufhellen", + ), + ) + .on_hover_text( + "Schwarz wird Weiß, Dunkelblau wird Hellblau. Gespeichert und \ + als PDF exportiert werden immer die Originalfarben.", + ) + .changed() + { + paper_changed = true; + } + if paper_changed { + actions.push(UiAction::SetPaperTheme { + theme: state.paper_theme, + adapt_ink: state.adapt_ink_colors, + }); + } + section(ui, "ZOOM"); ui.horizontal(|ui| { if ui @@ -1702,6 +1945,9 @@ pub fn render_ui( .on_hover_text( "Aus: ein Finger verschiebt, zwei Finger zoomen (empfohlen mit Stift).\nAn: ein Finger zeichnet.", ); + if let Some(curve) = pressure_curve_editor(ui, state) { + actions.push(UiAction::SavePressureCurve(curve)); + } section(ui, "WERKZEUGLEISTE"); ui.horizontal(|ui| { @@ -1757,6 +2003,7 @@ pub fn render_ui( state.close_overlays(); state.is_update_dialog_open = true; } + }); }) .response .rect @@ -1796,8 +2043,14 @@ pub fn render_ui( .show(ui, |ui| { for (idx, page) in notebook.pages.iter().enumerate() { let is_current = idx == notebook.current_page_index; - if page_thumbnail(ui, page, idx + 1, is_current) - .clicked() + if page_thumbnail( + ui, + state, + page, + idx + 1, + is_current, + ) + .clicked() { actions.push(UiAction::GoToPage(idx)); state.show_page_jump_popup = false; @@ -2917,6 +3170,19 @@ mod tests { out } + #[test] + fn test_stroke_tool_follows_active_tool() { + assert_eq!(ActiveTool::Pen.stroke_tool(false), StrokeTool::Pen); + assert_eq!( + ActiveTool::Highlighter.stroke_tool(false), + StrokeTool::Highlighter + ); + assert_eq!(ActiveTool::Shape.stroke_tool(false), StrokeTool::Pen); + // Eingerastete Form (Formwerkzeug oder Halten am Strichende) + assert_eq!(ActiveTool::Shape.stroke_tool(true), StrokeTool::Shape); + assert_eq!(ActiveTool::Pen.stroke_tool(true), StrokeTool::Shape); + } + #[test] fn test_all_ui_texts_are_renderable() { // Zeichen ohne Glyphe in den eingebauten egui-Schriften erscheinen als leere Kästchen @@ -3130,11 +3396,14 @@ mod tests { // Leere Seite: nur das weiße Blatt let empty = Page::new(1, PageBackground::Blank); - assert_eq!(page_thumbnail_shapes(&empty, rect).len(), 1); + assert_eq!( + page_thumbnail_shapes(&empty, rect, PaperTheme::White, true).len(), + 1 + ); // Striche liegen innerhalb der Kachel let page = page_with_strokes(3, 20); - let shapes = page_thumbnail_shapes(&page, rect); + let shapes = page_thumbnail_shapes(&page, rect, PaperTheme::White, true); assert_eq!(shapes.len(), 1 + 3); let pts = shape_points(&shapes); assert!(!pts.is_empty()); @@ -3145,7 +3414,7 @@ mod tests { // Sehr volle Seite wird auf das Punktbudget ausgedünnt (+ Endpunkte je Strich) let full = page_with_strokes(100, 1000); - let pts = shape_points(&page_thumbnail_shapes(&full, rect)); + let pts = shape_points(&page_thumbnail_shapes(&full, rect, PaperTheme::White, true)); assert!( pts.len() <= THUMBNAIL_POINT_BUDGET + 2 * 100, "{} Punkte statt höchstens {}",