diff --git a/ASSETS.md b/ASSETS.md index 3233c99d0..db2a97816 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -224,13 +224,16 @@ Generated-in-code art is original and has no file to list. This covers the defau | `assets/icons/dc-swap.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-swatch-kinds.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-symbol-sprayer.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | -| `assets/icons/dc-touch-type.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-touch-type.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-transform-panel.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-transparency.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-twirl.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | -| `assets/icons/dc-type-area.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | -| `assets/icons/dc-type-path.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | -| `assets/icons/dc-type-vertical.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-type.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-type-area.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-type-path.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-type-vertical.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-type-vertical-area.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-type-vertical-path.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-width-profile.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-width.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-wrinkle.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | diff --git a/assets/icons/dc-touch-type.svg b/assets/icons/dc-touch-type.svg index f5af072c9..2f9a196c8 100644 --- a/assets/icons/dc-touch-type.svg +++ b/assets/icons/dc-touch-type.svg @@ -1 +1 @@ - + diff --git a/assets/icons/dc-type-area.svg b/assets/icons/dc-type-area.svg index f37960dae..538b0ed24 100644 --- a/assets/icons/dc-type-area.svg +++ b/assets/icons/dc-type-area.svg @@ -1 +1 @@ - + diff --git a/assets/icons/dc-type-path.svg b/assets/icons/dc-type-path.svg index 42584412c..a57eceffd 100644 --- a/assets/icons/dc-type-path.svg +++ b/assets/icons/dc-type-path.svg @@ -1 +1 @@ - + diff --git a/assets/icons/dc-type-vertical-area.svg b/assets/icons/dc-type-vertical-area.svg new file mode 100644 index 000000000..b1c530b84 --- /dev/null +++ b/assets/icons/dc-type-vertical-area.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-type-vertical-path.svg b/assets/icons/dc-type-vertical-path.svg new file mode 100644 index 000000000..5e5955bd8 --- /dev/null +++ b/assets/icons/dc-type-vertical-path.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-type-vertical.svg b/assets/icons/dc-type-vertical.svg index d72855530..69fc0b5ff 100644 --- a/assets/icons/dc-type-vertical.svg +++ b/assets/icons/dc-type-vertical.svg @@ -1 +1 @@ - + diff --git a/assets/icons/dc-type.svg b/assets/icons/dc-type.svg new file mode 100644 index 000000000..e87a94f67 --- /dev/null +++ b/assets/icons/dc-type.svg @@ -0,0 +1 @@ + diff --git a/crates/engine/src/tests/pathtype.rs b/crates/engine/src/tests/pathtype.rs index c80ca3236..5ead0cbd2 100644 --- a/crates/engine/src/tests/pathtype.rs +++ b/crates/engine/src/tests/pathtype.rs @@ -277,3 +277,41 @@ fn direct_selection_drags_an_anchor_of_the_path_and_the_type_reflows() { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(path(&s).subpaths[0].anchors[1].p, middle); } + +/// Type on a path made by clicking a path with the plain Type tool (an open path) or Type on a +/// Path (a closed one) shows its brackets as soon as the Selection or Direct Selection tool is +/// chosen, and its centre bracket, dragged across the path, flips it. +#[test] +fn type_on_a_path_made_with_the_type_tool_has_brackets_at_once() { + let v = ViewInfo::default(); + for (tool, d, mods) in [ + ("selection", "M100 300 L500 300", Mods::default()), + ("directSelection", "M100 300 L500 300", Mods::default()), + ("selection", "M100 300 L500 300 L500 500 L100 500 Z", Mods::default()), + ] { + let mut s = session(); + s.execute("path.create", &json!({ "d": d })).unwrap(); + s.select_tool(if d.ends_with('Z') { "typeOnPath" } else { "type" }, v).unwrap(); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, 300.0, 300.0).with_mods(mods), v).unwrap(); + } + let id = *s.doc().unwrap().selection.whole().first().expect("the new type is selected"); + assert!(matches!(text(&s, id).kind, TextKind::OnPath { .. }), "{tool} {d}"); + s.execute("text.setText", &json!({"ids": [id.0], "text": "Slide"})).unwrap(); + s.select_tool(tool, v).unwrap(); + let lines = s.overlays(v).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Line { .. })).count(); + assert!(lines >= 5, "{tool} {d}: the brackets are drawn at once ({lines} lines)"); + if d.ends_with('Z') { + continue; + } + // The centre bracket stands midway between the start (300) and the end of the path (500). + let (start, end) = brackets(&s, id); + assert!(near(start, 0.5) && end.is_none(), "{start} {end:?}"); + let centre = Point::new(400.0, 295.0); + assert_eq!(s.cursor(centre, Mods::default(), v), vectorcraft_tools::Cursor::PathBracket, "{tool}"); + drag(&mut s, tool, centre, Point::new(400.0, 330.0), Mods::default()); + let (start, end) = brackets(&s, id); + assert!(near(start, 0.0) && end.is_some_and(|e| near(e, 0.5)), "{tool}: flipped, {start} {end:?}"); + assert!(ink(&s, id).y0 > 299.0, "{tool}: below the path"); + } +} diff --git a/crates/tools/src/catalog.rs b/crates/tools/src/catalog.rs index ac18d6beb..9bbd7cbdf 100644 --- a/crates/tools/src/catalog.rs +++ b/crates/tools/src/catalog.rs @@ -37,8 +37,8 @@ pub const TOOL_GROUPS: &[&[ToolInfo]] = &[ t("areaType", "Area Type Tool", None, "tool-type-area"), t("typeOnPath", "Type on a Path Tool", None, "tool-type-path"), t("verticalType", "Vertical Type Tool", None, "tool-type-vertical"), - t("verticalAreaType", "Vertical Area Type Tool", None, "tool-type-vertical"), - t("verticalTypeOnPath", "Vertical Type on a Path Tool", None, "tool-type-path"), + t("verticalAreaType", "Vertical Area Type Tool", None, "tool-type-vertical-area"), + t("verticalTypeOnPath", "Vertical Type on a Path Tool", None, "tool-type-vertical-path"), t("touchType", "Touch Type Tool", Some("Shift+T"), "tool-touch-type"), ], &[ diff --git a/crates/tools/src/cursor.rs b/crates/tools/src/cursor.rs index f7eb200ea..a59bc28af 100644 --- a/crates/tools/src/cursor.rs +++ b/crates/tools/src/cursor.rs @@ -165,6 +165,14 @@ pub enum Cursor { SymbolStyler, /// The vertical type tools: a turned I-beam. TypeVertical, + /// A type tool over a closed path's outline: a click sets area type inside it. + TypeArea, + /// ...the vertical type tools. + TypeAreaVertical, + /// A type tool over a path's outline: a click sets type along it. + TypeOnPath, + /// ...the vertical type tools. + TypeOnPathVertical, /// The Scissors over a path segment (a click splits the path there)... Scissors, /// ...or away from every segment (a click does nothing). diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml index 4766f0f6d..5447261e6 100644 --- a/crates/tools/src/cursors.toml +++ b/crates/tools/src/cursors.toml @@ -241,7 +241,7 @@ look = "The Pen nib with a curve badge." id = "Text" group = "Type" shown_when = [ - "Every horizontal type tool: Type, Area Type and Type on a Path (the Area and Path ones show the crosshair over a suitable path).", + "Every horizontal type tool: Type, Area Type and Type on a Path, away from a path a click would set type in or along (TypeArea, TypeOnPath).", "Stands in for TypeVertical until it's drawn.", ] hotspot = "The I-beam's centre." @@ -250,10 +250,38 @@ look = "An I-beam with a short crossbar marking the baseline." [[cursor]] id = "TypeVertical" group = "Type" -shown_when = ["The vertical type tools: Vertical Type, Vertical Area Type and Vertical Type on a Path (the Area and Path ones show the crosshair over a suitable path)."] +shown_when = ["The vertical type tools: Vertical Type, Vertical Area Type and Vertical Type on a Path, away from a path a click would set type in or along (TypeAreaVertical, TypeOnPathVertical)."] hotspot = "The I-beam's centre." look = "The Text I-beam turned a quarter turn." +[[cursor]] +id = "TypeArea" +group = "Type" +shown_when = ["Type over a closed path's outline, and Area Type over one with Alt up: a click sets area type inside the path."] +hotspot = "The I-beam's centre." +look = "The Text I-beam with a small badge below right: a square frame with three short lines of type in it." + +[[cursor]] +id = "TypeAreaVertical" +group = "Type" +shown_when = ["Vertical Type over a closed path's outline, and Vertical Area Type over one with Alt up: a click sets vertical area type inside the path."] +hotspot = "The I-beam's centre." +look = "TypeArea turned a quarter turn." + +[[cursor]] +id = "TypeOnPath" +group = "Type" +shown_when = ["Type over an open path's outline; Type on a Path over any path's outline (Alt up); Area Type over an open path with Alt held: a click sets type along the path."] +hotspot = "The I-beam's centre." +look = "The Text I-beam with a small badge below right: a short smooth curve with a dot on it." + +[[cursor]] +id = "TypeOnPathVertical" +group = "Type" +shown_when = ["The vertical type tools where TypeOnPath shows: a click sets vertical type along the path."] +hotspot = "The I-beam's centre." +look = "TypeOnPath turned a quarter turn." + # ── Transform and resize ────────────────────────────────────────────────────────────────────────── [[cursor]] @@ -262,7 +290,6 @@ group = "Transform and resize" shown_when = [ "The generic drawing cursor: all shape, line, grid, flare, cut, graph and liquify tools, and Place.", "The Rotate, Reflect, Scale and Shear tools with a selection (Reflect, Scale and Shear even mid-drag).", - "The Area Type and Type on a Path tools over a suitable path.", "The Artboard tool outside every artboard; the Gradient tool away from the annotator; Free Transform corners in Distort or Perspective mode.", "With Use Precise Cursors on, it replaces the Pen, Eyedropper, Slice, Blend and Shape Builder cursors.", "Stands in for every drawing tool cursor not drawn yet.", diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index e86bd4c4f..3e854231d 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -1,8 +1,15 @@ //! The Type tools: Type (T), Area Type and Type on a Path. //! //! Type: click places point type, drag draws an area-type frame, clicking into existing text -//! places the caret. Area Type / Type on a Path: click a path to turn it into a text frame or a -//! baseline (`text.createInPath`). While type is being edited, a click that lands on no type only +//! places the caret, and a click on a path's outline makes type in or along it: area type inside a +//! closed path and type on a path along an open one, as the reference app's Type tool does ("Select +//! the Type tool […] and click anywhere on the object's path", [Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html); +//! "To create horizontal text along a path, select the Type tool or the Type On A Path tool […] If +//! the path is closed, rather than open, you must use the Type On A Path tool", [Create type on a +//! path](https://web.archive.org/web/20220127090635/https://helpx.adobe.com/illustrator/using/creating-type-path.html)), +//! Alt or not: type along a closed path stays with Type on a Path. +//! Area Type / Type on a Path: click a path to turn it into a text frame or a baseline +//! (`text.createInPath`). While type is being edited, a click that lands on no type only //! ends the editing, and the next click makes new type. Point type placed by a click and left //! empty is discarded when editing ends (`text.discardEmpty`); frames keep their shape. Where new //! type goes (the click, the frame's corners) snaps to Smart Guides ([`DrawSnap`]), hovering too. @@ -609,15 +616,29 @@ impl TypeTool { self.caret = r.end; } - /// Path under `p` for the Area Type / Type on a Path tools. - fn path_at(cx: &ToolContext, p: Point, closed: bool) -> Option { - let h = hit_test(cx.doc, p, vectorcraft_doc::hit::HitOptions { tol: cx.tol(4.0), outline: true, ..Default::default() })?; + /// The path whose outline is under `p`, and whether it is closed: within the isolated group + /// being edited, and no guide while guides are hidden or locked ([`ToolContext::hit_options`]). + fn path_at(cx: &ToolContext, p: Point) -> Option<(NodeId, bool)> { + let h = hit_test(cx.doc, p, vectorcraft_doc::hit::HitOptions { tol: cx.tol(4.0), outline: true, ..cx.hit_options() })?; match &cx.doc.node(h.leaf)?.kind { - NodeKind::Path { path, .. } if !closed || path.is_closed() => Some(h.leaf), + NodeKind::Path { path, .. } => Some((h.leaf, path.is_closed())), _ => None, } } + /// What a click at `p` with the tool acting as `mode` makes of the path there: the path, and + /// whether the type goes along it (else inside it); None when it makes no type in a path. The + /// Type tools set type inside a closed path and along an open one; Area Type needs a closed + /// path and Type on a Path takes either. + fn in_path(cx: &ToolContext, p: Point, mode: Mode) -> Option<(NodeId, bool)> { + let (id, closed) = Self::path_at(cx, p)?; + match mode { + Mode::Type => Some((id, !closed)), + Mode::Area => closed.then_some((id, false)), + Mode::OnPath => Some((id, true)), + } + } + /// Start editing the type under `p` with the caret there → the actions (ending the previous /// edit, selecting the type); None when no type is under `p`. A click among the characters /// edits them, whatever Type Object Selection by Path Only says. @@ -660,18 +681,17 @@ impl TypeTool { if was_editing && area.is_none() { return out; } - // Area Type / Type on a Path: click a path. - if mode != Mode::Type && drag.is_none() { - let on_path = mode == Mode::OnPath; - if let Some(pid) = Self::path_at(cx, start, !on_path) { - let mode = if on_path { "onPath" } else { "area" }; - out.push(Action::Exec( - "text.createInPath".into(), - json!({"path": pid.0, "mode": mode, "text": "", "at": [start.x, start.y], "vertical": vertical, "placeholder": cx.placeholder_text}), - )); - out.push(Action::Notify("text.editNew".into())); - return out; - } + // A click on a path: type in it or along it. + if drag.is_none() + && let Some((pid, on_path)) = Self::in_path(cx, start, mode) + { + let mode = if on_path { "onPath" } else { "area" }; + out.push(Action::Exec( + "text.createInPath".into(), + json!({"path": pid.0, "mode": mode, "text": "", "at": [start.x, start.y], "vertical": vertical, "placeholder": cx.placeholder_text}), + )); + out.push(Action::Notify("text.editNew".into())); + return out; } let mut params = match area { Some(r) => { diff --git a/crates/tools/src/text/tests.rs b/crates/tools/src/text/tests.rs index 172a8613b..ab3229809 100644 --- a/crates/tools/src/text/tests.rs +++ b/crates/tools/src/text/tests.rs @@ -807,7 +807,7 @@ fn the_pointer_shows_the_type_tool_shift_or_alt_switches_to() { assert_eq!(cursor("verticalType", away, SHIFT), Cursor::Text); assert_eq!(cursor("verticalAreaType", away, SHIFT), Cursor::Text); assert_eq!(cursor("areaType", on_line, Mods::default()), Cursor::Text, "no frame for area type"); - assert_eq!(cursor("areaType", on_line, ALT), Cursor::Crosshair, "a baseline for type on a path"); + assert_eq!(cursor("areaType", on_line, ALT), Cursor::TypeOnPath, "a baseline for type on a path"); assert_eq!(cursor("typeOnPath", on_line, ALT), Cursor::Text); let mut tool = TypeTool::new("type"); tool.pointer(&c, &PointerEvent::new(PointerKind::Down, away.x, away.y).with_mods(SHIFT)); @@ -816,6 +816,83 @@ fn the_pointer_shows_the_type_tool_shift_or_alt_switches_to() { assert_eq!(tool.cursor(&c, away, Mods::default()), Cursor::Text); } +/// The plain Type tools make type in or along a path clicked on its outline: area type inside a +/// closed path (Alt held or not: type along a closed path is Type on a Path's) and type on a path +/// along an open one. A click away from paths still makes point type, a drag still draws a frame +/// (even from a path's outline), and a click on existing type edits it. The pointer says which. +#[test] +fn the_type_tool_makes_type_in_or_along_a_clicked_path() { + let (mut d, rect, line) = doc_with_rect_and_line(); + let (sel, p) = (Selection::default(), paint()); + let (on_line, on_rect, away) = ((250.0, 400.0), (100.0, 150.0), (450.0, 450.0)); + for id in ["type", "verticalType"] { + let vertical = id == "verticalType"; + let c = cx(&d, &sel, &p); + for (at, mods, path, mode, cursor) in [ + (on_rect, Mods::default(), rect, "area", if vertical { Cursor::TypeAreaVertical } else { Cursor::TypeArea }), + (on_line, Mods::default(), line, "onPath", if vertical { Cursor::TypeOnPathVertical } else { Cursor::TypeOnPath }), + (on_rect, ALT, rect, "area", if vertical { Cursor::TypeAreaVertical } else { Cursor::TypeArea }), + (on_line, ALT, line, "onPath", if vertical { Cursor::TypeOnPathVertical } else { Cursor::TypeOnPath }), + ] { + assert_eq!(TypeTool::new(id).cursor(&c, Point::new(at.0, at.1), mods), cursor, "{id} {at:?} {mods:?}"); + let mut tool = TypeTool::new(id); + let acts = gesture(&mut tool, &c, at, None, mods, Mods::default()); + let (cmd, params) = made(&acts).unwrap_or_else(|| panic!("{id} {at:?} {mods:?}: {acts:?}")); + assert_eq!(cmd, "text.createInPath", "{id} {at:?} {mods:?}"); + assert_eq!( + (¶ms["path"], ¶ms["mode"], ¶ms["vertical"]), + (&json!(path.0), &json!(mode), &json!(vertical)), + "{id} {at:?} {mods:?}" + ); + assert_eq!(tool.id(), id, "the tool chosen stays"); + } + // Shift switches the direction over a path too. + let acts = gesture(&mut TypeTool::new(id), &c, on_rect, None, SHIFT, Mods::default()); + assert_eq!(made(&acts).map(|(_, p)| (p["mode"].clone(), p["vertical"].clone())), Some((json!("area"), json!(!vertical)))); + // Away from paths: point type. + assert_eq!( + TypeTool::new(id).cursor(&c, Point::new(away.0, away.1), Mods::default()), + if vertical { Cursor::TypeVertical } else { Cursor::Text } + ); + let acts = gesture(&mut TypeTool::new(id), &c, away, None, Mods::default(), Mods::default()); + let (cmd, params) = made(&acts).unwrap_or_else(|| panic!("{acts:?}")); + assert_eq!((cmd, params.get("area")), ("text.create", None), "{id}: point type"); + // A drag from the rectangle's outline draws a frame. + let acts = gesture(&mut TypeTool::new(id), &c, on_rect, Some((200.0, 250.0)), Mods::default(), Mods::default()); + let (cmd, params) = made(&acts).unwrap_or_else(|| panic!("{acts:?}")); + assert_eq!((cmd, ¶ms["area"]), ("text.create", &json!({"width": 100.0, "height": 100.0})), "{id}: drag"); + } + // A click on existing type over the rectangle's outline edits the type. + let l = d.layers[0].id; + let tid = d.alloc_id(); + let t = TextObject::point(Point::new(95.0, 155.0), "Hello", Default::default()); + d.insert(Some(l), 2, Node::new(tid, NodeKind::Text(Box::new(t)))).unwrap(); + let c = cx(&d, &sel, &p); + let mut tool = TypeTool::new("type"); + let acts = gesture(&mut tool, &c, (100.0, 150.0), None, Mods::default(), Mods::default()); + assert!(made(&acts).is_none(), "{acts:?}"); + assert_eq!(tool.editing, Some(tid)); +} + +/// A guide is no path to set type in or along while guides are hidden or locked: the click makes +/// point type, as it does on the selection tools' terms. +#[test] +fn the_type_tool_leaves_hidden_or_locked_guides_alone() { + let (mut d, rect, _) = doc_with_rect_and_line(); + if let Some(NodeKind::Path { guide, .. }) = d.node_mut(rect).map(|n| &mut n.kind) { + *guide = true; + } + let (sel, p) = (Selection::default(), paint()); + let mut c = cx(&d, &sel, &p); + c.guides = false; + assert_eq!(TypeTool::new("type").cursor(&c, Point::new(100.0, 150.0), Mods::default()), Cursor::Text); + let acts = gesture(&mut TypeTool::new("type"), &c, (100.0, 150.0), None, Mods::default(), Mods::default()); + assert_eq!(made(&acts).map(|(cmd, _)| cmd), Some("text.create"), "{acts:?}"); + c.guides = true; + let acts = gesture(&mut TypeTool::new("type"), &c, (100.0, 150.0), None, Mods::default(), Mods::default()); + assert_eq!(made(&acts).map(|(cmd, _)| cmd), Some("text.createInPath"), "{acts:?}"); +} + /// While type is being edited, Shift and Alt switch no tool: a frame dragged out is the tool /// chosen's, and the pointer stays its own. #[test] diff --git a/crates/tools/src/text/tool.rs b/crates/tools/src/text/tool.rs index a3e27487a..c983205da 100644 --- a/crates/tools/src/text/tool.rs +++ b/crates/tools/src/text/tool.rs @@ -636,9 +636,15 @@ impl Tool for TypeTool { (Some(_), Some(held)) => held, _ => self.acting(m), }; - if mode != Mode::Type && Self::path_at(cx, p, mode == Mode::Area).is_some() { - return Cursor::Crosshair; + // While type is being edited a click on a path only ends the editing. + let in_path = if self.editing.is_some() { None } else { Self::in_path(cx, p, mode) }; + match (in_path, vertical) { + (Some((_, false)), false) => Cursor::TypeArea, + (Some((_, false)), true) => Cursor::TypeAreaVertical, + (Some((_, true)), false) => Cursor::TypeOnPath, + (Some((_, true)), true) => Cursor::TypeOnPathVertical, + (None, false) => Cursor::Text, + (None, true) => Cursor::TypeVertical, } - if vertical { Cursor::TypeVertical } else { Cursor::Text } } } diff --git a/crates/ui-egui/src/canvas/cursor.rs b/crates/ui-egui/src/canvas/cursor.rs index 1056e79e7..71f70e455 100644 --- a/crates/ui-egui/src/canvas/cursor.rs +++ b/crates/ui-egui/src/canvas/cursor.rs @@ -119,7 +119,8 @@ pub(super) fn cursor_icon(c: Cursor) -> egui::CursorIcon { Cursor::PencilNew => C::Crosshair, Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor | Cursor::ShapeBuilder | Cursor::ShapeBuilderErase => C::Crosshair, Cursor::PencilClose | Cursor::PencilStraight | Cursor::PencilContinue | Cursor::PaintbrushClose => C::Crosshair, - Cursor::TypeVertical => C::VerticalText, + Cursor::TypeVertical | Cursor::TypeAreaVertical | Cursor::TypeOnPathVertical => C::VerticalText, + Cursor::TypeArea | Cursor::TypeOnPath => C::Text, // The system has no tip outline to go round: its crosshair aims as well. Cursor::BlobBrush | Cursor::Eraser => C::Crosshair, // Not drawn yet: the cursor each borrows. diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index c9a0bf9a9..44377a2c8 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -366,6 +366,42 @@ fn ibeam(p: &mut Ink, o: Pos2) { line(p, o + vec2(-2.0, 3.0), o + vec2(2.0, 3.0)); } +/// A type tool over a path: the Text I-beam (turned a quarter turn for vertical type) with a badge +/// below right of it, as the Pen and the Blend tool mark what a click does: a small frame with +/// lines of type in it for area type inside a closed path, a smooth curve with a point on it for +/// type along a path. +fn type_in_path(p: &mut Ink, o: Pos2, vertical: bool, on_path: bool) { + let at = |x: f32, y: f32| if vertical { o + vec2(-y, x) } else { o + vec2(x, y) }; + line(p, at(0.0, -8.0), at(0.0, 8.0)); + line(p, at(-3.0, -8.0), at(3.0, -8.0)); + line(p, at(-3.0, 8.0), at(3.0, 8.0)); + line(p, at(-2.0, 3.0), at(2.0, 3.0)); + let b = o + if vertical { vec2(2.0, 6.0) } else { vec2(6.0, 2.0) }; + if on_path { + // A quarter-wave rising to the right, smooth at this size. + let curve: Vec = (0..=16) + .map(|i| { + let t = i as f32 / 16.0; + b + vec2(10.0 * t, 8.0 - 8.0 * (t * std::f32::consts::FRAC_PI_2).sin().powf(1.6)) + }) + .collect(); + strokes(p, &[curve]); + let dot = b + vec2(4.0, 8.0 - 8.0 * (0.4 * std::f32::consts::FRAC_PI_2).sin().powf(1.6)); + p.circle_filled(dot, 2.4, HALO); + p.circle_filled(dot, 1.5, INK); + } else { + poly(p, vec![b, b + vec2(9.0, 0.0), b + vec2(9.0, 9.0), b + vec2(0.0, 9.0)], HALO, INK); + let rows: Vec> = if vertical { + [6.5, 4.5, 2.5].iter().map(|&x| vec![b + vec2(x, 2.5), b + vec2(x, 6.5)]).collect() + } else { + [2.5, 4.5, 6.5].iter().map(|&y| vec![b + vec2(2.5, y), b + vec2(6.5, y)]).collect() + }; + for r in rows { + p.add(Shape::line(r, Stroke::new(1.0, INK))); + } + } +} + /// The Blend tool: a crosshair with a square below right of the hotspot, hollow away from art, /// filled over an object; over an anchor point a ringed dot (the blend starts there). fn blend(p: &mut Ink, o: Pos2, badge: Cursor) { @@ -440,7 +476,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; 89] = [ +const GLYPHS: [Cursor; 93] = [ Cursor::Hand, Cursor::HandGrab, Cursor::ZoomIn, @@ -519,6 +555,10 @@ const GLYPHS: [Cursor; 89] = [ Cursor::SymbolScreener, Cursor::SymbolStyler, Cursor::TypeVertical, + Cursor::TypeArea, + Cursor::TypeAreaVertical, + Cursor::TypeOnPath, + Cursor::TypeOnPathVertical, Cursor::Scissors, Cursor::MeshAdd, Cursor::PuppetPin, @@ -605,6 +645,10 @@ fn glyph(c: Cursor, p: Pos2) -> Option> { Cursor::PenJoin => pen(ink, p, "j"), Cursor::PenConvert => pen(ink, p, "^"), Cursor::Text => ibeam(ink, p), + Cursor::TypeArea => type_in_path(ink, p, false, false), + Cursor::TypeAreaVertical => type_in_path(ink, p, true, false), + Cursor::TypeOnPath => type_in_path(ink, p, false, true), + Cursor::TypeOnPathVertical => type_in_path(ink, p, true, true), Cursor::AddStop => stop_badge(ink, p, true), Cursor::RemoveStop => stop_badge(ink, p, false), Cursor::Slice => slice(ink, p), diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 33ddde543..579df2187 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -174,9 +174,12 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("dc-transform-panel", include_bytes!("../../../assets/icons/dc-transform-panel.svg")), ("dc-transparency", include_bytes!("../../../assets/icons/dc-transparency.svg")), ("dc-twirl", include_bytes!("../../../assets/icons/dc-twirl.svg")), + ("dc-type", include_bytes!("../../../assets/icons/dc-type.svg")), ("dc-type-area", include_bytes!("../../../assets/icons/dc-type-area.svg")), ("dc-type-path", include_bytes!("../../../assets/icons/dc-type-path.svg")), ("dc-type-vertical", include_bytes!("../../../assets/icons/dc-type-vertical.svg")), + ("dc-type-vertical-area", include_bytes!("../../../assets/icons/dc-type-vertical-area.svg")), + ("dc-type-vertical-path", include_bytes!("../../../assets/icons/dc-type-vertical-path.svg")), ("dc-width", include_bytes!("../../../assets/icons/dc-width.svg")), ("dc-width-profile", include_bytes!("../../../assets/icons/dc-width-profile.svg")), ("dc-wrinkle", include_bytes!("../../../assets/icons/dc-wrinkle.svg")), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 8987cc64c..a3601af18 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -29,10 +29,12 @@ pub fn tool_icon(name: &str) -> &'static str { "tool-pen-delete" => "pen-tool-delete", "tool-anchor" => "dc-anchor", "tool-curvature" => "spline", - "tool-type" => "type", + "tool-type" => "dc-type", "tool-type-area" => "dc-type-area", "tool-type-path" => "dc-type-path", "tool-type-vertical" => "dc-type-vertical", + "tool-type-vertical-area" => "dc-type-vertical-area", + "tool-type-vertical-path" => "dc-type-vertical-path", "tool-touch-type" => "dc-touch-type", "tool-line" => "dc-line", "tool-arc" => "dc-arc", @@ -212,6 +214,20 @@ mod tests { assert_ne!(add, delete, "plus and minus differ"); } + /// The seven type tools each have an icon of their own, drawn for VectorCraft (the vertical + /// Area and Path tools used to borrow the horizontal ones' icons). + #[test] + fn every_type_tool_has_its_own_icon_with_the_same_t() { + let tools = ["type", "areaType", "typeOnPath", "verticalType", "verticalAreaType", "verticalTypeOnPath", "touchType"]; + let names: Vec<&str> = tools.iter().map(|id| tool_icon(vectorcraft_tools::catalog::tool_info(id).unwrap().icon)).collect(); + let unique: std::collections::BTreeSet<_> = names.iter().collect(); + assert_eq!(unique.len(), tools.len(), "{names:?}"); + for name in &names { + assert!(name.starts_with("dc-"), "{name} is drawn for VectorCraft"); + assert!(exists(name), "{name}"); + } + } + #[test] fn shared_catalog_preserves_fallback_and_tool_aliases() { let ImageSource::Bytes { uri, bytes } = source("missing-icon") else { panic!("expected SVG bytes") }; diff --git a/docs/type-parity.md b/docs/type-parity.md index c63e17840..9c0b7d400 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-31 · **Change:** minor (Glyphs panel: the character selected with the Type tool is highlighted and scrolled into view, #4908; previously minor: Serbian in Cyrillic and Latin and Croatian among the languages, with their quote marks; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units (#394); scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (the Type tools make area type or type on a path from a clicked path; scores unchanged) · **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 @@ -20,7 +20,7 @@ against Illustrator's public documentation; **estimated** unless noted. |---|---|---| | Soft return (forced line break, Shift+Enter) | done | Shift+Enter inserts a soft return (U+2028), breaking the line without starting a new paragraph; paragraph spacing (space before/after) and first-line indents apply only at paragraph ends; continuation lines in lists do not repeat the marker; soft return lines in justified text do not spread (#2754) | | Native font-family labels | partial | With Show Font Names in English off, localized legacy names are used when typographic names contain no native label (#2208); loaded-font and catalog-only regressions. Choosing among multiple native languages is unchanged. | -| 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 | +| 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; the Type and Vertical Type tools make area type when a closed path's outline is clicked and type on a path when an open one is, with pointers that say which, as the public help describes ([Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html), [Create type on a path](https://web.archive.org/web/20220127090635/https://helpx.adobe.com/illustrator/using/creating-type-path.html)) | | Area Type Options (rows, columns, inset, first baseline, Text Flow by rows or by columns, Auto Size, Shrink Text to Fit) | done | Text Flow fills the cells down each column first (By Columns, the default, as files from before it open) or across each row first (By Rows), in the dialog and `text.areaOptions {flow}` (#4978); 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); a thread holds area type and type on a path in any mix, as Illustrator links type in an area or along a path ([help](https://helpx.adobe.com/illustrator/using/manage-text-area.html)) | | Threading from the ports: selected area type and type on a path show an in port and an out port, empty, with an arrow on a side where the frame is threaded, or with a red "+" in the out port when the text doesn't fit; a click on either port loads the text; a click on a closed path, area type or type on a path threads into it, a click on empty canvas makes a frame of the same kind, shape and size there, a drag draws a rectangular frame; the frame placed comes after the clicked one from its out port and before it from its in port (the story then starts there); Escape or another tool cancels; a double-click on a threaded port breaks the thread there | partial | #2572: the Selection tool, `text.thread.add {from, to?, rect?, at?, before?}`, as the reference app's help describes ports and threading ([archived help, Threading text between objects](https://web.archive.org/web/20210119051048/https://helpx.adobe.com/illustrator/using/creating-text.html), [create text threads](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/create-text-threads-between-objects.html)); area type's in port sits on the left side below the top-left corner, type on a path's just before its start bracket; Show Text Threads draws its lines between the same ports; with more than 1,000 such objects selected no ports are drawn. a double-click on a port where the frame is threaded breaks the thread there, the story flowing into the frames before the break and the frames after it left empty, still threaded with each other when two or more (`text.thread.break`, [archived help](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html)). Remaining: the ports only take clicks with the Selection tool, and the loaded cursor borrows the I-beam until it's drawn | @@ -75,6 +75,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | The Type and Vertical Type tools make area type in a clicked closed path and type on a path along a clicked open one, with their own pointers; each type tool has its own icon; scores unchanged (estimated) | | 2026-10-31 | minor | Units › East Asian Type acts (#394): Q and Ha (a quarter of a millimetre each) are units that preference offers; scores unchanged | | 2026-11-01 | minor | Glyphs panel (#4908): the character selected with the Type tool (a range's first, else the one before the caret) is highlighted in the grid and scrolled into view when the selection moves to another character; Show › Alternates for Current Selection not yet; scores unchanged | | 2026-11-01 | minor | Serbian: Cyrillic (`sr-Cyrl`), Serbian: Latin (`sr-Latn`) and Croatian (`hr`) in the language list, with their quote marks (Serbian „…” and ’…’ per the Pravopis srpskoga jezika, Matica srpska 2010, ISBN 978-86-7946-079-0, т. 168, and [CLDR `sr`](https://github.com/unicode-org/cldr/blob/main/common/main/sr.xml); Croatian „…“ and ‚…‘ per [CLDR `hr`](https://github.com/unicode-org/cldr/blob/main/common/main/hr.xml)); Serbian text gets the fonts' Serbian forms (italic б, г, д, п, т); scores unchanged |