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:
parent
36b6a86873
commit
dd9493bdbd
|
|
@ -372,6 +372,15 @@ func run(w *app.Window) error {
|
|||
}
|
||||
case key.EditEvent:
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -83,19 +83,15 @@ func TestRealFile_LargeFile_IMECommits(t *testing.T) {
|
|||
var win struct {
|
||||
Start int
|
||||
Text string
|
||||
CtxStart int
|
||||
Context string
|
||||
OffsetRune int
|
||||
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}
|
||||
Snip string
|
||||
}{e.IMEWindowStartByte, e.IMEWindowText, e.IMESnippetText}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Inspect: %v", err)
|
||||
|
|
@ -103,19 +99,16 @@ func TestRealFile_LargeFile_IMECommits(t *testing.T) {
|
|||
win = v.(struct {
|
||||
Start int
|
||||
Text string
|
||||
CtxStart int
|
||||
Context string
|
||||
OffsetRune int
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user