diff --git a/ROADMAP.md b/ROADMAP.md index 4161fa9..1e0fcdc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -136,6 +136,8 @@ Newest first. - **2026-10-12** · Desktop write callbacks and CLI raster exports use the atomic writer too; open readers retain the original bytes, while private Unix modes and symlink targets survive replacement. Permanent tests cover both actual callers. No readiness percentage change. - **2026-10-12** · Save As, library edits and book collection edits persist prospective values before changing live state; failed writes preserve the document and existing collections. Save a Copy rebases data-source paths for its destination without changing the open document. 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** · 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. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index e84f0cb..cd0e7cb 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -417,7 +417,8 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { path: None, ..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.max(0.0).min(len)); + (Rect::new(0.0, 0.0, (end - pt.start).max(1.0), size * 1.6), opts) } None => (item.text_area(), tf.options.clone()), }; @@ -3179,6 +3180,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/doc/src/item.rs b/crates/doc/src/item.rs index 5cfe05f..0a70199 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -345,8 +345,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, /// Run the other way (the text sits on the other side). pub flip: bool, pub align: PathAlign, diff --git a/crates/engine/src/cmd/path_type.rs b/crates/engine/src/cmd/path_type.rs index 6d0da22..ffd852f 100644 --- a/crates/engine/src/cmd/path_type.rs +++ b/crates/engine/src/cmd/path_type.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "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 { 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 { 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.max(0.0).min(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 { 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,93 @@ 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)); + } + + #[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 2f6c5fd..b57e9e8 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -288,6 +288,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/geom/src/warp.rs b/crates/geom/src/warp.rs index fe6b890..34041d2 100644 --- a/crates/geom/src/warp.rs +++ b/crates/geom/src/warp.rs @@ -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); } } diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 6c5b00a..d01abe9 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1636,7 +1636,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)); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 1b1cb7d..59ca44b 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2200,6 +2200,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("")), }), diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 087a2b5..a2ee8f2 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1455,3 +1455,27 @@ fn list_numbering_format_expression_and_bullet_import_and_round_trip() { check(&d); check(&import_idml(&export_idml(&d)).unwrap()); } + +#[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"); + } +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 87bece8..f3de390 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -342,6 +342,8 @@ pub enum Cursor { Pen, /// 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, diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 8f59c35..fc9a8c3 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -38,6 +38,11 @@ enum Drag { start: Point, cur: 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, @@ -51,6 +56,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) @@ -378,6 +427,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) { @@ -437,6 +493,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 } => { if (p - start).hypot() < cx.tol(3.0) { return vec![]; @@ -682,7 +746,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 { .. } => vec![Action::Commit], + Drag::Move { .. } | Drag::Resize { .. } | Drag::Anchor { .. } | Drag::Rotate { .. } | Drag::Bracket { .. } => { + vec![Action::Commit] + } Drag::Pending { .. } => match release { Some(id) => vec![Action::Exec("selection.toggle".into(), json!({"id": id}))], None => vec![], @@ -739,15 +805,19 @@ impl Tool for SelectionTool { } } - fn overlays(&self, _cx: &ToolContext) -> Vec { + fn overlays(&self, cx: &ToolContext) -> Vec { match &self.drag { Drag::Marquee { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))], Drag::Move { .. } | Drag::Resize { .. } | Drag::Rotate { .. } | Drag::Anchor { .. } => self.guides.clone(), + Drag::None | Drag::Bracket { .. } => brackets(cx), _ => vec![], } } fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + if matches!(self.drag, Drag::Bracket { .. }) || (matches!(self.drag, Drag::None) && bracket_at(cx, p).is_some()) { + return Cursor::PathBracket; + } if self.direct { return Cursor::ArrowHollow; } @@ -769,6 +839,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 { + 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, diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index f8f58c7..e8fe514 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -764,3 +764,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 = 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}"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 71cdd17..077ae29 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -353,6 +353,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { Cursor::ResizeNeSw => C::ResizeNeSw, Cursor::Rotate => C::Alias, Cursor::Pen | Cursor::PenContinue => C::Crosshair, + Cursor::PathBracket => C::ResizeColumn, Cursor::Text => C::Text, Cursor::Hand => C::Grab, Cursor::HandGrab => C::Grabbing, @@ -978,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() { @@ -1009,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));