diff --git a/crates/automation/src/workspace/tests.rs b/crates/automation/src/workspace/tests.rs index 89b80ca943..ce865908d2 100644 --- a/crates/automation/src/workspace/tests.rs +++ b/crates/automation/src/workspace/tests.rs @@ -699,7 +699,6 @@ fn windows_root_case_variants_keep_io_inside_the_grant() { std::fs::write(inside.join("notes.bin"), b"keep").unwrap(); assert!(workspace.write(&configured.join("notes.bin").to_string_lossy(), b"no").is_err()); assert_eq!(std::fs::read(inside.join("notes.bin")).unwrap(), b"keep"); - } /// Unix roots with different case must not become aliases of the granted prefix. diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index fa8a6c5b89..38debfa6aa 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -658,8 +658,6 @@ fn hidden_base_hides_clipping_group() { assert!(close4(px(&d, 1, 1), [1.0; 4])); } - - #[test] fn isolated_group_differs_from_pass_through() { // A Multiply layer inside a group: pass-through multiplies with the background, diff --git a/crates/engine/src/cmd/file_cmds/export.rs b/crates/engine/src/cmd/file_cmds/export.rs index 72e625ac0c..1f7f9677ff 100644 --- a/crates/engine/src/cmd/file_cmds/export.rs +++ b/crates/engine/src/cmd/file_cmds/export.rs @@ -44,7 +44,7 @@ pub struct LayerExportReport { pub fn export_layers_report_with(s: &Session, p: &Value, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result { let cmd = "file.export.layersToFiles"; let format = p.get("format").and_then(Value::as_str).unwrap_or("png").trim_start_matches('.').to_ascii_lowercase(); - let trim=p.get("trim").and_then(Value::as_bool).unwrap_or(false); + let trim = p.get("trim").and_then(Value::as_bool).unwrap_or(false); let visible_only = p.get("visibleOnly").and_then(Value::as_bool).unwrap_or(true); // Callers outside `Session::execute` (headless workspace exports) carry the session's gate // into `encode`, so a PSD embeds no file a linked smart object names. @@ -74,19 +74,21 @@ pub fn export_layers_report_with(s: &Session, p: &Value, mut write: impl FnMut(& only.clipped = false; one.layers = vec![only]; if trim { - let mut bounds=Rect::EMPTY; - let _=photocraft_compose::render_bands(&one,one.bounds(),0,|band|->std::result::Result<(),()> { - let width=band.rect.width() as usize; - for (i,rgba) in band.px.iter().enumerate() { - if rgba[3]>0.0 && width>0 { - let x=band.rect.x0+(i%width) as i32; - let y=band.rect.y0+(i/width) as i32; - bounds=bounds.union(&Rect::new(x,y,x+1,y+1)); + let mut bounds = Rect::EMPTY; + let _ = photocraft_compose::render_bands(&one, one.bounds(), 0, |band| -> std::result::Result<(), ()> { + let width = band.rect.width() as usize; + for (i, rgba) in band.px.iter().enumerate() { + if rgba[3] > 0.0 && width > 0 { + let x = band.rect.x0 + (i % width) as i32; + let y = band.rect.y0 + (i / width) as i32; + bounds = bounds.union(&Rect::new(x, y, x + 1, y + 1)); } } Ok(()) }); - if !bounds.is_empty() && bounds!=one.bounds(){crate::image_cmds::crop_doc(&mut one,cmd,bounds,true,[0.0;4])?;} + if !bounds.is_empty() && bounds != one.bounds() { + crate::image_cmds::crop_doc(&mut one, cmd, bounds, true, [0.0; 4])?; + } } let name = format!("{}_{:04}_{}.{format}", sanitize(&prefix), index, sanitize(&l.name)); diff --git a/crates/engine/src/cmd/symmetry_cmds/preset_tests.rs b/crates/engine/src/cmd/symmetry_cmds/preset_tests.rs index 8a66c510a9..cdce342336 100644 --- a/crates/engine/src/cmd/symmetry_cmds/preset_tests.rs +++ b/crates/engine/src/cmd/symmetry_cmds/preset_tests.rs @@ -160,7 +160,6 @@ fn selection_transparency_lock_and_mask_targets_apply_to_all_passes() { assert!(s.active().unwrap().doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().content_bounds().is_empty()); } - #[test] fn streaming_across_dual_axes_keeps_one_opacity_and_matches_commit() { let points = [[40.0, 30.0], [40.0, 20.0], [35.0, 20.0], [45.0, 40.0]]; diff --git a/crates/engine/src/cmd/vector_cmds/paths.rs b/crates/engine/src/cmd/vector_cmds/paths.rs index 167cb9d2a6..3fe1b7461f 100644 --- a/crates/engine/src/cmd/vector_cmds/paths.rs +++ b/crates/engine/src/cmd/vector_cmds/paths.rs @@ -79,26 +79,32 @@ pub(super) fn path_set(s: &mut Session, p: &Value) -> Result { let name = p.get("name").and_then(Value::as_str).map(str::to_string); let append = p.get("op").and_then(Value::as_str).map(|o| op_from(o).ok_or_else(|| bad(CMD, format!("unknown op `{o}`")))).transpose()?; s.edit(if is_work(name.as_deref()) { "Work Path" } else { "Save Path" }, |doc, _| { - let merge = |old: Option<&Path>, new: Path| match (old, append) { - (Some(o), Some(op)) => { - let mut o = o.clone(); - o.subpaths.extend(new.subpaths.into_iter().map(|mut sp| { - sp.op = op; - sp - })); - o - } - _ => new, + let merge = |old: Option<&Path>, new: Path| -> Result { + let merged = match (old, append) { + (Some(o), Some(op)) => { + let mut o = o.clone(); + o.subpaths.extend(new.subpaths.into_iter().map(|mut sp| { + sp.op = op; + sp + })); + o + } + _ => new, + }; + // Appending small Pen components must not bypass the complete path's bounds and + // tessellation budget. Validate before changing the document or its history. + vector::flatten::validate_path(&merged).map_err(|e| bad(CMD, e))?; + Ok(merged) }; if is_work(name.as_deref()) { - doc.work_path = Some(merge(doc.work_path.as_ref(), path)); + doc.work_path = Some(merge(doc.work_path.as_ref(), path)?); } else { let n = name.clone().unwrap_or_default(); match doc.paths.iter_mut().find(|q| q.name == n) { Some(q) => { - q.path = merge(Some(&q.path), path); + q.path = merge(Some(&q.path), path)?; } - None => doc.paths.push(NamedPath { name: n, path, psd_raw: None }), + None => doc.paths.push(NamedPath { name: n, path: merge(None, path)?, psd_raw: None }), } } Ok(()) diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs index f879aa2319..323d3f5203 100644 --- a/crates/engine/src/error.rs +++ b/crates/engine/src/error.rs @@ -1,5 +1,5 @@ -use crate::command_hints; use super::LayerId; +use crate::command_hints; #[derive(Debug, thiserror::Error)] pub enum EngineError { diff --git a/crates/engine/tests/pen_path_append.rs b/crates/engine/tests/pen_path_append.rs new file mode 100644 index 0000000000..41306ea6a2 --- /dev/null +++ b/crates/engine/tests/pen_path_append.rs @@ -0,0 +1,80 @@ +use photocraft_engine::Session; +use serde_json::json; + +fn session() -> Session { + let mut session = Session::new(); + session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + session +} + +#[test] +fn appended_work_and_saved_components_preserve_existing_geometry_and_undo() { + for name in ["work", "Saved drawing"] { + let mut session = session(); + let first = json!({"subpaths": [{"closed": false, "knots": [[20, 20], [80, 20]]}]}); + let second = json!({"subpaths": [{"closed": false, "knots": [[30, 60], [90, 60]]}]}); + session.execute("path.set", json!({"name": name, "path": first})).unwrap(); + let before = session.execute("path.info", json!({"name": name})).unwrap(); + session.execute("path.set", json!({"name": name, "path": second, "op": "combine"})).unwrap(); + let after = session.execute("path.info", json!({"name": name})).unwrap(); + assert_eq!(after["path"]["subpaths"].as_array().unwrap().len(), 2); + assert_eq!(after["path"]["subpaths"][0], before["path"]["subpaths"][0]); + session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(session.execute("path.info", json!({"name": name})).unwrap(), before); + session.execute("edit.redo", json!({})).unwrap(); + assert_eq!(session.execute("path.info", json!({"name": name})).unwrap(), after); + } +} + +#[test] +fn appending_valid_components_cannot_exceed_total_path_budget() { + let mut session = session(); + let subpath = json!({"closed": false, "knots": []}); + session.execute("path.set", json!({"path": {"subpaths": vec![subpath.clone(); 2_049]}})).unwrap(); + let before = session.active().unwrap().doc.work_path.clone(); + let history = session.active().unwrap().history.entries().len(); + let error = session.execute("path.set", json!({"path": {"subpaths": vec![subpath; 2_048]}, "op": "combine"})).unwrap_err(); + assert!(error.to_string().contains("4096")); + assert_eq!(session.active().unwrap().doc.work_path, before); + assert_eq!(session.active().unwrap().history.entries().len(), history); +} + +#[test] +fn invalid_path_coordinates_leave_work_and_saved_paths_unchanged() { + for name in ["work", "Saved drawing"] { + let mut session = session(); + session.execute("path.set", json!({"name": name, "path": {"subpaths": [{"knots": [[10, 10], [20, 20]]}]}})).unwrap(); + let before = session.execute("path.info", json!({"name": name})).unwrap(); + let history = session.active().unwrap().history.entries().len(); + assert!( + session.execute("path.set", json!({"name": name, "op": "combine", "path": {"subpaths": [{"knots": [[-1.0e308, 10], [1.0e308, 20]]}]}})).is_err() + ); + assert_eq!(session.execute("path.info", json!({"name": name})).unwrap(), before); + assert_eq!(session.active().unwrap().history.entries().len(), history); + } +} + +#[test] +fn large_straight_work_and_saved_paths_append_and_undo_without_shape_coordinate_limits() { + for name in ["work", "Saved drawing"] { + let mut session = session(); + let large = json!({"subpaths": [{"closed": true, "knots": [[0.0, 0.0], [3.0e9, 0.0], [3.0e9, 1.0e9]]}]}); + let before = session.active().unwrap().doc.clone(); + let history = session.active().unwrap().history.entries().len(); + session.execute("path.set", json!({"name":name,"path":large})).unwrap(); + let first = session.execute("path.info", json!({"name":name})).unwrap(); + assert_eq!(first["path"]["subpaths"][0]["knots"][1]["anchor"], json!([3.0e9, 0.0])); + assert_eq!(session.active().unwrap().history.entries().len(), history + 1); + session.execute("path.set", json!({"name":name,"op":"combine","path":{"subpaths":[{"knots":[[10,20],[80,20]]}]}})).unwrap(); + let appended = session.execute("path.info", json!({"name":name})).unwrap(); + assert_eq!(appended["path"]["subpaths"].as_array().unwrap().len(), 2); + assert_eq!(appended["path"]["subpaths"][0], first["path"]["subpaths"][0]); + session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(session.execute("path.info", json!({"name":name})).unwrap(), first); + session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(session.active().unwrap().doc.work_path, before.work_path); + assert_eq!(session.active().unwrap().doc.paths, before.paths); + session.execute("edit.redo", json!({})).unwrap(); + assert_eq!(session.execute("path.info", json!({"name":name})).unwrap(), first); + } +} diff --git a/crates/io/tests/it/color.rs b/crates/io/tests/it/color.rs index a9e0aa6f10..15453a2518 100644 --- a/crates/io/tests/it/color.rs +++ b/crates/io/tests/it/color.rs @@ -210,7 +210,7 @@ fn cmyk_merged_composite_reads_through_the_embedded_profile() { if depth == 8 { data.push(v as u8) } else { data.extend((v as u16).to_be_bytes()) } } } - f.image_data = photocraft_psd::ImageData { compression: Compression::Raw, data, ..Default::default() }; + f.image_data = photocraft_psd::ImageData { compression: Compression::Raw, data, stored: None }; f.resources.push(ImageResource::new(1039, profile.to_bytes().to_vec())); let merged = merged_composite(&f).unwrap(); let mut seen_far = false; diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index c7321e917d..9a1c142c17 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -294,4 +294,6 @@ mod nonfinite_tests { } /// Bounded finite affine terms accepted before type layout. -pub fn usable_tysh_transform(matrix:&[f64;6])->bool {transform_is_safe(&Affine{m:*matrix})} +pub fn usable_tysh_transform(matrix: &[f64; 6]) -> bool { + transform_is_safe(&Affine { m: *matrix }) +} diff --git a/crates/text/src/psd/import.rs b/crates/text/src/psd/import.rs index 670267a7a0..e4040b2bfe 100644 --- a/crates/text/src/psd/import.rs +++ b/crates/text/src/psd/import.rs @@ -298,7 +298,9 @@ pub fn text_layer_from_tysh(data: &[u8], dpi: f32) -> Option { layer.transform.m = corrected; } } - if !super::usable_tysh_transform(&layer.transform.m) {return None;} + if !super::usable_tysh_transform(&layer.transform.m) { + return None; + } layer.sync_summary(); Some(layer) } @@ -359,7 +361,9 @@ fn legacy_point_origin(t: &TySh) -> Option<[f64; 2]> { return None; } let shifted = [a * bounds[0] + c * bounds[1] + e, b * bounds[0] + d * bounds[1] + f]; - if !shifted.iter().all(|v|v.is_finite()) {return None;} + if !shifted.iter().all(|v| v.is_finite()) { + return None; + } let origin_distance = e.abs().max(f.abs()); let shifted_distance = shifted[0].abs().max(shifted[1].abs()); // A local bounding-box origin in the thousands and a transform origin beyond a normal canvas @@ -795,8 +799,8 @@ pub const OPENTYPE_KEYS: [(&str, &str); 8] = [ #[cfg(test)] mod tysh_geometry_tests { use super::*; -use crate::psd::{usable_tysh_transform, write_tysh}; -use photocraft_psd::descriptor::UnicodeString; + use crate::psd::{usable_tysh_transform, write_tysh}; + use photocraft_psd::descriptor::UnicodeString; #[test] fn overflowing_legacy_origin_does_not_escape_as_a_nonfinite_transform() { diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index d12c85d005..5672b91f0b 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -47,6 +47,7 @@ impl PhotocraftApp { monitors: Default::default(), checker: None, drag: None, + cancelled_vector_drag: false, move_blocked: false, tool_override: None, live_stroke: None, @@ -98,6 +99,7 @@ impl PhotocraftApp { tab_strip: None, drop_places: Default::default(), view_docs: Vec::new(), + path_document: None, fps: 0.0, last_frame_time: 0.0, thumbs: HashMap::new(), diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 0f62682553..5645693e4b 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -253,6 +253,22 @@ impl PhotocraftApp { } pub fn sync_views(&mut self) { + // The Paths panel selection belongs to the active document. Ordinary edits replace the + // snapshot and advance its revision; a replaced snapshot without a newer revision is a + // reopened document, even with the same ID. + let document = self.session.active().map(|st| (st.doc.id, st.revision, std::sync::Arc::downgrade(&st.doc))); + let same_document = match (&self.path_document, &document) { + (Some((old_id, old_revision, old_source)), Some((id, revision, source))) => old_id == id && (revision > old_revision || old_source.ptr_eq(source)), + (None, None) => true, + _ => false, + }; + if !same_document { + self.ui.selected_path = None; + } + self.path_document = document; + vector_ui::cancel_stale_pen(self); + crate::direct_select::cancel_stale(self); + canvas::cancel_stale_vector_drag(self); type_transform::cancel_stale(self); crate::cam_transform::cancel_stale(self); crate::lasso_ui::cancel_stale(self); diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 100167f238..49b22678a7 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -61,9 +61,7 @@ impl PhotocraftApp { } // So does selecting another layer or document by any route (#6483). type_tool::end_if_left(self); - if self.ui.pen.as_ref().is_some_and(|p| !p.drawn_with(self.ui.tool)) { - vector_ui::pen_end(self); - } + vector_ui::finish_pen_if_left(self); transform_tool::end_if_left(self); shape_stroke_ui::sync_editor(self, ctx); prefs_ui::tick(self, ctx); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4588fb8d42..e09149a4c0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1656,13 +1656,14 @@ fn finish_canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, view: &mut Vie // Scrollbars (scrollbars.rs): drawn over the canvas edges, they take the pointer there. let t0 = crate::gpu_canvas::now_ms(); let before = view.center; - let mode=app.session.prefs().interface.scrollbar_visibility; - let visible=match mode { - photocraft_engine::prefs::ScrollbarVisibility::Hidden=>false, - photocraft_engine::prefs::ScrollbarVisibility::AutoHide=>ui.rect_contains_pointer(rect)||ui.input(|i|i.pointer.any_down()), - _=>true, + let mode = app.session.prefs().interface.scrollbar_visibility; + let visible = match mode { + photocraft_engine::prefs::ScrollbarVisibility::Hidden => false, + photocraft_engine::prefs::ScrollbarVisibility::AutoHide => ui.rect_contains_pointer(rect) || ui.input(|i| i.pointer.any_down()), + _ => true, }; - let over_bars = visible && app.ui.view.shows_scrollbars() + let over_bars = visible + && app.ui.view.shows_scrollbars() && crate::scrollbars::show(ui, rect, view, flip, aspect, app.session.prefs().tools.overscroll, egui::Id::unique(("pc-canvas", idx))); ctx.data_mut(|d| d.insert_temp(bars_id, over_bars)); if view.center != before { @@ -1964,7 +1965,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } if middle_rotate && response.dragged() && !stroke_dismissal { if let Some(pointer) = response.interact_pointer_pos() - && crate::rotate_view::middle_drag(view, &xf, pointer, response.drag_delta().x) + && crate::rotate_view::middle_drag(&mut view, &xf, pointer, response.drag_delta().x) { ctx.request_repaint(); } @@ -2632,3 +2633,12 @@ mod lasso_preview_tests { assert_eq!((dashes, solid), (0, 1), "Magnetic Lasso: the traced border is solid"); } } + +pub(crate) use drag::cancel_stale_vector_drag; + +#[cfg(test)] +#[path = "pen_gesture_owner_tests.rs"] +mod pen_gesture_owner_tests; +#[cfg(test)] +#[path = "pen_path_visual_tests.rs"] +mod pen_path_visual_tests; diff --git a/crates/ui-egui/src/canvas/cursors.rs b/crates/ui-egui/src/canvas/cursors.rs index f6367ecd0d..67222ed7f9 100644 --- a/crates/ui-egui/src/canvas/cursors.rs +++ b/crates/ui-egui/src/canvas/cursors.rs @@ -211,5 +211,9 @@ pub(crate) fn composite_color(app: &mut PhotocraftApp, x: f64, y: f64) -> Option sample_color(app, x, y, "all") } -pub(crate) fn alt_loads_mixer(tool:Tool,mods:egui::Modifiers)->bool {mods.alt && !mods.ctrl && tool==Tool::MixerBrush} -pub(crate) fn alt_picks(tool:Tool,mods:egui::Modifiers)->bool {alt_samples(tool,mods)||alt_loads_mixer(tool,mods)} +pub(crate) fn alt_loads_mixer(tool: Tool, mods: egui::Modifiers) -> bool { + mods.alt && !mods.ctrl && tool == Tool::MixerBrush +} +pub(crate) fn alt_picks(tool: Tool, mods: egui::Modifiers) -> bool { + alt_samples(tool, mods) || alt_loads_mixer(tool, mods) +} diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs index 6083756416..ac3da2eacb 100644 --- a/crates/ui-egui/src/canvas/drag.rs +++ b/crates/ui-egui/src/canvas/drag.rs @@ -31,6 +31,9 @@ pub struct Drag { /// keeps the locked direction stable between events. The Move tool's own drags use `move_mods`. pub sel_axis: Option<[f64; 2]>, pub lasso: Option, + /// Freeform Pen and Path Selection gestures belong to their source document and target. + pub(super) vector_owner: Option, + pub(super) pen_owner: Option, } impl Drag { @@ -49,6 +52,19 @@ impl Drag { sel_move: None, sel_axis: None, lasso: None, + vector_owner: None, + pen_owner: None, + } + } + + /// Vector drags capture their owner at the press, the Freeform Pen also its destination, before + /// later ⌥-clicks or a temporary Direct Selection can finish them. + pub(crate) fn capture_vector_owners(&mut self, app: &PhotocraftApp) { + if matches!(self.tool, Tool::FreeformPen | Tool::PathSelection) { + self.vector_owner = VectorGestureOwner::capture(app); + } + if self.tool == Tool::FreeformPen { + self.pen_owner = crate::vector_ui::capture_pen_owner(app); } } @@ -326,3 +342,58 @@ pub(crate) fn draw_readout(ctx: &egui::Context, id: &str, cursor }, ); } + +/// The document, revision and target a vector drag started on. +#[derive(Clone, Debug)] +pub(super) struct VectorGestureOwner { + doc: photocraft_doc::DocId, + document: std::sync::Weak, + revision: u64, + layer: Option, + selected_path: Option, + vector_mask: bool, + mode: String, + tool: Tool, +} + +/// Cancel a vector drag before it can preview or finish on another document or target; the rest of +/// its pointer events go nowhere. True when one was cancelled. +pub(crate) fn cancel_stale_vector_drag(app: &mut PhotocraftApp) -> bool { + let stale = app.drag.as_ref().and_then(|d| d.vector_owner.as_ref()).is_some_and(|owner| !owner.valid(app)); + if stale { + app.drag = None; + app.cancelled_vector_drag = true; + } + stale +} + +impl VectorGestureOwner { + fn capture(app: &PhotocraftApp) -> Option { + let st = app.session.active()?; + Some(Self { + doc: st.doc.id, + document: std::sync::Arc::downgrade(&st.doc), + revision: st.revision, + layer: st.active_layer, + selected_path: app.ui.selected_path.clone(), + vector_mask: app.ui.vector_mask_target, + mode: app.ui.tool_options.vector_mode.clone(), + tool: app.active_tool(), + }) + } + + pub(super) fn valid(&self, app: &PhotocraftApp) -> bool { + Self::capture(app).is_some_and(|now| { + self.doc == now.doc + && self.document.ptr_eq(&now.document) + && self.revision == now.revision + && self.layer == now.layer + && self.selected_path == now.selected_path + && self.vector_mask == now.vector_mask + && self.mode == now.mode + && self.tool == now.tool + }) + } +} + +use photocraft_doc::Document; diff --git a/crates/ui-egui/src/canvas/gesture.rs b/crates/ui-egui/src/canvas/gesture.rs index 251b6c9205..d67bc23af1 100644 --- a/crates/ui-egui/src/canvas/gesture.rs +++ b/crates/ui-egui/src/canvas/gesture.rs @@ -13,6 +13,9 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { app.live_stroke = None; return; } + if d.vector_owner.as_ref().is_some_and(|owner| !owner.valid(app)) { + return; + } let mut end = d.points.last().copied().unwrap_or([d.start[0], d.start[1], 1.0]); let (sx, sy) = snap_aliased_tip(app, d.tool, end[0], end[1]); end[0] = sx; @@ -78,7 +81,11 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { } match d.tool { Tool::ObjectSelection => crate::retouch_ui::finish_object_selection(app, d.start, [end[0], end[1]], d.modifiers), - Tool::FreeformPen => crate::vector_ui::freeform_finish(app, &d.points, d.lasso.as_ref().map_or(&[], |l| l.straight())), + Tool::FreeformPen => { + if let Some(owner) = d.pen_owner { + crate::vector_ui::freeform_finish_owned(app, &d.points, d.lasso.as_ref().map_or(&[], |l| l.straight()), owner); + } + } // Photoshop: a click without dragging opens Create Rectangle / Ellipse / … instead. t if crate::vector_ui::is_shape_tool(t) && crate::shape_dialog::is_click(d.start, &d.points, app.point_zoom()) diff --git a/crates/ui-egui/src/canvas/rotate_view/tests.rs b/crates/ui-egui/src/canvas/rotate_view/tests.rs index 86b4663042..a9fb172bb4 100644 --- a/crates/ui-egui/src/canvas/rotate_view/tests.rs +++ b/crates/ui-egui/src/canvas/rotate_view/tests.rs @@ -21,14 +21,8 @@ fn middle_drag_rotates_about_the_pointer_without_panning() { #[test] fn middle_drag_ignores_invalid_or_stationary_input() { let mut view = View::default(); - let xf = ViewXform { - rect: Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 400.0)), - zoom: 1.0, - center: view.center, - flip: false, - rotation: 0.0, - aspect: 1.0, - }; + let xf = + ViewXform { rect: Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 400.0)), zoom: 1.0, center: view.center, flip: false, rotation: 0.0, aspect: 1.0 }; assert!(!middle_drag(&mut view, &xf, pos2(200.0, 120.0), 0.0)); assert!(!middle_drag(&mut view, &xf, pos2(200.0, 120.0), f32::NAN)); assert!(!middle_drag(&mut view, &xf, pos2(f32::INFINITY, 120.0), 20.0)); diff --git a/crates/ui-egui/src/canvas/tool_input.rs b/crates/ui-egui/src/canvas/tool_input.rs index 677586047a..4bd5a445ab 100644 --- a/crates/ui-egui/src/canvas/tool_input.rs +++ b/crates/ui-egui/src/canvas/tool_input.rs @@ -1,3 +1,4 @@ +use super::cancel_stale_vector_drag; use super::cursors::{alt_eyedropper, sample_eyedropper}; use super::drag::Drag; use super::gesture::{finish_gesture, polygon_click, selection_mode}; @@ -296,6 +297,17 @@ fn handle_move_pointer(app: &mut PhotocraftApp, ev: ToolEvent, tool: Tool, mods: /// Tool state machine. Shared by mouse input and automation. pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { + cancel_stale_vector_drag(app); + if app.cancelled_vector_drag { + match ev { + ToolEvent::Down { .. } => app.cancelled_vector_drag = false, + ToolEvent::Move { .. } => return, + ToolEvent::Up { .. } => { + app.cancelled_vector_drag = false; + return; + } + } + } let Some((armed, mods)) = preflight_tool_event(app, ev, mods) else { return }; let Some((raw, ev, mods)) = filter_event_before_tool(app, ev, mods, armed) else { return }; let tool = event_tool(app, mods); @@ -479,7 +491,9 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) } // ⌥ flips Dodge/Burn and Blur/Sharpen for this stroke. let tool = crate::retouch_ui::alt_flipped(tool, mods.alt); - app.drag = Some(Drag { clear, ..Drag::new(tool, from.map_or([x, y], |p| [p[0], p[1]]), points, mods, erase) }); + let mut drag = Drag { clear, ..Drag::new(tool, from.map_or([x, y], |p| [p[0], p[1]]), points, mods, erase) }; + drag.capture_vector_owners(app); + app.drag = Some(drag); app.trail = None; app.stylus.begin_stroke(); if from.is_some() { diff --git a/crates/ui-egui/src/control/dispatch.rs b/crates/ui-egui/src/control/dispatch.rs index b67dbef801..c2600f064a 100644 --- a/crates/ui-egui/src/control/dispatch.rs +++ b/crates/ui-egui/src/control/dispatch.rs @@ -1,3 +1,4 @@ +use super::METHODS; use super::inspect::inspect; use super::params::{ BUTTONS, KEY_MODIFIERS, MAX_CLICKS, POINTER_MODIFIERS, UI_SET_FIELDS, bool_field, err, f32_field, f32_range, input_choice, input_modifiers, input_number, @@ -69,7 +70,7 @@ fn execute_command(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRe let p = &req.params; let Some(id) = str_param(p, "command").or(str_param(p, "id")) else { return err("missing `command`") }; if req.method == "engine.execute" && METHODS.contains(&id) && photocraft_engine::commands::find(id).is_none() { - return err(photocraft_engine::command_hints::protocol_method_as_command(id)); + return err(photocraft_engine::command_hints::protocol_method_as_command(id)); } let params = p.get("params").cloned().unwrap_or(json!({})); let wait = wait_param(p); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5533e2c81e..68fe215004 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -148,6 +148,8 @@ # Art History Brush (#213). # Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) +# Smart Filter row actions (#1265) + Mask Options… Možnosti masky… Layer Mask Display Options… Možnosti zobrazení masky vrstvy… Channel Color Barva kanálu @@ -1234,7 +1236,6 @@ Dots Tečky Drag a rectangle around the object Táhněte obdélník kolem objektu Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Tažením v obraze přidáte omezení; Shift pro vodorovné/svislé; pravým kliknutím na omezení ho změníte nebo odstraníte. - Drag to move the active shape's path or the Work Path Tažením přesunete cestu aktivního tvaru nebo pracovní cestu Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Klik: vybrat bod · Tažení: přesunout bod, úchyt nebo segment · {shift}+klik: přidat · {alt}+klik: celá podcesta Draw with the Pen tool (P) or make a work path from a selection. Kreslete nástrojem pero (P) nebo vytvořte pracovní cestu z výběru. Drop an image or PSD anywhere to open it. Přetáhněte sem obrázek nebo PSD a otevře se. @@ -1959,11 +1960,9 @@ Slice Select Tool Nástroj výběr řezu Slice Tool Nástroj řez Smart Filters Chytré filtry -# Smart Filter row actions (#1265) Move Up Přesunout nahoru Move Down Přesunout dolů Delete Smart Filter Odstranit chytrý filtr - Smudge Tool Nástroj rozmazání Soft Měkké Soften Změkčení @@ -4179,8 +4178,6 @@ navigator Zoom Zvětšení layers Fill Krytí výplně layers Fill: Krytí výplně: cameraRaw Dehaze Odstranění oparu - Move Up Přesunout nahoru - Move Down Přesunout dolů Previous Brush Předchozí štětec Next Brush Další štětec First Brush První štětec @@ -4277,3 +4274,4 @@ cameraRaw Dehaze Odstranění oparu @plural Imported {n} shape from {file}|Imported {n} shapes from {file} Počet tvarů importovaných z {file}: {n}|Počet tvarů importovaných z {file}: {n}|Počet tvarů importovaných z {file}: {n} @plural Imported {n} shape from {group}|Imported {n} shapes from {group} Počet tvarů importovaných z {group}: {n}|Počet tvarů importovaných z {group}: {n}|Počet tvarů importovaných z {group}: {n} Scrollbar Visibility Viditelnost posuvníků + Drag to move the selected path Tažením přesunete vybranou cestu diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c6544b205e..514253b27f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2458,7 +2458,7 @@ Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Σύρετε ή κάντε κλικ για πολυγωνικά σημεία · κρατήστε Alt κατά το λάσο για ευθείες · {add} προσθήκη · {sub} αφαίρεση πριν από τη σχεδίαση Drag to draw a gradient Σύρετε για να σχεδιάσετε διαβάθμιση Drag to move the active layer Σύρετε για μετακίνηση του ενεργού επιπέδου - Drag to move the active shape's path or the Work Path Σύρετε για μετακίνηση της διαδρομής του ενεργού σχήματος ή της διαδρομής εργασίας + Drag to move the selected path Σύρετε για μετακίνηση της επιλεγμένης διαδρομής Dual Brush Διπλό πινέλο Duplicate Path Διπλασιασμός διαδρομής Rename Path… Μετονομασία διαδρομής… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 720121b7c1..0b6f4569c1 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -82,6 +82,8 @@ # Color panel menu: modes, copy commands and colour ramps (#6000) # Pen draft after a refused commit (#7648) # Art History Brush (#213). +# Smart Filter row actions (#1265) + Mask Options… Opciones de máscara… Layer Mask Display Options… Opciones de visualización de máscara de capa… Channel Color Color del canal @@ -1168,7 +1170,6 @@ Dots Puntos Drag a rectangle around the object Arrastre un rectángulo alrededor del objeto Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Arrastre sobre la imagen para añadir una restricción; Mayús para horizontal/vertical; clic derecho en una restricción para cambiarla o eliminarla. - Drag to move the active shape's path or the Work Path Arrastre para mover el trazado de la forma activa o el trazado de trabajo Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Clic: seleccionar punto · Arrastrar: mover punto, manejador o segmento · {shift}-clic: añadir · {alt}-clic: subtrazado completo Draw with the Pen tool (P) or make a work path from a selection. Dibuje con la herramienta Pluma (P) o cree un trazado de trabajo a partir de una selección. Drop an image or PSD anywhere to open it. Suelte una imagen o un PSD en cualquier lugar para abrirlo. @@ -1890,11 +1891,9 @@ Slice Select Tool Herramienta Selección de sector Slice Tool Herramienta Sector Smart Filters Filtros inteligentes -# Smart Filter row actions (#1265) Move Up Mover arriba Move Down Mover abajo Delete Smart Filter Eliminar filtro inteligente - Smudge Tool Herramienta Dedo Soft Suave Soften Suavizar @@ -4346,8 +4345,6 @@ navigator Zoom Zoom The fastest and the softest, with colour fringing on fine detail. El más rápido y el más suave, con halo de color en el detalle fino. “{name}” already exists. Replace it? «{name}» ya existe. ¿Reemplazarlo? The name in the save dialog was “{name}”. El nombre en el cuadro de diálogo Guardar era «{name}». - Move Up Mover arriba - Move Down Mover abajo Previous Brush Pincel anterior Next Brush Pincel siguiente First Brush Primer pincel @@ -4442,3 +4439,4 @@ navigator Zoom Zoom @plural Imported {n} shape from {file}|Imported {n} shapes from {file} Cantidad de formas importadas de {file}: {n}|Cantidad de formas importadas de {file}: {n} @plural Imported {n} shape from {group}|Imported {n} shapes from {group} Cantidad de formas importadas de {group}: {n}|Cantidad de formas importadas de {group}: {n} Scrollbar Visibility Visibilidad de las barras de desplazamiento + Drag to move the selected path Arrastre para mover el trazado seleccionado diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 93d7870fdd..35ed609691 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -81,6 +81,8 @@ # Pen draft after a refused commit (#7648) # Art History Brush (#213). +# Smart Filter row actions. + Paper Width Papír szélessége Paper Height Papír magassága @id edit.purge.undo Visszavonási adatok @@ -1731,11 +1733,9 @@ fontWeight Thin Hajszálvékony Show the layer's effects ({key}-click: all layers) Réteg effektusainak megjelenítése ({key}+kattintás: minden réteg) Skip Kihagy Smart Filters Intelligens szűrők -# Smart Filter row actions. Move Up Mozgatás felfelé Move Down Mozgatás lefelé Delete Smart Filter Intelligens szűrő törlése - Soft Light Lágy fény Soften Lágyítás Source Forrás @@ -4347,8 +4347,6 @@ cameraRaw Yellow Sárga The fastest and the softest, with colour fringing on fine detail. A leggyorsabb és a leglágyabb, apró részleteken színes szegéllyel. “{name}” already exists. Replace it? „{name}” már létezik. Lecseréli? The name in the save dialog was “{name}”. A mentési párbeszédpanelen megadott név: „{name}”. - Move Up Mozgatás felfelé - Move Down Mozgatás lefelé Previous Brush Előző ecset Next Brush Következő ecset First Brush Első ecset @@ -4440,3 +4438,4 @@ cameraRaw Yellow Sárga @plural Imported {n} shape from {file}|Imported {n} shapes from {file} A(z) {file} forrásból importált alakzatok száma: {n} @plural Imported {n} shape from {group}|Imported {n} shapes from {group} A(z) {group} forrásból importált alakzatok száma: {n} Scrollbar Visibility Görgetősávok láthatósága + Drag to move the selected path Húzza a kijelölt útvonal mozgatásához diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0e8d595a09..10aaaea5ff 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1215,6 +1215,7 @@ Drag a rectangle around the object Trascina un rettangolo attorno all'oggetto Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Trascina sull'immagine per aggiungere un vincolo; Maiusc per orizz/vert; clic destro su un vincolo per modificarlo o eliminarlo. Drag to move the active shape's path or the Work Path Trascina per spostare il tracciato forma attiva o il Tracciato di lavoro + Drag to move the selected path Trascina per spostare il tracciato selezionato Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Clic: seleziona punto · Trascina: sposta punto, maniglia o segmento · {shift}-clic: aggiungi · {alt}-clic: intero sottotracciato Draw with the Pen tool (P) or make a work path from a selection. Disegna con Penna (P) o crea un tracciato di lavoro da una selezione. Drop an image or PSD anywhere to open it. Trascina un'immagine o PSD ovunque per aprirlo. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 520d27522f..22b314d1bd 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -142,6 +142,8 @@ # Pen draft after a refused commit (#7648) # Art History Brush (#213). +# Smart Filter row actions (#1265) + Mask Options… マスクオプション… Layer Mask Display Options… レイヤーマスクの表示オプション… Channel Color チャンネルカラー @@ -1223,7 +1225,6 @@ Dots ドット Drag a rectangle around the object オブジェクトを囲む矩形をドラッグ Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. 画像上をドラッグして制約を追加。Shift で水平/垂直、制約を右クリックで変更・削除。 - Drag to move the active shape's path or the Work Path ドラッグして、アクティブなシェイプのパスまたは作業用パスを移動 Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath クリック: アンカーポイントを選択 · ドラッグ: ポイント、ハンドル、セグメントを移動 · {shift}+クリック: 追加 · {alt}+クリック: サブパス全体 Draw with the Pen tool (P) or make a work path from a selection. ペンツール(P)で描くか、選択範囲から作業用パスを作成します。 Drop an image or PSD anywhere to open it. 画像や PSD をここにドロップすると開きます。 @@ -1943,11 +1944,9 @@ Slice Select Tool スライス選択ツール Slice Tool スライスツール Smart Filters スマートフィルター -# Smart Filter row actions (#1265) Move Up 上へ移動 Move Down 下へ移動 Delete Smart Filter スマートフィルターを削除 - Smudge Tool 指先ツール Soft ソフト Soften ぼかし @@ -4111,8 +4110,6 @@ curves Smooth スムーズ The fastest and the softest, with colour fringing on fine detail. 最も高速で最も柔らかく、細部に色にじみが出る。 “{name}” already exists. Replace it? 「{name}」は既に存在します。置き換えますか? The name in the save dialog was “{name}”. 保存ダイアログで指定した名前は「{name}」でした。 - Move Up 上へ移動 - Move Down 下へ移動 Previous Brush 前のブラシ Next Brush 次のブラシ First Brush 最初のブラシ @@ -4207,3 +4204,4 @@ curves Smooth スムーズ Choose a custom shape file (.csh). カスタムシェイプファイル (.csh) を選択してください。 @plural Imported {n} shape from {file}|Imported {n} shapes from {file} {file} から読み込んだシェイプ数: {n} @plural Imported {n} shape from {group}|Imported {n} shapes from {group} {group} から読み込んだシェイプ数: {n} + Drag to move the selected path ドラッグして選択したパスを移動 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 36f7e93279..4f4a52d0ab 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -113,6 +113,8 @@ # Art History Brush (#213). # Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) +# Smart Filter row actions (#1265) + Mask Options… 마스크 옵션… Layer Mask Display Options… 레이어 마스크 표시 옵션… Channel Color 채널 색상 @@ -1200,7 +1202,6 @@ Dots 점 Drag a rectangle around the object 오브젝트를 둘러싸는 사각형을 드래그하세요 Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. 이미지 위에서 드래그하여 제약 조건 추가; Shift 키로 수평/수직 유지; 제약 조건을 오른쪽 클릭하여 변경 또는 삭제. - Drag to move the active shape's path or the Work Path 드래그하여 활성 모양의 패스 또는 작업 패스 이동 Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath 클릭: 기준점 선택 · 드래그: 기준점, 핸들 또는 세그먼트 이동 · {shift}-클릭: 추가 · {alt}-클릭: 하위 패스 전체 Draw with the Pen tool (P) or make a work path from a selection. 펜 도구(P)로 그리거나 선택 영역에서 작업 패스를 만드세요. Drop an image or PSD anywhere to open it. 이미지나 PSD를 아무 곳에나 놓아 여세요. @@ -1919,11 +1920,9 @@ Slice Select Tool 분할 영역 선택 도구 Slice Tool 분할 영역 도구 Smart Filters 스마트 필터 -# Smart Filter row actions (#1265) Move Up 위로 이동 Move Down 아래로 이동 Delete Smart Filter 스마트 필터 삭제 - Smudge Tool 손가락 도구 Soft 부드럽게 Soften 부드럽게 하기 @@ -3645,8 +3644,6 @@ curves Smooth 매끄럽게 The fastest and the softest, with colour fringing on fine detail. 가장 빠르고 가장 부드러우며, 미세한 디테일에 색 번짐이 있음. “{name}” already exists. Replace it? “{name}”이(가) 이미 있습니다. 대치하시겠습니까? The name in the save dialog was “{name}”. 저장 대화상자에 입력한 이름: “{name}” - Move Up 위로 이동 - Move Down 아래로 이동 Previous Brush 이전 브러시 Next Brush 다음 브러시 First Brush 첫 번째 브러시 @@ -3751,3 +3748,4 @@ curves Smooth 매끄럽게 @plural Imported {n} shape from {group}|Imported {n} shapes from {group} {group}에서 가져온 모양 수: {n} Scrollbar Visibility 스크롤 막대 표시 Hidden 숨김 + Drag to move the selected path 드래그하여 선택한 패스 이동 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 4fd466e031..69821b92db 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -115,6 +115,8 @@ # Pen draft after a refused commit (#7648) # Art History Brush (#213). +# Smart Filter row actions (#1265) + Mask Options… Opções de máscara… Layer Mask Display Options… Opções de exibição de máscara de camada… Channel Color Cor do canal @@ -1180,7 +1182,6 @@ Dots Pontos Drag a rectangle around the object Arraste um retângulo ao redor do objeto Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Arraste sobre a imagem para adicionar uma restrição; Shift para horizontal/vertical; clique direito em uma restrição para alterá-la ou excluí-la. - Drag to move the active shape's path or the Work Path Arraste para mover o demarcador da forma ativa ou o demarcador de trabalho Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Clique: selecionar ponto · Arrastar: mover ponto, alça ou segmento · {shift}-clique: adicionar · {alt}-clique: subdemarcador inteiro Draw with the Pen tool (P) or make a work path from a selection. Desenhe com a ferramenta Caneta (P) ou crie um demarcador de trabalho a partir de uma seleção. Drop an image or PSD anywhere to open it. Solte uma imagem ou um PSD em qualquer lugar para abri-lo. @@ -1892,11 +1893,9 @@ Slice Select Tool Ferramenta Seleção de fatia Slice Tool Ferramenta Fatia Smart Filters Filtros inteligentes -# Smart Filter row actions (#1265) Move Up Mover para cima Move Down Mover para baixo Delete Smart Filter Excluir filtro inteligente - Smudge Tool Ferramenta Borrar Soft Suave Soften Suavizar @@ -4232,8 +4231,6 @@ layerLabel No Color Sem cor The fastest and the softest, with colour fringing on fine detail. O mais rápido e o mais suave, com franjamento de cor em detalhes finos. “{name}” already exists. Replace it? “{name}” já existe. Substituir? The name in the save dialog was “{name}”. O nome na caixa de diálogo Salvar era “{name}”. - Move Up Mover para cima - Move Down Mover para baixo Previous Brush Pincel anterior Next Brush Próximo pincel First Brush Primeiro pincel @@ -4457,3 +4454,4 @@ layerLabel No Color Sem cor @plural Imported {n} shape from {file}|Imported {n} shapes from {file} Quantidade de formas importadas de {file}: {n}|Quantidade de formas importadas de {file}: {n} @plural Imported {n} shape from {group}|Imported {n} shapes from {group} Quantidade de formas importadas de {group}: {n}|Quantidade de formas importadas de {group}: {n} Scrollbar Visibility Visibilidade das barras de rolagem + Drag to move the selected path Arraste para mover o demarcador selecionado diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 76b81f69ca..dfc794b621 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1356,7 +1356,7 @@ Current Path Текущий контур Vector Mask from Current Path Векторная маска из текущего контура Create new shape from the current path Создать новую фигуру из текущего контура - Drag to move the active shape's path or the Work Path Перетащите, чтобы переместить контур активной фигуры или рабочий контур + Drag to move the selected path Перетащите, чтобы переместить выбранный контур Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Щелчок: выбрать узел · Перетаскивание: переместить узел, маркер или сегмент · {shift}-щелчок: добавить · {alt}-щелчок: весь подконтур Draw with the Pen tool (P) or make a work path from a selection. Нарисуйте пером (P) или создайте рабочий контур из выделения. Source: not set ({key}-click with Clone Stamp) Источник: не задан ({key}-клик штампом) diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 6861678a3b..3b9001a8a3 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -151,6 +151,8 @@ # Pen draft after a refused commit (#7648) # Art History Brush (#213). +# Smart Filter row actions (#1265). + Rows Redovi Number Broj Margin Margina @@ -1906,11 +1908,9 @@ Slice Select Tool Alatka za izbor isečka Slice Tool Alatka za isecanje na isečke Smart Filters Pametni filteri -# Smart Filter row actions (#1265). Move Up Pomeri nagore Move Down Pomeri nadole Delete Smart Filter Obriši pametni filter - Smudge Tool Alatka za razmazivanje Soft Meko Soften Omekšaj @@ -4492,8 +4492,6 @@ shape Appearance Izgled The fastest and the softest, with colour fringing on fine detail. Najbrži i najmekši, sa obojenim rubovima na finim detaljima. “{name}” already exists. Replace it? „{name}“ već postoji. Zameniti? The name in the save dialog was “{name}”. Ime u dijalogu za čuvanje je bilo „{name}“. - Move Up Pomeri nagore - Move Down Pomeri nadole Previous Brush Prethodna četkica Next Brush Sledeća četkica First Brush Prva četkica @@ -4599,3 +4597,4 @@ shape Appearance Izgled Choose a custom shape file (.csh). Izaberite datoteku prilagođenih oblika (.csh). @plural Imported {n} shape from {file}|Imported {n} shapes from {file} Broj oblika uvezenih iz {file}: {n}|Broj oblika uvezenih iz {file}: {n}|Broj oblika uvezenih iz {file}: {n} @plural Imported {n} shape from {group}|Imported {n} shapes from {group} Broj oblika uvezenih iz {group}: {n}|Broj oblika uvezenih iz {group}: {n}|Broj oblika uvezenih iz {group}: {n} + Drag to move the selected path Prevucite da pomerite izabranu putanju diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index dfaeec35cb..e49aaead6a 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4256,3 +4256,4 @@ cameraData Lens Lens @plural Imported {n} shape from {file}|Imported {n} shapes from {file} {file} kaynağından içe aktarılan şekil sayısı: {n} @plural Imported {n} shape from {group}|Imported {n} shapes from {group} {group} kaynağından içe aktarılan şekil sayısı: {n} Scrollbar Visibility Kaydırma çubuklarının görünürlüğü + Drag to move the selected path Seçili yolu taşımak için sürükleyin diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 6936491ef3..bb8753c3a2 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1323,7 +1323,7 @@ Current Path Поточний контур Vector Mask from Current Path Векторна маска з поточного контуру Create new shape from the current path Створити нову фігуру з поточного контуру - Drag to move the active shape's path or the Work Path Перетягніть, щоб перемістити контур активної фігури або робочий контур + Drag to move the selected path Перетягніть, щоб перемістити вибраний контур Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Клацання: вибрати вузол · Перетягування: перемістити вузол, ручку або сегмент · {shift}+клацання: додати · {alt}+клацання: увесь підконтур Draw with the Pen tool (P) or make a work path from a selection. Малюйте пером (P) або створіть робочий контур із виділення. Source: not set ({key}-click with Clone Stamp) Джерело: не задано ({key}+клацання клонувальним штампом) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 8ac14760ba..62f8391b5c 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1039,6 +1039,7 @@ Align horizontal centers Căn tâm ngang Align right edges Căn cạnh phải Drag to move the active shape's path or the Work Path Kéo để di chuyển đường dẫn hình đang chọn hoặc Đường dẫn làm việc + Drag to move the selected path Kéo để di chuyển đường dẫn đã chọn Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Bấm: góc · Kéo: mượt · Bấm điểm đầu: đóng · {key} xong · Esc hủy Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Bấm: chọn điểm neo · Kéo: di chuyển điểm neo, tay nắm hoặc đoạn · {shift}-bấm: thêm · {alt}-bấm: cả nhánh con Pixel Layer Lớp pixel diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index feffaa81f7..496dce1cf2 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -116,6 +116,8 @@ # Art History Brush (#213). # Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) +# Smart Filter row actions (#1265) + Mask Options… 蒙版选项… Layer Mask Display Options… 图层蒙版显示选项… Channel Color 通道颜色 @@ -1227,7 +1229,6 @@ Dots 点 Drag a rectangle around the object 围绕对象拖出矩形 Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. 在图像上拖动以添加约束;按 Shift 保持水平或垂直;右键单击约束可更改或删除。 - Drag to move the active shape's path or the Work Path 拖动以移动当前形状的路径或工作路径 Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath 单击:选择锚点 · 拖动:移动锚点、手柄或线段 · {shift}-单击:添加 · {alt}-单击:整个子路径 Draw with the Pen tool (P) or make a work path from a selection. 使用钢笔工具(P)绘制,或从选区创建工作路径。 Drop an image or PSD anywhere to open it. 将图像或 PSD 拖放到任意位置即可打开。 @@ -1968,11 +1969,9 @@ Slice Select Tool 切片选择工具 Slice Tool 切片工具 Smart Filters 智能滤镜 -# Smart Filter row actions (#1265) Move Up 上移 Move Down 下移 Delete Smart Filter 删除智能滤镜 - Smudge Tool 涂抹工具 Soft 软 Soften 柔化 @@ -3099,8 +3098,6 @@ curves Smooth 平滑 The fastest and the softest, with colour fringing on fine detail. 最快也最柔和,细节处有色边。 “{name}” already exists. Replace it? “{name}”已存在。要替换它吗? The name in the save dialog was “{name}”. 存储对话框中的名称是“{name}”。 - Move Up 上移 - Move Down 下移 Previous Brush 上一个画笔 Next Brush 下一个画笔 First Brush 第一个画笔 @@ -3214,3 +3211,4 @@ curves Smooth 平滑 @plural Imported {n} shape from {group}|Imported {n} shapes from {group} 从 {group} 导入的形状数量:{n} Scrollbar Visibility 滚动条可见性 Hidden 隐藏 + Drag to move the selected path 拖动以移动选中的路径 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 1fcbf4dd74..2f29eb49c9 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -118,6 +118,8 @@ # Art History Brush (#213). # Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) +# Smart Filter row actions (#1265) + Mask Options… 遮色片選項… Layer Mask Display Options… 圖層遮色片顯示選項… Channel Color 色版顏色 @@ -1210,7 +1212,6 @@ Dots 點 Drag a rectangle around the object 在物件周圍拖曳出矩形 Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. 在影像上拖曳以新增限制線;按住 Shift 可限制水平/垂直;在限制線上按右鍵可變更或刪除。 - Drag to move the active shape's path or the Work Path 拖曳以移動作用中形狀的路徑或工作路徑 Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath 按一下:選取錨點 · 拖曳:移動錨點、把手或線段 · {shift}-按一下:增加 · {alt}-按一下:整個子路徑 Draw with the Pen tool (P) or make a work path from a selection. 使用筆型工具 (P) 繪製,或從選取範圍建立工作路徑。 Drop an image or PSD anywhere to open it. 將影像或 PSD 拖放到任何位置即可開啟。 @@ -1938,11 +1939,9 @@ Slice Select Tool 切片選取工具 Slice Tool 切片工具 Smart Filters 智慧型濾鏡 -# Smart Filter row actions (#1265) Move Up 上移 Move Down 下移 Delete Smart Filter 刪除智慧型濾鏡 - Smudge Tool 指尖工具 Soft 柔邊 Soften 柔化 @@ -3060,8 +3059,6 @@ curves Smooth 平滑 The fastest and the softest, with colour fringing on fine detail. 最快也最柔和,細節處有色邊。 “{name}” already exists. Replace it? 「{name}」已存在。要取代它嗎? The name in the save dialog was “{name}”. 儲存對話框中的名稱是「{name}」。 - Move Up 上移 - Move Down 下移 Previous Brush 上一個筆刷 Next Brush 下一個筆刷 First Brush 第一個筆刷 @@ -3170,3 +3167,4 @@ curves Smooth 平滑 @plural Imported {n} shape from {group}|Imported {n} shapes from {group} 從 {group} 匯入的形狀數量:{n} Scrollbar Visibility 捲軸可見性 Hidden 隱藏 + Drag to move the selected path 拖曳以移動選取的路徑 diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index 5ef25410c5..d40943d1ca 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -1128,7 +1128,9 @@ fn hand_pointer_over_the_canvas_under_a_dialog() { fn esc_ends_a_pen_path_and_keeps_it() { let mut h = harness(); h.state_mut().ui.tool = crate::state::Tool::Pen; - h.state_mut().ui.pen = Some(crate::vector_ui::PenPath { knots: vec![[[50.0, 50.0]; 3], [[150.0, 50.0]; 3], [[150.0, 120.0]; 3]], ..Default::default() }); + let owner = crate::vector_ui::capture_pen_owner(h.state()); + h.state_mut().ui.pen = + Some(crate::vector_ui::PenPath { knots: vec![[[50.0, 50.0]; 3], [[150.0, 50.0]; 3], [[150.0, 120.0]; 3]], owner, ..Default::default() }); h.key_press(Key::Escape); h.run_steps(2); assert!(h.state().ui.pen.is_none(), "the Pen leaves drawing state"); @@ -1147,7 +1149,8 @@ fn esc_ends_a_pen_path_and_keeps_it() { fn pen_shows_the_close_path_pointer_over_the_first_anchor() { let mut h = harness(); h.state_mut().ui.tool = crate::state::Tool::Pen; - h.state_mut().ui.pen = Some(crate::vector_ui::PenPath { knots: vec![[[50.0, 50.0]; 3], [[150.0, 50.0]; 3]], ..Default::default() }); + let owner = crate::vector_ui::capture_pen_owner(h.state()); + h.state_mut().ui.pen = Some(crate::vector_ui::PenPath { knots: vec![[[50.0, 50.0]; 3], [[150.0, 50.0]; 3]], owner, ..Default::default() }); let screen = |h: &Harness<'static, PhotocraftApp>, x: f32, y: f32| { let v = &h.state().ui.views[0]; let c = h.state().last_canvas_rect.center(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4544f7213b..f5b06ef4a4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -335,6 +335,9 @@ pub struct PhotocraftApp { monitors: monitor_status::State, checker: Option, drag: Option, + /// An owned vector drag was cancelled (`canvas::cancel_stale_vector_drag`): the rest of its + /// pointer events go nowhere. + pub(crate) cancelled_vector_drag: bool, /// A Move-tool press landed on a locked layer: the first pointer move shows Photoshop's /// message (`move_lock`), a plain click shows nothing. pub(crate) move_blocked: bool, @@ -456,6 +459,9 @@ pub struct PhotocraftApp { pub(crate) drop_places: std::collections::VecDeque, /// The document each of `ui.views` belongs to, as of the last [`Self::sync_views`]. view_docs: Vec, + /// The document `ui.selected_path` belongs to, as of the last [`Self::sync_views`]: a Paths + /// panel selection never carries over to a same-named path in another tab. + path_document: Option<(DocId, u64, std::sync::Weak)>, pub fps: f32, pub(crate) last_frame_time: f64, thumbs: HashMap<(photocraft_doc::LayerId, u8), (u64, egui::TextureHandle)>, @@ -855,3 +861,6 @@ mod select_and_mask_button_tests; #[cfg(test)] mod saved_path_edit_tests; + +#[cfg(test)] +mod pen_path_ownership_tests; diff --git a/crates/ui-egui/src/panels/mask_thumbs_ui/tests.rs b/crates/ui-egui/src/panels/mask_thumbs_ui/tests.rs index 5824af3593..f0ab8faad5 100644 --- a/crates/ui-egui/src/panels/mask_thumbs_ui/tests.rs +++ b/crates/ui-egui/src/panels/mask_thumbs_ui/tests.rs @@ -611,7 +611,9 @@ fn clicking_the_vector_mask_targets_it_for_the_path_tools() { let k = layer(&h, masked).vector_mask.unwrap().path.subpaths[0].knots[0].anchor; assert_eq!((k.x, k.y), (25.0, 12.0)); // The Pen adds a subpath to it. - h.state_mut().ui.pen = Some(crate::vector_ui::PenPath { knots: vec![[[150.0, 10.0]; 3], [[190.0, 10.0]; 3], [[190.0, 40.0]; 3]], ..Default::default() }); + let owner = crate::vector_ui::capture_pen_owner(h.state()); + h.state_mut().ui.pen = + Some(crate::vector_ui::PenPath { knots: vec![[[150.0, 10.0]; 3], [[190.0, 10.0]; 3], [[190.0, 40.0]; 3]], owner, ..Default::default() }); crate::vector_ui::pen_commit(h.state_mut(), true); assert_eq!(layer(&h, masked).vector_mask.unwrap().path.subpaths.len(), 2); assert!(h.state().session.active().unwrap().doc.work_path.is_none(), "not a work path"); diff --git a/crates/ui-egui/src/pen_gesture_owner_tests.rs b/crates/ui-egui/src/pen_gesture_owner_tests.rs new file mode 100644 index 0000000000..6f6a093d19 --- /dev/null +++ b/crates/ui-egui/src/pen_gesture_owner_tests.rs @@ -0,0 +1,92 @@ +use egui::Modifiers; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; + +fn app(tool: Tool) -> 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; + app.ui.extras.snap = false; + app +} + +fn down(app: &mut PhotocraftApp) { + tool_event(app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Move { x: 50.0, y: 35.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Move { x: 90.0, y: 80.0, pressure: 1.0 }, Modifiers::NONE); + assert!(app.drag.is_some()); +} + +#[test] +fn freeform_and_path_selection_releases_cannot_edit_a_different_document() { + for tool in [Tool::FreeformPen, Tool::PathSelection] { + let mut app = app(tool); + app.run("path.set", json!({"path": {"subpaths": [{"knots": [[20, 20], [50, 35], [90, 80]]}]}})).unwrap(); + let original = app.session.active().unwrap().doc.work_path.clone(); + down(&mut app); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.sync_views(); + let revision = app.session.active().unwrap().revision; + tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 100.0 }, Modifiers::NONE); + assert!(app.drag.is_none()); + assert!(app.session.active().unwrap().doc.work_path.is_none()); + assert_eq!(app.session.active().unwrap().revision, revision); + assert_eq!(app.session.documents()[0].doc.work_path, original); + } +} + +#[test] +fn freeform_release_cancels_after_revision_or_target_change() { + for change in ["revision", "selected", "mode"] { + let mut app = app(Tool::FreeformPen); + down(&mut app); + match change { + "revision" => { + app.run("path.set", json!({"path": {"subpaths": [{"knots": [[10, 10], [15, 15]]}]}})).unwrap(); + } + "selected" => app.ui.selected_path = Some("Another path".into()), + _ => app.ui.tool_options.vector_mode = "shape".into(), + } + let before = app.session.active().unwrap().doc.clone(); + let revision = app.session.active().unwrap().revision; + tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 100.0 }, Modifiers::NONE); + assert!(app.drag.is_none()); + assert_eq!(app.session.active().unwrap().doc.work_path, before.work_path); + assert_eq!(app.session.active().unwrap().doc.layers, before.layers); + assert_eq!(app.session.active().unwrap().revision, revision); + } +} + +#[test] +fn freeform_valid_release_is_one_undoable_path_edit() { + let mut app = app(Tool::FreeformPen); + let history = app.session.active().unwrap().history.entries().len(); + down(&mut app); + tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 100.0 }, Modifiers::NONE); + assert!(app.session.active().unwrap().doc.work_path.is_some()); + assert_eq!(app.session.active().unwrap().history.entries().len(), history + 1); + app.run("edit.undo", json!({})).unwrap(); + assert!(app.session.active().unwrap().doc.work_path.is_none()); + app.run("edit.redo", json!({})).unwrap(); + assert!(app.session.active().unwrap().doc.work_path.is_some()); +} + +#[test] +fn frame_cancellation_consumes_old_events_after_switching_tools() { + let mut app = app(Tool::FreeformPen); + down(&mut app); + app.ui.tool = Tool::PolygonLasso; + app.ui.polygon = vec![[10.0, 10.0], [20.0, 20.0]]; + let polygon = app.ui.polygon.clone(); + app.sync_views(); + assert!(app.drag.is_none()); + let modifiers = Modifiers { alt: true, ..Modifiers::NONE }; + tool_event(&mut app, ToolEvent::Move { x: 100.0, y: 100.0, pressure: 1.0 }, modifiers); + tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 100.0 }, modifiers); + assert_eq!(app.ui.polygon, polygon, "old pointer events must not become a Polygonal Lasso gesture"); + assert!(!app.cancelled_vector_drag); +} diff --git a/crates/ui-egui/src/pen_path_ownership_tests.rs b/crates/ui-egui/src/pen_path_ownership_tests.rs new file mode 100644 index 0000000000..1a592d3e5a --- /dev/null +++ b/crates/ui-egui/src/pen_path_ownership_tests.rs @@ -0,0 +1,468 @@ +//! Pen drafts and explicit document paths retain their destination until a single commit. + +use egui::Modifiers; +use photocraft_doc::{LayerContent, Path}; +use serde_json::{Value, json}; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; +use crate::vector_ui; + +fn app(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.ui.tool = tool; + app +} + +fn steps(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.past_len() +} + +fn seed(app: &mut PhotocraftApp, name: &str, y: f64) { + app.run("path.set", json!({"name": name, "path": {"subpaths": [{"closed": false, "knots": [[10.0, y], [90.0, y]]}]}})).unwrap(); +} + +fn path(app: &PhotocraftApp, name: &str) -> Path { + let doc = &app.session.active().unwrap().doc; + if name == "work" { doc.work_path.clone().unwrap() } else { doc.paths.iter().find(|p| p.name == name).unwrap().path.clone() } +} + +fn click(app: &mut PhotocraftApp, x: f64, y: f64) { + tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Up { x, y }, Modifiers::NONE); +} + +fn draft(app: &mut PhotocraftApp) { + click(app, 30.0, 60.0); + click(app, 130.0, 60.0); +} + +#[test] +fn selected_work_and_saved_paths_append_pen_and_curvature_components_once() { + for tool in [Tool::Pen, Tool::CurvaturePen] { + for name in ["work", "Saved drawing"] { + let mut app = app(tool); + seed(&mut app, "work", 20.0); + seed(&mut app, "Saved drawing", 40.0); + app.ui.selected_path = Some(name.into()); + let (original, other) = (path(&app, name), path(&app, if name == "work" { "Saved drawing" } else { "work" })); + let n = steps(&app); + draft(&mut app); + assert_eq!(steps(&app), n, "placing anchors is not a history step"); + vector_ui::pen_commit(&mut app, false); + let after = path(&app, name); + assert_eq!(after.subpaths.len(), 2, "{tool:?}: {name}"); + assert_eq!(after.subpaths[0], original.subpaths[0]); + let anchors: Vec<_> = after.subpaths[1].knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect(); + assert_eq!(anchors, [[30.0, 60.0], [130.0, 60.0]]); + assert_eq!(path(&app, if name == "work" { "Saved drawing" } else { "work" }), other); + assert_eq!(app.ui.selected_path.as_deref(), Some(name)); + assert_eq!(steps(&app), n + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(path(&app, name), original); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(path(&app, name), after); + } + } +} + +#[test] +fn deselected_work_path_is_replaced_without_touching_saved_paths() { + let mut app = app(Tool::Pen); + seed(&mut app, "work", 20.0); + seed(&mut app, "Saved drawing", 40.0); + app.ui.selected_path = None; + let (before, saved, n) = (path(&app, "work"), path(&app, "Saved drawing"), steps(&app)); + draft(&mut app); + vector_ui::pen_commit(&mut app, false); + let after = path(&app, "work"); + assert_eq!(after.subpaths.len(), 1); + assert_eq!(after.subpaths[0].knots[0].anchor.y, 60.0); + assert_eq!(path(&app, "Saved drawing"), saved); + assert_eq!(steps(&app), n + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(path(&app, "work"), before); +} + +#[test] +fn captured_path_and_shape_mode_do_not_follow_later_ui_selection() { + for shape in [false, true] { + let mut app = app(Tool::Pen); + seed(&mut app, "work", 20.0); + seed(&mut app, "Saved drawing", 40.0); + app.ui.selected_path = Some("Saved drawing".into()); + app.ui.tool_options.vector_mode = if shape { "shape" } else { "path" }.into(); + let (work, saved, n) = (path(&app, "work"), path(&app, "Saved drawing"), steps(&app)); + click(&mut app, 30.0, 60.0); + app.ui.selected_path = Some("work".into()); + app.ui.tool_options.vector_mode = if shape { "path" } else { "shape" }.into(); + app.ui.vector_mask_target = true; + click(&mut app, 130.0, 60.0); + vector_ui::pen_commit(&mut app, false); + assert_eq!(path(&app, "work"), work); + if shape { + let st = app.session.active().unwrap(); + assert!(matches!(st.doc.layer(st.active_layer.unwrap()).unwrap().content, LayerContent::Shape(_))); + assert_eq!(path(&app, "Saved drawing"), saved); + assert_eq!(app.ui.selected_path.as_deref(), Some("layer")); + } else { + assert_eq!(path(&app, "Saved drawing").subpaths.len(), 2); + assert_eq!(app.ui.selected_path.as_deref(), Some("Saved drawing")); + } + assert_eq!(steps(&app), n + 1); + } +} + +#[test] +fn captured_pen_paint_preserves_float_colour_and_alpha_after_toolbar_changes() { + for closed in [false, true] { + let mut app = app(Tool::Pen); + app.ui.tool_options.vector_mode = "shape".into(); + app.ui.tool_options.shape_fill = true; + app.ui.tool_options.stroke_width = 3.0; + let foreground = [0.12345, 0.34567, 0.56789, 0.375]; + let background = [0.76543, 0.54321, 0.32109, 0.625]; + app.session.tools.foreground = foreground; + app.session.tools.background = background; + click(&mut app, 30.0, 60.0); + app.session.tools.foreground = [1.0; 4]; + app.session.tools.background = [0.0; 4]; + click(&mut app, 130.0, 60.0); + if closed { + click(&mut app, 90.0, 130.0); + } + vector_ui::pen_commit(&mut app, closed); + let st = app.session.active().unwrap(); + let LayerContent::Shape(shape) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("expected Pen shape") }; + let paint = |c: [f32; 4]| photocraft_doc::Fill::Solid(photocraft_doc::Color::rgba(c[0], c[1], c[2], c[3])); + assert_eq!(shape.fill, closed.then(|| paint(foreground))); + assert_eq!(shape.stroke.as_ref().unwrap().paint, paint(if closed { background } else { foreground })); + } +} + +#[test] +fn stale_drafts_are_cancelled_before_tool_switch_or_pointer_input() { + for tool in [Tool::Pen, Tool::CurvaturePen] { + for change in ["document", "revision", "reopen"] { + let mut app = app(tool); + seed(&mut app, "Saved drawing", 20.0); + app.ui.selected_path = Some("Saved drawing".into()); + draft(&mut app); + let source = app.session.active().unwrap().doc.clone(); + let revision = app.session.active().unwrap().revision; + match change { + "document" => { + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.session.execute("path.set", json!({"name":"Saved drawing", "path": {"subpaths": [{"knots": [[50, 50], [80, 80]]}]}})).unwrap(); + } + "revision" => { + app.session.execute("path.set", json!({"name":"work", "path": {"subpaths": [{"knots": [[50, 50], [80, 80]]}]}})).unwrap(); + } + "reopen" => { + app.session.close(0).unwrap(); + app.session.add_document((*source).clone(), None); + app.session.active_mut().unwrap().revision = revision; + } + _ => panic!("unknown document-change fixture: {change}"), + } + let (current, n) = (app.session.active().unwrap().doc.clone(), steps(&app)); + app.ui.tool = Tool::Brush; + vector_ui::finish_pen_if_left(&mut app); + assert!(app.ui.pen.is_none(), "{tool:?}: {change}"); + vector_ui::pen_move(&mut app, 160.0, 80.0, false); + vector_ui::curvature_move(&mut app, 160.0, 80.0); + vector_ui::pen_up(&mut app); + vector_ui::curvature_up(&mut app); + vector_ui::pen_commit(&mut app, false); + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, ¤t)); + assert_eq!(steps(&app), n); + assert_eq!(source.paths[0].path.subpaths.len(), 1); + } + } +} + +#[test] +fn sync_views_and_input_apis_discard_unowned_or_closed_document_drafts() { + for entry in ["sync", "move", "up", "commit", "undo", "toggle", "down"] { + let mut app = app(Tool::CurvaturePen); + draft(&mut app); + app.session.close(0).unwrap(); + match entry { + "sync" => app.sync_views(), + "move" => vector_ui::curvature_move(&mut app, 160.0, 80.0), + "up" => vector_ui::curvature_up(&mut app), + "commit" => { + vector_ui::pen_commit(&mut app, false); + } + "undo" => { + assert!(vector_ui::pen_undo_last_point(&mut app)); + } + "toggle" => vector_ui::curvature_toggle(&mut app, 30.0, 60.0), + "down" => vector_ui::curvature_down(&mut app, 160.0, 80.0), + _ => panic!("unknown stale-draft entry fixture: {entry}"), + } + assert!(app.ui.pen.is_none(), "{entry}"); + } + let mut app = app(Tool::Pen); + draft(&mut app); + let restored = serde_json::from_value(serde_json::to_value(app.ui.pen.as_ref().unwrap()).unwrap()).unwrap(); + app.ui.pen = Some(restored); + let n = steps(&app); + vector_ui::pen_commit(&mut app, false); + assert!(app.ui.pen.is_none()); + assert!(app.session.active().unwrap().doc.work_path.is_none()); + assert_eq!(steps(&app), n); +} + +#[test] +fn same_named_saved_paths_do_not_share_selection_or_drafts_across_tabs() { + let mut app = app(Tool::Pen); + seed(&mut app, "Saved drawing", 20.0); + let first = path(&app, "Saved drawing"); + app.run("file.new", json!({"width":200,"height":200})).unwrap(); + seed(&mut app, "Saved drawing", 90.0); + let second = path(&app, "Saved drawing"); + assert!(app.session.set_active(0)); + app.sync_views(); + app.ui.selected_path = Some("Saved drawing".into()); + draft(&mut app); + assert!(app.session.set_active(1)); + app.sync_views(); + assert!(app.ui.selected_path.is_none()); + assert!(app.ui.pen.is_none()); + let n = steps(&app); + vector_ui::pen_commit(&mut app, false); + assert_eq!(path(&app, "Saved drawing"), second); + assert_eq!(steps(&app), n); + // A fresh drawing without explicitly selecting B's saved path creates a work path. + draft(&mut app); + vector_ui::pen_commit(&mut app, false); + assert_eq!(path(&app, "Saved drawing"), second); + assert_eq!(path(&app, "work").subpaths[0].knots[0].anchor.y, 60.0); + assert!(app.session.set_active(0)); + app.sync_views(); + assert!(app.ui.selected_path.is_none()); + assert_eq!(path(&app, "Saved drawing"), first); +} + +#[test] +fn reopened_same_id_and_revision_do_not_reuse_the_saved_path_selection() { + let mut app = app(Tool::Pen); + seed(&mut app, "Saved drawing", 20.0); + app.ui.selected_path = Some("Saved drawing".into()); + let original = path(&app, "Saved drawing"); + draft(&mut app); + let closed = app.session.close(0).unwrap(); + // Recovery can preserve both the document ID and revision before another frame is drawn. + app.session.add_document((*closed.doc).clone(), None); + app.session.active_mut().unwrap().revision = closed.revision; + assert_eq!(app.session.active().unwrap().doc.id, closed.doc.id); + app.sync_views(); + assert!(app.ui.selected_path.is_none()); + assert!(app.ui.pen.is_none()); + draft(&mut app); + vector_ui::pen_commit(&mut app, false); + assert_eq!(path(&app, "Saved drawing"), original); + assert_eq!(path(&app, "work").subpaths[0].knots[0].anchor.y, 60.0); +} + +#[test] +fn ordinary_path_edits_undo_and_redo_preserve_the_explicit_saved_path_selection() { + let mut app = app(Tool::Pen); + seed(&mut app, "Saved drawing", 20.0); + app.ui.selected_path = Some("Saved drawing".into()); + let original = path(&app, "Saved drawing"); + let revision = app.session.active().unwrap().revision; + seed(&mut app, "Saved drawing", 90.0); + assert!(app.session.active().unwrap().revision > revision); + assert_eq!(app.ui.selected_path.as_deref(), Some("Saved drawing")); + let changed = path(&app, "Saved drawing"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(path(&app, "Saved drawing"), original); + assert_eq!(app.ui.selected_path.as_deref(), Some("Saved drawing")); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(path(&app, "Saved drawing"), changed); + assert_eq!(app.ui.selected_path.as_deref(), Some("Saved drawing")); + app.sync_views(); + assert_eq!(app.ui.selected_path.as_deref(), Some("Saved drawing")); +} + +#[test] +fn failed_pen_and_curvature_commits_keep_the_exact_draft_and_do_not_retry_each_frame() { + for tool in [Tool::Pen, Tool::CurvaturePen] { + let mut app = app(tool); + click(&mut app, 30.0, 60.0); + click(&mut app, 1e40, 60.0); + let (before, original, n) = (app.session.active().unwrap().doc.clone(), app.ui.pen.clone().unwrap(), steps(&app)); + vector_ui::pen_commit(&mut app, false); + let failed = app.ui.pen.as_ref().expect("invalid geometry leaves an editable draft"); + assert_eq!(failed.knots, original.knots); + assert_eq!(failed.unlinked, original.unlinked); + assert_eq!(failed.owner, original.owner); + assert!(failed.commit_failed && app.ui.status_error); + assert!(std::sync::Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); + assert_eq!(steps(&app), n); + app.ui.tool = Tool::Brush; + let journal = app.session.journal.len(); + vector_ui::finish_pen_if_left(&mut app); + assert_eq!(app.session.journal.len(), journal, "tool switch must not continually retry a failing commit"); + assert!(vector_ui::path_to_selection(&mut app, Value::Null).is_err(), "a failed draft cannot select an unrelated path"); + app.ui.pen.as_mut().unwrap().knots[1] = [[130.0, 60.0]; 3]; + vector_ui::pen_commit(&mut app, false); + assert!(app.ui.pen.is_none()); + assert_eq!(path(&app, "work").subpaths[0].knots[1].anchor.x, 130.0); + assert_eq!(steps(&app), n + 1); + } +} + +#[test] +fn ordinary_tool_switch_commits_the_owned_draft_once() { + for tool in [Tool::Pen, Tool::CurvaturePen] { + let mut app = app(tool); + draft(&mut app); + let n = steps(&app); + app.ui.tool = Tool::Brush; + vector_ui::finish_pen_if_left(&mut app); + vector_ui::finish_pen_if_left(&mut app); + assert_eq!(path(&app, "work").subpaths[0].knots.len(), 2); + assert_eq!(steps(&app), n + 1); + assert!(app.ui.pen.is_none()); + } +} + +#[test] +fn selected_saved_paths_move_and_free_transform_without_changing_work_or_shape() { + for tool in [Tool::PathSelection, Tool::DirectSelection] { + let mut app = app(tool); + app.run("shape.create", json!({"kind": "rect", "rect": [110, 110, 50, 40]})).unwrap(); + let shape = app.run("shape.info", json!({})).unwrap(); + seed(&mut app, "work", 20.0); + seed(&mut app, "Saved drawing", 40.0); + app.ui.selected_path = Some("Saved drawing".into()); + let (original, work, n) = (path(&app, "Saved drawing"), path(&app, "work"), steps(&app)); + let (params, selected) = vector_ui::free_transform_path(&app).unwrap(); + assert_eq!(params, json!({"name":"Saved drawing"})); + assert_eq!(selected, original); + vector_ui::path_selection_finish(&mut app, [0.0, 0.0], [10.4, -4.6]); + let moved = path(&app, "Saved drawing"); + for (a, b) in original.subpaths[0].knots.iter().zip(&moved.subpaths[0].knots) { + assert_eq!(b.anchor.x, a.anchor.x + 10.0); + assert_eq!(b.anchor.y, a.anchor.y - 5.0); + assert_eq!(b.in_ctrl.x, a.in_ctrl.x + 10.0); + assert_eq!(b.out_ctrl.y, a.out_ctrl.y - 5.0); + } + assert_eq!(path(&app, "work"), work); + assert_eq!(app.run("shape.info", json!({})).unwrap(), shape); + assert_eq!(steps(&app), n + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(path(&app, "Saved drawing"), original); + let mut params = params; + params["matrix"] = json!([1, 0, 0, 1, 7, 9]); + app.run("path.transform", params).unwrap(); + assert_eq!(path(&app, "Saved drawing").subpaths[0].knots[0].anchor.x, 17.0); + assert_eq!(path(&app, "work"), work); + assert_eq!(app.run("shape.info", json!({})).unwrap(), shape); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(path(&app, "Saved drawing"), original); + app.ui.selected_path = None; + assert_eq!(vector_ui::free_transform_path(&app).unwrap().0["name"], "layer", "deselection restores the shape fallback"); + } +} + +#[test] +fn explicit_work_path_precedes_vector_mask_and_deselection_restores_mask_fallback() { + let mut app = app(Tool::PathSelection); + seed(&mut app, "work", 20.0); + let layer = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.vectorMask.add", json!({"layer":layer.0,"path":{"subpaths":[{"knots":[[30,40],[80,40]]}]}})).unwrap(); + app.ui.vector_mask_target = true; + app.ui.selected_path = Some("work".into()); + assert_eq!(vector_ui::free_transform_path(&app).unwrap().0, json!({"name":"work"})); + app.ui.selected_path = None; + assert_eq!(vector_ui::free_transform_path(&app).unwrap().0, json!({"name":"layer","layer":layer.0})); +} + +#[test] +fn pen_keeps_its_captured_vector_mask_when_another_path_is_selected() { + let mut app = app(Tool::Pen); + seed(&mut app, "Saved drawing", 20.0); + let layer = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.vectorMask.add", json!({"layer":layer.0,"path":{"subpaths":[{"knots":[[30,40],[80,40]]}]}})).unwrap(); + app.ui.selected_path = None; + app.ui.vector_mask_target = true; + let (saved, original, n) = (path(&app, "Saved drawing"), app.session.active().unwrap().doc.layer(layer).unwrap().vector_mask.clone().unwrap(), steps(&app)); + click(&mut app, 30.0, 60.0); + app.ui.vector_mask_target = false; + app.ui.selected_path = Some("Saved drawing".into()); + click(&mut app, 130.0, 60.0); + vector_ui::pen_commit(&mut app, false); + let mask = app.session.active().unwrap().doc.layer(layer).unwrap().vector_mask.clone().unwrap(); + assert_eq!(mask.path.subpaths.len(), 2); + assert_eq!(mask.path.subpaths[0], original.path.subpaths[0]); + assert_eq!(mask.path.subpaths[1].knots[0].anchor.y, 60.0); + assert_eq!(path(&app, "Saved drawing"), saved); + assert!(app.session.active().unwrap().doc.work_path.is_none()); + assert_eq!(steps(&app), n + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc.layer(layer).unwrap().vector_mask.as_ref(), Some(&original)); +} + +#[test] +fn freeform_finish_uses_its_captured_saved_path_and_rejects_foreign_documents() { + let mut app = app(Tool::FreeformPen); + seed(&mut app, "work", 20.0); + seed(&mut app, "Saved drawing", 40.0); + app.ui.selected_path = Some("Saved drawing".into()); + let owner = vector_ui::capture_pen_owner(&app).unwrap(); + let points = [[30.0, 60.0, 1.0], [80.0, 60.0, 1.0], [130.0, 60.0, 1.0]]; + let (original, work, n) = (path(&app, "Saved drawing"), path(&app, "work"), steps(&app)); + app.ui.selected_path = Some("work".into()); + app.ui.tool_options.vector_mode = "shape".into(); + vector_ui::freeform_finish_owned(&mut app, &points, &[], owner); + assert_eq!(path(&app, "Saved drawing").subpaths.len(), 2); + assert_eq!(path(&app, "Saved drawing").subpaths[0], original.subpaths[0]); + assert_eq!(path(&app, "work"), work); + assert_eq!(steps(&app), n + 1); + app.ui.tool_options.vector_mode = "path".into(); + let owner = vector_ui::capture_pen_owner(&app).unwrap(); + app.session.execute("file.new", json!({"width":200,"height":200})).unwrap(); + let (source, n) = (app.session.active().unwrap().doc.clone(), steps(&app)); + vector_ui::freeform_finish_owned(&mut app, &points, &[], owner); + assert!(std::sync::Arc::ptr_eq(&source, &app.session.active().unwrap().doc)); + assert_eq!(steps(&app), n); +} + +#[test] +fn tab_switch_commits_only_the_valid_original_captured_saved_path() { + let mut app = app(Tool::Pen); + seed(&mut app, "Saved drawing", 20.0); + app.ui.selected_path = Some("Saved drawing".into()); + draft(&mut app); + let source = app.session.active().unwrap().doc.id; + app.session.execute("file.new", json!({"width":200,"height":200})).unwrap(); + let current = app.session.active().unwrap().doc.clone(); + assert!(vector_ui::cancel_stale_pen(&mut app)); + assert!(app.ui.pen.is_none()); + assert!(std::sync::Arc::ptr_eq(¤t, &app.session.active().unwrap().doc)); + let old = app.session.documents().iter().find(|s| s.doc.id == source).unwrap(); + assert_eq!(old.doc.paths.iter().find(|p| p.name == "Saved drawing").unwrap().path.subpaths.len(), 2); + assert!(old.doc.work_path.is_none()); +} +#[test] +fn deleting_the_captured_target_cancels_without_recreating_it() { + let mut app = app(Tool::Pen); + seed(&mut app, "Saved drawing", 20.0); + app.ui.selected_path = Some("Saved drawing".into()); + draft(&mut app); + app.session.execute("path.delete", json!({"name":"Saved drawing"})).unwrap(); + let current = app.session.active().unwrap().doc.clone(); + let n = steps(&app); + vector_ui::cancel_stale_pen(&mut app); + assert!(app.ui.pen.is_none()); + assert!(app.ui.status_error); + assert!(std::sync::Arc::ptr_eq(¤t, &app.session.active().unwrap().doc)); + assert_eq!(steps(&app), n); + assert!(app.session.active().unwrap().doc.paths.is_empty()); +} diff --git a/crates/ui-egui/src/pen_path_visual_tests.rs b/crates/ui-egui/src/pen_path_visual_tests.rs new file mode 100644 index 0000000000..6fc23e8ade --- /dev/null +++ b/crates/ui-egui/src/pen_path_visual_tests.rs @@ -0,0 +1,101 @@ +//! Synthetic offscreen evidence for editing a selected saved path beside the Work Path. + +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::kittest::Queryable; +use photocraft_doc::Path; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::ViewXform; +use crate::state::Tool; + +fn path(app: &PhotocraftApp, name: &str) -> Path { + let doc = &app.session.active().unwrap().doc; + if name == "work" { doc.work_path.clone().unwrap() } else { doc.paths.iter().find(|p| p.name == name).unwrap().path.clone() } +} + +fn translated(mut path: Path, dx: f64, dy: f64) -> Path { + for knot in path.subpaths.iter_mut().flat_map(|s| &mut s.knots) { + for point in [&mut knot.anchor, &mut knot.in_ctrl, &mut knot.out_ctrl] { + point.x += dx; + point.y += dy; + } + } + path +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +/// Run explicitly with a wgpu adapter. PHOTOCRAFT_PEN_VISUAL_DIR chooses the output directory; +/// PHOTOCRAFT_PEN_VISUAL_BASELINE=1 characterizes the old Work Path targeting for a before capture. +#[test] +#[ignore = "manual offscreen visual fixture; writes synthetic screenshots"] +fn saved_path_drag_visual_fixture() { + let baseline = std::env::var("PHOTOCRAFT_PEN_VISUAL_BASELINE").is_ok_and(|v| v == "1"); + let output = std::env::var_os("PHOTOCRAFT_PEN_VISUAL_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| "log/pen-path-visual".into()); + std::fs::create_dir_all(&output).expect("create synthetic screenshot directory"); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1280.0, 800.0)).with_max_steps(64).wgpu().build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.edit_prefs(|p| p.interface.language = "en".into()); + if let Some(rs) = cc.wgpu_render_state.as_ref() { + app.set_wgpu(rs.clone()); + } + app.run("file.new", json!({"name": "Saved path targeting", "width": 400, "height": 260, "background": "white"})).unwrap(); + app.run("path.set", json!({"name": "work", "path": {"subpaths": [{"closed": true, "knots": [[40, 80], [140, 80], [90, 180]]}]}})).unwrap(); + app.run( + "path.set", + json!({"name": "Saved drawing", "path": {"subpaths": [{"closed": false, "knots": [ + {"anchor": [220, 90], "in": [220, 90], "out": [260, 40]}, + {"anchor": [320, 180], "in": [280, 230], "out": [320, 180]} + ]}]}}), + ) + .unwrap(); + app.sync_views(); + app.ui.tool = Tool::PathSelection; + app.ui.extras.snap = false; + app.ui.dock_tabs.layers = 2; + app.ui.views[0].fit_pending = false; + app.ui.views[0].zoom = 1.5; + app.ui.views[0].center = [200.0, 130.0]; + app + }); + h.run_steps(8); + h.get_by_label("Saved drawing").click(); + h.run_steps(4); + assert_eq!(h.state().ui.selected_path.as_deref(), Some("Saved drawing"), "the Paths panel row selects the saved path"); + let (work, saved) = (path(h.state(), "work"), path(h.state(), "Saved drawing")); + let history = h.state().session.active().unwrap().history.past_len(); + let mode = if baseline { "baseline" } else { "fixed" }; + let before = output.join(format!("{mode}-before.png")); + h.render().expect("render initial scene").save(&before).expect("save initial scene"); + + // Screen coordinates come from the real full-app layout, including its dock and rulers. + let xf = ViewXform::active(h.state()).expect("active document canvas"); + let start = xf.to_screen(220.0, 90.0); + let end = xf.to_screen(245.0, 70.0); + assert!(xf.rect.contains(start) && xf.rect.contains(end)); + h.event(Event::PointerMoved(start)); + h.event(button(start, true)); + h.step(); + h.event(Event::PointerMoved(end)); + h.step(); + assert!(h.state().drag.is_some(), "native movement past the drag threshold starts a path gesture"); + h.event(button(end, false)); + h.step(); + h.run_steps(12); + assert!(h.state().drag.is_none()); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1, "one native drag is one Undo step"); + if baseline { + assert_eq!(path(h.state(), "Saved drawing"), saved, "baseline incorrectly leaves the selected saved path untouched"); + assert_eq!(path(h.state(), "work"), translated(work, 25.0, -20.0), "baseline incorrectly moves the Work Path"); + } else { + assert_eq!(path(h.state(), "work"), work, "editing a saved path preserves the Work Path"); + assert_eq!(path(h.state(), "Saved drawing"), translated(saved, 25.0, -20.0), "the selected saved path and its curve handles move together"); + } + let after = output.join(format!("{mode}-after.png")); + h.render().expect("render completed drag").save(&after).expect("save completed drag"); + println!("{mode}: selected Saved drawing; native drag (+25, -20); screenshots: {}, {}", before.display(), after.display()); +} diff --git a/crates/ui-egui/src/theme/tests.rs b/crates/ui-egui/src/theme/tests.rs index 45840eee4a..41cc21279f 100644 --- a/crates/ui-egui/src/theme/tests.rs +++ b/crates/ui-egui/src/theme/tests.rs @@ -34,7 +34,7 @@ fn hidden_scrollbars_keep_wheel_scrolling_available() { ui.label(format!("Row {i}")); } }); - *offset = area.state.offset.y; + *offset = area.state.clamped_offset().y; }, 0.0, ); @@ -42,6 +42,7 @@ fn hidden_scrollbars_keep_wheel_scrolling_available() { h.run(); h.hover_at(egui::pos2(30.0, 40.0)); h.event(egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, unit: egui::MouseWheelUnit::Line, delta: egui::vec2(0.0, -3.0), phase: egui::TouchPhase::Move, diff --git a/crates/ui-egui/src/tools/anchor_tools.rs b/crates/ui-egui/src/tools/anchor_tools.rs index 26c765b662..e280b3382c 100644 --- a/crates/ui-egui/src/tools/anchor_tools.rs +++ b/crates/ui-egui/src/tools/anchor_tools.rs @@ -1,6 +1,6 @@ //! Add Anchor Point and Delete Anchor Point tools (P group with the Pen, #1044), as in Photoshop. -//! They edit the paths Direct Selection edits, front first: the targeted vector mask or the active -//! shape layer's path, then the work path. +//! They edit the paths Direct Selection edits, front first: the work or saved path selected in the +//! Paths panel, the targeted vector mask or the active shape layer's path, then the work path. //! //! * Add Anchor Point: hovering a segment highlights it and shows where the anchor would go; a //! click inserts it there (`path.addAnchor`). A curve is split without changing its shape. @@ -38,9 +38,7 @@ pub fn effective(tool: Tool, mods: Modifiers) -> Option { /// segment (Add) or anchor (Delete) hit on it. An anchor hit with Add, or a segment with Delete, /// edits nothing. fn target(app: &PhotocraftApp, tool: Tool, p: [f64; 2]) -> Option<(PathRef, Path, Hit)> { - let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); - let (r, path, hit) = - crate::direct_select::candidates(app).into_iter().find_map(|(r, path)| edit::hit(&path, Point::new(p[0], p[1]), tol, &[]).map(|h| (r, path, h)))?; + let (r, path, hit) = crate::direct_select::hit_target(app, p, false)?; match (tool, hit) { (Tool::AddAnchorPoint, Hit::Segment(..)) | (Tool::DeleteAnchorPoint, Hit::Anchor(_)) => Some((r, path, hit)), _ => None, @@ -140,7 +138,7 @@ fn apply(app: &mut PhotocraftApp, r: PathRef, hit: Hit) { } match app.run(id, params) { // The anchors shift: a Direct Selection on this path would point at the wrong ones. - Ok(_) if app.ui.direct_selection.target == Some(r) => app.ui.direct_selection = DirectSelection { target: Some(r), ..Default::default() }, + Ok(_) if app.ui.direct_selection.target.as_ref() == Some(&r) => app.ui.direct_selection = DirectSelection { target: Some(r), ..Default::default() }, Ok(_) => {} Err(e) => { app.ui.status = e; diff --git a/crates/ui-egui/src/tools/anchor_tools/tests.rs b/crates/ui-egui/src/tools/anchor_tools/tests.rs index c21cc5aaf6..e4414e001c 100644 --- a/crates/ui-egui/src/tools/anchor_tools/tests.rs +++ b/crates/ui-egui/src/tools/anchor_tools/tests.rs @@ -133,7 +133,7 @@ fn shape_layer_paths_are_edited_and_a_direct_selection_is_cleared() { let mut app = app(Tool::DirectSelection); let id = app.run("shape.create", json!({"kind": "rect", "rect": [20, 20, 60, 60], "fill": "#3070c0"})).unwrap()["layer"].as_u64().unwrap(); click(&mut app, [80.0, 80.0], Modifiers::NONE); - assert_eq!((app.ui.direct_selection.target, app.ui.direct_selection.anchors.clone()), (Some(PathRef::Layer(id)), vec![[0, 2]])); + assert_eq!((app.ui.direct_selection.target.clone(), app.ui.direct_selection.anchors.clone()), (Some(PathRef::Layer(id)), vec![[0, 2]])); app.ui.tool = Tool::AddAnchorPoint; click(&mut app, [50.0, 20.0], Modifiers::NONE); let info = app.run("shape.info", json!({"layer": id})).unwrap(); @@ -231,6 +231,32 @@ fn the_pen_cursor_shows_what_auto_add_delete_would_do() { assert_eq!(badge(&app, Tool::Pen, Modifiers::NONE), None); } +#[test] +fn add_and_delete_anchor_tools_edit_the_selected_saved_path() { + let mut app = app(Tool::AddAnchorPoint); + with_path(&mut app); + let before_work = work(&app); + let path = photocraft_engine::vector_cmds::path_json(&before_work); + app.run("path.set", json!({"name":"Saved", "path":path})).unwrap(); + app.ui.selected_path = Some("Saved".into()); + let n = steps(&app); + click(&mut app, [80.0, 120.0], Modifiers::NONE); + let saved = || app.session.active().unwrap().doc.paths.iter().find(|path| path.name == "Saved").unwrap().path.clone(); + let added = saved(); + assert_eq!(added.subpaths[0].knots.len(), 5); + assert_eq!(work(&app), before_work); + assert_eq!(steps(&app), n + 1); + app.ui.tool = Tool::DeleteAnchorPoint; + let anchor = added.subpaths[0].knots[3].anchor; + click(&mut app, [anchor.x, anchor.y], Modifiers::NONE); + let deleted = app.session.active().unwrap().doc.paths.iter().find(|path| path.name == "Saved").unwrap().path.clone(); + assert_eq!(deleted.subpaths[0].knots.len(), 4); + assert_eq!(work(&app), before_work); + assert_eq!(steps(&app), n + 2); + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc.paths.iter().find(|path| path.name == "Saved").unwrap().path, added); +} + fn pen_anchors(app: &PhotocraftApp) -> Vec<[f64; 2]> { app.ui.pen.as_ref().map(|p| p.knots.iter().map(|k| k[0]).collect()).unwrap_or_default() } diff --git a/crates/ui-egui/src/tools/direct_select.rs b/crates/ui-egui/src/tools/direct_select.rs index 42c1922b1f..f38792558e 100644 --- a/crates/ui-egui/src/tools/direct_select.rs +++ b/crates/ui-egui/src/tools/direct_select.rs @@ -81,6 +81,94 @@ pub struct DirectSelection { /// adds the anchor it then drags, and both are one step. #[serde(skip)] pub coalesce: Option, + #[serde(skip)] + pub(crate) owner: Option, + #[serde(skip)] + pub(crate) cancelled_drag: bool, +} + +/// Runtime selection ownership is never restored from settings onto another document. +#[derive(Clone, Debug)] +pub(crate) struct Owner { + document: photocraft_doc::DocId, + source: std::sync::Weak, + revision: u64, + layer: Option, + selected_path: Option, + vector_mask: bool, + tool: Tool, +} + +impl PartialEq for Owner { + fn eq(&self, other: &Self) -> bool { + self.source.ptr_eq(&other.source) + && (self.document, self.revision, self.layer, &self.selected_path, self.vector_mask, self.tool) + == (other.document, other.revision, other.layer, &other.selected_path, other.vector_mask, other.tool) + } +} + +impl Owner { + fn current(app: &PhotocraftApp) -> Option { + let st = app.session.active()?; + Some(Self { + document: st.doc.id, + source: std::sync::Arc::downgrade(&st.doc), + revision: st.revision, + layer: st.active_layer, + selected_path: app.ui.selected_path.clone(), + vector_mask: app.ui.vector_mask_target || st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::VectorMask, + tool: app.ui.tool, + }) + } + + fn same_target(&self, app: &PhotocraftApp) -> bool { + self.same_path_target(app) && app.ui.tool == self.tool + } + + fn same_path_target(&self, app: &PhotocraftApp) -> bool { + app.session.active().is_some_and(|st| { + st.doc.id == self.document + && st.active_layer == self.layer + && (app.ui.vector_mask_target || st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::VectorMask) == self.vector_mask + }) && app.ui.selected_path == self.selected_path + } + + fn valid(&self, app: &PhotocraftApp) -> bool { + self.valid_path(app) && app.ui.tool == self.tool + } + + fn valid_path(&self, app: &PhotocraftApp) -> bool { + self.same_path_target(app) + && app.session.active().is_some_and(|st| st.revision == self.revision && self.source.ptr_eq(&std::sync::Arc::downgrade(&st.doc))) + } +} + +/// Is `ds` still about the document, revision and target it was made on? Convert Point works on +/// the handles selected with Direct Selection, so an idle selection crosses those two tools; a +/// drag keeps its own tool. +fn still_valid(app: &PhotocraftApp, ds: &DirectSelection) -> bool { + ds.owner.as_ref().is_some_and(|owner| owner.valid(app) || (ds.drag.is_none() && owns(owner.tool) && owns(app.ui.tool) && owner.valid_path(app))) +} + +/// Clear stale anchors and gestures, keeping the pointer until the old release arrives. True +/// when a drag was cancelled. +pub(crate) fn cancel_stale(app: &mut PhotocraftApp) -> bool { + let ds = &app.ui.direct_selection; + let empty = ds.target.is_none() && ds.anchors.is_empty() && ds.segment.is_none() && ds.drag.is_none(); + if (ds.owner.is_none() && empty) || still_valid(app, ds) { + return false; + } + let cancelled_drag = ds.cancelled_drag || ds.drag.is_some(); + app.ui.direct_selection = DirectSelection { cancelled_drag, ..Default::default() }; + cancelled_drag +} + +/// Rebind a selection to the revision its own command made; another target or tool drops it. +fn keep_after_command(app: &mut PhotocraftApp, mut selection: DirectSelection) { + if selection.owner.as_ref().is_some_and(|owner| owner.same_target(app)) { + selection.owner = Owner::current(app); + app.ui.direct_selection = selection; + } } #[derive(Clone, Debug, PartialEq)] @@ -181,13 +269,15 @@ pub(crate) fn active_paths(app: &PhotocraftApp) -> Vec<(PathRef, Path)> { .or_else(|| crate::vector_ui::active_shape_path(app)) .map(|(id, p)| (PathRef::Layer(id), p)) .or_else(|| crate::vector_ui::active_type_path(app).map(|(id, p)| (PathRef::TypePath(id), p))); - let work = app.session.active().and_then(|st| st.doc.work_path.clone()).filter(|_| crate::vector_ui::work_path_shown(app)); - let saved = crate::vector_ui::selected_saved_path_index(app); - layer.into_iter().chain(saved.map(|(i, p)| (PathRef::Saved(i), p))).chain(work.map(|p| (PathRef::Work, p))).collect() + let saved = crate::vector_ui::selected_saved_path_index(app).map(|(i, p)| (PathRef::Saved(i), p)); + let work = app.session.active().and_then(|st| st.doc.work_path.clone()).filter(|_| crate::vector_ui::work_path_shown(app)).map(|p| (PathRef::Work, p)); + let mut out: Vec<_> = saved.into_iter().chain(layer).chain(work).collect(); + out.retain(|(target, _)| writable(app, target)); + out } -/// The paths Direct Selection edits, front first: the Pen path being drawn, the active paths, then -/// every visible shape layer. +/// The paths Direct Selection edits, front first: the Pen path being drawn, the active paths, every +/// visible shape layer, then the work path. pub(crate) fn candidates(app: &PhotocraftApp) -> Vec<(PathRef, Path)> { let active = app.session.active().map(|st| st.doc.id); let pen = app @@ -209,10 +299,37 @@ pub(crate) fn candidates(app: &PhotocraftApp) -> Vec<(PathRef, Path)> { out.push((PathRef::Layer(l.id.0), sh.path.clone())); } } + if !out.iter().any(|(r, _)| *r == PathRef::Work) { + out.extend(st.doc.work_path.clone().map(|p| (PathRef::Work, p))); + } } out } +/// Can `target` be edited: a layer's path only while the layer is not locked. +pub(crate) fn writable(app: &PhotocraftApp, target: &PathRef) -> bool { + match target { + PathRef::Layer(id) | PathRef::TypePath(id) => app.session.active().is_some_and(|st| { + let id = photocraft_doc::LayerId(*id); + let locks = st.doc.effective_locks(id); + st.doc.layer(id).is_some() && !locks.all && !locks.pixels + }), + _ => app.session.active().is_some(), + } +} + +/// The frontmost editable path under `p` and what is hit on it: the handles shown on a selection +/// still valid count when `handles` is set. The hover and the press share it. +pub(crate) fn hit_target(app: &PhotocraftApp, p: [f64; 2], handles: bool) -> Option<(PathRef, Path, Hit)> { + let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); + let ds = &app.ui.direct_selection; + let valid = handles && still_valid(app, ds); + candidates(app).into_iter().filter(|(r, _)| writable(app, r)).find_map(|(r, path)| { + let shown = if valid && ds.target.as_ref() == Some(&r) { shown_handles(ds, &path) } else { Vec::new() }; + edit::hit(&path, Point::new(p[0], p[1]), tol, &shown).map(|h| (r, path, h)) + }) +} + fn retracted(k: &photocraft_doc::Knot, h: Handle) -> bool { let c = if h == Handle::In { k.in_ctrl } else { k.out_ctrl }; (c.x - k.anchor.x).abs() < 1e-9 && (c.y - k.anchor.y).abs() < 1e-9 @@ -238,6 +355,18 @@ fn shown_handles(ds: &DirectSelection, path: &Path) -> Vec<([usize; 2], Handle)> /// Canvas pointer events for Direct Selection and the Pen's ⌘ / ⌥ modes; false when the event is /// not theirs. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool { + cancel_stale(app); + if app.ui.direct_selection.cancelled_drag { + // The rest of a gesture cancelled on another document or target goes nowhere. + match ev { + ToolEvent::Down { .. } => app.ui.direct_selection.cancelled_drag = false, + ToolEvent::Up { .. } => { + app.ui.direct_selection.cancelled_drag = false; + return true; + } + ToolEvent::Move { .. } => return true, + } + } let tool = app.ui.tool; match ev { ToolEvent::Down { x, y, .. } => { @@ -255,12 +384,17 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool // Photoshop. A press on it edits it in place and the Pen carries on (#4022). A // refused commit keeps the draft and the press does nothing more. if !crate::vector_ui::pen_commit(app, false) { + app.ui.direct_selection.cancelled_drag = true; return true; } } if tool == Tool::FreeformPen { // The same for a Freeform Pen path waiting for its next ⌥-click. crate::lasso_ui::commit(app); + if app.ui.pen.is_some() { + app.ui.direct_selection.cancelled_drag = true; + return true; + } } press(app, [x, y], mods, convert) } @@ -294,29 +428,21 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool /// Does a press at `p` hit the Pen path being drawn: one of its anchors, shown handles or segments? fn hits_pen(app: &PhotocraftApp, p: [f64; 2]) -> bool { - let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); - let ds = &app.ui.direct_selection; - candidates(app).into_iter().filter(|(r, _)| *r == PathRef::Pen).any(|(r, path)| { - let handles = if ds.target == Some(r) { shown_handles(ds, &path) } else { Vec::new() }; - edit::hit(&path, Point::new(p[0], p[1]), tol, &handles).is_some() - }) + hit_target(app, p, true).is_some_and(|(r, ..)| r == PathRef::Pen) } /// A press: pick what's under the pointer and start a drag. False lets the Pen handle a ⌥-press /// that hit nothing; the Convert Point tool has nothing else to do, so it keeps the press. fn press(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers, convert: bool) -> bool { let own_tool = app.ui.tool == Tool::ConvertPoint; - let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); - let ds = &app.ui.direct_selection; - let found = candidates(app).into_iter().find_map(|(r, path)| { - let handles = if ds.target == Some(r) { shown_handles(ds, &path) } else { Vec::new() }; - edit::hit(&path, Point::new(p[0], p[1]), tol, &handles).map(|h| (r, path, h)) - }); - let Some((target, base, hit)) = found else { + let Some((target, base, hit)) = hit_target(app, p, true) else { if convert { return own_tool; } - app.ui.direct_selection.drag = Some(Drag::Marquee { start: p, end: p, add: mods.shift }); + let owner = Owner::current(app); + let ds = &mut app.ui.direct_selection; + ds.drag = Some(Drag::Marquee { start: p, end: p, add: mods.shift }); + ds.owner = owner; return true; }; if let PathRef::Layer(id) | PathRef::TypePath(id) = target @@ -324,10 +450,12 @@ fn press(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers, convert: bool) - { let _ = app.run("layer.select", json!({"layer": id})); } + let owner = Owner::current(app); let ds = &mut app.ui.direct_selection; - if ds.target != Some(target) { - *ds = DirectSelection { target: Some(target), ..Default::default() }; + if ds.target.as_ref() != Some(&target) { + *ds = DirectSelection { target: Some(target.clone()), ..Default::default() }; } + ds.owner = owner; let edit = match hit { Hit::Handle(k, h) => { let kn = base.subpaths.get(k[0]).and_then(|s| s.knots.get(k[1])); @@ -482,9 +610,18 @@ fn release(app: &mut PhotocraftApp, target: PathRef, base: &Path, edit: &Edit, s if let (Some(p), Some(key)) = (params.as_object_mut(), app.ui.direct_selection.coalesce.take()) { p.insert("coalesce".into(), json!(key)); } - if let Err(e) = app.run(id, params) { - app.ui.status = e; - app.ui.status_error = true; + // sync_views checks ownership while the command runs: keep this selection off the live UI + // until its own command is done, then bind it to the revision that made. + let selection = std::mem::take(&mut app.ui.direct_selection); + match app.run(id, params) { + Ok(_) => keep_after_command(app, selection), + Err(e) => { + if selection.owner.as_ref().is_some_and(|owner| owner.valid(app)) { + app.ui.direct_selection = selection; + } + app.ui.status = e; + app.ui.status_error = true; + } } } @@ -493,9 +630,10 @@ fn release(app: &mut PhotocraftApp, target: PathRef, base: &Path, edit: &Edit, s fn marquee(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2], add: bool) { let rect = [start[0], start[1], end[0], end[1]]; let hits = candidates(app).into_iter().map(|(r, path)| (r, edit::anchors_in(&path, rect))).find(|(_, a)| !a.is_empty()); + let owner = Owner::current(app); let ds = &mut app.ui.direct_selection; match hits { - Some((r, anchors)) if add && ds.target == Some(r) => { + Some((r, anchors)) if add && ds.target.as_ref() == Some(&r) => { for a in anchors { if !ds.anchors.contains(&a) { ds.anchors.push(a); @@ -510,14 +648,16 @@ fn marquee(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2], add: bool) { ds.segment = None; } } + ds.owner = owner; } /// Arrow keys with Direct Selection and selected anchors (#4486), as in Photoshop: nudge them /// 1 px (⇧ 10 px) through `path.moveAnchors`, one history step per press. Not while a drag is /// in progress. Returns true when a key was used. pub fn arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { + cancel_stale(app); let ds = &app.ui.direct_selection; - let Some(target) = ds.target.filter(|_| app.ui.tool == Tool::DirectSelection && !ds.anchors.is_empty() && ds.drag.is_none()) else { + let Some(target) = ds.target.clone().filter(|_| app.ui.tool == Tool::DirectSelection && !ds.anchors.is_empty() && ds.drag.is_none()) else { return false; }; // The anchors belong to a path the canvas no longer offers (another saved path picked in the @@ -539,9 +679,15 @@ pub fn arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { if let (Some(p), Some(t)) = (params.as_object_mut(), target.params(app).as_object()) { p.extend(t.clone()); } - if let Err(e) = app.run("path.moveAnchors", params) { - app.ui.status = e; - app.ui.status_error = true; + // The nudge makes a new revision: the selection follows it, as after a drag. + let selection = std::mem::take(&mut app.ui.direct_selection); + match app.run("path.moveAnchors", params) { + Ok(_) => keep_after_command(app, selection), + Err(e) => { + app.ui.direct_selection = selection; + app.ui.status = e; + app.ui.status_error = true; + } } return true; } @@ -569,7 +715,7 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn F _ => path, }; crate::vector_ui::draw_outline(painter, &path, to_scr, accent); - let ours = ds.target == Some(r); + let ours = ds.target.as_ref() == Some(&r); for &(k, h) in if ours { shown_handles(ds, &path) } else { Vec::new() }.iter() { if let Some(kn) = path.subpaths.get(k[0]).and_then(|s| s.knots.get(k[1])) { let c = if h == Handle::In { kn.in_ctrl } else { kn.out_ctrl }; @@ -590,3 +736,70 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn F #[cfg(test)] mod tests; + +#[cfg(test)] +mod freeform_command_tests { + use super::*; + + /// Is a Direct Selection drag under way on the document and target it started on? + fn captured_drag(app: &PhotocraftApp) -> bool { + app.ui.direct_selection.drag.is_some() && app.ui.direct_selection.owner.as_ref().is_some_and(|owner| owner.valid(app)) + } + + fn app(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + for name in ["work", "Saved drawing"] { + app.run("path.set", json!({"name": name, "path":{"subpaths":[{"knots":[[20,20],[120,20]]}]}})).unwrap(); + } + app.sync_views(); + app.ui.tool = tool; + app.ui.selected_path = Some("Saved drawing".into()); + app + } + + #[test] + fn command_with_freeform_and_curvature_edits_the_selected_saved_path_after_modifier_release() { + for tool in [Tool::FreeformPen, Tool::CurvaturePen] { + let mut app = app(tool); + let original = app.session.active().unwrap().doc.clone(); + let n = app.session.active().unwrap().history.past_len(); + let command = Modifiers { command: true, ctrl: true, ..Default::default() }; + assert!(pointer(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, command)); + assert_eq!(app.ui.direct_selection.target, Some(PathRef::Saved(0))); + assert!(captured_drag(&app)); + assert!(pointer(&mut app, ToolEvent::Move { x: 30.0, y: 40.0, pressure: 1.0 }, Modifiers::NONE)); + assert!(pointer(&mut app, ToolEvent::Up { x: 30.0, y: 40.0 }, Modifiers::NONE)); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.work_path, original.work_path); + assert_eq!(st.doc.paths[0].path.subpaths[0].knots[0].anchor, Point::new(30.0, 40.0)); + assert_eq!(st.history.past_len(), n + 1); + assert!(app.ui.direct_selection.owner.as_ref().unwrap().valid(&app)); + assert!(!captured_drag(&app)); + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc.paths, original.paths); + } + } + + #[test] + fn command_cannot_edit_an_existing_path_when_the_freeform_trace_failed_to_finish() { + let mut app = app(Tool::FreeformPen); + let original = app.session.active().unwrap().doc.clone(); + let n = app.session.active().unwrap().history.past_len(); + for [x, y] in [[40.0, 60.0], [1e40, 60.0], [140.0, 80.0]] { + assert!(crate::lasso_ui::pointer(&mut app, ToolEvent::Down { x, y, pressure: 1.0 }, Modifiers::ALT)); + assert!(crate::lasso_ui::pointer(&mut app, ToolEvent::Up { x, y }, Modifiers::ALT)); + } + let command = Modifiers { command: true, ctrl: true, ..Default::default() }; + assert!(pointer(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, command)); + let pen = app.ui.pen.as_ref().expect("failed Freeform commit must preserve its fitted draft"); + assert!(pen.commit_failed && pen.owner.is_some()); + assert!(app.ui.direct_selection.drag.is_none()); + assert!(app.ui.direct_selection.cancelled_drag); + assert!(pointer(&mut app, ToolEvent::Up { x: 30.0, y: 40.0 }, command)); + assert!(app.ui.direct_selection.drag.is_none()); + assert_eq!(app.session.active().unwrap().doc.paths, original.paths); + assert_eq!(app.session.active().unwrap().doc.work_path, original.work_path); + assert_eq!(app.session.active().unwrap().history.past_len(), n); + } +} diff --git a/crates/ui-egui/src/tools/direct_select/tests.rs b/crates/ui-egui/src/tools/direct_select/tests.rs index 27a9fc59d8..1a2d0c57a6 100644 --- a/crates/ui-egui/src/tools/direct_select/tests.rs +++ b/crates/ui-egui/src/tools/direct_select/tests.rs @@ -64,7 +64,7 @@ fn dragging_an_anchor_moves_it_in_one_undoable_step() { assert_eq!(anchor(&app, 3), Point::new(50.0, 130.0)); assert_eq!(anchor(&app, 0), Point::new(40.0, 40.0), "only the dragged anchor moved"); assert_eq!(steps(&app), n + 1, "one history step for the whole drag"); - assert_eq!((app.ui.direct_selection.target, app.ui.direct_selection.anchors.clone()), (Some(PathRef::Work), vec![[0, 3]])); + assert_eq!((app.ui.direct_selection.target.clone(), app.ui.direct_selection.anchors.clone()), (Some(PathRef::Work), vec![[0, 3]])); assert!(app.session.undo()); assert_eq!(work(&app), before); } @@ -569,3 +569,250 @@ fn a_targeted_vector_mask_on_type_on_a_path_is_edited_not_the_type_path() { assert!((type_path(&app).subpaths[0].knots[1].anchor.x - 160.0).abs() < 1e-6); assert_eq!(layer(&app).vector_mask.unwrap().path.subpaths[0].knots[0].anchor, Point::new(40.0, 30.0)); } + +fn save_path(app: &mut PhotocraftApp) { + let path = photocraft_engine::vector_cmds::path_json(&work(app)); + app.run("path.set", json!({"name": "Saved", "path": path})).unwrap(); +} + +fn saved(app: &PhotocraftApp) -> Path { + app.session.active().unwrap().doc.paths.iter().find(|path| path.name == "Saved").unwrap().path.clone() +} + +/// The arrow keys nudge the anchors of the selected saved path, and the selection follows each +/// nudge's new revision; the work path stays. +#[test] +fn arrow_keys_nudge_the_selected_saved_path() { + let mut app = app(Tool::DirectSelection); + with_path(&mut app); + save_path(&mut app); + app.ui.selected_path = Some("Saved".into()); + click(&mut app, [40.0, 120.0], Modifiers::NONE); + assert_eq!(app.ui.direct_selection.target, Some(PathRef::Saved(0))); + let (work_before, n) = (work(&app), steps(&app)); + assert!(press(&mut app, egui::Key::ArrowRight, Modifiers::NONE)); + assert!(press(&mut app, egui::Key::ArrowRight, Modifiers::NONE)); + assert_eq!(saved(&app).subpaths[0].knots[3].anchor, Point::new(42.0, 120.0)); + assert_eq!(work(&app), work_before); + assert_eq!(steps(&app), n + 2); +} + +#[test] +fn selected_saved_and_work_paths_win_overlapping_shape_hits() { + for name in ["Saved", "work"] { + let mut app = app(Tool::DirectSelection); + let id = app.run("shape.create", json!({"kind":"rect", "rect":[40,40,80,80]})).unwrap()["layer"].as_u64().unwrap(); + with_path(&mut app); + save_path(&mut app); + app.ui.selected_path = Some(name.into()); + let (before_work, before_saved, n) = (work(&app), saved(&app), steps(&app)); + let before_shape = app.run("shape.info", json!({"layer":id})).unwrap()["path"].clone(); + drag(&mut app, [40.0, 120.0], [55.0, 130.0], Modifiers::NONE); + let (actual, unchanged, target) = if name == "Saved" { (saved(&app), work(&app), PathRef::Saved(0)) } else { (work(&app), saved(&app), PathRef::Work) }; + assert_eq!(actual.subpaths[0].knots[3].anchor, Point::new(55.0, 130.0)); + assert_eq!(unchanged, if name == "Saved" { before_work.clone() } else { before_saved.clone() }); + assert_eq!(app.ui.direct_selection.target.as_ref(), Some(&target)); + assert_eq!(steps(&app), n + 1); + assert_eq!(app.run("shape.info", json!({"layer":id})).unwrap()["path"], before_shape); + assert!(app.session.undo()); + assert_eq!(work(&app), before_work); + assert_eq!(saved(&app), before_saved); + app.sync_views(); + assert!(app.ui.direct_selection.anchors.is_empty(), "undo invalidates runtime anchor ownership"); + } +} + +fn start_command_drag(app: &mut PhotocraftApp) { + assert!(super::pointer(app, ToolEvent::Down { x: 40.0, y: 120.0, pressure: 1.0 }, Modifiers::COMMAND)); + assert!(matches!(app.ui.direct_selection.drag, Some(Drag::Edit { .. }))); +} + +fn consume_stale_drag(app: &mut PhotocraftApp) { + assert!(super::pointer(app, ToolEvent::Move { x: 60.0, y: 140.0, pressure: 1.0 }, Modifiers::NONE)); + assert!(super::pointer(app, ToolEvent::Up { x: 60.0, y: 140.0 }, Modifiers::NONE)); + assert!(app.ui.direct_selection.drag.is_none()); + assert!(app.ui.direct_selection.anchors.is_empty()); + assert!(!app.ui.direct_selection.cancelled_drag); +} + +#[test] +fn direct_selection_cannot_apply_to_another_document_or_a_reopened_identity() { + for reopen in [false, true] { + let mut app = app(Tool::Pen); + with_path(&mut app); + let source = app.session.active().unwrap().doc.clone(); + let revision = app.session.active().unwrap().revision; + start_command_drag(&mut app); + if reopen { + app.session.close(0).unwrap(); + } + app.session.add_document((*source).clone(), None); + app.session.active_mut().unwrap().revision = revision; + let (before, n) = (work(&app), steps(&app)); + if !reopen { + assert_ne!(source.id, app.session.active().unwrap().doc.id); + } else { + assert_eq!(source.id, app.session.active().unwrap().doc.id, "a reopened document may preserve its ID"); + } + // The frame hook may cancel before the old pointer event arrives. + app.sync_views(); + consume_stale_drag(&mut app); + assert_eq!(work(&app), before); + assert_eq!(steps(&app), n); + assert!(app.ui.pen.is_none(), "an old release must not start a new Pen draft"); + if !reopen { + assert_eq!(app.session.documents()[0].doc.work_path.as_ref(), source.work_path.as_ref()); + } + } +} + +#[test] +fn external_revision_cancels_preview_and_preserves_the_new_path() { + let mut app = app(Tool::Pen); + with_path(&mut app); + start_command_drag(&mut app); + assert!(super::pointer(&mut app, ToolEvent::Move { x: 50.0, y: 130.0, pressure: 1.0 }, Modifiers::COMMAND)); + app.session.execute("path.moveAnchors", json!({"name":"work", "anchors":[[0,0]], "move":[-5,7]})).unwrap(); + let (before, n) = (work(&app), steps(&app)); + consume_stale_drag(&mut app); + assert_eq!(work(&app), before); + assert_eq!(steps(&app), n); + assert!(app.ui.pen.is_none()); +} + +#[test] +fn layer_path_and_tool_changes_cancel_the_owned_edit() { + for change in ["layer", "path", "vectorMask", "tool"] { + let mut app = app(Tool::Pen); + with_path(&mut app); + save_path(&mut app); + let first = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + let second = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.select", json!({"layer":first.0})).unwrap(); + start_command_drag(&mut app); + match change { + "layer" => { + app.session.execute("layer.select", json!({"layer":second.0})).unwrap(); + } + "path" => app.ui.selected_path = Some("Saved".into()), + "vectorMask" => app.ui.vector_mask_target = true, + "tool" => app.ui.tool = Tool::Brush, + _ => panic!("unknown target-change fixture: {change}"), + } + let (before_work, before_saved, n) = (work(&app), saved(&app), steps(&app)); + consume_stale_drag(&mut app); + assert_eq!(work(&app), before_work, "{change}"); + assert_eq!(saved(&app), before_saved, "{change}"); + assert_eq!(steps(&app), n, "{change}"); + assert!(app.ui.pen.is_none()); + } +} + +#[test] +fn closing_a_document_and_changing_marquee_target_discard_old_events() { + let mut app = app(Tool::Pen); + with_path(&mut app); + start_command_drag(&mut app); + let closed = app.session.close(0).unwrap(); + app.sync_views(); + consume_stale_drag(&mut app); + assert_eq!(closed.doc.work_path.as_ref().unwrap().subpaths[0].knots[3].anchor, Point::new(40.0, 120.0)); + + app.session.add_document((*closed.doc).clone(), None); + app.sync_views(); + save_path(&mut app); + app.ui.tool = Tool::DirectSelection; + assert!(super::pointer(&mut app, ToolEvent::Down { x: 30.0, y: 110.0, pressure: 1.0 }, Modifiers::NONE)); + assert!(matches!(app.ui.direct_selection.drag, Some(Drag::Marquee { .. }))); + app.ui.selected_path = Some("Saved".into()); + let n = steps(&app); + consume_stale_drag(&mut app); + assert_eq!(steps(&app), n); +} + +#[test] +fn serialized_anchor_selection_has_no_runtime_ownership_and_is_not_restored() { + let mut app = app(Tool::DirectSelection); + with_path(&mut app); + click(&mut app, [40.0, 120.0], Modifiers::NONE); + let serialized = serde_json::to_value(&app.ui.direct_selection).unwrap(); + assert!(serialized.get("owner").is_none()); + assert!(serialized.get("drag").is_none()); + assert!(serialized.get("cancelled_drag").is_none()); + app.ui.direct_selection = serde_json::from_value(serialized).unwrap(); + assert!(app.ui.direct_selection.owner.is_none()); + super::cancel_stale(&mut app); + assert!(app.ui.direct_selection.anchors.is_empty()); + assert!(app.ui.direct_selection.target.is_none()); +} + +#[test] +fn command_drag_after_failed_pen_commit_preserves_draft_and_existing_work_path() { + let mut app = app(Tool::Pen); + with_path(&mut app); + app.ui.extras.snap = false; + for [x, y] in [[1e40, 0.0], [40.0, 120.0]] { + tool_event(&mut app, ToolEvent::Down { x, y, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x, y }, Modifiers::NONE); + } + let before_draft = app.ui.pen.as_ref().unwrap().knots.clone(); + let (before_work, n, revision) = (work(&app), steps(&app), app.session.active().unwrap().revision); + // ⌘ off the draft ends it (a press on it would edit it in place, #4022); here that fails. + tool_event(&mut app, ToolEvent::Down { x: 40.0, y: 40.0, pressure: 1.0 }, Modifiers::COMMAND); + assert!(app.ui.pen.as_ref().unwrap().commit_failed); + assert!(app.ui.direct_selection.drag.is_none(), "the press must not go on to edit the old work path"); + assert!(app.ui.direct_selection.cancelled_drag); + tool_event(&mut app, ToolEvent::Move { x: 60.0, y: 140.0, pressure: 1.0 }, Modifiers::COMMAND); + tool_event(&mut app, ToolEvent::Up { x: 60.0, y: 140.0 }, Modifiers::COMMAND); + assert_eq!(app.ui.pen.as_ref().unwrap().knots, before_draft); + assert_eq!(work(&app), before_work); + assert_eq!(steps(&app), n); + assert_eq!(app.session.active().unwrap().revision, revision); + assert!(app.ui.status_error); + assert!(!app.ui.direct_selection.cancelled_drag); +} + +#[test] +fn switching_between_selection_and_convert_tools_cancels_an_in_flight_drag() { + for (from, to) in [(Tool::DirectSelection, Tool::ConvertPoint), (Tool::ConvertPoint, Tool::DirectSelection)] { + let mut app = app(from); + with_path(&mut app); + let (before, n) = (work(&app), steps(&app)); + tool_event(&mut app, ToolEvent::Down { x: 40.0, y: 120.0, pressure: 1.0 }, Modifiers::NONE); + assert!(app.ui.direct_selection.drag.is_some()); + app.ui.tool = to; + app.sync_views(); + consume_stale_drag(&mut app); + assert_eq!((work(&app), steps(&app)), (before, n)); + } +} + +#[test] +fn the_convert_point_tool_edits_selected_saved_paths_without_changing_the_work_path() { + let mut app = app(Tool::ConvertPoint); + with_path(&mut app); + save_path(&mut app); + app.ui.selected_path = Some("Saved".into()); + let (before_work, before_saved, n) = (work(&app), saved(&app), steps(&app)); + click(&mut app, [120.0, 40.0], Modifiers::NONE); + assert_eq!(saved(&app).subpaths[0].knots[1], photocraft_doc::Knot::corner(120.0, 40.0)); + assert_eq!(work(&app), before_work); + assert_eq!(steps(&app), n + 1); + assert_eq!(app.ui.direct_selection.target, Some(PathRef::Saved(0))); + assert!(app.session.undo()); + assert_eq!(saved(&app), before_saved); +} + +#[test] +fn changing_the_channel_vector_mask_target_cancels_an_owned_edit() { + let mut app = app(Tool::DirectSelection); + with_path(&mut app); + tool_event(&mut app, ToolEvent::Down { x: 40.0, y: 120.0, pressure: 1.0 }, Modifiers::NONE); + assert!(app.ui.direct_selection.drag.is_some()); + let (before, n) = (work(&app), steps(&app)); + app.session.active_mut().unwrap().channel_view.target = photocraft_engine::channel_cmds::ChannelTarget::VectorMask; + assert!(!app.ui.vector_mask_target, "the channel target can change before the UI syncs its mask flag"); + consume_stale_drag(&mut app); + assert_eq!((work(&app), steps(&app)), (before, n)); +} diff --git a/crates/ui-egui/src/tools/lasso_ui.rs b/crates/ui-egui/src/tools/lasso_ui.rs index fac20a39ed..38dfaecdb1 100644 --- a/crates/ui-egui/src/tools/lasso_ui.rs +++ b/crates/ui-egui/src/tools/lasso_ui.rs @@ -161,6 +161,12 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool return true; } modifiers(app, mods); + // A fitted path whose commit failed remains the user's draft. Starting another trace + // must neither retry it automatically nor replace it on the next release. Releasing Alt + // in this press may itself have finished the previous trace into that failed draft. + if tool == Tool::FreeformPen && matches!(ev, ToolEvent::Down { .. }) && !active(app) && app.ui.pen.is_some() { + return true; + } match ev { ToolEvent::Down { .. } => { if active(app) { @@ -205,6 +211,9 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool let polygonal = mods.alt && nothing_selected; let intent = if polygonal { Modifiers { alt: false, ..mods } } else { mods }; let mut d = Drag::new(tool, p, vec![[p[0], p[1], 1.0]], intent, false); + if tool == Tool::FreeformPen { + d.capture_vector_owners(app); + } let document = app.session.active().map(|st| st.doc.id); d.lasso = Some(Lasso { cursor: p, down: true, polygonal, document, straight: Vec::new() }); app.drag = Some(d); diff --git a/crates/ui-egui/src/tools/lasso_ui/tests.rs b/crates/ui-egui/src/tools/lasso_ui/tests.rs index 71a5b68777..af1d45a2e0 100644 --- a/crates/ui-egui/src/tools/lasso_ui/tests.rs +++ b/crates/ui-egui/src/tools/lasso_ui/tests.rs @@ -170,6 +170,142 @@ fn mouse(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, kind: &str, x: f h.run_steps(2); } +fn freeform_harness() -> egui_kittest::Harness<'static, PhotocraftApp> { + let mut h = harness(); + let app = h.state_mut(); + for name in ["work", "Saved drawing"] { + app.run("path.set", json!({"name": name, "path":{"subpaths":[{"knots":[[20,20],[220,20]]}]}})).unwrap(); + } + app.ui.tool = Tool::FreeformPen; + app.ui.tool_options.vector_mode = "path".into(); + app.ui.selected_path = Some("Saved drawing".into()); + app.ui.extras.snap = false; + h.run_steps(2); + h +} + +fn freeform_run_and_straight_click(h: &mut egui_kittest::Harness<'static, PhotocraftApp>) { + mouse(h, "down", 50.0, 50.0, Modifiers::NONE); + mouse(h, "move", 90.0, 40.0, Modifiers::NONE); + mouse(h, "move", 120.0, 50.0, Modifiers::NONE); + mouse(h, "up", 120.0, 50.0, Modifiers::ALT); + assert!(active(h.state()), "Alt-held release keeps the fitted path in progress"); + mouse(h, "down", 180.0, 100.0, Modifiers::ALT); + mouse(h, "up", 180.0, 100.0, Modifiers::ALT); + assert!(active(h.state())); +} + +#[test] +fn real_freeform_freehand_and_alt_straight_segments_append_to_the_owned_saved_path_once() { + let mut h = freeform_harness(); + let original = h.state().session.active().unwrap().doc.clone(); + let n = h.state().session.active().unwrap().history.past_len(); + freeform_run_and_straight_click(&mut h); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); + let app = h.state(); + assert!(!active(app) && app.ui.pen.is_none()); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.work_path, original.work_path); + let path = &st.doc.paths[0].path; + assert_eq!(path.subpaths.len(), 2); + assert_eq!(path.subpaths[0], original.paths[0].path.subpaths[0]); + let knots = &path.subpaths[1].knots; + assert_eq!(knots.last().unwrap().anchor, photocraft_geom::Point::new(180.0, 100.0)); + assert_eq!(knots.last().unwrap().in_ctrl, knots.last().unwrap().anchor); + assert_eq!(knots[knots.len() - 2].out_ctrl, knots[knots.len() - 2].anchor); + assert_eq!(st.history.past_len(), n + 1); + assert!(h.state_mut().session.undo()); + assert_eq!(h.state().session.active().unwrap().doc.paths, original.paths); +} + +#[test] +fn real_freeform_stale_target_source_revision_and_document_cancel_without_finishing_elsewhere() { + for change in ["target", "source", "revision", "document"] { + let mut h = freeform_harness(); + freeform_run_and_straight_click(&mut h); + let app = h.state_mut(); + match change { + "target" => app.ui.selected_path = Some("work".into()), + "source" => { + let st = app.session.active_mut().unwrap(); + st.doc = std::sync::Arc::new((*st.doc).clone()); + } + "revision" => app.session.active_mut().unwrap().revision += 1, + "document" => { + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + } + _ => panic!("unexpected stale-gesture test case: {change}"), + } + let source = h.state().session.active().unwrap().doc.clone(); + let n = h.state().session.active().unwrap().history.past_len(); + mouse(&mut h, "move", 200.0, 120.0, Modifiers::ALT); + mouse(&mut h, "up", 200.0, 120.0, Modifiers::NONE); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + let app = h.state(); + assert!(!active(app) && app.ui.pen.is_none(), "{change}: no stale trace remains"); + assert!(std::sync::Arc::ptr_eq(&source, &app.session.active().unwrap().doc), "{change}: late input changed document pixels or paths"); + assert_eq!(app.session.active().unwrap().history.past_len(), n, "{change}"); + } +} + +#[test] +fn another_freeform_trace_cannot_replace_a_failed_owned_draft_or_retry_it_automatically() { + let mut app = app(); + app.ui.tool = Tool::FreeformPen; + app.ui.extras.snap = false; + for [x, y] in [[40.0, 60.0], [1e40, 60.0], [140.0, 80.0]] { + event(&mut app, "down", x, y, Modifiers::ALT); + event(&mut app, "up", x, y, Modifiers::ALT); + } + modifiers(&mut app, Modifiers::NONE); + let failed = app.ui.pen.clone().expect("invalid fitted trace must remain available"); + assert!(failed.commit_failed && failed.owner.is_some()); + assert!(!active(&app)); + let source = app.session.active().unwrap().doc.clone(); + let n = app.session.active().unwrap().history.past_len(); + let journal = app.session.journal.len(); + for middle in [[1e40, 90.0], [220.0, 90.0]] { + for [x, y] in [[160.0, 120.0], middle, [260.0, 140.0]] { + event(&mut app, "down", x, y, Modifiers::ALT); + event(&mut app, "move", x + 10.0, y + 10.0, Modifiers::ALT); + event(&mut app, "up", x, y, Modifiers::ALT); + assert!(!active(&app), "pending fitted draft owns the next drawing attempt"); + } + modifiers(&mut app, Modifiers::NONE); + assert_eq!(app.ui.pen.as_ref(), Some(&failed)); + assert!(std::sync::Arc::ptr_eq(&source, &app.session.active().unwrap().doc)); + assert_eq!(app.session.active().unwrap().history.past_len(), n); + assert_eq!(app.session.journal.len(), journal, "drawing must not retry the failed engine command"); + } +} + +#[test] +fn freeform_alt_release_in_the_next_press_keeps_the_failed_fit() { + let mut app = app(); + app.ui.tool = Tool::FreeformPen; + for [x, y] in [[40.0, 60.0], [1e40, 60.0], [140.0, 80.0]] { + event(&mut app, "down", x, y, Modifiers::ALT); + event(&mut app, "up", x, y, Modifiers::ALT); + } + assert!(active(&app)); + let source = app.session.active().unwrap().doc.clone(); + let n = app.session.active().unwrap().history.past_len(); + // No separate modifier-change event: this press both releases Alt and tries a new trace. + event(&mut app, "down", 200.0, 100.0, Modifiers::NONE); + let failed = app.ui.pen.clone().expect("failed fit must survive the press that finished it"); + assert!(failed.commit_failed && failed.owner.is_some()); + assert!(!active(&app)); + let journal = app.session.journal.len(); + event(&mut app, "move", 240.0, 120.0, Modifiers::NONE); + event(&mut app, "up", 240.0, 120.0, Modifiers::NONE); + assert_eq!(app.ui.pen.as_ref(), Some(&failed)); + assert_eq!(app.session.journal.len(), journal); + assert!(std::sync::Arc::ptr_eq(&source, &app.session.active().unwrap().doc)); + assert_eq!(app.session.active().unwrap().history.past_len(), n); +} + #[test] fn real_canvas_alt_clicks_enter_escape_and_resume() { for finish in ["enter", "escape", "resume", "close"] { diff --git a/crates/ui-egui/src/tools/shape_stroke_ui/shortcut_tests.rs b/crates/ui-egui/src/tools/shape_stroke_ui/shortcut_tests.rs index fe83b3bbee..cfa3ba432b 100644 --- a/crates/ui-egui/src/tools/shape_stroke_ui/shortcut_tests.rs +++ b/crates/ui-egui/src/tools/shape_stroke_ui/shortcut_tests.rs @@ -77,7 +77,8 @@ fn escape_dismisses_stroke_child_then_parent_without_deselecting_the_document() #[test] fn enter_selects_a_stroke_style_and_escape_closes_its_popups_without_committing_pen() { let mut app = app(Tool::Pen); - app.ui.pen = Some(crate::vector_ui::PenPath { knots: vec![[[12.0, 12.0]; 3], [[60.0, 40.0]; 3]], ..Default::default() }); + let owner = crate::vector_ui::capture_pen_owner(&app); + app.ui.pen = Some(crate::vector_ui::PenPath { knots: vec![[[12.0, 12.0]; 3], [[60.0, 40.0]; 3]], owner, ..Default::default() }); let pen = app.ui.pen.clone(); let original = app.session.active().unwrap().doc.clone(); let history = app.session.active().unwrap().history.past_len(); diff --git a/crates/ui-egui/src/tools/vector_ui.rs b/crates/ui-egui/src/tools/vector_ui.rs index e8bac48567..0fd501ad18 100644 --- a/crates/ui-egui/src/tools/vector_ui.rs +++ b/crates/ui-egui/src/tools/vector_ui.rs @@ -89,6 +89,13 @@ pub struct PenPath { /// Curvature Pen: the anchor being dragged. #[serde(skip)] pub moving: Option, + /// The document and target the first anchor was placed on; a draft without one (restored + /// from settings) is never committed. + #[serde(skip)] + pub(crate) owner: Option, + /// The last commit failed: the draft stays as it was and isn't retried on its own. + #[serde(skip)] + pub(crate) commit_failed: bool, /// Drawing carries on from an endpoint of this open subpath of a document path (#6593): the /// commit replaces that subpath instead of adding one. #[serde(default)] @@ -133,3 +140,131 @@ mod pen_path_ops_tests; mod paint_tests; pub(crate) use pen::freeform_locked_shape; + +/// Where a Pen draft goes, captured at its first anchor. +#[derive(Clone, Debug)] +pub(crate) struct PenOwner { + document: DocId, + revision: u64, + source: Weak, + target: PenTarget, + options: ToolOptions, + layer: Option, + vector_mask: bool, + foreground: [f32; 4], + background: [f32; 4], +} + +impl PartialEq for PenOwner { + fn eq(&self, other: &Self) -> bool { + self.document == other.document && self.revision == other.revision && self.source.ptr_eq(&other.source) && self.target == other.target + } +} + +#[derive(Clone, Debug, PartialEq)] +enum PenTarget { + /// A new shape layer, with the paint chosen when drawing began. + Shape { + fill: Value, + stroke: Value, + open_stroke: Value, + }, + VectorMask(u64), + /// The work path or a saved path; `append` adds the draft as a subpath (#4721). + Path { + name: String, + append: bool, + }, +} + +/// Where the Pen draws now: a new shape layer (Shape mode), else the work or saved path selected +/// in the Paths panel (the new path joins it, #4721), else the targeted vector mask, else a new +/// Work Path that replaces the old one. +pub(crate) fn capture_pen_owner(app: &PhotocraftApp) -> Option { + let st = app.session.active()?; + let target = if app.ui.tool_options.vector_mode == "shape" { + let mut open_stroke = app.ui.tool_options.shape_stroke.params(); + open_stroke["width"] = json!(app.ui.tool_options.stroke_width.max(1.0)); + open_stroke["color"] = json!(app.session.tools.foreground); + PenTarget::Shape { fill: fill_param(app), stroke: stroke_param(app), open_stroke } + } else if let Some((name, _)) = selected_document_geometry(app) { + PenTarget::Path { name, append: true } + } else if let Some(id) = targeted_vector_mask_id(app) { + PenTarget::VectorMask(id) + } else { + PenTarget::Path { name: "work".into(), append: false } + }; + Some(PenOwner { + document: st.doc.id, + revision: st.revision, + source: Arc::downgrade(&st.doc), + target, + options: app.ui.tool_options.clone(), + layer: st.active_layer, + vector_mask: app.ui.vector_mask_target, + foreground: app.session.tools.foreground, + background: app.session.tools.background, + }) +} + +impl PenOwner { + fn valid(&self, app: &PhotocraftApp) -> bool { + app.session.active().is_some_and(|st| st.doc.id == self.document && st.revision == self.revision && self.source.ptr_eq(&Arc::downgrade(&st.doc))) + } +} + +use photocraft_doc::DocId; +use std::sync::{Arc, Weak}; + +use path_ops::{selected_document_geometry, targeted_vector_mask_id}; +pub(crate) use pen::{finish_pen_if_left, freeform_finish_owned}; +use shapes::{fill_param, stroke_param}; + +/// Commit a valid source-owned draft on a tab switch; otherwise cancel with an actionable diagnostic. +pub(crate) fn cancel_stale_pen(app: &mut PhotocraftApp) -> bool { + let Some(pen) = app.ui.pen.as_ref() else { return false }; + if pen.owner.as_ref().is_some_and(|o| o.valid(app)) { + return false; + } + if pen.commit_failed { + return false; + } + let owner = pen.owner.clone(); + if let Some(owner) = owner.as_ref() { + let index = app + .session + .documents() + .iter() + .position(|st| st.doc.id == owner.document && st.revision == owner.revision && owner.source.ptr_eq(&Arc::downgrade(&st.doc))); + if let Some(index) = index { + let active = app.session.active_index(); + let saved_ui = app.ui.clone(); + let draft = app.ui.pen.take(); + app.session.set_active(index); + app.ui.pen = draft; + // This source matches the immutable owner; normal commit validation still applies. + let result = pen::try_pen_commit(app, false); + if let Some(active) = active { + app.session.set_active(active); + } + let failed = app.ui.pen.take(); + let message = app.ui.status.clone(); + app.ui = saved_ui; + app.ui.pen = if result.is_ok() { None } else { failed }; + if result.is_err() { + app.ui.status = message; + app.ui.status_error = true; + } + app.ui.pen_doc = app.ui.pen.as_ref().and_then(|p| p.owner.as_ref().map(|o| o.document)); + return true; + } + } + app.ui.pen = None; + app.ui.pen_doc = None; + if app.ui.direct_selection.target == Some(crate::direct_select::PathRef::Pen) { + app.ui.direct_selection = Default::default(); + } + app.ui.status = "Pen draft cancelled because its source document or path changed; newer work was preserved".into(); + app.ui.status_error = true; + true +} diff --git a/crates/ui-egui/src/tools/vector_ui/curvature.rs b/crates/ui-egui/src/tools/vector_ui/curvature.rs index e51262b1df..2020dbe2b9 100644 --- a/crates/ui-egui/src/tools/vector_ui/curvature.rs +++ b/crates/ui-egui/src/tools/vector_ui/curvature.rs @@ -44,7 +44,12 @@ pub fn curvature_down(app: &mut PhotocraftApp, x: f64, y: f64) { if app.ui.pen.is_none() { app.ui.pen_doc = app.session.active().map(|st| st.doc.id); } + let owner = capture_pen_owner(app); let pen = app.ui.pen.get_or_insert_with(|| PenPath { curvature: true, ..Default::default() }); + if pen.owner.is_none() { + pen.owner = owner; + } + pen.commit_failed = false; if hit.is_none() { pen.knots.push([[x, y]; 3]); } @@ -53,6 +58,7 @@ pub fn curvature_down(app: &mut PhotocraftApp, x: f64, y: f64) { } pub fn curvature_move(app: &mut PhotocraftApp, x: f64, y: f64) { + cancel_stale_pen(app); if let Some(pen) = app.ui.pen.as_mut().filter(|p| p.curvature) && let Some(k) = pen.moving.and_then(|i| pen.knots.get_mut(i)) { @@ -62,6 +68,7 @@ pub fn curvature_move(app: &mut PhotocraftApp, x: f64, y: f64) { } pub fn curvature_up(app: &mut PhotocraftApp) { + cancel_stale_pen(app); if let Some(pen) = app.ui.pen.as_mut() { pen.moving = None; } @@ -76,6 +83,7 @@ pub fn curvature_toggle(app: &mut PhotocraftApp, x: f64, y: f64) { crate::direct_select::curvature_toggle_active(app, [x, y]); return; } + cancel_stale_pen(app); let Some(i) = curvature_hit(app, x, y) else { return }; let Some(pen) = app.ui.pen.as_mut() else { return }; if let Some(at) = pen.unlinked.iter().position(|&c| c == i) { @@ -85,3 +93,5 @@ pub fn curvature_toggle(app: &mut PhotocraftApp, x: f64, y: f64) { } curvature_refresh(pen, false); } + +use super::{cancel_stale_pen, capture_pen_owner}; diff --git a/crates/ui-egui/src/tools/vector_ui/options.rs b/crates/ui-egui/src/tools/vector_ui/options.rs index 0bddcbcf63..7d5d1c39c9 100644 --- a/crates/ui-egui/src/tools/vector_ui/options.rs +++ b/crates/ui-egui/src/tools/vector_ui/options.rs @@ -29,7 +29,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo } let o = &mut app.ui.tool_options; if tool == Tool::PathSelection && target.is_none() { - lbl(ui, if app.ui.vector_mask_target { "Drag to move the targeted vector mask" } else { tl!("Drag to move the active shape's path or the Work Path") }); + lbl(ui, if app.ui.vector_mask_target { "Drag to move the targeted vector mask" } else { tl!("Drag to move the selected path") }); return true; } if tool == Tool::DirectSelection && target.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 5644c9201a..e6c3a1a6cc 100644 --- a/crates/ui-egui/src/tools/vector_ui/path_ops.rs +++ b/crates/ui-egui/src/tools/vector_ui/path_ops.rs @@ -60,11 +60,9 @@ pub fn with_active_path(app: &PhotocraftApp, id: &str, params: Value) -> Value { pub fn path_to_selection(app: &mut PhotocraftApp, params: Value) -> Result { if app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2) { // The finished path becomes the work path, or a shape layer's path or a vector mask. - let on_layer = app.ui.tool_options.vector_mode == "shape" || targeted_vector_mask(app).is_some(); if !pen_commit(app, false) { return Err(app.ui.status.clone()); } - app.ui.selected_path = Some(if on_layer { "layer" } else { "work" }.into()); } let name = active_path_name(app) .ok_or_else(|| tl!("No path to make a selection from: draw one with the Pen tool or select one in the Paths panel.").to_string())?; @@ -123,13 +121,30 @@ pub(super) enum PathTarget { Work, } +/// The work or saved path explicitly selected in the Paths panel, by name, and its geometry. Only +/// such a selection overrides the active layer's path. +pub(super) fn selected_document_geometry(app: &PhotocraftApp) -> Option<(String, &Path)> { + let st = app.session.active()?; + match app.ui.selected_path.as_deref()? { + "work" => st.doc.work_path.as_ref().map(|p| ("work".into(), p)), + "layer" => None, + name => st.doc.paths.iter().find(|p| p.name == name).map(|p| (p.name.clone(), &p.path)), + } +} + /// The targeted vector mask of the active layer, if the Layers panel targets it. pub(crate) fn targeted_vector_mask(app: &PhotocraftApp) -> Option<(u64, Path)> { + let id = targeted_vector_mask_id(app)?; + let mask = app.session.active()?.doc.layer(photocraft_doc::LayerId(id))?.vector_mask.as_ref()?; + Some((id, mask.path.clone())) +} + +pub(super) fn targeted_vector_mask_id(app: &PhotocraftApp) -> Option { let st = app.session.active()?; let l = st.active_layer.and_then(|id| st.doc.layer(id))?; let targeted = app.ui.vector_mask_target || st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::VectorMask; (targeted && !matches!(l.content, LayerContent::Shape(_))).then_some(())?; - l.vector_mask.as_ref().map(|m| (l.id.0, m.path.clone())) + l.vector_mask.as_ref().map(|_| l.id.0) } /// The active type layer's path in document space, if it is type on a path (#9114). @@ -168,6 +183,9 @@ pub(crate) fn delete_key_deletes_work_path(app: &PhotocraftApp) -> bool { /// The path Path Selection edits: the targeted vector mask, the active shape layer's path, the /// saved path selected in the Paths panel (#8478), else the work path. pub(super) fn target_path(app: &PhotocraftApp) -> Option<(PathTarget, Path)> { + if let Some((name, p)) = selected_document_geometry(app) { + return Some((if name == "work" { PathTarget::Work } else { PathTarget::Saved(name) }, p.clone())); + } if let Some((id, p)) = targeted_vector_mask(app) { return Some((PathTarget::VectorMask(id), p)); } @@ -308,13 +326,13 @@ pub fn path_selection_finish(app: &mut PhotocraftApp, start: [f64; 2], end: [f64 let moved = path.transform(&Affine::translate(dx.round(), dy.round())); app.run("layer.vectorMask.edit", json!({"layer": id, "path": photocraft_engine::vector_cmds::path_json(&moved)})) } - PathTarget::Saved(name) => { - let moved = path.transform(&Affine::translate(dx.round(), dy.round())); - app.run("path.set", json!({"name": name, "path": photocraft_engine::vector_cmds::path_json(&moved)})) - } PathTarget::Work => { let moved = path.transform(&Affine::translate(dx.round(), dy.round())); 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())); + app.run("path.set", json!({"name": name, "path": photocraft_engine::vector_cmds::path_json(&moved)})) + } }; } diff --git a/crates/ui-egui/src/tools/vector_ui/path_selection_tests.rs b/crates/ui-egui/src/tools/vector_ui/path_selection_tests.rs index 8cab12747a..5794da87f5 100644 --- a/crates/ui-egui/src/tools/vector_ui/path_selection_tests.rs +++ b/crates/ui-egui/src/tools/vector_ui/path_selection_tests.rs @@ -67,7 +67,8 @@ fn cmd_enter_while_drawing_finishes_the_path_and_loads_it() { let mut h = harness(); h.state_mut().ui.tool = Tool::Pen; let p = |x: f64, y: f64| [[x, y], [x, y], [x, y]]; - h.state_mut().ui.pen = Some(PenPath { knots: vec![p(20.0, 20.0), p(120.0, 20.0), p(120.0, 80.0)], ..Default::default() }); + let owner = crate::vector_ui::capture_pen_owner(h.state()); + h.state_mut().ui.pen = Some(PenPath { knots: vec![p(20.0, 20.0), p(120.0, 20.0), p(120.0, 80.0)], owner, ..Default::default() }); h.run_steps(2); press(&mut h, Key::Enter, command()); let app = h.state(); @@ -80,7 +81,8 @@ fn cmd_enter_while_drawing_finishes_the_path_and_loads_it() { // Plain ↩ still only finishes a path. let mut h = harness(); h.state_mut().ui.tool = Tool::Pen; - h.state_mut().ui.pen = Some(PenPath { knots: vec![p(20.0, 20.0), p(120.0, 20.0), p(120.0, 80.0)], ..Default::default() }); + let owner = crate::vector_ui::capture_pen_owner(h.state()); + h.state_mut().ui.pen = Some(PenPath { knots: vec![p(20.0, 20.0), p(120.0, 20.0), p(120.0, 80.0)], owner, ..Default::default() }); h.run_steps(2); press(&mut h, Key::Enter, Modifiers::NONE); assert!(h.state().session.active().unwrap().doc.work_path.is_some()); diff --git a/crates/ui-egui/src/tools/vector_ui/pen.rs b/crates/ui-egui/src/tools/vector_ui/pen.rs index 92d41827fe..8b7a1144de 100644 --- a/crates/ui-egui/src/tools/vector_ui/pen.rs +++ b/crates/ui-egui/src/tools/vector_ui/pen.rs @@ -52,7 +52,12 @@ pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { { app.ui.pen = Some(resumed); } + let owner = capture_pen_owner(app); let pen = app.ui.pen.get_or_insert_with(PenPath::default); + if pen.owner.is_none() { + pen.owner = owner; + } + pen.commit_failed = false; pen.adjusting_in = false; pen.last_pointer = Some([x, y]); pen.reposition = false; @@ -92,6 +97,7 @@ pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { /// Pen drag: pull out the handles of the anchor just placed. ⇧ (read live, as it is held) /// turns the handle to the nearest 45° direction from its anchor. pub fn pen_move(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { + cancel_stale_pen(app); if let Some(pen) = app.ui.pen.as_mut() && pen.dragging { @@ -132,6 +138,9 @@ pub(super) fn shift_45(origin: [f64; 2], p: [f64; 2], shift: bool) -> [f64; 2] { /// clicking the first anchor commits the path. Therefore Cmd/Ctrl+Z must consume one /// knot rather than undoing an unrelated, already committed document operation. pub fn pen_undo_last_point(app: &mut PhotocraftApp) -> bool { + if cancel_stale_pen(app) { + return true; + } if !cancel_stale(app) { return false; } @@ -169,6 +178,7 @@ pub fn pen_undo_last_point(app: &mut PhotocraftApp) -> bool { } pub fn pen_up(app: &mut PhotocraftApp) { + cancel_stale_pen(app); let jitter = 2.0 / f64::from(app.point_zoom().max(0.01)); let Some(pen) = app.ui.pen.as_mut() else { return }; pen.dragging = false; @@ -191,6 +201,7 @@ pub fn pen_up(app: &mut PhotocraftApp) { /// ⌥/Alt during a Pen drag breaks the pair of handles being pulled out: from then on only the /// outgoing handle follows the pointer and the incoming curve stays as it is (#3306). pub fn pen_break_handle(app: &mut PhotocraftApp) { + cancel_stale_pen(app); let Some(pen) = app.ui.pen.as_mut().filter(|p| p.dragging && !p.adjusting_last) else { return }; let Some(i) = pen.knots.len().checked_sub(1) else { return }; if !pen.unlinked.contains(&i) { @@ -202,6 +213,7 @@ pub fn pen_break_handle(app: &mut PhotocraftApp) { /// ⌥/Alt-press on one of the last anchor's handles while drawing grabs that handle alone: the /// drag turns it and the other handle stays put (#3306, #6077). False when the press misses both. pub fn pen_grab_handle(app: &mut PhotocraftApp, x: f64, y: f64) -> bool { + cancel_stale_pen(app); let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); let Some(pen) = app.ui.pen.as_mut() else { return false }; let Some((i, k)) = pen.knots.len().checked_sub(1).zip(pen.knots.last()) else { return false }; @@ -299,66 +311,49 @@ pub(crate) fn pen_replace(app: &mut PhotocraftApp, path: &Path) { } } -/// Preserve a foreign document's draft as its Work Path before another document can edit it. -/// A failed commit retains the draft and blocks the caller until it can be retried. pub fn cancel_stale(app: &mut PhotocraftApp) -> bool { - let active = app.session.active().map(|st| st.doc.id); - if app.ui.pen.is_none() { - app.ui.pen_doc = None; - return true; - } - let Some(owner) = app.ui.pen_doc.filter(|d| Some(*d) != active) else { return true }; - if let Some(index) = app.session.documents().iter().position(|d| d.doc.id == owner) { - let Some(mut pen) = app.ui.pen.clone() else { return true }; - if pen.curvature { - curvature_refresh(&mut pen, false); - } - let active = app.session.active_index(); - app.session.set_active(index); - // Bypass shell targets and sync_views: both belong to the newly active document. - let result = app.session.execute("path.set", json!({"name": "work", "op": "combine", "path": pen_to_json(&pen, false)})); - if let Some(index) = active { - app.session.set_active(index); - } - if let Err(e) = result { - app.ui.status = e.to_string(); - app.ui.status_error = true; - return false; - } - } - app.ui.pen = None; - app.ui.pen_doc = None; - if app.ui.direct_selection.target == Some(crate::direct_select::PathRef::Pen) { - app.ui.direct_selection = Default::default(); - } - true + cancel_stale_pen(app); + !app.ui.pen.as_ref().is_some_and(|p| p.commit_failed && p.owner.as_ref().is_some_and(|o| !o.valid(app))) } -/// Finish the pen path: a work path (Path mode) or a new shape layer (Shape mode). A commit the -/// document refuses (a locked shape layer to add to…) keeps the draft, so nothing drawn is lost -/// and it can be committed again; `false` then. pub fn pen_commit(app: &mut PhotocraftApp, closed: bool) -> bool { - // The control channel is drained after `sync_views`, so a document switch in this frame has - // not been seen yet: never commit the path into the document it was not drawn on. - if !cancel_stale(app) { - return false; - } - let Some(draft) = app.ui.pen.take() else { return true }; - let doc = app.ui.pen_doc.take(); - let mut pen = draft.clone(); - if pen.curvature { - curvature_refresh(&mut pen, closed); - } - if !commit_path(app, &pen, closed) { - app.ui.pen = Some(draft); - app.ui.pen_doc = doc; - return false; - } + try_pen_commit(app, closed).is_ok() +} + +/// Finish the draft as [`pen_commit`] does. On failure the draft stays as it was, marked +/// `commit_failed`, and the error is shown and returned. +pub(super) fn try_pen_commit(app: &mut PhotocraftApp, closed: bool) -> Result<(), String> { + cancel_stale_pen(app); + let Some(mut pen) = app.ui.pen.take() else { return Ok(()) }; if app.ui.direct_selection.target == Some(crate::direct_select::PathRef::Pen) { // Its anchors are no longer the Pen's: the committed path is another path. app.ui.direct_selection = Default::default(); } - true + let original = fold_close(&pen) + .map(|mut folded| { + folded.dragging = false; + folded.adjusting_last = false; + folded + }) + .unwrap_or_else(|| pen.clone()); + if pen.curvature { + curvature_refresh(&mut pen, closed); + } + if let Err(e) = commit_path(app, &pen, closed) { + app.ui.pen = Some(PenPath { commit_failed: true, ..original }); + app.ui.status = e.clone(); + app.ui.status_error = true; + return Err(e); + } + Ok(()) +} + +/// Another tool finishes the Pen's draft, once: a draft whose commit failed waits for the user. +pub(crate) fn finish_pen_if_left(app: &mut PhotocraftApp) { + cancel_stale_pen(app); + if app.ui.pen.as_ref().is_some_and(|p| !p.commit_failed && !p.drawn_with(app.ui.tool)) { + pen_commit(app, false); + } } /// End the Pen path for good (Esc, or switching to another tool): commit it, and when the @@ -397,7 +392,7 @@ pub fn pen_end(app: &mut PhotocraftApp) { /// layer; in Path mode a subpath of the targeted vector mask, else of the selected path or a new /// work path. An added subpath takes the options bar's Path Operation (#1397). `false` when the /// document refused it, with the reason in the status bar. -fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) -> bool { +fn commit_path_current(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) -> bool { if pen.knots.len() < 2 { return true; } @@ -684,6 +679,16 @@ pub(crate) fn freeform_locked_shape(app: &PhotocraftApp) -> bool { /// as the Pen does. Releasing on the start point closes the path; a click draws nothing. The /// points at indices `straight` were ⌥-clicked: the segment into each one stays straight. pub fn freeform_finish(app: &mut PhotocraftApp, points: &[[f64; 3]], straight: &[usize]) { + let Some(owner) = capture_pen_owner(app) else { return }; + freeform_finish_owned(app, points, straight, owner); +} + +/// [`freeform_finish`] to the destination captured when the trace began. A trace of another +/// document commits nothing; a failed commit keeps the fitted path as a draft. +pub(crate) fn freeform_finish_owned(app: &mut PhotocraftApp, points: &[[f64; 3]], straight: &[usize], owner: PenOwner) { + if !owner.valid(app) { + return; + } let trace: Vec<[f64; 2]> = points.iter().map(|p| [p[0], p[1]]).collect(); let (Some(first), Some(last)) = (trace.first(), trace.last()) else { return }; let tol = 6.0 / app.point_zoom().max(0.01) as f64; @@ -698,7 +703,13 @@ pub fn freeform_finish(app: &mut PhotocraftApp, points: &[[f64; 3]], straight: & } else { fit_with_straight_segments(&trace, straight, fit, closed.then_some(tol)) }; - commit_path(app, &pen.0, pen.1); + let (mut pen, closed) = pen; + pen.owner = Some(owner); + if let Err(e) = commit_path(app, &pen, closed) { + app.ui.pen = Some(PenPath { commit_failed: true, ..pen }); + app.ui.status = e; + app.ui.status_error = true; + } } fn knot_points(k: &photocraft_doc::Knot) -> [[f64; 2]; 3] { @@ -756,3 +767,37 @@ fn fit_with_straight_segments(trace: &[[f64; 2]], straight: &[usize], fit: f64, corners.dedup(); (PenPath { knots, unlinked: corners, ..Default::default() }, closed) } + +use super::{PenOwner, cancel_stale_pen, capture_pen_owner}; + +fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) -> Result<(), String> { + let Some(owner) = pen.owner.as_ref().filter(|o| o.valid(app)) else { return Err("Pen draft target is stale; no path was changed".into()) }; + let options = std::mem::replace(&mut app.ui.tool_options, owner.options.clone()); + let vector_mask = std::mem::replace(&mut app.ui.vector_mask_target, owner.vector_mask); + app.ui.selected_path = match &owner.target { + super::PenTarget::Path { name, append } => append.then(|| name.clone()), + _ => None, + }; + if let Some(st) = app.session.active_mut() { + st.active_layer = owner.layer; + } + let foreground = std::mem::replace(&mut app.session.tools.foreground, owner.foreground); + let background = std::mem::replace(&mut app.session.tools.background, owner.background); + let channel = app.session.active().map(|st| st.channel_view.target); + if let Some(st) = app.session.active_mut() { + st.channel_view.target = if matches!(owner.target, super::PenTarget::VectorMask(_)) { + photocraft_engine::channel_cmds::ChannelTarget::VectorMask + } else { + photocraft_engine::channel_cmds::ChannelTarget::Composite + }; + } + let result = commit_path_current(app, pen, closed); + if let (Some(st), Some(channel)) = (app.session.active_mut(), channel) { + st.channel_view.target = channel; + } + app.session.tools.foreground = foreground; + app.session.tools.background = background; + app.ui.tool_options = options; + app.ui.vector_mask_target = vector_mask; + if result { Ok(()) } else { Err(app.ui.status.clone()) } +} diff --git a/crates/ui-egui/src/tools/vector_ui/tests.rs b/crates/ui-egui/src/tools/vector_ui/tests.rs index c2d59f6805..780266c8a4 100644 --- a/crates/ui-egui/src/tools/vector_ui/tests.rs +++ b/crates/ui-egui/src/tools/vector_ui/tests.rs @@ -78,7 +78,7 @@ fn all_shape_tools_and_open_pen_use_the_complete_stroke_defaults() { a.run("edit.redo", json!({})).unwrap(); assert_eq!(a.run("shape.info", json!({})).unwrap(), info); a.ui.tool_options.vector_mode = "shape".into(); - a.ui.pen = Some(PenPath { knots: vec![[[30.0, 40.0]; 3], [[80.0, 40.0]; 3]], ..Default::default() }); + a.ui.pen = Some(PenPath { knots: vec![[[30.0, 40.0]; 3], [[80.0, 40.0]; 3]], owner: capture_pen_owner(&a), ..Default::default() }); pen_commit(&mut a, false); let open = a.run("shape.info", json!({})).unwrap(); assert_eq!(open["stroke"]["dashes"], info["stroke"]["dashes"]); @@ -86,6 +86,44 @@ fn all_shape_tools_and_open_pen_use_the_complete_stroke_defaults() { } } +#[test] +fn freeform_straight_segments_keep_the_captured_saved_path_and_reject_a_foreign_document() { + let mut app = app(); + app.ui.tool = Tool::FreeformPen; + let seed = json!({"subpaths":[{"knots":[[20,20],[120,20]]}]}); + for name in ["work", "Saved drawing"] { + app.run("path.set", json!({"name": name, "path": seed})).unwrap(); + } + app.ui.selected_path = Some("Saved drawing".into()); + let owner = capture_pen_owner(&app).unwrap(); + let original = app.session.active().unwrap().doc.clone(); + let n = app.session.active().unwrap().history.past_len(); + let points = [[20.0, 60.0, 1.0], [60.0, 60.0, 1.0], [100.0, 80.0, 1.0], [140.0, 80.0, 1.0]]; + app.ui.selected_path = Some("work".into()); + app.ui.tool_options.vector_mode = "shape".into(); + freeform_finish_owned(&mut app, &points, &[1, 3], owner); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.work_path, original.work_path); + let saved = &st.doc.paths.iter().find(|p| p.name == "Saved drawing").unwrap().path; + assert_eq!(saved.subpaths[0], original.paths[0].path.subpaths[0]); + assert_eq!(saved.subpaths.len(), 2); + let knots = &saved.subpaths[1].knots; + assert_eq!(knots.len(), 4); + for (a, b) in [(0, 1), (2, 3)] { + assert_eq!(knots[a].out_ctrl, knots[a].anchor, "straight segment starts at its anchor"); + assert_eq!(knots[b].in_ctrl, knots[b].anchor, "straight segment ends at its anchor"); + } + assert_eq!(st.history.past_len(), n + 1); + assert!(app.ui.pen.is_none()); + app.ui.tool_options.vector_mode = "path".into(); + let owner = capture_pen_owner(&app).unwrap(); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let (source, n) = (app.session.active().unwrap().doc.clone(), app.session.active().unwrap().history.past_len()); + freeform_finish_owned(&mut app, &points, &[1, 3], owner); + assert!(Arc::ptr_eq(&source, &app.session.active().unwrap().doc)); + assert_eq!(app.session.active().unwrap().history.past_len(), n); +} + 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(); @@ -121,6 +159,24 @@ fn paths_context_menu_uses_the_clicked_row_and_dispatches_commands() { assert_eq!(duplicate["name"], "First copy 2", "duplicate must not overwrite an existing path"); } +#[test] +fn failed_freeform_straight_segment_commit_keeps_its_owned_fitted_draft() { + let mut app = app(); + app.ui.tool = Tool::FreeformPen; + let owner = capture_pen_owner(&app).unwrap(); + let (source, n) = (app.session.active().unwrap().doc.clone(), app.session.active().unwrap().history.past_len()); + let points = [[20.0, 60.0, 1.0], [1e40, 60.0, 1.0], [140.0, 80.0, 1.0]]; + freeform_finish_owned(&mut app, &points, &[1, 2], owner.clone()); + let pen = app.ui.pen.as_ref().expect("invalid fitted geometry must remain available after its failed commit"); + assert!(pen.commit_failed); + assert_eq!(pen.owner.as_ref(), Some(&owner)); + assert!(owner.valid(&app)); + assert_eq!(pen.knots.len(), 3); + assert_eq!(pen.knots[1], [[1e40, 60.0]; 3]); + assert!(Arc::ptr_eq(&source, &app.session.active().unwrap().doc)); + assert_eq!(app.session.active().unwrap().history.past_len(), n); +} + #[test] fn work_path_context_save_and_layer_path_deletion_rules() { let mut app = app(); @@ -329,7 +385,7 @@ fn show_target_path_and_extras_hide_the_path_outlines() { } // The Pen's path in progress is a gesture, not the target path: it stays visible. app.ui.tool = Tool::Pen; - app.ui.pen = Some(PenPath { knots: vec![[[20.0, 20.0]; 3], [[60.0, 40.0]; 3]], ..Default::default() }); + app.ui.pen = Some(PenPath { knots: vec![[[20.0, 20.0]; 3], [[60.0, 40.0]; 3]], owner: capture_pen_owner(&app), ..Default::default() }); toggle(&mut app, "view.show.targetPath"); assert!(overlay_shapes(&app, &ctx) > 0, "the pen path in progress is still drawn"); } @@ -1118,7 +1174,8 @@ fn pen_last_anchor_hit_tolerance_tracks_zoom_and_draft_round_trips() { assert_eq!(pen.knots[1][2], pen.knots[1][0]); let saved = serde_json::to_value(pen).unwrap(); let restored: PenPath = serde_json::from_value(saved).unwrap(); - assert_eq!(restored, *pen); + // The runtime owner isn't saved: a restored draft belongs to no document. + assert_eq!(restored, PenPath { owner: None, ..pen.clone() }); assert_eq!(pen_to_json(&restored, false)["subpaths"][0]["knots"][1]["smooth"], false); pen_down(&mut app, 100.0 + 7.0 / f64::from(zoom), 100.0, false); assert_eq!(app.ui.pen.as_ref().unwrap().knots.len(), 3); diff --git a/crates/vector/src/flatten.rs b/crates/vector/src/flatten.rs index a586dfa1bf..cc50817c74 100644 --- a/crates/vector/src/flatten.rs +++ b/crates/vector/src/flatten.rs @@ -73,28 +73,51 @@ fn eval(seg: &[Point; 4], t: f64) -> (f64, f64) { /// Validate before interactive rendering: finite coordinates and a bounded tessellation and /// dash workload. Arc length is bounded by the cubic control polygon, without allocating pixels. pub fn validate_shape(path: &Path, stroke: Option<&photocraft_doc::ShapeStroke>) -> Result<(), &'static str> { + validate_workload(path, stroke, 1_000_000.0, "shape coordinates must be finite and within ±1000000 px") +} + +fn validate_workload( + path: &Path, + stroke: Option<&photocraft_doc::ShapeStroke>, + coordinate_limit: f64, + coordinate_error: &'static str, +) -> Result<(), &'static str> { let knots = path.subpaths.iter().try_fold(0usize, |n, s| n.checked_add(s.knots.len())).ok_or("too many path knots")?; if knots > 4096 || path.subpaths.len() > 4096 { - return Err("shape path exceeds 4096 knots/subpaths"); + return Err("path exceeds 4096 knots/subpaths"); } let mut points = 0usize; let mut length = 0.0; for sub in &path.subpaths { for k in &sub.knots { for p in [k.anchor, k.in_ctrl, k.out_ctrl] { - if !p.x.is_finite() || !p.y.is_finite() || p.x.abs() > 1_000_000.0 || p.y.abs() > 1_000_000.0 { - return Err("shape coordinates must be finite and within ±1000000 px"); + if !p.x.is_finite() || !p.y.is_finite() || p.x.abs() > coordinate_limit || p.y.abs() > coordinate_limit { + return Err(coordinate_error); } } } points = points.saturating_add(1); for seg in sub.segments() { + let mut segment_length = 0.0; + for pair in seg.windows(2) { + let (dx, dy) = (pair[1].x - pair[0].x, pair[1].y - pair[0].y); + if !dx.is_finite() || !dy.is_finite() { + return Err("path geometry arithmetic must be finite"); + } + segment_length += dx.hypot(dy); + if !segment_length.is_finite() { + return Err("path geometry arithmetic must be finite"); + } + } + length += segment_length; + if !length.is_finite() { + return Err("path geometry arithmetic must be finite"); + } points = points.saturating_add(if is_line(&seg) { 1 } else { pieces(&seg, crate::DEFAULT_TOLERANCE) }); - length += seg.windows(2).map(|w| (w[1].x - w[0].x).hypot(w[1].y - w[0].y)).sum::(); } } if points > 100_000 { - return Err("shape tessellation exceeds 100000 points"); + return Err("path tessellation exceeds 100000 points"); } if let Some(s) = stroke { s.validate()?; @@ -109,6 +132,12 @@ pub fn validate_shape(path: &Path, stroke: Option<&photocraft_doc::ShapeStroke>) Ok(()) } +/// Work and saved paths may extend far beyond the canvas. Keep their coordinates usable by the +/// f32 UI while bounding the complete path's flattening workload before publishing an edit. +pub fn validate_path(path: &Path) -> Result<(), &'static str> { + validate_workload(path, None, f64::from(f32::MAX), "path coordinates must be finite and representable as f32") +} + /// Points one flattened path may hold. Paths that never pass [`validate_shape`] (vector masks /// from files, imported SVG, effect outlines) are flattened more coarsely past it, so hostile /// curves cannot exhaust memory. @@ -203,6 +232,58 @@ mod tests { } } + #[test] + fn general_paths_bound_total_knots_subpaths_and_combined_tessellation() { + let line = Subpath::polygon(&[(0.0, 0.0)]); + let mut paths = Path { subpaths: vec![line.clone(); 4096], ..Default::default() }; + assert!(validate_path(&paths).is_ok()); + paths.subpaths.push(line); + assert_eq!(validate_path(&paths), Err("path exceeds 4096 knots/subpaths")); + let knots = Path { subpaths: vec![Subpath::polygon(&vec![(0.0, 0.0); 4097])], ..Default::default() }; + assert_eq!(validate_path(&knots), Err("path exceeds 4096 knots/subpaths")); + + let curve = shapes::ellipse(0.0, 0.0, 1000.0, 1000.0).subpaths.remove(0); + let points = 1 + curve.segments().iter().map(|s| if is_line(s) { 1 } else { pieces(s, crate::DEFAULT_TOLERANCE) }).sum::(); + let half = 100_000 / points / 2 + 1; + let mut combined = Path { subpaths: vec![curve.clone(); half], ..Default::default() }; + assert!(validate_path(&combined).is_ok()); + assert!(validate_shape(&combined, None).is_ok()); + combined.subpaths.extend(vec![curve; half]); + assert!(combined.subpaths.len() < 4096 && combined.subpaths.iter().map(|s| s.knots.len()).sum::() < 4096); + assert_eq!(validate_path(&combined), Err("path tessellation exceeds 100000 points")); + assert_eq!(validate_shape(&combined, None), Err("path tessellation exceeds 100000 points")); + } + + #[test] + fn general_paths_reject_coordinates_that_overflow_the_ui_or_segment_arithmetic() { + for extent in [f64::from(f32::MAX) * 2.0, 1.0e308, f64::INFINITY, f64::NAN] { + let path = Path { subpaths: vec![Subpath::polygon(&[(-extent, 0.0), (extent, 0.0)])], ..Default::default() }; + assert_eq!(validate_path(&path), Err("path coordinates must be finite and representable as f32")); + } + // The shared arithmetic guard also rejects overflow independently of coordinate policy. + let overflowing_difference = Path { subpaths: vec![Subpath::polygon(&[(-1.0e308, 0.0), (1.0e308, 0.0)])], ..Default::default() }; + assert_eq!(validate_workload(&overflowing_difference, None, f64::MAX, "coordinates"), Err("path geometry arithmetic must be finite")); + let overflowing_length = Path { subpaths: vec![Subpath::polygon(&[(0.0, 0.0), (f64::MAX, f64::MAX)])], ..Default::default() }; + assert_eq!(validate_workload(&overflowing_length, None, f64::MAX, "coordinates"), Err("path geometry arithmetic must be finite")); + } + + #[test] + fn general_paths_keep_large_straight_coordinates_and_shape_limits_stay_put() { + let at_shape_limit = Path { subpaths: vec![Subpath::polygon(&[(0.0, 0.0), (1_000_000.0, 0.0)])], ..Default::default() }; + assert!(validate_shape(&at_shape_limit, None).is_ok()); + let outside_shape = Path { subpaths: vec![Subpath::polygon(&[(0.0, 0.0), (1_000_001.0, 0.0)])], ..Default::default() }; + assert_eq!(validate_shape(&outside_shape, None), Err("shape coordinates must be finite and within ±1000000 px")); + assert!(validate_path(&outside_shape).is_ok()); + for extent in [3.0e9, f64::from(f32::MAX)] { + let path = Path { subpaths: vec![Subpath::polygon(&[(0.0, 0.0), (extent, 0.0), (extent, extent)])], ..Default::default() }; + assert!(validate_path(&path).is_ok()); + let line = flatten_subpath(&path.subpaths[0], crate::DEFAULT_TOLERANCE); + assert_eq!(line.pts, [(0.0, 0.0), (extent, 0.0), (extent, extent)]); + assert!(line.pts.iter().all(|(x, y)| (*x as f32).is_finite() && (*y as f32).is_finite())); + assert!(validate_shape(&path, None).is_err()); + } + } + #[test] fn hostile_curves_flatten_within_the_point_budget() { // Unvalidated paths (a vector mask from a file, an imported SVG) can carry handles far