From d8900e1c16ffa372ce20fd4f11a082cc6035e60c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 10:51:58 -0700 Subject: [PATCH 1/2] M4: the Text on a path tool Text went onto a path only with the Design panel's Put text on path button (a text layer and a path selected together) or with text.onPath. The Text on a path tool, in the Text flyout of the toolbar (tool.textOnPath), shows the I-beam over a shape's or vector's path, and a click there puts new, empty text on the path, starting at the click, and starts typing. The text takes the path layer's place, transform, box, constraints and effects, and its fills when one of them shows; the path layer is removed. Ending the edit with nothing typed puts the path back with no undo step. The tool measures the distance on the page, so a layer scaled more one way than the other, or shown smaller, is reached where it shows. Shapes in front whose outline is away from the click do not hide a path behind them, and paths that a mask or a clipping frame hides at the click are skipped, as are locked layers and layers inside instances. A double-click into a text layer that was just made no longer makes it count as an existing layer, so leaving it empty records no undo step, with this tool and with the Text tool. --- crates/doc/src/geometry.rs | 2 +- crates/engine/src/cmd/view.rs | 3 + crates/engine/src/cursor_tests.rs | 4 + crates/engine/src/draw_tools_tests.rs | 143 ++++++++++++++++++++++++++ crates/engine/src/textedit.rs | 8 +- crates/engine/src/tools.rs | 139 ++++++++++++++++++++++++- crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/src/icons.rs | 7 ++ crates/ui-egui/src/toolbar.rs | 2 +- docs/mcp.md | 7 +- docs/ui-parity.md | 5 +- 11 files changed, 312 insertions(+), 9 deletions(-) diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index b4f89dbc..095c0c60 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -126,7 +126,7 @@ pub fn hit_leaf(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolR /// Whether child `c` of `parent` is masked at `pt`: a visible mask below it (the nearest mask /// below it, a hidden one masks nothing) doesn't cover the point, so nothing of `c` shows there. -fn masked_out(doc: &Document, parent: &Node, c: NodeId, pt: Point) -> bool { +pub fn masked_out(doc: &Document, parent: &Node, c: NodeId, pt: Point) -> bool { if !doc.has_masks() { return false; } diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 17f71b49..a81a222c 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -535,6 +535,9 @@ pub fn specs() -> Vec { spec!("tool.pen", "Pen", "{}", false, |s, _| tool(s, ToolId::Pen)), spec!("tool.pencil", "Pencil", "{}", false, |s, _| tool(s, ToolId::Pencil)), spec!("tool.text", "Text", "{}", false, |s, _| tool(s, ToolId::Text)), + spec!("tool.textOnPath", "Text on a path", "{} a click on a shape's or vector's path puts new text on it", false, |s, _| { + tool(s, ToolId::TextOnPath) + }), spec!("tool.comment", "Comment", "{}", false, |s, _| tool(s, ToolId::Comment)), spec!("tool.shapeBuilder", "Shape builder", "{}", false, |s, _| tool(s, ToolId::ShapeBuilder)), // `apply: false` is for the UI colour picker: the eyedropper only samples (into diff --git a/crates/engine/src/cursor_tests.rs b/crates/engine/src/cursor_tests.rs index 7a9e0621..6ffb33e4 100644 --- a/crates/engine/src/cursor_tests.rs +++ b/crates/engine/src/cursor_tests.rs @@ -70,6 +70,7 @@ fn scene() -> (Session, NodeId, NodeId) { const EMPTY: (f64, f64) = (700.0, 600.0); const ON_SQUARE: (f64, f64) = (150.0, 150.0); +const ON_SQUARE_EDGE: (f64, f64) = (101.0, 150.0); const ON_TEXT: (f64, f64) = (420.0, 120.0); #[test] @@ -101,6 +102,9 @@ fn each_tool_has_its_cursor_by_modifier_and_what_is_under_the_pointer() { (ToolId::Text, NONE, EMPTY, Crosshair, "the Text tool makes a text where it is pressed"), (ToolId::Text, NONE, ON_SQUARE, Crosshair, "over a shape too"), (ToolId::Text, NONE, ON_TEXT, Text, "the I-beam over a text: a click edits it there"), + (ToolId::TextOnPath, NONE, EMPTY, Default, "text on a path: a click on empty canvas does nothing"), + (ToolId::TextOnPath, NONE, ON_SQUARE, Default, "nor inside a shape, away from its path"), + (ToolId::TextOnPath, NONE, ON_SQUARE_EDGE, Text, "the I-beam over a shape's path: a click puts text on it"), (ToolId::Pen, NONE, EMPTY, Pen(PenCursor::New), "the pen: a new point"), (ToolId::Comment, NONE, EMPTY, Comment, "the comment pin"), (ToolId::Eyedropper, NONE, ON_SQUARE, Eyedropper, "the eyedropper"), diff --git a/crates/engine/src/draw_tools_tests.rs b/crates/engine/src/draw_tools_tests.rs index d67b0af4..39fc545f 100644 --- a/crates/engine/src/draw_tools_tests.rs +++ b/crates/engine/src/draw_tools_tests.rs @@ -60,6 +60,149 @@ fn text_goes_onto_a_path_and_off_again() { assert!(s.doc().unwrap().node(v).is_some() && text_path(&s, t).is_none(), "undo brings the path back"); } +/// A click (press and release) at (`x`, `y`) at 100%. +fn click(s: &mut Session, x: f64, y: f64) { + use crate::tools::{PointerEvent, PointerKind}; + s.pointer(&PointerEvent::at(PointerKind::Down, x, y, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, x, y, 1.0)).unwrap(); +} + +fn editing(s: &Session) -> Option { + s.ds().unwrap().text_edit.as_ref().map(|te| te.id) +} + +#[test] +fn the_text_on_a_path_tool_types_new_text_onto_the_path_it_clicks() { + let mut s = Session::new(); + let v = id(s.execute("vector.create", &json!({"path": "M0 0 L200 0", "x": 40, "y": 100, "props": {"fill": "#FF0000"}})).unwrap()); + s.execute("effect.add", &json!({"ids": [v], "type": "dropShadow"})).unwrap(); + let (vxf, vw, fills, effects) = { + let n = s.doc().unwrap().node(v).unwrap(); + (n.transform, n.width, n.fills.clone(), n.effects.clone()) + }; + assert!(!fills.is_empty() && !effects.is_empty()); + s.set_tool(crate::tools::ToolId::TextOnPath); + // Away from the path a click does nothing. + click(&mut s, 140.0, 160.0); + assert!(editing(&s).is_none() && s.doc().unwrap().node(v).is_some(), "only a path takes text"); + // A quarter of the way along, a few pixels off the line. + click(&mut s, 90.0, 103.0); + let t = editing(&s).expect("typing starts on the path"); + assert!(s.doc().unwrap().node(v).is_none(), "the path layer became the text's path"); + crate::textedit::insert(&mut s, "Along").unwrap(); + crate::textedit::end(&mut s).unwrap(); + let doc = s.doc().unwrap(); + let n = doc.node(t).unwrap(); + assert_eq!((n.transform, n.width), (vxf, vw), "in the path's place"); + assert_eq!(n.kind.text().map(|t| t.characters.as_str()), Some("Along")); + assert_eq!((&n.fills, &n.effects), (&fills, &effects), "the path's fill and effects carry over to the text"); + let tp = text_path(&s, t).unwrap(); + assert!((tp.start - 0.25).abs() < 0.01, "the text starts where the path was clicked: {}", tp.start); + assert_eq!(s.tool.tool, crate::tools::ToolId::Move, "back to Move after placing, as with the Text tool"); + s.execute("edit.undo", &json!({})).unwrap(); + let doc = s.doc().unwrap(); + assert!(doc.node(v).is_some() && doc.node(t).is_none(), "one undo step brings the path back"); +} + +#[test] +fn a_shape_in_front_whose_path_is_away_from_the_click_does_not_hide_the_path_behind() { + let mut s = Session::new(); + let back = id(s.execute("vector.create", &json!({"path": "M0 0 L200 0", "x": 40, "y": 100})).unwrap()); + // A filled square over the line: the click is inside it, 20 or more from its outline. + let front = id(s.execute("shape.rectangle", &json!({"x": 60, "y": 80, "width": 100, "height": 60})).unwrap()); + s.set_tool(crate::tools::ToolId::TextOnPath); + click(&mut s, 90.0, 100.0); + assert!(editing(&s).is_some(), "typing starts on the path behind"); + let doc = s.doc().unwrap(); + assert!(doc.node(back).is_none() && doc.node(front).is_some(), "the clicked path took the text"); +} + +#[test] +fn a_path_scaled_more_one_way_is_reached_where_it_shows() { + let mut s = Session::new(); + let v = id(s.execute("vector.create", &json!({"path": "M0 0 L100 100", "x": 0, "y": 0})).unwrap()); + // Ten times wider than tall: on the page the line runs from (0, 0) to (1000, 100). + s.doc_mut().unwrap().get_mut(v).unwrap().transform = figcraft_geom::Affine::scale_non_uniform(10.0, 1.0); + s.set_tool(crate::tools::ToolId::TextOnPath); + // 5 below the line on the page, halfway along it. + click(&mut s, 500.0, 55.0); + let t = editing(&s).expect("typing starts on the path"); + let tp = text_path(&s, t).unwrap(); + assert!((tp.start - 0.5).abs() < 0.01, "{}", tp.start); + // A layer shown at a tenth of its size: its outline is still reached 5 screen pixels away. + let mut s = Session::new(); + let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 1000, "height": 1000})).unwrap()); + s.doc_mut().unwrap().get_mut(r).unwrap().transform = figcraft_geom::Affine::scale(0.1); + s.set_tool(crate::tools::ToolId::TextOnPath); + click(&mut s, 105.0, 50.0); + assert!(editing(&s).is_some() && s.doc().unwrap().node(r).is_none(), "typing starts on the small square's path"); +} + +#[test] +fn paths_a_mask_or_a_turned_clipping_frame_hides_take_no_text() { + let mut s = Session::new(); + // A square masked by a circle in its top-left corner: its right edge doesn't show. + let m = id(s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()); + let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + s.execute("object.mask", &json!({"ids": [m, r]})).unwrap(); + // A clipping frame turned 45°, with a line outside it: inside the frame's bounding box, but clipped. + let f = id(s.execute("frame.create", &json!({"x": 300, "y": 0, "width": 100, "height": 100, "props": {"clip": true}})).unwrap()); + let l = id(s.execute("vector.create", &json!({"path": "M0 0 L40 0", "x": -60, "y": 50, "parent": f})).unwrap()); + s.execute("node.set", &json!({"id": f, "props": {"rotation": 45}})).unwrap(); + let hidden = s.doc().unwrap().world_transform(l) * figcraft_geom::Point::new(20.0, 0.0); + let frame_box = s.doc().unwrap().world_bounds(f).unwrap(); + assert!(frame_box.contains(hidden), "the line's middle is inside the frame's bounding box"); + s.set_tool(crate::tools::ToolId::TextOnPath); + for (x, y) in [(100.0, 50.0), (hidden.x, hidden.y)] { + click(&mut s, x, y); + assert!(editing(&s).is_none(), "nothing shows at ({x}, {y})"); + } + let doc = s.doc().unwrap(); + assert!(doc.node(r).is_some() && doc.node(l).is_some()); +} + +#[test] +fn text_on_a_stroke_only_path_gets_the_usual_fill() { + let mut s = Session::new(); + let v = id(s.execute("vector.create", &json!({"path": "M0 0 C50 -50 150 -50 200 0", "x": 40, "y": 100, "props": {"fills": []}})).unwrap()); + assert!(!s.doc().unwrap().node(v).unwrap().fills.iter().any(|f| f.visible), "a path with a stroke only"); + s.set_tool(crate::tools::ToolId::TextOnPath); + click(&mut s, 40.0, 100.0); + let t = editing(&s).expect("typing starts on the path"); + assert!(s.doc().unwrap().node(t).unwrap().fills.iter().any(|f| f.visible), "the text can be seen"); +} + +#[test] +fn text_on_a_path_left_empty_puts_the_path_back_without_an_undo_step() { + let mut s = Session::new(); + let r = id(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()); + let locked = id(s.execute("shape.rectangle", &json!({"x": 400, "y": 100, "width": 100, "height": 100, "props": {"locked": true}})).unwrap()); + let before = (s.doc().unwrap().clone(), s.undo_labels(100).len()); + s.set_tool(crate::tools::ToolId::TextOnPath); + click(&mut s, 400.0, 150.0); + assert!(editing(&s).is_none(), "a locked layer's path doesn't take text"); + // Inside the square, away from its outline, nothing happens either. + click(&mut s, 150.0, 150.0); + assert!(editing(&s).is_none()); + click(&mut s, 100.0, 150.0); + assert!(editing(&s).is_some() && s.doc().unwrap().node(r).is_none()); + crate::textedit::end(&mut s).unwrap(); + assert_eq!(*s.doc().unwrap(), before.0, "the square is back as it was"); + assert_eq!(s.undo_labels(100).len(), before.1, "no undo step"); + assert!(s.doc().unwrap().node(locked).is_some()); + // A double-click on the path, then nothing typed: the same (with this tool and the Text tool). + use crate::tools::{PointerEvent, PointerKind, ToolId}; + for tool in [ToolId::TextOnPath, ToolId::Text] { + s.set_tool(tool); + click(&mut s, 100.0, 150.0); + s.pointer(&PointerEvent::at(PointerKind::DoubleClick, 100.0, 150.0, 1.0)).unwrap(); + assert!(editing(&s).is_some(), "{tool:?}"); + crate::textedit::end(&mut s).unwrap(); + assert_eq!(*s.doc().unwrap(), before.0, "{tool:?}: the file is back as it was"); + assert_eq!(s.undo_labels(100).len(), before.1, "{tool:?}: no undo step"); + } +} + #[test] fn svg_text_paths_import_as_text_on_a_path() { // SVG text is parsed with Inter standing in for every family (CI has no fonts). diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 85ea535b..2dfc774c 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -17,6 +17,8 @@ pub fn begin(s: &mut Session, id: NodeId, caret: Option, created: bool) - let n = t.char_count(); s.begin_interaction("Edit text")?; let ds = s.ds_mut()?; + // Editing the same layer again (a double-click into a text just made) keeps it new. + let created = created || ds.text_edit.as_ref().is_some_and(|te| te.id == id && te.created); let (caret, anchor) = match caret { Some(c) => (c.min(n), c.min(n)), None => (n, 0), @@ -44,9 +46,13 @@ pub fn end(s: &mut Session) -> Result<()> { // Nothing to undo for a text box that was never filled. The gesture snapshot was taken // after the layer was added, so it still holds it: re-base it on the document without the // layer, and committing then records no step (and leaves the redo stack alone). + // A snapshot taken before the layer was added (the Text on a path tool, which also took + // the path layer away) is put back whole instead. let ds = s.ds_mut()?; let doc = ds.doc.clone(); - if let Some(it) = ds.interaction.as_mut() { + if ds.interaction.as_ref().is_some_and(|it| it.doc.node(te.id).is_none()) { + s.cancel_interaction()?; + } else if let Some(it) = ds.interaction.as_mut() { it.doc = doc; } } diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 86feed4f..ae97f0e8 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -36,6 +36,7 @@ pub enum ToolId { Pen, Pencil, Text, + TextOnPath, Comment, Eyedropper, ShapeBuilder, @@ -50,7 +51,7 @@ pub enum ToolId { } impl ToolId { - pub const ALL: [ToolId; 25] = [ + pub const ALL: [ToolId; 26] = [ ToolId::Move, ToolId::Scale, ToolId::Hand, @@ -66,6 +67,7 @@ impl ToolId { ToolId::Pen, ToolId::Pencil, ToolId::Text, + ToolId::TextOnPath, ToolId::Comment, ToolId::Eyedropper, ToolId::ShapeBuilder, @@ -94,6 +96,7 @@ impl ToolId { ToolId::Pen => "pen", ToolId::Pencil => "pencil", ToolId::Text => "text", + ToolId::TextOnPath => "textOnPath", ToolId::Comment => "comment", ToolId::Eyedropper => "eyedropper", ToolId::ShapeBuilder => "shapeBuilder", @@ -126,6 +129,7 @@ impl ToolId { ToolId::Pen => "Pen", ToolId::Pencil => "Pencil", ToolId::Text => "Text", + ToolId::TextOnPath => "Text on a path", ToolId::Comment => "Comment", ToolId::Eyedropper => "Pick color", ToolId::ShapeBuilder => "Shape builder", @@ -277,7 +281,8 @@ impl ToolId { ToolId::Pen => Cursor::Pen(PenCursor::New), ToolId::Comment => Cursor::Comment, ToolId::Eyedropper => Cursor::Eyedropper, - ToolId::Move | ToolId::Scale => Cursor::Default, + // Text on a path acts only over a path, where it shows the I-beam. + ToolId::Move | ToolId::Scale | ToolId::TextOnPath => Cursor::Default, // The other tools make something where they are pressed: shapes, frames, slices, pencil // strokes, a new text (over a text the Text tool shows the I-beam instead). _ => Cursor::Crosshair, @@ -643,6 +648,29 @@ pub(crate) const SNAP_PX: f64 = 5.0; /// Screen pixels the pointer travels after a press before a move or resize starts: a click or a /// double-click made with an unsteady hand doesn't drag what it lands on. const DRAG_SLOP_PX: f64 = 3.0; +/// Screen pixels from a path within which the Text on a path tool takes it. +const PATH_REACH_PX: f64 = 6.0; + +/// The visible layers under `id` whose box, grown by `reach` (page units), holds `p`, front-most +/// first: what the Text on a path tool measures its distance to. Content a clipping frame or a +/// mask hides at `p` and the operands of boolean groups are left out. +fn path_candidates(doc: &Document, id: NodeId, p: Point, reach: f64, depth: usize, out: &mut Vec) { + let Some(n) = doc.node(id) else { return }; + if !n.visible || depth > 512 || matches!(n.kind, NodeKind::BooleanOp { .. }) { + return; + } + if n.clip && n.kind.is_frame_like() && !n.local_rect().contains(doc.world_transform(id).inverse() * p) { + return; + } + for c in n.paint_order().rev() { + if !figcraft_doc::geometry::masked_out(doc, n, c, p) { + path_candidates(doc, c, p, reach, depth + 1, out); + } + } + if n.children.is_empty() && !n.is_mask && doc.world_bounds(id).is_some_and(|b| b.inflate(reach, reach).contains(p)) { + out.push(id); + } +} /// Corners of the selection's handle box in page coordinates (tl, tr, br, bl). pub fn selection_box(doc: &Document, sel: &[NodeId]) -> Option<[Point; 4]> { @@ -996,7 +1024,7 @@ impl Session { let _ = crate::shapebuilder::arrangement(self, &sel); } if let Ok(ds) = self.ds_mut() { - if t != ToolId::Text && t != ToolId::Move { + if !matches!(t, ToolId::Text | ToolId::TextOnPath | ToolId::Move) { ds.text_edit = None; } ds.revision += 1; @@ -1056,6 +1084,7 @@ impl Session { Ok(()) } ToolId::Text => self.text_down(ev), + ToolId::TextOnPath => self.text_on_path_down(ev), ToolId::Pen => self.pen_down(ev), ToolId::Pencil => { self.tool.gesture = Gesture::Pencil { points: vec![ev.p()], pressure: vec![ev.pressure] }; @@ -1111,6 +1140,9 @@ impl Session { if tool == ToolId::Text && (on_edited || on_text(self)) { cursor = Cursor::Text; } + if tool == ToolId::TextOnPath && (on_edited || self.path_at(&doc, page, ev).is_some()) { + cursor = Cursor::Text; + } let mut hover = None; let mut guides = Vec::new(); let mut measure_box = None; @@ -2124,6 +2156,107 @@ impl Session { Ok(()) } + /// The layer whose path is under `ev` for the Text on a path tool and where along it the press + /// is (0..1 by length): the front-most shape or vector layer within a few screen pixels of its + /// first subpath (the one text follows), not locked and not inside an instance. Layers in front + /// whose path is further away don't hide it. + pub(crate) fn path_at(&self, doc: &Document, page: NodeId, ev: &PointerEvent) -> Option<(NodeId, f64)> { + let reach = ev.tol() * PATH_REACH_PX; + let near = |id: NodeId| -> Option<(NodeId, f64)> { + let n = doc.node(id)?; + let shape = matches!( + n.kind, + NodeKind::Rectangle + | NodeKind::Ellipse { .. } + | NodeKind::Polygon { .. } + | NodeKind::Star { .. } + | NodeKind::Line + | NodeKind::Vector { .. } + ); + if !shape || locked_in(doc, id) || crate::components::inside_instance(doc, id) { + return None; + } + let geom = figcraft_doc::geometry::stroke_geometry(n)?; + let line = figcraft_geom::measure::polylines(&geom)?.into_iter().next()?; + // Measured on the page (a layer scaled more one way than the other is still reached + // where it shows), the start by length along the layer's own path. + let world = doc.world_transform(id); + let p = ev.p(); + let mut best: Option<(f64, f64)> = None; + for (i, w) in line.points.windows(2).enumerate() { + let (a, b) = (world * w[0], world * w[1]); + let d = b - a; + let l2 = d.hypot2(); + let u = if l2 > 1e-18 { ((p - a).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 }; + let dist = (a.lerp(b, u) - p).hypot(); + if best.is_none_or(|(_, bd)| dist < bd) { + let (la, lb) = (*line.lengths.get(i)?, *line.lengths.get(i + 1)?); + best = Some((la + u * (lb - la), dist)); + } + } + let (along, dist) = best?; + let len = line.length(); + (len > 1e-9 && dist <= reach).then_some((id, along / len)) + }; + let mut candidates = Vec::new(); + path_candidates(doc, page, ev.p(), reach, 0, &mut candidates); + candidates.into_iter().find_map(near) + } + + /// A press with the Text on a path tool: on a path, a new empty text layer goes onto it, + /// starting where it was pressed, and typing starts. The text takes the path layer's place, + /// geometry, transform, constraints and effects, and its fills when one of them shows; the path + /// layer is removed. Ending the edit with nothing typed puts the path back without an undo step + /// ([`textedit::end`]). Elsewhere a press does nothing. + fn text_on_path_down(&mut self, ev: &PointerEvent) -> Result<()> { + if let Some(te) = self.ds()?.text_edit.clone() { + if self.ds()?.doc.world_bounds(te.id).is_some_and(|b| b.contains(ev.p())) { + return self.text_click(te.id, ev, ev.shift); + } + textedit::end(self)?; + } + let ds = self.ds()?; + let (doc, page) = (ds.doc.clone(), ds.page); + let Some((path, start)) = self.path_at(&doc, page, ev) else { return Ok(()) }; + let Some(pn) = doc.node(path) else { return Ok(()) }; + let (Some(parent), Some(geom)) = (pn.parent, figcraft_doc::geometry::stroke_geometry(pn)) else { return Ok(()) }; + let index = doc.index_in_parent(path); + self.begin_interaction("Text on a path")?; + let ds = self.ds_mut()?; + let d = ds.doc_mut(); + let id = d.alloc_id(); + let mut n = Node::new(id, NodeKind::Text(TextData::default()), "Text", pn.width, pn.height); + n.name = String::new(); + n.transform = pn.transform; + n.constraints = pn.constraints; + n.child_layout = pn.child_layout.clone(); + n.effects = pn.effects.clone(); + n.styles.effect = pn.styles.effect.clone(); + // A path drawn with a stroke only has no fill to give: the text gets the usual one. + let filled = pn.fills.iter().any(|f| f.visible); + if filled { + n.fills = pn.fills.clone(); + n.styles.fill = pn.styles.fill.clone(); + } else if let Some(fills) = readable_text_fills(d, parent) { + n.fills = fills; + } + let carried = |k: &str| k.starts_with("effects/") || (filled && (k == "fill" || k.starts_with("fills/"))); + n.bound = pn.bound.iter().filter(|(k, _)| carried(k)).map(|(k, v)| (k.clone(), v.clone())).collect(); + if let NodeKind::Text(t) = &mut n.kind { + let mut tp = TextPath::new(&geom); + tp.start = start; + t.path = Some(Box::new(tp)); + } + d.insert(n, parent, index)?; + d.remove(path)?; + ds.selection = vec![id]; + textedit::begin(self, id, Some(0), true)?; + if !self.prefs.keep_tool { + self.tool.tool = ToolId::Move; + } + Ok(()) + } + /// Drag out a new text box: past the drag slop it has the drag's size (at least one line /// tall) and its top-left, and that size is fixed. fn text_box_drag(&mut self, ev: &PointerEvent, id: NodeId, start: Point, line: f64) -> Result<()> { diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index e1ed336e..20a13346 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1401,6 +1401,7 @@ name = "English" "Text" = "Text" "Text align" = "Text align" "Text content" = "Text content" +"Text on a path" = "Text on a path" "Text size" = "Text size" "Text style" = "Text style" "Text styles" = "Text styles" diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 2e5af4cb..aeb35096 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -40,6 +40,7 @@ icons! { Pen, Pencil, Text, + TextOnPath, Comment, Actions, Chevron, @@ -561,6 +562,12 @@ fn draw(p: &P, icon: Icon) { p.line(11.5, 3.0, 11.5, 5.5); p.line(5.5, 12.5, 9.5, 12.5); } + TextOnPath => { + // A small T above the curve it follows. + p.line(4.5, 3.0, 11.5, 3.0); + p.line(8.0, 3.0, 8.0, 8.5); + p.poly(&[(2.0, 13.5), (5.0, 11.5), (8.0, 10.8), (11.0, 11.5), (14.0, 13.5)], false); + } Comment => { let mut b: Vec<(f32, f32)> = arc_pts(8.5, 7.5, 5.0, 2.75, TAU + 1.95).collect(); b.push((2.5, 13.5)); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index b95575b8..8f526acc 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -70,7 +70,7 @@ const GROUPS: &[Group] = &[ tr_noop("Drawing tools"), &[(ToolId::Pen, Icon::Pen), (ToolId::Pencil, Icon::Pencil), (ToolId::ShapeBuilder, Icon::ShapeBuilder)], ), - Group::tools("text", tr_noop("Text"), &[(ToolId::Text, Icon::Text)]), + Group::tools("text", tr_noop("Text"), &[(ToolId::Text, Icon::Text), (ToolId::TextOnPath, Icon::TextOnPath)]), Group::tools("comment", tr_noop("Comment"), &[(ToolId::Comment, Icon::Comment)]), ]; diff --git a/docs/mcp.md b/docs/mcp.md index 8361286d..6254b207 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -926,7 +926,12 @@ click or drag across regions with `pointer` to merge, add `alt` to remove; `shap them, `text.removeFromPath {ids?}` makes it plain auto-width text again. Resizing the layer scales the path. The text stays editable (`text.edit.*`, `type_text`); `get_node` shows `textPath {d (SVG path data in the layer's space), start, flip}`. Exports draw it as outlines - (PDF too: not selectable); underline and strikethrough aren't drawn on a path. + (PDF too: not selectable); underline and strikethrough aren't drawn on a path. With the tool + `tool.textOnPath`, a `pointer` press within 6 screen pixels of a shape's or vector's first + subpath puts a new, empty text layer on it, starting at the press, and starts typing: the text + also takes the path layer's constraints and effects, and its fills when one of them shows. Ending + the edit with nothing typed puts the path layer back with no undo step. Locked layers and layers + inside instances are skipped. - **Pattern fills**: `fill.pattern {source (layer id), ids?, index?, tile? (grid|horizontalHex| verticalHex), scale?, spacing? [x, y], align? (start|center|end), opacity?}` makes the fill at `index` (default the top one) the source layer repeated, at `scale` × its own size with `spacing` diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2b8c66be..adc90ba4 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -88,7 +88,7 @@ interaction feel not yet compared. | Text: point, auto height, fixed; caret, words, lines, IME | done | the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | | Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`) | | Comment (C), Shift+C to show/hide | done | local only | -| Text on a path (Figma Draw) | done | put on from the Design panel or `text.onPath`, not by clicking a path with the Text tool; the path is edited by resizing only | +| Text on a path (Figma Draw) | done | the Text on a path tool in the Text flyout (`tool.textOnPath`) shows the I-beam over a shape's or vector's path, and a click there puts new text on the path, starting at the click, with the path's effects and its fill when one shows; the path layer is removed, and the path comes back if nothing is typed. Also from the Design panel or `text.onPath`; the path is edited by resizing only | | Stretch and scatter brushes (Figma Draw) | done | eight presets drawn in code; the Pencil's brush is set by `tool.pencilBrush`, not from the toolbar | | Image crop handles | done | | @@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | The Text on a path tool (`tool.textOnPath`, in the Text flyout): a click on a shape's or vector's path puts new text on it, starting at the click, with the path's fill (when one shows) and effects, as the help center's Guide to text describes; the path layer is removed, and an edit left empty puts it back with no undo step (`draw_tools_tests`, `cursor_tests`) | | 2026-10-24 | minor | Boards (M12): eight board bindings pinned (tool keys Q sticky, B shapes, X connector, M marker, U highlighter, E eraser, W stamp, and Shift+Cmd/Ctrl+Enter for the next sticky; boards only, on keys no design-file shortcut uses): 49 → 57 pinned | | 2026-10-15 | minor | Community fixes (#262, #272, #277, #300, #308, #311, #322, #352, #363, #372, #390): first runs follow the system theme; new text is readable on the frame it is drawn in; a long pages list scrolls on its own and new pages take the current page's colour; the pen shows its handles while dragging a point out and ends its path when you leave Design; vector edit mode keeps the canvas still; a drag with the Text tool makes a fixed-size box; colour emoji; the selection highlight on the last line; Up and Down in the font picker with a live preview. Numbers unchanged | | 2026-10-22 | minor | Vector edit mode: points selected in several layers get the transform box, in the page's axes since the layers can be turned differently; its corners and edges scale them, outside a corner rotates them and inside drags them, each layer's points in its own coordinates and one undo step for all, and a click inside clears every layer's points (`transform_tests`). Numbers unchanged | From e46825b1cbc557521931aaaf9ff62c393fcf9a23 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 13:51:31 -0700 Subject: [PATCH 2/2] M4: Text on a path checks masks only for layers that give a candidate The hover search now recurses into each child first and looks up its mask only when the child added a candidate, as layers_at in crates/doc/src/geometry.rs does. --- crates/engine/src/tools.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index ae97f0e8..63b10558 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -663,8 +663,10 @@ fn path_candidates(doc: &Document, id: NodeId, p: Point, reach: f64, depth: usiz return; } for c in n.paint_order().rev() { - if !figcraft_doc::geometry::masked_out(doc, n, c, p) { - path_candidates(doc, c, p, reach, depth + 1, out); + let before = out.len(); + path_candidates(doc, c, p, reach, depth + 1, out); + if out.len() > before && figcraft_doc::geometry::masked_out(doc, n, c, p) { + out.truncate(before); } } if n.children.is_empty() && !n.is_mask && doc.world_bounds(id).is_some_and(|b| b.inflate(reach, reach).contains(p)) {