diff --git a/ROADMAP.md b/ROADMAP.md index a213add..35bc92d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 4aa16fd..4efd32b 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -3166,6 +3166,51 @@ pub fn adjacent_row(l: &Line, x: f64, caret_y: f64, up: bool) -> Option { #[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 { + 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( diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 3bb8271..38de38a 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -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; diff --git a/crates/engine/src/cmd/path_type.rs b/crates/engine/src/cmd/path_type.rs index c136cb8..9ce091a 100644 --- a/crates/engine/src/cmd/path_type.rs +++ b/crates/engine/src/cmd/path_type.rs @@ -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:?}"); + } + } } diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 71d390d..a1a17cc 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -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 }))); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2f30386..5bc9146 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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));