Merge pull request #9306 from FartmanAndBurps/contrib/work-path-live-preview

Smooth live preview for Work Path dragging
This commit is contained in:
David Mann authored and GitHub committed 2026-11-08 00:12:23 +01:00
commit 5525dc41ae
6 files changed
+257 -16

No files matched your search

+6
View File
@@ -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]]);
+15 -4
View File
@@ -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<Result<(), String>> {
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)
+3
View File
@@ -201,6 +201,9 @@ impl PenPath {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod path_drag_preview_tests;
#[cfg(test)]
mod path_selection_tests;
+13 -4
View File
@@ -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);
}
@@ -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<Point> {
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<Pos2> {
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::<Vec<_>>()[..]);
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());
}
+53 -8
View File
@@ -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)}))
}
};