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