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.
This commit is contained in:
parent
dd9493bdbd
commit
cb8ebc003f
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user