diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs index 8be1a51c5d..fdc718be0f 100644 --- a/crates/ui-egui/src/canvas/drag.rs +++ b/crates/ui-egui/src/canvas/drag.rs @@ -86,6 +86,12 @@ impl Drag { } } + /// The document and target a vector drag was pressed on are still the current ones + /// ([`VectorGestureOwner`]); false for a drag that captured none. + pub(crate) fn vector_owner_valid(&self, app: &PhotocraftApp) -> bool { + self.vector_owner.as_ref().is_some_and(|owner| owner.valid(app)) + } + /// Reposition: move the start and every point so the last one lands on `to` (same size). pub fn shift_to(&mut self, to: [f64; 2]) { let last = self.points.last().map_or(self.start, |p| [p[0], p[1]]); diff --git a/crates/ui-egui/src/tools/path_components.rs b/crates/ui-egui/src/tools/path_components.rs index 33aef52d8d..7fd6fd55f7 100644 --- a/crates/ui-egui/src/tools/path_components.rs +++ b/crates/ui-egui/src/tools/path_components.rs @@ -166,16 +166,27 @@ pub(crate) fn press(app: &mut PhotocraftApp, at: [f64; 2], shift: bool) { /// components, one History step. `None` with none or all of them selected: the whole path moves /// instead, which keeps a live rectangle or ellipse live. pub(crate) fn move_selected(app: &mut PhotocraftApp, d: [f64; 2]) -> Option> { - let (r, path, heads) = selected(app)?; - if target(app).map(|(t, _)| t) != Some(r) || heads.len() >= path.components().len() { - return None; - } + let (r, _) = moving_selected(app)?; let mut params = r.params(app); params["anchors"] = json!(app.ui.direct_selection.anchors); params["move"] = json!(d); Some(run_selected_command(app, "path.moveAnchors", params)) } +/// The path [`move_selected`] moves components of, with some but not all of them selected. +fn moving_selected(app: &PhotocraftApp) -> Option<(PathRef, Path)> { + let (r, path, heads) = selected(app)?; + (target(app).map(|(t, _)| t) == Some(r) && heads.len() < path.components().len()).then_some((r, path)) +} + +/// The path [`move_selected`] leaves after a drag by `d`, for the drag's live preview: the +/// selected components moved with their handles, as `path.moveAnchors` moves them. +pub(crate) fn moved_selected(app: &PhotocraftApp, d: [f64; 2]) -> Option<(PathRef, Path)> { + let (r, mut path) = moving_selected(app)?; + photocraft_vector::edit::move_anchors(&mut path, &app.ui.direct_selection.anchors, d).ok()?; + Some((r, path)) +} + /// Do `+` and `-` pick a Path Operation with `tool`? The tools whose options bar has the menu. fn has_keys(tool: Tool) -> bool { matches!(tool, Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen | Tool::PathSelection) diff --git a/crates/ui-egui/src/tools/vector_ui.rs b/crates/ui-egui/src/tools/vector_ui.rs index d5a0ada77a..bbd674c4f4 100644 --- a/crates/ui-egui/src/tools/vector_ui.rs +++ b/crates/ui-egui/src/tools/vector_ui.rs @@ -201,6 +201,9 @@ impl PenPath { #[cfg(test)] mod tests; +#[cfg(test)] +mod path_drag_preview_tests; + #[cfg(test)] mod path_selection_tests; diff --git a/crates/ui-egui/src/tools/vector_ui/overlay.rs b/crates/ui-egui/src/tools/vector_ui/overlay.rs index 1e50b63b47..e2e8a241c7 100644 --- a/crates/ui-egui/src/tools/vector_ui/overlay.rs +++ b/crates/ui-egui/src/tools/vector_ui/overlay.rs @@ -1,5 +1,5 @@ use super::curvature::curvature_refresh; -use super::path_ops::{active_type_path, targeted_vector_mask, work_path_shown_in}; +use super::path_ops::{MovedPath, active_type_path, path_selection_preview, targeted_vector_mask, work_path_shown_in}; use super::pen::{closing, over_first_anchor, shift_45}; use egui::{Color32, Pos2, Rect, Stroke, pos2, vec2}; use photocraft_doc::vector::Path; @@ -157,14 +157,17 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform // a path tool is active, as in Photoshop: a Work Path left unselected (one a PSD arrived // with, or after Ctrl-click Load as Selection) would otherwise cover the marching ants (#6551). let anchors = shows_anchors(tool); + // A Path Selection drag shows its path where the release will put it. + let moving = path_selection_preview(app, doc); + let moved = |which: MovedPath| moving.as_ref().filter(|(m, _)| *m == which).map(|(_, p)| p); if let Some(wp) = doc.work_path.as_ref().filter(|_| work_path_shown_in(app, doc)) && (anchors || app.ui.selected_path.as_deref() == Some("work")) { - draw_path(wp, anchors); + draw_path(moved(MovedPath::Work).unwrap_or(wp), anchors); } // The saved path selected in the Paths panel is the target path (#6550). if let Some(p) = selected_saved_path(app) { - draw_path(p, anchors); + draw_path(moved(MovedPath::Saved).unwrap_or(p), anchors); } let st = app.session.active(); if let Some(l) = st.and_then(|s| s.active_layer.and_then(|id| s.doc.layer(id))) @@ -188,8 +191,14 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform // Path Selection's selected components show solid anchors, as in Photoshop. if tool == Tool::PathSelection && st.is_some_and(|s| s.doc.id == doc.id) - && let Some((_, path, _)) = crate::path_components::selected(app) + && let Some((r, path, _)) = crate::path_components::selected(app) { + let path = match r { + crate::direct_select::PathRef::Work => moved(MovedPath::Work), + crate::direct_select::PathRef::Saved(_) => moved(MovedPath::Saved), + _ => None, + } + .unwrap_or(&path); for k in app.ui.direct_selection.anchors.iter().filter_map(|[s, k]| path.subpaths.get(*s).and_then(|sp| sp.knots.get(*k))) { draw_anchor(painter, to_scr([k.anchor.x, k.anchor.y]), true, accent); } diff --git a/crates/ui-egui/src/tools/vector_ui/path_drag_preview_tests.rs b/crates/ui-egui/src/tools/vector_ui/path_drag_preview_tests.rs new file mode 100644 index 0000000000..436775f6de --- /dev/null +++ b/crates/ui-egui/src/tools/vector_ui/path_drag_preview_tests.rs @@ -0,0 +1,167 @@ +//! #9306: a Path Selection drag shows its path following the pointer, where the release will put +//! it, instead of leaving it in place until the release. The document only changes on release. + +use egui::{Modifiers, Pos2}; +use photocraft_doc::vector::Path; +use photocraft_geom::{Affine, Point}; +use serde_json::json; + +use super::path_ops::{MovedPath, path_selection_preview}; +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, ViewXform, tool_event}; +use crate::state::Tool; + +const NAME: &str = "Path 1"; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.sync_views(); + app.ui.tool = Tool::PathSelection; + app.ui.extras.snap = false; + app +} + +fn square(x: f64, y: f64, w: f64) -> serde_json::Value { + json!({"closed": true, "knots": [[x, y], [x + w, y], [x + w, y + w], [x, y + w]]}) +} + +fn work(app: &PhotocraftApp) -> Path { + app.session.active().unwrap().doc.work_path.clone().unwrap() +} + +fn revision(app: &PhotocraftApp) -> u64 { + app.session.active().unwrap().revision +} + +fn preview(app: &PhotocraftApp) -> Option<(MovedPath, Path)> { + path_selection_preview(app, &app.session.active().unwrap().doc) +} + +fn press_and_move(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2]) { + tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, Modifiers::NONE); + assert!(app.drag.is_some()); +} + +fn release(app: &mut PhotocraftApp, at: [f64; 2]) { + tool_event(app, ToolEvent::Up { x: at[0], y: at[1] }, Modifiers::NONE); +} + +fn anchors(p: &Path) -> Vec { + p.subpaths.iter().flat_map(|s| s.knots.iter().map(|k| k.anchor)).collect() +} + +/// The centres of the anchor squares the vector overlay draws, document = screen pixels. +fn drawn_anchors(app: &PhotocraftApp) -> Vec { + let xf = ViewXform { + rect: egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(200., 200.)), + zoom: 1.0, + center: [100., 100.], + flip: false, + rotation: 0., + aspect: 1., + }; + let doc = app.session.active().unwrap().doc.clone(); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(Default::default(), |ui| super::draw_overlay(app, ui.painter(), &xf, &doc)); + out.textures_delta.clear(); + out.shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Rect(r) if (r.rect.width() - 6.0).abs() < 0.01 => Some(r.rect.center()), + _ => None, + }) + .collect() +} + +fn near(drawn: &[Pos2], p: Point) -> bool { + drawn.iter().any(|q| (f64::from(q.x) - p.x).abs() < 0.01 && (f64::from(q.y) - p.y).abs() < 0.01) +} + +#[test] +fn a_work_path_drag_shows_the_path_where_the_release_puts_it() { + let mut app = app(); + app.run("path.set", json!({"path": {"subpaths": [square(20.0, 20.0, 40.0)]}})).unwrap(); + let before = work(&app); + let rev = revision(&app); + press_and_move(&mut app, [20.0, 40.0], [30.4, 45.6]); + assert_eq!(work(&app), before, "the drag itself leaves the document alone"); + assert_eq!(revision(&app), rev); + let (which, shown) = preview(&app).expect("the drag previews the work path"); + assert_eq!(which, MovedPath::Work); + // The same whole-pixel offset the release moves by. + assert_eq!(shown, before.transform(&Affine::translate(10.0, 6.0))); + let drawn = drawn_anchors(&app); + assert!(anchors(&shown).iter().all(|p| near(&drawn, *p)), "anchors drawn at the moved path: {drawn:?}"); + assert!(!near(&drawn, Point::new(60.0, 20.0)), "not at the path's old corner: {drawn:?}"); + release(&mut app, [30.4, 45.6]); + assert_eq!(work(&app), shown, "the release lands where the preview showed"); + assert!(preview(&app).is_none()); +} + +#[test] +fn a_click_shows_no_move() { + let mut app = app(); + app.run("path.set", json!({"path": {"subpaths": [square(20.0, 20.0, 40.0)]}})).unwrap(); + press_and_move(&mut app, [20.0, 40.0], [20.2, 40.1]); + assert!(preview(&app).is_none()); +} + +#[test] +fn only_the_selected_component_moves_in_the_preview() { + let mut app = app(); + app.run("path.set", json!({"path": {"subpaths": [square(20.0, 20.0, 40.0), square(120.0, 120.0, 40.0)]}})).unwrap(); + let before = work(&app); + // Pressing the first square's edge selects that component only. + press_and_move(&mut app, [20.0, 40.0], [25.0, 47.0]); + let (which, shown) = preview(&app).expect("the drag previews the component move"); + assert_eq!(which, MovedPath::Work); + let moved = anchors(&shown); + let old = anchors(&before); + assert_eq!(&moved[..4], &old[..4].iter().map(|p| Point::new(p.x + 5.0, p.y + 7.0)).collect::>()[..]); + assert_eq!(&moved[4..], &old[4..], "the other component stays"); + let drawn = drawn_anchors(&app); + assert!(near(&drawn, Point::new(25.0, 27.0)) && near(&drawn, Point::new(120.0, 120.0)), "{drawn:?}"); + release(&mut app, [25.0, 47.0]); + assert_eq!(work(&app), shown); +} + +#[test] +fn the_selected_saved_path_previews_too() { + let mut app = app(); + app.run("path.set", json!({"name": NAME, "path": {"subpaths": [square(40.0, 40.0, 80.0)]}})).unwrap(); + app.ui.selected_path = Some(NAME.into()); + let saved = |app: &PhotocraftApp| app.session.active().unwrap().doc.paths.iter().find(|p| p.name == NAME).unwrap().path.clone(); + let before = saved(&app); + press_and_move(&mut app, [40.0, 80.0], [52.0, 71.0]); + let (which, shown) = preview(&app).expect("the drag previews the saved path"); + assert_eq!(which, MovedPath::Saved); + assert_eq!(saved(&app), before); + release(&mut app, [52.0, 71.0]); + assert_eq!(saved(&app), shown); +} + +#[test] +fn a_shape_layer_drag_previews_no_outline() { + // Its pixels move only on release, so its outline does not run ahead of them. + let mut app = app(); + app.run("shape.create", json!({"kind": "rect", "rect": [20, 20, 60, 60], "fill": "#ff0000"})).unwrap(); + press_and_move(&mut app, [40.0, 40.0], [50.0, 50.0]); + assert!(preview(&app).is_none()); + release(&mut app, [50.0, 50.0]); + let st = app.session.active().unwrap(); + let photocraft_doc::LayerContent::Shape(sh) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("not a shape layer") }; + assert_eq!(sh.path.control_bounds().map(|b| [b.0, b.1]), Some([30.0, 30.0]), "the release still moves it"); +} + +#[test] +fn a_stale_drag_previews_nothing() { + let mut app = app(); + app.run("path.set", json!({"path": {"subpaths": [square(20.0, 20.0, 40.0)]}})).unwrap(); + press_and_move(&mut app, [20.0, 40.0], [30.0, 50.0]); + assert!(preview(&app).is_some()); + // The document changed under the drag, so its release moves nothing either. + app.session.execute("path.set", json!({"name": NAME, "path": {"subpaths": [square(0.0, 0.0, 10.0)]}})).unwrap(); + assert!(preview(&app).is_none()); +} diff --git a/crates/ui-egui/src/tools/vector_ui/path_ops.rs b/crates/ui-egui/src/tools/vector_ui/path_ops.rs index 2d23f6d3a4..1815ad43fb 100644 --- a/crates/ui-egui/src/tools/vector_ui/path_ops.rs +++ b/crates/ui-egui/src/tools/vector_ui/path_ops.rs @@ -343,16 +343,61 @@ fn near_outline(path: &Path, at: [f64; 2], tol: f64) -> bool { !(at[0] < x0 - pad || at[0] > x1 + pad || at[1] < y0 - pad || at[1] > y1 + pad) } -pub fn path_selection_finish(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { +/// How far a Path Selection drag from `start` to `end` moves its path: whole pixels. `None` for +/// a click (under half a pixel of travel), which picks a shape layer instead. +fn path_selection_offset(start: [f64; 2], end: [f64; 2]) -> Option<[f64; 2]> { let (dx, dy) = (end[0] - start[0], end[1] - start[1]); - let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); if dx.abs() + dy.abs() < 0.5 { + return None; + } + Some([dx.round(), dy.round()]) +} + +/// The path a Path Selection drag in progress shows moved. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum MovedPath { + Work, + /// The saved path selected in the Paths panel. + Saved, +} + +/// The work path or selected saved path of a Path Selection drag in progress, moved where its +/// release puts it ([`path_selection_finish`]: the same offset, only the selected components when +/// some but not all are), so the outline follows the pointer instead of jumping on release. +/// `None` without a live drag on `doc`, for a click, and for a shape layer's or vector mask's +/// path, whose pixels move only on release. +pub(crate) fn path_selection_preview(app: &PhotocraftApp, doc: &Document) -> Option<(MovedPath, Path)> { + let d = app.drag.as_ref().filter(|d| d.tool == Tool::PathSelection && d.vector_owner_valid(app))?; + if app.session.active().is_none_or(|st| st.doc.id != doc.id) { + return None; + } + let end = d.points.last().map_or(d.start, |p| [p[0], p[1]]); + let [dx, dy] = path_selection_offset(d.start, end).filter(|o| o.iter().all(|v| v.is_finite()))?; + if let Some((r, path)) = crate::path_components::moved_selected(app, [dx, dy]) { + return match r { + crate::direct_select::PathRef::Work => Some((MovedPath::Work, path)), + crate::direct_select::PathRef::Saved(_) => Some((MovedPath::Saved, path)), + _ => None, + }; + } + let (target, path) = target_path(app)?; + let moved = match target { + PathTarget::Work => MovedPath::Work, + PathTarget::Saved(_) => MovedPath::Saved, + PathTarget::Shape(_) | PathTarget::VectorMask(_) => return None, + }; + Some((moved, path.transform(&Affine::translate(dx, dy)))) +} + +pub fn path_selection_finish(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { + let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); + let Some([dx, dy]) = path_selection_offset(start, end) else { let Some(st) = app.session.active() else { return }; if let Some(id) = pick_shape_at(&st.doc, start, tol) { let _ = app.run("layer.select", json!({"layer": id.0})); } return; - } + }; if target_path(app).is_none() { let Some(st) = app.session.active() else { return }; if let Some(id) = pick_shape_at(&st.doc, start, tol) { @@ -360,7 +405,7 @@ pub fn path_selection_finish(app: &mut PhotocraftApp, start: [f64; 2], end: [f64 } } // Selected components move on their own (`path_components`); with none, the whole path. - if let Some(r) = crate::path_components::move_selected(app, [dx.round(), dy.round()]) { + if let Some(r) = crate::path_components::move_selected(app, [dx, dy]) { if let Err(e) = r { app.ui.status = e; app.ui.status_error = true; @@ -369,17 +414,17 @@ pub fn path_selection_finish(app: &mut PhotocraftApp, start: [f64; 2], end: [f64 } let Some((target, path)) = target_path(app) else { return }; let _ = match target { - PathTarget::Shape(id) => app.run("shape.edit", json!({"layer": id, "move": [dx.round(), dy.round()]})), + PathTarget::Shape(id) => app.run("shape.edit", json!({"layer": id, "move": [dx, dy]})), PathTarget::VectorMask(id) => { - let moved = path.transform(&Affine::translate(dx.round(), dy.round())); + let moved = path.transform(&Affine::translate(dx, dy)); app.run("layer.vectorMask.edit", json!({"layer": id, "path": photocraft_engine::vector_cmds::path_json(&moved)})) } PathTarget::Work => { - let moved = path.transform(&Affine::translate(dx.round(), dy.round())); + let moved = path.transform(&Affine::translate(dx, dy)); app.run("path.set", json!({"name": "work", "path": photocraft_engine::vector_cmds::path_json(&moved)})) } PathTarget::Saved(name) => { - let moved = path.transform(&Affine::translate(dx.round(), dy.round())); + let moved = path.transform(&Affine::translate(dx, dy)); app.run("path.set", json!({"name": name, "path": photocraft_engine::vector_cmds::path_json(&moved)})) } };