diff --git a/go.mod b/go.mod index 60b5f4e..dc03af5 100644 --- a/go.mod +++ b/go.mod @@ -15,3 +15,5 @@ require ( golang.org/x/net v0.48.0 // indirect golang.org/x/text v0.32.0 // indirect ) + +replace gioui.org => /home/gmp/gioui diff --git a/go.sum b/go.sum index 3fb7323..5315f4d 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY= eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA= -gioui.org v0.10.2 h1:bZU5CORROwc51sNha0zYdE2qWVaDncOp5EjV5nrZQZ8= -gioui.org v0.10.2/go.mod h1:iKILKNq6+LHMWhP/HjGDW/wDidUzRnb7B6c7ZD9y1Mg= gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= gioui.org/shader v1.0.9 h1:XxnqIfmClWpN49kizxH2W0JcCFrrEP4q3jZmNYaltbs= gioui.org/shader v1.0.9/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= diff --git a/internal/editor/state.go b/internal/editor/state.go index 6f0b908..8f640fa 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -127,15 +127,15 @@ type EditorState struct { 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). + // one rune (a forced restartInput): an anomalous commit (drift-snapped + // or distant — see HandleIMECommit) means the IME's local text is + // desynchronized from ours, and it may keep re-sending the same fix in + // a loop (observed on the phone: an endless stream of empty commits). + // A selection push cannot heal this (it is deduplicated away after + // commits); only a restart makes the IME re-fetch the real text and + // selection around the caret. Set by HandleIMECommit on the first + // anomaly, 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 @@ -2220,6 +2220,19 @@ func (s *State) computeIMESnippetWindow() { } } +// imeSelectionRuneRange returns the live selection in absolute file runes, +// or (-1, -1) when there is none. Used by the anomalous-commit guard to +// allow commits inside a user selection anywhere. +func imeSelectionRuneRange() (int, int) { + if !selActive() { + return -1, -1 + } + e := &TheState.Editor + r0 := TheState.imeRuneOffsetAt(e.SelectionStart) + r1 := r0 + utf8.RuneCountInString(TheState.fileContent(e.SelectionStart, e.SelectionEnd)) + return r0, r1 +} + // fileLenNow returns the current content length (chunked or string buffer). func (s *State) fileLenNow() int64 { if cb := s.Editor.ChunkedBuffer; cb != nil { @@ -2362,28 +2375,43 @@ 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-commit guard. A legitimate IME commit is always local: at + // the caret (a typed char, a composing word), inside the live + // selection, or a correction of the word just typed (a few runes left + // of the caret). Anything else means the IME's local text has + // desynchronized from ours — its reported range addresses text that is + // not where we think it is. Applying it verbatim clobbers distant text + // (observed on the phone: a 5-rune autocorrect 19,000 runes from the + // caret); the only safe action is to apply it at the caret and force a + // re-syncing snippet re-push on the next frame (see IMEForceResync): a + // desynced IME is healed only by a restart (a selection push is + // deduplicated away after commits — see Renderer.FlushIME), so the + // resync is armed on the FIRST anomaly, not after a streak. + const maxCommitDistance = 1024 + caretRune := TheState.imeRuneOffsetAt(TheState.Editor.CursorPosition) + selStartRune, selEndRune := imeSelectionRuneRange() + anomalous := endRune-startRune <= 2 && endRune != caretRune + if !anomalous && c.Text != "" { + // The commit must be near the caret, or inside the live selection + // (a replacement of a user selection, wherever it is). + lo := caretRune - maxCommitDistance + hi := caretRune + maxCommitDistance + if selStartRune >= 0 { + if selStartRune < lo { + lo = selStartRune + } + if selEndRune > hi { + hi = selEndRune + } + } + if startRune > hi || endRune < lo { + anomalous = 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 + if anomalous { + log.Printf("IME DRIFT-SNAP range=[%d,%d) text=%q caret=%d -> snap to caret + resync", startRune, endRune, c.Text, caretRune) + startRune, endRune = caretRune, caretRune + TheState.Editor.IMEForceResync = true } absStart, absEnd := runeToByteWhole(startRune), runeToByteWhole(endRune) log.Printf("IME COMMIT range=[%d,%d) text=%q -> [%d,%d)", startRune, endRune, c.Text, absStart, absEnd) diff --git a/internal/test/e2e/real_file_large_ime_test.go b/internal/test/e2e/real_file_large_ime_test.go index d2182d2..f51bdfb 100644 --- a/internal/test/e2e/real_file_large_ime_test.go +++ b/internal/test/e2e/real_file_large_ime_test.go @@ -211,12 +211,12 @@ 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. +// TestRealFile_IMEForceResync pins the desync recovery: the first +// anomalous IME commit (a drift-snapped small commit, a distant commit — +// the phone autocorrect that landed 19,000 runes from the caret — or an +// empty fix-up) arms a forced re-syncing snippet re-push on the very next +// frame (a desynced IME is healed only by a restart; a selection push is +// deduplicated away after commits). A normal commit never arms it. func TestRealFile_IMEForceResync(t *testing.T) { model := largeFileContent(4000) h, _ := realFileHarness(t, "resync.txt", model) @@ -227,7 +227,6 @@ func TestRealFile_IMEForceResync(t *testing.T) { 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) { @@ -241,17 +240,13 @@ func TestRealFile_IMEForceResync(t *testing.T) { 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. + // The logic 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 { @@ -266,38 +261,47 @@ func TestRealFile_IMEForceResync(t *testing.T) { return false } - // Two anomalous commits (an empty fix-up and a drifted insertion): - // below the threshold, no resync. + // A normal commit at the caret arms nothing. + commit(caretRune, caretRune, "q") + if forceInLatestFrame() { + t.Fatal("resync armed by a normal commit") + } + + // A small drifted commit (Gboard fix-up at a stale position): the frame + // emitted by that commit carries the resync flag. 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") + t.Fatal("resync not armed after a drift-snapped commit") } - // A later frame (no new anomalies) carries no flag: the resync fires - // exactly once. + // One-shot: a later frame carries no flag. 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") + t.Fatal("resync flag not one-shot") + } + + // A distant replacement commit (width > 2: the autocorrect class that + // the old guard let through) is snapped to the caret and arms the + // resync. + commit(caretRune+20000, caretRune+20005, "silly") + if !forceInLatestFrame() { + t.Fatal("resync not armed after a distant commit") + } + + // The distant commit must have landed at the caret, not at 20000: + // the caret advances by exactly the inserted text length. + var cur int + if err := h.WithState(func(st *editor.State) { + cur = st.Editor.CursorPosition + }); err != nil { + t.Fatalf("WithState: %v", err) + } + // "q" (1) + "silly" (5) inserted at the original caret. + if want := caretByte + 6; cur != want { + t.Fatalf("cursor = %d, want %d (distant commit must land at the caret)", cur, want) } }