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) } }