M0: preserve IME typing sessions and simplify input handling

This commit is contained in:
skylartaylor
2026-10-25 08:14:45 +01:00
parent 340e3495d8
commit 349dafc0bd
4 changed files with 333 additions and 236 deletions
+100 -83
View File
@@ -21,7 +21,7 @@
//! underlined, until the IME commits it (it then goes through [`Tool::text_input`]) or clears it.
use std::ops::Range;
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, PoisonError};
use serde_json::{Value, json};
use vectorcraft_doc::hit::hit_test;
@@ -74,7 +74,7 @@ type CachedLayout = ((u64, TextObject), Arc<TextLayout>);
static NEXT_EDIT_SESSION: Mutex<u64> = Mutex::new(0);
fn next_edit_session() -> u64 {
let mut next = NEXT_EDIT_SESSION.lock().unwrap_or_else(|e| e.into_inner());
let mut next = NEXT_EDIT_SESSION.lock().unwrap_or_else(PoisonError::into_inner);
let session = next.checked_add(1).unwrap_or(u64::MAX);
*next = session;
session
@@ -103,8 +103,8 @@ pub struct TypeTool {
typing: Option<Typing>,
preedit: Option<Preedit>,
/// A command from outside the tool (`settle_interaction`, an external edit while typing) ended
/// the typing session, while the text stays open for editing. The next IME commit then starts
/// a fresh session over that edit, so it is application-origin, never `InputMethod`.
/// the typing session, while the text stays open for editing. A commit over that edit is
/// application-origin unless an IME deletion has already begun a fresh typing session.
typing_ended: bool,
/// Where the button went down, and where new type goes from there (snapped to Smart Guides).
press: Option<(Point, Point)>,
@@ -434,6 +434,7 @@ impl TypeTool {
}
if self.typing.is_none() {
out.push(Action::Begin("Typing".into()));
self.typing_ended = false;
}
let len = match &self.typing {
Some(ty) => edit::runs_len(&ty.cur),
@@ -928,15 +929,18 @@ impl Tool for TypeTool {
if self.editing.is_none() {
return true;
}
let normalized: String =
s.chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).map(|c| if c == '\r' { '\n' } else { c }).collect();
if normalized != s {
return false;
}
if normalized.is_empty() {
if s.is_empty() {
return true;
}
if cx.doc.setup.typographers_quotes && matches!(normalized.as_str(), "\"" | "'") {
// A straight quote's direction depends on the character before the insertion. During a
// commit this check still sees the pre-edit at the caret, while `text_input` replaces it
// from its start; with a custom pair whose opening quote is straight, that can look
// verbatim here and then be rewritten. Never claim verbatim for one.
if cx.doc.setup.typographers_quotes && matches!(s, "\"" | "'") {
return false;
}
let Some(normalized) = self.input_text(cx, s) else { return false };
if normalized != s {
return false;
}
let clip: String = self.clipboard.iter().map(|r| r.text.as_str()).collect();
@@ -1211,78 +1215,7 @@ impl Tool for TypeTool {
}
None => None,
};
let removed = preedit.as_ref().map_or(0, |r| r.end - r.start);
let logical_len = len - removed;
let to_logical = |byte: usize| -> usize {
match &preedit {
Some(r) if byte > r.start && byte < r.end => r.start,
Some(r) if byte >= r.end => byte - removed,
_ => byte,
}
};
let cursor = to_logical(caret);
let anchor = to_logical(anchor);
let selection_start = cursor.min(anchor);
let selection_end = cursor.max(anchor);
let selection_len = selection_end - selection_start;
if selection_len > max_bytes {
return ImeSurrounding::Unavailable("the selection is wider than the IME surrounding-text limit");
}
let budget = max_bytes - selection_len;
let before_available = selection_start;
let after_available = logical_len - selection_end;
let mut before = (budget / 2).min(before_available);
let mut after = (budget - before).min(after_available);
before = (budget - after).min(before_available);
after = (budget - before).min(after_available);
let mut window_start = selection_start - before;
let mut window_end = selection_end + after;
let logical_boundary = |byte: usize| -> bool {
match &preedit {
Some(r) if byte == r.start => true,
Some(r) if byte < r.start => plain_is_boundary(&segments, byte),
Some(_) => plain_is_boundary(&segments, byte + removed),
None => plain_is_boundary(&segments, byte),
}
};
while window_start < selection_start && !logical_boundary(window_start) {
window_start += 1;
}
while selection_end < window_end && !logical_boundary(window_end) {
window_end -= 1;
}
let to_original = |byte: usize| -> usize {
match &preedit {
Some(r) if byte > r.start => byte + removed,
_ => byte,
}
};
let original_start = to_original(window_start);
let original_end = to_original(window_end);
let mut text = String::with_capacity(window_end - window_start);
match &preedit {
Some(r) => {
if window_start < r.start {
let first_end = original_end.min(r.start);
if original_start < first_end {
copy_plain_range(&segments, original_start, first_end, &mut text);
}
}
if r.start < window_end {
let second_start = r.end.max(original_start);
if second_start < original_end {
copy_plain_range(&segments, second_start, original_end, &mut text);
}
}
}
None => {
if original_start < original_end {
copy_plain_range(&segments, original_start, original_end, &mut text);
}
}
}
debug_assert_eq!(text.len(), window_end - window_start, "removing the pre-edit must preserve the logical window length");
ImeSurrounding::Available { text, cursor: cursor - window_start, anchor: anchor - window_start }
ime_surrounding_window(&segments, preedit, len, caret, anchor, max_bytes)
}
fn composing(&self) -> bool {
self.preedit.is_some()
@@ -1485,6 +1418,90 @@ impl Tool for TypeTool {
}
}
/// The logical IME surrounding window: the pre-edit is removed and the caret/anchor offsets
/// are remapped around it. `preedit` has already been validated against `segments`.
fn ime_surrounding_window(
segments: &[&str],
preedit: Option<Range<usize>>,
len: usize,
caret: usize,
anchor: usize,
max_bytes: usize,
) -> ImeSurrounding {
let removed = preedit.as_ref().map_or(0, |r| r.end - r.start);
let logical_len = len - removed;
let to_logical = |byte: usize| -> usize {
match &preedit {
Some(r) if byte > r.start && byte < r.end => r.start,
Some(r) if byte >= r.end => byte - removed,
_ => byte,
}
};
let cursor = to_logical(caret);
let anchor = to_logical(anchor);
let selection_start = cursor.min(anchor);
let selection_end = cursor.max(anchor);
let selection_len = selection_end - selection_start;
if selection_len > max_bytes {
return ImeSurrounding::Unavailable("the selection is wider than the IME surrounding-text limit");
}
let budget = max_bytes - selection_len;
let before_available = selection_start;
let after_available = logical_len - selection_end;
let mut before = (budget / 2).min(before_available);
let mut after = (budget - before).min(after_available);
before = (budget - after).min(before_available);
after = (budget - before).min(after_available);
let mut window_start = selection_start - before;
let mut window_end = selection_end + after;
let logical_boundary = |byte: usize| -> bool {
match &preedit {
Some(r) if byte == r.start => true,
Some(r) if byte < r.start => plain_is_boundary(segments, byte),
Some(_) => plain_is_boundary(segments, byte + removed),
None => plain_is_boundary(segments, byte),
}
};
while window_start < selection_start && !logical_boundary(window_start) {
window_start += 1;
}
while selection_end < window_end && !logical_boundary(window_end) {
window_end -= 1;
}
let to_original = |byte: usize| -> usize {
match &preedit {
Some(r) if byte > r.start => byte + removed,
_ => byte,
}
};
let original_start = to_original(window_start);
let original_end = to_original(window_end);
let mut text = String::with_capacity(window_end - window_start);
match &preedit {
Some(r) => {
if window_start < r.start {
let first_end = original_end.min(r.start);
if original_start < first_end {
copy_plain_range(segments, original_start, first_end, &mut text);
}
}
if r.start < window_end {
let second_start = r.end.max(original_start);
if second_start < original_end {
copy_plain_range(segments, second_start, original_end, &mut text);
}
}
}
None => {
if original_start < original_end {
copy_plain_range(segments, original_start, original_end, &mut text);
}
}
}
debug_assert_eq!(text.len(), window_end - window_start, "removing the pre-edit must preserve the logical window length");
ImeSurrounding::Available { text, cursor: cursor - window_start, anchor: anchor - window_start }
}
#[cfg(test)]
#[path = "text_tests.rs"]
mod tests;
@@ -8,7 +8,8 @@ use egui_kittest::kittest::Queryable;
use serde_json::json;
use vectorcraft_engine::Session;
use super::*;
use super::show;
use crate::VectorcraftApp;
type Harness = egui_kittest::Harness<'static, VectorcraftApp>;
+174 -152
View File
@@ -6,6 +6,7 @@ use craft_keymap::{Active, CommandRef};
use egui::{Key, KeyboardShortcut, Modifiers};
use serde_json::json;
use vectorcraft_engine::cmd::clipboard::{Flavour, TEXT};
use vectorcraft_engine::{Result as EngineResult, UiRequest, ViewInfo};
use vectorcraft_tools::{Mods, SurroundingUnit, ToolKey};
use crate::VectorcraftApp;
@@ -219,6 +220,22 @@ fn is_shortcut_chord(m: egui::Modifiers) -> bool {
(m.command || m.ctrl) && !m.alt
}
enum Seq {
Text(String),
Preedit(String, Option<std::ops::Range<usize>>),
Commit(String),
Erase(ToolKey, Mods),
/// An input method replaces text around the composition (upstream's character counts or
/// the fork's exact UTF-8 byte counts), then commits.
DeleteSurrounding(usize, usize, SurroundingUnit),
// The actual pointer/key still belongs to its existing handler. Its position in this
// sequence must nevertheless interrupt an old composition before later terminal input.
InterruptPointer,
InterruptNavigation,
InterruptCommand,
ResetCompleted(egui::ImeResetId),
}
/// Typed text and IME events, in the order they came, to the Type tool.
fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
let view = app.view_info();
@@ -233,21 +250,55 @@ fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
// characters in one frame interleaves correctly. Taken as two passes (all text, then the keys)
// the deletes land after the inserts and the diacritics are lost. Each handled event is removed
// so `route_type_input` below does not process the same Backspace/Delete again.
enum Seq {
Text(String),
Preedit(String, Option<std::ops::Range<usize>>),
Commit(String),
Erase(ToolKey, Mods),
/// An input method replaces text around the composition (upstream's character counts or
/// the fork's exact UTF-8 byte counts), then commits.
DeleteSurrounding(usize, usize, SurroundingUnit),
// The actual pointer/key still belongs to its existing handler. Its position in this
// sequence must nevertheless interrupt an old composition before later terminal input.
InterruptPointer,
InterruptNavigation,
InterruptCommand,
ResetCompleted(egui::ImeResetId),
let seq = type_sequence(ctx);
let viewport = ctx.viewport_id();
let mut old_composition_retired = false;
for e in seq {
// Before the UI, a changed composition or guard is settled `pending`: a token bound here
// could outlive a route this pass invalidates later (a palette/dialog/Home) and attach to
// the stock descriptor. The post-UI finalizer binds the confirmed replacement.
crate::ime::sync_incoming_before_ui(app, ctx);
if let Seq::ResetCompleted(request) = &e {
old_composition_retired |= crate::ime::complete(app, ctx, *request);
continue;
}
if matches!(app.ime_compositions.get(&viewport), Some(crate::ime::IncomingComposition::Stale)) {
match &e {
// Accepted old payloads precede the fence even when their terminal arrives.
// None of these events proves the local reset has reached its boundary.
Seq::Commit(_) | Seq::DeleteSurrounding(..) | Seq::Preedit(..) => continue,
_ => {}
}
}
// A pointer/navigation/command notice has not reached its real handler yet. Text and
// erases after it belong to the old caret, not to the destination it will establish.
if crate::ime::deferred(app, ctx) {
match &e {
Seq::Text(_) | Seq::Erase(..) => continue,
_ => {}
}
}
let preedit_empty = match &e {
Seq::Preedit(text, _) => Some(text.is_empty()),
_ => None,
};
let terminal_commit = matches!(&e, Seq::Commit(_));
let from_ime = matches!(&e, Seq::Preedit(..) | Seq::Commit(_) | Seq::DeleteSurrounding(..));
// Errors are already reported by the engine's interaction (a failed preview keeps it).
let result = apply_type_event(app, ctx, view, e, shortcut_mods, was_composing, &mut old_composition_retired);
if result.is_ok() && from_ime {
if terminal_commit {
app.ime_compositions.remove(&viewport);
} else {
crate::ime::remember_incoming(app, ctx, preedit_empty);
}
}
}
old_composition_retired
}
/// Take this frame's typed text, IME events and plain Backspace/Delete, in the order they arrived.
fn type_sequence(ctx: &egui::Context) -> Vec<Seq> {
let mut seq = vec![];
ctx.input_mut(|i| {
// Move the events out instead of cloning their strings: a keyboard hook that reinjects a
@@ -321,159 +372,130 @@ fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
}
i.events = kept;
});
let viewport = ctx.viewport_id();
let mut old_composition_retired = false;
for e in seq {
// Before the UI, a changed composition or guard is settled `pending`: a token bound here
// could outlive a route this pass invalidates later (a palette/dialog/Home) and attach to
// the stock descriptor. The post-UI finalizer binds the confirmed replacement.
crate::ime::sync_incoming_before_ui(app, ctx);
if let Seq::ResetCompleted(request) = &e {
old_composition_retired |= crate::ime::complete(app, ctx, *request);
continue;
seq
}
/// Apply one sequence item to the active tool; the caller filters stale/deferred events and
/// turns a successful IME mutation into its receipt.
fn apply_type_event(
app: &mut VectorcraftApp,
ctx: &egui::Context,
view: ViewInfo,
e: Seq,
shortcut_mods: bool,
was_composing: bool,
old_composition_retired: &mut bool,
) -> EngineResult<Vec<UiRequest>> {
match e {
Seq::InterruptPointer => {
*old_composition_retired |= crate::ime::defer_interrupt(app, ctx);
Ok(vec![])
}
if matches!(app.ime_compositions.get(&viewport), Some(crate::ime::IncomingComposition::Stale)) {
match &e {
// Accepted old payloads precede the fence even when their terminal arrives.
// None of these events proves the local reset has reached its boundary.
Seq::Commit(_) | Seq::DeleteSurrounding(..) | Seq::Preedit(..) => continue,
_ => {}
Seq::InterruptNavigation => {
// Active composition owns its navigation keys. Once cleared, application caret
// navigation interrupts its still-pending terminal event, even in this batch.
if !app.session.tool_composing() {
*old_composition_retired |= crate::ime::defer_interrupt(app, ctx);
}
Ok(vec![])
}
// A pointer/navigation/command notice has not reached its real handler yet. Text and
// erases after it belong to the old caret, not to the destination it will establish.
if crate::ime::deferred(app, ctx) {
match &e {
Seq::Text(_) | Seq::Erase(..) => continue,
_ => {}
Seq::InterruptCommand => {
if crate::ime::blocked(app, ctx) {
*old_composition_retired |= crate::ime::defer_interrupt(app, ctx);
}
Ok(vec![])
}
let preedit_empty = match &e {
Seq::Preedit(text, _) => Some(text.is_empty()),
_ => None,
};
let terminal_commit = matches!(&e, Seq::Commit(_));
let from_ime = matches!(&e, Seq::Preedit(..) | Seq::Commit(_) | Seq::DeleteSurrounding(..));
// Errors are already reported by the engine's interaction (a failed preview keeps it).
let result = match e {
Seq::InterruptPointer => {
old_composition_retired |= crate::ime::defer_interrupt(app, ctx);
Ok(vec![])
Seq::ResetCompleted(_) => Ok(vec![]),
// Keys the IME passed through come as text only when nothing is marked. This is an
// application edit (also a hook IME's reinjected characters): `Other` dominates.
Seq::Text(t) if !app.session.tool_composing() && !shortcut_mods => {
let target_before = crate::ime::target(&mut app.session, view);
let before = crate::ime::stamp(&mut app.session, view);
let r = app.session.tool_text(&t, view);
if r.is_ok() {
crate::ime::note_application_edit(app, ctx, view, target_before, before);
}
Seq::InterruptNavigation => {
// Active composition owns its navigation keys. Once cleared, application caret
// navigation interrupts its still-pending terminal event, even in this batch.
if !app.session.tool_composing() {
old_composition_retired |= crate::ime::defer_interrupt(app, ctx);
}
Ok(vec![])
}
Seq::InterruptCommand => {
if crate::ime::blocked(app, ctx) {
old_composition_retired |= crate::ime::defer_interrupt(app, ctx);
}
Ok(vec![])
}
Seq::ResetCompleted(_) => Ok(vec![]),
// Keys the IME passed through come as text only when nothing is marked. This is an
// application edit (also a hook IME's reinjected characters): `Other` dominates.
Seq::Text(t) if !app.session.tool_composing() && !shortcut_mods => {
let target_before = crate::ime::target(&mut app.session, view);
let before = crate::ime::stamp(&mut app.session, view);
let r = app.session.tool_text(&t, view);
if r.is_ok() {
r
}
Seq::Text(_) => Ok(vec![]),
// A pre-edit is not committed text: it changes neither the outgoing snapshot (the
// engine query removes it) nor the cause.
Seq::Preedit(text, active_range_chars) => {
app.ime_marked = Some(text.clone()).filter(|t| !t.is_empty());
app.session.tool_preedit(&text, active_range_chars, view)
}
// A bare line break confirms the composition (Enter): the marked text, not a newline.
// It is an accepted IME commit of that marked text when the tool inserts the marked
// text unchanged; a commit the tool rewrites (a quoted or filtered marked text) is an
// application edit instead.
Seq::Commit(t) if t == "\n" || t == "\r" => {
let target_before = crate::ime::target(&mut app.session, view);
let before = crate::ime::stamp(&mut app.session, view);
let composing = app.session.tool_composing();
let verbatim = app.session.tool_ime_session_matches(view)
&& match app.ime_marked.as_deref() {
Some(m) => app.session.tool_text_is_verbatim(m, view),
None => true,
};
keep_marked_text(app);
if composing {
if verbatim {
crate::ime::note_ime_apply(app, ctx, view, target_before, before);
} else {
crate::ime::note_application_edit(app, ctx, view, target_before, before);
}
r
}
Seq::Text(_) => Ok(vec![]),
// A pre-edit is not committed text: it changes neither the outgoing snapshot (the
// engine query removes it) nor the cause.
Seq::Preedit(text, active_range_chars) => {
app.ime_marked = Some(text.clone()).filter(|t| !t.is_empty());
app.session.tool_preedit(&text, active_range_chars, view)
}
// A bare line break confirms the composition (Enter): the marked text, not a newline.
// It is an accepted IME commit of that marked text when the tool inserts the marked
// text unchanged; a commit the tool rewrites (a quoted or filtered marked text) is an
// application edit instead.
Seq::Commit(t) if t == "\n" || t == "\r" => {
let target_before = crate::ime::target(&mut app.session, view);
let before = crate::ime::stamp(&mut app.session, view);
let composing = app.session.tool_composing();
let verbatim = app.session.tool_ime_session_matches(view)
&& match app.ime_marked.as_deref() {
Some(m) => app.session.tool_text_is_verbatim(m, view),
None => true,
};
keep_marked_text(app);
if composing {
if verbatim {
crate::ime::note_ime_apply(app, ctx, view, target_before, before);
} else {
crate::ime::note_application_edit(app, ctx, view, target_before, before);
}
Ok(vec![])
}
// An accepted IME commit: stamp the bounded committed context immediately before and
// after, so only a real mutation (not a no-op, rejection or failure) earns the receipt.
// A commit the tool rewrites (Typographer's Quotes, a filtered control character) is an
// application edit and must never earn `InputMethod`.
Seq::Commit(t) => {
app.ime_marked = None;
let target_before = crate::ime::target(&mut app.session, view);
let before = crate::ime::stamp(&mut app.session, view);
// A document edit since the composition means this commit starts a fresh typing
// session: that reset is application-side, so it cannot earn `InputMethod`.
let verbatim = app.session.tool_ime_session_matches(view) && app.session.tool_text_is_verbatim(&t, view);
let r = app.session.tool_text(&t, view);
if r.is_ok() {
if verbatim {
crate::ime::note_ime_apply(app, ctx, view, target_before, before);
} else {
crate::ime::note_application_edit(app, ctx, view, target_before, before);
}
Ok(vec![])
}
// An accepted IME commit: stamp the bounded committed context immediately before and
// after, so only a real mutation (not a no-op, rejection or failure) earns the receipt.
// A commit the tool rewrites (Typographer's Quotes, a filtered control character) is an
// application edit and must never earn `InputMethod`.
Seq::Commit(t) => {
app.ime_marked = None;
let target_before = crate::ime::target(&mut app.session, view);
let before = crate::ime::stamp(&mut app.session, view);
// A document edit since the composition means this commit starts a fresh typing
// session: that reset is application-side, so it cannot earn `InputMethod`.
let verbatim = app.session.tool_ime_session_matches(view) && app.session.tool_text_is_verbatim(&t, view);
let r = app.session.tool_text(&t, view);
if r.is_ok() {
if verbatim {
crate::ime::note_ime_apply(app, ctx, view, target_before, before);
} else {
crate::ime::note_application_edit(app, ctx, view, target_before, before);
}
}
r
r
}
// While composing (or in a frame that began composing), editing keys belong to the IME.
Seq::Erase(tk, m) if !was_composing && !app.session.tool_composing() => {
// Mirror `route_type_input`/`text_key`: surface an erase error in the status bar
// instead of discarding it, and report the successful edit as application-origin.
let target_before = crate::ime::target(&mut app.session, view);
let before = crate::ime::stamp(&mut app.session, view);
let r = app.session.tool_key(tk, m, view);
match &r {
Err(e) => app.status(e.to_string()),
Ok(_) => crate::ime::note_application_edit(app, ctx, view, target_before, before),
}
// While composing (or in a frame that began composing), editing keys belong to the IME.
Seq::Erase(tk, m) if !was_composing && !app.session.tool_composing() => {
// Mirror `route_type_input`/`text_key`: surface an erase error in the status bar
// instead of discarding it, and report the successful edit as application-origin.
let target_before = crate::ime::target(&mut app.session, view);
let before = crate::ime::stamp(&mut app.session, view);
let r = app.session.tool_key(tk, m, view);
match &r {
Err(e) => app.status(e.to_string()),
Ok(_) => crate::ime::note_application_edit(app, ctx, view, target_before, before),
}
r
}
Seq::Erase(..) => Ok(vec![]),
// Deleting around the composition starts the same typing session as the commit that
// follows, so the replacement is one undo step; an error reaches the status bar. The
// receipt covers only this accepted deletion.
Seq::DeleteSurrounding(before, after, unit) => {
let target_before = crate::ime::target(&mut app.session, view);
let stamp_before = crate::ime::stamp(&mut app.session, view);
let r = app.session.tool_ime_delete_surrounding(before, after, unit, view);
match &r {
Err(e) => app.status(e.to_string()),
Ok(_) => crate::ime::note_ime_apply(app, ctx, view, target_before, stamp_before),
}
r
}
};
if result.is_ok() && from_ime {
if terminal_commit {
app.ime_compositions.remove(&viewport);
} else {
crate::ime::remember_incoming(app, ctx, preedit_empty);
r
}
Seq::Erase(..) => Ok(vec![]),
// Deleting around the composition starts the same typing session as the commit that
// follows, so the replacement is one undo step; an error reaches the status bar. The
// receipt covers only this accepted deletion.
Seq::DeleteSurrounding(before, after, unit) => {
let target_before = crate::ime::target(&mut app.session, view);
let stamp_before = crate::ime::stamp(&mut app.session, view);
let r = app.session.tool_ime_delete_surrounding(before, after, unit, view);
match &r {
Err(e) => app.status(e.to_string()),
Ok(_) => crate::ime::note_ime_apply(app, ctx, view, target_before, stamp_before),
}
r
}
}
old_composition_retired
}
pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
+57
View File
@@ -1158,6 +1158,63 @@ fn an_ime_commit_the_document_rewrites_is_other() {
assert_eq!(filtered.change_cause, egui::output::ImeChangeCause::Other, "filtering is not an IME insertion");
}
#[test]
fn an_ime_quote_commit_uses_the_character_before_its_pre_edit() {
let (mut app, id) = app_with_text("a");
// The pre-edit ends in an opening quote, but replacement starts after the letter `a`.
app.run("document.setup", json!({"quotes": {"double": ["\"", "”"]}})).unwrap();
let ctx = egui::Context::default();
ime_frame(&mut app, &ctx, vec![], |_| {});
ime_frame(&mut app, &ctx, vec![], |_| {});
begin_typing(&mut app, &ctx, id);
app.session.set_tool_option("select", &json!({"start": 1, "end": 1}));
ime_frame(&mut app, &ctx, vec![], |_| {});
ime_frame(&mut app, &ctx, vec![preedit("\"", 1)], |_| {});
let committed = ime_frame(&mut app, &ctx, vec![Event::Ime(egui::ImeEvent::Commit("\"".into()))], |_| {}).expect("still editing");
assert_eq!(plain(&app, id), "a”", "the document inserts its closing quote");
assert_eq!(committed.change_cause, egui::output::ImeChangeCause::Other, "a rewritten quote is not an unchanged IME insertion");
}
/// An outside command can end the typing session (`endTyping`) while the Type tool still owns
/// the IME. The IME's following `DeleteSurrounding` starts a new typing session; that session
/// must clear the ended marker, so after its commit and a caret move the next ordinary IME
/// commit is an accepted `InputMethod` mutation, not an application edit.
#[test]
fn ime_delete_surrounding_after_end_typing_keeps_the_next_commit_input_method() {
let (mut app, id) = app_with_text("abc");
let ctx = egui::Context::default();
ime_frame(&mut app, &ctx, vec![], |_| {});
ime_frame(&mut app, &ctx, vec![], |_| {});
begin_typing(&mut app, &ctx, id);
app.session.set_tool_option("select", &json!({"start": 3, "end": 3}));
ime_frame(&mut app, &ctx, vec![], |_| {});
// A real typing session, settled by an outside command as `settle_interaction` does.
ime_frame(&mut app, &ctx, vec![Event::Text("x".into())], |_| {}).expect("typing owns the IME");
assert_eq!(plain(&app, id), "abcx");
ime_frame(&mut app, &ctx, vec![], |app| {
app.session.commit_interaction().expect("the typing preview is committed");
app.session.set_tool_option("endTyping", &json!(true));
});
// The IME deletes the typed character and commits its replacement in the new session.
let deleted = ime_frame(
&mut app,
&ctx,
vec![Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }), Event::Ime(egui::ImeEvent::Commit("Z".into()))],
|_| {},
)
.expect("the Type tool still owns the IME");
assert_eq!(plain(&app, id), "abcZ");
assert_eq!(deleted.change_cause, egui::output::ImeChangeCause::InputMethod);
// A caret move ends the deletion's session. The following IME commit starts a fresh session;
// because no outside command ended that session, the commit is InputMethod.
let left = egui::Event::Key { key: egui::Key::ArrowLeft, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
ime_frame(&mut app, &ctx, vec![left], |_| {});
let committed =
ime_frame(&mut app, &ctx, vec![Event::Ime(egui::ImeEvent::Commit("Q".into()))], |_| {}).expect("the Type tool still owns the IME");
assert_eq!(plain(&app, id), "abcQZ");
assert_eq!(committed.change_cause, egui::output::ImeChangeCause::InputMethod, "a caret move is not an application-side reset");
}
#[test]
fn a_reloaded_document_gets_a_new_ime_replacement_epoch() {
let (mut app, id) = app_with_text("Same");