From cb8ebc003fed1c73d58b12927d29dd9f949c3aa1 Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Sun, 13 Sep 2026 18:23:06 -0400 Subject: [PATCH] IME: force a re-syncing snippet re-push when the IME desynchronizes After the selection-replacement autocorrect on the phone, Gboard's local text was out of sync with ours and it re-sent the same empty fix-up commit in an endless loop (~one per 150 ms, each drift-snapped to the caret and applied as a no-op). The file was never damaged, but the IME never converged because it kept 'fixing' text that did not exist in its own model. The app cannot see the IME's model; the only recovery the IME contract offers is a restartInput, which makes it re-fetch the real text and selection around the caret. Arm that recovery automatically: three consecutive anomalous commits (drift-snapped, or empty text) set IMEForceResync, and the next frame ships the snippet trimmed by one rune, which changes the pushed text and forces the restart. The streak resets on any normal commit, so isolated anomalies never trigger it, and the resync is one-shot. TestRealFile_IMEForceResync pins the arm/reset/consume cycle. --- internal/editor/state.go | 28 ++++++ internal/test/e2e/real_file_large_ime_test.go | 91 +++++++++++++++++++ internal/ui/element.go | 4 + internal/ui/render.go | 18 ++++ 4 files changed, 141 insertions(+) diff --git a/internal/editor/state.go b/internal/editor/state.go index 096508e..6f0b908 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -126,6 +126,16 @@ type EditorState struct { IMECaretRune int IMESelStartRune int IMESelEndRune int + // IMEForceResync asks the next frame to re-push the snippet trimmed by + // one rune (a forced restartInput): after several consecutive + // anomalous commits (drift-snapped or empty) the IME's local text is + // desynchronized from ours and it keeps re-sending the same fix (an + // endless empty-commit loop). A forced restart makes it re-fetch the + // real text and selection around the caret, which heals the model. + // Set by HandleIMECommit, consumed by the next frame (EditorLayout). + IMEForceResync bool + // imeAnomStreak counts consecutive anomalous IME commits (see above). + imeAnomStreak int // EditSeq counts content edits (incremented by markDirty). The shaped // glyph layout arriving via layoutChan is only applied to the WrapIndex // when its EditSeq matches, so a layout shaped before an edit can never @@ -2352,13 +2362,29 @@ func HandleIMECommit(data any) { if startRune > endRune { startRune, endRune = endRune, startRune } + snapped := false if endRune-startRune <= 2 { caretRune := TheState.imeRuneOffsetAt(TheState.Editor.CursorPosition) if endRune != caretRune { log.Printf("IME DRIFT-SNAP range=[%d,%d) caret=%d -> snap to caret", startRune, endRune, caretRune) startRune, endRune = caretRune, caretRune + snapped = true } } + // Anomalous commits (snapped, or empty: an IME fix-up that changes + // nothing) mean the IME's local text has desynchronized from ours. + // After a few in a row, force a re-syncing snippet re-push on the next + // frame (see IMEForceResync) so the IME re-fetches the real text and + // the loop ends; a normal commit resets the streak. + if snapped || c.Text == "" { + TheState.Editor.imeAnomStreak++ + if TheState.Editor.imeAnomStreak >= 3 { + TheState.Editor.imeAnomStreak = 0 + TheState.Editor.IMEForceResync = true + } + } else { + TheState.Editor.imeAnomStreak = 0 + } absStart, absEnd := runeToByteWhole(startRune), runeToByteWhole(endRune) log.Printf("IME COMMIT range=[%d,%d) text=%q -> [%d,%d)", startRune, endRune, c.Text, absStart, absEnd) applyIMECommitBytes(absStart, absEnd, c.Text) @@ -2837,6 +2863,8 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { editorElem.IMECaretRune = TheState.Editor.IMECaretRune editorElem.IMESelStartRune = TheState.Editor.IMESelStartRune editorElem.IMESelEndRune = TheState.Editor.IMESelEndRune + editorElem.IMEForceResync = TheState.Editor.IMEForceResync + TheState.Editor.IMEForceResync = false editorElem.WindowStartByte = start // While the find bar is open, key focus belongs to the main-owned // "find_bar" widget.Editor: the editor stops its per-frame IME sync, and diff --git a/internal/test/e2e/real_file_large_ime_test.go b/internal/test/e2e/real_file_large_ime_test.go index 616d550..d2182d2 100644 --- a/internal/test/e2e/real_file_large_ime_test.go +++ b/internal/test/e2e/real_file_large_ime_test.go @@ -210,3 +210,94 @@ func firstDiff(a, b string) int { } return n } + +// TestRealFile_IMEForceResync pins the desync self-heal: after several +// consecutive anomalous IME commits (drift-snapped or empty — the pattern +// Gboard produces when its local text has desynchronized from the app and it +// keeps re-sending the same fix), the next frame arms a forced snippet +// re-push (a restartInput that makes the IME re-fetch the real text). A +// normal commit resets the streak, so isolated anomalies never resync. +func TestRealFile_IMEForceResync(t *testing.T) { + model := largeFileContent(4000) + h, _ := realFileHarness(t, "resync.txt", model) + defer h.Cleanup() + + h.SendConfig(780, 400) + if _, err := h.WaitForFrame(5 * time.Second); err != nil { + t.Fatalf("wait for frame: %v", err) + } + + // A caret deep in the file, in absolute file runes. + const caretByte = 10000 + caretRune := utf8.RuneCountInString(model[:caretByte]) + if err := h.WithState(func(st *editor.State) { + st.Editor.CursorPosition = caretByte + }); err != nil { + t.Fatalf("WithState: %v", err) + } + + commit := func(start, end int, text string) { + h.SendInput([]ui.InputEvent{{ + Handler: editor.HandleIMECommit, + Data: editor.IMECommit{StartRune: start, EndRune: end, Text: text}, + }}) + // The logic goroutine applies commits asynchronously; wait for the + // resulting frame before asserting on the derived state. + prev := h.FrameCount() + if _, err := h.WaitForFrameCount(prev+1, 5*time.Second); err != nil { + t.Fatalf("wait for commit frame: %v", err) + } + } + // forceInLatestFrame reports whether the latest delivered frame's editor + // element carries the forced-resync flag (the state flag is consumed by + // the very frame that follows the arming commit, so the frame is the + // observable artifact). + forceInLatestFrame := func() bool { + frames := h.GetFrames() + if len(frames) == 0 { + t.Fatal("no frames") + } + for _, el := range frames[len(frames)-1] { + if tf, ok := el.(ui.TextField); ok && tf.ID() == "editor_text" { + return tf.IMEForceResync + } + } + t.Fatal("no editor_text element in latest frame") + return false + } + + // Two anomalous commits (an empty fix-up and a drifted insertion): + // below the threshold, no resync. + commit(caretRune+5, caretRune+5, "") + commit(caretRune+6, caretRune+6, "z") + if forceInLatestFrame() { + t.Fatalf("resync armed after only 2 anomalous commits") + } + + // A normal commit resets the streak: two more anomalies must not arm. + commit(caretRune+10, caretRune+10, "q") + commit(caretRune+11, caretRune+11, "") + commit(caretRune+12, caretRune+12, "") + if forceInLatestFrame() { + t.Fatalf("resync armed after a reset streak") + } + + // One more anomaly makes three in a row: the frame emitted by that + // commit carries the flag on its editor element (the state flag is + // consumed by that same frame). + commit(caretRune+13, caretRune+13, "") + if !forceInLatestFrame() { + t.Fatalf("resync not armed after 3 consecutive anomalous commits") + } + + // A later frame (no new anomalies) carries no flag: the resync fires + // exactly once. + prev := h.FrameCount() + h.SendConfig(780, 400) + if _, err := h.WaitForFrameCount(prev+1, 5*time.Second); err != nil { + t.Fatalf("wait for frame: %v", err) + } + if forceInLatestFrame() { + t.Fatalf("resync flag not one-shot") + } +} diff --git a/internal/ui/element.go b/internal/ui/element.go index 536173c..b031c55 100644 --- a/internal/ui/element.go +++ b/internal/ui/element.go @@ -224,6 +224,10 @@ type TextField struct { IMECaretRune int IMESelStartRune int IMESelEndRune int + // IMEForceResync: re-push the snippet trimmed by one rune this frame + // (a forced restartInput that heals a desynchronized IME; see + // EditorState.IMEForceResync). + IMEForceResync bool // EditSeq is the buffer edit sequence at frame-build time (0 for // non-editor fields); the renderer uses it to detect the frame that // carries an IME commit's result (see Renderer.imeHold). diff --git a/internal/ui/render.go b/internal/ui/render.go index 0a6360c..1eb2d56 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -379,6 +379,24 @@ func (r *Renderer) FlushIME(gtx layout.Context, tf TextField) { }, Text: tf.IMESnippetText, } + if tf.IMEForceResync && utf8.RuneCountInString(tf.IMESnippetText) > 2 { + // The IME's local text has desynchronized (several consecutive + // drift-snapped or empty commits — see EditorState.IMEForceResync) + // and it keeps re-sending the same fix in a loop. Pushing the + // window trimmed by one rune forces a snippet change (and thus a + // restartInput): the IME re-fetches the real text and selection + // around the caret and its model converges. The next frame pushes + // the full window again (one more restart), then everything is + // quiet again. + rs := []rune(tf.IMESnippetText) + snippet = key.Snippet{ + Range: key.Range{ + Start: tf.IMESnippetStartRune + 1, + End: tf.IMESnippetEndRune, + }, + Text: string(rs[1:]), + } + } if snippet != r.lastSnippet { r.lastSnippet = snippet // A snippet change resets the IME's selection: force the selection