Pad/internal/test/e2e/real_file_scroll_selection_test.go
Greg Pomerantz f54ca2f81a IME: map commits against the whole buffer; drop the renderer-side model
The renderer kept a mirror of the pushed IME snippet (the 'IME model')
to translate commit positions, but it transiently desynced from the
buffer on fling/tap sequences (observed as a few-byte mapping drift on
both the x86_64 emulator and the ARM phone), corrupting text. The model
string also sat on the main goroutine next to the JNI render path,
where the app observed states that were impossible for Go memory
(string contents changing between reads microseconds apart), pointing
at corruption in the native bridge layer.

Restructure along the lines of the Android InputConnection contract
and Gio's own reference editor (widget/editor.go):

- Commits carry absolute file runes (the pushed snippet's coordinate
  space) straight to the logic goroutine, which maps them to bytes
  against the WHOLE buffer (runeToByteWhole, an 8 KiB-step scan).
  Scrolling moves the window, not the buffer, so the mapping is exact
  mid-fling by construction — no mirror to desync.
- Drift guard in HandleIMECommit: a small commit (range <= 2 runes)
  is always anchored at the caret the IME was last told about; if the
  IME reports it ending elsewhere, its snippet text is stale (a
  dropped restartInput, as Gboard does during flings) and its
  position is in the stale text's coordinates — snap the commit to
  the cursor, the only position it cannot drift from.
- FlushIME simplifies to: push the snippet when the frame's
  (context+window) text differs from the last push (gioui dedupes
  against its own cache), force the selection re-push in the same
  frame. After a commit the frame text equals what the IME already
  holds locally, so the restart is naturally suppressed; a fling
  re-anchors the IME once per text change.
- Remove the renderer model (adoptFrame/ModelTranslate/
  ApplyIMEEdit/ApplyIMEKey/IMECaret), the IME freeze/settle
  machinery (IMEFrozen, markIMEScrollActive, imeSettleChan), and the
  window-relative imeRuneToByte.

Also fixed along the way (both found while chasing the corruption):

- real.ReadFileAt: loop over short reads. A single ReadAt on Android
  FUSE can return a short read, silently truncating a chunk and
  shifting every byte offset after it.
- logic: a late lazy-chunk result no longer clobbers a buffer that
  SetContent has already fully loaded.
- e2e: large-file IME test (1.6 MB file, fling + commit).
- app icon (scripts/make_icon.py + cmd/pad/appicon.png) so gogio
  builds the mipmap/adaptive icon set.

Verified: go vet + staticcheck, go test -race (all packages), and the
emulator scenario loop (open moby excerpt, fling to mid-file, tap,
type 'a', byte-compare the saved file) 75/75 clean.
2026-09-13 11:57:38 -04:00

202 lines
7.0 KiB
Go

package e2e_test
import (
"fmt"
"strings"
"testing"
"time"
"gioui.org/io/key"
"pad/internal/editor"
"pad/internal/test/e2e"
"pad/internal/ui"
)
// uniformContent returns a file of n lines, each exactly 21 bytes:
// "Lxxx" + 16 filler chars (derived from the line number, so every line is
// unique) + "\n". Line i therefore starts at byte i*21.
func uniformContent(n int) string {
var b strings.Builder
for i := 0; i < n; i++ {
b.WriteString(fmt.Sprintf("L%03d", i))
b.WriteString(strings.Repeat(string(rune('a'+i%26)), 16))
b.WriteString("\n")
}
return b.String()
}
// The chunked-buffer window bookkeeping must stay in lockstep with the
// visible window text:
// - Editor.IMEWindowStartByte is the absolute byte where the window starts
// (it offsets the IME's window-relative EditEvent.Range into the buffer);
// - the TextField's SelectionStart/End are window-relative.
//
// A classic shadowing regression broke both (a `:=` inside the
// `cb != nil` block of frameOf shadowed the outer start/end, so both were
// always 0 for chunked files): at non-zero scroll the selection highlight
// was mapped against byte 0 (it "jumped" to different text as the user
// scrolled) and IME commits landed near the top of the file instead of at
// the caret — silent data corruption. These tests scroll to a non-zero
// window start and pin both mappings.
// lastEditorTextField finds the editor_text TextField in the latest captured
// frame and reports whether it was found.
func lastEditorTextField(t *testing.T, h *e2e.Harness) (ui.TextField, bool) {
t.Helper()
frames := h.GetFrames()
if len(frames) == 0 {
t.Fatal("no frames captured")
}
for _, elem := range frames[len(frames)-1] {
if tf, ok := elem.(ui.TextField); ok && tf.ID() == "editor_text" {
return tf, true
}
}
return ui.TextField{}, false
}
func TestRealFile_WindowSelectionAtScroll(t *testing.T) {
const (
lines = 60
lineLen = 21
scrollLn = 10 // window starts at line 10 (byte 210)
)
// 60 lines * 16.8 dp = 1008 dp of content; a 400 dp window gives a
// ~304 dp editor region (~18 lines), so the file is scrollable and the
// window at line 10 is fully inside the file.
h, _ := realFileHarness(t, "win.txt", uniformContent(lines))
defer h.Cleanup()
h.SendConfig(780, 400)
if _, err := h.WaitForFrame(5 * time.Second); err != nil {
t.Fatalf("wait for frame: %v", err)
}
// Select 6 filler chars on line 12 (well inside the window that starts
// at line 10). Filler starts at in-line offset 4 (after "L012").
selStart := 12*lineLen + 9 // byte 261
selEnd := 12*lineLen + 15 // byte 267
// Scroll 5 dp INTO line 10: the app's line pitch is a float32 (16.8000007…)
// that differs in the last bits from this file's float64 16.8, so scrolling
// to the exact line boundary is fragile; an interior offset is robust.
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)
}
// Config re-triggers a frame (state changes alone do not emit frames).
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) The IME window start must be the window's absolute byte start.
v, err := h.Inspect(func(st *editor.State) any {
return st.Editor.IMEWindowStartByte
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
if got, want := v.(int), scrollLn*lineLen; got != want {
t.Fatalf("IMEWindowStartByte = %d, want %d (window starts at line %d)", got, want, scrollLn)
}
// 2) The rendered window-relative selection must be offset by that same
// start (the pre-fix code mapped it against byte 0).
tf, ok := lastEditorTextField(t, h)
if !ok {
t.Fatal("no editor_text TextField in latest frame")
}
wantS, wantE := selStart-scrollLn*lineLen, selEnd-scrollLn*lineLen
if tf.SelectionStart != wantS || tf.SelectionEnd != wantE {
t.Fatalf("window selection = [%d,%d), want [%d,%d)", tf.SelectionStart, tf.SelectionEnd, wantS, wantE)
}
// The window text must actually begin at the window start.
if want, got := "L010", tf.Value[:4]; got != want {
t.Fatalf("window text starts with %q, want %q", got, want)
}
}
func TestRealFile_IMECommitAtScroll(t *testing.T) {
const (
lines = 60
lineLen = 21
scrollLn = 10
editLn = 12
)
h, path := realFileHarness(t, "win2.txt", uniformContent(lines))
defer h.Cleanup()
h.SendConfig(780, 400)
if _, err := h.WaitForFrame(5 * time.Second); err != nil {
t.Fatalf("wait for frame: %v", err)
}
// The selection (absolute) and the matching EditEvent range: the IME
// reports offsets in absolute file runes (the coordinate space of the
// pushed snippet, which now starts at the leading context's true file
// position). The content is ASCII, so rune == byte and the range is just
// the absolute byte offsets.
selStart := editLn*lineLen + 5
selEnd := editLn*lineLen + 11
// Scroll 5 dp into line 10 (see TestRealFile_WindowSelectionAtScroll for
// why the offset avoids the exact line boundary).
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)
}
// Typing replaces the selection: "ZZ" over the 6 selected filler chars
// (in-line offsets [5,11)) on line 12. The commit range is in absolute
// file runes (== bytes here). The pre-fix code (windowStart=0, no
// leading context) applied a mis-mapped range near the top of the file,
// corrupting it.
h.SendInput([]ui.InputEvent{{
Handler: editor.HandleKeyDown,
Data: key.EditEvent{
Range: key.Range{Start: selStart, End: selEnd},
Text: "ZZ",
},
}})
time.Sleep(100 * time.Millisecond)
got, err := h.FullContent()
if err != nil {
t.Fatalf("FullContent: %v", err)
}
linesOut := strings.Split(got, "\n")
// Expectation derived from the original line: in-line [5,11) replaced.
filler := strings.Repeat(string(rune('a'+editLn%26)), 16)
origLn := fmt.Sprintf("L%03d", editLn) + filler
wantLn12 := origLn[:5] + "ZZ" + origLn[11:]
if linesOut[editLn] != wantLn12 {
t.Fatalf("line %d = %q, want %q (IME commit must land in the scrolled window, not at byte 0)", editLn, linesOut[editLn], wantLn12)
}
// Line 2 must be untouched (the pre-fix corruption target).
if want := fmt.Sprintf("L%03d", 2) + strings.Repeat("c", 16); linesOut[2] != want {
t.Fatalf("line 2 = %q, want %q (must not be clobbered by a scrolled commit)", linesOut[2], want)
}
// Persistence check: the on-disk file matches.
if err := h.Flush(); err != nil {
t.Fatalf("flush: %v", err)
}
if disk := readDisk(t, path); disk != got {
t.Fatalf("disk = %q, want in-memory content", disk)
}
}