diff --git a/ROADMAP.md b/ROADMAP.md index a18cc8739..80bfdc044 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 124e93471..ccc66a137 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -716,7 +716,8 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { // 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()), }; diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index f3eedc074..2c07450e9 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -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); diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 417027a14..7b7256fe5 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -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, /// 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 6d0da227d..aca1a624e 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.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 { 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)); + } } diff --git a/crates/geom/src/warp.rs b/crates/geom/src/warp.rs index fe6b89078..34041d280 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 d11a2ede8..3f06e8e55 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -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)); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index d184e5072..ae64f00b1 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -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("")), }), diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 06990f531..b08348259 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -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"); + } +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index c1b9a285d..0610e4581 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -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, diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index e21000641..7e9fe3270 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -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 { + 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 7bef4a0ca..770de2164 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -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 = 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/cursors.rs b/crates/ui-egui/src/cursors.rs index c7dcd75ab..9960e2792 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -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,