feat(text): Edit type on a path along the curve

The Type tool's clicks, drags and double-clicks on type on a path went to
the character at the same spot of the straightened line, and the caret
and highlight were drawn straight. A click now finds the character where
its nearest point along the path is (compose::PathTextMap, from the line
to the path and back, as path_glyphs places the glyphs); the caret stands
across the path there and the highlight bends with the text. A click on
the letters, above the path, hits the frame.

Depends on #636 (PathWarp::nearest).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
moppeeerandClaude Opus 5.5 committed 2026-10-12 23:35:58 +02:00
1 parent cded7d997e
commit f96895f400
6 files changed
+135 -3

No files matched your search

+1
View File
@@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-12** · Editing type on a path: the Type tool clicks, drags and double-clicks along the curve (the character under the pointer, not one on the straightened line), a click on the letters themselves hits the frame, and the caret and highlight bend with the path. No readiness percentage change.
- **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** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
- **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change.
+45
View File
@@ -3166,6 +3166,51 @@ pub fn adjacent_row(l: &Line, x: f64, caret_y: f64, up: bool) -> Option<f64> {
#[cfg(test)]
mod tests;
/// Type on a path: between the composed line (x along it, y as composed) and the frame's path
/// space, as `path_glyphs` places the glyphs.
pub struct PathTextMap {
warp: designcraft_geom::warp::PathWarp,
start: f64,
/// Line y that sits on the path (the alignment), and the line's baseline.
on: f64,
baseline: f64,
}
impl PathTextMap {
/// The map of `item` (type on a path) for its composed frame `ft`; None for other frames.
pub fn new(item: &designcraft_doc::Item, ft: &FrameText) -> Option<Self> {
let pt = item.text_frame()?.options.path.as_ref()?;
let warp = designcraft_geom::warp::PathWarp::new(&item.path.to_bezpath(), pt.flip);
let (on, baseline) = match ft.lines.first() {
Some(l) => (
match pt.align {
designcraft_doc::PathAlign::Baseline => l.baseline,
designcraft_doc::PathAlign::Ascender => l.baseline - l.ascent,
designcraft_doc::PathAlign::Descender => l.baseline + l.descent,
designcraft_doc::PathAlign::Center => l.baseline - (l.ascent - l.descent) / 2.0,
},
l.baseline,
),
None => (0.0, 0.0),
};
Some(PathTextMap { warp, start: pt.start, on, baseline })
}
/// A point of the composed line, on the path: along it by x, across it by y.
pub fn to_path(&self, p: Point) -> Point {
match self.warp.at(self.start + p.x) {
Some((q, a)) => q + designcraft_geom::Vec2::new(-a.sin(), a.cos()) * (p.y - self.on),
None => p,
}
}
/// A point of the path space, on the composed line: how far along the path its nearest point
/// is, at the baseline (for hit testing).
pub fn to_line(&self, p: Point) -> Point {
Point::new(self.warp.nearest(p) - self.start, self.baseline)
}
}
/// Each visible glyph of a type-on-a-path frame placed on the path: its outline in frame space
/// (grouped by run style).
pub fn path_glyphs(
+7 -1
View File
@@ -416,5 +416,11 @@ pub fn item_hit(it: &Item, p: Point, tol: f64) -> bool {
if area && it.path.is_closed() && designcraft_geom::hit::fill_contains(&bp, designcraft_geom::FillRule::NonZero, inner) {
return true;
}
designcraft_geom::hit::stroke_contains(&bp, it.stroke.weight.max(1.0), tol, inner)
// Type on a path: the letters stand over the path, so a click on them hits it too.
let reach = if it.text_frame().is_some_and(|t| t.options.path.is_some()) { PATH_TYPE_REACH } else { 0.0 };
designcraft_geom::hit::stroke_contains(&bp, it.stroke.weight.max(1.0), tol + reach, inner)
}
/// How far from its path type on a path is hit (points): about a cap height of 12–18 pt type,
/// either side (the type may sit on or under the path).
const PATH_TYPE_REACH: f64 = 12.0;
+54
View File
@@ -214,4 +214,58 @@ mod tests {
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));
}
#[test]
fn the_type_tool_clicks_and_selects_along_a_curved_path() {
for flip in [false, true] {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
// An arch: up from (100, 400) over (300, 200) and down to (500, 400).
let id = s
.execute("path.create", &json!({"anchors": [{"p": [100, 400], "out": [100, 250]}, {"p": [300, 200], "in": [200, 200], "out": [400, 200]}, {"p": [500, 400], "in": [500, 250]}]}))
.unwrap()["id"]
.as_u64()
.unwrap();
let r = s.execute("type.onPath", &json!({"id": id, "text": "AAAA BBBB CCCC DDDD EEEE FFFF GGGG", "flip": flip})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
// Where each glyph sits on the path, from the composed line.
let d = s.doc().unwrap().doc.clone();
let it = d.item(designcraft_doc::ItemId(id)).unwrap();
let cs = designcraft_compose::compose_story(&d, sid, &Default::default());
let ft = &cs.frames[0];
let map = designcraft_compose::PathTextMap::new(it, ft).unwrap();
let l = &ft.lines[0];
let glyph = |byte: usize| l.glyphs.iter().find(|g| g.byte == byte).unwrap();
let on_path = |byte: usize| {
let g = glyph(byte);
// A little outside the path, as a click near the letter would be.
map.to_path(designcraft_geom::Point::new(g.x + g.adv * 0.3, l.baseline - 4.0))
};
// A click by the first C (byte 10), on the curve: the caret goes there.
let p = on_path(10);
let r = s.execute("text.placeCaret", &json!({"frame": id, "point": [p.x, p.y]})).unwrap();
assert_eq!(r["pos"], 10, "flip={flip}: {r}");
// Dragging to the first E (byte 20) selects C to D.
let q = on_path(20);
s.execute("text.extendTo", &json!({"frame": id, "point": [q.x, q.y]})).unwrap();
let t = s.doc().unwrap().selection.text.unwrap();
assert_eq!(t.range(), 10..20, "flip={flip}");
// A double-click on a word (its first click places the caret) selects it.
let w = on_path(16);
s.execute("text.select", &json!({"story": sid.0, "anchor": 0})).unwrap();
s.execute("text.selectWord", &json!({"frame": id, "point": [w.x, w.y]})).unwrap();
assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 15..19, "flip={flip}");
// A click on a letter, above the path, hits the frame (the type tool's hit test).
let above = map.to_path(designcraft_geom::Point::new(glyph(10).x, l.baseline - l.ascent * 0.7));
let sp = s.doc().unwrap().doc.clone();
assert_eq!(
sp.hit_item(0, sp.parent_xf(&sp.find(designcraft_doc::ItemId(id)).unwrap()) * above, 1.0),
Some(designcraft_doc::ItemId(id)),
"flip={flip}"
);
// The map runs both ways along the path.
let back = map.to_line(map.to_path(designcraft_geom::Point::new(123.0, l.baseline)));
assert!((back.x - 123.0).abs() < 0.01, "{back:?}");
}
}
}
+2
View File
@@ -279,6 +279,8 @@ fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize, Op
let inner = xf.inverse() * pt;
let cs = s.cache.get(&st.doc, sid, None);
let fi = cs.frames.iter().position(|f| f.frame == frame)?;
// Type on a path: the click lands on the line where it is along the path.
let inner = cs.frames.get(fi).and_then(|ft| compose::PathTextMap::new(it, ft)).map_or(inner, |m| m.to_line(inner));
if let Some((table, row, col, b)) = compose::hit_cell(&cs, fi, inner) {
return Some((sid, b, Some(CellAddr { table, row, col })));
}
+26 -2
View File
@@ -979,6 +979,28 @@ fn draw_text_selection(
if range.is_empty() {
continue;
}
// Type on a path: the highlight bends with the text.
if let Some(map) = compose::PathTextMap::new(it, ft) {
for l in &ft.lines {
let (s, e) = (range.start.max(l.range.start), range.end.min(l.range.end));
if s >= e {
continue;
}
for q in compose::highlight_quads(l, s, e, false) {
let (x0, x1) = (q[0].x.min(q[1].x), q[0].x.max(q[1].x));
let (y0, y1) = (q[0].y.min(q[2].y), q[0].y.max(q[2].y));
// Short slices, each turned with the path.
let n = ((x1 - x0) / 2.0).ceil().clamp(1.0, 2000.0) as usize;
for k in 0..n {
let (a, b) = (x0 + (x1 - x0) * k as f64 / n as f64, x0 + (x1 - x0) * (k + 1) as f64 / n as f64);
let poly =
[Point::new(a, y0), Point::new(b, y0), Point::new(b, y1), Point::new(a, y1)].map(|p| xf.to_screen(m * map.to_path(p)));
painter.add(egui::Shape::convex_polygon(poly.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE));
}
}
}
continue;
}
let mut quads = Vec::new();
let mut glyphs = Vec::new();
for (li, l) in ft.lines.iter().enumerate() {
@@ -1010,8 +1032,10 @@ fn draw_text_selection(
let m = a * doc.text_xf(it);
let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0;
if blink {
let p0 = xf.to_screen(m * Point::new(x, bl - asc));
let p1 = xf.to_screen(m * Point::new(x, bl + desc));
// Type on a path: the caret stands across the path where it is along it.
let on = |p: Point| compose::PathTextMap::new(it, ft).map_or(p, |map| map.to_path(p));
let p0 = xf.to_screen(m * on(Point::new(x, bl - asc)));
let p1 = xf.to_screen(m * on(Point::new(x, bl + desc)));
painter.line_segment([p0, p1], Stroke::new(1.0, Color32::BLACK));
}
painter.ctx().request_repaint_after(std::time::Duration::from_millis(330));