ui: dedup IME snippet/selection to fix rapid-commit desync

TextField.Draw re-pushed key.SnippetCmd and key.SelectionCmd every frame
while focused. Re-pushing an unchanged snippet resets the IME's composition
and caret, so commits arriving faster than a frame interleaved with those
resets and desynced the cursor (garbled/duplicated text).

Mirror widget.Editor's updateSnippet/selection gating: track the last-pushed
snippet and caret in the main-owned Renderer (keyed by field ID, reset on
focus (re)gain), and only emit the ops when they actually change. A fresh
push is forced whenever the field (re)gains focus so the IME always starts
from a known state. Also drop a leftover per-frame 'Focused:' debug print.

On-device: rapid back-to-back commits (0.08-0.1s cadence, faster than a
frame) now land cleanly - type HELLO, append+backspace, and a
browser->editor navigation round-trip all produce exact content.
go test -race ./... green.
This commit is contained in:
Greg Pomerantz 2026-08-16 13:00:25 -04:00
parent 4ee72cf848
commit 46383c1fc1
2 changed files with 151 additions and 113 deletions

View File

@ -7,12 +7,12 @@ import (
"strings"
"gioui.org/font"
"gioui.org/io/key"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/io/key"
)
// Region defines a screen area in device-independent pixels (Dp).
@ -209,7 +209,15 @@ func (tf TextField) String() string {
// and draws display lines inline — one LayoutString call, no double-shaping.
func (tf TextField) Draw(gtx layout.Context, r *Renderer) {
if tf.Focused {
fmt.Printf("Focused: tag = %s\n", tf.id)
// (Re-)gained focus for this field (or a different field than the one the
// dedup state currently tracks): force a fresh snippet/selection push so
// the IME starts from a known state.
if r.lastIMEField != tf.id || !r.lastWasFocused {
r.lastSnippet = key.Snippet{}
r.lastSelCaret = -1
}
r.lastIMEField = tf.id
r.lastWasFocused = true
gtx.Execute(key.FocusCmd{Tag: tf.id})
gtx.Execute(key.SoftKeyboardCmd{Show: true})
// IME wiring. The visible window (tf.Value) is pushed as the snippet
@ -220,16 +228,35 @@ func (tf TextField) Draw(gtx layout.Context, r *Renderer) {
// Item 4: tell the IME this is a text field (enables the text keyboard,
// autocorrect, and suggestions).
key.InputHintOp{Tag: tf.id, Hint: key.HintText}.Add(gtx.Ops)
// Item 2: push the snippet (the visible window) for swipe/autocorrect.
gtx.Execute(key.SnippetCmd{Tag: tf.id, Snippet: key.Snippet{
// Item 2: push the snippet (the visible window) for swipe/autocorrect,
// but only when it changed. Re-pushing an unchanged snippet every frame
// resets the IME's composition/cursor, which desyncs fast commits (see
// widget.Editor's updateSnippet dedup).
snippet := key.Snippet{
Range: key.Range{Start: 0, End: runeCount(tf.Value, len(tf.Value))},
Text: tf.Value,
}})
}
if snippet != r.lastSnippet {
r.lastSnippet = snippet
gtx.Execute(key.SnippetCmd{Tag: tf.id, Snippet: snippet})
}
// Item 1: sync the caret so the IME's selection matches. Window-relative
// rune index of the caret (tf.CursorPosition is a byte offset in tf.Value).
// rune index of the caret (tf.CursorPosition is a byte offset in
// tf.Value). Push only when it moves, so a static caret does not reset
// the IME every frame.
caret := runeCount(tf.Value, tf.CursorPosition)
if caret != r.lastSelCaret {
r.lastSelCaret = caret
gtx.Execute(key.SelectionCmd{Tag: tf.id, Range: key.Range{Start: caret, End: caret}, Caret: key.Caret{}})
}
} else if r.lastIMEField == tf.id {
// This (previously-focused) field lost focus: forget it so the next focus
// pushes a fresh snippet/selection.
r.lastIMEField = ""
r.lastWasFocused = false
r.lastSnippet = key.Snippet{}
r.lastSelCaret = -1
}
r.drawWrappedText(gtx, tf.Value, tf.region, tf.WrapWidth, tf.ScrollOffset, tf.CursorPosition)
}

View File

@ -15,6 +15,7 @@ import (
"gioui.org/gesture"
"gioui.org/io/event" // Import event package
"gioui.org/io/input"
"gioui.org/io/key"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op"
@ -66,6 +67,16 @@ type Renderer struct {
displayLineCount int // number of display lines from last drawWrappedText
lastLineY Dp // last line baseline offset from text origin, in Dp (derived from GlyphLayout)
glyphLayout GlyphLayout // captured per-glyph layout from last drawWrappedText
// IME dedup (main-owned, persistent across frames). Re-pushing an unchanged
// snippet or selection every frame resets the IME's composition and caret,
// which desyncs fast commits; push only on change, as widget.Editor does in
// updateSnippet and its selection gating. Keyed to the focused field's ID so
// it stays correct if a second TextField is ever added.
lastIMEField string
lastWasFocused bool
lastSnippet key.Snippet
lastSelCaret int // window-relative rune index of last-pushed caret; -1 = none
}
// New creates a new Renderer.