From 1a98c9b8bbb8e6d0cd713dc91e32dfa71a58e31b Mon Sep 17 00:00:00 2001 From: Harald Date: Sun, 18 Oct 2026 21:13:15 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Knopf=20f=C3=BCr=20das=20Seitenverh?= =?UTF-8?q?=C3=A4ltnis-Schloss=20der=20Auswahl=20(Issue=20#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Im Tablet-Betrieb gibt es keine Umschalttaste. Beim Auswahlwerkzeug steht unter den Stiftfarben jetzt ein Schloss: đź”’ skaliert gleichmäßig, 🔓 frei. Vorgabe ist gesperrt, sobald ein Bild ausgewählt ist, sonst frei. Das Umschalten gilt fĂĽr die aktuelle Auswahl; eine neue Auswahl beginnt wieder mit der Vorgabe. Shift sperrt am Rechner zusätzlich. Co-Authored-By: Claude Opus 5.5 --- src/i18n/en.rs | 2 ++ src/main.rs | 12 +++++++-- src/selection.rs | 66 +++++++++++++++++++++++++++++++++++++++++++++++- src/ui.rs | 58 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 3 deletions(-) diff --git a/src/i18n/en.rs b/src/i18n/en.rs index bb63c6f..ba0e2b1 100644 --- a/src/i18n/en.rs +++ b/src/i18n/en.rs @@ -286,6 +286,8 @@ pub const EN: &[(&str, &str)] = &[ ("Seite {number}", "Page {number}"), ("Seiten ({count})", "Pages ({count})"), ("Seiten durchgehend scrollen", "Scroll pages continuously"), + ("Seitenverhältnis frei – tippen zum Sperren", "Aspect ratio free – tap to lock"), + ("Seitenverhältnis gesperrt – tippen zum Freigeben", "Aspect ratio locked – tap to unlock"), ("Serialisierungsfehler: {e}", "Serialization error: {e}"), ("Server-Einstellungen gespeichert", "Server settings saved"), ("Server:", "Server:"), diff --git a/src/main.rs b/src/main.rs index cbf0d0a..05c8d2b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -651,8 +651,8 @@ fn selection_press( _, ) => { let initial_strokes = page.get_strokes(&selection_state.selected_ids); - // Bilder nicht verzerren (Issue #29); Shift hält das Verhältnis auch bei Strichen - let keep_aspect = shift || initial_strokes.iter().any(|s| s.is_image()); + // Schloss der Auswahl (Bilder: an, Issue #29); Shift sperrt zusätzlich + let keep_aspect = shift || selection_state.keeps_aspect(page); DragMode::Scale { fixed_corner_page, start_pos_page, @@ -3101,6 +3101,8 @@ fn main() -> Result<(), Box> { // 3. Toolbar & Widgets rendern updater.update(); ui_state.page_view = Some(page_view(&canvas, window.scale_factor() as f32)); + ui_state.selection_aspect_lock = (!selection_state.is_empty()) + .then(|| selection_state.keeps_aspect(notebook.current_page())); ui_state.restorable_page = last_deleted_page .as_ref() .filter(|(id, _, _)| *id == manifest.id) @@ -3201,6 +3203,12 @@ fn main() -> Result<(), Box> { update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } + UiAction::ToggleAspectLock => { + if !selection_state.is_empty() { + selection_state.toggle_aspect_lock(notebook.current_page()); + window.request_redraw(); + } + } UiAction::RotateSelected => { if !selection_state.is_empty() && selection_state.rotate_selected_90_deg(notebook.current_page_mut()) { diff --git a/src/selection.rs b/src/selection.rs index 24b8846..fa6585a 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -36,7 +36,7 @@ pub enum DragMode { start_pos_page: Vec2, initial_box_size: Vec2, initial_strokes: Vec, - /// Seitenverhältnis beibehalten (Bilder in der Auswahl oder Shift gedrĂĽckt) + /// Seitenverhältnis beibehalten (Schloss der Auswahl oder Shift gedrĂĽckt) keep_aspect: bool, }, } @@ -89,6 +89,9 @@ pub struct SelectionState { pub selected_ids: Vec, pub drag_mode: DragMode, pub clipboard: Vec, + /// Von Hand umgeschaltetes Seitenverhältnis-Schloss und die Auswahl (IDs), fĂĽr die es + /// gilt. Ă„ndert sich die Auswahl, gilt wieder die Vorgabe (siehe [`Self::keeps_aspect`]). + aspect_lock: Option<(Vec, bool)>, } impl SelectionState { @@ -97,9 +100,28 @@ impl SelectionState { selected_ids: Vec::new(), drag_mode: DragMode::None, clipboard: Vec::new(), + aspect_lock: None, } } + /// Ob Skalieren an den Eckgriffen das Seitenverhältnis der Auswahl beibehält. Vorgabe: + /// ja, sobald ein Bild ausgewählt ist (Issue #29); per Knopf fĂĽr diese Auswahl umschaltbar. + pub fn keeps_aspect(&self, page: &Page) -> bool { + match &self.aspect_lock { + Some((ids, locked)) if *ids == self.selected_ids => *locked, + _ => page + .get_strokes(&self.selected_ids) + .iter() + .any(|s| s.is_image()), + } + } + + /// Schaltet das Seitenverhältnis-Schloss der aktuellen Auswahl um. + pub fn toggle_aspect_lock(&mut self, page: &Page) { + let locked = !self.keeps_aspect(page); + self.aspect_lock = Some((self.selected_ids.clone(), locked)); + } + /// PrĂĽft, ob aktuell Objekte ausgewählt sind. pub fn is_empty(&self) -> bool { self.selected_ids.is_empty() @@ -577,6 +599,48 @@ mod tests { ); } + #[test] + fn test_aspect_lock_defaults_to_images_and_toggles_per_selection() { + let mut page = Page::new(1, PageBackground::Blank); + let corners = [ + Vec2::new(0.0, 0.0), + Vec2::new(100.0, 0.0), + Vec2::new(100.0, 50.0), + Vec2::new(0.0, 50.0), + ]; + page.add_stroke(Stroke::new_image(1, "a.png".into(), corners)); + page.add_stroke(make_test_stroke(2, 200.0, 200.0, [0.1, 0.1, 0.1, 1.0])); + let mut sel = SelectionState::new(); + + // Vorgabe: Bilder gesperrt, Striche frei + sel.select_single(1); + assert!(sel.keeps_aspect(&page)); + sel.select_single(2); + assert!(!sel.keeps_aspect(&page)); + + // Umschalten gilt fĂĽr diese Auswahl + sel.toggle_aspect_lock(&page); + assert!(sel.keeps_aspect(&page)); + sel.select_single(1); + sel.toggle_aspect_lock(&page); + assert!(!sel.keeps_aspect(&page)); + sel.toggle_aspect_lock(&page); + assert!(sel.keeps_aspect(&page)); + sel.toggle_aspect_lock(&page); + + // Neue Auswahl: wieder die Vorgabe + sel.select_multiple(vec![1, 2]); + assert!(sel.keeps_aspect(&page)); + sel.select_single(1); + assert!( + !sel.keeps_aspect(&page), + "gleiche Auswahl behält das Umschalten" + ); + sel.clear(); + sel.select_single(1); + assert!(!sel.keeps_aspect(&page)); + } + #[test] fn test_recolor_ignores_images() { let mut page = Page::new(1, PageBackground::Blank); diff --git a/src/ui.rs b/src/ui.rs index 9abd7c1..522f029 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -255,6 +255,8 @@ pub enum UiAction { /// Zwischenablage an dieser Stelle der aktuellen Seite einfĂĽgen (KontextmenĂĽ) PasteAt(glam::Vec2), RotateSelected, + /// Seitenverhältnis beim Skalieren der Auswahl sperren bzw. freigeben + ToggleAspectLock, // Dokumenten- & Notizbuch-Verwaltung OpenNotebook(String), /// Neues Notizbuch im Ordner `folder` anlegen und öffnen @@ -410,6 +412,8 @@ pub struct UiState { pub confirm_delete_page: bool, /// Seitenzahl der zuletzt gelöschten Seite, solange sie wiederhergestellt werden kann pub restorable_page: Option, + /// Seitenverhältnis-Schloss der Auswahl (`None`: nichts ausgewählt) + pub selection_aspect_lock: Option, /// Fehler beim Flushen vor dem Beenden pub shutdown_error: Option, /// Fensterseite der Werkzeugleiste @@ -508,6 +512,7 @@ impl Default for UiState { pressed_stylus_button: None, confirm_delete_page: false, restorable_page: None, + selection_aspect_lock: None, shutdown_error: None, sidebar_side: SidebarSide::Left, language: None, @@ -3045,6 +3050,30 @@ pub fn render_ui( 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 { @@ -5189,6 +5218,35 @@ mod tests { } } + #[test] + fn test_aspect_lock_button_only_with_selection() { + // Den Knopf in der Leiste des Auswahlwerkzeugs suchen: ohne Auswahl gesperrt + let notebook = Notebook::new(); + let toggles = |lock: Option| { + let mut probe = UiState::new(); + probe.active_tool = ActiveTool::Select; + render_frames(&mut probe, ¬ebook, 10); + let bar = probe.toolbar_rect.unwrap(); + let mut found = 0; + let mut y = bar.min.y + 4.0; + while y < bar.max.y { + let mut state = UiState::new(); + state.active_tool = ActiveTool::Select; + state.selection_aspect_lock = lock; + let ctx = render_frames(&mut state, ¬ebook, 10); + let actions = click_at(&ctx, &mut state, ¬ebook, egui::pos2(bar.center().x, y)); + if actions.contains(&UiAction::ToggleAspectLock) { + found += 1; + } + y += 4.0; + } + found + }; + assert_eq!(toggles(None), 0); + assert!(toggles(Some(true)) > 0); + assert!(toggles(Some(false)) > 0); + } + #[test] fn test_sidebar_fits_window_height_for_every_tool() { let notebook = Notebook::new();