Files
vectorcraft/crates/tools/src/text_tests.rs
T
bflatasticandClaude Opus 5.5 4eac7b505f Fix #506: drawing tools snap to Smart Guides as the Selection tool does
Every drawing tool now asks one snapper (guides::DrawSnap) for the points it
places: the Pen's and Curvature tool's anchors, the Pencil's ends, the shape
tools' start and dragged corner or radius (Rectangle, Rounded Rectangle,
Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's
click and frame and a new artboard's corners. Points land on other objects'
anchors, centres and paths ("anchor", "center", "path") or line up with their
anchors, edges and centres ("align"); hovering shows it before the press, the
Pen's guides end its rubber band, and the Pen lands on the path being drawn
too. Shift keeps a point to its 45-degree step (from Constrain Angle) or a
box's diagonal and slides it along that way into line with a target.
Preferences > Smart Guides > Construction Guides and Angles now act: a drawn
point near a guide through the point it leaves from lands on it.

Before, the Pen snapped only its press and showed nothing, and the shape tools
re-gathered targets on every drag step from the document showing their own
preview, so the dragged corner stuck to where the last step left it.

Performance: targets are gathered once per document state (keyed on the
document's uid and revision, now in ToolContext), from the art in the window,
and sorted, so the anchors, centres and lines near the pointer are found by
bisection. Leaving objects out of the targets no longer looks up each node's
ancestry (quadratic in the document's size): one pruned walk does it, which
also speeds up the Selection tool's moves and resizes.

The direction handles' HandleSnap (#494) is now a DrawSnap holding its own
targets (Targets::for_handle) for the drag: one cache, one lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:20:27 +07:00

359 lines
16 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 a_drag_across_type_not_being_edited_selects_its_text() {
let (d, id) = doc_with_text("Select me please");
let mut tool = TypeTool::new("type");
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));
// The press starts editing the type and the drag selects, in one gesture.
let out = tool.pointer(&c, &PointerEvent::new(PointerKind::Down, a.x + 0.1, a.y));
assert!(out.contains(&Action::Exec("select.set".into(), json!({"ids": [id.0]}))));
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.editing, Some(id));
assert_eq!(tool.sel(), (7, 9));
// A plain click still just places the caret.
let mut tool = TypeTool::new("type");
tool.pointer(&c, &PointerEvent::new(PointerKind::Down, a.x + 0.1, a.y));
tool.pointer(&c, &PointerEvent::new(PointerKind::Up, a.x + 0.1, a.y));
assert_eq!((tool.editing, tool.sel()), (Some(id), (7, 7)));
}
#[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());
}
#[test]
fn arrows_follow_the_columns_of_vertical_type() {
let (mut d, id) = doc_with_text("§§§\n§§§");
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
t.vertical = true;
}
let mut tool = TypeTool::new("type");
tool.start_editing(id, 0);
let s = "§".len();
key(&mut tool, &d, ToolKey::Down, Mods::default());
assert_eq!(tool.caret, s, "↓ goes down the column to the next character");
key(&mut tool, &d, ToolKey::Left, Mods::default());
assert_eq!(tool.caret, "§§§\n§".len(), "← goes on to the next column, keeping the place in it");
key(&mut tool, &d, ToolKey::Right, Mods::default());
assert_eq!(tool.caret, s, "→ comes back");
key(&mut tool, &d, ToolKey::Up, Mods::default());
assert_eq!(tool.caret, 0);
}
#[test]
fn hebrew_typing_and_visual_arrows_keep_logical_text() {
let (mut d, id, mut tool) = editing("");
let snap = d.clone();
let (sel, p) = (Selection::default(), paint());
let actions = tool.text_input(&cx(&d, &sel, &p), "שלום");
apply(&mut d, &snap, &actions);
assert_eq!(tool.caret, "שלום".len());
key(&mut tool, &d, ToolKey::Right, Mods::default());
assert_eq!(tool.caret, 6, "Right moves toward the logical start in Hebrew");
key(&mut tool, &d, ToolKey::Left, Mods::default());
assert_eq!(tool.caret, 8);
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() };
assert_eq!(t.plain_text(), "שלום");
}
/// Where new type goes snaps to Smart Guides (#506): a click beside a rect lines up with its
/// centre, and hovering shows it first; an area's corners land on its anchors.
#[test]
fn new_type_snaps_to_smart_guides() {
let (d, _) = crate::testutil::doc_with_rect();
let (sel, p) = (Selection::default(), paint());
let c = cx(&d, &sel, &p);
let mut tool = TypeTool::new("type");
tool.pointer(&c, &PointerEvent::new(PointerKind::Move, 151.0, 330.0));
assert!(tool.overlays(&c).iter().any(|o| matches!(o, Overlay::Label { text, p, .. } if text == "align" && *p == Point::new(150.0, 330.0))));
tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 151.0, 330.0));
let acts = tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 151.0, 330.0));
assert!(acts.iter().any(|a| matches!(a, Action::Exec(cmd, v) if cmd == "text.create" && v["x"] == 150.0 && v["y"] == 330.0)), "{acts:?}");
let mut tool = TypeTool::new("type");
tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 102.0, 98.0));
tool.pointer(&c, &PointerEvent::new(PointerKind::Drag, 199.0, 202.0));
let acts = tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 199.0, 202.0));
let area = json!({"width": 100.0, "height": 100.0});
assert!(acts.iter().any(|a| matches!(a, Action::Exec(cmd, v) if cmd == "text.create" && v["x"] == 100.0 && v["area"] == area)), "{acts:?}");
}