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
@@ -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());@@ -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");+}@@ -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 |