mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
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:
@@ -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;
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user