diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index 01bc4e70..1ee885f5 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -128,7 +128,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..63b10558 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,31 @@ 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() { + 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)) { + 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 +1026,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 +1086,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 +1142,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 +2158,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 e8b64043..526bf2f3 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1402,6 +1402,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 dc503195..53ca6b7a 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 f462de45..e1c219c6 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 | | @@ -173,6 +173,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| | 2026-10-24 | minor | Selection colors: a row's Styles menu also lists the document's color variables, and picking one binds it to every fill and stroke the row stands for, replacing a color style there (`selection.bindColorVariable`; a text range with fills of its own takes the variable's value); a row linked to a style or variable has a Detach button that unlinks it and keeps the color (`selection.detachColor`). Still partial: no library styles or variables, no new style or variable from a row | +| 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 |