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"
|
"strings"
|
||||||
|
|
||||||
"gioui.org/font"
|
"gioui.org/font"
|
||||||
|
"gioui.org/io/key"
|
||||||
"gioui.org/layout"
|
"gioui.org/layout"
|
||||||
"gioui.org/op"
|
"gioui.org/op"
|
||||||
"gioui.org/op/clip"
|
"gioui.org/op/clip"
|
||||||
"gioui.org/op/paint"
|
"gioui.org/op/paint"
|
||||||
"gioui.org/unit"
|
"gioui.org/unit"
|
||||||
"gioui.org/io/key"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Region defines a screen area in device-independent pixels (Dp).
|
// 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.
|
// and draws display lines inline — one LayoutString call, no double-shaping.
|
||||||
func (tf TextField) Draw(gtx layout.Context, r *Renderer) {
|
func (tf TextField) Draw(gtx layout.Context, r *Renderer) {
|
||||||
if tf.Focused {
|
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.FocusCmd{Tag: tf.id})
|
||||||
gtx.Execute(key.SoftKeyboardCmd{Show: true})
|
gtx.Execute(key.SoftKeyboardCmd{Show: true})
|
||||||
// IME wiring. The visible window (tf.Value) is pushed as the snippet
|
// 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,
|
// Item 4: tell the IME this is a text field (enables the text keyboard,
|
||||||
// autocorrect, and suggestions).
|
// autocorrect, and suggestions).
|
||||||
key.InputHintOp{Tag: tf.id, Hint: key.HintText}.Add(gtx.Ops)
|
key.InputHintOp{Tag: tf.id, Hint: key.HintText}.Add(gtx.Ops)
|
||||||
// Item 2: push the snippet (the visible window) for swipe/autocorrect.
|
// Item 2: push the snippet (the visible window) for swipe/autocorrect,
|
||||||
gtx.Execute(key.SnippetCmd{Tag: tf.id, Snippet: key.Snippet{
|
// 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))},
|
Range: key.Range{Start: 0, End: runeCount(tf.Value, len(tf.Value))},
|
||||||
Text: 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
|
// 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)
|
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{}})
|
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)
|
r.drawWrappedText(gtx, tf.Value, tf.region, tf.WrapWidth, tf.ScrollOffset, tf.CursorPosition)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"gioui.org/gesture"
|
"gioui.org/gesture"
|
||||||
"gioui.org/io/event" // Import event package
|
"gioui.org/io/event" // Import event package
|
||||||
"gioui.org/io/input"
|
"gioui.org/io/input"
|
||||||
|
"gioui.org/io/key"
|
||||||
"gioui.org/io/pointer"
|
"gioui.org/io/pointer"
|
||||||
"gioui.org/layout"
|
"gioui.org/layout"
|
||||||
"gioui.org/op"
|
"gioui.org/op"
|
||||||
|
|
@ -66,6 +67,16 @@ type Renderer struct {
|
||||||
displayLineCount int // number of display lines from last drawWrappedText
|
displayLineCount int // number of display lines from last drawWrappedText
|
||||||
lastLineY Dp // last line baseline offset from text origin, in Dp (derived from GlyphLayout)
|
lastLineY Dp // last line baseline offset from text origin, in Dp (derived from GlyphLayout)
|
||||||
glyphLayout GlyphLayout // captured per-glyph layout from last drawWrappedText
|
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.
|
// New creates a new Renderer.
|
||||||
|
|
@ -228,7 +239,7 @@ func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent {
|
||||||
// ScrollY range: Min = -scrollOffset (remaining above), Max = large (content height unknown yet).
|
// ScrollY range: Min = -scrollOffset (remaining above), Max = large (content height unknown yet).
|
||||||
// With Min==Max==0, clampSplit consumes zero scroll.
|
// With Min==Max==0, clampSplit consumes zero scroll.
|
||||||
delta := reg.scroll.Update(m, q, time.Now(), gesture.Vertical,
|
delta := reg.scroll.Update(m, q, time.Now(), gesture.Vertical,
|
||||||
pointer.ScrollRange{}, pointer.ScrollRange{Min: -(1<<30), Max: 1<<30})
|
pointer.ScrollRange{}, pointer.ScrollRange{Min: -(1 << 30), Max: 1 << 30})
|
||||||
if delta != 0 {
|
if delta != 0 {
|
||||||
events = append(events, InputEvent{
|
events = append(events, InputEvent{
|
||||||
Handler: reg.handler,
|
Handler: reg.handler,
|
||||||
|
|
@ -400,7 +411,7 @@ func (r *Renderer) drawText(gtx layout.Context, str string, size unit.Sp, reg Re
|
||||||
case AlignStart:
|
case AlignStart:
|
||||||
drawX = reg.X
|
drawX = reg.X
|
||||||
case AlignCenter:
|
case AlignCenter:
|
||||||
drawX = reg.X + (reg.W - textW) / 2
|
drawX = reg.X + (reg.W-textW)/2
|
||||||
case AlignEnd:
|
case AlignEnd:
|
||||||
drawX = reg.X + reg.W - textW
|
drawX = reg.X + reg.W - textW
|
||||||
}
|
}
|
||||||
|
|
@ -515,7 +526,7 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
|
||||||
var layout GlyphLayout
|
var layout GlyphLayout
|
||||||
layout.LineHeight = Dp(float32(lineHeightSp))
|
layout.LineHeight = Dp(float32(lineHeightSp))
|
||||||
byteOffset := 0
|
byteOffset := 0
|
||||||
layout.VisualLineStarts = append(layout.VisualLineStarts,byteOffset)
|
layout.VisualLineStarts = append(layout.VisualLineStarts, byteOffset)
|
||||||
for g, ok := r.shp.NextGlyph(); ok; g, ok = r.shp.NextGlyph() {
|
for g, ok := r.shp.NextGlyph(); ok; g, ok = r.shp.NextGlyph() {
|
||||||
// Record layout data for this glyph.
|
// Record layout data for this glyph.
|
||||||
// g.X is in fixed.Int26_6 — shift >> 6 for device pixels, divide by scale for Dp.
|
// g.X is in fixed.Int26_6 — shift >> 6 for device pixels, divide by scale for Dp.
|
||||||
|
|
@ -537,7 +548,7 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
|
||||||
line = line[:0]
|
line = line[:0]
|
||||||
if g.Flags&text.FlagLineBreak != 0 {
|
if g.Flags&text.FlagLineBreak != 0 {
|
||||||
lineCount++
|
lineCount++
|
||||||
layout.VisualLineStarts = append(layout.VisualLineStarts,byteOffset)
|
layout.VisualLineStarts = append(layout.VisualLineStarts, byteOffset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user