Type tool: a click on a path's outline makes area type or type on a path, with cursors that say which

This commit is contained in:
ham9zah
2026-11-02 14:36:08 +07:00
committed by bflatastic
parent 1b5d11ef4b
commit afd9ec036c
7 changed files with 182 additions and 28 deletions
+8
View File
@@ -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).
+30 -3
View File
@@ -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 and Area Type over a closed path's outline, Alt up for Type: a click sets area type inside the path."]
hotspot = "The I-beam's centre."
look = "The I-beam between two arcs, like a pair of parentheses around it: the type goes inside the shape."
[[cursor]]
id = "TypeAreaVertical"
group = "Type"
shown_when = ["Vertical Type and Vertical Area Type over a closed path's outline, Alt up for Vertical Type: 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, or a closed one with Alt held; Type on a Path over any path's outline; Area Type over an open path with Alt held: a click sets type along the path."]
hotspot = "The I-beam's centre."
look = "The I-beam crossing a wavy line below its centre: the type runs along the line."
[[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.",
+40 -17
View File
@@ -1,8 +1,16 @@
//! 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/20220127000000/https://helpx.adobe.com/illustrator/using/creating-type-path.html)).
//! Alt (Option) held over a closed path sets type along it instead: our own shortcut, so the Type
//! tool reaches both without switching tools.
//! 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.
@@ -178,6 +186,8 @@ pub struct TypeTool {
drag: Option<Point>,
/// The Type tool the press acts as, with the keys held at the press ([`Self::acting`]).
held: Option<(Mode, bool)>,
/// Alt (Option) was held at the press: the Type tool sets type along a closed path, not in it.
held_alt: bool,
/// Smart Guides for where new type goes.
snap: DrawSnap,
/// The press landed in the edited text: dragging selects.
@@ -609,15 +619,28 @@ 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<NodeId> {
/// The path whose outline is under `p`, and whether it is closed.
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, ..Default::default() })?;
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, or along a closed one with
/// `alt`; Area Type needs a closed path and Type on a Path takes either.
fn in_path(cx: &ToolContext, p: Point, mode: Mode, alt: bool) -> Option<(NodeId, bool)> {
let (id, closed) = Self::path_at(cx, p)?;
match mode {
Mode::Type => Some((id, alt || !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.
@@ -639,6 +662,7 @@ impl TypeTool {
/// (#2871). A drag that draws an area-type frame makes it either way.
fn on_up(&mut self, cx: &ToolContext, start: Point, at: Point) -> Vec<Action> {
let (mode, vertical) = self.held.take().unwrap_or((self.mode, self.vertical));
let alt = std::mem::take(&mut self.held_alt);
// Click into existing text: place the caret.
if let Some(out) = self.edit_at(cx, start) {
return out;
@@ -660,18 +684,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, alt)
{
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) => {
+59 -1
View File
@@ -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,64 @@ 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, type on a path along an open one or, with Alt held, along a closed 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, "onPath", if vertical { Cursor::TypeOnPathVertical } else { Cursor::TypeOnPath }),
(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!(
(&params["path"], &params["mode"], &params["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, &params["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));
}
/// 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]
+13 -6
View File
@@ -55,6 +55,7 @@ impl Tool for TypeTool {
}
self.press = Some((ev.pos, self.snap.press(cx, ev.pos, self.editing.as_slice(), None)));
self.held = Some(self.acting(ev.mods));
self.held_alt = ev.mods.alt && self.editing.is_none();
self.drag = None;
vec![]
}
@@ -632,13 +633,19 @@ impl Tool for TypeTool {
/// The pointer of the Type tool that a press would act as with the keys held, or, while a
/// press is held, of the one it acts as ([`TypeTool::acting`]).
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
let (mode, vertical) = match (self.press, self.held) {
(Some(_), Some(held)) => held,
_ => self.acting(m),
let ((mode, vertical), alt) = match (self.press, self.held) {
(Some(_), Some(held)) => (held, self.held_alt),
_ => (self.acting(m), m.alt && self.editing.is_none()),
};
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, alt) };
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 }
}
}
+2 -1
View File
@@ -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.
+30
View File
@@ -366,6 +366,28 @@ 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 I-beam (turned a quarter turn for vertical type, without its
/// baseline mark) between two arcs for area type inside a closed path, or crossing a wavy line 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));
if on_path {
let wave = (0..=12).map(|i| {
let x = -9.0 + 1.5 * i as f32;
at(x, 4.0 + 2.0 * (x * 0.55).sin())
});
strokes(p, &[wave.collect()]);
} else {
strokes(
p,
&[vec![at(-6.0, -8.0), at(-8.5, -4.0), at(-8.5, 4.0), at(-6.0, 8.0)], vec![at(6.0, -8.0), at(8.5, -4.0), at(8.5, 4.0), at(6.0, 8.0)]],
);
}
}
/// 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) {
@@ -519,6 +541,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 +631,10 @@ fn glyph(c: Cursor, p: Pos2) -> Option<Vec<Shape>> {
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),