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.
198 lines
6.8 KiB
Go
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)
|
|
}
|
|
}
|