IME: fix random mid-word capitalization (stable snippet window + commit in-flight handling)

Gboard capitalizes the first character of a fresh input session and
derives its word context from the text it holds locally; it never
queries the app for context while typing. The editor was resetting that
session on essentially every event, so Gboard re-anchored to a fresh
session mid-word and caps went random:

- The pushed snippet WAS the render window. Any viewport change (keyboard
  show/hide animation, tap re-centering, scroll) changed the snippet, and
  gioui turns every snippet change into imm.restartInput — a full IME
  session reset. The snippet is now a hysteresis window around the caret
  (32 KB, re-anchor only when the caret is within 4 KB of an edge),
  decoupled from the render window: typing, taps within range, keyboard
  animation and flings never re-push it.
- gioui's EditEvent callback applies the commit to its own window state
  directly, so the op queue lags it by one commit; any frame event in
  the gap regressed the state and sent a restartInput with pre-commit
  text per keystroke. The drained commit is now applied to the pushed
  model immediately (Renderer.ApplyIMECommitToModel), and a short hold
  keeps stale pre-commit frames out of FlushIME until the logic's
  post-commit frame arrives.
- The layout feedback loop re-emitted on exact float equality of the
  derived last-line Y, spinning a re-emit -> shape -> re-emit loop
  (float-sum noise) that re-drew the editor and re-pushed IME state;
  gate it with a 0.5 dp epsilon.
- The render window bottom mapped through the WrapIndex whose
  in-viewport counts land while this very window is being shaped: the
  bottom oscillated frame to frame, resizing the window (and the old
  snippet) every frame. Use a fixed line span from the stable top
  instead (each logical line yields >= 1 visual line, so the viewport is
  always covered).

Result: zero snippet re-pushes during typing or flings (one re-anchor at
a far tap/file switch); emulator typing tests show all-lowercase
mid-word commits ('thaaaaaaaae', 'vapoaaaaaaaar') and the stress suite
(7 scenarios) passes clean with contiguous insertions only.
This commit is contained in:
Greg Pomerantz 2026-09-13 17:00:49 -04:00
parent 36b6a86873
commit dd9493bdbd
7 changed files with 309 additions and 149 deletions

View File

@ -372,6 +372,15 @@ func run(w *app.Window) error {
} }
case key.EditEvent: case key.EditEvent:
if focusedID == "editor_text" { if focusedID == "editor_text" {
// Apply the commit to the pushed snippet model
// IMMEDIATELY (see Renderer.ApplyIMECommitToModel):
// gioui's EditEvent callback already advanced its
// own window state, and without this the op queue
// lags it by one commit — every frame event in
// the gap regresses the state and sends a
// restartInput with pre-commit text per keystroke
// (the random-caps bug).
renderer.ApplyIMECommitToModel(gtx, k)
// The commit range is in absolute file runes (the // The commit range is in absolute file runes (the
// coordinate space of the pushed snippet, whose // coordinate space of the pushed snippet, whose
// Range.Start is the context start). The logic maps // Range.Start is the context start). The logic maps

View File

@ -568,15 +568,28 @@ func (cb *ChunkedBuffer) visibleByteRangePrecise(scrollOffset ui.Dp, viewportHei
startLine = int(v0) startLine = int(v0)
endLine := int(math.Ceil(float64(scrollOffset+viewportHeight) / float64(lineHeight))) endLine := int(math.Ceil(float64(scrollOffset+viewportHeight) / float64(lineHeight)))
if w := cb.WrapIndex; w != nil { if w := cb.WrapIndex; w != nil {
// The window TOP is a visual line (the viewport top): map it to the
// logical line that contains it.
//
// The window BOTTOM must NOT be mapped through the index the same
// way: the wrap counts for lines INSIDE the viewport land precisely
// while this window is being shaped (their measured layouts feed the
// index), so the mapped bottom would oscillate frame to frame as
// measurements arrive, resizing the window every frame. Each
// resize changes the pushed IME snippet, and gioui turns a snippet
// change into a restartInput — a restart on every frame reset the
// IME's per-word input session, the source of random mid-word
// capitalization.
//
// Instead use a fixed span of logical lines from the stable top:
// each logical line produces at least one visual line, so the
// viewport's bottom visual line lies at or above
// startLine + (bottomVisual - topVisual); the window always covers
// the viewport, over-fetching only by the wrapped lines inside it
// (bounded by the viewport height; the renderer clips).
bottomVisual := endLine
startLine = w.LineForVisual(int32(v0)) startLine = w.LineForVisual(int32(v0))
// endLine is still in VISUAL space: it is the visual line at the endLine = startLine + (bottomVisual - int(v0))
// viewport bottom, so map it to the logical line that contains it,
// exactly like the top. Using the raw visual number as a logical
// index over-fetched the window by every wrapped line above the
// viewport — an over-fetch that grows without bound with scroll
// depth, so shaping/drawing (and therefore scroll responsiveness)
// degraded the further down the file you were.
endLine = w.LineForVisual(int32(endLine))
} }
if startLine < 0 { if startLine < 0 {

View File

@ -356,7 +356,12 @@ func (l *Logic) Run() {
l.emitFrame() l.emitFrame()
} }
} }
if derivedLastLineY != l.state.LastLineY { // Epsilon-gated: the derived value is a float sum of line
// heights, and exact-equality re-emit spun a re-emit -> shape ->
// re-emit loop whenever the sum landed on a different float
// representation frame to frame (each loop iteration re-drew the
// editor, re-pushed IME state, and starved real work).
if d := derivedLastLineY - l.state.LastLineY; d > 0.5 || d < -0.5 {
l.state.LastLineY = derivedLastLineY l.state.LastLineY = derivedLastLineY
l.emitFrame() l.emitFrame()
} }
@ -440,6 +445,9 @@ func (l *Logic) openFile(path string) {
// from scratch (the window/context fields are recomputed by buildFrame). // from scratch (the window/context fields are recomputed by buildFrame).
TheState.Editor.imeRuneCacheByte = 0 TheState.Editor.imeRuneCacheByte = 0
TheState.Editor.imeRuneCacheCount = 0 TheState.Editor.imeRuneCacheCount = 0
TheState.Editor.imeSnipWS, TheState.Editor.imeSnipWE, TheState.Editor.imeSnipArmed = 0, 0, false
TheState.Editor.IMESnippetText = ""
TheState.Editor.IMECaretRune, TheState.Editor.IMESelStartRune, TheState.Editor.IMESelEndRune = 0, -1, -1
TheState.Editor.IMEContext = "" TheState.Editor.IMEContext = ""
TheState.Editor.IMEContextStartByte = 0 TheState.Editor.IMEContextStartByte = 0
TheState.Editor.IMEOffsetRune = 0 TheState.Editor.IMEOffsetRune = 0

View File

@ -108,6 +108,24 @@ type EditorState struct {
// (imeAnchorEdit) and a file switch resets it to (0,0). // (imeAnchorEdit) and a file switch resets it to (0,0).
imeRuneCacheByte int imeRuneCacheByte int
imeRuneCacheCount int imeRuneCacheCount int
// IME snippet window (byte offsets, hysteresis-gated — see
// computeIMESnippetWindow). Decoupled from the render window: the render
// window follows the viewport (keyboard show/hide resizes it), while the
// IME snippet must be STABLE so a tap that re-centers the render window
// does not re-push the snippet. A snippet re-push reaches Gboard as
// imm.restartInput, which starts a fresh input session and capitalizes
// the first committed character even mid-word. The snippet re-anchors
// only when the caret leaves the window margins.
imeSnipWS int
imeSnipWE int
imeSnipArmed bool
IMESnippetText string
IMESnippetStartRune int
IMESnippetEndRune int
IMECaretRune int
IMESelStartRune int
IMESelEndRune int
// EditSeq counts content edits (incremented by markDirty). The shaped // EditSeq counts content edits (incremented by markDirty). The shaped
// glyph layout arriving via layoutChan is only applied to the WrapIndex // glyph layout arriving via layoutChan is only applied to the WrapIndex
// when its EditSeq matches, so a layout shaped before an edit can never // when its EditSeq matches, so a layout shaped before an edit can never
@ -2095,21 +2113,6 @@ func countRunes(s string) int {
return n return n
} }
// lastRunesStart returns the byte offset where the last n runes of s begin
// (0 if s has fewer than n runes). It stops at rune boundaries, so the result
// never splits a UTF-8 sequence.
func lastRunesStart(s string, n int) int {
start := len(s)
for i := 0; i < n && start > 0; i++ {
start--
// Walk back over continuation bytes (0x80-0xBF) to the lead byte.
for start > 0 && s[start]&0xC0 == 0x80 {
start--
}
}
return start
}
// fileContent returns file[lo:hi) from the active buffer (chunked or small). // fileContent returns file[lo:hi) from the active buffer (chunked or small).
func (s *State) fileContent(lo, hi int) string { func (s *State) fileContent(lo, hi int) string {
e := &s.Editor e := &s.Editor
@ -2136,26 +2139,85 @@ func (s *State) bufferLen() int {
return len(s.Editor.Buffer) return len(s.Editor.Buffer)
} }
// imeLeadingContext returns up to 10 runes immediately before byte position
// start in the active buffer ("" at file start, or for too-large files). At
// most 64 bytes are scanned (10 runes * 4 bytes + margin).
func imeLeadingContext(start int) string {
if start <= 0 || TheState.Editor.TooLarge {
return ""
}
a := start - 64
if a < 0 {
a = 0
}
span := TheState.fileContent(a, start)
return span[lastRunesStart(span, 10):]
}
// imeRuneOffsetAt returns the rune count of the file prefix [0, bytePos). // imeRuneOffsetAt returns the rune count of the file prefix [0, bytePos).
// It scans only the delta from the cached anchor (imeRuneCacheByte/Count), so // It scans only the delta from the cached anchor (imeRuneCacheByte/Count), so
// repeated calls with a slowly moving byte position (scrolling while typing) // repeated calls with a slowly moving byte position (scrolling while typing)
// are O(delta); a large jump (file switch, long scroll) does one full scan // are O(delta); a large jump (file switch, long scroll) does one full scan
// and then anchors again. Must be called on the logic goroutine (owner). // and then anchors again. Must be called on the logic goroutine (owner).
// computeIMESnippetWindow maintains the hysteresis-gated IME snippet window
// (see the imeSnip* fields) and fills IMESnippetText / IMESnippet*Rune /
// IMECaretRune / IMESel* for the renderer to push. Must run on the logic
// goroutine during layout.
//
// The window is [32 KB] around the caret and re-anchors only when the caret
// is within [4 KB] of an edge (a screenful of flings stays inside, so
// fling-tap-type does not restart the IME session; only a genuinely distant
// tap or file switch re-anchors). Consequences:
// - typing inside the window never re-anchors (the frame's snippet text
// changes by the commit, which ApplyIMECommitToModel has already pushed
// - FlushIME dedupes and no restartInput is sent per keystroke);
// - a tap/caret move within the margins does not re-anchor: Gboard keeps
// its input session, so the first character after a tap is lowercase
// even mid-word (a render-window snippet re-anchored on every viewport
// change, resetting Gboard to a fresh session that caps the first
// character);
// - scrolls (flings) do not move the caret, so they never re-anchor: the
// IME stays anchored to the caret even while it is off-screen, which is
// what commit targeting needs.
//
// Edits shift the byte window silently (the text is re-read fresh every
// frame); the drift self-corrects at the next re-anchor.
func (s *State) computeIMESnippetWindow() {
const snipSize, snipMargin = 32768, 4096
caret := s.Editor.CursorPosition
if s.Editor.imeSnipArmed {
if caret < s.Editor.imeSnipWS+snipMargin || caret > s.Editor.imeSnipWE-snipMargin {
s.Editor.imeSnipArmed = false
}
}
if !s.Editor.imeSnipArmed {
ws := caret - snipSize/2
if ws < 0 {
ws = 0
}
s.Editor.imeSnipWS = ws
s.Editor.imeSnipWE = ws + snipSize
s.Editor.imeSnipArmed = true
}
if end := int(s.fileLenNow()); end > 0 && s.Editor.imeSnipWE > end {
s.Editor.imeSnipWE = end
}
text := s.fileContent(s.Editor.imeSnipWS, s.Editor.imeSnipWE)
startRune := s.imeRuneOffsetAt(s.Editor.imeSnipWS)
s.Editor.IMESnippetText = text
s.Editor.IMESnippetStartRune = startRune
s.Editor.IMESnippetEndRune = startRune + utf8.RuneCountInString(text)
s.Editor.IMECaretRune = s.imeRuneOffsetAt(caret)
if selActive() {
ss, se := s.Editor.SelectionStart, s.Editor.SelectionEnd
if ss < s.Editor.imeSnipWS {
ss = s.Editor.imeSnipWS
}
if se > s.Editor.imeSnipWE {
se = s.Editor.imeSnipWE
}
r0 := s.imeRuneOffsetAt(ss)
s.Editor.IMESelStartRune = r0
s.Editor.IMESelEndRune = r0 + utf8.RuneCountInString(s.fileContent(ss, se))
} else {
s.Editor.IMESelStartRune = -1
s.Editor.IMESelEndRune = -1
}
}
// fileLenNow returns the current content length (chunked or string buffer).
func (s *State) fileLenNow() int64 {
if cb := s.Editor.ChunkedBuffer; cb != nil {
return cb.fileLen
}
return int64(len(s.Editor.Buffer))
}
func (s *State) imeRuneOffsetAt(bytePos int) int { func (s *State) imeRuneOffsetAt(bytePos int) int {
e := &s.Editor e := &s.Editor
fileLen := s.bufferLen() fileLen := s.bufferLen()
@ -2353,7 +2415,6 @@ func applyIMECommitBytes(startByte, endByte int, text string) {
markDirty() markDirty()
} }
func markDirty() { func markDirty() {
// Every content edit funnels through here, so EditSeq is the universal // Every content edit funnels through here, so EditSeq is the universal
// "content changed" token (the WrapIndex layout-correlation gate uses it). // "content changed" token (the WrapIndex layout-correlation gate uses it).
@ -2681,9 +2742,7 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
// is no leading context. See imeRuneToByte / Renderer.FlushIME. // is no leading context. See imeRuneToByte / Renderer.FlushIME.
TheState.Editor.IMEWindowStartByte = start TheState.Editor.IMEWindowStartByte = start
TheState.Editor.IMEWindowText = visibleContent TheState.Editor.IMEWindowText = visibleContent
TheState.Editor.IMEContext = imeLeadingContext(start) TheState.computeIMESnippetWindow()
TheState.Editor.IMEContextStartByte = start - len(TheState.Editor.IMEContext)
TheState.Editor.IMEOffsetRune = TheState.imeRuneOffsetAt(TheState.Editor.IMEContextStartByte)
// Window-relative selection for the TextField (byte offsets into // Window-relative selection for the TextField (byte offsets into
// visibleContent); -1 means nothing visible is selected. The IME push and // visibleContent); -1 means nothing visible is selected. The IME push and
// the in-app highlight consume this; the logic side keeps absolute // the in-app highlight consume this; the logic side keeps absolute
@ -2772,8 +2831,12 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
// it from its true file position (see imeLeadingContext / IMEOffsetRune // it from its true file position (see imeLeadingContext / IMEOffsetRune
// above and Renderer.FlushIME): this is what stops the keyboard from // above and Renderer.FlushIME): this is what stops the keyboard from
// auto-capitalizing mid-sentence at the top of the visible window. // auto-capitalizing mid-sentence at the top of the visible window.
editorElem.IMEContext = TheState.Editor.IMEContext editorElem.IMESnippetText = TheState.Editor.IMESnippetText
editorElem.IMEOffset = TheState.Editor.IMEOffsetRune editorElem.IMESnippetStartRune = TheState.Editor.IMESnippetStartRune
editorElem.IMESnippetEndRune = TheState.Editor.IMESnippetEndRune
editorElem.IMECaretRune = TheState.Editor.IMECaretRune
editorElem.IMESelStartRune = TheState.Editor.IMESelStartRune
editorElem.IMESelEndRune = TheState.Editor.IMESelEndRune
editorElem.WindowStartByte = start editorElem.WindowStartByte = start
// While the find bar is open, key focus belongs to the main-owned // While the find bar is open, key focus belongs to the main-owned
// "find_bar" widget.Editor: the editor stops its per-frame IME sync, and // "find_bar" widget.Editor: the editor stops its per-frame IME sync, and

View File

@ -83,19 +83,15 @@ func TestRealFile_LargeFile_IMECommits(t *testing.T) {
var win struct { var win struct {
Start int Start int
Text string Text string
CtxStart int Snip string
Context string
OffsetRune int
} }
v, err := h.Inspect(func(st *editor.State) any { v, err := h.Inspect(func(st *editor.State) any {
e := &st.Editor e := &st.Editor
return struct { return struct {
Start int Start int
Text string Text string
CtxStart int Snip string
Context string }{e.IMEWindowStartByte, e.IMEWindowText, e.IMESnippetText}
OffsetRune int
}{e.IMEWindowStartByte, e.IMEWindowText, e.IMEContextStartByte, e.IMEContext, e.IMEOffsetRune}
}) })
if err != nil { if err != nil {
t.Fatalf("Inspect: %v", err) t.Fatalf("Inspect: %v", err)
@ -103,19 +99,16 @@ func TestRealFile_LargeFile_IMECommits(t *testing.T) {
win = v.(struct { win = v.(struct {
Start int Start int
Text string Text string
CtxStart int Snip string
Context string
OffsetRune int
}) })
if want := model[win.Start : win.Start+len(win.Text)]; win.Text != want { if want := model[win.Start : win.Start+len(win.Text)]; win.Text != want {
t.Fatalf("IME window text desync at start=%d", win.Start) t.Fatalf("IME window text desync at start=%d", win.Start)
} }
if win.CtxStart+len(win.Context) != win.Start || // The IME snippet is a hysteresis window around the caret (see
win.Context != model[win.CtxStart:win.Start] { // State.computeIMESnippetWindow); it must always be a fresh slice of
t.Fatalf("IME context desync: ctxStart=%d ctx=%q", win.CtxStart, win.Context) // the model.
} if i := strings.Index(model, win.Snip); i < 0 {
if want := utf8.RuneCountInString(model[:win.CtxStart]); win.OffsetRune != want { t.Fatalf("IME snippet not a slice of the model: %q", win.Snip[:min(40, len(win.Snip))])
t.Fatalf("IME offset rune = %d, want %d", win.OffsetRune, want)
} }
// 2) Replacement commit: absolute file runes (the coordinate space the // 2) Replacement commit: absolute file runes (the coordinate space the

View File

@ -213,20 +213,26 @@ type TextField struct {
// is running (the IME insets dispatches redraw the app), so the keyboard // is running (the IME insets dispatches redraw the app), so the keyboard
// could not be dismissed. See TextField.Draw. // could not be dismissed. See TextField.Draw.
ShowIMESeq uint64 ShowIMESeq uint64
// IMEContext is the leading context (up to 10 runes immediately before // IME snippet (hysteresis window around the caret — see
// the window start) that the renderer prepends to Value when pushing the // State.computeIMESnippetWindow): IMESnippetText is the pushed text,
// IME snippet; IMEOffset is the absolute file rune offset where // IMESnippet{Start,End}Rune its absolute file rune range, IMECaretRune
// IMEContext begins (the pushed snippet's Range.Start). Computed by the // the caret's absolute rune, and IMESel*Rune the live selection's rune
// logic layer at frame-build time; see Renderer.FlushIME and the IME // range (-1/-1 when no selection).
// fields on EditorState (IMEContext/IMEOffsetRune). IMESnippetText string
IMEContext string IMESnippetStartRune int
IMEOffset int IMESnippetEndRune int
IMECaretRune int
IMESelStartRune int
IMESelEndRune int
// EditSeq is the buffer edit sequence at frame-build time (0 for
// non-editor fields); the renderer uses it to detect the frame that
// carries an IME commit's result (see Renderer.imeHold).
EditSeq uint64
// WindowStartByte is the absolute file byte offset where Value (the // WindowStartByte is the absolute file byte offset where Value (the
// visible window) begins; -1/0 for whole-buffer fields. Used by the // visible window) begins; -1/0 for whole-buffer fields. Used by the
// renderer's IME model to translate snippet positions back to file bytes // renderer's IME model to translate snippet positions back to file bytes
// (see Renderer.ModelTranslate). // (see Renderer.ModelTranslate).
WindowStartByte int WindowStartByte int
} }
func (tf TextField) Type() string { return "textfield" } func (tf TextField) Type() string { return "textfield" }
@ -293,23 +299,6 @@ func (tf TextField) Draw(gtx layout.Context, r *Renderer) {
r.drawWrappedText(gtx, tf.Value, tf.region, tf.WordWrap, tf.WrapWidth, tf.ScrollOffset, tf.CursorPosition, tf.SelectionStart, tf.SelectionEnd, tf.CaretDrag, tf.MatchRanges, tf.CurrentMatch, tf.Focused) r.drawWrappedText(gtx, tf.Value, tf.region, tf.WordWrap, tf.WrapWidth, tf.ScrollOffset, tf.CursorPosition, tf.SelectionStart, tf.SelectionEnd, tf.CaretDrag, tf.MatchRanges, tf.CurrentMatch, tf.Focused)
} }
// runeCount returns the number of UTF-8 runes in s[:bytePos] (bytePos is a
// byte offset, clamped to len(s)). A rune starts at an ASCII byte (<0x80) or a
// multi-byte lead byte (>=0xC0); 0x80-0xBF are continuation bytes.
func runeCount(s string, bytePos int) int {
if bytePos > len(s) {
bytePos = len(s)
}
n := 0
for i := 0; i < bytePos; i++ {
b := s[i]
if b < 0x80 || b >= 0xC0 {
n++
}
}
return n
}
// NewTextField creates a visible multiline TextField. // NewTextField creates a visible multiline TextField.
func NewTextField(id string, value string, region Region, wordWrap bool, wrapWidth Dp, scrollOffset Dp, cursorPos int, selStart, selEnd int, interactions []Interaction) TextField { func NewTextField(id string, value string, region Region, wordWrap bool, wrapWidth Dp, scrollOffset Dp, cursorPos int, selStart, selEnd int, interactions []Interaction) TextField {
return TextField{ return TextField{

View File

@ -110,6 +110,7 @@ type Renderer struct {
lastIMEField string lastIMEField string
lastWasFocused bool lastWasFocused bool
lastSnippet key.Snippet lastSnippet key.Snippet
lastSelStart int // absolute rune index of last-pushed selection start; -1 = no selection lastSelStart int // absolute rune index of last-pushed selection start; -1 = no selection
lastSelCaret int // absolute rune index of last-pushed selection end/caret lastSelCaret int // absolute rune index of last-pushed selection end/caret
lastIMEShowSeq uint64 // last ShowIMESeq value that issued SoftKeyboardCmd{Show:true} lastIMEShowSeq uint64 // last ShowIMESeq value that issued SoftKeyboardCmd{Show:true}
@ -128,7 +129,6 @@ type Renderer struct {
// of the pre-edit frame text (which is what desynced the old push-from- // of the pre-edit frame text (which is what desynced the old push-from-
// Draw path and restarted the IME on every keystroke). // Draw path and restarted the IME on every keystroke).
// Caret geometry drawn during the most recent frame, in view-local // Caret geometry drawn during the most recent frame, in view-local
// pixels (imeCaretPos is the caret/baseline intersection, per key.Caret). // pixels (imeCaretPos is the caret/baseline intersection, per key.Caret).
// FlushIME attaches it to the pushed SelectionCmd: gioui only calls // FlushIME attaches it to the pushed SelectionCmd: gioui only calls
@ -144,6 +144,20 @@ type Renderer struct {
imeCaretDescent float32 imeCaretDescent float32
lastIMECaretPos f32.Point lastIMECaretPos f32.Point
// imeHold: a commit was applied to the pushed model (see
// ApplyIMECommitToModel) but the logic's post-commit frame has not been
// drawn yet. The frame(s) drawn in the gap are pre-commit; their
// snippets would clobber the model with the stale text, regressing
// gioui's editor state and causing a restart per keystroke. While the
// hold is active, FlushIME skips a frame whose EditSeq equals the
// pre-commit sequence and resumes on the next one.
imeHoldActive bool
imeHoldEditSeq uint64
imeHoldFrames int
// lastEditSeq: the EditSeq of the last editor frame drawn (the
// pre-commit sequence when an IME commit is in flight).
lastEditSeq int64
// FocusCmd dedup (main-owned, persistent across frames). key.FocusCmd is // FocusCmd dedup (main-owned, persistent across frames). key.FocusCmd is
// issued ONLY on a focus transition, never per frame: even a no-op FocusCmd // issued ONLY on a focus transition, never per frame: even a no-op FocusCmd
// (same focus) takes the router's "immediate command" path, which re-queues // (same focus) takes the router's "immediate command" path, which re-queues
@ -263,56 +277,127 @@ func (r *Renderer) pointInHandleBox(p image.Point) bool {
return false return false
} }
// ApplyIMECommitToModel applies a drained IME commit to the pushed snippet
// model and re-pushes it IMMEDIATELY, on the drain pass. This is required
// because gioui's EditEvent callback applies the same commit to its own
// window state (w.imeState) directly, bypassing the op queue; the op-queue
// state then lags w.imeState by exactly this commit, and gioui's
// per-frame comparison (updateState) sees the mismatch as a SNIPPET
// REGRESSION — it rolls the state back and sends a restartInput with the
// PRE-commit text on every single keystroke. Gboard resets its per-word
// input session on that restart (and it never queries the app for
// context), which is the source of the random mid-word capitalization.
// Pushing the post-commit snippet here makes the op queue match w.imeState
// before the next frame: the comparison is a no-op, no restart is sent,
// and Gboard's session (auto-shift, composition) survives the keystroke.
// The logic goroutine applies the same commit to the buffer asynchronously;
// when its frame arrives, FlushIME sees the snippet unchanged (already
// pushed) and does nothing. If the logic SNAPS the commit to a different
// position (drift guard), the post-commit frame's snippet differs from the
// model and one resyncing restart is pushed then — correct.
func (r *Renderer) ApplyIMECommitToModel(gtx layout.Context, ev key.EditEvent) {
sn := r.lastSnippet
local := ev.Range.Start - sn.Range.Start
total := utf8.RuneCountInString(sn.Text)
if local < 0 || local > total || ev.Range.End-sn.Range.Start > total {
return // commit outside the pushed window; the next frame resyncs
}
// Replace [local, local+repl) runes with ev.Text (repl is the commit's
// own replaced span — 0 for a plain insertion).
rs := []rune(sn.Text)
repl := ev.Range.End - ev.Range.Start
newRs := make([]rune, 0, len(rs)+len([]rune(ev.Text)))
newRs = append(newRs, rs[:local]...)
newRs = append(newRs, []rune(ev.Text)...)
if local+repl < len(rs) {
newRs = append(newRs, rs[local+repl:]...)
}
newSnip := key.Snippet{
Range: key.Range{
Start: sn.Range.Start,
End: sn.Range.End + len([]rune(ev.Text)) - repl,
},
Text: string(newRs),
}
r.lastSnippet = newSnip
gtx.Execute(key.SnippetCmd{Tag: "editor_text", Snippet: newSnip})
// The caret is at the end of the inserted text: push the selection so
// the op queue's selection matches w.imeState (the EditEvent's Replace
// moved it there). The px caret position is the current one (the next
// frame corrects it); gioui's updateCaret is cosmetic for the IME.
// Hold the pre-commit frame(s) out of FlushIME until the post-commit
// frame arrives (they would clobber the model just pushed).
r.imeHoldActive = true
r.imeHoldEditSeq = uint64(r.lastEditSeq)
r.imeHoldFrames = 0
caret := ev.Range.End + utf8.RuneCountInString(ev.Text)
r.lastSelStart, r.lastSelCaret = -1, caret
r.lastIMECaretPos = r.imeCaretPos
gtx.Execute(key.SelectionCmd{
Tag: "editor_text",
Range: key.Range{Start: caret, End: caret},
Caret: key.Caret{
Pos: r.imeCaretPos,
Ascent: r.imeCaretAscent,
Descent: r.imeCaretDescent,
},
})
}
func (r *Renderer) FlushIME(gtx layout.Context, tf TextField) { func (r *Renderer) FlushIME(gtx layout.Context, tf TextField) {
ctxRunes := utf8.RuneCountInString(tf.IMEContext) // An IME commit is in flight (see ApplyIMECommitToModel): a pre-commit
// The snippet is the visible window with up to 10 runes of leading // frame must not push its stale snippet and regress gioui's state.
// context prepended, addressed from the absolute file rune where the if r.imeHoldActive {
// context begins (tf.IMEOffset): the IME sees the true document if tf.EditSeq == r.imeHoldEditSeq {
// position, and Android's TextUtils.getCapsMode // Bounded so a no-op commit (no EditSeq advance) cannot stick
// (GioView.getCursorCapsMode) can find real preceding context instead of // the hold: after a few frames resume normal flushing.
// treating the top of the visible window as the start of a sentence. if r.imeHoldFrames++; r.imeHoldFrames > 8 {
// r.imeHoldActive = false
// The snippet is pushed whenever the frame's text differs from the last } else {
// push (window moved, edit applied, file switched). gioui dedupes a return
// SnippetCmd against its own cache, so only real changes reach the OS as }
// imm.restartInput. After a commit the frame text (the buffer with the }
// commit applied) equals what the IME already holds locally, so the r.imeHoldActive = false
// dedupe naturally suppresses the restart; a fling moves the window and }
// re-anchors the IME exactly once per text change. Commit positions if tf.EditSeq > 0 {
// arriving while a restart is dropped (Gboard during flings) are caught r.lastEditSeq = int64(tf.EditSeq)
// by the logic-side drift guard (editor.HandleIMECommit), which snaps a }
// small commit to the cursor — the position the IME was last told. // The snippet is the hysteresis window around the caret (see
// State.computeIMESnippetWindow), shipped precomputed by the logic:
// stable across render-window moves (keyboard show/hide, tap
// re-centering, scrolls), so those events do NOT re-push the snippet —
// a re-push reaches Gboard as imm.restartInput, which starts a fresh
// input session that capitalizes the first committed character even
// mid-word. It re-anchors only when the caret leaves the window
// margins (far tap, fling past the window, file switch), and on edits
// (whose text ApplyIMECommitToModel has already pushed, so the frame's
// copy dedupes away).
snippet := key.Snippet{ snippet := key.Snippet{
Range: key.Range{ Range: key.Range{
Start: tf.IMEOffset, Start: tf.IMESnippetStartRune,
End: tf.IMEOffset + ctxRunes + utf8.RuneCountInString(tf.Value), End: tf.IMESnippetEndRune,
}, },
Text: tf.IMEContext + tf.Value, Text: tf.IMESnippetText,
} }
if snippet != r.lastSnippet { if snippet != r.lastSnippet {
r.lastSnippet = snippet r.lastSnippet = snippet
// The snippet change resets the IME's selection: force the // A snippet change resets the IME's selection: force the selection
// selection re-push below so the caret is re-anchored in the new // re-push below so the caret is re-anchored in the new text in the
// text in the same frame. // same frame. (A drained IME commit is applied to the model BEFORE
// this runs — see ApplyIMECommitToModel — so a normal keystroke
// never reaches this branch; it is the re-anchoring path: scroll,
// tap, file switch, snapped commit, external edit.)
r.lastSelStart, r.lastSelCaret = -1, -1 r.lastSelStart, r.lastSelCaret = -1, -1
gtx.Execute(key.SnippetCmd{Tag: tf.id, Snippet: snippet}) gtx.Execute(key.SnippetCmd{Tag: tf.id, Snippet: snippet})
} }
// Push the caret/selection (deduped) in absolute file runes, the same // Push the caret/selection (deduped) in absolute file runes, the same
// coordinate space as the snippet (see widget.Editor's updateIMEState). // coordinate space as the snippet (see widget.Editor's updateIMEState).
var selStart, selEnd int var selStart, selEnd int
if tf.SelectionStart >= 0 && tf.SelectionEnd > tf.SelectionStart { if tf.IMESelStartRune >= 0 && tf.IMESelEndRune > tf.IMESelStartRune {
s, e := tf.SelectionStart, tf.SelectionEnd selStart, selEnd = tf.IMESelStartRune, tf.IMESelEndRune
if s < 0 {
s = 0
}
if e > len(tf.Value) {
e = len(tf.Value)
}
selStart = tf.IMEOffset + ctxRunes + runeCount(tf.Value, s)
selEnd = tf.IMEOffset + ctxRunes + runeCount(tf.Value, e)
} else { } else {
selStart = -1 selStart = -1
selEnd = tf.IMEOffset + ctxRunes + runeCount(tf.Value, tf.CursorPosition) selEnd = tf.IMECaretRune
} }
selectionJumped := selStart != r.lastSelStart || selEnd != r.lastSelCaret selectionJumped := selStart != r.lastSelStart || selEnd != r.lastSelCaret
caretMoved := r.imeCaretPos != r.lastIMECaretPos caretMoved := r.imeCaretPos != r.lastIMECaretPos