The GlyphLayout is shaped from the visible window alone, so its ByteOffsets are relative to the window start (IMEWindowStartByte). But SetCursorFromPoint, HandleHome, HandleEnd and HandleVerticalCursorMove treated them as absolute file offsets. On a large file scrolled deep, the cursor was set to a window-relative offset (near the file start), so the next EditorLayout computed visibleCursorPos = CursorPosition - start as a small/negative value and the IME selection snapped to the window top (regardless of where the user tapped). Add glyphBase() and convert at the four cursor boundaries: subtract the base before searching the window-relative offsets, add it back before storing the absolute CursorPosition. For small files (window == whole file) the base is 0 and the change is a no-op. Add TestTapToPosition_WindowBaseOffset, a regression test that sets a non-zero IMEWindowStartByte and asserts the cursor lands at base + window byte (fails on the pre-fix window-relative assignment). Verified on-device: with a 6 MB file scrolled to ~line 1200, a tap now places the cursor on the tapped line (typed text lands ~16 lines below the window top), where before it always landed on the window top.
189 lines
7.3 KiB
Go
189 lines
7.3 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_WindowBaseOffset is a regression test for the
|
|
// window-relative GlyphLayout bug: the GlyphLayout is shaped from the visible
|
|
// window alone, so its ByteOffsets are relative to the window start
|
|
// (IMEWindowStartByte). SetCursorFromPoint must add that base to produce an
|
|
// absolute file offset. Before the fix, a tap on a scrolled large file set the
|
|
// cursor to a window-relative offset (near the file start), so the IME
|
|
// selection snapped to the window top on every tap.
|
|
func TestTapToPosition_WindowBaseOffset(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) // window-relative
|
|
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.Editor.IMEWindowStartByte = 47618 // window starts deep in the file
|
|
TheState.ScrollOffset = 16500
|
|
|
|
// 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)
|
|
|
|
// Window line 3 is window-relative byte 6, so the absolute cursor must be
|
|
// base (47618) + 6 = 47624. Before the fix it would be 6 (window-relative),
|
|
// which is the file start, not the tapped line.
|
|
want := 47618 + 6
|
|
if TheState.Editor.CursorPosition < want-2 || TheState.Editor.CursorPosition > want+2 {
|
|
t.Errorf("cursor = %d, want %d (base 47618 + window byte 6); window-relative offset bug?", TheState.Editor.CursorPosition, want)
|
|
}
|
|
// Regression: the buggy cursor would be ~6 (window-relative, near file start).
|
|
if TheState.Editor.CursorPosition < 100 {
|
|
t.Errorf("cursor = %d, window-relative offset leaked (should be absolute ~%d)", TheState.Editor.CursorPosition, want)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|