feat(guided): select and delete one Guided Upright guide

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.
This commit is contained in:
mahmatias
2026-10-21 14:11:08 -03:00
parent e142e2ffa4
commit 7635b38c36
5 changed files with 202 additions and 12 deletions
+2
View File
@@ -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;
+55 -12
View File
@@ -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] {
+15
View File
@@ -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,
_ => {}
}
+5
View File
@@ -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,
+125
View File
@@ -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");
}