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).
213 lines
7.6 KiB
Go
213 lines
7.6 KiB
Go
package editor
|
|
|
|
import (
|
|
"math/rand"
|
|
"testing"
|
|
|
|
"pad/internal/io/pool/types"
|
|
"pad/internal/ui"
|
|
)
|
|
|
|
// TestScrollVisualDecompose_ViewportIdentity is the regression test for the
|
|
// word-wrap scroll jump: when the viewport top crosses the bottom of a
|
|
// wrapped logical line, the view jumped past the wrapped remainder instead of
|
|
// moving pixel-by-pixel with the finger.
|
|
//
|
|
// The invariant under test: the document visual offset at the viewport top is
|
|
// ALWAYS exactly the scroll offset s. If k is the logical line at the viewport
|
|
// top with sub-line shift r, the viewport top sits V(k)*lh + r into the
|
|
// document (V(k) = visual lines before line k, each lh tall, plus r into the
|
|
// next). Any mapping that does not satisfy V(k)*lh + r == s silently skips or
|
|
// re-shows content — the jump.
|
|
//
|
|
// Before the fix, all mapping sites assumed V(k) == k (1 logical line = 1
|
|
// visual line), so for any wrapped file the identity failed by exactly
|
|
// (V(k)-k)*lh once the first wrapped line had been scrolled past.
|
|
func TestScrollVisualDecompose_ViewportIdentity(t *testing.T) {
|
|
const n = 500 // logical lines
|
|
rng := rand.New(rand.NewSource(11))
|
|
counts := make([]int32, n)
|
|
for i := range counts {
|
|
counts[i] = int32(1 + rng.Intn(4)) // each line wraps into 1..4 visual lines
|
|
}
|
|
|
|
cb := newTestBufferForWrap(n, counts)
|
|
|
|
lh := float64(EffectiveLineHeight())
|
|
w := cb.WrapIndex
|
|
totalVisuals := float64(w.TotalVisuals())
|
|
maxS := (totalVisuals - 1) * lh // stay inside the last visual line
|
|
|
|
check := func(s ui.Dp, label string) {
|
|
TheState.ScrollOffset = s
|
|
k, r := scrollVisualDecompose()
|
|
|
|
if k < 0 || k >= n {
|
|
t.Fatalf("%s: k = %d out of range [0,%d)", label, k, n)
|
|
}
|
|
// r = s - V(k)*lh places the viewport top r below the TOP of line k;
|
|
// for a wrapped line the top may be several visual lines above the
|
|
// viewport, so r spans [0, count(k)*lh).
|
|
if r < -1e-9 || r >= float64(w.Get(k))*lh+1e-9 {
|
|
t.Fatalf("%s: r = %f outside [0, count(k)*lh = %f*lh)", label, r, float64(w.Get(k)))
|
|
}
|
|
// The viewport top must lie inside line k's visual span.
|
|
vk := float64(w.VisualsBefore(k))
|
|
if vk*lh > float64(s)+1e-9 {
|
|
t.Fatalf("%s: s = %f is above line k's span start V(k)*lh = %f", label, float64(s), vk*lh)
|
|
}
|
|
if k+1 < n && float64(s) >= float64(w.VisualsBefore(k+1))*lh-1e-9 {
|
|
t.Fatalf("%s: s = %f is at/past line %d's span start (k should be >= %d)", label, float64(s), k+1, k+1)
|
|
}
|
|
// The identity: the viewport top is exactly s into the document's
|
|
// visual space.
|
|
got := vk*lh + r
|
|
if d := got - float64(s); d < -1e-9 || d > 1e-9 {
|
|
t.Fatalf("%s: s = %f: V(k)*lh + r = %f, want s = %f (off by %f — a scroll jump)", label, float64(s), got, float64(s), d)
|
|
}
|
|
// k must be the logical line containing the viewport-top visual line.
|
|
v0 := int32(float64(s) / lh)
|
|
if want := w.LineForVisual(v0); k != want {
|
|
t.Fatalf("%s: s = %f: k = %d, want LineForVisual(%d) = %d", label, float64(s), k, v0, want)
|
|
}
|
|
}
|
|
|
|
// Sub-line sweep in fine steps across the whole document.
|
|
steps := 4000
|
|
for i := 0; i <= steps; i++ {
|
|
s := ui.Dp(maxS * float64(i) / float64(steps))
|
|
check(s, "sweep")
|
|
}
|
|
// Random offsets (arbitrary sub-line positions).
|
|
for i := 0; i < 2000; i++ {
|
|
s := ui.Dp(rng.Float64() * maxS)
|
|
check(s, "random")
|
|
}
|
|
}
|
|
|
|
// TestScrollVisualDecompose_LegacyIdentity pins the legacy behavior: with an
|
|
// all-ones WrapIndex (no wrap corrections applied, i.e. pre-shaping) or no
|
|
// WrapIndex at all, the mapping must be exactly the old 1:1 mapping, so
|
|
// non-wrapped files and pre-shaping frames are unchanged.
|
|
func TestScrollVisualDecompose_LegacyIdentity(t *testing.T) {
|
|
const n = 100
|
|
cb := newTestBufferForWrap(n, nil) // all ones
|
|
cb.WrapIndex.ResetAll()
|
|
|
|
lh := float64(EffectiveLineHeight())
|
|
maxS := float64(n)*lh - lh/2
|
|
rng := rand.New(rand.NewSource(13))
|
|
check := func(s ui.Dp, label string) {
|
|
TheState.ScrollOffset = s
|
|
k, r := scrollVisualDecompose()
|
|
v0, r0 := scrollDecompose(s, ui.Dp(lh))
|
|
if float64(k) != float64(v0) || r != r0 {
|
|
t.Fatalf("%s: s = %f: (k,r) = (%d,%f), want legacy (v0,r0) = (%d,%f)", label, float64(s), k, r, v0, r0)
|
|
}
|
|
}
|
|
steps := 500
|
|
for i := 0; i <= steps; i++ {
|
|
check(ui.Dp(maxS*float64(i)/float64(steps)), "sweep")
|
|
}
|
|
for i := 0; i < 500; i++ {
|
|
check(ui.Dp(rng.Float64()*maxS), "random")
|
|
}
|
|
|
|
// No WrapIndex at all (the String-Buffer fallback / pre-build state).
|
|
cb.WrapIndex = nil
|
|
for i := 0; i < 100; i++ {
|
|
check(ui.Dp(rng.Float64()*maxS), "no-wi")
|
|
}
|
|
}
|
|
|
|
// TestScrollVisualDecompose_WrappedBoundarySweep walks the scroll offset
|
|
// across every wrapped-line boundary and checks that the viewport top moves
|
|
// pixel-by-pixel: the logical line at the top advances by exactly the number
|
|
// of visual lines the previous line occupies, and the fixed-line position
|
|
// identity T(i) = V(i)*lh - s holds across the boundary (no skip, no repeat).
|
|
func TestScrollVisualDecompose_WrappedBoundarySweep(t *testing.T) {
|
|
counts := []int32{1, 3, 1, 2, 4, 1}
|
|
cb := newTestBufferForWrap(len(counts), counts)
|
|
lh := float64(EffectiveLineHeight())
|
|
w := cb.WrapIndex
|
|
|
|
eps := lh / 8
|
|
var prevK int
|
|
var prevS float64
|
|
for v := int32(0); v < w.TotalVisuals(); v++ {
|
|
for _, s := range []ui.Dp{ui.Dp(float64(v)*lh - eps/2), ui.Dp(float64(v) * lh), ui.Dp(float64(v)*lh + eps/2)} {
|
|
if float64(s) < 0 {
|
|
continue
|
|
}
|
|
TheState.ScrollOffset = s
|
|
k, r := scrollVisualDecompose()
|
|
if k < prevK {
|
|
t.Fatalf("s = %f: k decreased (%d -> %d): content re-shown", float64(s), prevK, k)
|
|
}
|
|
// A fixed line moves exactly finger-speed across the boundary:
|
|
// T(i) = V(i)*lh - s for the line that was at the top before.
|
|
if i, ok := firstVisualToLogical(w, v-1); ok && k > i {
|
|
Tbefore := float64(w.VisualsBefore(i))*lh - prevS
|
|
Tafter := float64(w.VisualsBefore(i))*lh - float64(s)
|
|
if d := (Tbefore - Tafter) - (float64(s) - prevS); d < -1e-9 || d > 1e-9 {
|
|
t.Fatalf("s = %f: fixed line moved by %f for scroll delta %f (jump!)", float64(s), Tbefore-Tafter, float64(s)-prevS)
|
|
}
|
|
}
|
|
if k == prevK && float64(s) > prevS {
|
|
// Same top line: the sub-line shift must advance by the delta.
|
|
if d := (r - (prevS - float64(w.VisualsBefore(k))*lh)) - (float64(s) - prevS); d < -1e-9 || d > 1e-9 {
|
|
t.Fatalf("s = %f: r advanced by %f for delta %f", float64(s), d, float64(s)-prevS)
|
|
}
|
|
}
|
|
_ = r
|
|
prevK, prevS = k, float64(s)
|
|
}
|
|
}
|
|
}
|
|
|
|
// firstVisualToLogical maps visual line v to its logical line (mirrors
|
|
// LineForVisual; the test wants a named helper for readability).
|
|
func firstVisualToLogical(w *WrapIndex, v int32) (int, bool) {
|
|
if v < 0 {
|
|
return 0, false
|
|
}
|
|
return w.LineForVisual(v), true
|
|
}
|
|
|
|
// newTestBufferForWrap builds a minimal editor State backed by a chunked
|
|
// buffer whose WrapIndex is seeded with the given per-line visual counts
|
|
// (nil = all ones). It points TheState at it for the scroll mapping under
|
|
// test. The buffer content is one short line per logical line; only the
|
|
// WrapIndex matters to the mapping.
|
|
func newTestBufferForWrap(n int, counts []int32) *ChunkedBuffer {
|
|
var buf []byte
|
|
for i := 0; i < n; i++ {
|
|
buf = append(buf, 'x')
|
|
if i < n-1 {
|
|
buf = append(buf, '\n')
|
|
}
|
|
}
|
|
cb := NewChunkedBuffer("/wrap-map.txt", DefaultChunkSize, nil, "")
|
|
cb.SetContent(buf)
|
|
offsets := []int32{0}
|
|
for i := 0; i < len(buf); i++ {
|
|
if buf[i] == '\n' {
|
|
offsets = append(offsets, int32(i+1))
|
|
}
|
|
}
|
|
cb.LineIndex = types.NewLineIndex(offsets, 0, int64(len(buf)))
|
|
w := NewWrapIndex(len(offsets))
|
|
if counts != nil {
|
|
for i, c := range counts {
|
|
w.Set(i, c)
|
|
}
|
|
}
|
|
cb.WrapIndex = w
|
|
|
|
TheState = NewState()
|
|
TheState.Editor.ChunkedBuffer = cb
|
|
TheState.Editor.GlyphLayout = ui.GlyphLayout{} // LineHeight 0 -> EffectiveLineHeight
|
|
TheState.ScrollOffset = 0
|
|
return cb
|
|
}
|