Merge pull request #4387 from dotCooCoo/feat/type-tool-modifier-switch

Type tools: Shift switches between horizontal and vertical, and Alt switches between area and path type
This commit is contained in:
BFlat
2026-10-29 10:55:35 +07:00
committed by GitHub
5 changed files with 215 additions and 7 deletions
+35
View File
@@ -994,6 +994,41 @@ fn a_frame_dragged_with_the_type_tools_is_the_dragged_rectangle() {
}
}
/// Through the session, a click with Shift held and the Type tool chosen makes vertical type and
/// edits it with the Type tool still chosen, and the pointer with Shift held is the vertical
/// I-beam. A click with Alt held and the Type on a Path tool chosen turns a rectangle into area
/// type.
#[test]
fn shift_and_alt_switch_the_type_tools_through_the_session() {
let mut s = session();
let v = ViewInfo::default();
let (shift, alt) = (Mods { shift: true, ..Mods::default() }, Mods { alt: true, ..Mods::default() });
s.select_tool("type", v).unwrap();
assert_eq!(s.cursor(Point::new(400.0, 300.0), shift, v), vectorcraft_tools::Cursor::TypeVertical);
assert_eq!(s.cursor(Point::new(400.0, 300.0), Mods::default(), v), vectorcraft_tools::Cursor::Text);
for k in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(k, 400.0, 300.0).with_mods(shift), v).unwrap();
}
assert_eq!(s.tool_id(), "type");
let id = s.doc().unwrap().selection.objects[0];
assert!(obj(&s, id).vertical);
assert!(s.tool_wants_text());
s.tool_text("縦", v).unwrap();
assert_eq!(obj(&s, id).plain_text(), "縦");
key(&mut s, ToolKey::Escape, Mods::default());
let rect = NodeId(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap());
s.select_tool("typeOnPath", v).unwrap();
for k in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(k, 100.0, 150.0).with_mods(alt), v).unwrap();
}
assert_eq!(s.tool_id(), "typeOnPath");
assert!(s.doc().unwrap().doc.node(rect).is_none(), "the rectangle became the frame");
let id = s.doc().unwrap().selection.objects[0];
assert!(matches!(obj(&s, id).kind, TextKind::Area { .. }));
assert!(!obj(&s, id).vertical);
}
#[test]
fn area_type_from_a_circle_keeps_glyphs_inside() {
let mut s = session();
+26 -4
View File
@@ -6,6 +6,9 @@
//! 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.
//! While no type is being edited, a press with Shift held makes the new type with the tool's
//! horizontal or vertical counterpart, and a press with Alt (Option) held makes it with Type on a
//! Path in place of Area Type or the reverse ([`TypeTool::acting`]); the pointer shows that tool.
//!
//! While editing: caret movement by character / word (Cmd or Alt) / line (Up/Down) / line ends
//! (Home/End; Cmd = whole text), Shift extends the selection, drag selects, double-click selects a
@@ -136,6 +139,8 @@ pub struct TypeTool {
press: Option<(Point, Point)>,
/// The other corner of an area dragged out (snapped).
drag: Option<Point>,
/// The Type tool the press acts as, with the keys held at the press ([`Self::acting`]).
held: Option<(Mode, bool)>,
/// Smart Guides for where new type goes.
snap: DrawSnap,
/// The press landed in the edited text: dragging selects.
@@ -160,6 +165,22 @@ impl TypeTool {
Self { mode, vertical: id.starts_with("vertical"), ..Default::default() }
}
/// The Type tool (which tool, and whether vertical) that a press with `mods` held acts as.
/// While no type is being edited, Shift switches between the horizontal and the vertical tool,
/// and Alt switches between Area Type and Type on a Path (the Type tool has no counterpart for
/// Alt); both together do both. While type is being edited, it is the tool chosen.
fn acting(&self, mods: Mods) -> (Mode, bool) {
if self.editing.is_some() {
return (self.mode, self.vertical);
}
let mode = match self.mode {
Mode::Area if mods.alt => Mode::OnPath,
Mode::OnPath if mods.alt => Mode::Area,
mode => mode,
};
(mode, self.vertical != mods.shift)
}
fn text<'a>(cx: &'a ToolContext, id: NodeId) -> Option<&'a TextObject> {
match &cx.doc.node(id)?.kind {
NodeKind::Text(t) => Some(t),
@@ -437,6 +458,7 @@ impl TypeTool {
/// edited, a click that lands on no type only ends the editing; the next click makes new type
/// (#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));
// Click into existing text: place the caret.
if let Some(out) = self.edit_at(cx, start) {
return out;
@@ -459,13 +481,13 @@ impl TypeTool {
return out;
}
// Area Type / Type on a Path: click a path.
if self.mode != Mode::Type && drag.is_none() {
let on_path = self.mode == Mode::OnPath;
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": self.vertical, "placeholder": cx.placeholder_text}),
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;
@@ -481,7 +503,7 @@ impl TypeTool {
if let Some(rotation) = vectorcraft_geom::constrain_rotation(cx.constrain_angle) {
params["rotation"] = json!(rotation);
}
params["vertical"] = json!(self.vertical);
params["vertical"] = json!(vertical);
params["placeholder"] = json!(cx.placeholder_text);
out.push(Action::Exec("text.create".into(), params));
out.push(Action::Notify("text.editNew".into()));
+143
View File
@@ -30,6 +30,8 @@ fn key(tool: &mut TypeTool, d: &Document, k: ToolKey, mods: Mods) -> Vec<Action>
const SHIFT: Mods = Mods { shift: true, alt: false, cmd: false, ctrl: false, space: false };
const CMD: Mods = Mods { shift: false, alt: false, cmd: true, ctrl: false, space: false };
const ALT: Mods = Mods { shift: false, alt: true, cmd: false, ctrl: false, space: false };
const SHIFT_ALT: Mods = Mods { shift: true, alt: true, cmd: false, ctrl: false, space: false };
/// Apply the tool's previews like the engine would (on top of the session snapshot).
fn apply(d: &mut Document, snapshot: &Document, acts: &[Action]) {
@@ -473,6 +475,147 @@ fn vertical_tools_preserve_their_ids_and_create_vertical_text() {
}
}
/// A press from `tool` at `from` with `down` held, dragged to `to` (none for a click) and released
/// with `up` held → the actions of the release.
fn gesture(tool: &mut TypeTool, c: &ToolContext, from: (f64, f64), to: Option<(f64, f64)>, down: Mods, up: Mods) -> Vec<Action> {
tool.pointer(c, &PointerEvent::new(PointerKind::Down, from.0, from.1).with_mods(down));
let end = to.unwrap_or(from);
if to.is_some() {
tool.pointer(c, &PointerEvent::new(PointerKind::Drag, end.0, end.1).with_mods(down));
}
tool.pointer(c, &PointerEvent::new(PointerKind::Up, end.0, end.1).with_mods(up))
}
/// The command that makes new type among `acts`, with its params.
fn made(acts: &[Action]) -> Option<(&str, &Value)> {
acts.iter().find_map(|a| match a {
Action::Exec(cmd, p) if cmd == "text.create" || cmd == "text.createInPath" => Some((cmd.as_str(), p)),
_ => None,
})
}
/// [`crate::testutil::doc_with_rect`] plus an open path from (100, 400) to (400, 400).
fn doc_with_rect_and_line() -> (Document, NodeId, NodeId) {
let (mut d, rect) = crate::testutil::doc_with_rect();
let l = d.layers[0].id;
let line = d.alloc_id();
let path = vectorcraft_geom::PathData::from_bezpath(&BezPath::from_vec(vec![
vectorcraft_geom::PathEl::MoveTo(Point::new(100.0, 400.0)),
vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 400.0)),
]));
d.insert(Some(l), 1, Node::path(line, path, vectorcraft_doc::Appearance::default_art())).unwrap();
(d, rect, line)
}
/// Shift held at the press makes the new type as the horizontal or vertical counterpart of each
/// Type tool would, for a click and for a drag; the keys at the release do not matter, and the
/// next press without Shift acts as the tool chosen.
#[test]
fn shift_switches_between_the_horizontal_and_the_vertical_type_tool() {
let (d, rect) = crate::testutil::doc_with_rect();
let (sel, p) = (Selection::default(), paint());
let c = cx(&d, &sel, &p);
for (id, at, cmd) in [
("type", (400.0, 400.0), "text.create"),
("verticalType", (400.0, 400.0), "text.create"),
("areaType", (100.0, 150.0), "text.createInPath"),
("verticalAreaType", (100.0, 150.0), "text.createInPath"),
("typeOnPath", (150.0, 100.0), "text.createInPath"),
("verticalTypeOnPath", (150.0, 100.0), "text.createInPath"),
] {
let vertical = id.starts_with("vertical");
let mut tool = TypeTool::new(id);
let acts = gesture(&mut tool, &c, at, None, SHIFT, Mods::default());
let (made_by, params) = made(&acts).unwrap_or_else(|| panic!("{id}: {acts:?}"));
assert_eq!(made_by, cmd, "{id}");
assert_eq!(params["vertical"], json!(!vertical), "{id} with Shift");
if cmd == "text.createInPath" {
assert_eq!(params["path"], json!(rect.0), "{id}");
assert_eq!(params["mode"], if id.contains("OnPath") { "onPath" } else { "area" }, "{id}");
}
assert_eq!(tool.id(), id, "the tool chosen stays");
let acts = gesture(&mut tool, &c, at, None, Mods::default(), SHIFT);
assert_eq!(made(&acts).map(|(_, p)| p["vertical"].clone()), Some(json!(vertical)), "{id} without Shift at the press");
}
// A frame dragged out with Shift held.
let mut tool = TypeTool::new("type");
let acts = gesture(&mut tool, &c, (300.0, 300.0), Some((400.0, 400.0)), SHIFT, Mods::default());
let (cmd, params) = made(&acts).unwrap_or_else(|| panic!("{acts:?}"));
assert_eq!((cmd, &params["area"], &params["vertical"]), ("text.create", &json!({"width": 100.0, "height": 100.0}), &json!(true)));
}
/// Alt held at the press makes the new type as Type on a Path would with the Area Type tools and
/// as Area Type would with the Type on a Path tools, keeping their writing direction; with Shift
/// too, the direction switches as well. The Type tools have no counterpart for Alt.
#[test]
fn alt_switches_between_area_type_and_type_on_a_path() {
let (d, rect, line) = doc_with_rect_and_line();
let (sel, p) = (Selection::default(), paint());
let c = cx(&d, &sel, &p);
let (on_line, on_rect) = ((250.0, 400.0), (100.0, 150.0));
for (id, at, mods, path, mode, vertical) in [
("areaType", on_line, ALT, line, "onPath", false),
("verticalAreaType", on_line, ALT, line, "onPath", true),
("typeOnPath", on_rect, ALT, rect, "area", false),
("verticalTypeOnPath", on_rect, ALT, rect, "area", true),
("areaType", on_line, SHIFT_ALT, line, "onPath", true),
("verticalTypeOnPath", on_rect, SHIFT_ALT, rect, "area", false),
] {
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} {mods:?}: {acts:?}"));
assert_eq!(cmd, "text.createInPath", "{id} {mods:?}");
assert_eq!((&params["path"], &params["mode"], &params["vertical"]), (&json!(path.0), &json!(mode), &json!(vertical)), "{id} {mods:?}");
assert_eq!(tool.id(), id);
}
// An open path is no frame for the Area Type tool without Alt, and Type on a Path keeps the
// rectangle as a baseline.
let acts = gesture(&mut TypeTool::new("areaType"), &c, on_line, None, Mods::default(), Mods::default());
assert_eq!(made(&acts).map(|(cmd, _)| cmd), Some("text.create"), "{acts:?}");
let acts = gesture(&mut TypeTool::new("typeOnPath"), &c, on_rect, None, Mods::default(), Mods::default());
assert_eq!(made(&acts).map(|(_, p)| p["mode"].clone()), Some(json!("onPath")), "{acts:?}");
for id in ["type", "verticalType"] {
let acts = gesture(&mut TypeTool::new(id), &c, (450.0, 450.0), None, ALT, Mods::default());
let (cmd, params) = made(&acts).unwrap_or_else(|| panic!("{id}: {acts:?}"));
assert_eq!((cmd, &params["vertical"]), ("text.create", &json!(id == "verticalType")), "{id} with Alt");
}
}
/// The pointer shows the Type tool that a press would act as with the keys held, and while a press
/// is held, the one it acts as.
#[test]
fn the_pointer_shows_the_type_tool_shift_or_alt_switches_to() {
let (d, _, _) = doc_with_rect_and_line();
let (sel, p) = (Selection::default(), paint());
let c = cx(&d, &sel, &p);
let (away, on_line) = (Point::new(450.0, 450.0), Point::new(250.0, 400.0));
let cursor = |id: &str, at: Point, mods: Mods| TypeTool::new(id).cursor(&c, at, mods);
assert_eq!(cursor("type", away, SHIFT), Cursor::TypeVertical);
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("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));
assert_eq!(tool.cursor(&c, away, Mods::default()), Cursor::TypeVertical, "the press keeps the tool it acts as");
tool.pointer(&c, &PointerEvent::new(PointerKind::Up, away.x, away.y));
assert_eq!(tool.cursor(&c, away, Mods::default()), Cursor::Text);
}
/// 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]
fn shift_and_alt_switch_no_tool_while_type_is_edited() {
let (d, _, mut tool) = editing("Hello");
let (sel, p) = (Selection::default(), paint());
let c = cx(&d, &sel, &p);
assert_eq!(tool.cursor(&c, Point::new(400.0, 400.0), SHIFT), Cursor::Text);
let acts = gesture(&mut tool, &c, (300.0, 300.0), Some((400.0, 400.0)), SHIFT_ALT, SHIFT_ALT);
let (cmd, params) = made(&acts).unwrap_or_else(|| panic!("{acts:?}"));
assert_eq!((cmd, &params["vertical"]), ("text.create", &json!(false)));
}
#[test]
fn ime_ranges_convert_characters_to_bytes() {
assert_eq!(char_range_to_bytes("ががく", 1..3), Some(3..9));
+10 -3
View File
@@ -54,6 +54,7 @@ impl Tool for TypeTool {
return out;
}
self.press = Some((ev.pos, self.snap.press(cx, ev.pos, self.editing.as_slice(), None)));
self.held = Some(self.acting(ev.mods));
self.drag = None;
vec![]
}
@@ -626,10 +627,16 @@ impl Tool for TypeTool {
}
o
}
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
if self.mode != Mode::Type && Self::path_at(cx, p, self.mode == Mode::Area).is_some() {
/// 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),
};
if mode != Mode::Type && Self::path_at(cx, p, mode == Mode::Area).is_some() {
return Cursor::Crosshair;
}
if self.vertical { Cursor::TypeVertical } else { Cursor::Text }
if vertical { Cursor::TypeVertical } else { Cursor::Text }
}
}
+1
View File
@@ -797,6 +797,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
The Paragraph panel's Bullets button, Type › Bullets and Numbering › Apply Bullets and the context menu's Apply Bullets (`text.setFormat {list, bullet}`, `type.applyBullets`) make paragraphs a bulleted list with the documented presets (none, filled dot, dash, hollow circle, filled square; [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): the bullet starts each paragraph's first line in its first character's font, size and color, the first line's text starts at the next tab stop after the bullet (the left indent counts as a stop), the lines it wraps to start at the left indent, a paragraph with no indents gets 18 pt and -18 pt, and Return continues the list; Reset Panel turns it off, and paragraph styles and the native file keep it. The Bullets and Numbering dialog, levels and Convert to Text are not built yet.
The Paragraph panel's Numbered List button, Type › Bullets and Numbering › Apply Numbers and the context menu's Apply Numbers (`text.setFormat {list, number}`, `type.applyNumbers`) make paragraphs a numbered list with the documented presets (1, A, a, I, i, 01, 001, and None, which turns the list off; [Bullets and numbering](https://web.archive.org/web/20250807202418/https://helpx.adobe.com/illustrator/using/bullets-numbering.html), [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): each paragraph starts with its number and a period, counted through the list paragraphs of the story and its threaded frames, with the text and indents as in a bulleted list; letters go on from Z to AA, and roman numerals go on from III to IV, an empty paragraph is not numbered, and a paragraph in no list starts the count again at 1.
Type › Bullets and Numbering › Convert to Text (`type.convertListToText`, `text.convertListToText`) makes the bullets and numbers of the list paragraphs editable characters of their text ([Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html), [December 2022 release notes](https://web.archive.org/web/20260203094741/https://helpx.adobe.com/illustrator/using/whats-new/2023-1.html)): each marker becomes its characters and a tab at the start of its paragraph, in the marker's font, size and color, the list is turned off, and a left tab stop at the left indent keeps the text after the tab where it started; threaded type keeps the numbers its story counted.
While no type is being edited, a press with Shift held makes new type with the horizontal or vertical counterpart of the Type, Area Type or Type on a Path tool chosen, and a press with Alt (Option) held makes it with Type on a Path in place of Area Type or the reverse, in the chosen tool's writing direction; with both keys held, both switch ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). The pointer shows the tool the press acts as, and the tool chosen stays selected.
The Type tool starts a list from the characters [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html) names (`text.startList`): a space typed on its own after `1.`, `1)`, `A.` or `A)` at the start of a paragraph in no list makes the paragraph a numbered list in 1, 2, 3 or A, B, C, and a space typed after `*`, `#`, `>` or `+` makes it a bulleted list with the filled dot. The typed characters are removed, and one Undo puts them back as text with no list. The same characters later in a paragraph, and pasted list characters, start nothing.
PDF export writes bullets and numbers as text, in the glyphs and at the places the canvas draws them, in area type, type on a path, right-to-left paragraphs and threaded frames, and a PDF viewer selects, copies and finds them; a marker glyph that stands for no character is drawn as a path.
The Character panel menu's Character Direction (with the Indic options; `text.setFormat {charDirection}`, `text.setRangeStyle {charDirection}`) sets the selected characters left to right or right to left inside their paragraph, as a Unicode bidi embedding: a date set Left to Right keeps its order in right-to-left text, and a word set Right to Left keeps the punctuation after it on its left. Default (the default) leaves the characters to the bidi algorithm; character styles and the native file keep the setting.