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.
213 lines
6.6 KiB
Go
213 lines
6.6 KiB
Go
package e2e_test
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"gioui.org/io/key"
|
|
"pad/internal/editor"
|
|
"pad/internal/ui"
|
|
)
|
|
|
|
// largeFileContent generates a deterministic ~1.6 MB text file: 40,000 lines
|
|
// of ~40 unique ASCII bytes each, with a multibyte rune ("é", "中") sprinkled
|
|
// through every 500th line so rune offsets and byte offsets genuinely differ.
|
|
// Generating in-test (t.TempDir, via realFileHarness) keeps the repository
|
|
// small while still exercising the >500 KB chunked/IME path the same way a
|
|
// real multi-hundred-kb note would.
|
|
func largeFileContent(lines int) string {
|
|
var b strings.Builder
|
|
for i := 0; i < lines; i++ {
|
|
b.WriteString(fmt.Sprintf("large file line %05d pad", i))
|
|
if i%500 == 0 {
|
|
b.WriteString(" é 中")
|
|
}
|
|
b.WriteString(strings.Repeat(string(rune('a'+i%26)), 12))
|
|
b.WriteString("\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// TestRealFile_LargeFile_IMECommits pins IME commit correctness on a large
|
|
// (~1.6 MB, well over the 500 KB threshold that the on-device "needs a
|
|
// gioui fork" claim referred to) file:
|
|
//
|
|
// - the IME window invariants (window text, leading context, absolute
|
|
// rune offset) hold for a window deep in the file;
|
|
// - a replacement commit (a selection, as a swipe/autocorrect would send)
|
|
// lands exactly in the scrolled window and nowhere else;
|
|
// - an insert commit (empty range at the caret, a plain typed char) in a
|
|
// second, deeper window lands exactly at the caret.
|
|
//
|
|
// The full-content comparison after each commit catches the classic
|
|
// corruption mode (the commit mapped through a stale/zero window start and
|
|
// clobbering text near the top of the file).
|
|
func TestRealFile_LargeFile_IMECommits(t *testing.T) {
|
|
const (
|
|
lines = 40000
|
|
scrollLn = 20000 // scroll deep into the file
|
|
editLn = 20008 // comfortably inside the ~18-line viewport window
|
|
)
|
|
model := largeFileContent(lines)
|
|
h, path := realFileHarness(t, "large.txt", model)
|
|
defer h.Cleanup()
|
|
|
|
h.SendConfig(780, 400)
|
|
if _, err := h.WaitForFrame(5 * time.Second); err != nil {
|
|
t.Fatalf("wait for frame: %v", err)
|
|
}
|
|
|
|
// Scroll deep (5 dp into the line: avoid the float32/float64 line-pitch
|
|
// boundary fragility, see TestRealFile_WindowSelectionAtScroll) and put a
|
|
// selection on line editLn, 6 bytes into its filler.
|
|
selStart := byteOffsetOfLine(t, model, editLn) + 31
|
|
selEnd := selStart + 6
|
|
if err := h.WithState(func(st *editor.State) {
|
|
st.ScrollOffset = ui.Dp(16.8*scrollLn + 5)
|
|
st.Editor.SelectionStart = selStart
|
|
st.Editor.SelectionEnd = selEnd
|
|
st.Editor.CursorPosition = selEnd
|
|
}); err != nil {
|
|
t.Fatalf("WithState: %v", err)
|
|
}
|
|
prev := h.FrameCount()
|
|
h.SendConfig(780, 400)
|
|
if _, err := h.WaitForFrameCount(prev+1, 5*time.Second); err != nil {
|
|
t.Fatalf("wait for scrolled frame: %v", err)
|
|
}
|
|
|
|
// 1) IME window invariants for a window deep in a large file.
|
|
var win struct {
|
|
Start int
|
|
Text string
|
|
Snip string
|
|
}
|
|
v, err := h.Inspect(func(st *editor.State) any {
|
|
e := &st.Editor
|
|
return struct {
|
|
Start int
|
|
Text string
|
|
Snip string
|
|
}{e.IMEWindowStartByte, e.IMEWindowText, e.IMESnippetText}
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Inspect: %v", err)
|
|
}
|
|
win = v.(struct {
|
|
Start int
|
|
Text string
|
|
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)
|
|
}
|
|
// 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
|
|
// real IME uses; the multibyte sprinkles make runes != bytes).
|
|
absStart := utf8.RuneCountInString(model[:selStart])
|
|
absEnd := utf8.RuneCountInString(model[:selEnd])
|
|
wantModel := model[:selStart] + "ZZ" + model[selEnd:]
|
|
h.SendInput([]ui.InputEvent{{
|
|
Handler: editor.HandleKeyDown,
|
|
Data: key.EditEvent{
|
|
Range: key.Range{Start: absStart, End: absEnd},
|
|
Text: "ZZ",
|
|
},
|
|
}})
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
got, err := h.FullContent()
|
|
if err != nil {
|
|
t.Fatalf("FullContent: %v", err)
|
|
}
|
|
if got != wantModel {
|
|
t.Fatalf("after replacement commit: content differs (first diff at %d)", firstDiff(wantModel, got))
|
|
}
|
|
|
|
// 3) Insert commit in a deeper window: caret-only (empty range), the
|
|
// plain "typed a character" case.
|
|
deepLn := 30000
|
|
caret := byteOffsetOfLine(t, wantModel, deepLn) + 20
|
|
if err := h.WithState(func(st *editor.State) {
|
|
st.ScrollOffset = ui.Dp(16.8*float64(deepLn) + 5)
|
|
st.Editor.SelectionStart = -1
|
|
st.Editor.SelectionEnd = -1
|
|
st.Editor.CursorPosition = caret
|
|
}); err != nil {
|
|
t.Fatalf("WithState: %v", err)
|
|
}
|
|
prev = h.FrameCount()
|
|
h.SendConfig(780, 400)
|
|
if _, err := h.WaitForFrameCount(prev+1, 5*time.Second); err != nil {
|
|
t.Fatalf("wait for second scrolled frame: %v", err)
|
|
}
|
|
absCaret := utf8.RuneCountInString(wantModel[:caret])
|
|
wantModel2 := wantModel[:caret] + "Q" + wantModel[caret:]
|
|
h.SendInput([]ui.InputEvent{{
|
|
Handler: editor.HandleKeyDown,
|
|
Data: key.EditEvent{
|
|
Range: key.Range{Start: absCaret, End: absCaret},
|
|
Text: "Q",
|
|
},
|
|
}})
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
got, err = h.FullContent()
|
|
if err != nil {
|
|
t.Fatalf("FullContent: %v", err)
|
|
}
|
|
if got != wantModel2 {
|
|
t.Fatalf("after insert commit: content differs (first diff at %d)", firstDiff(wantModel2, got))
|
|
}
|
|
if cp, err := h.Inspect(func(st *editor.State) any { return st.Editor.CursorPosition }); err != nil {
|
|
t.Fatalf("Inspect: %v", err)
|
|
} else if cp.(int) != caret+1 {
|
|
t.Fatalf("cursor = %d, want %d", cp.(int), caret+1)
|
|
}
|
|
|
|
// 4) Persistence: the on-disk file matches the in-memory content.
|
|
if err := h.Flush(); err != nil {
|
|
t.Fatalf("flush: %v", err)
|
|
}
|
|
if disk := readDisk(t, path); disk != wantModel2 {
|
|
t.Fatalf("disk content differs from in-memory (first diff at %d)", firstDiff(wantModel2, disk))
|
|
}
|
|
}
|
|
|
|
// byteOffsetOfLine returns the absolute byte offset of the start of line n
|
|
// (0-indexed) in model. The generated lines are unique, so a plain index of
|
|
// the line prefix is unambiguous.
|
|
func byteOffsetOfLine(t *testing.T, model string, n int) int {
|
|
t.Helper()
|
|
prefix := fmt.Sprintf("large file line %05d", n)
|
|
i := strings.Index(model, prefix)
|
|
if i < 0 {
|
|
t.Fatalf("line %d not found", n)
|
|
}
|
|
return i
|
|
}
|
|
|
|
// firstDiff returns the byte offset of the first difference between a and b
|
|
// (min(len) when they are a common prefix), for useful failure messages.
|
|
func firstDiff(a, b string) int {
|
|
n := len(a)
|
|
if len(b) < n {
|
|
n = len(b)
|
|
}
|
|
for i := 0; i < n; i++ {
|
|
if a[i] != b[i] {
|
|
return i
|
|
}
|
|
}
|
|
return n
|
|
}
|