Every scroll<->content mapping site (window start, sub-line shift, tap
mapping, max-scroll clamp, selection menu/handle positions) assumed
1 logical line = 1 visual line. When the viewport top crossed the
bottom of a wrapped line, the view jumped past the wrapped remainder
(jump magnitude (count-1)*lh) instead of moving pixel-by-pixel.
- WrapIndex (internal/editor/wrap_index.go): Fenwick tree of
per-logical-line visual-line counts, parallel to the LineIndex;
built at index-build time, bookkept by the same
UpdateLineIndexAfter{Insert,Delete} hooks (never under-stale: every
touched line resets to the estimate, the next shaping pass
re-corrects it).
- scrollVisualDecompose: the scroll offset lives in visual-line space:
k = LineForVisual(floor(s/lh)), r = s - V(k)*lh. All mapping sites
go through it, so the viewport top is always exactly s into the
document's visual space (V(k)*lh + r = s) — the jump invariant.
All-ones index reduces to the legacy 1:1 mapping (pre-shaping and
non-wrapped behavior unchanged by construction).
- Correction pipeline: the renderer's per-frame VisualLineStarts are
grouped per logical line and written back (applyWrapCounts). The
layout feedback now carries the exact window text the layout was
shaped for (carried in the frame) plus the window start line and the
content-edit counter; corrections apply only on edit-counter match,
and grouping over the current window text (wrong after a scroll moved
the window) is no longer possible.
- bytePosToScreenXY now applies the sub-line shift and the scaled line
pitch: the selection menu/handles were off by up to a full line.
- maxScroll uses TotalVisuals() with the effective (font-scaled) line
height; the bottom clamp lands exactly on the file end for wrapped
content.
- VisibleByteRange returns the real start line (was hardcoded 0).
- emitFrame: replace the unread handoff frame with the newer snapshot
instead of dropping it — a dropped final frame was never re-emitted
(emission is event-driven), leaving the consumer one state behind
forever; fixes the pre-existing TestRealFile_ShiftSelectionInsert
failure. Still non-blocking.
Tests (mutation-verified where practical): wrap_index_test.go (Fenwick
vs naive model, 3000 ops), wrap_bookkeeping_test.go (edit hooks vs
shadow-string oracle, 400 ops — caught a real m=0 under-marking),
wrap_mapping_test.go (the jump regression: V(k)*lh + r == s over sweeps
+ random offsets; legacy-identity pin; boundary sweep), wrap_apply_test.go
(VisualLineStarts grouping + guards — the first version exposed the
always-true WindowStartByte guard that blocked all post-scroll
corrections). go test -race ./... green.
On-device (emulator, 60 wrapped lines): dp sweep 0/17/50/67/134/340
lands on LINE000-vl0/1/3, LINE001-vl0, LINE002-vl0, LINE005-vl0 —
pixel-exact 1:1, no jump (dp 134 is where the old code jumped to
LINE008); bottom clamp exact.
Docs: architecture.md §6.2 (visual-line space invariant),
development_plan.md (Phase 13), spec.md (wrap + clamp lines).
223 lines
8.8 KiB
Go
223 lines
8.8 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). tapLocalY reads TheState.ScrollOffset.
|
|
TheState.ScrollOffset = 1539464
|
|
got := tapLocalY(214, 114)
|
|
// 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)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestHandleEnd_WindowBaseOffset is a regression test for the window-relative
|
|
// GlyphLayout bug in HandleEnd. The layout covers only the visible window
|
|
// (base = IMEWindowStartByte), so its ByteOffsets are window-relative.
|
|
// HandleEnd converts the end-of-line position to an absolute file offset
|
|
// (base + window offset) before using it to index the full file content.
|
|
// Before the fix it used the window-relative offset, landing on the previous
|
|
// line's newline.
|
|
func TestHandleEnd_WindowBaseOffset(t *testing.T) {
|
|
lh := float64(EditorLineHeight())
|
|
// File: line0 "AAAAAAAA\n" (bytes 0-8), line1 "BBBBBBBB\n" (bytes 9-17).
|
|
// Window starts at byte 9 (line 1). Window-relative offsets: B=0..7, \n=8.
|
|
gl := ui.GlyphLayout{LineHeight: EditorLineHeight()}
|
|
for i := 0; i < 9; i++ {
|
|
gl.ByteOffsets = append(gl.ByteOffsets, i)
|
|
gl.X = append(gl.X, ui.Dp(10+10*i))
|
|
gl.Y = append(gl.Y, ui.Dp(lh)) // single visual line
|
|
gl.Advance = append(gl.Advance, 10)
|
|
}
|
|
TheState = NewState()
|
|
TheState.Editor.Buffer = "AAAAAAAA\nBBBBBBBB\n"
|
|
TheState.Editor.GlyphLayout = gl
|
|
TheState.Editor.IMEWindowStartByte = 9
|
|
TheState.Editor.CursorPosition = 12 // line 1, 4th B (absolute)
|
|
|
|
HandleEnd()
|
|
|
|
// The end of line 1 is its newline at absolute byte 17. Before the fix the
|
|
// window-relative offset (8) indexed the previous line's newline (byte 8).
|
|
if TheState.Editor.CursorPosition != 17 {
|
|
t.Errorf("HandleEnd cursor = %d, want 17 (newline ending line 1); window-relative offset bug?", TheState.Editor.CursorPosition)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
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)
|
|
}
|
|
}
|