From 85d1ba16479355e1645e053833de436f755c3203 Mon Sep 17 00:00:00 2001 From: Harald Date: Tue, 6 Oct 2026 23:12:56 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20Zeichnen=20auf=20dem=20Blatt=20wiede?= =?UTF-8?q?r=20m=C3=B6glich;=20Farben=20und=20Zeichen=20korrigiert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression aus v0.3.0: Die UI-Trefferprüfung (egui layer_id_at) wertete eine bildschirmfüllende egui-Ebene der Ordnung Background als UI. Dadurch galt jeder Punkt auf dem Blatt als Oberfläche, und Maus, Stift und Touch zeichneten nicht. Background-Ebenen zählen jetzt nicht mehr als UI; die Prüfung liegt in ui.rs und ist durch einen Test abgedeckt (Blattpunkte, Leiste, Skalierung 1 und 2). Außerdem: - 25 ungültige vormultiplizierte Farben (RGB > Alpha) auf unmultipliziert umgestellt: Knöpfe in Dialogen waren weiße Kästen mit unlesbarer Schrift, die Auswahlfüllung war deckend statt durchscheinend - Zeichen ohne Glyphe in den egui-Schriften ersetzt (✓ ✕ ● ↑ ↓, Variantenzeichen U+FE0F hinter ⚠) - Tests finden künftig ungültige Farben und nicht darstellbare Zeichen in ui.rs, selection.rs und main.rs Co-Authored-By: Claude Opus 5.5 --- src/main.rs | 27 ++----- src/selection.rs | 4 +- src/ui.rs | 202 +++++++++++++++++++++++++++++++++++++---------- 3 files changed, 170 insertions(+), 63 deletions(-) diff --git a/src/main.rs b/src/main.rs index 7af1246..5fc77b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -55,21 +55,6 @@ fn update_window_title( window.set_title(&title); } -/// Prüft, ob ein Bildschirmpunkt (physische Pixel) auf einem UI-Element liegt. -/// -/// egui arbeitet in logischen Punkten; Touch- und Stift-Positionen kommen in physischen -/// Pixeln. `layer_id_at` erfasst jedes egui-Element des letzten Frames (Leiste, Popover, -/// Fenster), auch wenn egui den Zeiger selbst noch nicht kennt (Stift, erster Touch). -fn is_screen_point_in_ui( - egui_ctx: &egui::Context, - ui_state: &UiState, - screen_pos: (f32, f32), - scale: f32, -) -> bool { - let pos = egui::pos2(screen_pos.0 / scale, screen_pos.1 / scale); - ui_state.is_point_in_ui(pos.x, pos.y) || egui_ctx.layer_id_at(pos).is_some() -} - /// Exportiert das Notizbuch im Hintergrund als PDF. Noch nicht geladene Seiten werden vorher /// von der Festplatte nachgeladen; das Ergebnis geht über `result_tx` an den Event-Loop zurück. fn start_pdf_export( @@ -270,7 +255,7 @@ fn main() -> Result<(), Box> { Err(storage::StorageError::AlreadyLocked(msg)) => { eprintln!("[Storage-Lock] {msg}"); autosave.set_read_only(true); - ui_state.set_export_message("⚠️ In anderer Instanz geöffnet (Schreibschutz)"); + ui_state.set_export_message("⚠ In anderer Instanz geöffnet (Schreibschutz)"); None } Err(e) => { @@ -477,7 +462,7 @@ fn main() -> Result<(), Box> { .events .push(egui::Event::PointerMoved(ui_pos)); if stylus_ui_capture - || is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale) + || ui::is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale) { window.request_redraw(); } @@ -532,7 +517,7 @@ fn main() -> Result<(), Box> { stylus_tool_id = Some(tool.id()); let (sx, sy) = stylus_screen_pos; - if is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale) { + if ui::is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale) { // In die Eingabe-Warteschlange des NÄCHSTEN egui-Frames // (egui_ctx.input_mut würde vom nächsten Frame überschrieben) let ui_pos = egui::pos2(sx / scale, sy / scale); @@ -710,7 +695,7 @@ fn main() -> Result<(), Box> { // 2. Prüfen, ob das UI Pointer-Interaktionen beansprucht (Event-Isolation) // Konsumiert Klicks innerhalb der abgerundeten Pill-Toolbar, des Popovers oder offener Menüs let is_pointer_in_ui = egui_ctx.wants_pointer_input() - || is_screen_point_in_ui( + || ui::is_screen_point_in_ui( &egui_ctx, &ui_state, cursor_screen_pos, @@ -1539,7 +1524,7 @@ fn main() -> Result<(), Box> { match touch.phase { TouchPhase::Started => { // Event-Isolation für Touch-Eingaben - let is_touch_in_ui = is_screen_point_in_ui( + let is_touch_in_ui = ui::is_screen_point_in_ui( &egui_ctx, &ui_state, (tx, ty), @@ -2133,7 +2118,7 @@ fn main() -> Result<(), Box> { eprintln!("[Storage-Lock] {msg}"); _notebook_lock = None; autosave.set_read_only(true); - ui_state.set_export_message("⚠️ In anderer Instanz geöffnet (Schreibschutz)"); + ui_state.set_export_message("⚠ In anderer Instanz geöffnet (Schreibschutz)"); } Err(e) => { eprintln!("[Storage-Lock] Konnte Lock nicht erwerben: {e}"); diff --git a/src/selection.rs b/src/selection.rs index 49aa0cd..d3ee9a0 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -303,14 +303,14 @@ pub fn render_selection_overlay( painter.rect_filled( rect, egui::Rounding::same(4.0_f32), - egui::Color32::from_rgba_premultiplied(40, 120, 240, 35), + egui::Color32::from_rgba_unmultiplied(40, 120, 240, 35), ); painter.rect_stroke( rect, egui::Rounding::same(4.0_f32), egui::Stroke::new( 1.5_f32, - egui::Color32::from_rgba_premultiplied(80, 160, 255, 220), + egui::Color32::from_rgba_unmultiplied(80, 160, 255, 220), ), ); return; diff --git a/src/ui.rs b/src/ui.rs index f1f01a0..f186445 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -376,16 +376,16 @@ fn tool_pill_button( egui::Color32::WHITE, egui::Stroke::new( 1.0_f32, - egui::Color32::from_rgba_premultiplied(110, 165, 255, 190), + egui::Color32::from_rgba_unmultiplied(110, 165, 255, 190), ), ) } else { ( - egui::Color32::from_rgba_premultiplied(255, 255, 255, 8), + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 8), egui::Color32::from_gray(215), egui::Stroke::new( 1.0_f32, - egui::Color32::from_rgba_premultiplied(255, 255, 255, 15), + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 15), ), ) }; @@ -421,23 +421,23 @@ fn stroke_preview_button( egui::Color32::from_rgba_premultiplied(50, 85, 150, 220), egui::Stroke::new( 1.0_f32, - egui::Color32::from_rgba_premultiplied(120, 175, 255, 200), + egui::Color32::from_rgba_unmultiplied(120, 175, 255, 200), ), ) } else if response.hovered() { ( - egui::Color32::from_rgba_premultiplied(255, 255, 255, 16), + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 16), egui::Stroke::new( 1.0_f32, - egui::Color32::from_rgba_premultiplied(255, 255, 255, 25), + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 25), ), ) } else { ( - egui::Color32::from_rgba_premultiplied(255, 255, 255, 8), + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 8), egui::Stroke::new( 1.0_f32, - egui::Color32::from_rgba_premultiplied(255, 255, 255, 14), + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 14), ), ) }; @@ -497,18 +497,18 @@ fn action_pill_button( let (bg, stroke) = if enabled { ( - egui::Color32::from_rgba_premultiplied(255, 255, 255, 10), + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 10), egui::Stroke::new( 1.0_f32, - egui::Color32::from_rgba_premultiplied(255, 255, 255, 22), + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 22), ), ) } else { ( - egui::Color32::from_rgba_premultiplied(255, 255, 255, 3), + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 3), egui::Stroke::new( 1.0_f32, - egui::Color32::from_rgba_premultiplied(255, 255, 255, 8), + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 8), ), ) }; @@ -678,7 +678,7 @@ fn sidebar_divider(ui: &mut egui::Ui, button_size: f32) { rect.center().y, egui::Stroke::new( 1.0_f32, - egui::Color32::from_rgba_premultiplied(255, 255, 255, 25), + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 25), ), ); } @@ -767,6 +767,26 @@ fn color_circle_swatch( response.on_hover_text(tooltip) } +/// Prüft, ob ein Bildschirmpunkt (physische Pixel) auf einem UI-Element liegt. +/// +/// egui arbeitet in logischen Punkten; Touch- und Stift-Positionen kommen in physischen +/// Pixeln. `layer_id_at` erfasst die egui-Bereiche des letzten Frames (Leiste, Popover, +/// Fenster), auch wenn egui den Zeiger selbst noch nicht kennt (Stift, erster Touch). +/// Ebenen der Ordnung `Background` decken den ganzen Bildschirm ab und gehören zum +/// Zeichenblatt, nicht zur Oberfläche. +pub fn is_screen_point_in_ui( + ctx: &egui::Context, + state: &UiState, + screen_pos: (f32, f32), + scale: f32, +) -> bool { + let pos = egui::pos2(screen_pos.0 / scale, screen_pos.1 / scale); + state.is_point_in_ui(pos.x, pos.y) + || ctx + .layer_id_at(pos) + .is_some_and(|layer| layer.order != egui::Order::Background) +} + /// Rendert die schwebende Toolbar und alle zugehörigen UI-Komponenten mit `egui`. /// /// Gibt eine Liste von `UiAction`-Events zurück, die vom Hauptprogramm verarbeitet werden. @@ -811,7 +831,7 @@ pub fn render_ui( .rounding(egui::Rounding::same(UiState::TOOLBAR_ROUNDING)) .stroke(egui::Stroke::new( 1.0_f32, - egui::Color32::from_rgba_premultiplied(255, 255, 255, 30), + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30), )) .inner_margin(egui::Margin::same(6.0)) .shadow(egui::epaint::Shadow { @@ -1067,7 +1087,7 @@ pub fn render_ui( false, notebook.current_page_index > 0, None, - "Vorherige Seite (Bild↑)", + "Vorherige Seite (Bild auf)", ) .clicked() { @@ -1100,9 +1120,9 @@ pub fn render_ui( true, None, if is_last { - "Neue Seite anhängen (Bild↓ / N)" + "Neue Seite anhängen (Bild ab / N)" } else { - "Nächste Seite (Bild↓ / N)" + "Nächste Seite (Bild ab / N)" }, ) .clicked() @@ -1178,7 +1198,7 @@ pub fn render_ui( .rounding(egui::Rounding::same(12.0)) .stroke(egui::Stroke::new( 1.0_f32, - egui::Color32::from_rgba_premultiplied(255, 255, 255, 30), + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30), )) .inner_margin(egui::Margin::symmetric(14.0, 12.0)) .shadow(egui::epaint::Shadow { @@ -1329,7 +1349,7 @@ pub fn render_ui( .rounding(egui::Rounding::same(12.0)) .stroke(egui::Stroke::new( 1.0_f32, - egui::Color32::from_rgba_premultiplied(255, 255, 255, 30), + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30), )) .inner_margin(egui::Margin::symmetric(14.0, 12.0)) .shadow(egui::epaint::Shadow { @@ -1493,7 +1513,7 @@ pub fn render_ui( .rounding(egui::Rounding::same(12.0)) .stroke(egui::Stroke::new( 1.0_f32, - egui::Color32::from_rgba_premultiplied(255, 255, 255, 30), + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30), )) .inner_margin(egui::Margin::symmetric(14.0, 12.0)) .shadow(egui::epaint::Shadow { @@ -1709,7 +1729,7 @@ pub fn render_ui( .rounding(egui::Rounding::same(12.0)) .stroke(egui::Stroke::new( 1.0_f32, - egui::Color32::from_rgba_premultiplied(255, 255, 255, 30), + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30), )) .inner_margin(egui::Margin::symmetric(14.0, 12.0)) .shadow(egui::epaint::Shadow { @@ -1759,7 +1779,7 @@ pub fn render_ui( .rounding(egui::Rounding::same(14.0)) .stroke(egui::Stroke::new( 1.0_f32, - egui::Color32::from_rgba_premultiplied(255, 255, 255, 30), + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30), )) .inner_margin(egui::Margin::symmetric(18.0, 16.0)) .shadow(egui::epaint::Shadow { @@ -1782,7 +1802,7 @@ pub fn render_ui( ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui - .add(egui::Button::new(egui::RichText::new("✕").size(12.0)).frame(false)) + .add(egui::Button::new(egui::RichText::new("🗙").size(12.0)).frame(false)) .clicked() { state.is_document_menu_open = false; @@ -1845,7 +1865,7 @@ pub fn render_ui( egui::TextEdit::singleline(&mut state.renaming_title_input) .desired_width(230.0), ); - if ui.button("✓").clicked() + if ui.button("✔").clicked() || (edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) { let new_t = state.renaming_title_input.trim(); @@ -1857,19 +1877,19 @@ pub fn render_ui( } state.renaming_notebook_id = None; } - if ui.button("✕").clicked() { + if ui.button("🗙").clicked() { state.renaming_notebook_id = None; } } else { let (bg, border) = if is_current { ( egui::Color32::from_rgba_premultiplied(45, 80, 145, 200), - egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(110, 165, 255, 180)), + egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_unmultiplied(110, 165, 255, 180)), ) } else { ( - egui::Color32::from_rgba_premultiplied(255, 255, 255, 8), - egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 16)), + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 8), + egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_unmultiplied(255, 255, 255, 16)), ) }; @@ -1889,9 +1909,9 @@ pub fn render_ui( .color(egui::Color32::WHITE), ); if summary.status == crate::storage::NotebookStatus::Damaged { - ui.colored_label(egui::Color32::from_rgb(255, 100, 100), "⚠️ Beschädigt"); + ui.colored_label(egui::Color32::from_rgb(255, 100, 100), "⚠ Beschädigt"); } else if is_current { - ui.colored_label(egui::Color32::from_rgb(46, 204, 113), "● Aktiv"); + ui.colored_label(egui::Color32::from_rgb(46, 204, 113), "⚫ Aktiv"); } }); let subtext = if let Some(ref err) = summary.error { @@ -2000,7 +2020,7 @@ pub fn render_ui( .rounding(egui::Rounding::same(14.0)) .stroke(egui::Stroke::new( 1.0_f32, - egui::Color32::from_rgba_premultiplied(255, 255, 255, 30), + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30), )) .inner_margin(egui::Margin::symmetric(20.0, 16.0)) .shadow(egui::epaint::Shadow { @@ -2023,7 +2043,7 @@ pub fn render_ui( ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui - .add(egui::Button::new(egui::RichText::new("✕").size(13.0)).frame(false)) + .add(egui::Button::new(egui::RichText::new("🗙").size(13.0)).frame(false)) .clicked() { state.is_update_dialog_open = false; @@ -2079,7 +2099,7 @@ pub fn render_ui( } crate::updater::UpdaterStatus::UpToDate { current_version } => { ui.horizontal(|ui| { - ui.colored_label(egui::Color32::from_rgb(46, 204, 113), "✓"); + ui.colored_label(egui::Color32::from_rgb(46, 204, 113), "✔"); ui.label( egui::RichText::new(format!("ink-notes ist auf dem neuesten Stand (v{}).", current_version)) .color(egui::Color32::from_rgb(46, 204, 113)) @@ -2115,7 +2135,7 @@ pub fn render_ui( crate::updater::UpdaterStatus::UpdateAvailable(release) => { ui.vertical(|ui| { ui.horizontal(|ui| { - ui.colored_label(egui::Color32::from_rgb(46, 204, 113), "●"); + ui.colored_label(egui::Color32::from_rgb(46, 204, 113), "⚫"); ui.label( egui::RichText::new(format!("Neue Version verfügbar: {}", release.tag_name)) .strong() @@ -2191,7 +2211,7 @@ pub fn render_ui( crate::updater::UpdaterStatus::DownloadFinished { file_path, asset_name } => { ui.vertical(|ui| { ui.horizontal(|ui| { - ui.colored_label(egui::Color32::from_rgb(46, 204, 113), "✓"); + ui.colored_label(egui::Color32::from_rgb(46, 204, 113), "✔"); ui.label( egui::RichText::new(format!("'{}' erfolgreich heruntergeladen!", asset_name)) .strong() @@ -2303,7 +2323,7 @@ pub fn render_ui( }) .show(ui, |ui| { ui.label( - egui::RichText::new(format!("✓ {}", msg)) + egui::RichText::new(format!("✔ {}", msg)) .color(egui::Color32::from_rgba_unmultiplied( 220, 255, 230, alpha, )) @@ -2319,7 +2339,7 @@ pub fn render_ui( // 5. Nicht-wegklickbare Warnung bei Ladefehlern / Quarantäne (Block 1) if let Some(warning) = notebook.load_warnings.get(¬ebook.current_page().id) { - egui::Window::new("⚠️ Datenintegritäts-Warnung") + egui::Window::new("⚠ Datenintegritäts-Warnung") .collapsible(false) .resizable(false) .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) @@ -2342,7 +2362,7 @@ pub fn render_ui( .show(ctx, |ui| { ui.set_width(420.0); ui.horizontal(|ui| { - ui.colored_label(egui::Color32::from_rgb(255, 80, 80), "⚠️"); + ui.colored_label(egui::Color32::from_rgb(255, 80, 80), "⚠"); ui.label( egui::RichText::new(format!( "Seite {} konnte nicht fehlerfrei geladen werden!", @@ -2408,7 +2428,7 @@ pub fn render_ui( // Notfall-/Sicherheits-Dialog bei Fehlschlag des Speicherns vor dem Beenden if let Some(err) = &state.shutdown_error { let mut dismiss = false; - egui::Window::new("⚠️ Ungespeicherte Änderungen") + egui::Window::new("⚠ Ungespeicherte Änderungen") .collapsible(false) .resizable(false) .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) @@ -2431,7 +2451,7 @@ pub fn render_ui( .show(ctx, |ui| { ui.set_width(460.0); ui.horizontal(|ui| { - ui.colored_label(egui::Color32::from_rgb(255, 80, 80), "⚠️"); + ui.colored_label(egui::Color32::from_rgb(255, 80, 80), "⚠"); ui.label( egui::RichText::new("Fehler beim Beenden") .color(egui::Color32::from_rgb(255, 100, 100)) @@ -2910,4 +2930,106 @@ mod tests { "Auswahl-Aktionen fehlen: {r:?}" ); } + + /// Quelltexte, deren String-Literale in der Oberfläche erscheinen können. + const UI_SOURCES: [(&str, &str); 3] = [ + ("ui.rs", include_str!("ui.rs")), + ("selection.rs", include_str!("selection.rs")), + ("main.rs", include_str!("main.rs")), + ]; + + /// Liefert alle Zeichen aus String-Literalen eines Quelltexts (ohne Zeilenkommentare). + fn string_literal_chars(src: &str) -> Vec { + let mut out = Vec::new(); + let mut chars = src.chars().peekable(); + let (mut in_str, mut in_comment) = (false, false); + while let Some(c) = chars.next() { + if in_comment { + in_comment = c != '\n'; + } else if in_str { + match c { + '\\' => { + chars.next(); + } + '"' => in_str = false, + _ => out.push(c), + } + } else if c == '/' && chars.peek() == Some(&'/') { + in_comment = true; + } else if c == '"' { + in_str = true; + } + } + out + } + + #[test] + fn test_all_ui_texts_are_renderable() { + // Zeichen ohne Glyphe in den eingebauten egui-Schriften erscheinen als leere Kästchen + let ctx = egui::Context::default(); + ctx.begin_frame(egui::RawInput::default()); + let font = egui::FontId::proportional(14.0); + let mut missing = std::collections::BTreeSet::new(); + for (name, src) in UI_SOURCES { + for c in string_literal_chars(src) { + if !c.is_ascii() && !ctx.fonts(|f| f.has_glyphs(&font, &c.to_string())) { + missing.insert(format!("{name}: {c} (U+{:04X})", c as u32)); + } + } + } + let _ = ctx.end_frame(); + assert!( + missing.is_empty(), + "Nicht darstellbare Zeichen: {missing:#?}" + ); + } + + #[test] + fn test_no_invalid_premultiplied_colors() { + // Bei vormultiplizierten Farben darf kein Farbkanal größer als Alpha sein, + // sonst wird z. B. (255, 255, 255, 8) als fast deckendes Weiß gezeichnet + let mut invalid = Vec::new(); + for (name, src) in UI_SOURCES { + for part in src.split("from_rgba_premultiplied(").skip(1) { + let args: Vec = part + .split(')') + .next() + .unwrap_or_default() + .split(',') + .filter_map(|a| a.trim().parse().ok()) + .collect(); + if args.len() == 4 && args[..3].iter().any(|&v| v > args[3]) { + invalid.push(format!("{name}: {args:?}")); + } + } + } + assert!( + invalid.is_empty(), + "Ungültige vormultiplizierte Farben: {invalid:#?}" + ); + } + + #[test] + fn test_canvas_points_are_not_ui() { + // Regression v0.3.0: eine bildschirmfüllende Background-Ebene wurde als UI gewertet, + // dadurch ließ sich auf dem Blatt nicht mehr zeichnen + let notebook = Notebook::new(); + for scale in [1.0_f32, 2.0] { + let mut state = UiState::new(); + let ctx = render_frames(&mut state, ¬ebook, 10); + let bar = state.toolbar_rect.unwrap(); + let phys = |p: egui::Pos2| (p.x * scale, p.y * scale); + + for canvas_pt in [egui::pos2(600.0, 400.0), egui::pos2(1200.0, 800.0)] { + assert!( + !is_screen_point_in_ui(&ctx, &state, phys(canvas_pt), scale), + "Blattpunkt {canvas_pt:?} darf keine UI sein (Skalierung {scale})" + ); + } + assert!( + is_screen_point_in_ui(&ctx, &state, phys(bar.center()), scale), + "Leiste muss UI sein (Skalierung {scale})" + ); + } + } }