Files
vectorcraft/crates/tools/src/text_tests.rs
T
imachusandClaude Opus 5.5 66fa0a998a Type: input method composition (Japanese IME) in the Type tool
Typing with an input method (the macOS Japanese IME and the like) used to
insert the raw keys (romaji came in as ASCII): egui's Ime events weren't
handled.

- Tools gain `ime_preedit` / `composing` / `ime_caret`; the Type tool lays
  the marked text out in place inside its typing session, underlined (the
  clause being converted thicker), and commits it through `text_input`.
  Character ranges from the IME convert to byte ranges in one place. A
  cancelled composition leaves no empty undo step; an empty clear with
  nothing marked never deletes the selection.
- The UI sends Text and Ime events to the tool in order, sends IMEOutput
  every frame while text is edited (what lets egui-winit enable the IME
  and place its candidate window at the caret, or the converting clause),
  and holds back keys, Enter/Escape, clipboard, single-key shortcuts and
  menu commands (Undo, Cut, Select All… also from the native menu) while
  composing. A click, a tool switch or a style change ends the
  composition with the marked text kept as typed; on macOS the IME is
  told to discard it (NSTextInputContext.discardMarkedText) so it doesn't
  come back in the next input.
- Undo during a typing session also ends the Type tool's session, so its
  caret and marked text follow the document.
- Tests: tools (char → byte ranges, underlines and candidate position),
  engine (compose → commit is one step, cancel leaves no step, a click
  commits, Undo mid-composition is safe), ui-egui (synthetic Ime events:
  compose, commit, ⌘Z held back, IME output, click and app switch).

Checked by hand on macOS 27 with the Japanese IME (romaji input, convert,
commit, cancel, Undo/Redo after commit, click away, Latin mode, app
switch). Not checked: Windows and Linux IMEs, the web build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:41:50 +09:00

276 lines
11 KiB
Rust

//! Type tool state-machine tests (the engine side is covered in vectorcraft-engine's
//! `tests_textedit`).
use super::*;
use crate::testutil::{cx, paint};
use vectorcraft_doc::{CharStyle, Document, Node, Selection};
fn doc_with_text(text: &str) -> (Document, NodeId) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let id = d.alloc_id();
let t = TextObject::point(Point::new(100.0, 100.0), text, CharStyle { size: 20.0, ..Default::default() });
d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap();
(d, id)
}
fn editing(text: &str) -> (Document, NodeId, TypeTool) {
let (d, id) = doc_with_text(text);
let mut tool = TypeTool::new("type");
tool.start_editing(id, 0);
(d, id, tool)
}
fn key(tool: &mut TypeTool, d: &Document, k: ToolKey, mods: Mods) -> Vec<Action> {
let (sel, p) = (Selection::default(), paint());
tool.key(&cx(d, &sel, &p), k, mods)
}
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 };
/// Apply the tool's previews like the engine would (on top of the session snapshot).
fn apply(d: &mut Document, snapshot: &Document, acts: &[Action]) {
for a in acts {
if let Action::Preview(cmd, p) = a {
assert_eq!(cmd, "text.editRange");
*d = snapshot.clone();
let id = NodeId(p["id"].as_u64().unwrap());
let runs: Vec<TextRun> = serde_json::from_value(p["runs"].clone()).unwrap();
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
edit::replace_range_styled(&mut t.runs, p["start"].as_u64().unwrap() as usize, p["end"].as_u64().unwrap() as usize, &runs);
}
}
}
}
#[test]
fn typing_is_one_coalesced_preview() {
let (mut d, id, mut tool) = editing("Hello");
let snap = d.clone();
let (sel, p) = (Selection::default(), paint());
tool.caret = 5;
tool.anchor = 5;
let a1 = tool.text_input(&cx(&d, &sel, &p), " w");
assert_eq!(a1[0], Action::Begin("Typing".into()));
apply(&mut d, &snap, &a1);
let a2 = tool.text_input(&cx(&d, &sel, &p), "orld");
apply(&mut d, &snap, &a2);
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() };
assert_eq!(t.plain_text(), "Hello world");
assert!(!a2.iter().any(|a| matches!(a, Action::Begin(_))), "same session");
let Action::Preview(cmd, params) = a2.last().unwrap() else { panic!("{a2:?}") };
assert_eq!(cmd, "text.editRange");
assert_eq!(params["id"], json!(id.0));
assert_eq!((params["start"].as_u64(), params["end"].as_u64()), (Some(5), Some(5)));
let runs: Vec<TextRun> = serde_json::from_value(params["runs"].clone()).unwrap();
assert_eq!(runs[0].text, " world");
assert_eq!(tool.caret, 11);
// Moving the caret ends the session.
let a3 = key(&mut tool, &d, ToolKey::Left, Mods::default());
assert_eq!(a3, vec![Action::Commit]);
assert_eq!(tool.caret, 10);
// A document change behind the tool's back (undo) starts a fresh session.
let a4 = tool.text_input(&cx(&snap, &sel, &p), "!");
assert!(a4.contains(&Action::Begin("Typing".into())));
}
#[test]
fn shift_arrows_select_and_typing_replaces() {
let (d, _, mut tool) = editing("Hello world");
key(&mut tool, &d, ToolKey::End, Mods::default());
assert_eq!(tool.caret, 11);
key(&mut tool, &d, ToolKey::Left, Mods { alt: true, ..SHIFT });
assert_eq!(tool.sel(), (6, 11));
let (sel, p) = (Selection::default(), paint());
let acts = tool.text_input(&cx(&d, &sel, &p), "there");
let Action::Preview(_, params) = acts.last().unwrap() else { panic!() };
assert_eq!((params["start"].as_u64(), params["end"].as_u64()), (Some(6), Some(11)));
assert_eq!(tool.caret, 11);
assert_eq!(tool.sel(), (11, 11));
}
#[test]
fn word_and_line_navigation_keys() {
let (d, _, mut tool) = editing("One two three");
key(&mut tool, &d, ToolKey::Right, CMD);
assert_eq!(tool.caret, 3);
key(&mut tool, &d, ToolKey::Right, CMD);
assert_eq!(tool.caret, 7);
key(&mut tool, &d, ToolKey::Left, CMD);
assert_eq!(tool.caret, 4);
key(&mut tool, &d, ToolKey::End, SHIFT);
assert_eq!(tool.sel(), (4, 13));
// Left collapses a selection to its start.
key(&mut tool, &d, ToolKey::Left, Mods::default());
assert_eq!(tool.sel(), (4, 4));
key(&mut tool, &d, ToolKey::Home, Mods::default());
assert_eq!(tool.caret, 0);
key(&mut tool, &d, ToolKey::End, CMD);
assert_eq!(tool.caret, 13);
}
#[test]
fn up_down_move_between_lines() {
let (d, _, mut tool) = editing("First line\nSecond line");
tool.caret = 3;
tool.anchor = 3;
key(&mut tool, &d, ToolKey::Down, Mods::default());
assert!(tool.caret > 11 && tool.caret < 16, "{}", tool.caret);
key(&mut tool, &d, ToolKey::Up, Mods::default());
assert_eq!(tool.caret, 3, "goal column kept");
key(&mut tool, &d, ToolKey::Down, SHIFT);
key(&mut tool, &d, ToolKey::Down, SHIFT);
assert_eq!(tool.sel(), (3, 22));
}
#[test]
fn backspace_deletes_selection_or_word() {
let (d, _, mut tool) = editing("Hello big world");
tool.caret = 9;
tool.anchor = 9;
let acts = key(&mut tool, &d, ToolKey::Backspace, Mods { alt: true, ..Default::default() });
let Action::Preview(_, params) = acts.last().unwrap() else { panic!() };
assert_eq!((params["start"].as_u64(), params["end"].as_u64()), (Some(6), Some(9)));
assert_eq!(tool.caret, 6);
}
#[test]
fn double_and_triple_click_select_word_and_paragraph() {
let (d, id, mut tool) = editing("Alpha beta\ngamma");
let (sel, p) = (Selection::default(), paint());
let t = TypeTool::text(&cx(&d, &sel, &p), id).unwrap().clone();
let lay = vectorcraft_text::layout(FontDb::global(), &t);
let (top, bot) = vectorcraft_text::caret_position(&lay, 7);
let at = t.xf * top.midpoint(bot);
let c = cx(&d, &sel, &p);
for k in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick] {
tool.pointer(&c, &PointerEvent::new(k, at.x, at.y));
}
assert_eq!(tool.sel(), (6, 10), "word");
tool.pointer(&c, &PointerEvent::new(PointerKind::Down, at.x, at.y));
tool.pointer(&c, &PointerEvent::new(PointerKind::Up, at.x, at.y));
assert_eq!(tool.sel(), (0, 10), "paragraph");
}
#[test]
fn drag_selects_and_overlay_highlights() {
let (d, id, mut tool) = editing("Select me please");
let (sel, p) = (Selection::default(), paint());
let c = cx(&d, &sel, &p);
let t = TypeTool::text(&c, id).unwrap().clone();
let lay = vectorcraft_text::layout(FontDb::global(), &t);
let pt = |b: usize| {
let (a, bb) = vectorcraft_text::caret_position(&lay, b);
t.xf * a.midpoint(bb)
};
let (a, b) = (pt(7), pt(9));
tool.pointer(&c, &PointerEvent::new(PointerKind::Down, a.x + 0.1, a.y));
assert!(tool.busy());
tool.pointer(&c, &PointerEvent::new(PointerKind::Drag, b.x + 0.1, b.y));
tool.pointer(&c, &PointerEvent::new(PointerKind::Up, b.x + 0.1, b.y));
assert_eq!(tool.sel(), (7, 9));
let o = tool.overlays(&c);
assert!(o.iter().any(|o| matches!(o, Overlay::Highlight { .. })));
let opts = tool.options();
assert_eq!((opts["start"].as_u64(), opts["end"].as_u64()), (Some(7), Some(9)));
}
#[test]
fn select_all_and_styled_paste() {
let (d, _, mut tool) = editing("abc");
tool.set_option("selectAll", &json!(true));
let (sel, p) = (Selection::default(), paint());
let c = cx(&d, &sel, &p);
tool.key(&c, ToolKey::Right, SHIFT);
assert_eq!(tool.sel(), (0, 3));
let big = TextRun { text: "XY".into(), style: CharStyle { size: 40.0, ..Default::default() } };
tool.set_option("copy", &serde_json::to_value(vec![big.clone()]).unwrap());
let acts = tool.text_input(&c, "XY");
let Action::Preview(_, params) = acts.last().unwrap() else { panic!() };
let runs: Vec<TextRun> = serde_json::from_value(params["runs"].clone()).unwrap();
assert_eq!(runs, vec![big]);
}
#[test]
fn common_affixes_are_char_safe() {
assert_eq!(common_affixes("hello", "help"), (3, 0));
assert_eq!(common_affixes("aXb", "ab"), (1, 1));
assert_eq!(common_affixes("aaa", "aaaa"), (3, 0));
assert_eq!(common_affixes("é1", "è1"), (0, 1));
}
#[test]
fn area_and_path_tools_convert_clicked_paths() {
let (d, rect) = crate::testutil::doc_with_rect();
let (sel, p) = (Selection::default(), paint());
let c = cx(&d, &sel, &p);
let mut tool = TypeTool::new("areaType");
assert_eq!(tool.id(), "areaType");
tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 150.0));
let acts = tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 100.0, 150.0));
assert!(
acts.iter().any(|a| matches!(a, Action::Exec(cmd, p) if cmd == "text.createInPath" && p["path"] == json!(rect.0) && p["mode"] == "area")),
"{acts:?}"
);
let mut tool = TypeTool::new("typeOnPath");
tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 100.0));
let acts = tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 150.0, 100.0));
assert!(acts.iter().any(|a| matches!(a, Action::Exec(cmd, p) if cmd == "text.createInPath" && p["mode"] == "onPath")), "{acts:?}");
}
#[test]
fn vertical_tools_preserve_their_ids_and_create_vertical_text() {
for id in ["verticalType", "verticalAreaType", "verticalTypeOnPath"] {
let tool = TypeTool::new(id);
assert_eq!(tool.id(), id);
assert!(tool.vertical);
}
}
#[test]
fn ime_ranges_convert_characters_to_bytes() {
assert_eq!(char_range_to_bytes("ががく", 1..3), Some(3..9));
assert_eq!(char_range_to_bytes("ががく", 3..3), Some(9..9));
assert_eq!(char_range_to_bytes("a雅b", 1..2), Some(1..4));
assert_eq!(char_range_to_bytes("ががく", 2..4), None, "past the end");
#[allow(clippy::reversed_empty_ranges)]
let backwards = 2..1;
assert_eq!(char_range_to_bytes("ががく", backwards), None, "backwards");
}
#[test]
fn marked_text_is_underlined_with_the_converting_clause_thick() {
let (mut d, id, mut tool) = editing("曲");
let snap = d.clone();
let (sel, p) = (Selection::default(), paint());
tool.caret = 3;
tool.anchor = 3;
let acts = tool.ime_preedit(&cx(&d, &sel, &p), "雅楽演奏", Some(2..4));
apply(&mut d, &snap, &acts);
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() };
assert_eq!(t.plain_text(), "曲雅楽演奏");
assert_eq!(tool.preedit, Some(Preedit { range: 3..15, active: Some(6..12) }));
let widths: Vec<f32> = tool
.overlays(&cx(&d, &sel, &p))
.iter()
.filter_map(|o| match o {
Overlay::Path { width, .. } => Some(*width),
_ => None,
})
.collect();
assert_eq!(widths, vec![1.0, 2.5], "a thin underline, then the thick one under 演奏");
// The candidate window follows the converting clause.
let (top, _) = tool.ime_caret(&cx(&d, &sel, &p)).unwrap();
let lay = vectorcraft_text::layout(FontDb::global(), t);
let (at, _) = vectorcraft_text::caret_position(&lay, 9);
assert!((top - t.xf * at).hypot() < 1e-6);
// Keys wait for the IME; a commit replaces the marked text.
assert!(key(&mut tool, &d, ToolKey::Backspace, Mods::default()).is_empty());
let acts = tool.text_input(&cx(&d, &sel, &p), "雅楽演奏会");
apply(&mut d, &snap, &acts);
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() };
assert_eq!(t.plain_text(), "曲雅楽演奏会");
assert!(!tool.composing());
}