Tapping in the editor repositions the cursor, but it was never actually verified (the old unit test only checked in-bounds/no-panic). On-device verification found that on a large file scrolled deep, the cursor clamped to the bottom of the viewport regardless of tap position. Root cause: the tap handler computed the tap's text-local Y in content space (pt.Y - region.Y + full ScrollOffset) and passed it to SetCursorFromPoint, whose visualLine = y/lineHeight then produced a huge content-line number (e.g. 91,640). But the GlyphLayout is window-relative (layout.Y==0 is the top of the visible window), so the number far exceeded the window's line count and clamped to the last group (bottom line). The Phase 3 windowing refactor introduced the windowed layout without updating the tap handler. Fix: tapLocalY() converts the tap Y to window-relative space by adding only the sub-line remainder (ScrollOffset mod lineHeight), never the full scroll. Extracted as a named helper so it is unit-testable. Added two regression tests that fail on the pre-fix formula (cursor clamps to the bottom line) and pass on the fix. Verified on-device: taps now map linearly across the viewport. Also removed the per-tap/per-glyph log.Printf debug lines in SetCursorFromPoint.
146 lines
5.4 KiB
Go
146 lines
5.4 KiB
Go
package editor
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"pad/internal/ui"
|
|
)
|
|
|
|
// TestCursorPositioning_Basic verifies cursor positioning via GlyphLayout.
|
|
func TestCursorPositioning_Basic(t *testing.T) {
|
|
TheState = NewState()
|
|
TheState.Editor.Buffer = "hello\nworld"
|
|
|
|
// Two lines: "hello\n" (bytes 0-5) and "world" (bytes 6-10)
|
|
// Line 0 glyphs at Y=70, Line 1 glyphs at Y=140
|
|
TheState.Editor.GlyphLayout = ui.GlyphLayout{
|
|
ByteOffsets: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
|
|
X: []ui.Dp{10, 20, 30, 40, 50, 10, 20, 30, 40, 50, 60},
|
|
Y: []ui.Dp{70, 70, 70, 70, 70, 70, 140, 140, 140, 140, 140},
|
|
Advance: []ui.Dp{10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10},
|
|
}
|
|
|
|
// Test tapping on first line near X=30 (glyph index 2, byteOffset 2)
|
|
SetCursorFromPoint(25, 70)
|
|
if TheState.Editor.CursorPosition < 0 || TheState.Editor.CursorPosition > len(TheState.Editor.Buffer) {
|
|
t.Errorf("cursor position %d out of bounds after first tap", TheState.Editor.CursorPosition)
|
|
}
|
|
|
|
// Test tapping on second line
|
|
SetCursorFromPoint(35, 140)
|
|
if TheState.Editor.CursorPosition < 0 || TheState.Editor.CursorPosition > len(TheState.Editor.Buffer) {
|
|
t.Errorf("cursor position %d out of bounds after second tap", TheState.Editor.CursorPosition)
|
|
}
|
|
}
|
|
|
|
// TestCursorPositioning_EmptyDocument verifies cursor positioning handles empty documents.
|
|
func TestCursorPositioning_EmptyDocument(t *testing.T) {
|
|
TheState = NewState()
|
|
TheState.Editor.Buffer = ""
|
|
TheState.Editor.GlyphLayout = ui.GlyphLayout{}
|
|
|
|
// Should not panic on empty layout
|
|
SetCursorFromPoint(50, 50)
|
|
|
|
if TheState.Editor.CursorPosition != 0 {
|
|
t.Errorf("expected cursor at 0 for empty document, got %d", TheState.Editor.CursorPosition)
|
|
}
|
|
}
|
|
|
|
// TestCursorPositioning_IncompleteLayout verifies cursor positioning handles missing layout slices.
|
|
func TestCursorPositioning_IncompleteLayout(t *testing.T) {
|
|
TheState = NewState()
|
|
TheState.Editor.Buffer = "test"
|
|
// GlyphLayout with ByteOffsets and X but no Advance (partial data)
|
|
TheState.Editor.GlyphLayout = ui.GlyphLayout{
|
|
ByteOffsets: []int{0, 1, 2, 3},
|
|
X: []ui.Dp{10, 20, 30, 40},
|
|
Y: []ui.Dp{70, 70, 70, 70},
|
|
// Advance intentionally omitted
|
|
}
|
|
|
|
// Should not panic when Advance is missing
|
|
SetCursorFromPoint(25, 70)
|
|
}
|
|
|
|
// TestHandleCursorMove_Bounds verifies cursor movement stays within buffer bounds.
|
|
func TestHandleCursorMove_Bounds(t *testing.T) {
|
|
TheState = NewState()
|
|
TheState.Editor.Buffer = "hello"
|
|
TheState.Editor.CursorPosition = 0
|
|
|
|
// Move right 3 times
|
|
HandleCursorMove(1)
|
|
HandleCursorMove(1)
|
|
HandleCursorMove(1)
|
|
if TheState.Editor.CursorPosition != 3 {
|
|
t.Errorf("expected cursor at 3, got %d", TheState.Editor.CursorPosition)
|
|
}
|
|
|
|
// Move right past end — should clamp to buffer length
|
|
HandleCursorMove(10)
|
|
if TheState.Editor.CursorPosition != len(TheState.Editor.Buffer) {
|
|
t.Errorf("expected cursor at %d (end of buffer), got %d", len(TheState.Editor.Buffer), TheState.Editor.CursorPosition)
|
|
}
|
|
|
|
// Move left past start — should clamp to 0
|
|
HandleCursorMove(-100)
|
|
if TheState.Editor.CursorPosition != 0 {
|
|
t.Errorf("expected cursor at 0, got %d", TheState.Editor.CursorPosition)
|
|
}
|
|
}
|
|
|
|
// TestTapLocalY_WindowRelative is a regression test for the tap-to-position-cursor
|
|
// coordinate bug: the tap's text-local Y must be WINDOW-relative (layout.Y==0 is
|
|
// the top of the visible window), so it must NOT include the full scroll offset.
|
|
// The old code added the full ScrollOffset, which made visualLine a huge
|
|
// content-line number that clamped the cursor to the bottom of the viewport on
|
|
// any large file.
|
|
func TestTapLocalY_WindowRelative(t *testing.T) {
|
|
lh := float64(EditorLineHeight())
|
|
// Tap 100 Dp below a region top that starts at 114 Dp, with a deep scroll
|
|
// (a large file scrolled far down).
|
|
got := tapLocalY(214, 114, 1539464)
|
|
// Expected: (214-114) + (1539464 mod lineHeight) => in [100, 100+lineHeight).
|
|
if got < 100 || got >= 100+lh {
|
|
t.Errorf("tapLocalY = %v, want in [100, %v) (window-relative)", got, 100+lh)
|
|
}
|
|
// Regression: the buggy version returned ~1,539,564 (full scroll leaked in).
|
|
if got > 1000 {
|
|
t.Errorf("tapLocalY = %v, full scroll offset leaked in (must be window-relative)", got)
|
|
}
|
|
}
|
|
|
|
// TestTapToPosition_LargeFileScrolled is the end-to-end regression: with a
|
|
// window-relative GlyphLayout and a deep scroll, a tap a few lines below the top
|
|
// of the window must land on that window line, NOT be clamped to the last window
|
|
// line (the pre-fix behavior on large files).
|
|
func TestTapToPosition_LargeFileScrolled(t *testing.T) {
|
|
lh := float64(EditorLineHeight())
|
|
const lines = 40
|
|
gl := ui.GlyphLayout{LineHeight: EditorLineHeight()}
|
|
for i := 0; i < lines; i++ {
|
|
for _, x := range []ui.Dp{10, 20} {
|
|
gl.ByteOffsets = append(gl.ByteOffsets, i*2)
|
|
gl.X = append(gl.X, x)
|
|
gl.Y = append(gl.Y, ui.Dp(float64(i+1)*lh))
|
|
gl.Advance = append(gl.Advance, 10)
|
|
}
|
|
}
|
|
TheState = NewState()
|
|
TheState.Editor.GlyphLayout = gl
|
|
TheState.ScrollOffset = 1539464 // deep into a large file
|
|
|
|
// Tap ~3 lines below the top of the window.
|
|
regionTop := ui.Dp(114)
|
|
ptY := regionTop + ui.Dp(3*lh)
|
|
localY := tapLocalY(ptY, regionTop, TheState.ScrollOffset)
|
|
SetCursorFromPoint(15, localY)
|
|
|
|
// Should land on window line 3 (byte offset 6), NOT be clamped to the last
|
|
// window line (line 39, byte offset 78).
|
|
if TheState.Editor.CursorPosition < 4 || TheState.Editor.CursorPosition > 8 {
|
|
t.Errorf("cursor = %d, want window line 3 (byte 6); large-file scroll clamp regression?", TheState.Editor.CursorPosition)
|
|
}
|
|
}
|