Merge pull request #4113 from storytold/codex/egui-037-vectorcraft-20261023

Migrate VectorCraft input, text and capture to egui 0.37
This commit is contained in:
Dylan M. Taylor
2026-10-25 14:09:31 -04:00
committed by GitHub
162 changed files with 10301 additions and 1376 deletions
+135 -1
View File
@@ -3,7 +3,7 @@
use serde_json::json;
use vectorcraft_doc::{NodeId, NodeKind, TextKind};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, SurroundingUnit, ToolKey};
use super::*;
@@ -184,3 +184,137 @@ fn discard_empty_command() {
assert_eq!(undo_labels(&s), ["Type", "Rectangle"]);
assert_eq!(texts(&s).len(), 1);
}
/// An input method that replaces text around its composition (macOS press-and-hold/dictation,
/// Wayland text-input-v3): `DeleteSurroundingBytes` counts exact UTF-8 bytes and shares the
/// commit's typing session, so the replacement is one undo step. Never splits a UTF-8 sequence.
#[test]
fn ime_delete_surrounding_bytes_replaces_text_in_one_undo_step() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 100.0, 100.0);
s.tool_text("Héllo", v).unwrap();
let steps = undo_labels(&s).len();
// "llo" is three UTF-8 bytes before the caret; the commit inserts y in the same session.
s.tool_ime_delete_surrounding(3, 0, SurroundingUnit::Bytes, v).unwrap();
s.tool_text("y", v).unwrap();
key(&mut s, ToolKey::Escape);
assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::<Vec<_>>(), ["Héy"]);
assert_eq!(undo_labels(&s).len(), steps + 1, "delete + commit is one typing step");
}
/// Character counts (upstream `DeleteSurrounding`) delete whole scalar values, and a byte count
/// that lands inside a multi-byte character deletes fewer bytes rather than splitting it.
#[test]
fn ime_delete_surrounding_counts_characters_and_respects_utf8_boundaries() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 100.0, 100.0);
s.tool_text("aé", v).unwrap();
// A byte count starting inside é (byte 2 of the text) moves forward to the boundary: nothing
// is deleted rather than splitting the character, and the commit still goes in.
s.tool_ime_delete_surrounding(1, 0, SurroundingUnit::Bytes, v).unwrap();
s.tool_text("!", v).unwrap();
assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::<Vec<_>>(), ["aé!"]);
// One character before the caret removes the whole "!"; é itself is one character too.
s.tool_ime_delete_surrounding(1, 0, SurroundingUnit::Chars, v).unwrap();
s.tool_text("z", v).unwrap();
assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::<Vec<_>>(), ["aéz"]);
}
/// A selection is what an IME's surrounding bytes replace, and bytes after it count too; the
/// commit shares the one typing step.
#[test]
fn ime_delete_surrounding_replaces_a_selection_and_after_bytes() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 100.0, 100.0);
s.tool_text("Hello", v).unwrap();
// Caret before "lo"; Shift+Left selects the first "l".
key(&mut s, ToolKey::Left);
key(&mut s, ToolKey::Left);
s.tool_key(ToolKey::Left, Mods { shift: true, ..Mods::default() }, v).unwrap();
let steps = undo_labels(&s).len();
// One byte before the selection ("e") and one after it ("o") go with it.
s.tool_ime_delete_surrounding(1, 1, SurroundingUnit::Bytes, v).unwrap();
s.tool_text("y", v).unwrap();
key(&mut s, ToolKey::Escape);
assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::<Vec<_>>(), ["Hyo"]);
assert_eq!(undo_labels(&s).len(), steps + 1, "delete + commit is one typing step");
}
/// A pre-edit in the way is replaced by the IME's surrounding deletion, then the commit lands in
/// the same typing step.
#[test]
fn ime_delete_surrounding_replaces_the_preedit_and_commit_in_one_step() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 100.0, 100.0);
s.tool_text("ab", v).unwrap();
s.tool_preedit("xy", None, v).unwrap();
let steps = undo_labels(&s).len();
// Two bytes before the pre-edit: "ab" and the marked "xy" all go, then é is committed.
s.tool_ime_delete_surrounding(2, 0, SurroundingUnit::Bytes, v).unwrap();
s.tool_text("é", v).unwrap();
key(&mut s, ToolKey::Escape);
assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::<Vec<_>>(), ["é"]);
assert_eq!(undo_labels(&s).len(), steps + 1, "delete + commit is one typing step");
}
/// RTL text follows logical UTF-8 byte offsets too: the byte count before the pre-edit deletes
/// the whole preceding Hebrew character (two bytes), never splitting it.
#[test]
fn ime_delete_surrounding_uses_logical_bytes_for_rtl_text() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 100.0, 100.0);
s.tool_text("שלום", v).unwrap();
s.tool_preedit("xy", None, v).unwrap();
s.tool_ime_delete_surrounding(2, 0, SurroundingUnit::Bytes, v).unwrap();
s.tool_text("א", v).unwrap();
key(&mut s, ToolKey::Escape);
assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::<Vec<_>>(), ["שלוא"]);
}
/// The Type tool's IME owner is stable across ordinary edits in one document and changes with the
/// document (a new tab/file has a new process-unique uid).
#[test]
fn ime_target_survives_edits_and_changes_with_the_document() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 100.0, 100.0);
let first = s.tool_ime_target(v).expect("editing has a target");
s.tool_text("A", v).unwrap();
assert_eq!(s.tool_ime_target(v), Some(first), "ordinary typing keeps the owner");
key(&mut s, ToolKey::Left);
assert_eq!(s.tool_ime_target(v), Some(first), "caret moves keep the owner");
key(&mut s, ToolKey::Escape);
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s.select_tool("type", v).unwrap();
click(&mut s, 100.0, 100.0);
let second = s.tool_ime_target(v).expect("the new document's type has a target");
assert_ne!(first.0, second.0, "a document replacement is a new IME owner");
}
/// The outgoing surrounding-text source excludes the pre-edit and reports UTF-8 byte offsets.
#[test]
fn ime_surrounding_excludes_the_preedit_and_remaps_offsets() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 100.0, 100.0);
s.tool_text("Hé", v).unwrap();
s.tool_preedit("xy", None, v).unwrap();
let vectorcraft_tools::ImeSurrounding::Available { text, cursor, anchor } = s.tool_ime_surrounding(4000, v) else {
panic!("type editing has a context");
};
assert_eq!(text, "Hé");
assert_eq!((cursor, anchor), (3, 3), "the caret sits after the committed text, before the pre-edit");
}
+46 -1
View File
@@ -5,7 +5,7 @@ use vectorcraft_doc::Selection;
use vectorcraft_geom::Point;
use vectorcraft_tools::distort::perspective::widget::WidgetPlace;
use vectorcraft_tools::draw2::FEEDBACK;
use vectorcraft_tools::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, settings};
use vectorcraft_tools::{Action, Cursor, ImeSurrounding, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, settings};
use crate::{EngineError, Prefs, Result, Session};
@@ -429,6 +429,20 @@ impl Session {
self.apply_actions(acts)
}
/// Would [`Session::tool_text`] insert `text` unchanged (no control-character filtering,
/// newline normalization or document formatting such as Typographer's Quotes)? An IME commit
/// the application rewrites is an application edit, not an accepted IME insertion.
pub fn tool_text_is_verbatim(&mut self, text: &str, view: ViewInfo) -> bool {
self.with_tool_cx(view, |t, cx| t.text_input_is_verbatim(cx, text))
}
/// Does the active tool's open typing session still match the document? An IME commit after
/// an external edit starts a fresh session (an application-side reset), so its receipt must
/// not be accepted as an unchanged IME insertion.
pub fn tool_ime_session_matches(&mut self, view: ViewInfo) -> bool {
self.with_tool_cx(view, |t, cx| t.ime_session_matches_document(cx))
}
pub fn tool_wants_text(&self) -> bool {
self.tool.wants_text()
}
@@ -441,6 +455,37 @@ impl Session {
self.apply_actions(acts)
}
/// An IME replaces text around its composition before committing: delete `before`/`after`
/// units around the selection or pre-edit. The selection/pre-edit itself is preserved (the
/// following commit replaces it), and the commit shares the typing session, so the replacement
/// is one undo step.
pub fn tool_ime_delete_surrounding(
&mut self,
before: usize,
after: usize,
unit: vectorcraft_tools::SurroundingUnit,
view: ViewInfo,
) -> Result<Vec<UiRequest>> {
let acts = self.with_tool_cx(view, |t, cx| t.ime_delete_surrounding(cx, before, after, unit));
self.take_tool_panic()?;
self.apply_actions(acts)
}
/// The active tool's stable IME owner `(document uid, target id, replacement epoch)`: the
/// document uid changes on document replacement, the epoch when the same target is started
/// afresh. Ordinary edits and caret moves leave both unchanged.
pub fn tool_ime_target(&mut self, view: ViewInfo) -> Option<(u64, u64, u64)> {
self.with_tool_cx(view, |t, cx| t.ime_target(cx))
}
/// The committed logical text around the IME cursor (UTF-8 byte offsets), with any pre-edit
/// removed and at most `max_bytes` bytes copied: the app's outgoing surrounding-text producer
/// (egui E21b) reads it. An explicit [`ImeSurrounding::Unavailable`] carries the narrow reason
/// (no editor, invalid offsets, or a selection wider than the limit).
pub fn tool_ime_surrounding(&mut self, max_bytes: usize, view: ViewInfo) -> ImeSurrounding {
self.with_tool_cx(view, |t, cx| t.ime_surrounding(cx, max_bytes))
}
/// Is the active tool showing uncommitted IME text?
pub fn tool_composing(&self) -> bool {
self.tool.composing()