Pad/internal/test/e2e/real_file_scroll_selection_test.go
Greg Pomerantz 941285e7cc Fix scroll-anchored selection (shadowing) + IME insets keyboard handling
Two user-reported bugs in the post-wrap build:

1. Selection 'jumped' when scrolling: frameOf's chunked branch shadowed the
   outer start/end with 'start, end, winLine := cb.VisibleByteRange(...)',
   leaving IMEWindowStartByte at 0 for chunked files while scrolled, so the
   window-relative selection highlight (and IME commits) mis-mapped near the
   file top. Fix: declare winLine outside, assign. Mutation-verified
   regression tests: real_file_scroll_selection_test.go.

2. Bottom bar hidden behind the keyboard: GioView extends SurfaceView, which
   unconditionally marks the window FORMAT_TRANSLUCENT; translucent windows
   are never IME-resized, so adjustResize is dead. Fix (build scripts):
   smali-patch a PadInsetsListener OnApplyWindowInsetsListener onto the
   GioView (shrinks it by the IME inset bottom) + setDecorFitsSystemWindows
   (false) on API 30+ so insets are dispatched. targetSdk kept at 34.

3. (Found while fixing 2) Keyboard could not be dismissed: TextField.Draw
   issued SoftKeyboardCmd{Show:true} every frame; with insets-driven
   resizes, the hide animation triggered redraws that re-showed the keyboard
   mid-animation. Fix: ShowIMESeq pulse from the logic layer (open/tap/
   double-tap); renderer shows only on pulse change, re-arming on focus
   loss — matching widget.Editor.

On-device (emulator): BACK dismisses and stays down; tap re-shows; typing
works; bottom bar above keyboard; word selection anchored across flick
scroll. go test -race ./... green. Phone APK rebuilt with the same patch.
2026-08-17 21:52:53 -04:00

198 lines
6.8 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 window-relative EditEvent
// range: the IME reports offsets relative to the pushed snippet, which
// is the visible window.
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 pre-fix code (windowStart=0)
// applied this at byte 47 — line 2 — corrupting the file.
h.SendInput([]ui.InputEvent{{
Handler: editor.HandleKeyDown,
Data: key.EditEvent{
Range: key.Range{Start: selStart - scrollLn*lineLen, End: selEnd - scrollLn*lineLen},
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)
}
}