diff --git a/cmd/pad/main.go b/cmd/pad/main.go index ee0a679..6e01531 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -380,7 +380,7 @@ func run(w *app.Window) error { // the gap regresses the state and sends a // restartInput with pre-commit text per keystroke // (the random-caps bug). - renderer.ApplyIMECommitToModel(gtx, k) + renderer.ApplyIMECommitToModel(gtx, k, frame.EditSeq) // 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 diff --git a/doc/ime.md b/doc/ime.md index ff12ed5..e44c0eb 100644 --- a/doc/ime.md +++ b/doc/ime.md @@ -49,6 +49,24 @@ is only correct if the two still agree. the caret and arms the resync. A 5-rune autocorrect 19,000 runes from the caret is never legitimate; applying it verbatim clobbers distant text (commit `a83cc1a`). +- **Key-event edits desync the IME exactly like a dropped commit — and the + IME's next commit is anchored at the pre-edit caret.** Gboard sends + Enter as a KEY EVENT, not a commit (the logic's `handleKey(NameReturn)` + inserts the `\n`); the IME's model never sees that edit unless a + re-syncing snippet push reaches it first, and the push can lose the race + — the next commit can arrive in the very same input batch as the key + event, so no frame has drawn. While one such edit is pending + (`EditorState.imeStaleEditByte`), the guard accepts a small (≤ 2 rune) + TEXT commit that ends at or before the edit position (`IME STALE-OK`): + everything before the edit is byte-identical in both models, so applying + it verbatim is exact (the usual shape is the IME appending to, or + correcting, the word it last committed). The pending edit is consumed and + a resync is armed. Never relaxed for: empty-text commits (a stale range + deletion is how documents get clobbered), commits ending past the edit + (the range crosses the shift), or anything without a pending edit + (commit `a83cc1a`'s guard stays intact otherwise). The pending edit is + set by `noteNonIMEEdit` (Enter, hardware backspace/delete, cut, paste), + consumed by the next IME commit or key edit, cleared by a tap. - **A desynchronized IME is healed only by a restart.** See the structural fact below: selection pushes after a commit are deduplicated away, so the *only* mechanism that makes Gboard re-read the truth is a snippet @@ -57,6 +75,26 @@ is only correct if the two still agree. rune (one restart), the following frame ships the full window (one more), then quiet. One-shot, so normal typing never pays for it (commits `cb8ebc0`, `a83cc1a`). +- **Pre-commit frames must not flush the IME — and the hold is armed with + the drained frame's own seq.** `ApplyIMECommitToModel` updates the pushed + model on the drain pass and arms `imeHold` with the **EditSeq of the + frame being drained** (it is the pre-commit frame — built before the + logic processed the commit — and its snippet would clobber the just- + updated model). `FlushIME` skips frames whose EditSeq is at most the + armed one and resumes on the next higher; the logic's `HandleIMECommit` + always markDirties, so the post-commit frame exists and always advances + the seq. The hold is bounded (8 frames) so a stalled logic can never + stick the renderer. Two details that are load-bearing: the frame's + `ui.TextField.EditSeq` is copied from the state in `EditorLayout` — + without it every frame reads 0, `EditSeq <= holdSeq` matches *every* + frame, the hold releases only via the 8-frame timeout, and resync pushes + (and all IME sync) are silently swallowed (this is why the resync armed + by the 2026-09-19 drift-snap never reached Gboard, letting it keep + committing against a stale model); and the seq must be the drained + frame's, not "the last drawn frame's" — the drained frame is pre-commit + even when its seq already differs from earlier ones (a key event landed + in between), and arming with the older value releases the hold on the + very frame whose snippet is stale. ## What stock gioui does structurally (worked around, not patched) @@ -90,6 +128,13 @@ behavioral change. ## Gboard behaviors observed +- Gboard sends Enter as a **key event** (InputConnection.sendKeyEvent), + not a text commit: the app inserts the `\n` via `handleKey(NameReturn)` + and the IME's model never learns about it until a re-syncing snippet + push (see the key-event-edit invariant above). The emulator's Gboard + does the same; its word-appending is milder than the phone's AICore + Gboard, so the desync shows as a caret-off-by-one there and as + duplicated text on the phone. - Gboard keeps a word selection on its own (a tap near a word selects it) and commits autocorrect as a **replacement over its local selection** — the commit range is then as wide as the word, not a point insertion. @@ -113,7 +158,7 @@ Permanent, low-volume lines (see "Diagnostics"): |---|---| | `IME TAP … cursor=N winStart=M` | A tap moved the logic cursor (bytes). Compare with the next `IME COMMIT` ranges (runes) — they should be in the same neighborhood (rune ≈ byte − multibyte count, **compute it, don't assume a constant**). | | `IME COMMIT range=[a,b) text=… -> [x,y)` | The logic applied the commit at bytes [x,y). The pre-`->` range is Gboard's coordinate space; the post-`->` range is where it actually landed. | -| `IME DRIFT-SNAP … snap to caret + resync` | Anomaly detected: a commit far from the caret/selection (or a stale small commit) was applied at the caret instead. One = a tap/scroll desync just happened; a run = Gboard was looping. | +| `IME DRIFT-SNAP … snap to caret + resync` | Anomaly detected: a commit far from the caret/selection (or a stale small commit) was applied at the caret instead. One = a tap/scroll desync just happened; a run = Gboard was looping. | | `IME STALE-OK … staleEdit=N -> apply as-is + resync` | A key-event edit (usually Enter) is pending and the IME's commit was anchored at the pre-edit caret; it was applied at its reported range (byte-identical there) and a resync armed. Expected once per key-edit → keystroke sequence; a run without intervening commits = something is replaying. | | `IME PUSH snippet=[a,b) … (restart)` | We re-anchored the IME window → Gboard restarts its input session. Should be rare: app open, file switch, caret crossing a window margin. Frequent restarts = the hysteresis gate is being defeated (check render-window coupling). | | `IME PUSH sel=[s,e)` | We pushed a caret/selection to Gboard. If a tap does not produce one (or it repeats forever), the selection pipeline is broken upstream (focus gate, dedup, float-noise re-emit). | | `PADIME EditorStateChanged sel A -> B` / `PADIME updateSelection sel=[…]` / `PADIME restartInput` | The gio→Gboard boundary: what actually crossed into Android. If `IME PUSH` shows a value the `PADIME` lines never show, the drop is inside gioui (focus gate / dedup); if `PADIME` shows it and Gboard still misbehaves, the IME ignored it. | diff --git a/internal/editor/state.go b/internal/editor/state.go index 8f640fa..4557422 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -134,8 +134,24 @@ type EditorState struct { // A selection push cannot heal this (it is deduplicated away after // commits); only a restart makes the IME re-fetch the real text and // selection around the caret. Set by HandleIMECommit on the first - // anomaly, consumed by the next frame (EditorLayout). + // anomaly and by noteNonIMEEdit (a key-event edit desyncs the IME just + // as surely as a dropped commit does), consumed by the next frame + // (EditorLayout). IMEForceResync bool + // imeStaleEditByte is the byte position of one content edit the IME did + // NOT make (a key-event edit: Enter, hardware backspace/delete, cut, + // paste) that its local model is missing: the IME still addresses the + // caret as it was before that edit, and its next commit is anchored + // there (observed on device: Gboard sent Enter as a key event, the + // logic inserted the \n, and the very next keystroke committed against + // the pre-Enter caret, which the drift guard misread as a desync and + // snap-duplicated). -1 = no pending edit (the models agree). While set, + // the drift guard accepts one small TEXT commit that ends at or before + // the edit position — everything before it is byte-identical in both + // models, so applying it verbatim is exact (see HandleIMECommit). Any + // later IME commit or non-IME edit consumes it; a tap clears it (the + // tap's selection push / re-anchor re-reads the truth). + imeStaleEditByte 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 @@ -1732,6 +1748,7 @@ func handleMenuCut() { return } TheState.clipboardSetChan <- text + noteNonIMEEdit(e.SelectionStart) deleteRange(e.SelectionStart, e.SelectionEnd) markDirty() e.CursorPosition = e.SelectionStart @@ -1985,6 +2002,7 @@ func utf8AdvanceWidth(seg string) int { func HandleDelete() { e := &TheState.Editor if selActive() { + noteNonIMEEdit(e.SelectionStart) deleteRange(e.SelectionStart, e.SelectionEnd) e.CursorPosition = e.SelectionStart ClearSelection() @@ -1996,6 +2014,7 @@ func HandleDelete() { if buf != nil { seg := buf.Content(pos, pos+4) if w := utf8AdvanceWidth(seg); w > 0 { + noteNonIMEEdit(pos) buf.Delete(pos, w) buf.UpdateLineIndexAfterDelete(pos, pos+w) imeAnchorEdit(pos, seg[:w], "") @@ -2014,6 +2033,9 @@ func HandleDelete() { end = pos + 4 } w := utf8AdvanceWidth(str[pos:end]) + if w > 0 { + noteNonIMEEdit(pos) + } TheState.Editor.Buffer = str[:pos] + str[pos+w:] imeAnchorEdit(pos, str[pos:pos+w], "") e.findEdit(pos, pos+w, 0) @@ -2027,11 +2049,17 @@ func HandleInsert(s string) { // copy, or the writes below are lost. e := &TheState.Editor pos := e.CursorPosition - if selActive() { + replacing := selActive() + if replacing { pos = e.SelectionStart deleteRange(e.SelectionStart, e.SelectionEnd) ClearSelection() } + if s != "" || replacing { + // A key-event/paste edit the IME did not make: desync bookkeeping + // (see noteNonIMEEdit). + noteNonIMEEdit(pos) + } buf := e.ChunkedBuffer if buf != nil { buf.Insert(pos, s) @@ -2052,6 +2080,7 @@ func HandleInsert(s string) { func HandleBackspace() { e := &TheState.Editor if selActive() { + noteNonIMEEdit(e.SelectionStart) deleteRange(e.SelectionStart, e.SelectionEnd) e.CursorPosition = e.SelectionStart ClearSelection() @@ -2069,6 +2098,9 @@ func HandleBackspace() { segStart = 0 } w := utf8BackspaceWidth(buf.Content(segStart, pos)) + if w > 0 { + noteNonIMEEdit(pos - w) + } buf.Delete(pos-w, w) buf.UpdateLineIndexAfterDelete(pos-w, pos) imeAnchorEdit(pos-w, buf.Content(pos-w, pos), "") @@ -2084,6 +2116,9 @@ func HandleBackspace() { segStart = 0 } w := utf8BackspaceWidth(str[segStart:pos]) + if w > 0 { + noteNonIMEEdit(pos - w) + } TheState.Editor.Buffer = str[:pos-w] + str[pos:] imeAnchorEdit(pos-w, str[pos-w:pos], "") TheState.Editor.CursorPosition = pos - w @@ -2350,6 +2385,20 @@ type IMECommit struct { Text string } +// noteNonIMEEdit marks a content edit that the IME did not make: the IME's +// local model is missing it, so until a re-syncing snippet push reaches the +// IME (the next drawn frame, see IMEForceResync) the IME still addresses the +// caret as it was before the edit. Arm the resync now, and remember the edit +// position so the drift guard can accept the one commit the IME is about to +// send against its stale caret instead of snap-duplicating it. pos is the +// edit's byte position (before the mutation). Must be called on the logic +// goroutine (owner), only when the edit actually changes content. +func noteNonIMEEdit(pos int) { + e := &TheState.Editor + e.imeStaleEditByte = pos + e.IMEForceResync = true +} + // HandleIMECommit applies an IME commit from the real app (cmd/pad/main.go // routes editor_text key.EditEvents here; the test harness uses // HandleReplaceRange). In addition to the whole-buffer mapping it applies @@ -2391,6 +2440,28 @@ func HandleIMECommit(data any) { caretRune := TheState.imeRuneOffsetAt(TheState.Editor.CursorPosition) selStartRune, selEndRune := imeSelectionRuneRange() anomalous := endRune-startRune <= 2 && endRune != caretRune + if anomalous && c.Text != "" { + // Stale-caret tolerance (see imeStaleEditByte): a key-event edit + // (Enter, backspace, paste, cut) moved our buffer and caret without + // going through the IME, and the updated snippet may not have reached + // it yet — the commit can arrive in the very same input batch, so no + // frame has drawn. The IME's model still matches ours everywhere + // BEFORE the edit position and it still addresses the pre-edit caret, + // so a small TEXT commit ending at or before the edit position applies + // verbatim against identical text (the usual shape: the IME appending + // to, or correcting, the word it last committed). A range deletion + // (empty text) is never tolerated: deleting at a range the IME sees + // but we no longer have there is how documents get clobbered. The + // pending edit is consumed and a resync is armed: after the verbatim + // apply the IME's model is still missing our key edit, and the next + // frame's forced restart heals it. + if e := TheState.Editor.imeStaleEditByte; e >= 0 && endRune <= TheState.imeRuneOffsetAt(e) { + log.Printf("IME STALE-OK range=[%d,%d) text=%q caret=%d staleEdit=%d -> apply as-is + resync", startRune, endRune, c.Text, caretRune, e) + anomalous = false + TheState.Editor.imeStaleEditByte = -1 + TheState.Editor.IMEForceResync = true + } + } if !anomalous && c.Text != "" { // The commit must be near the caret, or inside the live selection // (a replacement of a user selection, wherever it is). @@ -2423,6 +2494,9 @@ func HandleIMECommit(data any) { // file offsets. Shared by HandleReplaceRange (rune-translated) and // HandleIMECommit (model-translated). Must run on the logic goroutine. func applyIMECommitBytes(startByte, endByte int, text string) { + // The IME made (or, drift-snapped, was snapped to) this edit, so its + // local model accounts for it: no key-edit is pending anymore. + TheState.Editor.imeStaleEditByte = -1 if startByte > endByte { startByte, endByte = endByte, startByte } @@ -2893,6 +2967,13 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { editorElem.IMESelEndRune = TheState.Editor.IMESelEndRune editorElem.IMEForceResync = TheState.Editor.IMEForceResync TheState.Editor.IMEForceResync = false + // EditSeq (the content-edit counter at frame-build time) is what the + // renderer's IME hold compares against: a pre-commit frame carries the + // seq the hold was armed with, a post-commit frame a newer one (which + // releases the hold). Without it every frame reads 0 and the hold can + // only time out, swallowing the resync pushes it is meant to protect + // against regressions from (see Renderer.FlushIME). + editorElem.EditSeq = TheState.Editor.EditSeq 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 @@ -3097,6 +3178,10 @@ func tapLocalY(ptY, regionTopY ui.Dp) float64 { // clears any selection. func SetCursorFromPoint(x, y float64) { ClearSelection() + // A tap re-reads the truth: the selection push (and any snippet + // re-anchor) that follows re-synchronizes the IME, so a pending + // key-edit can no longer describe its model. + TheState.Editor.imeStaleEditByte = -1 if pos, ok := textPosFromLocalPoint(x, y); ok { TheState.Editor.CursorPosition = pos log.Printf("IME TAP local=(%.0f,%.0f) cursor=%d winStart=%d offsetRune=%d", diff --git a/internal/test/e2e/ime_key_edit_test.go b/internal/test/e2e/ime_key_edit_test.go new file mode 100644 index 0000000..9d6b78f --- /dev/null +++ b/internal/test/e2e/ime_key_edit_test.go @@ -0,0 +1,173 @@ +package e2e_test + +import ( + "testing" + "time" + + "gioui.org/io/key" + "pad/internal/editor" + "pad/internal/test/e2e" + "pad/internal/ui" +) + +// incidentContent is the file from the 2026-09-19 phone incident: "test" +// plus five newlines (9 bytes). The tap target is byte 6 (the start of +// line 3), exactly where the user tapped. +const incidentContent = "test\n\n\n\n\n" + +func setCursor(t *testing.T, h *e2e.Harness, pos int) { + t.Helper() + if err := h.WithState(func(st *editor.State) { + st.Editor.CursorPosition = pos + }); err != nil { + t.Fatalf("set cursor: %v", err) + } +} + +// contentAfter sends the input batch (nil = none) and waits until the buffer +// content reaches want; the logic goroutine applies the events +// asynchronously, so a read straight after SendInput is a race. +func contentAfter(t *testing.T, h *e2e.Harness, events []ui.InputEvent, want string) string { + t.Helper() + if events != nil { + h.SendInput(events) + } + for i := 0; i < 300; i++ { + c, err := h.FullContent() + if err != nil { + t.Fatalf("FullContent: %v", err) + } + if c == want { + return c + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("buffer did not reach %q", want) + return "" +} + +// TestIMECommitAfterEnterKeySameBatch is the exact phone incident: tap at +// byte 6, type "a" (Gboard autocaps and commits [6,6)→"A"), press Enter +// (Gboard sends it as a KEY EVENT, not a commit — the logic inserts "\n" +// and the caret moves to 8, but Gboard's local model still lacks it), then +// type "a" (Gboard, one edit behind, commits the word append [6,7)→"Aa" +// against the pre-Enter caret). The key event and the append commit arrive +// in the same input batch, so no frame has drawn between them. +// +// The append must be applied at its reported range — everything before the +// pending key edit is byte-identical in both models — instead of being +// drift-snapped to the new caret and duplicated (the incident produced +// "test\n\nA\nAaAa\n\n\n"). +func TestIMECommitAfterEnterKeySameBatch(t *testing.T) { + h, _ := realFileHarness(t, "test.txt", incidentContent) + defer h.Cleanup() + setCursor(t, h, 6) + + // "a" typed with autocap: one clean IME commit. + contentAfter(t, h, []ui.InputEvent{{ + Handler: editor.HandleIMECommit, + Data: editor.IMECommit{StartRune: 6, EndRune: 6, Text: "A"}, + }}, "test\n\nA\n\n\n") + + // Enter (key event) and the stale-model word append in ONE batch: the + // logic processes the "\n" first, so when the append is guarded the + // caret is already at 8 while the commit ends at 7. "A" must be replaced + // by "Aa" in place and the Enter newline survive after it. + contentAfter(t, h, []ui.InputEvent{ + {Handler: editor.HandleKeyDown, Data: ui.KeyEvent{Name: key.NameReturn}}, + {Handler: editor.HandleIMECommit, Data: editor.IMECommit{StartRune: 6, EndRune: 7, Text: "Aa"}}, + }, "test\n\nAa\n\n\n\n") + + if pos, _ := h.CursorPosition(); pos != 8 { + t.Errorf("cursor: got %d want 8 (end of the replaced word)", pos) + } +} + +// TestIMECommitAfterEnterKeyNextBatch is the same incident with the append +// commit arriving in a LATER input batch than the Enter: a frame was +// emitted for the Enter, but the resyncing snippet push only lands on the +// next drawn frame, and the IME's commit can beat it. The pending key edit +// must still be honored. +func TestIMECommitAfterEnterKeyNextBatch(t *testing.T) { + h, _ := realFileHarness(t, "test.txt", incidentContent) + defer h.Cleanup() + setCursor(t, h, 6) + + contentAfter(t, h, []ui.InputEvent{{ + Handler: editor.HandleIMECommit, + Data: editor.IMECommit{StartRune: 6, EndRune: 6, Text: "A"}, + }}, "test\n\nA\n\n\n") + + contentAfter(t, h, []ui.InputEvent{{ + Handler: editor.HandleKeyDown, + Data: ui.KeyEvent{Name: key.NameReturn}, + }}, "test\n\nA\n\n\n\n") + + contentAfter(t, h, []ui.InputEvent{{ + Handler: editor.HandleIMECommit, + Data: editor.IMECommit{StartRune: 6, EndRune: 7, Text: "Aa"}, + }}, "test\n\nAa\n\n\n\n") +} + +// TestIMEEmptyCommitStillSnappedWithPendingEdit: a pending key edit does +// NOT relax the guard for range deletions (empty text). Deleting at a range +// the IME sees but the app no longer expects there is how documents get +// clobbered; such commits must still snap to the caret (a no-op there). +func TestIMEEmptyCommitStillSnappedWithPendingEdit(t *testing.T) { + h, _ := realFileHarness(t, "test.txt", incidentContent) + defer h.Cleanup() + setCursor(t, h, 6) + + contentAfter(t, h, []ui.InputEvent{{ + Handler: editor.HandleIMECommit, + Data: editor.IMECommit{StartRune: 6, EndRune: 6, Text: "A"}, + }}, "test\n\nA\n\n\n") + + contentAfter(t, h, []ui.InputEvent{{ + Handler: editor.HandleKeyDown, + Data: ui.KeyEvent{Name: key.NameReturn}, + }}, "test\n\nA\n\n\n\n") + + // Gboard's stale model still shows "A" at [6,7) and asks to delete it. + // The commit must snap to the caret (a no-op there), so the "A" + // survives. The content is unchanged by a correct snap, so poll for + // quiescence (a new frame after the input) before asserting. + contentAfter(t, h, []ui.InputEvent{{ + Handler: editor.HandleIMECommit, + Data: editor.IMECommit{StartRune: 6, EndRune: 7, Text: ""}, + }}, "test\n\nA\n\n\n\n") + before := h.FrameCount() + for h.FrameCount() == before { + time.Sleep(10 * time.Millisecond) + } + if c, _ := h.FullContent(); c != "test\n\nA\n\n\n\n" { + t.Fatalf("empty commit must snap to the caret (no-op), got %q", c) + } +} + +// TestIMECommitPastPendingEditStillSnapped: a commit that ends PAST the +// pending key edit addresses text the IME's model does not have (it is +// shifted by the edit), so it is still anomalous and snaps to the caret. +func TestIMECommitPastPendingEditStillSnapped(t *testing.T) { + h, _ := realFileHarness(t, "test.txt", incidentContent) + defer h.Cleanup() + setCursor(t, h, 6) + + contentAfter(t, h, []ui.InputEvent{{ + Handler: editor.HandleIMECommit, + Data: editor.IMECommit{StartRune: 6, EndRune: 6, Text: "A"}, + }}, "test\n\nA\n\n\n") + + contentAfter(t, h, []ui.InputEvent{{ + Handler: editor.HandleKeyDown, + Data: ui.KeyEvent{Name: key.NameReturn}, + }}, "test\n\nA\n\n\n\n") + + // Ends at 9, past the pending edit at 7: the range crosses the edit, so + // the file content there is NOT what the IME sees. Snap to the caret + // (8): "x" lands at 8, not 9. + contentAfter(t, h, []ui.InputEvent{{ + Handler: editor.HandleIMECommit, + Data: editor.IMECommit{StartRune: 9, EndRune: 9, Text: "x"}, + }}, "test\n\nA\nx\n\n\n") +} diff --git a/internal/ui/render.go b/internal/ui/render.go index 7d53e82..d849b85 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -147,17 +147,18 @@ type Renderer struct { // 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. + // drawn yet. The frame being drained is pre-commit (it was built before + // the logic processed the commit); its snippet would clobber the model + // with the stale text, regressing gioui's editor state and causing a + // restart per keystroke. The hold is armed with the drained frame's + // EditSeq: while the hold is active, FlushIME skips a frame whose + // EditSeq is at most the armed one (the drained frame, and any no-op + // frame repeating the seq) and resumes on the next higher one. The + // logic's HandleIMECommit markDirties the buffer, so the post-commit + // frame always advances it. 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 @@ -296,7 +297,9 @@ func (r *Renderer) pointInHandleBox(p image.Point) bool { // 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) { +// frameEditSeq is the EditSeq of the frame the commit was drained from +// (the pre-commit frame); it arms the hold (see the imeHold field). +func (r *Renderer) ApplyIMECommitToModel(gtx layout.Context, ev key.EditEvent, frameEditSeq uint64) { sn := r.lastSnippet local := ev.Range.Start - sn.Range.Start total := utf8.RuneCountInString(sn.Text) @@ -327,9 +330,12 @@ func (r *Renderer) ApplyIMECommitToModel(gtx layout.Context, ev key.EditEvent) { // 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). + // frame arrives (they would clobber the model just pushed). Arm with + // the drained frame's own EditSeq: it is the seq of the last + // pre-commit frame, and the post-commit frame's seq is strictly higher + // (the logic's HandleIMECommit markDirties the buffer). r.imeHoldActive = true - r.imeHoldEditSeq = uint64(r.lastEditSeq) + r.imeHoldEditSeq = frameEditSeq r.imeHoldFrames = 0 // The post-commit caret is at the END of the inserted text, which // starts at Range.Start: for an insertion (Start == End) that is @@ -357,9 +363,10 @@ func (r *Renderer) FlushIME(gtx layout.Context, tf TextField) { // 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 tf.EditSeq <= r.imeHoldEditSeq { + // Bounded so a commit whose post-commit frame never arrives + // (logic stall) cannot stick the hold: after a few frames + // resume normal flushing. if r.imeHoldFrames++; r.imeHoldFrames > 8 { r.imeHoldActive = false } else { @@ -368,9 +375,6 @@ func (r *Renderer) FlushIME(gtx layout.Context, tf TextField) { } 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