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 the (margin-free) statusBarH(32) = 32. // Let's set pt.Y to 10 + 32 = 42. localY := float64(pt.Y) - 32.0 + float64(editor.TheState.ScrollOffset) editor.SetCursorFromPoint(float64(pt.X), localY) } }, Data: ui.Point{X: 10, Y: 42}, }, }) // 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) } }