From c35515c54daf282005faa41352a1688db8752874 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 22:18:14 +0200 Subject: [PATCH] feat(tools): The Pen continues an open path from either end point As in InDesign, the Pen over an end point of an open path (selected, or under the pointer) shows a continue cursor; clicking it carries on drawing that path, new anchors going on its subpath, and clicking its other end closes it. Dragging from the end point first reshapes its direction lines. From the first anchor the subpath is reversed first. - engine: path.reverse (Object > Paths > Reverse Path), path.setAnchor; path.appendAnchor and path.close take a subpath, and appendAnchor refuses a closed subpath - tools: Cursor::PenContinue Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/object.rs | 194 ++++++++++++++++++++++++++++++- crates/tools/src/lib.rs | 2 + crates/tools/src/pen.rs | 108 ++++++++++++++++- crates/tools/src/tests.rs | 59 ++++++++++ crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/i18n/ar.rs | 2 + crates/ui-egui/src/i18n/it.rs | 2 + crates/ui-egui/src/i18n/ja.rs | 2 + crates/ui-egui/src/i18n/pt_br.rs | 2 + crates/ui-egui/src/i18n/uk.rs | 2 + crates/ui-egui/src/menus.rs | 2 + 13 files changed, 368 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c..4f42aa6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · The Pen tool continues an open path from either end point, as in InDesign: the cursor shows it over an end point, a click there carries on drawing that path, a drag first reshapes the end point's direction lines, and the other end closes it. Object › Paths › Reverse Path (`path.reverse`); `path.setAnchor`; `path.appendAnchor` and `path.close` take a subpath. No readiness percentage change. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index cd13be1..e79009e 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -595,8 +595,34 @@ pub fn specs() -> Vec { step_and_repeat ), cmd!("path.create", "Create Path", [], None, "{spread?, anchors: [{p:[x,y], in?:[x,y], out?:[x,y]}], closed?: bool}", has_doc, path_create), - cmd!("path.appendAnchor", "Add Anchor", [], None, "{id, anchor: {p, in?, out?}} (spread coords)", has_doc, path_append), - cmd!("path.close", "Close Path", ["Object", "Paths"], None, "{id}", has_doc, path_close), + cmd!( + "path.appendAnchor", + "Add Anchor", + [], + None, + "{id, anchor: {p, in?, out?} (spread coords), subpath?: index (default the last)} — after the subpath's last anchor", + has_doc, + path_append + ), + cmd!( + "path.setAnchor", + "Set Anchor", + [], + None, + "{id, subpath?: index (default the last), anchor: index|\"first\"|\"last\", p?, in?, out? (spread coords)} — moves an anchor or its direction lines; with both lines given and collinear it becomes smooth", + has_doc, + path_set_anchor + ), + cmd!( + "path.reverse", + "Reverse Path", + ["Object", "Paths"], + None, + "{ids?, subpath?: index (only that subpath of the first target)} — the anchors run the other way (the end point becomes the start)", + has_selection, + path_reverse + ), + cmd!("path.close", "Close Path", ["Object", "Paths"], None, "{id, subpath?: index (default every subpath)}", has_doc, path_close), cmd!("path.moveAnchors", "Move Anchors", [], None, "{id, anchors: [[subpath, index]], dx, dy, handle?: in|out}", has_doc, path_move_anchors), cmd!( "object.align", @@ -1935,11 +1961,20 @@ fn path_create(s: &mut Session, p: &Value) -> Result { fn path_append(s: &mut Session, p: &Value) -> Result { let id = super::id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?; let a = p.get("anchor").and_then(anchor_from).ok_or_else(|| bad("path.appendAnchor", "missing anchor"))?; + let subpath = p.get("subpath").and_then(Value::as_u64); s.edit(|d, _| { let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse(); let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - let sp = it.path.subpaths.last_mut().ok_or_else(|| bad("path.appendAnchor", "empty path"))?; + let sp = match subpath { + Some(i) => { + usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)).ok_or_else(|| bad("path.appendAnchor", "no such subpath"))? + } + None => it.path.subpaths.last_mut().ok_or_else(|| bad("path.appendAnchor", "empty path"))?, + }; + if sp.closed { + return Err(bad("path.appendAnchor", "the subpath is closed")); + } sp.anchors.push(a.transform(inv)); if it.shape == Shape::GraphicLine { it.shape = Shape::Path; @@ -1948,12 +1983,103 @@ fn path_append(s: &mut Session, p: &Value) -> Result { }) } +fn path_set_anchor(s: &mut Session, p: &Value) -> Result { + const ID: &str = "path.setAnchor"; + let id = super::id_param(p, "id").ok_or_else(|| bad(ID, "missing id"))?; + let pt = |k: &str| -> Option { + let a = p.get(k)?.as_array()?; + let q = Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?); + (q.x.is_finite() && q.y.is_finite()).then_some(q) + }; + let (new_p, new_in, new_out) = (pt("p"), pt("in"), pt("out")); + if new_p.is_none() && new_in.is_none() && new_out.is_none() { + return Err(bad(ID, "give p, in or out")); + } + let subpath = p.get("subpath").and_then(Value::as_u64); + let which = p.get("anchor").cloned().unwrap_or(Value::Null); + s.edit(|d, _| { + let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse(); + let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + if it.locked { + return Err(bad(ID, "the path is locked")); + } + let sp = match subpath { + Some(i) => usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)), + None => it.path.subpaths.last_mut(), + } + .ok_or_else(|| bad(ID, "no such subpath"))?; + let n = sp.anchors.len(); + let ai = match &which { + Value::String(w) if w == "first" => 0, + Value::String(w) if w == "last" => n.saturating_sub(1), + v => v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(ID, "anchor: an index, \"first\" or \"last\""))?, + }; + let a = sp.anchors.get_mut(ai).ok_or_else(|| bad(ID, "no such anchor"))?; + if let Some(q) = new_p { + let v = inv * q - a.p; + a.p += v; + a.h_in += v; + a.h_out += v; + } + if let Some(q) = new_in { + a.h_in = inv * q; + } + if let Some(q) = new_out { + a.h_out = inv * q; + } + if new_in.is_some() && new_out.is_some() { + let (i, o) = (a.h_in - a.p, a.h_out - a.p); + let collinear = (i.x * o.y - i.y * o.x).abs() <= 1e-6 * i.hypot().max(1.0) * o.hypot().max(1.0) && i.dot(o) <= 0.0; + a.kind = if collinear { designcraft_geom::AnchorKind::Smooth } else { designcraft_geom::AnchorKind::Corner }; + } + ok() + }) +} + +fn path_reverse(s: &mut Session, p: &Value) -> Result { + let ids = targets(s, p)?; + let only = p.get("subpath").and_then(Value::as_u64).map(|i| usize::try_from(i).unwrap_or(usize::MAX)); + if let (Some(i), Some(id)) = (only, ids.first()) + && s.doc()?.doc.item(*id).is_none_or(|it| it.path.subpaths.get(i).is_none()) + { + return Err(bad("path.reverse", "no such subpath")); + } + let ids = if only.is_some() { ids.into_iter().take(1).collect() } else { ids }; + s.edit(|d, _| { + let mut reversed = 0; + for id in &ids { + let Some(it) = d.item_mut(*id) else { continue }; + if it.locked || it.is_group() { + continue; + } + for (i, sp) in it.path.subpaths.iter_mut().enumerate() { + if only.is_some_and(|o| o != i) { + continue; + } + sp.anchors.reverse(); + for a in &mut sp.anchors { + std::mem::swap(&mut a.h_in, &mut a.h_out); + } + } + reversed += 1; + } + Ok(json!({"reversed": reversed})) + }) +} + fn path_close(s: &mut Session, p: &Value) -> Result { let id = super::id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?; + let only = p.get("subpath").and_then(Value::as_u64).map(|i| usize::try_from(i).unwrap_or(usize::MAX)); s.edit(|d, _| { let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - for sp in &mut it.path.subpaths { - sp.closed = true; + if only.is_some_and(|i| i >= it.path.subpaths.len()) { + return Err(bad("path.close", "no such subpath")); + } + for (i, sp) in it.path.subpaths.iter_mut().enumerate() { + if only.is_none_or(|o| o == i) { + sp.closed = true; + } } if it.shape == Shape::GraphicLine { it.shape = Shape::Path; @@ -2813,3 +2939,61 @@ mod swatch_name_tests { } } } + +#[cfg(test)] +mod path_edit_tests { + use designcraft_doc::ItemId; + use designcraft_geom::Point; + use serde_json::json; + + fn session_with_path() -> (crate::Session, u64) { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s + .execute( + "path.create", + &json!({"anchors": [{"p": [100, 100]}, {"p": [200, 100], "in": [170, 80], "out": [230, 120]}, {"p": [300, 200]}]}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + (s, id) + } + + fn anchors(s: &crate::Session, id: u64) -> Vec { + let d = &s.doc().unwrap().doc; + let it = d.item(ItemId(id)).unwrap(); + let xf = d.parent_xf(&d.find(ItemId(id)).unwrap()) * it.xf; + it.path.subpaths[0].anchors.iter().map(|a| a.transform(xf)).collect() + } + + #[test] + fn reverse_path_swaps_ends_and_direction_lines() { + let (mut s, id) = session_with_path(); + s.execute("path.reverse", &json!({"ids": [id]})).unwrap(); + let a = anchors(&s, id); + assert_eq!(a.iter().map(|a| a.p).collect::>(), [Point::new(300.0, 200.0), Point::new(200.0, 100.0), Point::new(100.0, 100.0)]); + assert_eq!((a[1].h_in, a[1].h_out), (Point::new(230.0, 120.0), Point::new(170.0, 80.0))); + assert!(s.execute("path.reverse", &json!({"ids": [id], "subpath": 3})).is_err()); + } + + #[test] + fn anchors_go_on_the_named_open_subpath_and_set_anchor_reshapes_one() { + let (mut s, id) = session_with_path(); + s.execute("path.appendAnchor", &json!({"id": id, "subpath": 0, "anchor": {"p": [400, 250]}})).unwrap(); + assert_eq!(anchors(&s, id).last().unwrap().p, Point::new(400.0, 250.0)); + assert!(s.execute("path.appendAnchor", &json!({"id": id, "subpath": 1, "anchor": {"p": [0, 0]}})).is_err()); + // Direction lines on the last anchor: collinear and opposite make it smooth. + s.execute("path.setAnchor", &json!({"id": id, "anchor": "last", "in": [380, 230], "out": [420, 270]})).unwrap(); + let last = *anchors(&s, id).last().unwrap(); + assert_eq!((last.h_in, last.h_out, last.kind), (Point::new(380.0, 230.0), Point::new(420.0, 270.0), designcraft_geom::AnchorKind::Smooth)); + s.execute("path.setAnchor", &json!({"id": id, "anchor": 0, "p": [90, 110]})).unwrap(); + assert_eq!(anchors(&s, id)[0].p, Point::new(90.0, 110.0)); + assert!(s.execute("path.setAnchor", &json!({"id": id, "anchor": 9, "p": [0, 0]})).is_err()); + assert!(s.execute("path.setAnchor", &json!({"id": id, "anchor": "last"})).is_err(), "nothing to set"); + // Closing one subpath; then it takes no more anchors. + s.execute("path.close", &json!({"id": id, "subpath": 0})).unwrap(); + assert!(s.execute("path.appendAnchor", &json!({"id": id, "anchor": {"p": [0, 0]}})).is_err()); + assert!(s.execute("path.close", &json!({"id": id, "subpath": 4})).is_err()); + } +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 247494f..4dad3be 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -335,6 +335,8 @@ pub enum Cursor { ResizeNeSw, Rotate, Pen, + /// The Pen over an open path's end point: a click continues the path. + PenContinue, Text, Hand, HandGrab, diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index 3c75e51..21e90f7 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -1,8 +1,9 @@ //! Pen tool (P): click for corner points, drag for smooth points, click the first point to close, //! Enter/Esc (or switching tools) to finish an open path. Each anchor is its own undo step, as in -//! InDesign. +//! InDesign. Clicking an end point of an open path (the cursor shows it) continues that path from +//! there; dragging from it first reshapes its direction lines. -use designcraft_doc::SpreadRef; +use designcraft_doc::{ItemId, SpreadRef}; use designcraft_geom::{BezPath, Point}; use serde_json::{Value, json}; @@ -19,6 +20,45 @@ pub struct PenTool { hover: Point, /// Smart guides in canvas coordinates, as `snap_point` returned them. guides: Vec, + /// Continuing an existing path: the subpath the new anchors go on. + subpath: Option, + /// The press was on the continued path's end point: a drag reshapes its direction lines + /// instead of adding an anchor. + editing_end: bool, + /// The press on the end point has been dragged. + end_moved: bool, +} + +/// An end point of an open path under canvas point `p`: (item, subpath, whether it is the last +/// anchor). The selection is checked first, then the frontmost item under the pointer. +fn endpoint_at(cx: &ToolContext, p: Point) -> Option<(ItemId, usize, bool)> { + let tol = cx.tol(6.0); + let mut ids = cx.selection.items.clone(); + if let Some((_, id)) = cx.hit(p) + && !ids.contains(&id) + { + ids.push(id); + } + for id in ids { + let Some(it) = cx.doc.item(id) else { continue }; + if it.locked || it.is_group() || !matches!(it.content, designcraft_doc::Content::Unassigned) { + continue; + } + let Some(xf) = cx.item_canvas_xf(id).map(|m| m * it.xf) else { continue }; + for (si, sp) in it.path.subpaths.iter().enumerate() { + if sp.closed { + continue; + } + let (Some(first), Some(last)) = (sp.anchors.first(), sp.anchors.last()) else { continue }; + if (xf * last.p - p).hypot() <= tol { + return Some((id, si, true)); + } + if (xf * first.p - p).hypot() <= tol { + return Some((id, si, false)); + } + } + } + None } fn anchor_json(a: &(Point, Point, Point)) -> Value { @@ -32,9 +72,35 @@ impl PenTool { self.anchors.clear(); self.dragging = false; self.guides.clear(); + self.subpath = None; + self.editing_end = false; + self.end_moved = false; vec![] } + /// Start continuing subpath `si` of `id` from its last (`at_end`) or first anchor: the anchors + /// so far are its other end and the end point, in spread coordinates. + fn continue_from(&mut self, cx: &ToolContext, id: ItemId, si: usize, at_end: bool) -> Vec { + let (Some(it), Some(loc)) = (cx.doc.item(id), cx.doc.find(id)) else { return vec![] }; + let Some(sp) = it.path.subpaths.get(si) else { return vec![] }; + let (Some(first), Some(last)) = (sp.anchors.first(), sp.anchors.last()) else { return vec![] }; + let xf = cx.doc.parent_xf(&loc) * it.xf; + let at = |a: &designcraft_geom::Anchor, flip: bool| { + let (i, o) = if flip { (a.h_out, a.h_in) } else { (a.h_in, a.h_out) }; + (xf * a.p, xf * i, xf * o) + }; + // From the first anchor the path is reversed first, so the new anchors follow on. + let (other, end) = if at_end { (at(first, false), at(last, false)) } else { (at(last, true), at(first, true)) }; + self.path = Some(id.0); + self.subpath = Some(si); + self.spread = Some(loc.spread); + self.anchors = vec![other, end]; + self.editing_end = true; + self.end_moved = false; + self.dragging = true; + if at_end { vec![] } else { vec![Action::Exec("path.reverse".into(), json!({"ids": [id.0], "subpath": si}))] } + } + /// Shift constrains first. The stored point is the snapped spread point. Guides are already canvas. fn snap_click(&mut self, cx: &ToolContext, spread: SpreadRef, mut p: Point, shift: bool) -> Point { if shift && let Some(last) = self.anchors.last() { @@ -53,7 +119,13 @@ impl PenTool { let anchors: Vec = self.anchors.iter().map(anchor_json).collect(); vec![Action::Exec("path.create".into(), json!({"spread": spread_json(sr), "anchors": anchors, "closed": false, "notifyPen": true}))] } - Some(id) => vec![Action::Exec("path.appendAnchor".into(), json!({"id": id, "anchor": anchor_json(last)}))], + Some(id) => { + let mut p = json!({"id": id, "anchor": anchor_json(last)}); + if let Some(si) = self.subpath { + p["subpath"] = json!(si); + } + vec![Action::Exec("path.appendAnchor".into(), p)] + } None => vec![], } } @@ -93,12 +165,24 @@ impl Tool for PenTool { && (first.0 - sp).hypot() <= cx.tol(6.0) { let out = match self.path { - Some(id) => vec![Action::Exec("path.close".into(), json!({"id": id}))], + Some(id) => { + let mut p = json!({"id": id}); + if let Some(si) = self.subpath { + p["subpath"] = json!(si); + } + vec![Action::Exec("path.close".into(), p)] + } None => vec![], }; self.finish(); return out; } + // On an open path's end point: continue that path. + if self.anchors.is_empty() + && let Some((id, si, at_end)) = endpoint_at(cx, ev.pos) + { + return self.continue_from(cx, id, si, at_end); + } let p = self.snap_click(cx, snap_spread, sp, ev.mods.shift); if self.anchors.is_empty() { self.spread = Some(sr); @@ -108,6 +192,7 @@ impl Tool for PenTool { vec![] } PointerKind::Drag => { + self.end_moved |= self.editing_end; if let Some(a) = self.anchors.last_mut() { // Smooth point: out handle follows the pointer, in handle mirrors it. a.2 = sp; @@ -120,6 +205,17 @@ impl Tool for PenTool { return vec![]; } self.dragging = false; + // The continued path's end point: a drag set its direction lines. + if std::mem::take(&mut self.editing_end) { + let (Some(id), Some(si), Some(&(p, h_in, h_out))) = (self.path, self.subpath, self.anchors.last()) else { return vec![] }; + if !std::mem::take(&mut self.end_moved) || (h_out - p).hypot() < 1e-9 { + return vec![]; + } + return vec![Action::Exec( + "path.setAnchor".into(), + json!({"id": id, "subpath": si, "anchor": "last", "in": [h_in.x, h_in.y], "out": [h_out.x, h_out.y]}), + )]; + } self.commit_anchor() } _ => vec![], @@ -165,8 +261,8 @@ impl Tool for PenTool { out } - fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::Pen + fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + if self.anchors.is_empty() && endpoint_at(cx, p).is_some() { Cursor::PenContinue } else { Cursor::Pen } } fn busy(&self) -> bool { diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 6c75b0d..f8f58c7 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -705,3 +705,62 @@ fn pen_click_snaps_to_a_guide() { let x = p["anchors"][0]["p"][0].as_f64().unwrap(); assert!((x - 100.0).abs() < 1e-6, "anchor x {x}"); } + +#[test] +fn pen_continues_an_open_path_from_either_end_point() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let anchors = vec![ + designcraft_geom::Anchor::corner(Point::new(100.0, 100.0)), + designcraft_geom::Anchor { + p: Point::new(200.0, 100.0), + h_in: Point::new(170.0, 80.0), + h_out: Point::new(230.0, 120.0), + kind: designcraft_geom::AnchorKind::Smooth, + }, + ]; + let mut it = Item::new(id, lid, Shape::Path, designcraft_geom::PathData::single(designcraft_geom::SubPath::new(anchors, false))); + it.stroke = Stroke::default(); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let off = l.xf(SpreadRef::Doc(0)).translation(); + let at = |x: f64, y: f64| (x + off.x, y + off.y); + let ev = |k: PointerKind, (x, y): (f64, f64)| PointerEvent::new(k, x, y); + let mut t = create("pen"); + // The cursor says the end point continues the path; elsewhere it's the plain pen. + assert_eq!(t.cursor(&cx, Point::new(at(200.0, 100.0).0, at(200.0, 100.0).1), Mods::default()), Cursor::PenContinue); + assert_eq!(t.cursor(&cx, Point::new(at(150.0, 300.0).0, at(150.0, 300.0).1), Mods::default()), Cursor::Pen); + // Clicking the last anchor adds nothing yet; the next click appends to that path. + assert!(t.pointer(&cx, &ev(PointerKind::Down, at(200.0, 100.0))).is_empty()); + assert!(t.pointer(&cx, &ev(PointerKind::Up, at(200.0, 100.0))).is_empty(), "a click on the end point changes nothing"); + t.pointer(&cx, &ev(PointerKind::Down, at(300.0, 200.0))); + let a = t.pointer(&cx, &ev(PointerKind::Up, at(300.0, 200.0))); + let [Action::Exec(cmd, p)] = a.as_slice() else { panic!("{a:?}") }; + assert_eq!((cmd.as_str(), p["id"].as_u64(), p["subpath"].as_u64()), ("path.appendAnchor", Some(id.0), Some(0))); + assert_eq!((p["anchor"]["p"][0].as_f64(), p["anchor"]["p"][1].as_f64()), (Some(300.0), Some(200.0))); + // Clicking the first anchor of the other end closes it. + let a = t.pointer(&cx, &ev(PointerKind::Down, at(100.0, 100.0))); + assert_eq!(a, vec![Action::Exec("path.close".into(), serde_json::json!({"id": id.0, "subpath": 0}))]); + assert!(t.pointer(&cx, &ev(PointerKind::Up, at(100.0, 100.0))).is_empty()); + // From the first anchor, the path is reversed first; a drag there sets its direction lines. + let mut t = create("pen"); + let a = t.pointer(&cx, &ev(PointerKind::Down, at(100.0, 100.0))); + assert_eq!(a, vec![Action::Exec("path.reverse".into(), serde_json::json!({"ids": [id.0], "subpath": 0}))]); + t.pointer(&cx, &ev(PointerKind::Drag, at(80.0, 140.0))); + let a = t.pointer(&cx, &ev(PointerKind::Up, at(80.0, 140.0))); + assert_eq!( + a, + vec![Action::Exec( + "path.setAnchor".into(), + serde_json::json!({"id": id.0, "subpath": 0, "anchor": "last", "in": [120.0, 60.0], "out": [80.0, 140.0]}) + )] + ); + // Closed paths and locked paths aren't continued. + let mut d2 = d.clone(); + d2.item_mut(id).unwrap().locked = true; + let cx = ctx(&d2, &s, &c, &l); + let t = create("pen"); + assert_eq!(t.cursor(&cx, Point::new(at(200.0, 100.0).0, at(200.0, 100.0).1), Mods::default()), Cursor::Pen); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4c7313e..176b8a2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -352,7 +352,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { Cursor::ResizeNwSe => C::ResizeNwSe, Cursor::ResizeNeSw => C::ResizeNeSw, Cursor::Rotate => C::Alias, - Cursor::Pen => C::Crosshair, + Cursor::Pen | Cursor::PenContinue => C::Crosshair, Cursor::Text => C::Text, Cursor::Hand => C::Grab, Cursor::HandGrab => C::Grabbing, diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5976d7d..39b31ca 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -55,6 +55,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("Reverse Path", ["Pfadrichtung umkehren", "Inverser le tracé", "Invertir trazado", "パスの方向反転", "反转路径"]), + ("Set Anchor", ["Ankerpunkt festlegen", "Définir le point d’ancrage", "Definir punto de ancla", "アンカーポイントを設定", "设置锚点"]), ("Clear", ["Löschen", "Effacer", "Borrar", "消去", "清除"]), ("Duplicate", ["Duplizieren", "Dupliquer", "Duplicar", "複製", "重复"]), ("Select All", ["Alles auswählen", "Tout sélectionner", "Seleccionar todo", "すべてを選択", "全选"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index c337348..d02370d 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Reverse Path", "عكس المسار"), + ("Set Anchor", "تعيين نقطة الارتساء"), ("Clear", "مسح"), ("Delete", "حذف"), ("Duplicate", "تكرار"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 34766cf..e61d461 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Reverse Path", "Inverti tracciato"), + ("Set Anchor", "Imposta ancoraggio"), ("Clear", "Cancella"), ("Duplicate", "Duplica"), ("Select All", "Seleziona tutto"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index de57db5..7dd06af 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Reverse Path", "パスの方向反転"), + ("Set Anchor", "アンカーポイントを設定"), ("Clear", "消去"), ("Duplicate", "複製"), ("Select All", "すべてを選択"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index a4d06bb..ea7f529 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Reverse Path", "Inverter demarcador"), + ("Set Anchor", "Definir ponto de ancoragem"), ("Clear", "Limpar"), ("Delete", "Excluir"), ("Duplicate", "Duplicar"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9b70204..96bd9bb 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Reverse Path", "Розвернути контур"), + ("Set Anchor", "Задати прив’язку"), ("Clear", "Очистити"), ("Duplicate", "Дублювати"), ("Select All", "Виділити все"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cb14223..f2b3d89 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -486,6 +486,8 @@ pub const MENUS: &[(&str, &[&str])] = &[ ">Paths", "cmd:object.makeCompoundPath", "cmd:object.releaseCompoundPath", + "-", + "cmd:path.reverse", "<", ">Pathfinder", "cmd:object.pathfinder|Add|{\"op\": \"add\"}",