Files
vectorcraft/crates/engine/src/tests_textedit.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 <[email protected]>
2026-10-06 21:41:50 +09:00

492 lines
22 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 ime_composition_shows_in_place_and_commits_as_one_step() {
// Romaji → kana → conversion → commit, as the macOS Japanese IME sends 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];
let created = undo_labels(&s).len();
s.tool_text("曲:", v).unwrap();
for (t, r) in [("g", 1..1), ("が", 1..1), ("がg", 2..2), ("ががく", 3..3)] {
s.tool_preedit(t, Some(r), v).unwrap();
assert_eq!(obj(&s, id).plain_text(), format!("曲:{t}"), "marked text lays out in place");
assert!(s.tool_composing());
}
// Conversion: the active clause is the whole word.
s.tool_preedit("雅楽", Some(0..2), v).unwrap();
assert_eq!(obj(&s, id).plain_text(), "曲:雅楽");
assert_eq!(s.tool_options()["composing"], json!(true));
// macOS clears the marked text, then commits.
s.tool_preedit("", None, v).unwrap();
s.tool_text("雅楽", v).unwrap();
assert!(!s.tool_composing());
assert_eq!(obj(&s, id).plain_text(), "曲:雅楽");
assert_eq!(s.tool_options()["caret"], json!("曲:雅楽".len()));
// Typing goes on after the commit, in the same session.
s.tool_text("。", v).unwrap();
key(&mut s, ToolKey::Escape, Mods::default());
assert_eq!(obj(&s, id).plain_text(), "曲:雅楽。");
assert_eq!(undo_labels(&s).len(), created + 1, "one undo step for the session");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(obj(&s, id).plain_text(), "");
}
#[test]
fn ime_cancel_leaves_no_undo_step_and_keeps_the_text() {
let mut s = session();
let v = ViewInfo::default();
let id = text(&mut s, "雅楽");
s.select_tool("type", v).unwrap();
let t = obj(&s, id);
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
let (a, b) = vectorcraft_text::caret_position(&lay, "雅楽".len());
let p = t.xf * a.midpoint(b);
click(&mut s, p.x - 0.1, p.y);
assert!(s.tool_wants_text());
s.set_tool_option("select", &json!({"start": 6, "end": 6}));
let before = undo_labels(&s).len();
s.tool_preedit("えんそう", Some(4..4), v).unwrap();
assert_eq!(obj(&s, id).plain_text(), "雅楽えんそう");
// Escape in the IME: the marked text goes, nothing else changes.
s.tool_preedit("", None, v).unwrap();
assert!(!s.tool_composing());
assert!(!s.in_interaction(), "a cancelled composition leaves no typing session");
assert_eq!(obj(&s, id).plain_text(), "雅楽");
assert_eq!(undo_labels(&s).len(), before);
// A stray clear (no composition) never deletes the selection.
s.set_tool_option("select", &json!({"start": 0, "end": 3}));
s.tool_preedit("", None, v).unwrap();
assert_eq!(obj(&s, id).plain_text(), "雅楽");
// A composition over a selection replaces it.
s.tool_preedit("が", Some(1..1), v).unwrap();
s.tool_preedit("", None, v).unwrap();
s.tool_text("我", v).unwrap();
assert_eq!(obj(&s, id).plain_text(), "我楽");
}
#[test]
fn clicking_away_keeps_marked_text_as_typed_and_undo_mid_composition_is_safe() {
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_preedit("しょうこ", Some(4..4), v).unwrap();
// Keys belong to the IME while it composes.
key(&mut s, ToolKey::Backspace, Mods::default());
assert_eq!(obj(&s, id).plain_text(), "しょうこ");
click(&mut s, 600.0, 500.0);
assert!(!s.tool_composing());
assert_eq!(obj(&s, id).plain_text(), "しょうこ", "the marked text stays, committed");
// The UI holds Undo back while composing; if it comes anyway, the session is committed first.
click(&mut s, 50.0, 50.0);
let id2 = s.doc().unwrap().selection.objects[0];
s.tool_preedit("ひちりき", Some(4..4), v).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(!s.tool_composing());
assert_eq!(obj(&s, id2).plain_text(), "");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(obj(&s, id2).plain_text(), "ひちりき");
}
#[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());
}
#[test]
fn vertical_text_creation_orientation_and_persistence() {
let mut s = session();
let r = s.execute("text.create", &json!({"x":100,"y":100,"text":"日本語", "vertical":true})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
let t = obj(&s, id);
assert!(t.vertical);
let round: TextObject = serde_json::from_value(serde_json::to_value(&t).unwrap()).unwrap();
assert!(round.vertical);
s.execute("type.orientation.horizontal", &json!({"id":id.0})).unwrap();
assert!(!obj(&s, id).vertical);
s.execute("edit.undo", &json!({})).unwrap();
assert!(obj(&s, id).vertical);
let old = serde_json::to_value(TextObject::point(Point::ZERO, "old", Default::default())).unwrap();
assert!(old.get("vertical").is_none());
let old: TextObject = serde_json::from_value(old).unwrap();
assert!(!old.vertical);
}