Merge pr/636: Tools: drag type-on-a-path start and end brackets (#636)

# Conflicts:
#	ROADMAP.md
#	crates/idml/src/tests.rs
#	crates/tools/src/lib.rs
#	crates/tools/src/select.rs
#	crates/tools/src/tests.rs
#	crates/ui-egui/src/canvas.rs
This commit is contained in:
aiongg
2026-10-13 00:08:12 +00:00
13 changed files with 261 additions and 9 deletions
+1
View File
@@ -128,6 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-12** · Type on a path has start and end brackets, as in InDesign: selected path type shows them across the path, and dragging either with the Selection or Direct Selection tool moves where the text starts or ends (text past the end bracket is overset). `type.pathOptions {end}`; IDML reads and writes EndBracket. No readiness percentage change.
- **2026-10-12** · The Pen tool continues an open path from either end point, as in InDesign: the cursor shows it over an end point, a click there carries on drawing that path, a drag first reshapes the end point's direction lines, and the other end closes it. Object › Paths › Reverse Path (`path.reverse`); `path.setAnchor`; `path.appendAnchor` and `path.close` take a subpath. No readiness percentage change.
- **2026-10-12** · The Eyedropper puts a colour sampled from an image on the stroke when the toolbox's Stroke box is in front, as in InDesign (a line takes it on its stroke). No readiness percentage change.
- **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
+2 -1
View File
@@ -716,7 +716,8 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
// Keep the path identity: its synthetic area uses full line extents.
..tf.options.clone()
};
(Rect::new(0.0, 0.0, (len - pt.start).max(1.0), size * 1.6), opts)
let end = pt.end.map_or(len, |e| e.clamp(0.0, len));
(Rect::new(0.0, 0.0, (end - pt.start).max(1.0), size * 1.6), opts)
}
None => (item.text_area(), tf.options.clone()),
};
+1 -1
View File
@@ -2520,7 +2520,7 @@ fn path_text_keeps_the_original_vertical_fit_limit() {
it.shape = designcraft_doc::Shape::Path;
it.path = designcraft_geom::shapes::line(Point::ZERO, Point::new(200.0, 0.0));
it.text_frame_mut().unwrap().options.path =
Some(designcraft_doc::PathType { start: 0.0, flip: false, align: designcraft_doc::PathAlign::Baseline });
Some(designcraft_doc::PathType { start: 0.0, end: None, flip: false, align: designcraft_doc::PathAlign::Baseline });
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| {
f.over.font_family = Some("Source Serif 4".into());
f.over.size = Some(20.0);
+5 -1
View File
@@ -405,8 +405,12 @@ pub struct TextFrameOptions {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct PathType {
/// Where the text starts, as a distance along the path.
/// Where the text starts, as a distance along the path (the start bracket).
pub start: f64,
/// Where the text ends, as a distance along the path (the end bracket); `None` is the path's
/// end. Text past it is overset.
#[serde(skip_serializing_if = "Option::is_none")]
pub end: Option<f64>,
/// Run the other way (the text sits on the other side).
pub flip: bool,
pub align: PathAlign,
+58 -4
View File
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Type on a Path Options…",
["Type", "Type on a Path"],
None,
"{id?, start?, flip?, align?: baseline|ascender|descender|center, delete?: true (Delete Type from Path)}",
"{id?, start?, end?: distance along the path or null (the path's end), flip?, align?: baseline|ascender|descender|center, delete?: true (Delete Type from Path)} — start and end are the brackets: start ≤ end, both on the path",
has_doc,
path_options
),
@@ -86,6 +86,7 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
let p = p.clone();
s.edit(|d, sel| {
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let len = designcraft_geom::warp::PathWarp::new(&it.path.to_bezpath(), false).length();
let Content::Text(tf) = &mut it.content else { return Err(bad("type.pathOptions", "not type on a path")) };
let Some(pt) = tf.options.path.as_mut() else { return Err(bad("type.pathOptions", "not type on a path")) };
if delete {
@@ -95,8 +96,26 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
sel.text = None;
return Ok(json!({"deleted": true}));
}
if let Some(v) = p.get("start").and_then(Value::as_f64) {
pt.start = v.max(0.0);
if let Some(v) = p.get("start").and_then(Value::as_f64).filter(|v| v.is_finite()) {
pt.start = v.clamp(0.0, len);
}
match p.get("end") {
Some(Value::Null) => pt.end = None,
Some(v) => {
let e = v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad("type.pathOptions", "end: a distance or null"))?;
pt.end = (e < len - 0.01).then_some(e.max(0.0));
}
None => {}
}
// The brackets can't cross: the one being moved stops at the other.
if let Some(e) = pt.end
&& pt.start > e
{
if p.get("end").is_some() {
pt.end = Some(pt.start);
} else {
pt.start = e;
}
}
if let Some(v) = p.get("flip").and_then(Value::as_bool) {
pt.flip = v;
@@ -104,7 +123,7 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(a) = align {
pt.align = a;
}
Ok(json!({"start": pt.start, "flip": pt.flip, "align": pt.align}))
Ok(json!({"start": pt.start, "end": pt.end, "flip": pt.flip, "align": pt.align}))
})
}
@@ -160,4 +179,39 @@ mod tests {
let pdf = designcraft_pdf::export_pdf(&d, &s2.cache, &Default::default()).unwrap();
assert!(pdf.len() > 1000);
}
#[test]
fn the_brackets_set_where_the_text_starts_and_ends() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("line.create", &json!({"a": [100, 300], "b": [500, 300]})).unwrap()["id"].as_u64().unwrap();
let r = s.execute("type.onPath", &json!({"id": id, "text": "Sixteen letters!"})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
let overset = |s: &Session| designcraft_compose::compose_story(&s.doc().unwrap().doc, sid, &Default::default()).overset_at.is_some();
let undo = s.doc().unwrap().history.undo.len();
assert!(!overset(&s));
// The end bracket close to the start: the text no longer fits, and is overset.
let r = s.execute("type.pathOptions", &json!({"id": id, "start": 50, "end": 70})).unwrap();
assert_eq!((r["start"].as_f64(), r["end"].as_f64()), (Some(50.0), Some(70.0)));
assert!(overset(&s));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
// Brackets can't cross: the start stops at the end, the end at the start.
let r = s.execute("type.pathOptions", &json!({"id": id, "start": 90})).unwrap();
assert_eq!(r["start"].as_f64(), Some(70.0));
let r = s.execute("type.pathOptions", &json!({"id": id, "start": 10, "end": 5})).unwrap();
assert_eq!((r["start"].as_f64(), r["end"].as_f64()), (Some(10.0), Some(10.0)));
// Past the path, or null: the path's end again; the start stays on the path.
let r = s.execute("type.pathOptions", &json!({"id": id, "end": 1000})).unwrap();
assert!(r["end"].is_null());
let r = s.execute("type.pathOptions", &json!({"id": id, "end": 200})).unwrap();
assert_eq!(r["end"].as_f64(), Some(200.0));
let r = s.execute("type.pathOptions", &json!({"id": id, "end": null, "start": 5000})).unwrap();
assert_eq!((r["start"].as_f64(), r["end"].is_null()), (Some(400.0), true));
assert!(s.execute("type.pathOptions", &json!({"id": id, "end": "far"})).is_err());
// Saved with the document.
let d = s.doc().unwrap().doc.clone();
let back = crate::cmd::file_from(&crate::cmd::file_bytes(&d)).unwrap();
let path = |d: &designcraft_doc::Document| d.item(designcraft_doc::ItemId(id)).unwrap().text_frame().unwrap().options.path.clone();
assert_eq!(path(&back), path(&d));
}
}
+21
View File
@@ -47,6 +47,22 @@ impl PathWarp {
None
}
/// The arc length (as `at` measures it, so flipped paths count from their end) of the point
/// of the path nearest `p`.
pub fn nearest(&self, p: Point) -> f64 {
use kurbo::ParamCurveNearest;
let mut best = (f64::INFINITY, 0.0);
let mut before = 0.0;
for (seg, len) in &self.segs {
let n = seg.nearest(p, 1e-6);
if n.distance_sq < best.0 {
best = (n.distance_sq, before + seg.subsegment(0.0..n.t).arclen(1e-4));
}
before += len;
}
if self.flip { self.total - best.1 } else { best.1 }
}
/// Transform for something laid out on a straight line (x along the line, y down from the
/// line) whose horizontal centre is `mid`: it lands centred at arc length `mid`, turned with
/// the path, its line at `baseline` on the path.
@@ -76,5 +92,10 @@ mod tests {
assert!((g * Point::new(50.0, 10.0) - Point::new(50.0, 0.0)).hypot() < 1e-9);
let flipped = PathWarp::new(&bp, true);
assert!((flipped.at(0.0).unwrap().0 - Point::new(100.0, 100.0)).hypot() < 1e-9);
// The nearest point by arc length, off the path and on it; flipped counts from the end.
assert!((w.nearest(Point::new(120.0, 50.0)) - 150.0).abs() < 1e-6);
assert!((w.nearest(Point::new(120.0, 150.0)) - 200.0).abs() < 1e-6, "past the end: the end");
assert!((w.nearest(Point::new(30.0, -8.0)) - 30.0).abs() < 1e-6);
assert!((flipped.nearest(Point::new(30.0, -8.0)) - 170.0).abs() < 1e-6);
}
}
+1 -1
View File
@@ -1792,7 +1792,7 @@ impl<'a> Ex<'a> {
.attr("TextAlignment", names::path_align_out(pt.align))
.attr("FlipPathEffect", if pt.flip { "Flipped" } else { "NotFlipped" })
.attr("StartBracket", num(pt.start))
.attr("EndBracket", num(len)),
.attr("EndBracket", num(pt.end.map_or(len, |e| e.min(len)))),
);
} else if let Content::Text(t) = &it.content {
let (prev, next) = self.threads.get(&it.id.0).copied().unwrap_or((None, None));
+5
View File
@@ -2593,6 +2593,11 @@ impl<'r> Importer<'r> {
let options = TextFrameOptions {
path: Some(designcraft_doc::PathType {
start: tp.num("StartBracket").unwrap_or(0.0).max(0.0),
// At (or past) the path's length it is the path's end: no end bracket.
end: tp
.num("EndBracket")
.filter(|e| e.is_finite() && *e >= 0.0)
.filter(|e| *e < designcraft_geom::warp::PathWarp::new(&it.path.to_bezpath(), false).length() - 0.01),
flip: tp.get("FlipPathEffect") == Some("Flipped"),
align: names::path_align_in(tp.get("TextAlignment").unwrap_or("")),
}),
+24
View File
@@ -3432,3 +3432,27 @@ fn vertical_balance_columns_balances_the_imported_frame() {
let off = per_column(&d);
assert!(off[0] > off[1] + 1, "unbalanced: {off:?}");
}
#[test]
fn type_on_a_path_keeps_its_start_and_end_brackets() {
use designcraft_geom::{Anchor, PathData, Point, SubPath};
for end in [Some(250.0), None] {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let id = designcraft_doc::ItemId(d.alloc());
let line = PathData::single(SubPath::new(vec![Anchor::corner(Point::new(100.0, 300.0)), Anchor::corner(Point::new(500.0, 300.0))], false));
let mut it = designcraft_doc::Item::new(id, lid, Shape::GraphicLine, line);
let sid = designcraft_doc::StoryId(d.alloc());
let mut tf = designcraft_doc::TextFrame { story: sid, options: Default::default() };
tf.options.path = Some(designcraft_doc::PathType { start: 30.0, end, ..Default::default() });
it.content = designcraft_doc::Content::Text(tf);
let mut story = designcraft_doc::Story::with_text(sid, "Along", Default::default());
story.frames = vec![id];
d.stories.insert(sid, std::sync::Arc::new(story));
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let back = import_idml(&export_idml(&d)).unwrap();
let pt = back.spreads[0].items[0].text_frame().unwrap().options.path.clone().unwrap();
assert!((pt.start - 30.0).abs() < 1e-6, "{pt:?}");
assert_eq!(pt.end.map(|e| e.round()), end, "an end bracket at the path's end is no end bracket");
}
}
+2
View File
@@ -371,6 +371,8 @@ pub enum Cursor {
PenConvert,
/// The Pen over an open path's end point: a click continues the path.
PenContinue,
/// Over a type-on-a-path bracket: dragging it moves where the text starts or ends.
PathBracket,
Text,
Hand,
HandGrab,
+92 -1
View File
@@ -65,6 +65,11 @@ enum Drag {
corner: usize,
start: Point,
},
/// Dragging a type-on-a-path bracket along its path (`end`: the end bracket).
Bracket {
id: ItemId,
end: bool,
},
Anchor {
id: u64,
si: usize,
@@ -78,6 +83,50 @@ enum Drag {
},
}
/// Half the length of a path-type bracket, in screen pixels.
const BRACKET_PX: f64 = 9.0;
/// A selected type-on-a-path item's path (inner space), its inner → canvas transform and its
/// brackets' arc lengths (start, end).
fn path_type_of(cx: &ToolContext, id: ItemId) -> Option<(designcraft_geom::warp::PathWarp, designcraft_geom::Affine, f64, f64)> {
let it = cx.doc.item(id)?;
let pt = it.text_frame()?.options.path.as_ref()?;
let warp = designcraft_geom::warp::PathWarp::new(&it.path.to_bezpath(), pt.flip);
let end = pt.end.unwrap_or(warp.length()).min(warp.length());
Some((warp, cx.item_canvas_xf(id)? * it.xf, pt.start, end))
}
/// The bracket line at arc length `s`: across the path, `BRACKET_PX` either side (canvas).
fn bracket_line(cx: &ToolContext, warp: &designcraft_geom::warp::PathWarp, xf: designcraft_geom::Affine, s: f64) -> Option<(Point, Point)> {
let (p, a) = warp.at(s)?;
let c = xf * p;
let t = xf * (p + designcraft_geom::Vec2::new(a.cos(), a.sin())) - c;
let n = designcraft_geom::Vec2::new(-t.y, t.x);
let n = n / n.hypot().max(1e-9) * cx.tol(BRACKET_PX);
Some((c - n, c + n))
}
/// The bracket of a selected type-on-a-path item under `p` (canvas): (item, whether it is the end
/// bracket).
fn bracket_at(cx: &ToolContext, p: Point) -> Option<(ItemId, bool)> {
let tol = cx.tol(BRACKET_PX);
cx.selection.items.iter().find_map(|id| {
let (warp, xf, start, end) = path_type_of(cx, *id)?;
if cx.doc.item(*id).is_some_and(|it| it.locked) {
return None;
}
let near = |s: f64| warp.at(s).is_some_and(|(q, _)| (xf * q - p).hypot() <= tol);
// The end bracket first: with both brackets at one point, it can still be pulled back.
if near(end) {
Some((*id, true))
} else if near(start) {
Some((*id, false))
} else {
None
}
})
}
/// Anchor or handle of a selected item under `p` (canvas): (item, subpath, anchor, handle).
pub fn anchor_at(cx: &ToolContext, p: Point) -> Option<(u64, usize, usize, Option<&'static str>)> {
anchor_at_in(cx, &cx.selection.items, p)
@@ -598,6 +647,13 @@ impl Tool for SelectionTool {
}
PointerKind::Down => {
self.shift_release = None;
// Type on a path: a bracket moves where the text starts or ends.
if !ev.mods.shift
&& let Some((id, end)) = bracket_at(cx, p)
{
self.drag = Drag::Bracket { id, end };
return vec![Action::Begin("Move Bracket".into())];
}
if self.direct
&& let Some((id, si, ai, handle)) = anchor_at(cx, p)
{
@@ -683,6 +739,14 @@ impl Tool for SelectionTool {
}
}
PointerKind::Drag => match self.drag.clone() {
Drag::Bracket { id, end } => {
let Some((warp, xf, _, _)) = path_type_of(cx, id) else { return vec![] };
if xf.determinant().abs() < 1e-12 {
return vec![];
}
let s = warp.nearest(xf.inverse() * p);
vec![Action::Preview("type.pathOptions".into(), json!({"id": id.0, if end { "end" } else { "start" }: s}))]
}
Drag::Pending { start, hit, content } => {
if (p - start).hypot() < cx.tol(3.0) {
return vec![];
@@ -954,7 +1018,9 @@ impl Tool for SelectionTool {
let release = self.shift_release.take();
let d = std::mem::replace(&mut self.drag, Drag::None);
match d {
Drag::Move { .. } | Drag::Resize { .. } | Drag::Anchor { .. } | Drag::Rotate { .. } | Drag::Corner { .. } => vec![Action::Commit],
Drag::Move { .. } | Drag::Resize { .. } | Drag::Anchor { .. } | Drag::Rotate { .. } | Drag::Corner { .. } | Drag::Bracket { .. } => {
vec![Action::Commit]
}
Drag::Pending { .. } => match release {
Some(id) => vec![Action::Exec("selection.toggle".into(), json!({"id": id}))],
None => vec![],
@@ -1021,6 +1087,7 @@ impl Tool for SelectionTool {
v
}
Drag::Move { .. } | Drag::Resize { .. } | Drag::Rotate { .. } | Drag::Anchor { .. } => self.guides.clone(),
Drag::None | Drag::Bracket { .. } => brackets(cx),
_ => vec![],
};
// The loaded text cursor places text: the frame's corners aren't edited meanwhile.
@@ -1038,6 +1105,9 @@ impl Tool for SelectionTool {
if let Drag::Move { content: true, .. } = self.drag {
return Cursor::HandGrab;
}
if matches!(self.drag, Drag::Bracket { .. }) || (matches!(self.drag, Drag::None) && bracket_at(cx, p).is_some()) {
return Cursor::PathBracket;
}
if self.direct {
let over_content = matches!(self.drag, Drag::None)
&& anchor_at(cx, p).is_none()
@@ -1070,6 +1140,27 @@ impl Tool for SelectionTool {
}
}
/// The start and end brackets of the selected type-on-a-path items, in the selection colour: a
/// line across the path with a short tick pointing at the text between them.
fn brackets(cx: &ToolContext) -> Vec<Overlay> {
let mut out = Vec::new();
for id in &cx.selection.items {
let Some((warp, xf, start, end)) = path_type_of(cx, *id) else { continue };
let color = cx.doc.item(*id).and_then(|it| cx.doc.layer(it.layer)).map_or([43, 155, 255], |l| l.color);
for (s, towards) in [(start, end.max(start + 1.0)), (end, start.min(end - 1.0))] {
let Some((a, b)) = bracket_line(cx, &warp, xf, s) else { continue };
out.push(Overlay::Line { a, b, color, dashed: false });
// The tick at the outer end points along the path towards the text.
if let (Some((p0, _)), Some((p1, _))) = (warp.at(s), warp.at(towards)) {
let dir = xf * p1 - xf * p0;
let dir = dir / dir.hypot().max(1e-9) * cx.tol(BRACKET_PX * 0.6);
out.push(Overlay::Line { a: b, b: b + dir, color, dashed: false });
}
}
}
out
}
fn handle_cursor(h: usize) -> Cursor {
match h {
0 | 4 => Cursor::ResizeNwSe,
+48
View File
@@ -1590,3 +1590,51 @@ fn pen_continues_an_open_path_from_either_end_point() {
let t = create("pen");
assert_eq!(t.cursor(&cx, Point::new(at(200.0, 100.0).0, at(200.0, 100.0).1), Mods::default()), Cursor::Pen);
}
#[test]
fn the_selection_tool_drags_a_path_type_bracket_along_its_path() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let id = ItemId(d.alloc());
let line = designcraft_geom::PathData::single(designcraft_geom::SubPath::new(
vec![designcraft_geom::Anchor::corner(Point::new(100.0, 300.0)), designcraft_geom::Anchor::corner(Point::new(500.0, 300.0))],
false,
));
let mut it = Item::new(id, lid, Shape::GraphicLine, line);
let sid = designcraft_doc::StoryId(d.alloc());
let mut tf = designcraft_doc::TextFrame { story: sid, options: Default::default() };
tf.options.path = Some(designcraft_doc::PathType { start: 40.0, end: Some(300.0), ..Default::default() });
it.content = designcraft_doc::Content::Text(tf);
let mut st = designcraft_doc::Story::with_text(sid, "On the line", Default::default());
st.frames = vec![id];
d.stories.insert(sid, Arc::new(st));
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let (c, l) = (Cache::new(), CanvasLayout::new(&d, false));
let off = l.xf(SpreadRef::Doc(0)).translation();
let at = |x: f64, y: f64| Point::new(x + off.x, y + off.y);
let ev = |k: PointerKind, p: Point| PointerEvent::new(k, p.x, p.y);
// Not selected: no brackets.
let none = Selection::default();
assert!(create("selection").overlays(&ctx(&d, &none, &c, &l)).is_empty());
let s = Selection { items: vec![id], ..Default::default() };
let cx = ctx(&d, &s, &c, &l);
let mut t = create("selection");
// Both brackets drawn across the path at 140 and 400 (a line and a tick each).
let o = t.overlays(&cx);
let xs: Vec<f64> = o.iter().filter_map(|o| if let Overlay::Line { a, b, .. } = o { (a.x == b.x).then_some(a.x - off.x) } else { None }).collect();
assert_eq!(xs, [140.0, 400.0], "{o:?}");
assert_eq!(t.cursor(&cx, at(140.0, 300.0), Mods::default()), Cursor::PathBracket);
assert_ne!(t.cursor(&cx, at(250.0, 300.0), Mods::default()), Cursor::PathBracket);
// Drag the start bracket: its distance along the path follows the pointer, off the path too.
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, at(140.0, 301.0))), vec![Action::Begin("Move Bracket".into())]);
let a = t.pointer(&cx, &ev(PointerKind::Drag, at(220.0, 330.0)));
let [Action::Preview(cmd, p)] = a.as_slice() else { panic!("{a:?}") };
assert_eq!((cmd.as_str(), p["id"].as_u64()), ("type.pathOptions", Some(id.0)));
assert!((p["start"].as_f64().unwrap() - 120.0).abs() < 1e-6, "{p}");
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, at(220.0, 330.0))), vec![Action::Commit]);
// The end bracket.
t.pointer(&cx, &ev(PointerKind::Down, at(400.0, 300.0)));
let a = t.pointer(&cx, &ev(PointerKind::Drag, at(450.0, 290.0)));
let [Action::Preview(_, p)] = a.as_slice() else { panic!("{a:?}") };
assert!((p["end"].as_f64().unwrap() - 350.0).abs() < 1e-6, "{p}");
}
+1
View File
@@ -274,6 +274,7 @@ pub fn system(c: Cursor) -> egui::CursorIcon {
Cursor::ResizeNeSw => C::ResizeNeSw,
Cursor::Rotate => C::Alias,
Cursor::Pen | Cursor::PenAdd | Cursor::PenDelete | Cursor::PenClose | Cursor::PenConvert | Cursor::PenContinue => C::Crosshair,
Cursor::PathBracket => C::ResizeColumn,
Cursor::Text => C::Text,
Cursor::Hand => C::Grab,
Cursor::HandGrab => C::Grabbing,