diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index 2ec21a322..907b35e1e 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -21,6 +21,15 @@ pub fn specs() -> Vec { has_multi, create ), + cmd!( + "text.thread.add", + "Thread Text", + [], + None, + "{from, to?, rect?: [x, y, width, height], at?: [x, y]} thread area type `from` (a click on its out port) into one more frame, put after it in its thread: closed path or area type `to`, a new rectangle `rect`, or a new frame of `from`'s shape and size with its top-left at `at` (give one of the three) → {id}; the two frames are selected", + has_doc, + add + ), cmd!( "text.thread.releaseSelection", "Release Selection", @@ -205,6 +214,90 @@ fn create(s: &mut Session, _: &Value) -> Result { Ok(json!({ "ids": out.iter().map(|i| i.0).collect::>() })) } +/// `text.thread.add`: the out port's loaded text, placed. +fn add(s: &mut Session, p: &Value) -> Result { + const C: &str = "text.thread.add"; + let given = |k: &str| p.get(k).filter(|v| !v.is_null()); + let from = checked_id(s, given("from").unwrap_or(&Value::Null), C)?; + let d = &s.doc()?.doc; + let src = text_of(d, from).ok_or_else(|| bad(C, "`from` must be area type"))?; + let style = src.first_style(); + // The frame to thread into: an existing object (`to`), or a new one beside `from`. + let (to, new) = match (given("to"), given("rect"), given("at")) { + (Some(v), None, None) => { + let to = checked_id(s, v, C)?; + let parent = d.parent_of(to).and_then(|i| d.node(i)); + let frame = text_of(d, to).is_some() || d.node(to).is_some_and(|n| frame_from_path(n, style.clone()).is_some()); + if to == from || !frame || parent.is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })) { + return Err(bad(C, "`to` must be a closed path or area type other than `from`")); + } + if d.text_threads.iter().any(|t| t.contains(&to)) { + return Err(bad(C, "`to` is already threaded")); + } + (to, None) + } + (None, Some(v), None) => { + let [x, y, w, h] = finite_numbers(v) + .filter(|[_, _, w, h]| *w > 0.0 && *h > 0.0) + .ok_or_else(|| bad(C, "`rect` must be [x, y, width, height] with a positive size"))?; + let frame = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, w, h)); + (NodeId(0), Some(empty_frame(src, style.clone(), frame, vectorcraft_geom::Affine::translate((x, y))))) + } + (None, None, Some(v)) => { + let [x, y] = finite_numbers(v).ok_or_else(|| bad(C, "`at` must be [x, y]"))?; + let TextKind::Area { frame } = &src.kind else { return Err(bad(C, "`from` must be area type")) }; + let b = frame.bounds().map(|b| src.xf.transform_rect_bbox(b)).ok_or_else(|| bad(C, "`from` has an empty frame"))?; + let xf = vectorcraft_geom::Affine::translate((x - b.x0, y - b.y0)) * src.xf; + (NodeId(0), Some(empty_frame(src, style.clone(), frame.clone(), xf))) + } + _ => return Err(bad(C, "give one of `to`, `rect` or `at`")), + }; + let appearance = d.node(from).map(|n| n.appearance.clone()); + let id = s.edit("Thread Text", |d, sel| { + let to = match new { + Some(t) => { + let (parent, idx, _) = d.position(from).ok_or(EngineError::NoNode(from))?; + let id = d.alloc_id(); + let mut n = Node::new(id, NodeKind::Text(Box::new(t))); + if let Some(a) = appearance { + n.appearance = a; + } + d.insert(parent, idx + 1, n)?; + id + } + None => { + if text_of(d, to).is_none() { + let n = d.node(to).cloned().ok_or(EngineError::NoNode(to))?; + *d.node_mut(to).ok_or(EngineError::NoNode(to))? = frame_from_path(&n, style).ok_or_else(|| bad(C, "not a closed path"))?; + } + to + } + }; + match d.text_threads.iter_mut().find(|t| t.contains(&from)) { + Some(t) => { + let at = t.iter().position(|x| *x == from).map_or(t.len(), |i| i + 1); + t.insert(at, to); + } + None => d.text_threads.push(vec![from, to]), + } + // `to` is new or changed, so [`reflow`] flows the story into it after this edit. + sel.set([from, to]); + Ok(to) + })?; + Ok(json!({ "id": id.0 })) +} + +/// An empty area-type frame `frame` placed by `xf`, with the type options of `src`. +fn empty_frame(src: &TextObject, style: vectorcraft_doc::CharStyle, frame: vectorcraft_geom::PathData, xf: vectorcraft_geom::Affine) -> TextObject { + let mut t = src.clone(); + t.runs = vec![TextRun { text: String::new(), style, inline: None }]; + t.set_all_paras(src.para_at(0).clone()); + t.kind = TextKind::Area { frame }; + t.xf = xf; + refresh_bounds(&mut t); + t +} + /// Give a node a fresh allocation so [`reflow`] sees its thread as changed. fn touch(d: &mut Document, id: NodeId) { if let Some(n) = d.node_mut(id) { @@ -413,6 +506,80 @@ mod tests { } } + fn new_id(v: Value) -> u64 { + v["id"].as_u64().unwrap() + } + + fn threads(s: &Session) -> Vec> { + s.doc().unwrap().doc.text_threads.iter().map(|t| t.iter().map(|i| i.0).collect()).collect() + } + + /// Bounds of area type `id`'s frame on the page. + fn frame_box(s: &Session, id: u64) -> vectorcraft_geom::Rect { + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") }; + let TextKind::Area { frame } = &t.kind else { panic!("not area type") }; + t.xf.transform_rect_bbox(frame.bounds().unwrap()) + } + + /// #2572: the text loaded from an out port threads into a closed path, into a new frame of the + /// first one's shape and size at a point, and into a new rectangle, each in one undo step. + #[test] + fn add_threads_into_a_path_a_frame_at_a_point_and_a_rectangle() { + let (mut s, a, b) = setup(); + assert_eq!(new_id(s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap()), b); + assert_eq!(threads(&s), [[a, b]]); + assert!(!text(&s, b).is_empty(), "the overflow flowed into the path"); + assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4)); + assert_eq!(s.doc().unwrap().selection.objects, [NodeId(a), NodeId(b)]); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(threads(&s).is_empty()); + assert!(matches!(s.doc().unwrap().doc.node(NodeId(b)).unwrap().kind, NodeKind::Path { .. }), "undo gives the path back"); + + let c = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [400, 200]})).unwrap()); + assert_eq!(frame_box(&s, c), vectorcraft_geom::Rect::new(400.0, 200.0, 550.0, 260.0), "the first frame's size, its top-left at the click"); + let d = new_id(s.execute("text.thread.add", &json!({"from": c, "rect": [10, 100, 300, 150]})).unwrap()); + assert_eq!(frame_box(&s, d), vectorcraft_geom::Rect::new(10.0, 100.0, 310.0, 250.0)); + assert_eq!(threads(&s), [[a, c, d]], "each new frame comes after the one it was threaded from"); + assert_eq!(format!("{}{}{}", text(&s, a), text(&s, c), text(&s, d)), TEXT.repeat(4)); + assert!(!text(&s, d).is_empty()); + // A frame threaded from the middle of a thread goes in after it. + let e = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [0, 0]})).unwrap()); + assert_eq!(threads(&s), [[a, e, c, d]]); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(NodeId(e)).is_none()); + assert_eq!(threads(&s), [[a, c, d]]); + } + + #[test] + fn add_refuses_bad_params_and_leaves_the_document_alone() { + let (mut s, a, b) = setup(); + let line = new_id(s.execute("shape.line", &json!({"x1": 0, "y1": 280, "x2": 50, "y2": 290})).unwrap()); + let before = s.doc().unwrap().doc.clone(); + for p in [ + json!({"to": b}), + json!({"from": b, "at": [0, 0]}), + json!({"from": 9999, "at": [0, 0]}), + json!({"from": -1, "at": [0, 0]}), + json!({"from": a}), + json!({"from": a, "to": b, "at": [0, 0]}), + json!({"from": a, "to": a}), + json!({"from": a, "to": line}), + json!({"from": a, "to": 9999}), + json!({"from": a, "at": [0]}), + json!({"from": a, "at": ["1", 2]}), + json!({"from": a, "rect": [0, 0, -10, 10]}), + json!({"from": a, "rect": [0, 0, 10, 0]}), + json!({"from": a, "rect": [0, 0, 10]}), + json!({"from": a, "rect": "big"}), + ] { + assert!(s.execute("text.thread.add", &p).is_err(), "{p} was accepted"); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before), "{p} changed the document"); + } + s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap(); + let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 200, "text": "x", "area": {"width": 50, "height": 20}})).unwrap()); + assert!(s.execute("text.thread.add", &json!({"from": c, "to": b})).is_err(), "a frame already threaded"); + } + #[test] fn release_and_remove() { let (mut s, a, b) = setup(); diff --git a/crates/engine/src/tests_textedit.rs b/crates/engine/src/tests_textedit.rs index fdacba63d..917a01156 100644 --- a/crates/engine/src/tests_textedit.rs +++ b/crates/engine/src/tests_textedit.rs @@ -1020,3 +1020,34 @@ fn overflow_marker_shows_with_the_selection_tool() { s.select_tool("selection", v).unwrap(); assert_eq!(red(&mut s), 0, "nothing selected"); } + +/// #2572: with the Selection tool, a click on the out port and a click on empty canvas thread the +/// overflow into a new frame there, in one undo step; Escape, or another tool, drops the loaded text. +#[test] +fn clicking_the_out_port_then_the_canvas_threads_the_text_into_a_new_frame() { + let mut s = session(); + let v = ViewInfo::default(); + let long = "Words that fill more than the frame holds. ".repeat(20); + let r = s.execute("text.create", &json!({"x": 50, "y": 50, "text": long, "size": 20, "area": {"width": 100, "height": 40}})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.select_tool("selection", v).unwrap(); + let port = Point::new(150.0, 90.0 - vectorcraft_tools::text::PORT_RISE_PX); + let loaded = |s: &mut Session| s.cursor(Point::new(400.0, 400.0), Mods::default(), v) == vectorcraft_tools::Cursor::LoadedText; + click(&mut s, port.x, port.y); + assert!(loaded(&mut s)); + key(&mut s, ToolKey::Escape, Mods::default()); + assert!(!loaded(&mut s), "Escape drops the text"); + click(&mut s, port.x, port.y); + s.select_tool("directSelection", v).unwrap(); + s.select_tool("selection", v).unwrap(); + assert!(!loaded(&mut s), "another tool drops the text"); + let steps = undo_labels(&s).len(); + click(&mut s, port.x, port.y); + click(&mut s, 400.0, 400.0); + let doc = &s.doc().unwrap().doc; + let [thread] = &doc.text_threads[..] else { panic!("one thread: {:?}", doc.text_threads) }; + assert_eq!(thread[0], id); + assert_eq!(format!("{}{}", obj(&s, id).plain_text(), obj(&s, thread[1]).plain_text()), long); + assert_eq!(&undo_labels(&s)[steps..], ["Thread Text"]); +} diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml index a056578ec..dc3838d9b 100644 --- a/crates/tools/src/cursors.toml +++ b/crates/tools/src/cursors.toml @@ -126,6 +126,15 @@ shown_when = ["The Selection tool over the type widget of selected type (a doubl hotspot = "The arrow's tip." look = "Arrow with a type badge (a letter T)." +[[cursor]] +id = "LoadedText" +group = "Selection" +shown_when = [ + "The Selection tool after a click on the red out port of selected area type whose text overflows: the pointer carries that text until a click threads it into a closed path, a click on empty canvas makes a new frame of the same shape there, or a drag draws the new frame. Escape or another tool drops it.", +] +hotspot = "Where the new frame's top-left corner goes (the click), or the path clicked." +look = "A small block of text lines: the overflow text the pointer carries. Until it is drawn it borrows the I-beam (Text)." + # ── Pen family ──────────────────────────────────────────────────────────────────────────────────── [[cursor]] diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index ee008f09a..b14fdf51c 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -518,6 +518,9 @@ pub enum Cursor { /// Over the type widget of selected type (a double-click converts point type to area type and /// back): the arrow with a type badge. TypeWidget, + /// The Selection tool loaded with the text of an area type's out port (a click threads it + /// into a path or a new frame, a drag draws the new frame). + LoadedText, /// The Shape Builder: a crosshair with a plus (merge mode)... ShapeBuilder, /// ...or, with Alt held, a minus (erase mode). @@ -667,7 +670,7 @@ impl Cursor { } /// The cursor this one borrows until it's drawn: the generic crosshair for the - /// tools, the I-beam for vertical type, the Pen's add cursor for the Mesh tool and Puppet + /// tools, the I-beam for vertical type and loaded text, the Pen's add cursor for the Mesh tool and Puppet /// Warp, not-allowed for the Scissors away from a path. `None` for a cursor with a look of /// its own. pub fn stand_in(self) -> Option { @@ -695,7 +698,7 @@ impl Cursor { | Cursor::SymbolScreener | Cursor::SymbolStyler | Cursor::Scissors => Some(Cursor::Crosshair), - Cursor::TypeVertical => Some(Cursor::Text), + Cursor::TypeVertical | Cursor::LoadedText => Some(Cursor::Text), Cursor::MeshAdd | Cursor::PuppetPin => Some(Cursor::PenAdd), Cursor::ScissorsNotAllowed => Some(Cursor::NotAllowed), _ => None, diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 9f5d4278c..b2f4d1f2d 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -16,6 +16,10 @@ //! scaling its type: the text reflows at its size. //! E pressed during a marquee drag makes the marquee take only the objects entirely inside it, and //! E pressed again makes it take the objects it touches. +//! A click on the red out port of selected area type whose text overflows loads the pointer with +//! that text ([`Cursor::LoadedText`]): the next click threads it into the closed path or area type +//! under the pointer, or into a new frame of the same shape and size at the click on empty canvas, +//! and a drag draws a rectangle for the new frame (`text.thread.add`). Escape drops the loaded text. use serde_json::{Value, json}; use vectorcraft_doc::hit::{hit_test, marquee, objects_at}; @@ -73,6 +77,12 @@ enum State { Pie(PieDrag), /// Dragging a bracket of type on a path. Bracket(BracketDrag), + /// Loaded with the text of area type `from` by a click on its out port; `press`: where the + /// next press went down and where the pointer is now. + Loaded { + from: NodeId, + press: Option<(Point, Point)>, + }, } #[derive(Default)] @@ -257,6 +267,45 @@ impl SelectionTool { }) }) } + + /// The pointer loaded with the text of area type `from` ([`State::Loaded`]): a press starts + /// placing it, and its release threads it (`text.thread.add`). None when `from` is no longer + /// area type: the press is an ordinary one. + fn loaded(&mut self, cx: &ToolContext, ev: &PointerEvent, from: NodeId, press: Option<(Point, Point)>) -> Option> { + let p = ev.pos; + match (ev.kind, press) { + (PointerKind::Down, _) if !cx.doc.node(from).is_some_and(is_area_type) => { + self.state = State::Idle; + return None; + } + (PointerKind::Down, _) => self.state = State::Loaded { from, press: Some((p, p)) }, + (PointerKind::Drag, Some((start, _))) => self.state = State::Loaded { from, press: Some((start, p)) }, + (PointerKind::Up, Some((start, _))) => { + self.state = State::Idle; + return Some(vec![Action::Exec("text.thread.add".into(), thread_params(cx, from, start, p))]); + } + _ => {} + } + Some(vec![]) + } +} + +/// Where the text of `from` goes for a press at `start` released at `end`: a dragged rectangle, +/// else the closed path or area type pressed on, else a new frame with its top-left at `start`. +fn thread_params(cx: &ToolContext, from: NodeId, start: Point, end: Point) -> Value { + let r = Rect::from_points(start, end); + if r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0) { + return json!({ "from": from.0, "rect": [r.x0, r.y0, r.width(), r.height()] }); + } + let frame = |n: &vectorcraft_doc::Node| { + is_area_type(n) || matches!(&n.kind, vectorcraft_doc::NodeKind::Path { path, .. } if path.subpaths.first().is_some_and(|s| s.closed)) + }; + let to = + hit_test(cx.doc, start, cx.hit_options()).map(|h| h.top_object(cx.isolation)).filter(|id| *id != from && cx.doc.node(*id).is_some_and(frame)); + match to { + Some(to) => json!({ "from": from.0, "to": to.0 }), + None => json!({ "from": from.0, "at": [start.x, start.y] }), + } } impl Tool for SelectionTool { @@ -277,11 +326,19 @@ impl Tool for SelectionTool { *enclosed = !*enclosed; return vec![]; } + if key == ToolKey::Escape && matches!(self.state, State::Loaded { .. }) { + self.state = State::Idle; + return vec![]; + } if key == ToolKey::Escape { self.guide.cancel() } else { vec![] } } fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { - key == ToolKey::Letter('e') && matches!(self.state, State::Marquee { .. }) + match self.state { + State::Marquee { .. } => key == ToolKey::Letter('e'), + State::Loaded { .. } => key == ToolKey::Escape, + _ => false, + } } fn transforming(&self) -> bool { @@ -294,6 +351,11 @@ impl Tool for SelectionTool { } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + if let State::Loaded { from, press } = self.state + && let Some(out) = self.loaded(cx, ev, from, press) + { + return out; + } if let Some(out) = self.guide.pointer(cx, ev) { return out; } @@ -337,6 +399,11 @@ impl Tool for SelectionTool { vec![] } (PointerKind::Down, _) => { + // 0. The out port of selected area type: the pointer takes its text. + if let Some(from) = crate::text::out_port_at(cx, p) { + self.state = State::Loaded { from, press: None }; + return vec![]; + } // 1. Live Corners widgets, pie widgets and type on a path's brackets, then the // bounding-box handles of the current selection. if let Some(c) = CornerDrag::hit(cx, ev, false) { @@ -542,6 +609,7 @@ impl Tool for SelectionTool { State::Marquee { start, cur, .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))), State::Corner(c) => o.extend(c.overlays(cx)), State::Pie(d) => o.extend(d.overlays()), + State::Loaded { press: Some((start, cur)), .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))), _ => {} } o.extend(self.guides.iter().cloned()); @@ -569,6 +637,7 @@ impl Tool for SelectionTool { State::Corner(_) => return Cursor::CornerRadius, State::Pie(_) => return Cursor::ArrowHollow, State::Bracket(_) => return Cursor::PathBracket, + State::Loaded { .. } => return Cursor::LoadedText, _ => {} } if self.guide.busy() { @@ -1537,4 +1606,80 @@ mod tests { t.pointer(&cx, &ev(PointerKind::Down, 400.0, 400.0)); assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 400.0, 400.0)), vec![Action::Exec("select.none".into(), json!({}))]); } + + /// [`doc_with_area_type`] with more text than its frame holds, selected; the rectangle's id and + /// where the frame's out port is. + fn overflowing_area_type() -> (vectorcraft_doc::Document, NodeId, NodeId, Point) { + let (mut d, id) = doc_with_area_type(); + let rect = d.children(Some(d.layers[0].id)).unwrap()[0].id; + let Some(vectorcraft_doc::NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("not text") }; + let style = vectorcraft_doc::CharStyle { size: 20.0, ..Default::default() }; + t.runs = vec![vectorcraft_doc::TextRun::new("Words that fill more than the frame holds. ".repeat(20), style)]; + // On the frame's right side, above its bottom-right corner (120 × 40 at 100%). + let port = t.xf * Point::new(120.0, 40.0 - crate::text::PORT_RISE_PX); + (d, id, rect, port) + } + + /// #2572: a click on the out port loads the pointer with the text; a click on empty canvas then + /// threads it into a new frame there, a click on a closed path into the path, a drag into the + /// rectangle it draws. + #[test] + fn a_click_on_the_out_port_loads_the_text_and_the_next_click_or_drag_threads_it() { + let (d, id, rect, port) = overflowing_area_type(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + let thread = |params: Value| vec![Action::Exec("text.thread.add".into(), params)]; + let load = |t: &mut SelectionTool| { + assert!(t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y)).is_empty()); + assert!(t.pointer(&cx, &ev(PointerKind::Up, port.x, port.y)).is_empty()); + assert_eq!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText); + }; + load(&mut t); + assert!(t.pointer(&cx, &ev(PointerKind::Down, 30.0, 420.0)).is_empty()); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 30.0, 420.0)), thread(json!({"from": id.0, "at": [30.0, 420.0]}))); + assert_ne!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText, "the text is placed"); + load(&mut t); + t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 150.0, 150.0)), thread(json!({"from": id.0, "to": rect.0}))); + load(&mut t); + t.pointer(&cx, &ev(PointerKind::Down, 20.0, 20.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 80.0, 90.0)); + assert!(t.overlays(&cx).contains(&Overlay::Marquee(Rect::new(20.0, 20.0, 80.0, 90.0))), "the new frame's outline"); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 80.0, 90.0)), thread(json!({"from": id.0, "rect": [20.0, 20.0, 60.0, 70.0]}))); + // The bounding box's corner handle below the port still resizes the frame. + let corner = Point::new(port.x, port.y + crate::text::PORT_RISE_PX); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, corner.x, corner.y)), vec![Action::Begin("Resize Type Area".into())]); + } + + /// Escape drops the loaded text, and the next click is an ordinary one; type that fits its + /// frame shows no out port to click. + #[test] + fn escape_drops_the_loaded_text_and_type_that_fits_has_no_port() { + let (d, id, _, port) = overflowing_area_type(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y)); + t.pointer(&cx, &ev(PointerKind::Up, port.x, port.y)); + assert!(t.claims_key(&cx, ToolKey::Escape), "Escape is the loaded pointer's"); + assert!(t.key(&cx, ToolKey::Escape, Mods::default()).is_empty()); + assert!(!t.claims_key(&cx, ToolKey::Escape)); + assert_ne!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText); + t.pointer(&cx, &ev(PointerKind::Down, 30.0, 420.0)); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 30.0, 420.0)), vec![Action::Exec("select.none".into(), json!({}))]); + + let (d, id) = doc_with_area_type(); + let mut s = Selection::default(); + s.set([id]); + let cx = crate::testutil::cx(&d, &s, &p); + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y)); + t.pointer(&cx, &ev(PointerKind::Up, port.x, port.y)); + assert_ne!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText); + } } diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 56ada31f4..6b99455ef 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -311,16 +311,32 @@ impl TypeTool { } } -/// The red "+" out port of `t` (area type and type on a path whose text doesn't fit): at the -/// bottom right of the frame, at the end of the path for type on a path. -fn overflow_port(t: &TextObject, cx: &ToolContext, o: &mut Vec) { - let port = match &t.kind { - TextKind::Area { frame } => frame.bounds().map(|b| t.xf * Point::new(b.x1, b.y1)), +/// How far (screen px) the out port of area type sits above the bottom-right corner of its frame, +/// on its right side: clear of the bounding box's corner handle, so both can be grabbed. +pub const PORT_RISE_PX: f64 = 12.0; +/// Half the out port's side (screen px). +const PORT_HALF_PX: f64 = 4.5; + +/// Centre of the out port of `t`: on the right side of an area type frame just above its +/// bottom-right corner ([`PORT_RISE_PX`]), at the end of the path for type on a path. +fn out_port(t: &TextObject, cx: &ToolContext) -> Option { + match &t.kind { + TextKind::Area { frame } => { + let b = frame.bounds()?; + let corner = t.xf * Point::new(b.x1, b.y1); + let up = t.xf * Point::new(b.x1, b.y0) - corner; + let len = up.hypot(); + Some(if len > 1e-9 && len.is_finite() { corner + up * (cx.tol(PORT_RISE_PX).min(len / 2.0) / len) } else { corner }) + } TextKind::OnPath { path, .. } => path.to_bezpath().segments().last().map(|s| t.xf * vectorcraft_geom::ParamCurve::end(&s)), TextKind::Point => None, - }; - let Some(c) = port else { return }; - let h = cx.tol(4.5); + } +} + +/// The red "+" out port of `t` (area type and type on a path whose text doesn't fit), at [`out_port`]. +fn overflow_port(t: &TextObject, cx: &ToolContext, o: &mut Vec) { + let Some(c) = out_port(t, cx) else { return }; + let h = cx.tol(PORT_HALF_PX); let r = Rect::new(c.x - h, c.y - h, c.x + h, c.y + h); let red = [230, 30, 30]; o.push(Overlay::Path { path: r.to_path(0.1), color: red, width: 1.0, dashed: false }); @@ -329,6 +345,20 @@ fn overflow_port(t: &TextObject, cx: &ToolContext, o: &mut Vec) { o.push(Overlay::Line { a: Point::new(m.x, m.y - k), b: Point::new(m.x, m.y + k), color: red, dashed: false }); } +/// The selected area type whose red out port is under `p` (only text that overflows shows one): +/// a click there loads the Selection tool with its text, to thread into another frame. +pub(crate) fn out_port_at(cx: &ToolContext, p: Point) -> Option { + let h = cx.tol(PORT_HALF_PX + 1.0); + let near = |t: &TextObject| out_port(t, cx).is_some_and(|c| (c.x - p.x).abs() <= h && (c.y - p.y).abs() <= h); + cx.selection.objects.iter().copied().find(|id| { + cx.doc + .node(*id) + .filter(|n| crate::select::is_area_type(n)) + .and_then(|_| TypeTool::text(cx, *id)) + .is_some_and(|t| near(t) && vectorcraft_text::layout(FontDb::global(), t).overflow) + }) +} + /// Which selected text overflows, for [`overflow_markers`]: each text object's layout verdict kept /// with the object and the fonts it was made from, as overlays run every frame. #[derive(Default)] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 87fb7873a..2058057e5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -665,7 +665,8 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { | Cursor::Scissors | Cursor::ScissorsNotAllowed | Cursor::MeshAdd - | Cursor::PuppetPin => cursor_icon(c.stand_in().unwrap_or(Cursor::Crosshair)), + | Cursor::PuppetPin + | Cursor::LoadedText => cursor_icon(c.stand_in().unwrap_or(Cursor::Crosshair)), } } @@ -2127,8 +2128,9 @@ impl HiddenMarks { } } -/// Text threads of the selected frames: a line from each frame's out port (bottom right) to the -/// next frame's in port (top left), like Illustrator's thread indicators. +/// Text threads of the selected frames: a line from each frame's out port (on its right side, +/// just above the bottom right, where the tools draw the red one) to the next frame's in port (top +/// left), like Illustrator's thread indicators. fn thread_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { let Some(st) = app.session.active() else { return }; for thread in &st.doc.text_threads { @@ -2138,7 +2140,8 @@ fn thread_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { let color = c32(st.doc.layer_color(thread[0])); let frames: Vec = thread.iter().filter_map(|id| st.doc.node(*id).and_then(|n| n.geometric_bounds())).collect(); for w in frames.windows(2) { - let (out, inp) = (xf.to_screen(Point::new(w[0].x1, w[0].y1)), xf.to_screen(Point::new(w[1].x0, w[1].y0))); + let rise = (vectorcraft_tools::text::PORT_RISE_PX / xf.zoom.max(1e-9)).min(w[0].height() / 2.0); + let (out, inp) = (xf.to_screen(Point::new(w[0].x1, w[0].y1 - rise)), xf.to_screen(Point::new(w[1].x0, w[1].y0))); p.line_segment([out, inp], Stroke::new(1.0, color)); for port in [out, inp] { p.rect_filled(egui::Rect::from_center_size(port, egui::vec2(7.0, 7.0)), 0.0, Color32::WHITE); diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index 4f6723118..d61c9bbd3 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -409,7 +409,7 @@ fn magnifier(p: &mut Ink, o: Pos2, out: bool) { /// Every cursor with a glyph: its own, drawn here, or the one it borrows. #[cfg(test)] -const GLYPHS: [Cursor; 74] = [ +const GLYPHS: [Cursor; 75] = [ Cursor::Hand, Cursor::HandGrab, Cursor::ZoomIn, @@ -456,6 +456,7 @@ const GLYPHS: [Cursor; 74] = [ Cursor::BlendAnchor, Cursor::PathBracket, Cursor::TypeWidget, + Cursor::LoadedText, Cursor::ShapeBuilder, Cursor::ShapeBuilderErase, Cursor::Lasso, diff --git a/docs/mcp.md b/docs/mcp.md index 7bbb2d3f8..1feeaaa8f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3374,6 +3374,28 @@ chosen artboards draw all of them. A PDF leaves out the layers that don't print {"name":"run_command","arguments":{"command":"text.areaOptions","params":{"fit":"shrinkText","fitMinPercent":60}}} ``` +## Threading text from the out port + +As in the reference app, a click with the Selection tool on the red "+" out port of selected area type whose text +overflows loads the pointer with that text (the port sits on the frame's right side, just above the bottom-right +corner, so the corner handle stays free). The next click threads the text into the closed path or area type under the +pointer, which becomes the next frame; a click on empty canvas makes a new frame of the first one's shape and size +with its top-left corner at the click, and a drag draws a rectangle for the new frame. Escape or another tool drops +the loaded text. Each placement is one undo step (`text.thread.add`), which agents can run directly: + +`text.thread.add {from, to? | rect?: [x, y, width, height] | at?: [x, y]}` threads area type `from` into one more +frame, put right after it in its thread (a new thread of the two when `from` has none): `to`, a closed path (converted +to an empty frame) or area type that is not threaded yet; `rect`, a new rectangular frame (positive width and height); +or `at`, a new frame of `from`'s shape and size whose top-left is at `at`. Give exactly one of the three. New frames +take `from`'s type options, paint and first character and paragraph attributes, and go just above it. The story +reflows through the thread, the two frames are selected, and it returns `{id}`, the frame threaded into. It refuses +an `id` that doesn't exist, a `from` that isn't area type (type on a path doesn't thread), an open path, `from` +itself, an object inside a compound path, a frame already in a thread, and coordinates that aren't finite numbers. + +```json +{"name":"run_command","arguments":{"command":"text.thread.add","params":{"from":42,"at":[300,120]}}} +``` + ## Converting between point type and area type With the Selection tool, a single selected point or area type object shows the type widget: a small circle beside diff --git a/docs/type-parity.md b/docs/type-parity.md index 996213549..9d85181b6 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (native family labels fall back to localized legacy names when typographic names are English-only, #2208; scores unchanged; Cmd+Shift+C aligns centre off the Mac, #1940; tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966; Cmd+Shift+C aligns centre off the Mac, a double-click on area type's bottom handle turns on Auto Size, #1940; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged. tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (a click on the out port threads the text into a closed path, a new frame at the click or a dragged rectangle, #2572 in part; scores unchanged; native family labels fall back to localized legacy names when typographic names are English-only, #2208; scores unchanged; Cmd+Shift+C aligns centre off the Mac, #1940; tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966; Cmd+Shift+C aligns centre off the Mac, a double-click on area type's bottom handle turns on Auto Size, #1940; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged. tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -22,6 +22,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802); `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands | | Area Type Options (rows, columns, inset, first baseline, Auto Size, Shrink Text to Fit) | done | the commands that make or restyle type say when it does not fit its frame; MCP `add_text` takes Auto Size and Shrink Text to Fit (#1471) | | Threaded text, Fit Headline, Text Wrap | done | Fit Headline sees wrapping in a frame one line tall (#1444) | +| Threading from the out port: a click on the red "+" loads the text; a click on a closed path or area type threads into it, a click on empty canvas makes a frame of the same shape and size there, a drag draws a rectangular frame; Escape or another tool cancels | partial | #2572 in part: the Selection tool, `text.thread.add {from, to?, rect?, at?}`; only the red port of overflowing area type is clickable (empty ports and in ports are not drawn, so they can't be clicked yet), type on a path doesn't thread, and the loaded cursor borrows the I-beam until it's drawn | | Type on a Path effects and options; brackets | done | #429 | | Character and Paragraph panels, styles with overrides | done | Character-style Auto-leading resets, partial definitions, local leading overrides, override markers, Undo/Redo and native serialization have regression coverage; differing paragraph alignments leave the shared alignment buttons unselected (headless regressions, full integration CI and visual review pass); mixed-value display for other paragraph fields remains missing | | Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. | @@ -60,6 +61,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | A click on area type's red out port loads the Selection tool with the overflow text; the next click threads it into a closed path or a new frame of the same shape there, a drag into a new rectangle (`text.thread.add`, #2572 in part). The out port moved up the frame's right side, clear of the corner handle. Scores and hours unchanged. | | 2026-10-16 | minor | Native font-family labels fall back from English-only typographic names to localized legacy names (#2208). English labels and native typographic precedence are preserved; synthetic loaded/catalog tests. Scores and estimates unchanged. | | 2026-10-16 | minor | Align Stroke on live type: the characters' stroke goes inside or outside the glyphs without creating outlines, for every run or a range; kept in the native file and in canvas, PDF, EPS and SVG output (#2294); beyond Illustrator, scores and hours unchanged | | 2026-10-14 | minor | Cmd+Shift+C (Align center) works on Windows and Linux too, where egui reported it as a copy (#1940); scores and hours unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 917ff471c..4de19cebb 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; scores unchanged; transparency grid visibility saved in native documents, #1968; Live Corners radius up to the neighbouring cuts, #2332; Image Trace quick presets: wording corrected to the shipped behaviour, the Image Trace button opens the controls; batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (the Selection tool threads text from the out port, #2572 in part; scores unchanged; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; scores unchanged; transparency grid visibility saved in native documents, #1968; Live Corners radius up to the neighbouring cuts, #2332; Image Trace quick presets: wording corrected to the shipped behaviour, the Image Trace button opens the controls; batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x) How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to, @@ -169,6 +169,7 @@ tools, never checked side by side) isn't captured row by row. | Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool | done | Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. | | Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture | done | Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests_distortkeys.rs`. | | Escape while typing ends the edit, keeps the type selected and switches to the Selection tool | done | #1802 (#1686, #1656) | +| Selection tool: a click on overflowing area type's out port loads the text cursor; a click on a closed path, on empty canvas or a drag threads the text there; Escape or another tool drops it | partial | #2572 in part (`text.thread.add`); the out port sits on the frame's right side above the corner handle so both can be grabbed; in ports and empty out ports are not drawn or clickable yet; the loaded cursor borrows the I-beam | | Shift-click selects several artboards (Artboard tool, Artboards panel); they move, Alt-copy and delete together | done | `artboard.move`/`artboard.delete` `indices` (#1139); panel menu, trash and Properties share selected-set deletion; all-selected retains artboard 1 and a single board cannot be deleted, with pointer-event and Undo/Redo coverage | | Artboard Delete/Backspace delete selected boards while protecting artwork, the last board and active drags | done | Headless shortcut regressions in `tests_distortkeys.rs` cover retaining the original first board when all are selected, single-board protection, artwork, saved state and Undo/Redo; the navigator follows the tool’s surviving board through repeated deletion and history changes. | | Artboard navigator synchronizes the active tool selection before deletion | done | Actual status-bar Next → Delete/Backspace and Next → Properties Delete regressions in `chrome.rs` cover surviving board records, preserved artwork and Undo/Redo; other tools keep object selection and history. **Tab-switch follow-up:** document-tab changes align the Artboard tool with the displayed board. Actual A → B → A tab clicks followed by Properties Delete and controls for same-tab multiselection, Move/Scale Artwork options and other tools passed, along with historical full CI on `a0671777`. [Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area). **Clean-tab close:** with the Artboard tool active, closing a clean active tab restores the surviving document's displayed board before Properties Delete. | @@ -241,6 +242,7 @@ re-measure of the panels added since. Slider rails show on Medium Dark (#1808, # | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Selection tool: clicking the red out port of overflowing area type, then a closed path, empty canvas or a dragged rectangle threads the text there (#2572 in part); Escape cancels. Scores and hours unchanged. | | 2026-10-17 | minor | Group Selection picks one path of a compound path (the outer shape or a hole) and moves it on its own; a second click adds the compound path (#2441, in part) | | 2026-10-16 | minor | The Move dialog opens on the offset of the last drag or Move and with the checkboxes it was last confirmed with; scores unchanged | | 2026-10-16 | minor | Native documents restore transparency grid visibility (#1968); save/open regression covers on/off, old files and SVG; scores and hours unchanged (estimated) |