collective

Browse / github.com/storytold/wordcraft / patch

fix(ui): don't glide the caret back over text an input method commits

The caret slides back over Chinese text each time an input method commits ## Summary With caret animation on (the default), every time a Chinese (Pinyin) composition is committed, the caret visibly slides **backwards** over the characters just typed before it settles after them. While composing, `main` draws the marked text over the page from the caret, in the interface font, and puts the caret at the input method's cursor inside it (#3556). The committed text is usually much narrower than its spelling: `zhongguo` becomes `中国`. Committing is an edit made by input, so the caret glide (`caret_glide.rs`) animates the move from the cursor in the marked text to the car

Status
open
Score
0.00
Adopters
0 other operators
Author
inter0925 (imported from pull request #4524, open)
Base
2cf1d6c7ae94723a45bd6e7e8c884fe375148750
Size
+99 −5 in 3 files
Review
pending
Shared
2026-10-28
Id
1fdd7f8917cbc4718aa18423

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/caret_glide.rs crates/ui-egui/src/caret_glide/tests.rs docs/typing-parity.md

diff --git a/crates/ui-egui/src/caret_glide.rs b/crates/ui-egui/src/caret_glide.rs--- a/crates/ui-egui/src/caret_glide.rs+++ b/crates/ui-egui/src/caret_glide.rs@@ -17,7 +17,10 @@ //! //! What never glides: a caret that moved because the view changed (zoom, a resized window, //! scrolling, another document), one that wasn't drawn the frame before (a selection, the canvas-//! unfocused), and moves that start or end off screen. Our choice; Word wasn't measured there.+//! unfocused), moves that start or end off screen, and moves the input method makes: its+//! composition is drawn over the page, so the caret's place in it is not a place in the text, and+//! committing eight letters of pinyin as two Chinese characters would slide the caret back over+//! them. Our choice; Word wasn't measured there. //! //! With animations off the caret jumps and blinks on and off, as it always did: File ▸ Options ▸ //! Advanced ▸ Animation (the `ui.caretAnimation` command) turns them off, and so do egui's@@ -72,6 +75,9 @@ pub struct Frame {     pub pointer: bool,     /// Seconds since the blink was restarted (by the last command or click).     pub since_blink: f64,+    /// The caret is drawn at the input method's cursor in a composition, or the input method+    /// sent something this frame (it can end a composition and commit its text a frame apart).+    pub composing: bool, }  /// How to draw the caret this frame.@@ -114,6 +120,8 @@ pub struct CaretGlide {     /// at the end of a frame, so the move they make is seen one frame later.     keys: bool,     pointer: bool,+    /// Last frame's [`Frame::composing`].+    composing: bool,     cause: u64,     view: u64,     frame_nr: u64,@@ -138,6 +146,7 @@ impl CaretGlide {             fade: None,             keys: false,             pointer: false,+            composing: false,             cause: 0,             view: 0,             frame_nr: 0,@@ -153,11 +162,13 @@ impl CaretGlide {         let same_view = f.view == self.view;         let by_keys = f.keys || (self.keys && continuous);         let by_pointer = f.pointer || (self.pointer && continuous);+        let composing = f.composing || (self.composing && continuous);         self.cause = f.cause;         self.view = f.view;         self.frame_nr = f.frame_nr;         self.keys = f.keys;         self.pointer = f.pointer;+        self.composing = f.composing;         let sane = is_finite(f.target) && f.now.is_finite();         if !(f.shown && sane) {             self.reset();@@ -181,7 +192,7 @@ impl CaretGlide {             self.fade = Some(FadeIn { from: was_opacity, started: f.now });             self.glide = match heading {                 // Keys glide, from where the caret was heading (not from where it has got to).-                Some(from) if by_keys && !by_pointer && same_view => {+                Some(from) if by_keys && !by_pointer && !composing && same_view => {                     let seen = f.viewport.expand(OFFSCREEN_SLACK);                     let on_screen = |b: Bar| seen.intersects(Rect::from_two_pos(b[0], b[1]));                     (on_screen(from) && on_screen(f.target)).then_some(Glide { from, to: f.target, started: f.now })@@ -193,6 +204,10 @@ impl CaretGlide {             self.glide = None;             self.fade = None;         }+        // A composition draws the caret at the input method's cursor: a glide under way stops.+        if composing {+            self.glide = None;+        }         // The text shifted under a glide (pixel snapping while scrolling): carry the glide along.         if let Some(g) = &mut self.glide             && g.to != f.target@@ -358,11 +373,12 @@ pub fn look(app: &WordApp, ui: &Ui, content_origin: Pos2, geo: &Geometry, target     }     let off = content_origin.to_vec2();     let since = since_ms / 1000.0;-    let (keys, pointer, now) = ui.input(|i| {+    let (keys, pointer, ime, now) = ui.input(|i| {         let typed = |e: &egui::Event| {             matches!(e, egui::Event::Key { pressed: true, .. } | egui::Event::Text(_) | egui::Event::Paste(_) | egui::Event::Ime(_))         };-        (i.events.iter().any(typed), i.pointer.any_down() || i.pointer.any_pressed() || i.pointer.any_released(), i.time)+        let ime = i.events.iter().any(|e| matches!(e, egui::Event::Ime(_)));+        (i.events.iter().any(typed), i.pointer.any_down() || i.pointer.any_pressed() || i.pointer.any_released(), ime, i.time)     });     let frame = Frame {         target: [target[0] - off, target[1] - off],@@ -375,6 +391,7 @@ pub fn look(app: &WordApp, ui: &Ui, content_origin: Pos2, geo: &Geometry, target         keys,         pointer,         since_blink: since,+        composing: ime || !app.canvas.ime_preedit.is_empty(),     };     let (mut look, gliding, secs) = ui.ctx().data_mut(|d| {         let state = d.get_temp_mut_or_default::<CaretGlide>(state_id());diff --git a/crates/ui-egui/src/caret_glide/tests.rs b/crates/ui-egui/src/caret_glide/tests.rs--- a/crates/ui-egui/src/caret_glide/tests.rs+++ b/crates/ui-egui/src/caret_glide/tests.rs@@ -30,6 +30,7 @@ impl Rig {             keys: false,             pointer: false,             since_blink: 0.0,+            composing: false,         };         let mut rig = Rig { glide: CaretGlide::with_secs(GLIDE_SECS), f, restarted: 100.0 };         assert_eq!(rig.glide.frame(rig.f), Look { bar: at, opacity: 1.0, moving: false }, "the first frame draws the caret in place");@@ -127,6 +128,22 @@ fn a_key_move_glides_and_lands_exactly() {     assert_eq!(r.step(DT), Look { bar: bar(200.0, 90.0), opacity: 1.0, moving: false }, "then it rests"); } +/// A composition that starts while the caret is still gliding stops the glide: the caret is+/// drawn at the input method's cursor at once, not carried there by the rest of the glide.+#[test]+fn a_composition_stops_a_glide_under_way() {+    let mut r = Rig::new(bar(100.0, 50.0));+    r.key_to(bar(200.0, 50.0));+    r.step(DT);+    assert!(r.glide.is_gliding());+    // The input method's cursor sits inside the composition: same cause, new target.+    r.f.composing = true;+    r.f.target = bar(240.0, 50.0);+    let look = r.step(DT);+    assert!(!r.glide.is_gliding(), "the glide stops: {look:?}");+    assert_eq!(look.bar, bar(240.0, 50.0));+}+ #[test] fn the_glide_is_the_same_at_any_frame_rate() {     let at = |hz: f64| {@@ -475,3 +492,58 @@ fn the_canvas_caret_glides_after_a_key() {     h.step();     assert!(state(&h.ctx).drawn().is_some()); }++/// Committing an input method's composition puts the caret after the committed text at once.+/// While composing, the caret is drawn at the input method's cursor inside the composition (which+/// is drawn over the page in the interface font); the committed text has its own width, often+/// much narrower (eight letters of pinyin for two Chinese characters), so a glide from there+/// would slide the caret back over the text it just typed. A key after the commit still glides.+#[test]+fn an_input_method_commit_does_not_glide_from_the_composition() {+    let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());+    a.run("text.insert", json!({"text": "Some text "})).unwrap();+    a.run("ui.animations", json!({"value": "on"})).unwrap();+    let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).with_step_dt(DT as f32).build_ui_state(+        |ui, app: &mut WordApp| {+            let ctx = ui.ctx().clone();+            app.logic(&ctx);+            app.ui(ui);+        },+        a,+    );+    h.ctx.all_styles_mut(|s| s.animation_time = 0.1);+    for _ in 0..30 {+        h.step();+    }+    let before = state(&h.ctx).drawn().expect("the focused canvas draws a caret");+    for (spelling, commit, lone) in [("zhongguo", "中国", false), ("shi", "是", false), ("nihao", "你好", true)] {+        h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: spelling.into(), active_range_chars: None }));+        for _ in 0..3 {+            h.step();+        }+        let composing = state(&h.ctx).drawn().expect("the caret is drawn in the composition");+        assert!(composing[0].x > before[0].x + 10.0, "{spelling}: the caret is at the composition's end");+        // Some input methods clear the composition and commit in the same batch.+        if lone {+            h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: String::new(), active_range_chars: None }));+        }+        h.event(egui::Event::Ime(egui::ImeEvent::Commit(commit.into())));+        h.step();+        let mut drawn = Vec::new();+        for _ in 0..20 {+            h.step();+            let s = state(&h.ctx);+            assert!(!s.is_gliding(), "{spelling}: the caret glides from the composition: {drawn:?}");+            drawn.push(s.drawn().expect("the caret stays drawn")[0].x);+        }+        let committed = h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body);+        assert!(committed.ends_with(commit), "{spelling}: committed: {committed:?}");+        assert!(drawn.windows(2).all(|w| w[0] == w[1]), "{spelling}: the caret rests after the commit: {drawn:?}");+    }+    // An ordinary key still glides.+    h.event(key(egui::Key::Home));+    for _ in 0..3 {+        h.step();+    }+    assert!(state(&h.ctx).is_gliding(), "Home after a commit glides as before");+}diff --git a/docs/typing-parity.md b/docs/typing-parity.md--- a/docs/typing-parity.md+++ b/docs/typing-parity.md@@ -1,6 +1,6 @@ # Typing parity with Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (`*Bold* and _italic_ as real formatting` in AutoFormat As You Type; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: queued IME replacements are rejected before mutation after their marked composition loses its body/equation target; previously trivial: the transactional equation delete proves its commit with the engine revision; previously trivial: a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, so a later direct commit still applies; previously trivial: a tracked own-insertion shifts the rest of a two-sided IME deletion; previously trivial: AutoCorrect/AutoFormat classification compares the actual paragraph; previously trivial: a replaced body starts a new input-method context and interrupts the composition; a quote the engine leaves as typed is the input method's own change; equation `DeleteSurrounding` erases counted characters; previously trivial: AutoCorrect finds the word after a no-break space or an ideographic space; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously trivial: while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343; the body IME deletion maps signed placeholder growth/shrink, keeps control markers outside the range and scopes its undo join to the matching replacement) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-27 · **Change:** trivial (committing an input method's composition puts the caret after the text without a glide back from the marked text; previously trivial: `*Bold* and _italic_ as real formatting` in AutoFormat As You Type; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: queued IME replacements are rejected before mutation after their marked composition loses its body/equation target; previously trivial: the transactional equation delete proves its commit with the engine revision; previously trivial: a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, so a later direct commit still applies; previously trivial: a tracked own-insertion shifts the rest of a two-sided IME deletion; previously trivial: AutoCorrect/AutoFormat classification compares the actual paragraph; previously trivial: a replaced body starts a new input-method context and interrupts the composition; a quote the engine leaves as typed is the input method's own change; equation `DeleteSurrounding` erases counted characters; previously trivial: AutoCorrect finds the word after a no-break space or an ideographic space; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously trivial: while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343; the body IME deletion maps signed placeholder growth/shrink, keeps control markers outside the range and scopes its undo join to the matching replacement) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4  What Word does when you type, observed black-box (same keystrokes typed into Word and WordCraft, results read back paragraph by paragraph) and pinned by tests in@@ -394,6 +394,10 @@ again.   and the caret and the candidate window sit at the input method's cursor inside it: the end of   the range it reports, or the end of the marked text when it reports none. The range is clamped   to the text (`canvas::tests::the_caret_follows_the_input_method_cursor_in_the_composition`).+  When the composition is committed the caret is at once after the committed text: it doesn't+  glide there from its place in the marked text, which is drawn over the page and is often much+  wider than what it turns into (`zhongguo` for 中国), so the glide went back over the new text+  (`caret_glide::tests::an_input_method_commit_does_not_glide_from_the_composition`). - A composition belongs to the text it was started on. When a command replaces the body under   the same document id — Replace Document Text, a version restore, Compare, Undo/Redo — or opens   a new document, the body's replacement epoch advances, the platform clears the composition, and@@ -492,6 +496,7 @@ again.  | Date | Change | Summary | |---|---|---|+| 2026-10-27 | trivial | Input methods: committing a composition puts the caret after the committed text at once; before, it glided from its place in the wider marked text, back over the new text. | | 2026-10-25 | trivial | Highlight decisions include fully selected paragraph marks in whole cells and ordinary ranges, including empty paragraphs; partial text columns exclude unselected marks (#4085). | | 2026-10-25 | trivial | Highlight toggle checks the full target beyond 10,000 runs without collecting resolved formatting; mixed and inherited tails and Undo/Redo covered (#4085). | | 2026-10-24 | trivial | Input methods: reject queued old-composition replacements before mutation after body/equation target replacement (new regression validation pending); equation `DeleteSurrounding` is one transactional command whose checked revision proves a receipt; a caret-only step in a mixed request refuses the whole deletion, and an over-cap request clears the painted pre-edit and the previous replacement join; a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, never a later direct commit; a tracked own-insertion shifts the following range; a body deletion maps signed placeholder growth/shrink, keeps control markers outside the range and scopes its replacement undo join to the matching `text.insert`; refusals clear the painted pre-edit and joins; AutoCorrect/AutoFormat classification compares the paragraph as typed; equation replacement interrupts the composition |