From 51344b9a5b86fc9d46c2acebd7f766d8fe0c65f8 Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Sun, 13 Sep 2026 18:58:27 -0400 Subject: [PATCH] IME: fix post-commit caret for replacement commits; trace IME pushes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ApplyIMECommitToModel computed the caret pushed to the IME right after a commit as Range.End + len(text). That is correct for a plain insertion (Start == End) but wrong for a replacement — the case a swipe/autocorrect over a selection produces: the inserted text starts at Range.Start, so the caret is Range.Start + len(text). A 4-rune replacement (the 'samet' autocorrect on the phone) pushed the caret 4 runes past the inserted text, desynchronizing the IME's caret from ours at exactly the moment its local model was being updated; the persistent 4-rune drift that followed (every commit 4 runes ahead, then the empty-fix-up loop) matches this. The logic side (applyIMECommitBytes) already computed the correct caret; this aligns the renderer's immediate push with it. Also log every IME push (snippet restarts, selection updates, commit pushes) from the renderer: the phone incident's logcat rotated before it could be re-analyzed, so the next occurrence must be reconstructable from what we actually pushed to the IME. --- internal/ui/render.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/internal/ui/render.go b/internal/ui/render.go index 1eb2d56..7d53e82 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -24,6 +24,7 @@ import ( "gioui.org/unit" "gioui.org/widget" "golang.org/x/image/math/fixed" +"log" ) // maxInt32 is a large value used as MaxWidth for single-line text layout. @@ -330,7 +331,15 @@ func (r *Renderer) ApplyIMECommitToModel(gtx layout.Context, ev key.EditEvent) { r.imeHoldActive = true r.imeHoldEditSeq = uint64(r.lastEditSeq) r.imeHoldFrames = 0 - caret := ev.Range.End + utf8.RuneCountInString(ev.Text) + // The post-commit caret is at the END of the inserted text, which + // starts at Range.Start: for an insertion (Start == End) that is + // Range.End + len, but for a replacement (a swipe/autocorrect over a + // selection) it is Range.Start + len — Range.End + len would land + // (End-Start) runes past the inserted text and desynchronize the IME's + // caret from ours (observed on the phone: a 4-rune replacement left + // every subsequent commit 4 runes ahead of the caret). + caret := ev.Range.Start + utf8.RuneCountInString(ev.Text) + log.Printf("IME PUSH commit-range=[%d,%d) text=%q -> sel caret=%d", ev.Range.Start, ev.Range.End, ev.Text, caret) r.lastSelStart, r.lastSelCaret = -1, caret r.lastIMECaretPos = r.imeCaretPos gtx.Execute(key.SelectionCmd{ @@ -405,6 +414,7 @@ func (r *Renderer) FlushIME(gtx layout.Context, tf TextField) { // 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.) + log.Printf("IME PUSH snippet=[%d,%d) len=%d (restart)", snippet.Range.Start, snippet.Range.End, utf8.RuneCountInString(snippet.Text)) r.lastSelStart, r.lastSelCaret = -1, -1 gtx.Execute(key.SnippetCmd{Tag: tf.id, Snippet: snippet}) } @@ -428,6 +438,7 @@ func (r *Renderer) FlushIME(gtx layout.Context, tf TextField) { r.lastSelStart = selStart r.lastSelCaret = selEnd r.lastIMECaretPos = r.imeCaretPos + log.Printf("IME PUSH sel=[%d,%d)", selStart, selEnd) rng := key.Range{Start: selStart, End: selEnd} if selStart < 0 { rng = key.Range{Start: selEnd, End: selEnd}