mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 06:07:06 -06:00
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:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user