Selection handles now track the finger 1:1 (anchor grab point + displacement) instead of snapping by whole lines, and crossing the opposite handle flips the selection (native behaviour) instead of clearing it. Caret and tap/handle line resolution use VisualLineStarts instead of the min-Y baseline: the window's first visual line may be an empty line with no recorded glyphs, which used to draw boundary carets one line too low per leading empty line and land taps/dragged handles one line below the finger. New exported ui.CaretPoint centralises byte->insertion-point mapping. The off-screen caret no longer clamps to the window edge: EditorLayout ships the true (possibly negative / past-end) window-relative cursor and the renderer skips the caret when the cursor is outside the shaped window, so scrolling past the caret no longer makes it jump onto the top/bottom line. IME/router replay fixes: key.FocusCmd is issued only on a focus transition (a per-frame no-op still takes the immediate-command path and re-queues all pointer events), and the key.SelectionCmd IME sync is deferred while a handle drag is in progress (each push re-injected the drag into every gesture). Handle drags forward only Grabbed events; a tap inside a handle grab box is a no-op. Also: key.FocusEvent no longer logs as unexpected in main; dead code removed (worker taskWrapper, browser applyXxxResult stubs, scrollIndex, mock_setup sortModeKey/lineSpan helpers); mock FileSystem.ListPaths prefix match uses strings.HasPrefix; build scripts run the new scripts/check.sh static gate (go vet + staticcheck). Tests: caret_point_test, touch_selection updates (flip/empty-line cases), off-window caret e2e, selection drag e2e grab step.
202 lines
7.0 KiB
Go
202 lines
7.0 KiB
Go
package e2e_test
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"pad/internal/editor"
|
|
"pad/internal/test/e2e"
|
|
"pad/internal/ui"
|
|
)
|
|
|
|
// TestEditorClickToMoveCursorWithScroll verifies that a tap in the editor
|
|
// accounts for the current scroll offset and lands on the right line.
|
|
//
|
|
// State setup/assertions use the harness's owner-side helpers (WithState /
|
|
// CursorPosition / Inspect); the tap handler closure runs on the logic
|
|
// goroutine via SendInput, so its reads of editor.TheState are safe.
|
|
func TestEditorClickToMoveCursorWithScroll(t *testing.T) {
|
|
// NewHarnessWithDefaults already starts the logic goroutine.
|
|
h := e2e.NewHarnessWithDefaults()
|
|
defer h.Cleanup()
|
|
|
|
// Switch to editor page (owner-side)
|
|
if err := h.WithState(func(st *editor.State) { editor.GoToEditor(nil) }); err != nil {
|
|
t.Fatalf("GoToEditor: %v", err)
|
|
}
|
|
h.SendConfig(780, 1688)
|
|
// Give it a moment to initialize
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
// Wait for frame
|
|
_, err := h.WaitForFrameCount(1, 5*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("timeout waiting for frame: %v", err)
|
|
}
|
|
|
|
// Setup:
|
|
// - 2 lines, each 10 chars + newline.
|
|
// - Line height = 14 * 1.2 = 16.8 (rounded?) Let's check EditorLineHeight()
|
|
// EditorLineHeight is 14 * 1.2 = 16.8.
|
|
lineHeight := 16.8
|
|
if err := h.WithState(func(st *editor.State) {
|
|
st.Editor.Buffer = "Line 1\nLine 2\nLine 3"
|
|
|
|
// Scroll to start of Line 2 (skip Line 1)
|
|
st.ScrollOffset = ui.Dp(lineHeight)
|
|
|
|
// GlyphLayout:
|
|
// Line 1: y = 0
|
|
// Line 2: y = 16.8
|
|
// Line 3: y = 33.6
|
|
st.Editor.GlyphLayout = ui.GlyphLayout{
|
|
ByteOffsets: []int{0, 7, 14}, // Start of each line
|
|
X: []ui.Dp{10, 10, 10},
|
|
Y: []ui.Dp{ui.Dp(0), ui.Dp(lineHeight), ui.Dp(lineHeight * 2)},
|
|
Advance: []ui.Dp{10, 10, 10},
|
|
}
|
|
}); err != nil {
|
|
t.Fatalf("WithState: %v", err)
|
|
}
|
|
|
|
// Tap at y=10 (within the text area).
|
|
// Because of scroll=16.8, this should correspond to:
|
|
// visualLine = (y + scroll) / lineHeight = (10 + 16.8) / 16.8 = 26.8 / 16.8 = 1.59 -> 1
|
|
// Line 1 is index 0. Line 2 is index 1.
|
|
// So visualLine 1 should be Line 2.
|
|
|
|
// The problem is that SetCursorFromPoint expects y in DP, but receives it as raw pixels
|
|
// if we're not careful. Let's pass the y value properly.
|
|
// The test harness sends raw pixel coordinates to handler.
|
|
// EditorLayout converts tap point:
|
|
// localY := float64(pt.Y - editorRegion.Y + TheState.ScrollOffset)
|
|
// So the handler receives Y as pt.Y, where pt.Y is relative to the screen.
|
|
// The test harness doesn't seem to account for region offset.
|
|
|
|
// Let's debug by printing in the test (owner-side read).
|
|
v, err := h.Inspect(func(st *editor.State) any { return float64(st.ScrollOffset) })
|
|
if err != nil {
|
|
t.Fatalf("Inspect: %v", err)
|
|
}
|
|
t.Logf("ScrollOffset: %v", v)
|
|
|
|
h.SendInput([]ui.InputEvent{
|
|
{
|
|
Handler: func(data any) {
|
|
if pt, ok := data.(ui.Point); ok {
|
|
// Manually simulate the offset correction that EditorLayout does
|
|
// editorRegion.Y is margin(10) + statusBarH(32) = 42.
|
|
// Let's set pt.Y to 10 + 42 = 52.
|
|
localY := float64(pt.Y) - 42.0 + float64(editor.TheState.ScrollOffset)
|
|
editor.SetCursorFromPoint(float64(pt.X), localY)
|
|
}
|
|
},
|
|
Data: ui.Point{X: 10, Y: 52},
|
|
},
|
|
})
|
|
|
|
// Assert cursor moved to start of Line 2 (offset 7)
|
|
time.Sleep(100 * time.Millisecond) // Give logic goroutine a moment to process input
|
|
pos, err := h.CursorPosition()
|
|
if err != nil {
|
|
t.Fatalf("CursorPosition: %v", err)
|
|
}
|
|
t.Logf("Cursor position: %d", pos)
|
|
if pos != 7 {
|
|
t.Errorf("expected cursor to move to 7 (Line 2), but got %d", pos)
|
|
}
|
|
}
|
|
|
|
// TestRealFile_CaretCursorOutsideWindow pins the off-screen caret contract:
|
|
// when the user scrolls the caret's line out of the viewport, the shaped
|
|
// window no longer contains the cursor's byte, and the frame must carry the
|
|
// TRUE window-relative cursor position (negative when the cursor is above
|
|
// the window, past len(Value) when below) so the renderer can skip the caret
|
|
// entirely.
|
|
//
|
|
// Pre-fix, EditorLayout clamped a negative window-relative cursor to 0 and
|
|
// drawWrappedText drew the caret unconditionally: scrolling the caret's line
|
|
// just past the top of the viewport made the caret "jump" onto the first
|
|
// visible line, then snap back to its real line when the user scrolled back
|
|
// (the reported cursor-jumps-to-adjacent-line bug). Native Android simply
|
|
// does not draw a caret that is off-screen.
|
|
func TestRealFile_CaretCursorOutsideWindow(t *testing.T) {
|
|
const (
|
|
lines = 60
|
|
lineLen = 21
|
|
scrollLn = 10
|
|
)
|
|
h, _ := realFileHarness(t, "caret_window.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)
|
|
}
|
|
|
|
// Scroll so the window starts at line 10 (byte 210); put the caret on
|
|
// line 5 (byte 105), ABOVE the window.
|
|
caretAbove := 5 * lineLen
|
|
if err := h.WithState(func(st *editor.State) {
|
|
st.ScrollOffset = ui.Dp(16.8*scrollLn + 5)
|
|
st.Editor.CursorPosition = caretAbove
|
|
}); err != nil {
|
|
t.Fatalf("WithState: %v", err)
|
|
}
|
|
prev := h.FrameCount()
|
|
h.SendConfig(780, 400) // state changes alone do not emit frames
|
|
if _, err := h.WaitForFrameCount(prev+1, 5*time.Second); err != nil {
|
|
t.Fatalf("wait for scrolled frame: %v", err)
|
|
}
|
|
tf, ok := lastEditorTextField(t, h)
|
|
if !ok {
|
|
t.Fatal("no editor_text TextField in latest frame")
|
|
}
|
|
if want := caretAbove - scrollLn*lineLen; tf.CursorPosition != want {
|
|
t.Fatalf("window cursor above = %d, want %d (negative: cursor above window, caret must be hidden)", tf.CursorPosition, want)
|
|
}
|
|
|
|
// Mirror case: caret on line 50 (byte 1050), BELOW the window (the
|
|
// ~18-line window at line 10 ends far short of it).
|
|
caretBelow := 50 * lineLen
|
|
if err := h.WithState(func(st *editor.State) {
|
|
st.Editor.CursorPosition = caretBelow
|
|
}); 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 (below): %v", err)
|
|
}
|
|
tf, ok = lastEditorTextField(t, h)
|
|
if !ok {
|
|
t.Fatal("no editor_text TextField in latest frame")
|
|
}
|
|
if want := caretBelow - scrollLn*lineLen; tf.CursorPosition != want {
|
|
t.Fatalf("window cursor below = %d, want %d (past len(Value): cursor below window, caret must be hidden)", tf.CursorPosition, want)
|
|
}
|
|
if tf.CursorPosition <= len(tf.Value) {
|
|
t.Fatalf("window cursor %d should be past window end %d", tf.CursorPosition, len(tf.Value))
|
|
}
|
|
|
|
// And the in-window case must still carry the plain relative offset.
|
|
if err := h.WithState(func(st *editor.State) {
|
|
st.Editor.CursorPosition = 12*lineLen + 9
|
|
}); 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 (inside): %v", err)
|
|
}
|
|
tf, ok = lastEditorTextField(t, h)
|
|
if !ok {
|
|
t.Fatal("no editor_text TextField in latest frame")
|
|
}
|
|
if want := 12*lineLen + 9 - scrollLn*lineLen; tf.CursorPosition != want {
|
|
t.Fatalf("window cursor inside = %d, want %d", tf.CursorPosition, want)
|
|
}
|
|
}
|