# Conflicts:
#	docs/ui-parity.md
This commit is contained in:
aiongg
2026-10-25 02:21:19 +00:00
11 changed files with 314 additions and 9 deletions
+1 -1
View File
@@ -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;
}
+3
View File
@@ -535,6 +535,9 @@ pub fn specs() -> Vec<CommandSpec> {
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
+4
View File
@@ -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"),
+143
View File
@@ -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<NodeId> {
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).
+7 -1
View File
@@ -17,6 +17,8 @@ pub fn begin(s: &mut Session, id: NodeId, caret: Option<usize>, 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;
}
}
+138 -3
View File
@@ -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<NodeId>) {
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<()> {
+1
View File
@@ -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"
+7
View File
@@ -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));
+1 -1
View File
@@ -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)]),
];
+6 -1
View File
@@ -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`
+3 -2
View File
File diff suppressed because one or more lines are too long