From 1643303b4740c347c1a756b12e4ea22cf2004342 Mon Sep 17 00:00:00 2001 From: Harald Date: Wed, 21 Oct 2026 09:20:34 +0200 Subject: [PATCH] feat: view-only mode (Issue #35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 👁 button below the notebooks hides the tools. Pen, finger and mouse then only scroll and zoom (the pen glides on like a finger), editing shortcuts are ignored, Page Down no longer appends pages, and selection, text box and ruler are closed when entering the mode. The toolbar size estimate now includes the measured frame overhead and can shrink buttons to 26 px, so the extra button still fits at 768 px. Co-Authored-By: Claude Opus 5.5 --- README.de.md | 1 + README.md | 1 + src/i18n/en.rs | 3 + src/main.rs | 64 +++- src/ui.rs | 847 +++++++++++++++++++++++++++++-------------------- 5 files changed, 561 insertions(+), 355 deletions(-) diff --git a/README.de.md b/README.de.md index 90eea61..b4bb408 100644 --- a/README.de.md +++ b/README.de.md @@ -29,6 +29,7 @@ Die OberflĂ€che gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra - **Tasten am Stift** im ☰-MenĂŒ belegen: Radieren (Standard), AuswĂ€hlen, Textmarker, Stift oder nichts – gilt, solange die Taste gedrĂŒckt ist. Jede Taste erscheint dort nach dem ersten DrĂŒcken. - **Radierer** in drei Modi: ganze Striche, nur den ĂŒberstrichenen Teil (Teilstrich) oder nur Textmarker. Die StiftrĂŒckseite radiert ebenfalls. - **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade. +- **Nur ansehen** (👁 in der Werkzeugleiste): Stift, Finger und Maus scrollen und zoomen nur; die Werkzeuge verschwinden, nichts lĂ€sst sich versehentlich Ă€ndern. Erneut 👁 tippen, um zu schreiben. - **Platz schaffen** (↕ in der Werkzeugleiste): aufs Blatt tippen und nach unten ziehen – alles darunter rutscht mit, auch auf den folgenden Seiten. Was ĂŒber ein Seitenende rutschen wĂŒrde, beginnt auf der nĂ€chsten Seite; fehlende Seiten werden angehĂ€ngt. Nach oben ziehen schließt leeren Platz. Ein Schritt fĂŒr RĂŒckgĂ€ngig. ### Formerkennung diff --git a/README.md b/README.md index c99784f..c35a3e4 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ The interface is available in English and German; ink-notes follows the system l - **Pen buttons** can be assigned in the ☰ menu: erase (default), select, highlighter, pen or nothing – active while the button is held. Each button appears there after it is pressed for the first time. - **Eraser** with three modes: whole strokes, only the part you wipe over (partial stroke), or highlighter only. The back end of the pen erases too. - **Virtual ruler** (`L`): move it, rotate it by its handle (snaps to 15°); strokes along an edge become straight. +- **View only** (👁 in the toolbar): pen, finger and mouse only scroll and zoom; the tools disappear and nothing can be changed by accident. Tap 👁 again to write. - **Make space** (↕ in the toolbar): tap the page and drag down – everything below moves along, also on the following pages. Whatever would slide past the end of a page continues on the next one; missing pages are appended. Dragging up closes empty space. Undo reverts it in one step. ### Shape recognition diff --git a/src/i18n/en.rs b/src/i18n/en.rs index a1f7669..118fba0 100644 --- a/src/i18n/en.rs +++ b/src/i18n/en.rs @@ -211,6 +211,9 @@ pub const EN: &[(&str, &str)] = &[ ("Notizbuch", "Notebook"), ("NotizbĂŒcher verwalten (aktuell: {title})", "Manage notebooks (current: {title})"), ("Nur Bilder (PNG, JPEG, GIF, WebP, BMP) und PDFs können eingefĂŒgt werden", "Only images (PNG, JPEG, GIF, WebP, BMP) and PDFs can be inserted"), + ("Nur ansehen – tippen, um wieder zu schreiben", "View only – tap to write again"), + ("Nur ansehen – 👁 erneut tippen, um zu schreiben", "View only – tap 👁 again to write"), + ("Nur ansehen: Stift, Finger und Maus verschieben nur das Blatt, nichts wird verĂ€ndert", "View only: pen, finger and mouse just move the page, nothing gets changed"), ("Nur Textmarker radieren – Handschrift darunter bleibt", "Erase highlighter only – handwriting underneath stays"), ("NĂ€chste Seite (Bild ab)", "Next page (Page Down)"), ("Oktaeder (3D-Kantenmodell)", "Octahedron (3D wireframe)"), diff --git a/src/main.rs b/src/main.rs index 8d5ce40..a8d7f9f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1440,6 +1440,8 @@ fn main() -> Result<(), Box> { // Platz schaffen lĂ€uft mit dem Stift bzw. dem Finger (sonst mit der Maus) let mut is_stylus_spacing = false; let mut is_touch_spacing = false; + // View only: the pen moves the page and remembers its last screen position (Issue #35) + let mut stylus_pan_last: Option<(f32, f32)> = None; let mut stylus_screen_pos = (0.0_f32, 0.0_f32); // Stift hat auf der UI aufgesetzt: alle Ereignisse bis zum Abheben gehen an egui let mut stylus_ui_capture = false; @@ -1605,6 +1607,7 @@ fn main() -> Result<(), Box> { } if stylus_tool_id == Some(tool.id()) { stylus_tool_id = None; + stylus_pan_last = None; if is_stylus_drawing { is_stylus_drawing = false; is_drawing = false; @@ -1685,7 +1688,9 @@ fn main() -> Result<(), Box> { // Stiftbewegung an egui weitergeben (Hover, Schieberegler ziehen), // solange nicht auf dem Blatt gezeichnet oder radiert wird - if stylus_ui_capture || (!is_stylus_drawing && !is_erasing && !is_stylus_selecting) { + if stylus_ui_capture + || (!is_stylus_drawing && !is_erasing && !is_stylus_selecting && stylus_pan_last.is_none()) + { let ui_pos = egui::pos2(sx / scale, sy / scale); egui_winit_state .egui_input_mut() @@ -1701,7 +1706,13 @@ fn main() -> Result<(), Box> { continue; } - if is_stylus_spacing { + if let Some((lx, ly)) = stylus_pan_last { + let delta = Vec2::new(sx - lx, sy - ly); + stylus_pan_last = Some((sx, sy)); + canvas.pan(delta.x, delta.y); + fling.track(delta, Instant::now()); + window.request_redraw(); + } else if is_stylus_spacing { space_move((sx, sy), &canvas, &mut ui_state); window.request_redraw(); } else if is_erasing { @@ -1769,6 +1780,13 @@ fn main() -> Result<(), Box> { continue; } + if ui_state.view_mode { + // View only: the pen moves the page like a finger + fling.stop(); + stylus_pan_last = Some((sx, sy)); + continue; + } + focus_page_at!((sx, sy)); // Werkzeug-Taste gedrĂŒckt: fĂŒr diesen Strich dieses Werkzeug let button_action = stylus_held.map(|(_, action)| action); @@ -1856,6 +1874,11 @@ fn main() -> Result<(), Box> { continue; } + if stylus_pan_last.take().is_some() { + fling.release(Instant::now()); + window.request_redraw(); + } + if is_erasing { is_erasing = false; } @@ -2158,6 +2181,10 @@ fn main() -> Result<(), Box> { let ctrl = modifiers.control_key(); let shift = modifiers.shift_key(); + if ui_state.view_mode && !ui::key_allowed_in_view_mode(&logical_key, ctrl) { + return; + } + match &logical_key { Key::Named(NamedKey::Escape) => { let mut changed = false; @@ -2395,6 +2422,9 @@ fn main() -> Result<(), Box> { } // NĂ€chste Seite: PageDown oder N (mit Lazy Loading) + // View only: never append a new page at the end + Key::Named(NamedKey::PageDown) + if ui_state.view_mode && notebook.current_page_index + 1 >= notebook.pages.len() => {} Key::Named(NamedKey::PageDown) => { selection_state.clear(); notebook.next_page(); @@ -2544,7 +2574,7 @@ fn main() -> Result<(), Box> { ElementState::Pressed => { // Palm-Rejection: Keine Mausevents verarbeiten, wenn der Stift aktiv // zeichnet oder auswĂ€hlt (manche Systeme melden ihn zusĂ€tzlich als Maus) - if is_stylus_drawing || is_stylus_selecting { + if is_stylus_drawing || is_stylus_selecting || stylus_pan_last.is_some() { return; } @@ -2555,7 +2585,11 @@ fn main() -> Result<(), Box> { fling.stop(); // 1. Pan via mittlere Maustaste oder Leertaste + Linksklick - if button == MouseButton::Middle || (button == MouseButton::Left && is_space_pressed) { + // In view-only mode the left and right buttons move the page too (Issue #35) + if button == MouseButton::Middle + || (button == MouseButton::Left && is_space_pressed) + || (ui_state.view_mode && matches!(button, MouseButton::Left | MouseButton::Right)) + { is_panning = true; pan_last_screen_pos = cursor_screen_pos; return; @@ -2638,7 +2672,9 @@ fn main() -> Result<(), Box> { } } ElementState::Released => { - if button == MouseButton::Middle || (button == MouseButton::Left && is_panning) { + if button == MouseButton::Middle + || (matches!(button, MouseButton::Left | MouseButton::Right) && is_panning) + { is_panning = false; } if is_erasing { @@ -2842,6 +2878,7 @@ fn main() -> Result<(), Box> { // beim Radieren, AuswĂ€hlen oder Text (dort hĂ€lt man auch mal still) if active_touches.len() == 1 && !is_pinch_gesturing + && !ui_state.view_mode && (!ui_state.touch_draw_enabled || ui_state.active_tool.is_drawing() || ui_state.active_tool == ActiveTool::Shape) @@ -2882,6 +2919,7 @@ fn main() -> Result<(), Box> { // Finger immer, AuswĂ€hlen, wenn er ein Objekt oder die Auswahl trifft // (Issue #26) let finger_uses_tool = match ui_state.active_tool { + _ if ui_state.view_mode => false, ActiveTool::Text | ActiveTool::Space => true, ActiveTool::Select => { focus_page_at!((tx, ty)); @@ -2899,7 +2937,7 @@ fn main() -> Result<(), Box> { // Halten soll hier kein KontextmenĂŒ öffnen long_press.cancel(); } - if !ui_state.touch_draw_enabled && !finger_uses_tool { + if (!ui_state.touch_draw_enabled || ui_state.view_mode) && !finger_uses_tool { // Standard: 1 Finger scrollt / verschiebt den Canvas (Pan) is_touch_panning = true; touch_pan_last_pos = (tx, ty); @@ -3186,6 +3224,20 @@ fn main() -> Result<(), Box> { update_window_title(&window, ¬ebook, &manifest, tool, autosave.status()); window.request_redraw(); } + UiAction::SetViewMode(on) => { + ui_state.view_mode = on; + if on { + // Nothing may stay half-edited while only viewing + finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state); + selection_state.clear(); + ui_state.ruler = None; + ui_state.ruler_drag = None; + canvas.ruler = None; + ui_state.set_export_message(t!("Nur ansehen – 👁 erneut tippen, um zu schreiben")); + } + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } UiAction::SetColor(color) => { if !selection_state.is_empty() && selection_state.recolor_selected(notebook.current_page_mut(), color) { diff --git a/src/ui.rs b/src/ui.rs index 1a03656..dbc0859 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -231,6 +231,8 @@ pub enum PageExportKind { #[derive(Debug, Clone, PartialEq)] pub enum UiAction { SetTool(ActiveTool), + /// Switch the view-only mode on or off (Issue #35) + SetViewMode(bool), SetColor([f32; 4]), SetStrokeWidthMm(f32), Undo, @@ -441,6 +443,8 @@ pub struct UiState { pub continuous_scroll: bool, /// Offenes Notizbuch ist eine endlose FlĂ€che (eine Seite ohne Rand, Issue #30) pub endless: bool, + /// View only: pen, finger and mouse just move the page, nothing gets changed (Issue #35) + pub view_mode: bool, /// Wechsel zur bzw. von der endlosen FlĂ€che ist gerade möglich (eine Seite ohne PDF bzw. /// Inhalt im Startausschnitt) pub endless_switch_possible: bool, @@ -538,6 +542,7 @@ impl Default for UiState { pattern_color: PatternColor::Light, continuous_scroll: true, endless: false, + view_mode: false, endless_switch_possible: true, text_color: PEN_COLOR_PRESETS[0].0, text_size_pt: crate::text::DEFAULT_TEXT_SIZE_PT, @@ -645,22 +650,23 @@ impl UiState { /// BlĂ€tterpfeile). pub fn sidebar_button_size(tool: ActiveTool, max_height: f32, continuous: bool) -> f32 { let select = matches!(tool, ActiveTool::Select | ActiveTool::Text); - // NotizbĂŒcher, 7 Werkzeuge, Lineal, Undo/Redo, Seitenzahl und neue Seite (einzeln - // blĂ€ttern: dazu zwei Pfeile), MenĂŒ (+ 4 Auswahl-Aktionen bzw. Schriftknöpfe) + // NotizbĂŒcher, Nur ansehen, 7 Werkzeuge, Lineal, Undo/Redo, Seitenzahl und neue Seite + // (einzeln blĂ€ttern: dazu zwei Pfeile), MenĂŒ (+ 4 Auswahl-Aktionen bzw. Schriftknöpfe) let page_buttons = if continuous { 2 } else { 4 }; - let buttons = 11 + page_buttons + if select { 4 } else { 0 }; + let buttons = 12 + page_buttons + if select { 4 } else { 0 }; let dividers = 5 + usize::from(select); let spacing = 3.0; let needed = |b: f32| { buttons as f32 * (b + spacing) + Self::MAX_PALETTE_LEN as f32 * (Self::sidebar_swatch_size(b) + spacing) + dividers as f32 * (7.0 + spacing) - + 12.0 + // frame, margins and the spacing egui adds (measured with the selection tool) + + 40.0 }; - [Self::SIDEBAR_BUTTON, 36.0, 32.0, 30.0, 28.0] + [Self::SIDEBAR_BUTTON, 36.0, 32.0, 30.0, 28.0, 26.0] .into_iter() .find(|&b| needed(b) <= max_height) - .unwrap_or(28.0) + .unwrap_or(26.0) } /// Von der Werkzeugleiste belegte Breite (logische Punkte, inkl. Randabstand), @@ -2903,6 +2909,29 @@ pub fn configure_context(ctx: &egui::Context) { ctx.options_mut(|o| o.zoom_with_keyboard = false); } +/// Keys that still work in view-only mode: moving through the notebook, zoom, saving, +/// exporting and quitting. Everything that would change the notebook is ignored (Issue #35). +pub fn key_allowed_in_view_mode(key: &winit::keyboard::Key, ctrl: bool) -> bool { + use winit::keyboard::{Key, NamedKey}; + match key { + Key::Named(named) => matches!( + named, + NamedKey::Escape + | NamedKey::Space + | NamedKey::PageUp + | NamedKey::PageDown + | NamedKey::Home + | NamedKey::End + ), + Key::Character(c) => { + ctrl && ["0", "+", "=", "-", "_", "s", "e", "q"] + .iter() + .any(|k| c.eq_ignore_ascii_case(k)) + } + _ => false, + } +} + /// 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. @@ -3013,387 +3042,409 @@ pub fn render_ui( } } - sidebar_divider(ui, btn); - - // --- Werkzeuge --- - let pen_resp = sidebar_button( - ui, - btn, - SidebarIcon::Glyph("✏"), - state.active_tool == ActiveTool::Pen, - true, - Some(state.pen_color), - if state.is_pen_menu_open { - "" - } else { - t!("Stift (B/U/R fĂŒr Farben) – erneut tippen fĂŒr Farbe & StĂ€rke") - }, - ); - pen_btn_rect = Some(pen_resp.rect); - if pen_resp.clicked() { - state.is_highlighter_menu_open = false; - state.is_more_menu_open = false; - if state.active_tool != ActiveTool::Pen { - state.active_tool = ActiveTool::Pen; - state.is_pen_menu_open = false; - actions.push(UiAction::SetTool(ActiveTool::Pen)); - actions.push(UiAction::SetColor(state.pen_color)); - actions.push(UiAction::SetStrokeWidthMm( - state.pen_stroke_width_mm, - )); - } else { - let open = !state.is_pen_menu_open; - state.close_overlays(); - state.is_pen_menu_open = open; - } - } - - let high_resp = sidebar_button( - ui, - btn, - SidebarIcon::Glyph("🖊"), - state.active_tool == ActiveTool::Highlighter, - true, - Some(state.highlighter_color), - if state.is_highlighter_menu_open { - "" - } else { - t!("Textmarker – erneut tippen fĂŒr Farbe & StĂ€rke") - }, - ); - high_btn_rect = Some(high_resp.rect); - if high_resp.clicked() { - state.is_pen_menu_open = false; - state.is_more_menu_open = false; - if state.active_tool != ActiveTool::Highlighter { - state.active_tool = ActiveTool::Highlighter; - state.is_highlighter_menu_open = false; - actions.push(UiAction::SetTool(ActiveTool::Highlighter)); - actions.push(UiAction::SetColor(state.highlighter_color)); - actions.push(UiAction::SetStrokeWidthMm( - state.highlighter_stroke_width_mm, - )); - } else { - let open = !state.is_highlighter_menu_open; - state.close_overlays(); - state.is_highlighter_menu_open = open; - } - } - - for (tool, icon, tooltip) in [ - ( - ActiveTool::Shape, - SidebarIcon::Glyph("📐"), - t!("Formen: Striche werden zu Linien, Rechtecken, Kreisen 
 (S/F)"), - ), - ( - ActiveTool::Eraser, - SidebarIcon::Eraser, - t!("Radierer (E oder rechte Maustaste) – darunter: ganze Striche, Teilstrich oder nur Textmarker"), - ), - ( - ActiveTool::Select, - SidebarIcon::Lasso, - t!("AuswĂ€hlen: verschieben, drehen, skalieren, kopieren (V)"), - ), - ( - ActiveTool::Space, - SidebarIcon::Glyph("↕"), - t!("Platz schaffen: auf das Blatt tippen und nach unten ziehen – alles \ - darunter rutscht mit, auch auf die folgenden Seiten; nach oben \ - schließt leeren Platz"), - ), - ( - ActiveTool::Text, - SidebarIcon::Glyph("T"), - t!("Text (T): ins Blatt tippen und schreiben, vorhandenen Text \ - antippen zum Ändern; Esc beendet"), - ), - ] { - if sidebar_button( - ui, - btn, - icon, - state.active_tool == tool, - true, - None, - tooltip, - ) - .clicked() - { - state.active_tool = tool; - state.is_pen_menu_open = false; - state.is_highlighter_menu_open = false; - state.is_more_menu_open = false; - actions.push(UiAction::SetTool(tool)); - } - } - - // --- Lineal (ein-/ausblenden) --- + // --- View only (Issue #35) --- if sidebar_button( ui, btn, - SidebarIcon::Ruler, - state.ruler.is_some(), + SidebarIcon::Glyph("👁"), + state.view_mode, true, None, - t!("Lineal (L): FlĂ€che ziehen = verschieben, Griff ⟳ = drehen; \ - Striche an einer Kante werden gerade"), + if state.view_mode { + t!("Nur ansehen – tippen, um wieder zu schreiben") + } else { + t!("Nur ansehen: Stift, Finger und Maus verschieben nur das Blatt, nichts wird verĂ€ndert") + }, ) .clicked() { - state.toggle_ruler(); + state.close_overlays(); + actions.push(UiAction::SetViewMode(!state.view_mode)); } - // --- Stifte bzw. Farben des aktiven Werkzeugs --- - let pens = matches!(state.active_tool, ActiveTool::Pen | ActiveTool::Shape); - // 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 = 0; - if pens { - shown = PEN_PRESET_COUNT; - for i in 0..PEN_PRESET_COUNT { - let preset = state.pen_presets[i]; - let selected = i == state.active_pen; - let tooltip = if selected { - t!( - "Stift {number} ({width} mm) – erneut tippen fĂŒr Farbe & StĂ€rke; \ - Änderungen merkt sich der Stift", - number = i + 1, - width = format!("{:.2}", preset.width_mm) - ) + + if !state.view_mode { + // --- Werkzeuge --- + let pen_resp = sidebar_button( + ui, + btn, + SidebarIcon::Glyph("✏"), + state.active_tool == ActiveTool::Pen, + true, + Some(state.pen_color), + if state.is_pen_menu_open { + "" } else { - t!("Stift {number} ({width} mm)", number = i + 1, width = format!("{:.2}", preset.width_mm)) - }; - let response = - pen_preset_swatch(ui, preset, selected, &tooltip, swatch); - if selected { - // Die Einstellungen erscheinen neben dem gewĂ€hlten Stift - pen_btn_rect = Some(response.rect); + t!("Stift (B/U/R fĂŒr Farben) – erneut tippen fĂŒr Farbe & StĂ€rke") + }, + ); + pen_btn_rect = Some(pen_resp.rect); + if pen_resp.clicked() { + state.is_highlighter_menu_open = false; + state.is_more_menu_open = false; + if state.active_tool != ActiveTool::Pen { + state.active_tool = ActiveTool::Pen; + state.is_pen_menu_open = false; + actions.push(UiAction::SetTool(ActiveTool::Pen)); + actions.push(UiAction::SetColor(state.pen_color)); + actions.push(UiAction::SetStrokeWidthMm( + state.pen_stroke_width_mm, + )); + } else { + let open = !state.is_pen_menu_open; + state.close_overlays(); + state.is_pen_menu_open = open; } - if response.clicked() { + } + + let high_resp = sidebar_button( + ui, + btn, + SidebarIcon::Glyph("🖊"), + state.active_tool == ActiveTool::Highlighter, + true, + Some(state.highlighter_color), + if state.is_highlighter_menu_open { + "" + } else { + t!("Textmarker – erneut tippen fĂŒr Farbe & StĂ€rke") + }, + ); + high_btn_rect = Some(high_resp.rect); + if high_resp.clicked() { + state.is_pen_menu_open = false; + state.is_more_menu_open = false; + if state.active_tool != ActiveTool::Highlighter { + state.active_tool = ActiveTool::Highlighter; state.is_highlighter_menu_open = false; - state.is_more_menu_open = false; - if selected { - let open = !state.is_pen_menu_open; - state.close_overlays(); - state.is_pen_menu_open = open; - } else { - state.is_pen_menu_open = false; - state.select_pen(i); - actions.push(UiAction::SetColor(state.pen_color)); - actions.push(UiAction::SetStrokeWidthMm( - state.pen_stroke_width_mm, - )); - } + actions.push(UiAction::SetTool(ActiveTool::Highlighter)); + actions.push(UiAction::SetColor(state.highlighter_color)); + actions.push(UiAction::SetStrokeWidthMm( + state.highlighter_stroke_width_mm, + )); + } else { + let open = !state.is_highlighter_menu_open; + state.close_overlays(); + state.is_highlighter_menu_open = open; } } - } - // 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 - } - _ => false, - }; - let tooltip = match tool { - ActiveTool::Select => { - t!("Auswahl in der Farbe von Stift {number} umfĂ€rben", number = i + 1) - } - _ if selected => t!( - "Farbe {number} – erneut tippen, um sie anzupassen", - number = i + 1 - ), - _ => t!("Farbe {number}", number = 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)), - } - } - } - 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 [ + + for (tool, icon, tooltip) in [ ( - EraserMode::All, - SidebarIcon::Text(t!("Alle")), - None, - t!("Alle Striche radieren"), + ActiveTool::Shape, + SidebarIcon::Glyph("📐"), + t!("Formen: Striche werden zu Linien, Rechtecken, Kreisen 
 (S/F)"), ), ( - EraserMode::Partial, - SidebarIcon::Glyph("✂"), - None, - t!("Teilstrich radieren – nur den ĂŒberstrichenen Teil entfernen (Formen ganz)"), + ActiveTool::Eraser, + SidebarIcon::Eraser, + t!("Radierer (E oder rechte Maustaste) – darunter: ganze Striche, Teilstrich oder nur Textmarker"), ), ( - EraserMode::HighlighterOnly, - SidebarIcon::Glyph("🖊"), - Some(state.highlighter_color), - t!("Nur Textmarker radieren – Handschrift darunter bleibt"), + ActiveTool::Select, + SidebarIcon::Lasso, + t!("AuswĂ€hlen: verschieben, drehen, skalieren, kopieren (V)"), + ), + ( + ActiveTool::Space, + SidebarIcon::Glyph("↕"), + t!("Platz schaffen: auf das Blatt tippen und nach unten ziehen – alles \ + darunter rutscht mit, auch auf die folgenden Seiten; nach oben \ + schließt leeren Platz"), + ), + ( + ActiveTool::Text, + SidebarIcon::Glyph("T"), + t!("Text (T): ins Blatt tippen und schreiben, vorhandenen Text \ + antippen zum Ändern; Esc beendet"), ), ] { if sidebar_button( ui, btn, icon, - state.eraser_mode == mode, - true, - indicator, - tooltip, - ) - .clicked() - { - state.eraser_mode = mode; - } - used += btn + spacing; - } - } - // Auswahl: unter den 4 Stiftfarben ist Platz fĂŒr das Schloss des - // SeitenverhĂ€ltnisses beim Skalieren (Bilder: gesperrt, Issue #29) - if state.active_tool == ActiveTool::Select { - let locked = state.selection_aspect_lock; - let (icon, tooltip) = if locked == Some(true) { - ("🔒", t!("SeitenverhĂ€ltnis gesperrt – tippen zum Freigeben")) - } else { - ("🔓", t!("SeitenverhĂ€ltnis frei – tippen zum Sperren")) - }; - if sidebar_button( - ui, - swatch, - SidebarIcon::Glyph(icon), - locked == Some(true), - locked.is_some(), - None, - tooltip, - ) - .clicked() - { - actions.push(UiAction::ToggleAspectLock); - } - used += swatch + spacing; - } - let reserved = - UiState::MAX_PALETTE_LEN as f32 * (swatch + spacing); - if reserved > used { - ui.add_space(reserved - used); - } - - // --- Aktionen fĂŒr die Auswahl --- - if state.active_tool == ActiveTool::Select { - sidebar_divider(ui, btn); - for (icon, tooltip, action) in [ - ("📋", t!("Kopieren (Strg+C)"), UiAction::CopySelected), - ("đŸ“„", t!("EinfĂŒgen (Strg+V)"), UiAction::PasteSelected), - ("🔄", t!("Um 90° drehen (R)"), UiAction::RotateSelected), - ("🗑", t!("Löschen (Entf)"), UiAction::DeleteSelected), - ] { - if sidebar_button( - ui, - btn, - SidebarIcon::Glyph(icon), - false, + state.active_tool == tool, true, None, tooltip, ) .clicked() { - actions.push(action); + state.active_tool = tool; + state.is_pen_menu_open = false; + state.is_highlighter_menu_open = false; + state.is_more_menu_open = false; + actions.push(UiAction::SetTool(tool)); } } - } - // --- Schrift des Text-Werkzeugs --- - if state.active_tool == ActiveTool::Text { + // --- Lineal (ein-/ausblenden) --- + if sidebar_button( + ui, + btn, + SidebarIcon::Ruler, + state.ruler.is_some(), + true, + None, + t!("Lineal (L): FlĂ€che ziehen = verschieben, Griff ⟳ = drehen; \ + Striche an einer Kante werden gerade"), + ) + .clicked() + { + state.toggle_ruler(); + } + + // --- Stifte bzw. Farben des aktiven Werkzeugs --- + let pens = matches!(state.active_tool, ActiveTool::Pen | ActiveTool::Shape); + // Fester Platz fĂŒr die lĂ€ngste Palette, damit die Knöpfe darunter + // beim Werkzeugwechsel nicht springen sidebar_divider(ui, btn); - text_style_buttons(ui, btn, state); - } + let swatch = UiState::sidebar_swatch_size(btn); + let mut shown = 0; + if pens { + shown = PEN_PRESET_COUNT; + for i in 0..PEN_PRESET_COUNT { + let preset = state.pen_presets[i]; + let selected = i == state.active_pen; + let tooltip = if selected { + t!( + "Stift {number} ({width} mm) – erneut tippen fĂŒr Farbe & StĂ€rke; \ + Änderungen merkt sich der Stift", + number = i + 1, + width = format!("{:.2}", preset.width_mm) + ) + } else { + t!("Stift {number} ({width} mm)", number = i + 1, width = format!("{:.2}", preset.width_mm)) + }; + let response = + pen_preset_swatch(ui, preset, selected, &tooltip, swatch); + if selected { + // Die Einstellungen erscheinen neben dem gewĂ€hlten Stift + pen_btn_rect = Some(response.rect); + } + if response.clicked() { + state.is_highlighter_menu_open = false; + state.is_more_menu_open = false; + if selected { + let open = !state.is_pen_menu_open; + state.close_overlays(); + state.is_pen_menu_open = open; + } else { + state.is_pen_menu_open = false; + state.select_pen(i); + actions.push(UiAction::SetColor(state.pen_color)); + actions.push(UiAction::SetStrokeWidthMm( + state.pen_stroke_width_mm, + )); + } + } + } + } + // 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 + } + _ => false, + }; + let tooltip = match tool { + ActiveTool::Select => { + t!("Auswahl in der Farbe von Stift {number} umfĂ€rben", number = i + 1) + } + _ if selected => t!( + "Farbe {number} – erneut tippen, um sie anzupassen", + number = i + 1 + ), + _ => t!("Farbe {number}", number = 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)), + } + } + } + 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(t!("Alle")), + None, + t!("Alle Striche radieren"), + ), + ( + EraserMode::Partial, + SidebarIcon::Glyph("✂"), + None, + t!("Teilstrich radieren – nur den ĂŒberstrichenen Teil entfernen (Formen ganz)"), + ), + ( + EraserMode::HighlighterOnly, + SidebarIcon::Glyph("🖊"), + Some(state.highlighter_color), + t!("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; + } + } + // Auswahl: unter den 4 Stiftfarben ist Platz fĂŒr das Schloss des + // SeitenverhĂ€ltnisses beim Skalieren (Bilder: gesperrt, Issue #29) + if state.active_tool == ActiveTool::Select { + let locked = state.selection_aspect_lock; + let (icon, tooltip) = if locked == Some(true) { + ("🔒", t!("SeitenverhĂ€ltnis gesperrt – tippen zum Freigeben")) + } else { + ("🔓", t!("SeitenverhĂ€ltnis frei – tippen zum Sperren")) + }; + if sidebar_button( + ui, + swatch, + SidebarIcon::Glyph(icon), + locked == Some(true), + locked.is_some(), + None, + tooltip, + ) + .clicked() + { + actions.push(UiAction::ToggleAspectLock); + } + used += swatch + spacing; + } + let reserved = + UiState::MAX_PALETTE_LEN as f32 * (swatch + spacing); + if reserved > used { + ui.add_space(reserved - used); + } - sidebar_divider(ui, btn); + // --- Aktionen fĂŒr die Auswahl --- + if state.active_tool == ActiveTool::Select { + sidebar_divider(ui, btn); + for (icon, tooltip, action) in [ + ("📋", t!("Kopieren (Strg+C)"), UiAction::CopySelected), + ("đŸ“„", t!("EinfĂŒgen (Strg+V)"), UiAction::PasteSelected), + ("🔄", t!("Um 90° drehen (R)"), UiAction::RotateSelected), + ("🗑", t!("Löschen (Entf)"), UiAction::DeleteSelected), + ] { + if sidebar_button( + ui, + btn, + SidebarIcon::Glyph(icon), + false, + true, + None, + tooltip, + ) + .clicked() + { + actions.push(action); + } + } + } - // --- Undo & Redo --- - let history = ¬ebook.current_page().history; - if sidebar_button( - ui, - btn, - SidebarIcon::Glyph("âŸČ"), - false, - history.can_undo(), - None, - t!("RĂŒckgĂ€ngig (Strg+Z)"), - ) - .clicked() - { - actions.push(UiAction::Undo); - } - if sidebar_button( - ui, - btn, - SidebarIcon::Glyph("⟳"), - false, - history.can_redo(), - None, - t!("Wiederholen (Strg+Y)"), - ) - .clicked() - { - actions.push(UiAction::Redo); - } + // --- Schrift des Text-Werkzeugs --- + if state.active_tool == ActiveTool::Text { + sidebar_divider(ui, btn); + text_style_buttons(ui, btn, state); + } + sidebar_divider(ui, btn); + + // --- Undo & Redo --- + let history = ¬ebook.current_page().history; + if sidebar_button( + ui, + btn, + SidebarIcon::Glyph("âŸČ"), + false, + history.can_undo(), + None, + t!("RĂŒckgĂ€ngig (Strg+Z)"), + ) + .clicked() + { + actions.push(UiAction::Undo); + } + if sidebar_button( + ui, + btn, + SidebarIcon::Glyph("⟳"), + false, + history.can_redo(), + None, + t!("Wiederholen (Strg+Y)"), + ) + .clicked() + { + actions.push(UiAction::Redo); + } + + } sidebar_divider(ui, btn); // --- Seitennavigation --- @@ -3448,7 +3499,8 @@ pub fn render_ui( { actions.push(UiAction::NextPage); } - if sidebar_button( + if !state.view_mode + && sidebar_button( ui, btn, SidebarIcon::Glyph("➕"), @@ -5583,6 +5635,103 @@ mod tests { } } + /// Taps down the middle of the toolbar and collects every action it produces. + fn toolbar_scan(view_mode: bool, notebook: &Notebook) -> Vec { + let mut probe = UiState::new(); + probe.view_mode = view_mode; + render_frames(&mut probe, notebook, 10); + let bar = probe.toolbar_rect.unwrap(); + let updater = crate::updater::UpdaterController::new(); + let screen = Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1280.0, 850.0), + )); + let mut actions = Vec::new(); + let mut y = bar.min.y + 4.0; + while y < bar.max.y { + let mut state = UiState::new(); + state.view_mode = view_mode; + let ctx = render_frames(&mut state, notebook, 10); + let pos = egui::pos2(bar.center().x, y); + for pressed in [true, false] { + let mut events = vec![egui::Event::PointerMoved(pos)]; + events.push(egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::default(), + }); + ctx.begin_frame(egui::RawInput { + screen_rect: screen, + events, + ..Default::default() + }); + actions.extend(render_ui( + &ctx, + &mut state, + notebook, + AutosaveStatus::Saved, + &updater, + )); + let _ = ctx.end_frame(); + } + y += 6.0; + } + actions + } + + #[test] + fn test_view_mode_hides_editing_buttons() { + let notebook = Notebook::new(); + let editing = toolbar_scan(false, ¬ebook); + assert!( + editing.contains(&UiAction::SetViewMode(true)), + "{editing:?}" + ); + assert!(editing.iter().any(|a| matches!(a, UiAction::SetTool(_)))); + assert!(editing.contains(&UiAction::AddPage)); + + let viewing = toolbar_scan(true, ¬ebook); + assert!( + viewing.contains(&UiAction::SetViewMode(false)), + "{viewing:?}" + ); + assert!( + !viewing.iter().any(|a| matches!( + a, + UiAction::SetTool(_) | UiAction::AddPage | UiAction::Undo | UiAction::Redo + )), + "{viewing:?}" + ); + } + + #[test] + fn test_view_mode_keeps_only_harmless_keys() { + use winit::keyboard::{Key, NamedKey, SmolStr}; + let ch = |c: &str| Key::Character(SmolStr::new(c)); + for (key, ctrl) in [ + (Key::Named(NamedKey::PageDown), false), + (Key::Named(NamedKey::Escape), false), + (ch("+"), true), + (ch("0"), true), + (ch("S"), true), + ] { + assert!(key_allowed_in_view_mode(&key, ctrl), "{key:?}"); + } + for (key, ctrl) in [ + (Key::Named(NamedKey::Delete), false), + (Key::Named(NamedKey::Backspace), false), + (ch("z"), true), + (ch("v"), true), + (ch("e"), false), + (ch("g"), false), + (ch("n"), false), + (ch("l"), false), + ] { + assert!(!key_allowed_in_view_mode(&key, ctrl), "{key:?}"); + } + } + #[test] fn test_aspect_lock_button_only_with_selection() { // Den Knopf in der Leiste des Auswahlwerkzeugs suchen: ohne Auswahl gesperrt @@ -5642,7 +5791,7 @@ mod tests { } // Große Fenster behalten die volle KnopfgrĂ¶ĂŸe assert_eq!( - UiState::sidebar_button_size(ActiveTool::Select, 1000.0, true), + UiState::sidebar_button_size(ActiveTool::Select, 1100.0, true), 40.0 ); }