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