From 349dafc0bd552783a906a0528bedc919b6b4db29 Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Sun, 25 Oct 2026 08:14:45 +0100 Subject: [PATCH] M0: preserve IME typing sessions and simplify input handling --- crates/tools/src/text.rs | 183 +++++----- .../src/dialogs/tests_accessibility.rs | 3 +- crates/ui-egui/src/shortcuts.rs | 326 ++++++++++-------- crates/ui-egui/src/tests_lifecycle.rs | 57 +++ 4 files changed, 333 insertions(+), 236 deletions(-) diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 4b59f3892..f982add4a 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -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); static NEXT_EDIT_SESSION: Mutex = 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, preedit: Option, /// 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>, + 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; diff --git a/crates/ui-egui/src/dialogs/tests_accessibility.rs b/crates/ui-egui/src/dialogs/tests_accessibility.rs index 1c0f4a826..a86087b26 100644 --- a/crates/ui-egui/src/dialogs/tests_accessibility.rs +++ b/crates/ui-egui/src/dialogs/tests_accessibility.rs @@ -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>; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index ec7a5e4b2..ab1dc15be 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -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>), + 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>), - 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 { 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> { + 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) { diff --git a/crates/ui-egui/src/tests_lifecycle.rs b/crates/ui-egui/src/tests_lifecycle.rs index d5cc6267b..8dbdd503d 100644 --- a/crates/ui-egui/src/tests_lifecycle.rs +++ b/crates/ui-egui/src/tests_lifecycle.rs @@ -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");