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).
@ -47,9 +47,9 @@ type Container struct {
Children []Element // Changed to Exported
}
func (c Container) Type() string { return "container" }
func (c Container) Region() Region { return c.region }
func (c Container) Visible() bool { return c.visible }
func (c Container) Type() string { return "container" }
func (c Container) Region() Region { return c.region }
func (c Container) Visible() bool { return c.visible }
func (c Container) Draw(gtx layout.Context, r *Renderer) {
// Draw background — children are drawn by drawElement, not here
if c.background != (Color{}) {
@ -96,9 +96,9 @@ type Label struct {
Bold bool
}
func (l Label) Type() string { return "label" }
func (l Label) Region() Region { return l.region }
func (l Label) Visible() bool { return l.visible }
func (l Label) Type() string { return "label" }
func (l Label) Region() Region { return l.region }
func (l Label) Visible() bool { return l.visible }
func (l Label) Interactions() []Interaction { return l.interactions }
func (l Label) Draw(gtx layout.Context, r *Renderer) {
col := l.Color
@ -109,7 +109,7 @@ func (l Label) Draw(gtx layout.Context, r *Renderer) {
// which correctly applies the container offset. No separate RegisterClick needed.
r.drawText(gtx, l.Text, l.FontSize, l.region, l.Align, col, l.id)
}
func (l Label) ID() string { return l.id }
func (l Label) ID() string { return l.id }
// String returns a string representation of the Label.
func (l Label) String() string {
@ -139,9 +139,9 @@ type Icon struct {
Size Dp // 0 = default icon size
}
func (i Icon) Type() string { return "icon" }
func (i Icon) Region() Region { return i.region }
func (i Icon) Visible() bool { return i.visible }
func (i Icon) Type() string { return "icon" }
func (i Icon) Region() Region { return i.region }
func (i Icon) Visible() bool { return i.visible }
func (i Icon) Interactions() []Interaction { return i.interactions }
func (i Icon) Draw(gtx layout.Context, r *Renderer) {
img := r.icon(i.Name)
@ -157,7 +157,7 @@ func (i Icon) Draw(gtx layout.Context, r *Renderer) {
}
r.drawPng(gtx, img, i.region, w, h)
}
func (i Icon) ID() string { return i.id }
func (i Icon) ID() string { return i.id }
// String returns a string representation of the Icon.
func (i Icon) String() string {
@ -178,27 +178,27 @@ func NewIcon(name string, region Region, size Dp, interactions []Interaction) Ic
// TextField accepts text input or displays multiline text.
type TextField struct {
id string
region Region
visible bool
interactions []Interaction
Value string
Placeholder string
Focused bool
Multiline bool
id string
region Region
visible bool
interactions []Interaction
Value string
Placeholder string
Focused bool
Multiline bool
CursorPosition int
ScrollOffset Dp
VisibleLines []Line
WordWrap bool
WrapWidth Dp
ScrollOffset Dp
VisibleLines []Line
WordWrap bool
WrapWidth Dp
}
func (tf TextField) Type() string { return "textfield" }
func (tf TextField) Region() Region { return tf.region }
func (tf TextField) Visible() bool { return tf.visible }
func (tf TextField) ID() string { return tf.id }
func (tf TextField) Type() string { return "textfield" }
func (tf TextField) Region() Region { return tf.region }
func (tf TextField) Visible() bool { return tf.visible }
func (tf TextField) ID() string { return tf.id }
func (tf TextField) Interactions() []Interaction { return tf.interactions }
func (tf TextField) NeedsClip() bool { return true }
func (tf TextField) NeedsClip() bool { return true }
// String returns a string representation of the TextField.
func (tf TextField) String() string {
@ -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,15 +228,34 @@ 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)
gtx.Execute(key.SelectionCmd{Tag: tf.id, Range: key.Range{Start: caret, End: caret}, Caret: key.Caret{}})
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)
}
@ -253,15 +280,15 @@ func runeCount(s string, bytePos int) int {
// NewTextField creates a visible multiline TextField.
func NewTextField(id string, value string, region Region, wrapWidth Dp, scrollOffset Dp, cursorPos int, interactions []Interaction) TextField {
return TextField{
id: id,
region: region,
visible: true,
interactions: interactions,
Value: value,
Multiline: true,
WordWrap: true,
WrapWidth: wrapWidth,
ScrollOffset: scrollOffset,
id: id,
region: region,
visible: true,
interactions: interactions,
Value: value,
Multiline: true,
WordWrap: true,
WrapWidth: wrapWidth,
ScrollOffset: scrollOffset,
CursorPosition: cursorPos,
}
}
@ -274,21 +301,21 @@ type Line struct {
// ListView displays a scrollable list of items.
type ListView struct {
id string
region Region
visible bool
interactions []Interaction
Items []ListItem
ScrollOffset Dp // pixel-level scroll offset
Selected int
id string
region Region
visible bool
interactions []Interaction
Items []ListItem
ScrollOffset Dp // pixel-level scroll offset
Selected int
RowTapHandler func(any) // handler for row taps, receives index as any
}
func (lv ListView) Type() string { return "listview" }
func (lv ListView) Region() Region { return lv.region }
func (lv ListView) Visible() bool { return lv.visible }
func (lv ListView) ID() string { return lv.id }
func (lv ListView) NeedsClip() bool { return true }
func (lv ListView) Type() string { return "listview" }
func (lv ListView) Region() Region { return lv.region }
func (lv ListView) Visible() bool { return lv.visible }
func (lv ListView) ID() string { return lv.id }
func (lv ListView) NeedsClip() bool { return true }
func (lv ListView) Interactions() []Interaction {
// ListView registers its scroll in Draw, not via registerInteraction.
// Filter out Scroll so registerInteraction only handles Tap.
@ -340,12 +367,12 @@ func (lv ListView) Draw(gtx layout.Context, r *Renderer) {
}
// Draw background for selected item (removed as per request)
/*
if item.Selected || (lv.Selected == i) {
r.drawBg(gtx, Region{
X: lv.region.X, Y: y,
W: lv.region.W, H: rowHeight,
}, Color{R: 200, G: 220, B: 255, A: 255})
}
if item.Selected || (lv.Selected == i) {
r.drawBg(gtx, Region{
X: lv.region.X, Y: y,
W: lv.region.W, H: rowHeight,
}, Color{R: 200, G: 220, B: 255, A: 255})
}
*/
// Register click area for this row
rowID := fmt.Sprintf("list_row_%d", rowGlobalIndex)
@ -421,10 +448,10 @@ type AlphaIndex struct {
ActiveLetter string
}
func (ai AlphaIndex) Type() string { return "alphaindex" }
func (ai AlphaIndex) Region() Region { return ai.region }
func (ai AlphaIndex) Visible() bool { return ai.visible }
func (ai AlphaIndex) ID() string { return ai.id }
func (ai AlphaIndex) Type() string { return "alphaindex" }
func (ai AlphaIndex) Region() Region { return ai.region }
func (ai AlphaIndex) Visible() bool { return ai.visible }
func (ai AlphaIndex) ID() string { return ai.id }
func (ai AlphaIndex) Interactions() []Interaction { return ai.interactions }
// String returns a string representation of the AlphaIndex.
@ -452,15 +479,15 @@ type Button struct {
Primary bool
}
func (b Button) Type() string { return "button" }
func (b Button) Region() Region { return b.region }
func (b Button) Visible() bool { return b.visible }
func (b Button) Type() string { return "button" }
func (b Button) Region() Region { return b.region }
func (b Button) Visible() bool { return b.visible }
func (b Button) Interactions() []Interaction { return b.interactions }
func (b Button) Draw(gtx layout.Context, r *Renderer) {
col := Color{R: 0, G: 0, B: 0, A: 255}
r.drawText(gtx, b.Text, r.theme.FontSize, b.region, AlignStart, col, b.id)
}
func (b Button) ID() string { return b.id }
func (b Button) ID() string { return b.id }
// String returns a string representation of the Button.
func (b Button) String() string {
@ -490,10 +517,10 @@ type SearchBar struct {
Forward bool
}
func (sb SearchBar) Type() string { return "searchbar" }
func (sb SearchBar) Region() Region { return sb.region }
func (sb SearchBar) Visible() bool { return sb.visible }
func (sb SearchBar) ID() string { return sb.id }
func (sb SearchBar) Type() string { return "searchbar" }
func (sb SearchBar) Region() Region { return sb.region }
func (sb SearchBar) Visible() bool { return sb.visible }
func (sb SearchBar) ID() string { return sb.id }
func (sb SearchBar) Interactions() []Interaction { return sb.interactions }
// String returns a string representation of the SearchBar.
@ -526,11 +553,11 @@ type Cursor struct {
Selection *Selection
}
func (c Cursor) NeedsClip() bool { return true }
func (c Cursor) Type() string { return "cursor" }
func (c Cursor) Region() Region { return c.region }
func (c Cursor) Visible() bool { return c.visible }
func (c Cursor) ID() string { return c.id }
func (c Cursor) NeedsClip() bool { return true }
func (c Cursor) Type() string { return "cursor" }
func (c Cursor) Region() Region { return c.region }
func (c Cursor) Visible() bool { return c.visible }
func (c Cursor) ID() string { return c.id }
func (c Cursor) Interactions() []Interaction { return c.interactions }
// String returns a string representation of the Cursor.
@ -596,10 +623,10 @@ type MergeHunk struct {
Resolution HunkResolution
}
func (mh MergeHunk) Type() string { return "mergehunk" }
func (mh MergeHunk) Region() Region { return mh.region }
func (mh MergeHunk) Visible() bool { return mh.visible }
func (mh MergeHunk) ID() string { return mh.id }
func (mh MergeHunk) Type() string { return "mergehunk" }
func (mh MergeHunk) Region() Region { return mh.region }
func (mh MergeHunk) Visible() bool { return mh.visible }
func (mh MergeHunk) ID() string { return mh.id }
func (mh MergeHunk) Interactions() []Interaction { return mh.interactions }
// String returns a string representation of the MergeHunk.
@ -627,10 +654,10 @@ type Toast struct {
Timeout int // milliseconds
}
func (t Toast) Type() string { return "toast" }
func (t Toast) Region() Region { return t.region }
func (t Toast) Visible() bool { return t.visible }
func (t Toast) ID() string { return t.id }
func (t Toast) Type() string { return "toast" }
func (t Toast) Region() Region { return t.region }
func (t Toast) Visible() bool { return t.visible }
func (t Toast) ID() string { return t.id }
func (t Toast) Interactions() []Interaction { return t.interactions }
// String returns a string representation of the Toast.
@ -656,10 +683,10 @@ type Spacer struct {
interactions []Interaction
}
func (s Spacer) Type() string { return "spacer" }
func (s Spacer) Region() Region { return s.region }
func (s Spacer) Visible() bool { return s.visible }
func (s Spacer) ID() string { return s.id }
func (s Spacer) Type() string { return "spacer" }
func (s Spacer) Region() Region { return s.region }
func (s Spacer) Visible() bool { return s.visible }
func (s Spacer) ID() string { return s.id }
func (s Spacer) Interactions() []Interaction { return s.interactions }
// String returns a string representation of the Spacer.
@ -685,10 +712,10 @@ type GioEditor struct {
interactions []Interaction
}
func (ge GioEditor) Type() string { return "gioeditor" }
func (ge GioEditor) Region() Region { return ge.region }
func (ge GioEditor) Visible() bool { return ge.visible }
func (ge GioEditor) ID() string { return ge.id }
func (ge GioEditor) Type() string { return "gioeditor" }
func (ge GioEditor) Region() Region { return ge.region }
func (ge GioEditor) Visible() bool { return ge.visible }
func (ge GioEditor) ID() string { return ge.id }
func (ge GioEditor) Interactions() []Interaction { return ge.interactions }
// String returns a string representation of the GioEditor.

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"
@ -55,17 +56,27 @@ type scrollReg struct {
// that Gio mutates during draw (e.g. the search bar's widget.Editor): such
// state must not live in the logic goroutine's State (architecture.md §1).
type Renderer struct {
theme Theme
shp *text.Shaper
scale float32 // px-per-Dp for the current draw pass; set in Draw
icons map[string]image.Image
clicks map[string]clickReg
Keys map[string]keyReg // Exported Keys map
scrolls map[string]scrollReg
gioEditors map[string]*widget.Editor // main-owned widget editors by element ID
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
theme Theme
shp *text.Shaper
scale float32 // px-per-Dp for the current draw pass; set in Draw
icons map[string]image.Image
clicks map[string]clickReg
Keys map[string]keyReg // Exported Keys map
scrolls map[string]scrollReg
gioEditors map[string]*widget.Editor // main-owned widget editors by element ID
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.
@ -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).
// With Min==Max==0, clampSplit consumes zero scroll.
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 {
events = append(events, InputEvent{
Handler: reg.handler,
@ -400,7 +411,7 @@ func (r *Renderer) drawText(gtx layout.Context, str string, size unit.Sp, reg Re
case AlignStart:
drawX = reg.X
case AlignCenter:
drawX = reg.X + (reg.W - textW) / 2
drawX = reg.X + (reg.W-textW)/2
case AlignEnd:
drawX = reg.X + reg.W - textW
}
@ -486,9 +497,9 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
PxPerEm: fixed.I(gtx.Sp(r.theme.FontSize)),
MinWidth: 0,
MaxWidth: int(r.toPx(wrapWidth)),
MaxLines: 0, // unlimited - wrap at MaxWidth
MaxLines: 0, // unlimited - wrap at MaxWidth
LineHeight: fixed.I(gtx.Sp(lineHeightSp)),
LineHeightScale: 1.0, // use LineHeight directly, don't scale
LineHeightScale: 1.0, // use LineHeight directly, don't scale
WrapPolicy: text.WrapHeuristically,
}
r.shp.LayoutString(params, str)
@ -515,7 +526,7 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
var layout GlyphLayout
layout.LineHeight = Dp(float32(lineHeightSp))
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() {
// Record layout data for this glyph.
// 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]
if g.Flags&text.FlagLineBreak != 0 {
lineCount++
layout.VisualLineStarts = append(layout.VisualLineStarts,byteOffset)
layout.VisualLineStarts = append(layout.VisualLineStarts, byteOffset)
}
}
}
@ -565,7 +576,7 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
cursorY = reg.Y - scrollOffset + layout.Y[len(layout.Y)-1] - Dp(r.theme.FontSize)
}
}
// Draw the cursor (thin vertical bar)
cursorRegion := Region{
X: cursorX,