IME: guard distant commits; arm the resync on the first anomaly
The boundary trace (PADIME at EditorStateChanged) showed why a desynchronized IME never heals itself: gioui's EditorReplace advances its stored selection state when the commit arrives, so the post-commit selection push is always deduplicated away and updateSelection is never sent after a commit. A desynced IME is therefore healed only by a snippet restart — and the resync was armed only after THREE consecutive anomalies, while the phone incident's first anomaly (a 5-rune autocorrect 19,000 runes from the caret, width > 2) sailed through the old <=2-rune guard and clobbered distant text before any resync could fire. Now: - the guard also snaps commits that are far from the caret AND outside the live selection (a legitimate commit is always local: at the caret, inside the selection, or a nearby correction); a distant commit lands at the caret instead of clobbering text; - the resync is armed on the FIRST anomalous commit (snapped or distant), not after a streak; the streak counter is gone; - imeSelectionRuneRange() lets the guard allow selection replacements anywhere. TestRealFile_IMEForceResync rewritten for the new semantics and extended with the distant-replacement case (must land at the caret and arm the resync).
This commit is contained in:
parent
51344b9a5b
commit
a83cc1a72f
2
go.mod
2
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
|
||||
|
|
|
|||
2
go.sum
2
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=
|
||||
|
|
|
|||
|
|
@ -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,29 +2375,44 @@ func HandleIMECommit(data any) {
|
|||
if startRune > endRune {
|
||||
startRune, endRune = endRune, startRune
|
||||
}
|
||||
snapped := false
|
||||
if endRune-startRune <= 2 {
|
||||
// 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)
|
||||
if endRune != caretRune {
|
||||
log.Printf("IME DRIFT-SNAP range=[%d,%d) caret=%d -> snap to caret", startRune, endRune, caretRune)
|
||||
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
|
||||
}
|
||||
}
|
||||
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
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user