IME: accept the one stale-caret commit after a key-event edit; fix the IME hold
Gboard sends Enter as a key event, not a commit, so the IME's model lags the buffer by exactly one edit and its next commit is anchored at the pre-edit caret. The drift guard snapped such commits to the new caret, duplicating the just-typed word (2026-09-19 phone incident: 'a<enter>a' became 'A' + 'AaAa'). - state.go: track one pending non-IME content edit (imeStaleEditByte, armed by noteNonIMEEdit on Enter/backspace/delete/cut/paste, consumed by the next IME commit or key edit, cleared by a tap). The guard accepts a small (<= 2 rune) TEXT commit ending at or before the edit position as-is (IME STALE-OK): everything before the edit is byte-identical in both models. Empty-text commits and commits ending past the edit still snap to the caret. - render.go + state.go + main.go: arm the pre-commit flush hold with the drained frame's own EditSeq. The hold was armed with the last drawn frame's seq while the frame's TextField.EditSeq was never set (always 0), so every frame matched and the hold released only via its 8-frame timeout - silently swallowing the resync pushes (and all IME sync) that heal the desync. EditorLayout now ships EditSeq with the frame's TextField; FlushIME releases on the next higher seq. - e2e (ime_key_edit_test.go): reproduce the incident (same-batch and next-batch variants) and pin the guard edges (deletions and past-the-edit commits still snap). - ime.md: document the key-event desync invariant and the hold.
This commit is contained in:
parent
9d5c02db03
commit
a9183a0f73
|
|
@ -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
|
||||
|
|
|
|||
47
doc/ime.md
47
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. |
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
173
internal/test/e2e/ime_key_edit_test.go
Normal file
173
internal/test/e2e/ime_key_edit_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user