Files
vectorcraft/crates/engine/src/tests_textedit.rs
T
theironchefandClaude Opus 5.5 2076f82b47 Edit: Undo during typing or a drag takes back only that, and Redo returns it
edit.undo cancelled an open interaction (rolling back the typing session
or drag preview without recording it) and then undid the step before it:
one Undo removed two steps, and the typing could not be redone. Undo now
commits the interaction first, so it is the step taken back and Redo can
restore it; commit_interaction does nothing when nothing is open or
nothing changed.

Fixes #159

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9TrG4w49oSRYbP3cqxA3D
2026-10-05 10:39:53 -07:00

381 lines
17 KiB
Rust

//! Rich text editing: range edit/style commands, the Type tools through the session, area and
//! on-path type from paths, Fit Headline, Convert To Area/Point Type.
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind, TextKind, TextObject};
use vectorcraft_geom::Point;
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn text(s: &mut Session, t: &str) -> NodeId {
let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": t, "size": 20})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
fn obj(s: &Session, id: NodeId) -> TextObject {
match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("not text"),
}
}
fn click(s: &mut Session, x: f64, y: f64) {
let v = ViewInfo::default();
for k in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(k, x, y), v).unwrap();
}
}
fn key(s: &mut Session, k: ToolKey, m: Mods) {
s.tool_key(k, m, ViewInfo::default()).unwrap();
}
fn undo_labels(s: &Session) -> Vec<String> {
s.doc().unwrap().history.undo.iter().map(|e| e.label.clone()).collect()
}
#[test]
fn edit_range_replaces_and_undoes() {
let mut s = session();
let id = text(&mut s, "Hello world");
let r = s.execute("text.editRange", &json!({"id": id.0, "start": 6, "end": 11, "insert": "there"})).unwrap();
assert_eq!(r["caret"], json!(11));
assert_eq!(obj(&s, id).plain_text(), "Hello there");
// Out-of-range and reversed offsets are clamped.
s.execute("text.editRange", &json!({"id": id.0, "start": 99, "end": 5, "insert": "!"})).unwrap();
assert_eq!(obj(&s, id).plain_text(), "Hello!");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(obj(&s, id).plain_text(), "Hello there");
assert!(s.execute("text.editRange", &json!({"start": 0})).is_err(), "id required");
let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
assert!(s.execute("text.editRange", &json!({"id": rect["id"], "insert": "x"})).is_err(), "not text");
}
#[test]
fn set_range_style_splits_runs() {
let mut s = session();
let id = text(&mut s, "Hello brave world");
let w0 = obj(&s, id).cached_bounds.unwrap().width();
let r = s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 6, "end": 11, "size": 40, "fill": "#ff0000", "tracking": 50})).unwrap();
assert_eq!(r["runs"], json!(3));
let t = obj(&s, id);
assert_eq!(t.runs[1].text, "brave");
assert_eq!((t.runs[0].style.size, t.runs[1].style.size, t.runs[2].style.size), (20.0, 40.0, 20.0));
assert_eq!(t.runs[1].style.tracking, 50.0);
assert!(t.cached_bounds.unwrap().width() > w0 * 1.2, "bounds refreshed");
// Kerning / scales / baseline / caps on another range; "auto" leading.
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 5, "kerning": 20, "hScale": 80, "vScale": 120, "baselineShift": 3, "rotation": 370, "allCaps": true, "leading": "auto", "font": "Inter", "strokeWidth": 1})).unwrap();
let st = obj(&s, id).runs[0].style.clone();
assert_eq!((st.kerning, st.h_scale, st.v_scale, st.baseline_shift, st.all_caps), (Some(20.0), 80.0, 120.0, 3.0, true));
assert!((st.rotation - 10.0).abs() < 1e-9);
assert_eq!(st.font_family, "Inter");
// One undo step per command.
assert_eq!(undo_labels(&s).iter().filter(|l| *l == "Character").count(), 2);
assert!(s.execute("text.setRangeStyle", &json!({"id": id.0})).is_err(), "nothing to change");
assert!(s.execute("text.setRangeStyle", &json!({"id": id.0, "size": -1})).is_err());
}
#[test]
fn set_range_style_defaults_to_all_text_and_get_range() {
let mut s = session();
let id = text(&mut s, "abc");
s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 30})).unwrap();
assert_eq!(obj(&s, id).runs.len(), 1);
assert_eq!(obj(&s, id).runs[0].style.size, 30.0);
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 1, "end": 2, "size": 10})).unwrap();
let r = s.execute("text.getRange", &json!({"id": id.0, "start": 0, "end": 2})).unwrap();
assert_eq!(r["text"], json!("ab"));
assert_eq!(r["runs"].as_array().unwrap().len(), 2);
assert_eq!(r["length"], json!(3));
}
#[test]
fn type_tool_typing_is_one_undo_step() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 200.0, 200.0);
let id = s.doc().unwrap().selection.objects[0];
s.tool_text("Hello", v).unwrap();
s.tool_text(" world", v).unwrap();
assert!(s.tool_wants_text());
assert_eq!(obj(&s, id).plain_text(), "Hello world");
key(&mut s, ToolKey::Left, Mods::default());
assert!(!s.in_interaction(), "moving the caret commits");
assert_eq!(undo_labels(&s).last().map(String::as_str), Some("Typing"));
s.tool_text("!", v).unwrap();
key(&mut s, ToolKey::Escape, Mods::default());
assert_eq!(obj(&s, id).plain_text(), "Hello worl!d");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(obj(&s, id).plain_text(), "Hello world");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(obj(&s, id).plain_text(), "");
}
#[test]
fn undo_while_typing_takes_back_only_the_typing_and_redo_returns_it() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 200.0, 200.0);
let id = s.doc().unwrap().selection.objects[0];
s.tool_text("abc", v).unwrap();
key(&mut s, ToolKey::Left, Mods::default());
key(&mut s, ToolKey::Right, Mods::default());
s.tool_text("def", v).unwrap();
assert!(s.in_interaction(), "still typing");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(obj(&s, id).plain_text(), "abc", "only the typing in progress is undone");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(obj(&s, id).plain_text(), "abcdef", "and Redo brings it back");
// Typing on after an undo starts a new session from the restored text.
s.execute("edit.undo", &json!({})).unwrap();
s.tool_text("!", v).unwrap();
assert_eq!(obj(&s, id).plain_text(), "abc!");
}
#[test]
fn undo_during_a_drag_takes_back_only_the_drag() {
let mut s = session();
let v = ViewInfo::default();
let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 50, "height": 50})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.select_tool("selection", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 125.0, 125.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 225.0, 125.0), v).unwrap();
assert!(s.in_interaction(), "dragging");
s.execute("edit.undo", &json!({})).unwrap();
let doc = &s.doc().unwrap().doc;
let b = doc.bounds_of(&[id], false).expect("the rectangle is still there");
assert_eq!(b.x0, 100.0, "back where it was: {b:?}");
}
#[test]
fn type_tool_selection_replace_and_delete() {
let mut s = session();
let v = ViewInfo::default();
let id = text(&mut s, "Hello brave world");
s.select_tool("type", v).unwrap();
// Click at the start of the text to edit it.
let t = obj(&s, id);
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
let (a, b) = vectorcraft_text::caret_position(&lay, 6);
let p = t.xf * a.midpoint(b);
click(&mut s, p.x + 0.1, p.y);
assert!(s.tool_wants_text());
assert_eq!(s.tool_options()["caret"], json!(6));
let shift_word = Mods { shift: true, alt: true, ..Default::default() };
key(&mut s, ToolKey::Right, shift_word);
assert_eq!((s.tool_options()["start"].as_u64(), s.tool_options()["end"].as_u64()), (Some(6), Some(11)));
s.tool_text("bold", v).unwrap();
assert_eq!(obj(&s, id).plain_text(), "Hello bold world");
key(&mut s, ToolKey::Home, Mods { shift: true, ..Default::default() });
key(&mut s, ToolKey::Delete, Mods::default());
assert_eq!(obj(&s, id).plain_text(), " world");
key(&mut s, ToolKey::End, Mods::default());
key(&mut s, ToolKey::Backspace, Mods { alt: true, ..Default::default() });
assert_eq!(obj(&s, id).plain_text(), " ");
}
#[test]
fn character_panel_flow_styles_the_selected_range_while_editing() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 50.0, 300.0);
let id = s.doc().unwrap().selection.objects[0];
s.tool_text("Make this big", v).unwrap();
// Select "this" (still inside the typing session).
s.set_tool_option("select", &json!({"start": 5, "end": 9}));
// What the panel does: end the typing session, then style the range.
s.set_tool_option("commitTyping", &json!(true));
s.commit_interaction().unwrap();
let o = s.tool_options();
s.execute("text.setRangeStyle", &json!({"id": o["editing"], "start": o["start"], "end": o["end"], "size": 48})).unwrap();
let t = obj(&s, id);
assert_eq!(t.runs.len(), 3);
assert_eq!(t.runs[1].text, "this");
assert_eq!(t.runs[1].style.size, 48.0);
// Typing continues in a new session, over the styled selection (keeps its style).
s.tool_text("THAT", v).unwrap();
key(&mut s, ToolKey::Escape, Mods::default());
let t = obj(&s, id);
assert_eq!(t.plain_text(), "Make THAT big");
assert_eq!(t.runs[1].text, "THAT");
assert_eq!(t.runs[1].style.size, 48.0);
let labels = undo_labels(&s);
assert_eq!(&labels[labels.len() - 3..], ["Typing", "Character", "Typing"]);
}
#[test]
fn select_all_and_cut_like_ui() {
let mut s = session();
let v = ViewInfo::default();
let id = text(&mut s, "abc def");
s.select_tool("type", v).unwrap();
let t = obj(&s, id);
let p = t.xf * Point::new(2.0, -5.0);
click(&mut s, p.x, p.y);
s.set_tool_option("selectAll", &json!(true));
let o = s.tool_options();
let len = obj(&s, id).plain_text().len() as u64;
let r = s.execute("text.getRange", &json!({"id": o["editing"], "start": o["start"], "end": o["end"].as_u64().unwrap().min(len)})).unwrap();
assert_eq!(r["text"], json!("abc def"));
s.set_tool_option("copy", &r["runs"]);
key(&mut s, ToolKey::Delete, Mods::default());
assert_eq!(obj(&s, id).plain_text(), "");
s.tool_text("abc def", v).unwrap();
s.tool_text("abc def", v).unwrap();
assert_eq!(obj(&s, id).plain_text(), "abc defabc def");
}
#[test]
fn area_type_from_a_circle_keeps_glyphs_inside() {
let mut s = session();
let e = s.execute("shape.ellipse", &json!({"x": 100, "y": 100, "width": 240, "height": 240})).unwrap();
let pid = e["id"].as_u64().unwrap();
let r = s.execute("text.createInPath", &json!({"path": pid, "mode": "area", "text": vectorcraft_engine_placeholder()})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
assert!(s.doc().unwrap().doc.node(NodeId(pid)).is_none(), "the path became the frame");
let t = obj(&s, id);
assert!(matches!(t.kind, TextKind::Area { .. }));
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
assert!(lay.lines.len() > 5);
let c = Point::new(220.0, 220.0);
for g in lay.glyphs.iter().filter(|g| !g.outline.elements().is_empty()) {
let l = &lay.lines[g.line];
for q in [Point::new(g.origin.x, l.baseline - l.ascent), Point::new(g.origin.x + g.advance, l.baseline + l.descent)] {
assert!((t.xf * q - c).hypot() <= 120.5, "{q:?}");
}
}
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
assert!(s.execute("text.createInPath", &json!({"path": 9999})).is_err());
}
fn vectorcraft_engine_placeholder() -> String {
"Type flows inside any closed shape, line by line, trimmed to the shape's edges at each band. ".repeat(4)
}
#[test]
fn type_on_path_starts_where_clicked() {
let mut s = session();
let r = s
.execute("shape.line", &json!({"x1": 100, "y1": 300, "x2": 500, "y2": 300}))
.or_else(|_| s.execute("path.create", &json!({"points": [[100, 300], [500, 300]], "closed": false})));
let pid = r.unwrap()["id"].as_u64().unwrap();
let r = s.execute("text.createInPath", &json!({"path": pid, "mode": "onPath", "text": "On the line", "at": [200, 310]})).unwrap();
let t = obj(&s, NodeId(r["id"].as_u64().unwrap()));
let TextKind::OnPath { start, .. } = t.kind else { panic!() };
assert!((start - 0.25).abs() < 0.01, "{start}");
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
let g0 = &lay.glyphs[0];
assert!((g0.origin.y - 300.0).abs() < 1e-6 && (g0.origin.x - 200.0).abs() < 1.0, "{:?}", g0.origin);
}
#[test]
fn type_on_path_tool_through_session() {
let mut s = session();
let v = ViewInfo::default();
let e = s.execute("shape.ellipse", &json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap();
s.select_tool("typeOnPath", v).unwrap();
assert_eq!(s.tool_id(), "typeOnPath");
click(&mut s, 300.0, 200.0);
let id = s.doc().unwrap().selection.objects[0];
assert_ne!(id.0, e["id"].as_u64().unwrap());
s.tool_text("Around", v).unwrap();
key(&mut s, ToolKey::Escape, Mods::default());
let t = obj(&s, id);
assert!(matches!(t.kind, TextKind::OnPath { .. }));
assert_eq!(t.plain_text(), "Around");
}
#[test]
fn fit_headline_fills_the_frame_width() {
let mut s = session();
let r = s
.execute(
"text.create",
&json!({"x": 50, "y": 50, "text": "Headline\nBody copy follows here", "size": 24, "area": {"width": 400, "height": 200}}),
)
.unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
let r = s.execute("type.fitHeadline", &json!({"ids": [id.0]})).unwrap();
assert!(r["tracking"][0].as_f64().unwrap() > 100.0, "{r}");
let t = obj(&s, id);
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
let l0 = &lay.lines[0];
assert_eq!(&t.plain_text()[l0.start..l0.end], "Headline", "still one line");
assert!((l0.x1 - l0.x0 - 400.0).abs() < 1.0, "{}", l0.x1 - l0.x0);
// The body keeps its tracking.
assert_eq!(t.runs.last().unwrap().style.tracking, 0.0);
let p = s.execute("text.create", &json!({"x": 1, "y": 1, "text": "point"})).unwrap();
assert!(s.execute("type.fitHeadline", &json!({"ids": [p["id"]]})).is_err(), "area type only");
}
fn first_glyph_doc_pos(t: &TextObject) -> Point {
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
t.xf * lay.glyphs[0].origin
}
#[test]
fn convert_area_point_keeps_text_in_place_for_every_alignment() {
for j in ["left", "center", "right"] {
let mut s = session();
let r = s
.execute(
"text.create",
&json!({"x": 100, "y": 100, "text": "Centered words wrap in a frame", "size": 18, "area": {"width": 150, "height": 200}}),
)
.unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("text.setStyle", &json!({"ids": [id.0], "justify": j})).unwrap();
let before = first_glyph_doc_pos(&obj(&s, id));
s.execute("type.convertToPointType", &json!({"ids": [id.0]})).unwrap();
let t = obj(&s, id);
assert!(matches!(t.kind, TextKind::Point));
let after = first_glyph_doc_pos(&t);
assert!((after - before).hypot() < 0.01, "{j}: {before:?} → {after:?}");
s.execute("type.convertToAreaType", &json!({"ids": [id.0]})).unwrap();
let t = obj(&s, id);
let back = first_glyph_doc_pos(&t);
assert!((back - before).hypot() < 0.01, "{j} (area again): {before:?} → {back:?}");
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
assert!(!lay.overflow);
}
}
#[test]
fn commands_are_listed_with_params() {
let s = session();
let cmds: Vec<Value> = s.commands().iter().map(|c| serde_json::to_value(c).unwrap()).collect();
for id in ["text.editRange", "text.setRangeStyle", "text.getRange", "text.createInPath", "type.fitHeadline"] {
assert!(cmds.iter().any(|c| c["id"] == id), "{id}");
}
}
#[test]
fn type_tool_state_does_not_leak_across_documents() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 200.0, 200.0);
let id = s.doc().unwrap().selection.objects[0];
s.tool_text("First", v).unwrap();
// A second document whose first object reuses the same id.
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
let id2 = text(&mut s, "Other");
assert!(!s.tool_wants_text(), "switching documents ends the edit");
s.tool_text("XYZ", v).unwrap();
assert_eq!(obj(&s, id2).plain_text(), "Other");
s.set_active(0);
assert_eq!(obj(&s, id).plain_text(), "First");
assert!(!s.in_interaction());
}