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\"}",