From 7635b38c3634800e0754bea93c122d8f68f51c0a Mon Sep 17 00:00:00 2001 From: mahmatias Date: Sun, 18 Oct 2026 11:56:27 -0300 Subject: [PATCH] feat(guided): select and delete one Guided Upright guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A click near a guide selects it (white, thicker, larger endpoints); drawing a new guide or clicking away clears the selection, and so does leaving the photo or the tool. Delete / Backspace with the tool open removes only the selected guide (geometry.guides minus its index, one undo step); with none selected it removes nothing — the photo itself is never deleted while Guided Upright is open. selected_guide is UI-only (serde skip). Coverage: tests_guided_guides 5/5. --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/detail.rs | 67 +++++++++--- crates/ui-egui/src/shortcuts.rs | 15 +++ crates/ui-egui/src/state.rs | 5 + crates/ui-egui/src/tests_guided_guides.rs | 125 ++++++++++++++++++++++ 5 files changed, 202 insertions(+), 12 deletions(-) create mode 100644 crates/ui-egui/src/tests_guided_guides.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a08fb6151..b7e778543 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -99,6 +99,8 @@ mod tests_grading; #[cfg(test)] mod tests_grid; #[cfg(test)] +mod tests_guided_guides; +#[cfg(test)] mod tests_info_dialogs; #[cfg(test)] mod tests_keymap; diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index b3f0ff386..2dae69630 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1535,8 +1535,12 @@ fn general_interaction( // ------------------------------------------------------------------------ crop fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, frame: &Frame, d: &DevelopSettings, id: PhotoId) { + // the selected guide belongs to this photo and to the Guided Upright tool + if app.ui.tool != "guidedUpright" || app.ui.selected_guide.is_some_and(|(photo, _)| photo != id.0) { + app.ui.selected_guide = None; + } if app.ui.tool == "guidedUpright" { - guided_overlay(app, ui, resp, map, frame, d); + guided_overlay(app, ui, resp, map, frame, d, id); return; } if app.ui.tool == "straighten" { @@ -1691,7 +1695,6 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons // the canvas (the loupe's own rect), not the photo: rotating happens in the margin around it angle_readout(ui, at, shown_angle, resp.rect); } - let _ = id; } /// The crop handles' names for screen readers, in `crop.drag`'s numbering: 0..3 corners, 4..7 @@ -1830,30 +1833,62 @@ fn angle_readout(ui: &egui::Ui, at: Pos2, angle: f64, bounds: Rect) { /// Guided Upright: draw up to four guides along lines that should be vertical or horizontal. Guides are /// stored in lens-corrected (pre-perspective) coordinates, so they stay attached to the image as it warps. -fn guided_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, frame: &Frame, d: &DevelopSettings) { +fn guided_overlay( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + resp: &egui::Response, + map: &CanvasMap, + frame: &Frame, + d: &DevelopSettings, + id: PhotoId, +) { let p = ui.painter_at(map.rect.expand(8.0)); let col = Color32::from_rgb(255, 196, 40); - let draw = |a: Pos2, b: Pos2| { - p.line_segment([a, b], Stroke::new(3.0, Color32::from_black_alpha(120))); - p.line_segment([a, b], Stroke::new(1.5, col)); + // the selected guide: brighter, so the Delete key has a visible target + let selected = app.ui.selected_guide.filter(|(photo, _)| *photo == id.0).map(|(_, i)| i); + let draw = |a: Pos2, b: Pos2, sel: bool| { + let (edge, core, at) = if sel { (3.5, 2.5, 5.0) } else { (3.0, 1.5, 4.0) }; + p.line_segment([a, b], Stroke::new(edge, Color32::from_black_alpha(120))); + p.line_segment([a, b], Stroke::new(core, if sel { Color32::WHITE } else { col })); for q in [a, b] { - p.circle_filled(q, 4.0, col); - p.circle_stroke(q, 4.0, Stroke::new(1.0, Color32::BLACK)); + p.circle_filled(q, at, if sel { Color32::WHITE } else { col }); + p.circle_stroke(q, at, Stroke::new(1.0, Color32::BLACK)); } }; - for (i, (a, b)) in d.geometry.guides.iter().enumerate() { - let (sa, sb) = (map.screen(frame.corrected_to_transformed(*a)), map.screen(frame.corrected_to_transformed(*b))); + let on_screen = |guide: &(Point, Point)| { + let (sa, sb) = (map.screen(frame.corrected_to_transformed(guide.0)), map.screen(frame.corrected_to_transformed(guide.1))); + (sa, sb) + }; + for (i, guide) in d.geometry.guides.iter().enumerate() { + let (sa, sb) = on_screen(guide); register(ui.ctx(), format!("uprightGuide:{i}"), Rect::from_two_pos(sa, sb)); - draw(sa, sb); + draw(sa, sb, selected == Some(i)); } ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + // a click near a guide selects it (Delete / Backspace removes it alone); a click away clears + if resp.clicked() + && let Some(q) = resp.interact_pointer_pos() + { + let hit = d.geometry.guides.iter().position(|guide| { + let (a, b) = on_screen(guide); + point_segment_dist(q, a, b) <= 6.0 + }); + app.ui.selected_guide = match hit { + Some(i) if selected != Some(i) => Some((id.0, i)), + _ => None, + }; + } + if resp.drag_started() { + // drawing a new guide leaves nothing selected + app.ui.selected_guide = None; + } if resp.drag_started() && let Some(q) = resp.interact_pointer_pos() { app.gesture = Some(Gesture::Guide { a: map.norm(q) }); } if let (Some(Gesture::Guide { a }), Some(q)) = (&app.gesture, resp.interact_pointer_pos()) { - draw(map.screen(*a), q); + draw(map.screen(*a), q, false); } if resp.drag_stopped() && let (Some(Gesture::Guide { a }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos()) @@ -1875,6 +1910,14 @@ fn to_straight(n: Point, angle: f64, frame: &Frame) -> Point { Point::new(r.x / w, r.y / h) } + +/// How far `q` is from the segment `a`–`b` (screen pixels): the click that selects a guide. +fn point_segment_dist(q: Pos2, a: Pos2, b: Pos2) -> f32 { + let ab = b - a; + let t = if ab.length_sq() > 0.0 { ((q - a).dot(ab) / ab.length_sq()).clamp(0.0, 1.0) } else { 0.0 }; + (a + ab * t).distance(q) +} + /// A crop box's corners (top-left, top-right, bottom-right, bottom-left of the unflipped photo) /// in the order they appear on screen when the photo is shown flipped. fn flipped_corners(pts: &[Pos2], flip_h: bool, flip_v: bool) -> [Pos2; 4] { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index b9d1874a1..fb483f61c 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -558,6 +558,21 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { } continue; } + R::Crop if app.ui.tool == "guidedUpright" => { + // Delete removes the selected guide alone; with none selected it removes + // nothing (the photo itself is never deleted while the tool is open) + if let (Some((photo, i)), Some(active)) = (app.ui.selected_guide, app.session.active()) { + if photo == active.0 + && let Some(d) = app.session.develop_of(active) + { + let guides: Vec<[f64; 4]> = + d.geometry.guides.iter().enumerate().filter(|(k, _)| *k != i).map(|(_, (a, b))| [a.x, a.y, b.x, b.y]).collect(); + let _ = app.run("geometry.guides", json!({"guides": guides})); + } + app.ui.selected_guide = None; + } + continue; + } R::RedEye => continue, _ => {} } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 7268d8a2b..9b0e8ee82 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -815,6 +815,10 @@ pub struct UiState { pub grading_hidden: Option<(lightcraft_catalog::PhotoId, [bool; 3])>, /// Active on-canvas tool: "", "brush", "linear", "radial", "wbPicker", "straighten", "remove". pub tool: String, + /// Guided Upright: the selected guide (photo id + index) while the tool is open. A Delete / + /// Backspace on it removes that guide alone; a click away or leaving the tool clears it. + #[serde(skip)] + pub selected_guide: Option<(u64, usize)>, pub brush_size: f32, pub brush_feather: f32, pub brush_flow: f32, @@ -1345,6 +1349,7 @@ impl Default for UiState { grading_mode: "3way".into(), grading_hidden: None, tool: String::new(), + selected_guide: None, brush_size: 0.04, brush_feather: 50.0, brush_flow: 60.0, diff --git a/crates/ui-egui/src/tests_guided_guides.rs b/crates/ui-egui/src/tests_guided_guides.rs new file mode 100644 index 000000000..65e7be6b9 --- /dev/null +++ b/crates/ui-egui/src/tests_guided_guides.rs @@ -0,0 +1,125 @@ +//! Headless tests of Guided Upright guide selection and Delete (issue: remove one guide): a click +//! near a guide selects it, Delete removes the selected guide alone, a click away clears the +//! selection, and with no selection Delete removes nothing (never the photo). + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +/// Crop with Guided Upright on and two horizontal guides drawn (in lens-corrected coordinates). +fn guided() -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + for (method, params) in [ + ("ui.set", json!({"view": "detail"})), + ("engine.execute", json!({"command": "tool.guidedUpright"})), + ("engine.execute", json!({"command": "geometry.guides", "params": {"guides": [[0.2, 0.3, 0.8, 0.3], [0.2, 0.7, 0.8, 0.7]]}})), + ] { + let r = h.request(method, params, T); + assert_eq!(r["ok"], true, "{r}"); + } + h.settle(SETTLE); + h +} + +fn guides_len(h: &Headless) -> usize { + let id = h.app.session.active().expect("a photo"); + h.app.session.catalog.photo(id).expect("the photo").develop.geometry.guides.len() +} + +fn guide_at(h: &Headless, i: usize) -> egui::Pos2 { + h.app.widgets.iter().rev().find(|(w, _)| *w == format!("uprightGuide:{i}")).map(|(_, r)| r.center()).expect("the guide is on screen") +} + +/// A click near a guide selects it (Delete has a visible target); the guides stay put. +#[test] +fn a_click_near_a_guide_selects_it() { + let mut h = guided(); + assert_eq!(guides_len(&h), 2); + let id = h.app.session.active().expect("a photo"); + let r = h.request("ui.click", json!({"x": guide_at(&h, 0).x, "y": guide_at(&h, 0).y}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.app.ui.selected_guide, Some((id.0, 0)), "the clicked guide is selected"); + assert_eq!(guides_len(&h), 2, "selecting removes nothing"); +} + +/// Delete removes the selected guide alone; the other one stays, and nothing stays selected. +#[test] +fn delete_removes_the_selected_guide() { + let mut h = guided(); + let r = h.request("ui.click", json!({"x": guide_at(&h, 1).x, "y": guide_at(&h, 1).y}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.app.ui.selected_guide.map(|(_, i)| i), Some(1)); + let r = h.request("ui.key", json!({"key": "Delete"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(guides_len(&h), 1, "one guide is gone"); + assert_eq!(h.app.ui.selected_guide, None, "and nothing is selected afterwards"); + // the survivor is the first guide: the second one's endpoints are gone + let id = h.app.session.active().expect("a photo"); + let d = h.app.session.catalog.photo(id).expect("the photo"); + let (a, b) = d.develop.geometry.guides[0]; + assert!( + (a.x - 0.2).abs() < 1e-6 && (a.y - 0.3).abs() < 1e-6 && (b.x - 0.8).abs() < 1e-6 && (b.y - 0.3).abs() < 1e-6, + "the first guide survived: {a:?}–{b:?}" + ); +} + +/// A click away from every guide clears the selection (and removes nothing). +#[test] +fn a_click_away_clears_the_selection() { + let mut h = guided(); + let r = h.request("ui.click", json!({"x": guide_at(&h, 0).x, "y": guide_at(&h, 0).y}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.ui.selected_guide.is_some()); + let img = h.app.image_rect.expect("the photo on screen"); + // below both guides, far from them + let r = h.request("ui.click", json!({"x": img.center().x, "y": img.top() + 0.95 * img.height()}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.app.ui.selected_guide, None); + assert_eq!(guides_len(&h), 2, "and removes nothing"); +} + +/// With nothing selected Delete removes no guide and never deletes the photo: the tool is open. +#[test] +fn delete_with_no_selection_removes_nothing() { + let mut h = guided(); + let undo = h.app.session.undo.len(); + let r = h.request("ui.key", json!({"key": "Delete"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(guides_len(&h), 2, "no guide is removed"); + assert_eq!(h.app.session.undo.len(), undo, "no edit either"); + assert!(h.app.session.active().is_some(), "the photo is still active"); +} + +/// Drawing a new guide clears the selection first, so Delete after a draw removes the new guide. +#[test] +fn drawing_a_new_guide_clears_the_selection() { + let mut h = guided(); + let r = h.request("ui.click", json!({"x": guide_at(&h, 0).x, "y": guide_at(&h, 0).y}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.ui.selected_guide.is_some()); + let img = h.app.image_rect.expect("the photo on screen"); + // a drag across the middle of the photo (clear of both guides) draws a third guide + let r = h.request( + "ui.drag", + json!({"x": img.left() + 0.3 * img.width(), "y": img.top() + 0.5 * img.height(), "toX": img.left() + 0.7 * img.width(), "toY": img.top() + 0.5 * img.height()}), + T, + ); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(guides_len(&h), 3, "the drawn guide is added"); + assert_eq!(h.app.ui.selected_guide, None, "and the click's selection is gone"); +}