From df77c5065720ce5485974895129a4b9b2f490bd9 Mon Sep 17 00:00:00 2001 From: FartmanAndBurps <345790579+FartmanAndBurps@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:28:15 +0000 Subject: [PATCH 1/3] Preview Work Path dragging without document mutations Show fractional drag feedback and commit one undoable move on release. Cancel stale or interrupted gestures safely. --- crates/ui-egui/src/canvas.rs | 8 + crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/shortcuts.rs | 8 + crates/ui-egui/src/state.rs | 4 + crates/ui-egui/src/vector_ui.rs | 25 +- crates/ui-egui/src/work_path_drag.rs | 157 +++++++++++++ crates/ui-egui/src/work_path_drag_tests.rs | 256 +++++++++++++++++++++ 7 files changed, 454 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/work_path_drag.rs create mode 100644 crates/ui-egui/src/work_path_drag_tests.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3a9a341316..36e63683d5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3473,6 +3473,10 @@ fn hdr_preview(app: &PhotocraftApp, doc: &photocraft_doc::Document) -> Option<[f /// Draw one canvas view and handle its input. `primary` = main window (tools active). pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: Rect, mut view: View, primary: bool) -> View { let ctx = ui.ctx().clone(); + crate::work_path_drag::reconcile(app, ctx.input(|i| i.focused)); + if crate::work_path_drag::preview(app).is_some() { + ctx.request_repaint(); + } // The camera as the app holds it before this frame's input. The canvas works on its own copy // (`view`) and writes it back at the end; a tool that moves the camera itself meanwhile (the // Crop tool's default mode pans and turns the view with the image, `crop_mode`) must not be @@ -5808,6 +5812,7 @@ fn tool_move(app: &mut PhotocraftApp, x: f64, y: f64, pressure: f32, mods: egui: } else if tool == Tool::CurvaturePen { crate::vector_ui::curvature_move(app, x, y); } + let mut preview_at = [x, y]; let zoom = app.point_zoom(); if let Some(d) = app.drag.as_mut().filter(|d| d.reposition) { d.track(mods); @@ -5820,6 +5825,7 @@ fn tool_move(app: &mut PhotocraftApp, x: f64, y: f64, pressure: f32, mods: egui: // ⇧: a selection-tool drag of the selection locks to multiples of 45° (#6231). The Move // tool's own drags were already locked by `move_mods::filter_event`. let [x, y] = crate::move_mods::selection_drag_to(d, [x, y], mods.shift && tool != Tool::Move); + preview_at = [x, y]; let moved = d.points.last().is_none_or(|p| (p[0] - x).abs() + (p[1] - y).abs() > 0.25); // A held pen repeats its point now and then, so the stroke's time keeps advancing. let held = app.stylus.last_point_ms().is_some_and(|t| app.stylus.clock_ms - t >= HOLD_REPEAT_MS) @@ -5829,6 +5835,7 @@ fn tool_move(app: &mut PhotocraftApp, x: f64, y: f64, pressure: f32, mods: egui: app.stylus.record_point(); } } + crate::work_path_drag::motion(app, preview_at); } /// Tool state machine. Shared by mouse input and automation. @@ -6129,6 +6136,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { let _ = app.run("layer.select", json!({"layer": id.0})); } + crate::work_path_drag::press(app, [x, y]); } Tool::Pen => { if !(mods.alt && crate::vector_ui::pen_grab_handle(app, x, y)) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4db3dc2a34..07e9d52b04 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -257,6 +257,7 @@ pub mod wheel_nav; pub mod wide_angle_ui; pub mod widgets; pub mod work_area; +pub mod work_path_drag; pub mod workspace_ui; pub mod zoom_hold; pub mod zoom_levels; @@ -1292,6 +1293,9 @@ impl PhotocraftApp { } self.sync_mask_targets(); } + if result.is_ok() { + work_path_drag::after_command(self, id); + } result } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 023bbb4d79..7bcef086f2 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -667,6 +667,14 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { return; } } + if crate::work_path_drag::owns_drag(app) + && app.drag.as_ref().is_some_and(|d| d.tool == crate::state::Tool::PathSelection) + && ctx.input_mut(|i| i.consume_key(i.modifiers, Key::Escape)) + { + crate::work_path_drag::cancel(app); + ctx.request_repaint(); + return; + } // Enter / Escape commit or cancel in-progress tool state (polygonal lasso, crop). if crate::lasso_ui::active(app) { let mods = ctx.input(|i| i.modifiers); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b3b9d7b05e..effb776499 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -1474,6 +1474,9 @@ pub struct UiState { /// Selected row in the Paths panel ("work" or a saved path name). #[serde(default)] pub selected_path: Option, + /// Whole-Work-Path drag preview is never persisted into preferences or documents. + #[serde(skip)] + pub work_path_drag: crate::work_path_drag::State, /// Layers panel kind filter ("pixel", "adjustment", "type", "shape", "smart"); empty = all. #[serde(default)] pub layer_filter: Vec, @@ -1677,6 +1680,7 @@ impl Default for UiState { direct_selection: Default::default(), corner_radius: None, selected_path: None, + work_path_drag: Default::default(), panels: Panels::default(), views: Vec::new(), dialogs: Vec::new(), diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 81426ccb5e..b5701313d4 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -1028,6 +1028,13 @@ pub fn pick_shape_at(doc: &Document, at: [f64; 2], tol: f64) -> Option, + selected_path: Option, + path: Path, + start: [f64; 2], + end: [f64; 2], +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct State { + gesture: Option, + // A cancelled/invalidated gesture must consume its late release, never retarget it. + owns_release: bool, +} + +fn work_target(app: &PhotocraftApp) -> bool { + let Some(st) = app.session.active() else { return false }; + // Preserve upstream layer/mask priority. Named saved paths retain their legacy route; + // adding saved-path editing is outside this Work-Path-only change. + if app.ui.selected_path.as_deref().is_some_and(|name| name != "work") { + return false; + } + if let Some(layer) = st.active_layer.and_then(|id| st.doc.layer(id)) { + if matches!(layer.content, LayerContent::Shape(_)) { + return false; + } + let mask = app.ui.vector_mask_target || st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::VectorMask; + if mask && layer.vector_mask.is_some() { + return false; + } + } + st.doc.work_path.is_some() +} + +fn valid(app: &PhotocraftApp, gesture: &Gesture) -> bool { + app.ui.tool == Tool::PathSelection + && app.ui.selected_path == gesture.selected_path + && work_target(app) + && app.session.active().is_some_and(|st| { + st.doc.id.0 == gesture.document + && st.revision == gesture.revision + && st.active_layer == gesture.layer + && st.doc.work_path.as_ref() == Some(&gesture.path) + }) +} + +/// Same finite fractional offset for preview and commit, retaining the old click dead zone. +fn displacement(start: [f64; 2], end: [f64; 2]) -> Option<[f64; 2]> { + let delta = [end[0] - start[0], end[1] - start[1]]; + if !start.iter().chain(end.iter()).all(|v| v.is_finite()) || !delta.iter().all(|v| v.is_finite() && v.abs() <= 1_000_000.0) { + return None; + } + Some(if delta[0].abs() + delta[1].abs() < 0.5 { [0.0, 0.0] } else { delta }) +} + +/// Called after upstream's existing shape picking, without changing the selected target. +pub fn press(app: &mut PhotocraftApp, at: [f64; 2]) { + app.ui.work_path_drag = State::default(); + if !app.ui.view.shows(app.ui.view.show.target_path) || !work_target(app) || displacement(at, at).is_none() { + return; + } + let Some(st) = app.session.active() else { return }; + let Some(path) = st.doc.work_path.clone() else { return }; + app.ui.work_path_drag = State { + gesture: Some(Gesture { + document: st.doc.id.0, + revision: st.revision, + layer: st.active_layer, + selected_path: app.ui.selected_path.clone(), + path, + start: at, + end: at, + }), + owns_release: true, + }; +} + +/// Canvas supplies document coordinates after its existing constraints, before sample gating. +pub fn motion(app: &mut PhotocraftApp, at: [f64; 2]) { + let Some(gesture) = app.ui.work_path_drag.gesture.as_ref() else { return }; + if !valid(app, gesture) || displacement(gesture.start, at).is_none() { + cancel(app); + return; + } + if let Some(gesture) = &mut app.ui.work_path_drag.gesture { + gesture.end = at; + } +} + +/// Invalidate immediately after a command, even if a later command restores the target. +pub fn after_command(app: &mut PhotocraftApp, id: &str) { + if let Some(gesture) = &app.ui.work_path_drag.gesture + && (!valid(app, gesture) || id == "layer.select" || id.starts_with("channel.target")) + { + cancel(app); + } +} + +/// Renderer consumes an offset, not a cloned or serialized path. +pub fn preview(app: &PhotocraftApp) -> Option<[f64; 2]> { + let gesture = app.ui.work_path_drag.gesture.as_ref()?; + let drag = app.drag.as_ref()?; + if !valid(app, gesture) || drag.tool != Tool::PathSelection || drag.start != gesture.start { + return None; + } + displacement(gesture.start, gesture.end) +} + +pub fn owns_drag(app: &PhotocraftApp) -> bool { + app.ui.work_path_drag.owns_release +} + +pub fn cancel(app: &mut PhotocraftApp) { + app.ui.work_path_drag.gesture = None; + if app.drag.as_ref().is_some_and(|d| d.tool == Tool::PathSelection) { + app.drag = None; + } +} + +pub fn reconcile(app: &mut PhotocraftApp, focused: bool) { + let Some(gesture) = app.ui.work_path_drag.gesture.as_ref() else { return }; + let lost = app.drag.as_ref().is_none_or(|d| d.tool != Tool::PathSelection || d.start != gesture.start); + if !focused || lost || !valid(app, gesture) { + cancel(app); + } +} + +/// None delegates non-Work-Path gestures to upstream. Owned late releases never delegate. +pub fn finish(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) -> Option> { + if !std::mem::take(&mut app.ui.work_path_drag.owns_release) { + return None; + } + let Some(gesture) = app.ui.work_path_drag.gesture.take() else { return Some(Err("Work Path drag was cancelled.".into())) }; + if start != gesture.start || !valid(app, &gesture) { + return Some(Err("Work Path drag target changed; no path was moved.".into())); + } + let Some([dx, dy]) = displacement(start, end) else { return Some(Err("Invalid Work Path displacement.".into())) }; + if dx == 0.0 && dy == 0.0 { + return Some(Ok(())); + } + let moved = gesture.path.transform(&Affine::translate(dx, dy)); + Some(app.run("path.set", json!({"name": "work", "path": photocraft_engine::vector_cmds::path_json(&moved)})).map(|_| ())) +} + +#[cfg(test)] +#[path = "work_path_drag_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/work_path_drag_tests.rs b/crates/ui-egui/src/work_path_drag_tests.rs new file mode 100644 index 0000000000..5bfbe48289 --- /dev/null +++ b/crates/ui-egui/src/work_path_drag_tests.rs @@ -0,0 +1,256 @@ +use super::*; +use crate::canvas::{ToolEvent, tool_event}; +use photocraft_doc::vector::Subpath; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 200, "background": "white"})).unwrap(); + let mut path = photocraft_vector::shapes::rect(10.0, 20.0, 50.0, 50.0); + path.subpaths.extend(photocraft_vector::shapes::ellipse(100.0, 20.0, 80.0, 60.0).subpaths); + path.subpaths.push(Subpath::polyline(&[(220.0, 40.0), (300.0, 60.0)])); + app.run("path.set", json!({"path": photocraft_engine::vector_cmds::path_json(&path)})).unwrap(); + app.ui.tool = Tool::PathSelection; + app.ui.view.extras = true; + app.ui.view.show.target_path = true; + app.sync_views(); + app +} +fn work(app: &PhotocraftApp) -> Path { + app.session.active().unwrap().doc.work_path.clone().unwrap() +} +fn event(app: &mut PhotocraftApp, event: ToolEvent) { + tool_event(app, event, egui::Modifiers::NONE); +} +fn down(app: &mut PhotocraftApp, at: [f64; 2]) { + event(app, ToolEvent::Down { x: at[0], y: at[1], pressure: 1.0 }); +} +fn moving(app: &mut PhotocraftApp, at: [f64; 2]) { + event(app, ToolEvent::Move { x: at[0], y: at[1], pressure: 1.0 }); +} +fn up(app: &mut PhotocraftApp, at: [f64; 2]) { + event(app, ToolEvent::Up { x: at[0], y: at[1] }); +} + +#[test] +fn whole_path_previews_before_release_without_mutation_then_commits_once() { + let mut app = app(); + let before = work(&app); + let history = app.session.active().unwrap().history.past_len(); + let revision = app.session.active().unwrap().revision; + let pixels = photocraft_compose::flatten(&app.session.active().unwrap().doc); + down(&mut app, [10.0, 20.0]); + for delta in [[1.125, 2.25], [15.375, -3.125], [-5.25, 8.125]] { + moving(&mut app, [10.0 + delta[0], 20.0 + delta[1]]); + assert_eq!(preview(&app), Some(delta)); + assert_eq!(work(&app), before); + assert_eq!(app.session.active().unwrap().history.past_len(), history); + assert_eq!(app.session.active().unwrap().revision, revision); + } + up(&mut app, [4.75, 28.125]); + let after = before.transform(&Affine::translate(-5.25, 8.125)); + assert_eq!(work(&app), after); + assert!(preview(&app).is_none()); + assert_eq!(app.session.active().unwrap().history.past_len(), history + 1); + assert_eq!(photocraft_compose::flatten(&app.session.active().unwrap().doc), pixels); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(work(&app), before); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(work(&app), after); + down(&mut app, [4.75, 28.125]); + moving(&mut app, [8.0, 30.0]); + let layer = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.select", json!({"layer": layer.0})).unwrap(); + assert!(preview(&app).is_none()); + assert!(finish(&mut app, [4.75, 28.125], [8.0, 30.0]).unwrap().is_err()); + assert_eq!(work(&app), after); +} + +#[test] +fn endpoint_updates_below_generic_stroke_sampling_threshold() { + let mut app = app(); + down(&mut app, [10.0, 20.0]); + moving(&mut app, [11.0, 20.0]); + let samples = app.drag.as_ref().unwrap().points.len(); + moving(&mut app, [11.0625, 20.03125]); + assert_eq!(app.drag.as_ref().unwrap().points.len(), samples); + assert_eq!(preview(&app), Some([1.0625, 0.03125])); + up(&mut app, [11.0625, 20.03125]); + assert_eq!(work(&app).subpaths[0].knots[0].anchor, photocraft_geom::Point::new(11.0625, 20.03125)); +} + +#[test] +fn click_dead_zone_and_invalid_displacement_do_not_create_history() { + let mut app = app(); + let before = work(&app); + let history = app.session.active().unwrap().history.past_len(); + down(&mut app, [10.0, 20.0]); + moving(&mut app, [10.125, 20.125]); + assert_eq!(preview(&app), Some([0.0, 0.0])); + up(&mut app, [10.125, 20.125]); + assert_eq!(work(&app), before); + assert_eq!(app.session.active().unwrap().history.past_len(), history); + assert!(displacement([0.0, 0.0], [f64::NAN, 0.0]).is_none()); + assert!(displacement([0.0, 0.0], [f64::INFINITY, 0.0]).is_none()); + assert!(displacement([0.0, 0.0], [1_000_001.0, 0.0]).is_none()); +} + +#[test] +fn escape_uses_real_shortcut_and_rejects_late_release() { + let mut app = app(); + let before = work(&app); + let history = app.session.active().unwrap().history.past_len(); + down(&mut app, [10.0, 20.0]); + moving(&mut app, [20.125, 25.5]); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + let mut input = egui::RawInput::default(); + input.events.push(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); + ctx.run_ui(input, |ui| crate::shortcuts::handle(&mut app, ui.ctx())).textures_delta.clear(); + assert!(app.drag.is_none()); + assert!(preview(&app).is_none()); + assert!(finish(&mut app, [10.0, 20.0], [20.125, 25.5]).unwrap().is_err()); + assert_eq!(work(&app), before); + assert_eq!(app.session.active().unwrap().history.past_len(), history); +} + +#[test] +fn stale_document_revision_geometry_target_and_tool_never_fall_back() { + for reason in ["revision", "geometry", "document", "target", "tool", "lost", "focus"] { + let mut app = app(); + down(&mut app, [10.0, 20.0]); + moving(&mut app, [20.0, 25.0]); + match reason { + "revision" => app.session.active_mut().unwrap().revision += 1, + "geometry" => { + std::sync::Arc::make_mut(&mut app.session.active_mut().unwrap().doc).work_path = Some(photocraft_vector::shapes::rect(1.0, 1.0, 10.0, 10.0)) + } + "document" => { + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + } + "target" => app.ui.selected_path = Some("Saved".into()), + "tool" => app.ui.tool = Tool::DirectSelection, + "lost" => app.drag = None, + "focus" => reconcile(&mut app, false), + _ => assert!(matches!(reason, "revision" | "geometry" | "document" | "target" | "tool" | "lost" | "focus")), + } + reconcile(&mut app, true); + let before = app.session.active().unwrap().doc.work_path.clone(); + let history = app.session.active().unwrap().history.past_len(); + assert!(preview(&app).is_none(), "{reason}"); + assert!(finish(&mut app, [10.0, 20.0], [20.0, 25.0]).unwrap().is_err(), "{reason}"); + assert_eq!(app.session.active().unwrap().doc.work_path, before, "{reason}"); + assert_eq!(app.session.active().unwrap().history.past_len(), history, "{reason}"); + } +} + +#[test] +fn named_paths_shape_layers_and_masks_retain_their_existing_route() { + let mut app = app(); + app.run("path.set", json!({"name": "Saved", "path": {"subpaths": [{"knots": [[5,5],[15,15]]}]}})).unwrap(); + app.ui.selected_path = Some("Saved".into()); + let before = app.session.active().unwrap().doc.clone(); + press(&mut app, [10.0, 20.0]); + assert!(preview(&app).is_none()); + assert!(finish(&mut app, [10.0, 20.0], [20.0, 20.0]).is_none()); + assert_eq!(app.ui.selected_path.as_deref(), Some("Saved")); + assert_eq!(app.session.active().unwrap().doc, before); + app.ui.selected_path = Some("work".into()); + app.run("layer.vectorMask.revealAll", json!({})).unwrap(); + press(&mut app, [10.0, 20.0]); + assert!(finish(&mut app, [10.0, 20.0], [20.0, 20.0]).is_none()); + app.ui.vector_mask_target = false; + app.run("shape.create", json!({"kind":"rect", "rect":[10,20,40,40]})).unwrap(); + press(&mut app, [10.0, 20.0]); + assert!(finish(&mut app, [10.0, 20.0], [20.0, 20.0]).is_none()); +} + +fn overlay(app: &PhotocraftApp, xf: crate::canvas::ViewXform) -> Vec { + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::background()); + crate::vector_ui::draw_overlay(app, &painter, &xf, &app.session.active().unwrap().doc); + }); + output.textures_delta.clear(); + output.shapes.into_iter().map(|s| s.shape).collect() +} + +#[test] +fn actual_overlay_moves_all_outlines_and_anchors_once() { + let mut app = app(); + down(&mut app, [10.0, 20.0]); + let xf = crate::canvas::ViewXform { + rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)), + zoom: 1.0, + center: [0.0, 0.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + let before = overlay(&app, xf); + moving(&mut app, [21.25, 24.5]); + let during = overlay(&app, xf); + let paths = |shapes: &[egui::Shape]| { + shapes + .iter() + .filter_map(|s| match s { + egui::Shape::Path(p) => Some(p.clone()), + _ => None, + }) + .collect::>() + }; + let a = paths(&before); + let b = paths(&during); + assert_eq!(a.len(), 3); + assert_eq!(b.len(), 3, "no duplicate original outlines"); + for (a, b) in a.iter().zip(&b) { + assert_eq!(a.points.len(), b.points.len()); + for (a, b) in a.points.iter().zip(&b.points) { + assert!((*b - *a - egui::vec2(11.25, 4.5)).length() < 0.001); + } + } + let anchors = |shapes: &[egui::Shape]| { + shapes + .iter() + .filter_map(|s| match s { + egui::Shape::Rect(r) => Some(r.rect.center()), + _ => None, + }) + .collect::>() + }; + let a = anchors(&before); + let b = anchors(&during); + assert_eq!(a.len(), 20); + assert_eq!(b.len(), 20); + for (a, b) in a.iter().zip(&b) { + assert!((*b - *a - egui::vec2(11.25, 4.5)).length() < 0.001); + } +} + +#[test] +fn view_mapping_fractional_offsets_and_transient_state_are_preserved() { + for (zoom, rotation, flip, aspect) in [(0.25, 0.0, false, 1.0), (2.0, 30.0, false, 1.0), (8.0, 90.0, true, 1.5)] { + let mut app = app(); + let xf = crate::canvas::ViewXform { + rect: egui::Rect::from_min_size(egui::pos2(10.0, 20.0), egui::vec2(800.0, 600.0)), + zoom, + center: [80.0, 60.0], + flip, + rotation, + aspect, + }; + let start = xf.to_doc(xf.to_screen(10.0, 20.0)); + let end = xf.to_doc(xf.to_screen(21.25, 24.5)); + app.ui.extras.snap = true; + app.ui.view.snap_to.grid = true; + let before = work(&app); + down(&mut app, start); + moving(&mut app, end); + let delta = preview(&app).unwrap(); + assert!((delta[0] - (end[0] - start[0])).abs() < 1e-8); + assert!((delta[1] - (end[1] - start[1])).abs() < 1e-8); + assert!(serde_json::to_value(&app.ui).unwrap().get("work_path_drag").is_none()); + up(&mut app, end); + assert_eq!(work(&app), before.transform(&Affine::translate(delta[0], delta[1]))); + } +} From 5c5a6888a9f3f0f6d82e3c1e3e64213ecc72d67b Mon Sep 17 00:00:00 2001 From: FartmanAndBurps <345790579+FartmanAndBurps@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:19:04 +0000 Subject: [PATCH 2/3] Keep Work Path drag focus reconciliation on the primary canvas Secondary document views must not cancel a primary-canvas gesture. Cover secondary focus, genuine primary focus loss, and one-step move history. --- crates/ui-egui/src/canvas.rs | 4 +- crates/ui-egui/src/work_path_drag_tests.rs | 57 ++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 36e63683d5..7d55d12c9e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3473,7 +3473,9 @@ fn hdr_preview(app: &PhotocraftApp, doc: &photocraft_doc::Document) -> Option<[f /// Draw one canvas view and handle its input. `primary` = main window (tools active). pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: Rect, mut view: View, primary: bool) -> View { let ctx = ui.ctx().clone(); - crate::work_path_drag::reconcile(app, ctx.input(|i| i.focused)); + if primary { + crate::work_path_drag::reconcile(app, ctx.input(|i| i.focused)); + } if crate::work_path_drag::preview(app).is_some() { ctx.request_repaint(); } diff --git a/crates/ui-egui/src/work_path_drag_tests.rs b/crates/ui-egui/src/work_path_drag_tests.rs index 5bfbe48289..c18166c842 100644 --- a/crates/ui-egui/src/work_path_drag_tests.rs +++ b/crates/ui-egui/src/work_path_drag_tests.rs @@ -254,3 +254,60 @@ fn view_mapping_fractional_offsets_and_transient_state_are_preserved() { assert_eq!(work(&app), before.transform(&Affine::translate(delta[0], delta[1]))); } } + +fn canvas_frame(app: &mut PhotocraftApp, primary: bool, focused: bool) { + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); + let input = egui::RawInput { screen_rect: Some(rect), focused, ..Default::default() }; + let view = app.ui.views[0].clone(); + ctx.run_ui(input, |ui| { + crate::canvas::canvas_view(app, ui, 0, rect, view.clone(), primary); + }) + .textures_delta + .clear(); +} + +#[test] +fn secondary_canvas_focus_does_not_cancel_primary_drag_or_split_history() { + let mut app = app(); + let before = work(&app); + let history = app.session.active().unwrap().history.past_len(); + down(&mut app, [10.0, 20.0]); + moving(&mut app, [21.25, 24.5]); + assert_eq!(preview(&app), Some([11.25, 4.5])); + canvas_frame(&mut app, true, true); + assert_eq!(preview(&app), Some([11.25, 4.5])); + canvas_frame(&mut app, false, false); + assert_eq!(preview(&app), Some([11.25, 4.5]), "secondary view must not cancel the primary drag"); + assert!(app.drag.is_some()); + assert_eq!(work(&app), before); + assert_eq!(app.session.active().unwrap().history.past_len(), history); + moving(&mut app, [23.375, 26.125]); + canvas_frame(&mut app, false, false); + assert_eq!(preview(&app), Some([13.375, 6.125])); + up(&mut app, [23.375, 26.125]); + let after = before.transform(&Affine::translate(13.375, 6.125)); + assert_eq!(work(&app), after); + assert_eq!(app.session.active().unwrap().history.past_len(), history + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(work(&app), before); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(work(&app), after); +} + +#[test] +fn primary_canvas_focus_loss_cancels_drag_without_history_or_late_release() { + let mut app = app(); + let before = work(&app); + let history = app.session.active().unwrap().history.past_len(); + down(&mut app, [10.0, 20.0]); + moving(&mut app, [21.25, 24.5]); + assert!(preview(&app).is_some()); + canvas_frame(&mut app, true, false); + assert!(preview(&app).is_none()); + assert!(app.drag.is_none()); + assert!(finish(&mut app, [10.0, 20.0], [21.25, 24.5]).unwrap().is_err()); + assert_eq!(work(&app), before); + assert_eq!(app.session.active().unwrap().history.past_len(), history); +} From e80f2425750e3a9c18ae6f777779e0117687f368 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 7 Nov 2026 23:23:59 +0100 Subject: [PATCH 3/3] PR #9306: preview Path Selection drags on main's own drag code The review on #9306 asked to build the live preview on main's Path Selection dragging instead of a separate handler. A drag now draws the work path, or the saved path selected in the Paths panel, where its release will put it: the same whole-pixel offset and click threshold as path_selection_finish (path_selection_offset, shared by both), and only the selected components when some but not all are (path_components:: moved_selected, through the engine's own move_anchors). The document still changes only on release, as one History step. A shape layer's or vector mask's outline is not previewed: their pixels move only on release. A stale drag (VectorGestureOwner) shows nothing, as its release moves nothing. Co-Authored-By: FartmanAndBurps Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/drag.rs | 6 + crates/ui-egui/src/tools/path_components.rs | 19 +- crates/ui-egui/src/tools/vector_ui.rs | 3 + crates/ui-egui/src/tools/vector_ui/overlay.rs | 17 +- .../vector_ui/path_drag_preview_tests.rs | 167 ++++++++++++++++++ .../ui-egui/src/tools/vector_ui/path_ops.rs | 61 ++++++- 6 files changed, 257 insertions(+), 16 deletions(-) create mode 100644 crates/ui-egui/src/tools/vector_ui/path_drag_preview_tests.rs 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)})) } };