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:
parent
4ee72cf848
commit
46383c1fc1
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user