mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-13 21:52:54 +00:00
Merge pull request #9306 from FartmanAndBurps/contrib/work-path-live-preview
Smooth live preview for Work Path dragging
This commit is contained in:
6 files changed
+257
-16
No files matched your search
@@ -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]]);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -201,6 +201,9 @@ impl PenPath {
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod path_drag_preview_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod path_selection_tests;
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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)}))
|
||||
}
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user