diff --git a/cmd/pad/main.go b/cmd/pad/main.go index 6f73450..ee0a679 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -371,27 +371,36 @@ func run(w *app.Window) error { }) } case key.EditEvent: - if focusedID == "editor_text" { - // The commit range is in absolute file runes (the - // coordinate space of the pushed snippet, whose - // Range.Start is the context start). The logic maps - // it to bytes against the whole buffer — exact - // even mid-fling — and applies the drift guard - // (see editor.HandleIMECommit). + 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 + // coordinate space of the pushed snippet, whose + // Range.Start is the context start). The logic maps + // it to bytes against the whole buffer — exact + // even mid-fling — and applies the drift guard + // (see editor.HandleIMECommit). + events = append(events, ui.InputEvent{ + Handler: editor.HandleIMECommit, + Data: editor.IMECommit{ + StartRune: k.Range.Start, + EndRune: k.Range.End, + Text: k.Text, + }, + }) + break + } events = append(events, ui.InputEvent{ - Handler: editor.HandleIMECommit, - Data: editor.IMECommit{ - StartRune: k.Range.Start, - EndRune: k.Range.End, - Text: k.Text, - }, + Handler: reg.Handler, + Data: k, }) - break - } - events = append(events, ui.InputEvent{ - Handler: reg.Handler, - Data: k, - }) case key.SnippetEvent: // Handle snippet event if necessary, or ignore case key.FocusEvent: diff --git a/internal/editor/chunked_buffer.go b/internal/editor/chunked_buffer.go index e327483..13a0841 100644 --- a/internal/editor/chunked_buffer.go +++ b/internal/editor/chunked_buffer.go @@ -568,15 +568,28 @@ func (cb *ChunkedBuffer) visibleByteRangePrecise(scrollOffset ui.Dp, viewportHei startLine = int(v0) endLine := int(math.Ceil(float64(scrollOffset+viewportHeight) / float64(lineHeight))) 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)) - // endLine is still in VISUAL space: it is the visual line at the - // 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)) + endLine = startLine + (bottomVisual - int(v0)) } if startLine < 0 { diff --git a/internal/editor/logic.go b/internal/editor/logic.go index fe33780..f1e2890 100644 --- a/internal/editor/logic.go +++ b/internal/editor/logic.go @@ -356,7 +356,12 @@ func (l *Logic) Run() { 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.emitFrame() } @@ -440,6 +445,9 @@ func (l *Logic) openFile(path string) { // from scratch (the window/context fields are recomputed by buildFrame). TheState.Editor.imeRuneCacheByte = 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.IMEContextStartByte = 0 TheState.Editor.IMEOffsetRune = 0 @@ -762,7 +770,7 @@ func (l *Logic) handleWorkerResult(res pool.Result) { } else if res.TaskType == pool.TypeReadFile { if res.Success { if content, ok := res.Data.([]byte); ok { - if l.state.Editor.ChunkedBuffer != nil { + if l.state.Editor.ChunkedBuffer != nil { // Full-load the in-range file: set the whole content (split into // resident chunks). No lazy load, so no stale-disk re-read. l.state.Editor.ChunkedBuffer.SetFileSize(int64(len(content))) diff --git a/internal/editor/state.go b/internal/editor/state.go index 4d7cc99..096508e 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -108,6 +108,24 @@ type EditorState struct { // (imeAnchorEdit) and a file switch resets it to (0,0). imeRuneCacheByte 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 // glyph layout arriving via layoutChan is only applied to the WrapIndex // when its EditSeq matches, so a layout shaped before an edit can never @@ -2095,21 +2113,6 @@ func countRunes(s string) int { 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). func (s *State) fileContent(lo, hi int) string { e := &s.Editor @@ -2136,26 +2139,85 @@ func (s *State) bufferLen() int { 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). // It scans only the delta from the cached anchor (imeRuneCacheByte/Count), so // 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 // 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 { e := &s.Editor fileLen := s.bufferLen() @@ -2353,7 +2415,6 @@ func applyIMECommitBytes(startByte, endByte int, text string) { markDirty() } - func markDirty() { // Every content edit funnels through here, so EditSeq is the universal // "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. TheState.Editor.IMEWindowStartByte = start TheState.Editor.IMEWindowText = visibleContent - TheState.Editor.IMEContext = imeLeadingContext(start) - TheState.Editor.IMEContextStartByte = start - len(TheState.Editor.IMEContext) - TheState.Editor.IMEOffsetRune = TheState.imeRuneOffsetAt(TheState.Editor.IMEContextStartByte) + TheState.computeIMESnippetWindow() // Window-relative selection for the TextField (byte offsets into // visibleContent); -1 means nothing visible is selected. The IME push and // 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 // above and Renderer.FlushIME): this is what stops the keyboard from // auto-capitalizing mid-sentence at the top of the visible window. - editorElem.IMEContext = TheState.Editor.IMEContext - editorElem.IMEOffset = TheState.Editor.IMEOffsetRune + editorElem.IMESnippetText = TheState.Editor.IMESnippetText + 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 // 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 diff --git a/internal/test/e2e/real_file_large_ime_test.go b/internal/test/e2e/real_file_large_ime_test.go index ae0c4aa..616d550 100644 --- a/internal/test/e2e/real_file_large_ime_test.go +++ b/internal/test/e2e/real_file_large_ime_test.go @@ -81,41 +81,34 @@ func TestRealFile_LargeFile_IMECommits(t *testing.T) { // 1) IME window invariants for a window deep in a large file. var win struct { - Start int - Text string - CtxStart int - Context string - OffsetRune int + Start int + Text string + Snip string } v, err := h.Inspect(func(st *editor.State) any { e := &st.Editor return struct { - Start int - Text string - CtxStart int - Context string - OffsetRune int - }{e.IMEWindowStartByte, e.IMEWindowText, e.IMEContextStartByte, e.IMEContext, e.IMEOffsetRune} + Start int + Text string + Snip string + }{e.IMEWindowStartByte, e.IMEWindowText, e.IMESnippetText} }) if err != nil { t.Fatalf("Inspect: %v", err) } win = v.(struct { - Start int - Text string - CtxStart int - Context string - OffsetRune int + Start int + Text string + Snip string }) if want := model[win.Start : win.Start+len(win.Text)]; win.Text != want { t.Fatalf("IME window text desync at start=%d", win.Start) } - if win.CtxStart+len(win.Context) != win.Start || - win.Context != model[win.CtxStart:win.Start] { - t.Fatalf("IME context desync: ctxStart=%d ctx=%q", win.CtxStart, win.Context) - } - if want := utf8.RuneCountInString(model[:win.CtxStart]); win.OffsetRune != want { - t.Fatalf("IME offset rune = %d, want %d", win.OffsetRune, want) + // The IME snippet is a hysteresis window around the caret (see + // State.computeIMESnippetWindow); it must always be a fresh slice of + // the model. + if i := strings.Index(model, win.Snip); i < 0 { + t.Fatalf("IME snippet not a slice of the model: %q", win.Snip[:min(40, len(win.Snip))]) } // 2) Replacement commit: absolute file runes (the coordinate space the diff --git a/internal/ui/element.go b/internal/ui/element.go index ce99597..536173c 100644 --- a/internal/ui/element.go +++ b/internal/ui/element.go @@ -213,20 +213,26 @@ type TextField struct { // is running (the IME insets dispatches redraw the app), so the keyboard // could not be dismissed. See TextField.Draw. ShowIMESeq uint64 - // IMEContext is the leading context (up to 10 runes immediately before - // the window start) that the renderer prepends to Value when pushing the - // IME snippet; IMEOffset is the absolute file rune offset where - // IMEContext begins (the pushed snippet's Range.Start). Computed by the - // logic layer at frame-build time; see Renderer.FlushIME and the IME - // fields on EditorState (IMEContext/IMEOffsetRune). - IMEContext string - IMEOffset int + // IME snippet (hysteresis window around the caret — see + // State.computeIMESnippetWindow): IMESnippetText is the pushed text, + // IMESnippet{Start,End}Rune its absolute file rune range, IMECaretRune + // the caret's absolute rune, and IMESel*Rune the live selection's rune + // range (-1/-1 when no selection). + IMESnippetText string + IMESnippetStartRune 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 // visible window) begins; -1/0 for whole-buffer fields. Used by the // renderer's IME model to translate snippet positions back to file bytes // (see Renderer.ModelTranslate). WindowStartByte int - } 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) } -// 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. func NewTextField(id string, value string, region Region, wordWrap bool, wrapWidth Dp, scrollOffset Dp, cursorPos int, selStart, selEnd int, interactions []Interaction) TextField { return TextField{ diff --git a/internal/ui/render.go b/internal/ui/render.go index ef65bef..0a6360c 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -110,6 +110,7 @@ type Renderer struct { lastIMEField string lastWasFocused bool lastSnippet key.Snippet + lastSelStart int // absolute rune index of last-pushed selection start; -1 = no selection lastSelCaret int // absolute rune index of last-pushed selection end/caret 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- // Draw path and restarted the IME on every keystroke). - // Caret geometry drawn during the most recent frame, in view-local // pixels (imeCaretPos is the caret/baseline intersection, per key.Caret). // FlushIME attaches it to the pushed SelectionCmd: gioui only calls @@ -144,6 +144,20 @@ type Renderer struct { imeCaretDescent float32 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 // 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 @@ -263,56 +277,127 @@ func (r *Renderer) pointInHandleBox(p image.Point) bool { 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) { - ctxRunes := utf8.RuneCountInString(tf.IMEContext) - // The snippet is the visible window with up to 10 runes of leading - // context prepended, addressed from the absolute file rune where the - // context begins (tf.IMEOffset): the IME sees the true document - // position, and Android's TextUtils.getCapsMode - // (GioView.getCursorCapsMode) can find real preceding context instead of - // treating the top of the visible window as the start of a sentence. - // - // The snippet is pushed whenever the frame's text differs from the last - // push (window moved, edit applied, file switched). gioui dedupes a - // 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 - // dedupe naturally suppresses the restart; a fling moves the window and - // re-anchors the IME exactly once per text change. Commit positions - // arriving while a restart is dropped (Gboard during flings) are caught - // by the logic-side drift guard (editor.HandleIMECommit), which snaps a - // small commit to the cursor — the position the IME was last told. + // An IME commit is in flight (see ApplyIMECommitToModel): a pre-commit + // frame must not push its stale snippet and regress gioui's state. + if r.imeHoldActive { + if tf.EditSeq == r.imeHoldEditSeq { + // Bounded so a no-op commit (no EditSeq advance) cannot stick + // the hold: after a few frames resume normal flushing. + if r.imeHoldFrames++; r.imeHoldFrames > 8 { + r.imeHoldActive = false + } else { + return + } + } + r.imeHoldActive = false + } + if tf.EditSeq > 0 { + r.lastEditSeq = int64(tf.EditSeq) + } + // 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{ Range: key.Range{ - Start: tf.IMEOffset, - End: tf.IMEOffset + ctxRunes + utf8.RuneCountInString(tf.Value), + Start: tf.IMESnippetStartRune, + End: tf.IMESnippetEndRune, }, - Text: tf.IMEContext + tf.Value, + Text: tf.IMESnippetText, } if snippet != r.lastSnippet { r.lastSnippet = snippet - // The snippet change resets the IME's selection: force the - // selection re-push below so the caret is re-anchored in the new - // text in the same frame. + // A snippet change resets the IME's selection: force the selection + // re-push below so the caret is re-anchored in the new text in the + // 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 gtx.Execute(key.SnippetCmd{Tag: tf.id, Snippet: snippet}) } // Push the caret/selection (deduped) in absolute file runes, the same // coordinate space as the snippet (see widget.Editor's updateIMEState). var selStart, selEnd int - if tf.SelectionStart >= 0 && tf.SelectionEnd > tf.SelectionStart { - s, e := tf.SelectionStart, tf.SelectionEnd - 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) + if tf.IMESelStartRune >= 0 && tf.IMESelEndRune > tf.IMESelStartRune { + selStart, selEnd = tf.IMESelStartRune, tf.IMESelEndRune } else { selStart = -1 - selEnd = tf.IMEOffset + ctxRunes + runeCount(tf.Value, tf.CursorPosition) + selEnd = tf.IMECaretRune } selectionJumped := selStart != r.lastSelStart || selEnd != r.lastSelCaret caretMoved := r.imeCaretPos != r.lastIMECaretPos