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).
219 lines
7.1 KiB
Go
219 lines
7.1 KiB
Go
package editor
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"pad/internal/io/pool/types"
|
|
"pad/internal/ui"
|
|
)
|
|
|
|
// TestApplyWrapCounts_Grouping verifies that a shaped window's
|
|
// VisualLineStarts (window-relative byte offsets, one per visual line,
|
|
// including the initial 0) are attributed to the correct logical lines:
|
|
// a wrapped logical line gets its true visual-line count, and lines the
|
|
// layout does not cover keep their previous counts (over-stale is safe,
|
|
// under-attributing is not).
|
|
func TestApplyWrapCounts_Grouping(t *testing.T) {
|
|
// Whole-file buffer; the shaped window covers lines 5..8.
|
|
var lines []string
|
|
for i := 0; i < 12; i++ {
|
|
lines = append(lines, "line-content-"+strings.Repeat("x", i%7))
|
|
}
|
|
// The windowed lines get distinctive shapes.
|
|
lines[5] = "A very long line that wraps three times"
|
|
lines[6] = "short"
|
|
lines[7] = "" // empty line
|
|
lines[8] = "another line that wraps twice"
|
|
content := strings.Join(lines, "\n") + "\n"
|
|
|
|
cb := newTestBufferForWrapWholeFile(content)
|
|
|
|
// Distinct seed counts so any (un)set line is distinguishable.
|
|
for i := 0; i < cb.WrapIndex.Len(); i++ {
|
|
cb.WrapIndex.Set(i, int32(9))
|
|
}
|
|
|
|
// The shaped window covers lines 5..8 (their content plus terminators).
|
|
winStart := byteOffsetOfLine(content, 5)
|
|
windowText := strings.Join(lines[5:9], "\n") + "\n"
|
|
off := map[string]int{}
|
|
crs := 0
|
|
for _, l := range lines[5:9] {
|
|
off[l] = crs
|
|
crs += len(l) + 1
|
|
}
|
|
// Fabricated visual line starts (window-relative), as the shaper would
|
|
// emit them: 0 first, then one per wrapped continuation, then one per
|
|
// following logical line start.
|
|
starts := []int{
|
|
0, // line 5 visual 1
|
|
20, // line 5 wraps
|
|
35, // line 5 wraps again
|
|
off["short"], // line 6
|
|
off[""], // line 7 (empty)
|
|
off["another line that wraps twice"], // line 8
|
|
off["another line that wraps twice"] + 19, // line 8 wraps
|
|
}
|
|
|
|
TheState = NewState()
|
|
TheState.Editor.ChunkedBuffer = cb
|
|
TheState.Editor.IMEWindowText = windowText
|
|
TheState.Editor.IMEWindowStartByte = winStart
|
|
TheState.Editor.EditSeq = 42
|
|
|
|
fb := ui.LayoutFeedback{
|
|
GlyphLayout: ui.GlyphLayout{VisualLineStarts: starts, LineHeight: 20},
|
|
WindowText: windowText,
|
|
WindowStartByte: winStart,
|
|
WindowStartLine: 5,
|
|
EditSeq: 42,
|
|
}
|
|
TheState.applyWrapCounts(fb)
|
|
|
|
want := map[int]int32{5: 3, 6: 1, 7: 1, 8: 2}
|
|
for line, wc := range want {
|
|
if got := cb.WrapIndex.Get(line); got != wc {
|
|
t.Errorf("line %d count = %d, want %d", line, got, wc)
|
|
}
|
|
}
|
|
// Untouched lines keep their seed count.
|
|
for _, line := range []int{0, 1, 2, 3, 4, 9, 10, 11} {
|
|
if got := cb.WrapIndex.Get(line); got != 9 {
|
|
t.Errorf("line %d count = %d, want untouched 9", line, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestApplyWrapCounts_TrailingNewline covers a window whose last line ends
|
|
// with '\n' at the window end: the shaper may emit a visual-line start at
|
|
// exactly len(winText) for the (empty) trailing line; it must not be
|
|
// attributed to the last real line.
|
|
func TestApplyWrapCounts_TrailingNewline(t *testing.T) {
|
|
content := "short\nlong line that wraps once here\nlast line ends with newline\n"
|
|
cb := newTestBufferForWrapWholeFile(content)
|
|
for i := 0; i < cb.WrapIndex.Len(); i++ {
|
|
cb.WrapIndex.Set(i, 7)
|
|
}
|
|
TheState = NewState()
|
|
TheState.Editor.ChunkedBuffer = cb
|
|
TheState.Editor.IMEWindowText = content
|
|
TheState.Editor.IMEWindowStartByte = 0
|
|
TheState.Editor.EditSeq = 1
|
|
|
|
// Line 0: "short" (1); line 1: wraps (2); line 2: "last line..." (1);
|
|
// plus a trailing-empty-line start at len(content).
|
|
starts := []int{0, 6, 6 + 18, len("short\nlong line that wraps once here\n"), len(content)}
|
|
fb := ui.LayoutFeedback{
|
|
GlyphLayout: ui.GlyphLayout{VisualLineStarts: starts},
|
|
WindowText: content,
|
|
WindowStartByte: 0,
|
|
WindowStartLine: 0,
|
|
EditSeq: 1,
|
|
}
|
|
TheState.applyWrapCounts(fb)
|
|
|
|
if got := cb.WrapIndex.Get(0); got != 1 {
|
|
t.Errorf("line 0 = %d, want 1", got)
|
|
}
|
|
if got := cb.WrapIndex.Get(1); got != 2 {
|
|
t.Errorf("line 1 = %d, want 2", got)
|
|
}
|
|
// Line 2 keeps the seed: the entry at len(content) belongs to the empty
|
|
// trailing line, which is already at the estimate.
|
|
if got := cb.WrapIndex.Get(2); got != 1 && got != 7 {
|
|
t.Errorf("line 2 = %d, want 1 (or untouched 7)", got)
|
|
}
|
|
}
|
|
|
|
// TestApplyWrapCounts_Guards pins the early-outs: no buffer, no wrap index,
|
|
// negative window start, empty window text, empty starts, and a window start
|
|
// byte past the window text.
|
|
func TestApplyWrapCounts_Guards(t *testing.T) {
|
|
content := "abc\ndef\n"
|
|
cb := newTestBufferForWrapWholeFile(content)
|
|
cb.WrapIndex.Set(0, 5)
|
|
cb.WrapIndex.Set(1, 5)
|
|
cb.WrapIndex.Set(2, 5)
|
|
|
|
base := func() *ui.LayoutFeedback {
|
|
return &ui.LayoutFeedback{
|
|
GlyphLayout: ui.GlyphLayout{VisualLineStarts: []int{0, 4}},
|
|
WindowText: content,
|
|
WindowStartByte: 0,
|
|
WindowStartLine: 0,
|
|
EditSeq: 0,
|
|
}
|
|
}
|
|
run := func(mut func(*State, *ui.LayoutFeedback)) {
|
|
TheState = NewState()
|
|
cbc := newTestBufferForWrapWholeFile(content)
|
|
cbc.WrapIndex.Set(0, 5)
|
|
cbc.WrapIndex.Set(1, 5)
|
|
cbc.WrapIndex.Set(2, 5)
|
|
TheState.Editor.ChunkedBuffer = cbc
|
|
TheState.Editor.IMEWindowText = content
|
|
TheState.Editor.IMEWindowStartByte = 0
|
|
fb := base()
|
|
wi := cbc.WrapIndex // capture before mut (some cases nil it)
|
|
mut(TheState, fb)
|
|
TheState.applyWrapCounts(*fb)
|
|
for i := 0; i < 3; i++ {
|
|
if got := wi.Get(i); got != 5 {
|
|
t.Errorf("guard case: line %d = %d, want untouched 5", i, got)
|
|
}
|
|
}
|
|
}
|
|
run(func(s *State, fb *ui.LayoutFeedback) { s.Editor.ChunkedBuffer = nil })
|
|
run(func(s *State, fb *ui.LayoutFeedback) { s.Editor.ChunkedBuffer.WrapIndex = nil })
|
|
run(func(s *State, fb *ui.LayoutFeedback) { fb.WindowStartLine = -1 })
|
|
run(func(s *State, fb *ui.LayoutFeedback) { fb.WindowText = "" })
|
|
run(func(s *State, fb *ui.LayoutFeedback) { fb.GlyphLayout.VisualLineStarts = nil })
|
|
_ = cb
|
|
}
|
|
|
|
// newTestBufferForWrapWholeFile builds a chunked buffer + LineIndex +
|
|
// all-ones WrapIndex over the given content (the LineIndex convention: a
|
|
// trailing "\n" opens an empty trailing line).
|
|
func newTestBufferForWrapWholeFile(content string) *ChunkedBuffer {
|
|
cb := NewChunkedBuffer("/wrap-group.txt", DefaultChunkSize, nil, "")
|
|
cb.SetContent([]byte(content))
|
|
offsets := []int32{0}
|
|
for i := 0; i < len(content); i++ {
|
|
if content[i] == '\n' {
|
|
offsets = append(offsets, int32(i+1))
|
|
}
|
|
}
|
|
cb.LineIndex = types.NewLineIndex(offsets, 0, int64(len(content)))
|
|
cb.WrapIndex = NewWrapIndex(len(offsets))
|
|
return cb
|
|
}
|
|
|
|
// byteOffsetOfLine returns the byte offset of the start of 0-based line i
|
|
// under the LineIndex convention.
|
|
func byteOffsetOfLine(content string, i int) int {
|
|
o := 0
|
|
for line := 0; line < i; line++ {
|
|
nx := strings.IndexByte(content[o:], '\n')
|
|
if nx < 0 {
|
|
return len(content)
|
|
}
|
|
o += nx + 1
|
|
}
|
|
return o
|
|
}
|
|
|
|
// lineSpan returns the total byte length of the first n lines (including
|
|
// their terminators) of content.
|
|
func lineSpan(content string, n int) int {
|
|
o := 0
|
|
for line := 0; line < n; line++ {
|
|
nx := strings.IndexByte(content[o:], '\n')
|
|
if nx < 0 {
|
|
return len(content) - o
|
|
}
|
|
o += nx + 1
|
|
}
|
|
return o
|
|
}
|