Pad/internal/editor/wrap_mapping_test.go
Greg Pomerantz d8b5bc704b Handle drags: 1:1 finger tracking with cross-flip; fix caret/taps on empty lines
Selection handles now track the finger 1:1 (anchor grab point + displacement)
instead of snapping by whole lines, and crossing the opposite handle flips
the selection (native behaviour) instead of clearing it.

Caret and tap/handle line resolution use VisualLineStarts instead of the
min-Y baseline: the window's first visual line may be an empty line with no
recorded glyphs, which used to draw boundary carets one line too low per
leading empty line and land taps/dragged handles one line below the finger.
New exported ui.CaretPoint centralises byte->insertion-point mapping.

The off-screen caret no longer clamps to the window edge: EditorLayout ships
the true (possibly negative / past-end) window-relative cursor and the
renderer skips the caret when the cursor is outside the shaped window, so
scrolling past the caret no longer makes it jump onto the top/bottom line.

IME/router replay fixes: key.FocusCmd is issued only on a focus transition
(a per-frame no-op still takes the immediate-command path and re-queues all
pointer events), and the key.SelectionCmd IME sync is deferred while a
handle drag is in progress (each push re-injected the drag into every
gesture). Handle drags forward only Grabbed events; a tap inside a handle
grab box is a no-op.

Also: key.FocusEvent no longer logs as unexpected in main; dead code removed
(worker taskWrapper, browser applyXxxResult stubs, scrollIndex, mock_setup
sortModeKey/lineSpan helpers); mock FileSystem.ListPaths prefix match uses
strings.HasPrefix; build scripts run the new scripts/check.sh static gate
(go vet + staticcheck). Tests: caret_point_test, touch_selection updates
(flip/empty-line cases), off-window caret e2e, selection drag e2e grab step.
2026-08-19 22:34:12 -04:00

211 lines
7.5 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))
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
}