From bb9dbb52d0cf19399241e38da132178fa6aa39ed Mon Sep 17 00:00:00 2001 From: Harald Date: Thu, 8 Oct 2026 16:51:16 +0200 Subject: [PATCH] feat: Eigene Farben, Bildschirmfarben wie im Export (Issue #13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Jede Farbe der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen: gewählte Farbe erneut antippen, Vorgabe oder mit 🎨 frei wählen (Farbwähler direkt im Popover). Die Leiste merkt sich die Farben (ui.json). - Tintenfarben sind sRGB-Werte wie im Export: Gezeichnet wird ohne sRGB-Umrechnung der GPU, Papier und Muster rechnet der Shader um. Schwarz ist am Bildschirm jetzt schwarz statt grau, der Textmarker mischt wie im PDF. - PDF-Export: Musterfarbe wird nach sRGB umgerechnet (war zu dunkel). - Auswahl wird in die Farben der Stifte umgefärbt. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + docs/ARCHITECTURE.md | 2 +- src/background.rs | 22 ++- src/canvas.rs | 44 +++-- src/export.rs | 6 +- src/image_layer.rs | 3 +- src/main.rs | 33 ++-- src/settings.rs | 80 ++++++++- src/shader.wgsl | 39 +++-- src/text.rs | 8 +- src/ui.rs | 402 ++++++++++++++++++++++++++++++++----------- 11 files changed, 479 insertions(+), 161 deletions(-) diff --git a/README.md b/README.md index b7bf4bd..54e44fd 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11). ### Schreiben und Zeichnen - **Vier Stifte** in der Werkzeugleiste, jeder mit eigener Farbe und Stärke (0,15–5 mm, mit Druckstärke). Ändert man Farbe oder Stärke, merkt sich der gewählte Stift das – auch über einen Neustart hinweg. **Textmarker** (halbtransparent, 4–8 mm) in vier Farben; innerhalb eines Strichs wird er nicht dunkler, erst ein neuer Strich deckt stärker. +- **Eigene Farben:** Jede Farbe in der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen – gewählte Farbe erneut antippen, eine Vorgabe oder mit 🎨 einen freien Farbton wählen. Die Leiste merkt sich die Farben. Am Bildschirm erscheinen sie genauso wie im PDF- und Bildexport. - **Glättung in zwei Stufen:** Während des Schreibens glättet ein 1€-Filter das Zittern, die Linie erscheint sofort. Beim Absetzen wird der Strich nachbearbeitet: Ecken (Knicke über 65°) bleiben erhalten, der Druckverlauf wird geglättet, überflüssige Punkte entfallen, die Enden werden rund. - **Druckkurve** im ☰-Menü an den eigenen Stift anpassen (weicher/härter, Schwelle gegen Grundrauschen). - **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Stiftrückseite und Seitentaste des Stifts radieren ebenfalls. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 71c764b..41155f4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -115,7 +115,7 @@ Jeder Strich ist ein eigener Draw-Call mit eigener Nummer im Stencil-Puffer: Ein - **Textfelder** werden mit `ab_glyph` gerastert, in einer Auflösung, die dem Zoom folgt (in Vierteln einer Zweierpotenz, höchstens 8 Pixel je Einheit). Der Texturschlüssel enthält einen Hash von Inhalt, Schrift und Farbe; jede Änderung erzeugt eine neue Textur. ### 4.2 Papier- und Musterfarben -Papierfarbe (`PaperTheme`) und Musterfarbe (`PatternColor`) stehen in `background.rs`. Alle Farben sind linear; die Fensterfläche ist sRGB, die Umrechnung übernimmt die GPU. Auf dunklem Papier kann der Shader dunkle Tinte aufhellen (`ink_params` in `CameraUniforms`), gespeichert werden immer die Originalfarben. +Papierfarbe (`PaperTheme`) und Musterfarbe (`PatternColor`) stehen in `background.rs`; sie sind linear angegeben und werden im Shader nach sRGB umgerechnet. Tintenfarben (Striche, Text) sind sRGB-Werte – dieselben Zahlen landen im PDF und in den Farbpunkten der Oberfläche. Gezeichnet wird ohne sRGB-Umrechnung der GPU (`render_format`), Texturen sind `Rgba8Unorm`: So wird halbtransparente Tinte wie im PDF in sRGB gemischt und sieht am Bildschirm genauso aus wie im Export. Auf dunklem Papier kann der Shader dunkle Tinte aufhellen (`ink_params` in `CameraUniforms`), gespeichert werden immer die Originalfarben. --- diff --git a/src/background.rs b/src/background.rs index f358e3a..5362f08 100644 --- a/src/background.rs +++ b/src/background.rs @@ -71,7 +71,8 @@ pub const LINED_SPACING: f32 = 75.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). +/// Papier- und Musterfarben sind linear (der Shader rechnet sie in sRGB um); Tintenfarben +/// dagegen sind sRGB-Werte wie im Export. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum PaperTheme { @@ -138,7 +139,24 @@ impl PaperTheme { PaperTheme::DarkGray => (0.004, 0.004, 0.005), PaperTheme::Black => (0.03, 0.03, 0.035), }; - wgpu::Color { r, g, b, a: 1.0 } + // Linear angegeben; gezeichnet wird ohne sRGB-Umrechnung + let srgb = |c: f64| f64::from(linear_to_srgb(c as f32)); + wgpu::Color { + r: srgb(r), + g: srgb(g), + b: srgb(b), + a: 1.0, + } + } +} + +/// Linearer Farbanteil (Papier, Muster) als sRGB-Wert (Tinte, Export). +pub fn linear_to_srgb(c: f32) -> f32 { + let c = c.clamp(0.0, 1.0); + if c <= 0.003_130_8 { + c * 12.92 + } else { + 1.055 * c.powf(1.0 / 2.4) - 0.055 } } diff --git a/src/canvas.rs b/src/canvas.rs index cce6122..32bfce0 100644 --- a/src/canvas.rs +++ b/src/canvas.rs @@ -316,13 +316,20 @@ impl CanvasState { ) .await?; - // 4. Geringe Bildverzögerung (Mailbox, sonst Fifo) & Frame-Latency 1 + // 4. Geringe Bildverzögerung (Mailbox, sonst Fifo) & Frame-Latency 1. + // Gezeichnet wird ohne sRGB-Umrechnung (siehe `render_format`); bietet die Fläche + // nur sRGB-Formate an, wird eine Ansicht ohne sRGB verwendet. let surface_format = surface_caps .formats .iter() .copied() - .find(|f| f.is_srgb()) + .find(|f| !f.is_srgb()) .unwrap_or(surface_caps.formats[0]); + let view_formats = if surface_format.is_srgb() { + vec![surface_format.remove_srgb_suffix()] + } else { + vec![] + }; let config = wgpu::SurfaceConfiguration { usage: wgpu::TextureUsages::RENDER_ATTACHMENT, @@ -331,14 +338,14 @@ impl CanvasState { height: size.height.max(1), present_mode, alpha_mode: surface_caps.alpha_modes[0], - view_formats: vec![], + view_formats, desired_maximum_frame_latency: 1, // Minimiert Eingabelatenz auf 1 Frame }; surface.configure(&device, &config); // Kantenglättung der Zeichenfläche (4× Multisampling), falls das Format es erlaubt let msaa_samples = if adapter - .get_texture_format_features(config.format) + .get_texture_format_features(render_format(&config)) .flags .sample_count_supported(MSAA_SAMPLES) { @@ -414,7 +421,7 @@ impl CanvasState { module: &shader, entry_point: "fs_ink", targets: &[Some(wgpu::ColorTargetState { - format: config.format, + format: render_format(&config), blend: Some(wgpu::BlendState::ALPHA_BLENDING), write_mask: wgpu::ColorWrites::ALL, })], @@ -450,7 +457,7 @@ impl CanvasState { module: &shader, entry_point: "fs_none", targets: &[Some(wgpu::ColorTargetState { - format: config.format, + format: render_format(&config), blend: None, write_mask: wgpu::ColorWrites::empty(), })], @@ -477,7 +484,7 @@ impl CanvasState { module: &shader, entry_point: "fs_main", targets: &[Some(wgpu::ColorTargetState { - format: config.format, + format: render_format(&config), blend: Some(wgpu::BlendState::ALPHA_BLENDING), write_mask: wgpu::ColorWrites::ALL, })], @@ -537,7 +544,7 @@ impl CanvasState { module: &shader, entry_point: "fs_pattern", targets: &[Some(wgpu::ColorTargetState { - format: config.format, + format: render_format(&config), blend: Some(wgpu::BlendState::ALPHA_BLENDING), write_mask: wgpu::ColorWrites::ALL, })], @@ -570,12 +577,12 @@ impl CanvasState { &device, &shader, &bind_group_layout, - config.format, + render_format(&config), msaa_samples, ); // 8. egui Renderer für das schwebende UI initialisieren - let egui_renderer = egui_wgpu::Renderer::new(&device, config.format, None, 1); + let egui_renderer = egui_wgpu::Renderer::new(&device, render_format(&config), None, 1); Ok(Self { surface, @@ -1340,9 +1347,10 @@ impl CanvasState { } let output = self.surface.get_current_texture()?; - let view = output - .texture - .create_view(&wgpu::TextureViewDescriptor::default()); + let view = output.texture.create_view(&wgpu::TextureViewDescriptor { + format: Some(render_format(&self.config)), + ..Default::default() + }); let mut encoder = self .device @@ -1509,6 +1517,14 @@ impl CanvasState { } } +/// Format, in das gezeichnet wird: ohne sRGB-Umrechnung. Die Farben werden also in sRGB +/// gemischt – wie im PDF-Export, damit halbtransparente Tinte (Textmarker) am Bildschirm +/// genauso aussieht. Tintenfarben sind sRGB-Werte, Papier- und Musterfarben rechnet der +/// Shader aus linearen Werten um. +fn render_format(config: &wgpu::SurfaceConfiguration) -> wgpu::TextureFormat { + config.format.remove_srgb_suffix() +} + /// Multisampling der Zeichenfläche gegen gezackte Strichkanten. const MSAA_SAMPLES: u32 = 4; @@ -1530,7 +1546,7 @@ fn create_msaa_view( mip_level_count: 1, sample_count: samples, dimension: wgpu::TextureDimension::D2, - format: config.format, + format: render_format(config), usage: wgpu::TextureUsages::RENDER_ATTACHMENT, view_formats: &[], }) diff --git a/src/export.rs b/src/export.rs index bb9e707..64c31d5 100644 --- a/src/export.rs +++ b/src/export.rs @@ -445,8 +445,10 @@ fn render_background_layer( ) { let background = page.background; let (Mm(page_width_mm), Mm(page_height_mm)) = pdf_page_mm(page); - // Exportiert wird immer auf weißem Papier - let [r, g, b, _] = pattern.color(crate::background::PaperTheme::White); + // Exportiert wird immer auf weißem Papier; die Musterfarbe ist linear, PDF erwartet sRGB + let [r, g, b, _] = pattern + .color(crate::background::PaperTheme::White) + .map(crate::background::linear_to_srgb); let grid_color = Color::Rgb(Rgb::new(r, g, b, None)); layer.set_outline_color(grid_color.clone()); layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt) diff --git a/src/image_layer.rs b/src/image_layer.rs index bd1c79b..c403545 100644 --- a/src/image_layer.rs +++ b/src/image_layer.rs @@ -363,7 +363,8 @@ impl ImageLayer { mip_level_count: levels.len() as u32, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8UnormSrgb, + // sRGB-Werte ohne Umrechnung: gezeichnet wird in sRGB (siehe canvas::render_format) + format: wgpu::TextureFormat::Rgba8Unorm, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, view_formats: &[], }); diff --git a/src/main.rs b/src/main.rs index 1f2ec0d..91c95b6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1060,6 +1060,11 @@ fn main() -> Result<(), Box> { ui_state.continuous_scroll = ui_settings.continuous_scroll; ui_state.pen_presets = ui_settings.pen_presets; ui_state.select_pen(ui_settings.active_pen); + ui_state.highlighter_colors = ui_settings.highlighter_colors; + ui_state.select_highlighter_color(ui_settings.active_highlighter); + ui_state.text_colors = ui_settings.text_colors; + ui_state.active_text_color = ui_settings.active_text_color; + ui_state.text_color = ui_settings.text_colors[ui_settings.active_text_color]; ui_state.current_notebook_id = manifest.id.clone(); ui_state.document_title = manifest.title.clone(); ui_state.available_notebooks = available_notebooks; @@ -1071,8 +1076,6 @@ fn main() -> Result<(), Box> { // Zuletzt gelöschte Seite (Notizbuch-Kennung, Index, Seite) zum Wiederherstellen in dieser // Sitzung let mut last_deleted_page: Option<(String, usize, ink_notes::document::Page)> = None; - // Stifte geändert, aber noch nicht gespeichert - let mut pen_presets_dirty = false; let mut clipboard = ClipboardBridge::new(); // PDF-Seiten werden im Hintergrund gerendert und wecken danach den Event-Loop let mut pdf_renderer = { @@ -3263,16 +3266,22 @@ fn main() -> Result<(), Box> { } } - // Stifte: geänderte Farbe oder Stärke gehört zum gewählten Stift. Gespeichert - // wird erst nach dem Loslassen (nicht bei jedem Schritt eines Schiebereglers). - if ui_state.sync_pen_preset() || ui_settings.active_pen != ui_state.active_pen { - pen_presets_dirty = true; - ui_settings.active_pen = ui_state.active_pen; - } - if pen_presets_dirty && !egui_ctx.is_using_pointer() { - pen_presets_dirty = false; - ui_settings.pen_presets = ui_state.pen_presets; - save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); + // Stifte und eigene Farben: geänderte Farbe oder Stärke gehört zum gewählten + // Stift bzw. zur gewählten Farbe. Gespeichert wird erst nach dem Loslassen + // (nicht bei jedem Schritt eines Schiebereglers oder Farbwählers). + ui_state.sync_tool_colors(); + if !egui_ctx.is_using_pointer() { + let mut updated = ui_settings.clone(); + updated.pen_presets = ui_state.pen_presets; + updated.active_pen = ui_state.active_pen; + updated.highlighter_colors = ui_state.highlighter_colors; + updated.active_highlighter = ui_state.active_highlighter; + updated.text_colors = ui_state.text_colors; + updated.active_text_color = ui_state.active_text_color; + if updated != ui_settings { + ui_settings = updated; + save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); + } } // Textbearbeitung beenden: Esc, anderes Werkzeug oder andere Seite diff --git a/src/settings.rs b/src/settings.rs index 50908f0..fe82493 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -87,13 +87,7 @@ impl PenPreset { /// Begrenzt Werte aus einer (evtl. von Hand bearbeiteten) Einstellungsdatei; Stifte sind /// immer deckend. pub fn sanitized(&self, fallback: PenPreset) -> Self { - let mut color = fallback.color; - if self.color.iter().all(|c| c.is_finite()) { - for (out, c) in color.iter_mut().zip(self.color).take(3) { - *out = c.clamp(0.0, 1.0); - } - } - color[3] = 1.0; + let color = sanitized_color(self.color, [0.0, 0.0, 0.0, 1.0]); let width_mm = if self.width_mm.is_finite() { self.width_mm .clamp(*Self::WIDTH_RANGE.start(), *Self::WIDTH_RANGE.end()) @@ -127,6 +121,35 @@ pub const DEFAULT_PEN_PRESETS: [PenPreset; PEN_PRESET_COUNT] = [ }, ]; +/// Farben des Textmarkers in der Werkzeugleiste (halbtransparent). +pub const DEFAULT_HIGHLIGHTER_COLORS: [[f32; 4]; 4] = [ + [1.0, 0.95, 0.1, 0.35], + [0.2, 0.9, 0.3, 0.35], + [0.25, 0.7, 1.0, 0.35], + [1.0, 0.3, 0.65, 0.35], +]; + +/// Farben des Text-Werkzeugs in der Werkzeugleiste. +pub const DEFAULT_TEXT_COLORS: [[f32; 4]; 5] = [ + [0.12, 0.12, 0.14, 1.0], + [0.15, 0.35, 0.85, 1.0], + [0.85, 0.20, 0.20, 1.0], + [0.18, 0.65, 0.35, 1.0], + [0.45, 0.45, 0.48, 1.0], +]; + +/// Begrenzt eine Farbe aus der Einstellungsdatei auf 0–1; die Deckkraft kommt immer aus +/// `fallback` (Stift und Text deckend, Textmarker halbtransparent). +fn sanitized_color(color: [f32; 4], fallback: [f32; 4]) -> [f32; 4] { + let mut out = fallback; + if color.iter().all(|c| c.is_finite()) { + for (o, c) in out.iter_mut().zip(color).take(3) { + *o = c.clamp(0.0, 1.0); + } + } + out +} + /// Benutzereinstellungen der Oberfläche. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -145,6 +168,11 @@ pub struct UiSettings { /// Stifte der Werkzeugleiste und der gewählte davon pub pen_presets: [PenPreset; PEN_PRESET_COUNT], pub active_pen: usize, + /// Eigene Farben von Textmarker und Text-Werkzeug und die gewählte davon + pub highlighter_colors: [[f32; 4]; 4], + pub active_highlighter: usize, + pub text_colors: [[f32; 4]; 5], + pub active_text_color: usize, } impl Default for UiSettings { @@ -158,6 +186,10 @@ impl Default for UiSettings { continuous_scroll: true, pen_presets: DEFAULT_PEN_PRESETS, active_pen: 0, + highlighter_colors: DEFAULT_HIGHLIGHTER_COLORS, + active_highlighter: 0, + text_colors: DEFAULT_TEXT_COLORS, + active_text_color: 0, } } } @@ -189,6 +221,22 @@ impl UiSettings { *preset = preset.sanitized(fallback); } settings.active_pen = settings.active_pen.min(PEN_PRESET_COUNT - 1); + for (color, fallback) in settings + .highlighter_colors + .iter_mut() + .zip(DEFAULT_HIGHLIGHTER_COLORS) + { + *color = sanitized_color(*color, fallback); + } + settings.active_highlighter = settings + .active_highlighter + .min(settings.highlighter_colors.len() - 1); + for (color, fallback) in settings.text_colors.iter_mut().zip(DEFAULT_TEXT_COLORS) { + *color = sanitized_color(*color, fallback); + } + settings.active_text_color = settings + .active_text_color + .min(settings.text_colors.len() - 1); settings } @@ -225,6 +273,10 @@ mod tests { width_mm: 1.5, }; PEN_PRESET_COUNT], active_pen: 2, + highlighter_colors: [[0.9, 0.5, 0.1, 0.35]; 4], + active_highlighter: 3, + text_colors: [[0.3, 0.1, 0.6, 1.0]; 5], + active_text_color: 4, }; settings.save_to(&path).unwrap(); assert_eq!(UiSettings::load_from(&path), settings); @@ -280,6 +332,20 @@ mod tests { assert_eq!(settings.pen_presets[0].color, [1.0, 0.0, 0.5, 1.0]); assert_eq!(settings.pen_presets[0].width_mm, 5.0); assert_eq!(settings.pen_presets[1].width_mm, 0.15); + + // Eigene Farben: Textmarker bleibt halbtransparent, Text deckend + std::fs::write( + &path, + br#"{"highlighter_colors": [[0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0], + [0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0]], "active_highlighter": 7, + "text_colors": [[5.0, 0.2, 0.3, 0.1], [0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0], + [0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0]]}"#, + ) + .unwrap(); + let settings = UiSettings::load_from(&path); + assert_eq!(settings.highlighter_colors[0], [0.1, 0.2, 0.3, 0.35]); + assert_eq!(settings.active_highlighter, 3); + assert_eq!(settings.text_colors[0], [1.0, 0.2, 0.3, 1.0]); } #[test] diff --git a/src/shader.wgsl b/src/shader.wgsl index 7cfa3f8..1f31f0f 100644 --- a/src/shader.wgsl +++ b/src/shader.wgsl @@ -30,22 +30,33 @@ fn vs_main(in: VertexInput) -> VertexOutput { return out; } -@fragment -fn fs_main(in: VertexOutput) -> @location(0) vec4 { - return in.color; +// Linearer Farbwert (Papier, Muster) -> sRGB: Gezeichnet wird ohne sRGB-Umrechnung der GPU +// (siehe `render_format` in canvas.rs), gemischt wird also wie im PDF in sRGB +fn linear_to_srgb(c: vec3) -> vec3 { + let low = c * 12.92; + let high = 1.055 * pow(c, vec3(1.0 / 2.4)) - vec3(0.055); + return select(high, low, c <= vec3(0.0031308)); } -// Striche: bei dunklem Papier wird die HSL-Helligkeit dunkler Farben gespiegelt -// (L -> 1 - L, Farbton und Sättigung bleiben). Entspricht `background::adapt_ink_color`. +// Papier: Farbe linear +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + return vec4(linear_to_srgb(clamp(in.color.rgb, vec3(0.0), vec3(1.0))), in.color.a); +} + +// Striche: Tintenfarben sind sRGB-Werte wie im Export und in der Oberfläche. Bei dunklem +// Papier wird die HSL-Helligkeit dunkler Farben gespiegelt (L -> 1 - L, Farbton und +// Sättigung bleiben). Entspricht `background::adapt_ink_color`. @fragment fn fs_ink(in: VertexOutput) -> @location(0) vec4 { - if (camera.ink_params.x < 0.5) { - return in.color; + var rgb = clamp(in.color.rgb, vec3(0.0), vec3(1.0)); + if (camera.ink_params.x >= 0.5) { + let max_c = max(max(rgb.r, rgb.g), rgb.b); + let min_c = min(min(rgb.r, rgb.g), rgb.b); + let lift = max(1.0 - max_c - min_c, 0.0); + rgb = min(rgb + vec3(lift), vec3(1.0)); } - 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); + return vec4(rgb, in.color.a); } // Bildschirmfüllendes Dreieck ohne Farbe (setzt den Stencil-Puffer zurück) @@ -61,7 +72,8 @@ fn fs_none() -> @location(0) vec4 { return vec4(0.0); } -// Eingefügte Bilder: texturierte Vierecke unter der Tinte +// Eingefügte Bilder: texturierte Vierecke unter der Tinte (Texturen enthalten sRGB-Werte +// und werden unverändert übernommen) struct ImageVertexInput { @location(0) position: vec2, @location(1) uv: vec2, @@ -169,5 +181,6 @@ fn fs_pattern(in: PatternOutput) -> @location(0) vec4 { coverage = clamp(radius_px + 0.5 - d, 0.0, 1.0); } } - return vec4(in.color.rgb, in.color.a * coverage * fade); + let rgb = linear_to_srgb(clamp(in.color.rgb, vec3(0.0), vec3(1.0))); + return vec4(rgb, in.color.a * coverage * fade); } diff --git a/src/text.rs b/src/text.rs index 9a3f137..cf65b0a 100644 --- a/src/text.rs +++ b/src/text.rs @@ -348,7 +348,7 @@ const MAX_TEXTURE_SIDE: f32 = 4096.0; const MAX_TEXTURE_PIXELS: f32 = 16.0 * 1024.0 * 1024.0; /// Rastert ein Textfeld mit `px_per_unit` Pixeln je Seiteneinheit (begrenzt auf eine -/// handhabbare Texturgröße). `color` ist linear wie die Tinte; die Pixel sind sRGB. +/// handhabbare Texturgröße). `color` ist sRGB wie die Tinte, ebenso die Pixel. pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage { let layout = text.layout(); let size = glam::Vec2::new(text.wrap_width.max(text.size), layout.height()); @@ -383,7 +383,7 @@ pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage } } - let rgb = [0, 1, 2].map(|i| egui::ecolor::gamma_u8_from_linear_f32(color[i])); + let rgb = [0, 1, 2].map(|i| (color[i].clamp(0.0, 1.0) * 255.0).round() as u8); let alpha = color[3].clamp(0.0, 1.0); let mut pixels = Vec::with_capacity(coverage.len() * 4); for c in coverage { @@ -476,8 +476,8 @@ mod tests { 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)); + // Die Farbe ist schon sRGB und wird unverändert übernommen + assert!(image.pixels.chunks(4).all(|p| p[..3] == [217, 51, 51])); } #[test] diff --git a/src/ui.rs b/src/ui.rs index 7ebe336..8f3a38a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -19,7 +19,8 @@ use crate::document::{ use crate::file_picker::{FilePicker, PickerKind}; use crate::ruler::{Ruler, RulerHit}; use crate::settings::{ - PenPreset, PressureCurve, SidebarSide, DEFAULT_PEN_PRESETS, PEN_PRESET_COUNT, + PenPreset, PressureCurve, SidebarSide, DEFAULT_HIGHLIGHTER_COLORS, DEFAULT_PEN_PRESETS, + DEFAULT_TEXT_COLORS, PEN_PRESET_COUNT, }; use crate::storage::NotebookSummary; use crate::text::{TextEditing, TextFont, TEXT_SIZES_PT}; @@ -266,6 +267,18 @@ pub struct UiState { /// `pen_stroke_width_mm` gehören immer zum gewählten Stift pub pen_presets: [PenPreset; PEN_PRESET_COUNT], pub active_pen: usize, + /// Eigene Farben des Textmarkers in der Werkzeugleiste und die gewählte; + /// `highlighter_color` gehört immer zur gewählten + pub highlighter_colors: [[f32; 4]; 4], + pub active_highlighter: usize, + /// Eigene Farben des Text-Werkzeugs und die zuletzt angetippte (ein geöffnetes Textfeld + /// kann eine andere Farbe haben) + pub text_colors: [[f32; 4]; 5], + pub active_text_color: usize, + /// Popover „Textfarbe“ ist offen + pub is_text_color_menu_open: bool, + /// Farbwähler in den Farb-Popovern aufgeklappt + pub show_color_picker: bool, /// Rückfrage „Seite löschen?“ im ☰-Menü ist offen pub confirm_delete_page: bool, /// Seitenzahl der zuletzt gelöschten Seite, solange sie wiederhergestellt werden kann @@ -349,6 +362,12 @@ impl Default for UiState { last_deleted_notebook: None, pen_presets: DEFAULT_PEN_PRESETS, active_pen: 0, + highlighter_colors: DEFAULT_HIGHLIGHTER_COLORS, + active_highlighter: 0, + text_colors: DEFAULT_TEXT_COLORS, + active_text_color: 0, + is_text_color_menu_open: false, + show_color_picker: false, confirm_delete_page: false, restorable_page: None, shutdown_error: None, @@ -391,6 +410,7 @@ impl UiState { /// sich nie mehrere überlagern. pub fn close_overlays(&mut self) { self.confirm_delete_page = false; + self.is_text_color_menu_open = false; self.is_pen_menu_open = false; self.is_highlighter_menu_open = false; self.is_more_menu_open = false; @@ -500,9 +520,36 @@ impl UiState { self.pen_stroke_width_mm = preset.width_mm; } + /// Wählt die eigene Textmarker-Farbe `index`. + pub fn select_highlighter_color(&mut self, index: usize) { + self.active_highlighter = index.min(self.highlighter_colors.len() - 1); + self.highlighter_color = self.highlighter_colors[self.active_highlighter]; + } + + /// Setzt die Textfarbe (auch für ein gerade bearbeitetes Textfeld). + pub fn set_text_color(&mut self, color: [f32; 4]) { + self.text_color = color; + if let Some(edit) = &mut self.text_edit { + edit.color = color; + edit.focus = true; + } + } + + /// Übernimmt geänderte Farbe oder Stärke des Stifts in die Vorlage des gewählten Stifts + /// und die Textmarker-Farbe in die gewählte eigene Farbe; `true`, wenn sich etwas + /// geändert hat (dann speichern). + pub fn sync_tool_colors(&mut self) -> bool { + let index = self + .active_highlighter + .min(self.highlighter_colors.len() - 1); + let highlighter_changed = self.highlighter_colors[index] != self.highlighter_color; + self.highlighter_colors[index] = self.highlighter_color; + self.sync_pen_preset() | highlighter_changed + } + /// Übernimmt geänderte Farbe oder Stärke des Stifts in die Vorlage des gewählten Stifts; /// `true`, wenn sie sich geändert hat (dann speichern). - pub fn sync_pen_preset(&mut self) -> bool { + fn sync_pen_preset(&mut self) -> bool { let current = PenPreset { color: self.pen_color, width_mm: self.pen_stroke_width_mm, @@ -930,6 +977,67 @@ fn autosave_status_style(status: AutosaveStatus) -> (egui::Color32, &'static str } /// Zeichnet einen runden Farbauswahl-Knopf (Color Swatch) mit anpassbarem Durchmesser. +/// Farbwahl in den Popovern: Vorgaben als Farbpunkte, dahinter 🎨 für einen frei +/// wählbaren Farbton (aufklappbar). Die Deckkraft von `color` bleibt erhalten (Textmarker +/// halbtransparent). `true`, wenn sich die Farbe geändert hat. +fn color_editor( + ui: &mut egui::Ui, + presets: &[ColorPreset], + color: &mut [f32; 4], + picker_open: &mut bool, +) -> bool { + let mut changed = false; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(8.0, 0.0); + for &(preset, label) in presets { + let is_sel = preset[..3] + .iter() + .zip(&color[..3]) + .all(|(a, b)| (a - b).abs() < 0.01); + if color_circle_swatch(ui, preset, is_sel, label, 30.0).clicked() { + *color = [preset[0], preset[1], preset[2], color[3]]; + changed = true; + } + } + if ui + .add(egui::SelectableLabel::new( + *picker_open, + egui::RichText::new("🎨").size(18.0), + )) + .on_hover_text("Eigene Farbe wählen") + .clicked() + { + *picker_open = !*picker_open; + } + }); + if *picker_open { + ui.add_space(6.0); + let [r, g, b] = [0, 1, 2].map(|i| (color[i].clamp(0.0, 1.0) * 255.0).round() as u8); + let mut picked = egui::Color32::from_rgb(r, g, b); + let picked_changed = ui + .scope(|ui| { + ui.spacing_mut().slider_width = 170.0; + egui::color_picker::color_picker_color32( + ui, + &mut picked, + egui::color_picker::Alpha::Opaque, + ) + }) + .inner; + if picked_changed { + let [r, g, b, _] = picked.to_array(); + *color = [ + r as f32 / 255.0, + g as f32 / 255.0, + b as f32 / 255.0, + color[3], + ]; + changed = true; + } + } + changed +} + /// Stift der Werkzeugleiste: ein Strich in Farbe und (angedeuteter) Stärke auf einem /// Stück Papier, damit auch schwarze Tinte auf der dunklen Leiste sichtbar ist. fn pen_preset_swatch( @@ -1195,7 +1303,7 @@ fn text_edit_overlay(ctx: &egui::Context, state: &mut UiState) { let height_px = font_size * ctx.pixels_per_point() * edit.text.font.height_per_em(); let 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 { + let color = color32_from(if adapt { adapt_ink_color(edit.color) } else { edit.color @@ -2001,6 +2109,7 @@ pub fn render_ui( let mut pen_btn_rect = None; let mut high_btn_rect = None; + let mut text_color_btn_rect = None; let mut more_btn_rect = None; let mut page_btn_rect = None; @@ -2208,26 +2317,11 @@ pub fn render_ui( // --- Stifte bzw. Farben des aktiven Werkzeugs --- let pens = matches!(state.active_tool, ActiveTool::Pen | ActiveTool::Shape); - let palette: Option<(&[ColorPreset], [f32; 4])> = - match state.active_tool { - ActiveTool::Pen | ActiveTool::Shape => None, - ActiveTool::Select => { - Some((&PEN_COLOR_PRESETS[..], state.pen_color)) - } - ActiveTool::Highlighter => Some(( - &HIGHLIGHTER_COLOR_PRESETS[..], - state.highlighter_color, - )), - ActiveTool::Text => { - Some((&PEN_COLOR_PRESETS[..], state.text_color)) - } - ActiveTool::Eraser => None, - }; // Fester Platz für die längste Palette, damit die Knöpfe darunter // beim Werkzeugwechsel nicht springen sidebar_divider(ui, btn); let swatch = UiState::sidebar_swatch_size(btn); - let mut shown = palette.map_or(0, |(presets, _)| presets.len()); + let mut shown = 0; if pens { shown = PEN_PRESET_COUNT; for i in 0..PEN_PRESET_COUNT { @@ -2267,29 +2361,74 @@ pub fn render_ui( } } } - if let Some((presets, current)) = palette { - for &(color, label) in presets { - let is_sel = (current[0] - color[0]).abs() < 0.05 - && (current[1] - color[1]).abs() < 0.05 - && (current[2] - color[2]).abs() < 0.05; - if color_circle_swatch(ui, color, is_sel, label, swatch) - .clicked() - { - 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; - } - } - // Auswahl umfärben, der Stift bleibt - _ => {} + // Eigene Farben von Textmarker und Text: antippen wählt, erneut + // tippen passt die Farbe an. Die Auswahl wird in die Farben der + // Stifte umgefärbt. + let tool = state.active_tool; + let slots: Option> = match tool { + ActiveTool::Highlighter => Some(state.highlighter_colors.to_vec()), + ActiveTool::Text => Some(state.text_colors.to_vec()), + ActiveTool::Select => { + Some(state.pen_presets.iter().map(|p| p.color).collect()) + } + _ => None, + }; + if let Some(colors) = slots { + shown = colors.len(); + for (i, color) in colors.into_iter().enumerate() { + let selected = match tool { + ActiveTool::Highlighter => i == state.active_highlighter, + ActiveTool::Text => { + i == state.active_text_color + && state.text_color == color } - actions.push(UiAction::SetColor(color)); + _ => false, + }; + let tooltip = match tool { + ActiveTool::Select => { + format!("Auswahl in der Farbe von Stift {} umfärben", i + 1) + } + _ if selected => format!( + "Farbe {} – erneut tippen, um sie anzupassen", + i + 1 + ), + _ => format!("Farbe {}", i + 1), + }; + let response = + color_circle_swatch(ui, color, selected, &tooltip, swatch); + if selected { + // Das Farb-Popover erscheint neben der gewählten Farbe + match tool { + ActiveTool::Highlighter => high_btn_rect = Some(response.rect), + _ => text_color_btn_rect = Some(response.rect), + } + } + if !response.clicked() { + continue; + } + match tool { + ActiveTool::Highlighter if selected => { + let open = !state.is_highlighter_menu_open; + state.close_overlays(); + state.is_highlighter_menu_open = open; + } + ActiveTool::Highlighter => { + state.close_overlays(); + state.select_highlighter_color(i); + actions.push(UiAction::SetColor(color)); + } + ActiveTool::Text if selected => { + let open = !state.is_text_color_menu_open; + state.close_overlays(); + state.is_text_color_menu_open = open; + } + ActiveTool::Text => { + state.close_overlays(); + state.active_text_color = i; + state.set_text_color(color); + actions.push(UiAction::SetColor(color)); + } + _ => actions.push(UiAction::SetColor(color)), } } } @@ -2565,36 +2704,14 @@ pub fn render_ui( .color(egui::Color32::from_gray(160)), ); ui.add_space(3.0); - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing = egui::vec2(8.0, 0.0); - for (color, label) in PEN_COLOR_PRESETS { - let is_sel = (state.pen_color[0] - color[0]).abs() < 0.05 - && (state.pen_color[1] - color[1]).abs() < 0.05 - && (state.pen_color[2] - color[2]).abs() < 0.05; - if color_circle_swatch(ui, color, is_sel, label, 30.0).clicked() { - state.pen_color = color; - actions.push(UiAction::SetColor(color)); - } - } - - // RGBA-Colorpicker - let mut egui_color = egui::Rgba::from_rgba_premultiplied( - state.pen_color[0], - state.pen_color[1], - state.pen_color[2], - state.pen_color[3], - ); - if egui::color_picker::color_edit_button_rgba( - ui, - &mut egui_color, - egui::color_picker::Alpha::Opaque, - ) - .changed() - { - state.pen_color = egui_color.to_array(); - actions.push(UiAction::SetColor(state.pen_color)); - } - }); + if color_editor( + ui, + &PEN_COLOR_PRESETS, + &mut state.pen_color, + &mut state.show_color_picker, + ) { + actions.push(UiAction::SetColor(state.pen_color)); + } ui.add_space(10.0); @@ -2662,10 +2779,13 @@ pub fn render_ui( ui.horizontal(|ui| { ui.label( - egui::RichText::new("Textmarker-Konfiguration") - .strong() - .color(egui::Color32::WHITE) - .size(12.5), + egui::RichText::new(format!( + "Textmarker – Farbe {}", + state.active_highlighter + 1 + )) + .strong() + .color(egui::Color32::WHITE) + .size(12.5), ); ui.with_layout( egui::Layout::right_to_left(egui::Align::Center), @@ -2681,41 +2801,20 @@ pub fn render_ui( // Zeile 1: Farbauswahl ui.label( - egui::RichText::new("MARKER-FARBE") + egui::RichText::new("FARBE") .size(10.5) .strong() .color(egui::Color32::from_gray(160)), ); ui.add_space(3.0); - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing = egui::vec2(8.0, 0.0); - for (color, label) in HIGHLIGHTER_COLOR_PRESETS { - let is_sel = (state.highlighter_color[0] - color[0]).abs() < 0.05 - && (state.highlighter_color[1] - color[1]).abs() < 0.05 - && (state.highlighter_color[2] - color[2]).abs() < 0.05; - if color_circle_swatch(ui, color, is_sel, label, 30.0).clicked() { - state.highlighter_color = color; - actions.push(UiAction::SetColor(color)); - } - } - - let mut egui_color = egui::Rgba::from_rgba_premultiplied( - state.highlighter_color[0], - state.highlighter_color[1], - state.highlighter_color[2], - state.highlighter_color[3], - ); - if egui::color_picker::color_edit_button_rgba( - ui, - &mut egui_color, - egui::color_picker::Alpha::OnlyBlend, - ) - .changed() - { - state.highlighter_color = egui_color.to_array(); - actions.push(UiAction::SetColor(state.highlighter_color)); - } - }); + if color_editor( + ui, + &HIGHLIGHTER_COLOR_PRESETS, + &mut state.highlighter_color, + &mut state.show_color_picker, + ) { + actions.push(UiAction::SetColor(state.highlighter_color)); + } ui.add_space(10.0); @@ -2779,6 +2878,59 @@ pub fn render_ui( popover_rect = Some(pop_response.inner); } + } else if state.is_text_color_menu_open { + if let Some(btn_rect) = text_color_btn_rect { + let (popover_pos, pivot) = + popover_placement(state.sidebar_side, area_response.inner, btn_rect); + let pop_response = egui::Area::new(egui::Id::new("text_color_popover")) + .fixed_pos(popover_pos) + .pivot(pivot) + .constrain(true) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + let frame = popover_frame().show(ui, |ui| { + ui.set_width(320.0); + touch_friendly_spacing(ui); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(format!( + "Textfarbe {}", + state.active_text_color + 1 + )) + .strong() + .color(egui::Color32::WHITE) + .size(12.5), + ); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if close_button(ui).clicked() { + state.is_text_color_menu_open = false; + } + }, + ); + }); + ui.add_space(8.0); + let slot = state.active_text_color.min(state.text_colors.len() - 1); + let mut color = state.text_colors[slot]; + if color_editor( + ui, + &PEN_COLOR_PRESETS, + &mut color, + &mut state.show_color_picker, + ) { + state.text_colors[slot] = color; + state.set_text_color(color); + actions.push(UiAction::SetColor(color)); + } + }); + frame.response.rect + }); + popover_rect = Some(pop_response.inner); + } else { + // Die Farbe ist nicht mehr zu sehen (anderes Werkzeug) + state.is_text_color_menu_open = false; + } } // 2b. ☰-MENÜ (selten genutzte Funktionen), öffnet sich nach oben zur Blattseite hin @@ -3223,6 +3375,7 @@ pub fn render_ui( // Automatisches Schließen der Popover bei Klick außerhalb von Leiste und Popover if (state.is_pen_menu_open || state.is_highlighter_menu_open + || state.is_text_color_menu_open || state.is_more_menu_open || state.show_page_jump_popup) && ctx.input(|i| i.pointer.any_pressed()) @@ -3233,6 +3386,7 @@ pub fn render_ui( if !in_toolbar && !in_popover { state.is_pen_menu_open = false; state.is_highlighter_menu_open = false; + state.is_text_color_menu_open = false; state.is_more_menu_open = false; state.show_page_jump_popup = false; } @@ -4542,6 +4696,44 @@ mod tests { assert!(state.ruler.is_none()); } + #[test] + fn test_custom_colors_remember_changes() { + let mut state = UiState::new(); + assert!(!state.sync_tool_colors(), "Ausgangszustand ist gespeichert"); + + // Stift 2 wählen und umfärben: der Stift merkt sich die Farbe + state.select_pen(1); + state.pen_color = [0.5, 0.1, 0.6, 1.0]; + assert!(state.sync_tool_colors()); + assert_eq!(state.pen_presets[1].color, [0.5, 0.1, 0.6, 1.0]); + state.select_pen(0); + state.select_pen(1); + assert_eq!(state.pen_color, [0.5, 0.1, 0.6, 1.0]); + + // Textmarker-Farbe 3 anpassen + state.select_highlighter_color(2); + state.highlighter_color = [0.9, 0.5, 0.1, 0.35]; + assert!(state.sync_tool_colors()); + assert_eq!(state.highlighter_colors[2], [0.9, 0.5, 0.1, 0.35]); + assert_eq!(state.highlighter_colors[0], DEFAULT_HIGHLIGHTER_COLORS[0]); + assert!(!state.sync_tool_colors()); + + // Textfarbe gilt auch für das gerade bearbeitete Feld + state.text_edit = Some(TextEditing::new_at( + 1, + glam::Vec2::new(100.0, 100.0), + PAGE_WIDTH, + 50.0, + TextFont::Sans, + [0.0, 0.0, 0.0, 1.0], + )); + state.set_text_color([0.2, 0.6, 0.3, 1.0]); + assert_eq!( + state.text_edit.as_ref().unwrap().color, + [0.2, 0.6, 0.3, 1.0] + ); + } + #[test] fn test_stroke_tool_follows_active_tool() { assert_eq!(ActiveTool::Pen.stroke_tool(false), StrokeTool::Pen);