Pad/internal/editor/selection_test.go
Greg Pomerantz ec11abf8f1 Add text selection, real-file e2e tests, and Android arrow-key support
Selection: shift+arrow extends a selection (absolute byte offsets,
anchor/caret model); insert/backspace/delete replace the selection; the
IME unions its reported range with the active selection; the highlight
is drawn in the TextField and the selection is pushed to the IME.

Android key input (the blocker found during on-device validation):
Gio v0.10 on Android (a) drops modifier state in the JNI bridge and
(b) wraps plain arrow-key presses in input.SystemEvent for focus
navigation, so arrow keys never reached the editor. main.go now
registers explicit named key.Filters for the four arrows (delivers the
press and suppresses the focus jump) and tracks the shift key itself.
Verified on the emulator: plain arrows move the caret, shift+arrow
shows a highlight, typing replaces the selection.

Real-file e2e tests (real on-disk files via the real FileSystem,
multi-chunk 256KB files, chunk-boundary and multi-byte edits) found
and fixed two real bugs:
  1. Line index: UpdateLineIndexAfterEdit only shifted offsets; edits
     involving newlines left it permanently inconsistent. Replaced with
     newline-aware UpdateLineIndexAfterInsert/UpdateLineIndexAfterDelete.
  2. Rune granularity: HandleBackspace/HandleDelete deleted one byte,
     corrupting multi-byte UTF-8 characters (e.g. a 2-byte char
     straddling a chunk boundary). Now rune-granular.

Also: airtight e2e harness load-wait (StatFile/ReadFile/BuildLineIndex
interleaving could satisfy the old condition early).

Full suite green under -race; on-device verified.
2026-08-17 00:32:53 -04:00

348 lines
11 KiB
Go

package editor
import (
"testing"
"gioui.org/io/key"
"pad/internal/ui"
)
// selState sets up a fresh state with a string buffer (the selection logic
// is buffer-implementation independent; chunked-buffer coverage is in the
// e2e real-file tests).
func selState(content string) {
TheState = NewState()
TheState.Editor.Buffer = content
TheState.Editor.CursorPosition = len(content)
}
// assertSelection checks the normalized selection triple.
func assertSelection(t *testing.T, wantStart, wantEnd int) {
t.Helper()
e := TheState.Editor
if e.SelectionStart != wantStart || e.SelectionEnd != wantEnd {
t.Fatalf("selection = [%d,%d), want [%d,%d)", e.SelectionStart, e.SelectionEnd, wantStart, wantEnd)
}
active := wantStart >= 0 && wantEnd > wantStart
if active != selActive() {
t.Fatalf("selActive() = %v, want %v (anchor=%d)", selActive(), active, e.SelectionAnchor)
}
}
func TestSetSelection_Basic(t *testing.T) {
selState("0123456789")
SetSelection(2, 7)
assertSelection(t, 2, 7)
if TheState.Editor.SelectionAnchor != 2 {
t.Errorf("anchor = %d, want 2", TheState.Editor.SelectionAnchor)
}
if TheState.Editor.CursorPosition != 7 {
t.Errorf("cursor = %d, want 7 (active end)", TheState.Editor.CursorPosition)
}
// Reversed arguments are normalized.
SetSelection(9, 4)
assertSelection(t, 4, 9)
if TheState.Editor.SelectionAnchor != 4 {
t.Errorf("anchor = %d, want 4", TheState.Editor.SelectionAnchor)
}
}
func TestSetSelection_Clamp(t *testing.T) {
selState("0123456789")
// End past EOF clamps to file length.
SetSelection(7, 100)
assertSelection(t, 7, 10)
// Negative start clamps to 0.
SetSelection(-5, 3)
assertSelection(t, 0, 3)
// Degenerate range clears instead of selecting.
SetSelection(5, 5)
assertSelection(t, -1, -1)
if TheState.Editor.CursorPosition != 5 {
t.Errorf("cursor = %d, want 5", TheState.Editor.CursorPosition)
}
}
func TestShiftExtend_Horizontal(t *testing.T) {
selState("0123456789")
TheState.Editor.CursorPosition = 3
// First shift-move anchors at the cursor and selects one char.
handleKey(key.NameRightArrow, true)
assertSelection(t, 3, 4)
// Further shift-moves extend from the anchor.
handleKey(key.NameRightArrow, true)
assertSelection(t, 3, 5)
handleKey(key.NameRightArrow, true)
assertSelection(t, 3, 6)
// Left moves shrink back toward the anchor; crossing it keeps the
// selection (anchor is the fixed end).
handleKey(key.NameLeftArrow, true)
assertSelection(t, 3, 5)
handleKey(key.NameLeftArrow, true)
assertSelection(t, 3, 4)
// Crossing the anchor: zero-length, represented as no selection.
handleKey(key.NameLeftArrow, true)
assertSelection(t, -1, -1)
// ...but the anchor survives so the next shift-move extends from the
// original spot.
if TheState.Editor.SelectionAnchor != 3 {
t.Errorf("anchor = %d, want 3 (kept across zero-length)", TheState.Editor.SelectionAnchor)
}
handleKey(key.NameLeftArrow, true)
assertSelection(t, 2, 3)
// A plain move clears the selection and moves one step (cursor 2 -> 1).
handleKey(key.NameLeftArrow, false)
assertSelection(t, -1, -1)
if TheState.Editor.SelectionAnchor != -1 {
t.Errorf("anchor = %d, want -1 after plain move", TheState.Editor.SelectionAnchor)
}
if TheState.Editor.CursorPosition != 1 {
t.Errorf("cursor = %d, want 1", TheState.Editor.CursorPosition)
}
}
func TestShiftExtend_UpDownHomeEnd(t *testing.T) {
TheState = NewState()
TheState.Editor.Buffer = "hello\nworld"
TheState.Editor.CursorPosition = 8 // line 1, inside "world"
TheState.Editor.GlyphLayout = makeTestLineLayout("hello\nworld")
// Layout: line 0 "hello" baseline Y=70, line 1 "world" baseline Y=140;
// X = 10 + 10*(column). Cursor 8 is line 1, column 2 (X=30).
handleKey(key.NameUpArrow, true)
// Up goes to the closest X on line 0: X=30 is byte 2 exactly.
assertSelection(t, 2, 8)
if TheState.Editor.CursorPosition != 2 {
t.Fatalf("cursor = %d, want 2", TheState.Editor.CursorPosition)
}
// Shift+End moves to end of line 0 (byte 5); the anchor stays at 8.
handleKey(key.NameEnd, true)
assertSelection(t, 5, 8)
// Plain Down clears and moves to the closest X on line 1. Cursor is at
// line 0 col 4 (X=50) -> line 1 col 4 = byte 10.
handleKey(key.NameDownArrow, false)
assertSelection(t, -1, -1)
if TheState.Editor.CursorPosition != 10 {
t.Fatalf("cursor = %d, want 10", TheState.Editor.CursorPosition)
}
// Shift+End extends to end of file (byte 11), anchoring at 10.
handleKey(key.NameEnd, true)
assertSelection(t, 10, 11)
// Shift+Home selects back to line start (byte 6), anchor 10 kept.
handleKey(key.NameHome, true)
assertSelection(t, 6, 10)
// Plain Home clears and moves.
handleKey(key.NameHome, false)
assertSelection(t, -1, -1)
if TheState.Editor.CursorPosition != 6 {
t.Errorf("cursor = %d, want 6", TheState.Editor.CursorPosition)
}
}
func TestInsert_ReplacesSelection(t *testing.T) {
selState("hello world")
SetSelection(5, 11) // " world"
HandleInsert("GO")
if TheState.Editor.Buffer != "helloGO" {
t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, "helloGO")
}
if TheState.Editor.CursorPosition != 7 {
t.Errorf("cursor = %d, want 7", TheState.Editor.CursorPosition)
}
assertSelection(t, -1, -1)
}
func TestBackspace_DeletesSelection(t *testing.T) {
selState("0123456789")
SetSelection(2, 7)
HandleBackspace()
if TheState.Editor.Buffer != "01789" {
t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, "01789")
}
if TheState.Editor.CursorPosition != 2 {
t.Errorf("cursor = %d, want 2", TheState.Editor.CursorPosition)
}
assertSelection(t, -1, -1)
}
func TestDeleteForward_DeletesSelection(t *testing.T) {
selState("0123456789")
SetSelection(2, 7)
HandleDelete()
if TheState.Editor.Buffer != "01789" {
t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, "01789")
}
if TheState.Editor.CursorPosition != 2 {
t.Errorf("cursor = %d, want 2", TheState.Editor.CursorPosition)
}
assertSelection(t, -1, -1)
}
func TestBackspace_NoSelection_StillDeletesChar(t *testing.T) {
selState("0123456789")
TheState.Editor.CursorPosition = 5
HandleBackspace()
// Deletes the char before the cursor (index 4, '4').
if TheState.Editor.Buffer != "012356789" {
t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, "012356789")
}
if TheState.Editor.CursorPosition != 4 {
t.Errorf("cursor = %d, want 4", TheState.Editor.CursorPosition)
}
}
func TestHandleReplaceRange_UnionsSelection(t *testing.T) {
selState("hello world")
SetSelection(0, 5) // "hello", cursor at 5
// IME reports an empty range at the caret: the selection must still be
// consumed.
HandleReplaceRange(5, 5, "X")
if TheState.Editor.Buffer != "X world" {
t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, "X world")
}
if TheState.Editor.CursorPosition != 1 {
t.Errorf("cursor = %d, want 1", TheState.Editor.CursorPosition)
}
assertSelection(t, -1, -1)
// IME reports the full selection range: same outcome (idempotent union).
selState("hello world")
SetSelection(0, 5)
HandleReplaceRange(0, 5, "X")
if TheState.Editor.Buffer != "X world" {
t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, "X world")
}
// No selection: ordinary insert at the caret (regression guard).
selState("hello")
TheState.Editor.CursorPosition = 5
HandleReplaceRange(5, 5, "!")
if TheState.Editor.Buffer != "hello!" {
t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, "hello!")
}
if TheState.Editor.CursorPosition != 6 {
t.Errorf("cursor = %d, want 6", TheState.Editor.CursorPosition)
}
}
func TestHandleReplaceRange_SelectionPartialOverlap(t *testing.T) {
// Selection [2,7); IME commit inserts at caret 7 (just outside the
// selection): union [2,7) -> the selected text is replaced, inserted
// text ends up where the selection was.
selState("0123456789")
SetSelection(2, 7)
HandleReplaceRange(7, 7, "AB")
want := "01AB789"
if TheState.Editor.Buffer != want {
t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, want)
}
if TheState.Editor.CursorPosition != 4 {
t.Errorf("cursor = %d, want 4", TheState.Editor.CursorPosition)
}
assertSelection(t, -1, -1)
}
func TestSelectionEdit_UTF8(t *testing.T) {
// "hélló": é is 2 bytes (1..3), ó is 2 bytes (5..7). Select the
// multi-byte span [1,5) = "éll" and replace it: must not split runes.
selState("hélló")
SetSelection(1, 5)
HandleInsert("a")
want := "ha" + "ó"
if TheState.Editor.Buffer != want {
t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, want)
}
if TheState.Editor.CursorPosition != 2 {
t.Errorf("cursor = %d, want 2", TheState.Editor.CursorPosition)
}
}
func TestSetCursorFromPoint_ClearsSelection(t *testing.T) {
TheState = NewState()
TheState.Editor.Buffer = "hello\nworld"
TheState.Editor.GlyphLayout = makeTestLineLayout("hello\nworld")
SetSelection(0, 5)
assertSelection(t, 0, 5)
// Tap on the second line (Y of line 1 glyphs).
SetCursorFromPoint(25, secondLineY(t))
assertSelection(t, -1, -1)
if TheState.Editor.CursorPosition < 6 || TheState.Editor.CursorPosition > 11 {
t.Errorf("cursor = %d, want in [6,11] (line 1)", TheState.Editor.CursorPosition)
}
}
func TestBareKeyName_ShiftIgnored(t *testing.T) {
// The legacy bare key.Name path has no modifier state: it must never
// start a selection.
selState("0123456789")
TheState.Editor.CursorPosition = 3
HandleKeyDown(key.NameRightArrow)
assertSelection(t, -1, -1)
if TheState.Editor.CursorPosition != 4 {
t.Errorf("cursor = %d, want 4", TheState.Editor.CursorPosition)
}
}
func TestKeyEvent_DoesNotAffectIMEEvents(t *testing.T) {
// key.EditEvent still flows to HandleReplaceRange unchanged.
selState("hello")
TheState.Editor.CursorPosition = 5
HandleKeyDown(key.EditEvent{Range: key.Range{Start: 5, End: 5}, Text: "!"})
if TheState.Editor.Buffer != "hello!" {
t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, "hello!")
}
}
// makeTestLineLayout builds a GlyphLayout for an ASCII buffer whose lines
// are laid out at fixed baselines (line i at Y=70+70*i), one glyph per byte
// with X = 10 + 10*column and advance 10.
func makeTestLineLayout(content string) ui.GlyphLayout {
var layout ui.GlyphLayout
y := 70
col := 0
for i := 0; i < len(content); i++ {
if i > 0 && content[i-1] == '\n' {
y += 70
col = 0
}
layout.ByteOffsets = append(layout.ByteOffsets, i)
layout.X = append(layout.X, 10+ui.Dp(col)*10)
layout.Y = append(layout.Y, ui.Dp(y))
layout.Advance = append(layout.Advance, 10)
if content[i] != '\n' {
col++
}
}
layout.LineHeight = 20
return layout
}
// secondLineY returns the Y of the first glyph of the second line in the
// layout produced by makeTestLineLayout.
func secondLineY(t *testing.T) float64 {
t.Helper()
l := TheState.Editor.GlyphLayout
for i := 1; i < len(l.Y); i++ {
if l.Y[i] > l.Y[0] {
return float64(l.Y[i])
}
}
t.Fatal("layout has no second line")
return 0
}