The screen->line tap mapping only needs the sub-line scroll remainder (tapLocalY adds r, never the full scroll) because the visible glyph layout is window-relative and IMEWindowStartByte re-anchors it to the file. That holds for every scroll offset IF the window start line k=floor(s/lh) and the sub-line remainder r stay consistent. Property test (4000 random scroll/tap pairs, asserting against an independent drawn-geometry ground truth, not the tap code's own math) exposed a real bug: int(s/lh) in the Dp float32 domain rounds the quotient to nearest and can round UP across an integer boundary while the float64 mod still reflects the line below. In a sub-pixel-wide band of scroll offsets the window started one line too far while the draw shift lagged by one line - the whole rendered window (and every tapped line) shifted by one. Fix: one shared float64 floor decomposition (scrollDecompose) used by the window start (visibleByteRangePrecise/Estimate), the renderer's sub-line shift (visibleScrollOffset), the tap mapping (tapLocalY), and chunk prefetching. Also route the shaper's line height (previously ignored by visibleByteRangePrecise) through VisibleByteRange. On-device cross-check: at s=4246.9 (r=13.3) and s=4210.7 (r=10.7), taps on visually identified lines typed markers that landed on exactly those lines in the file on disk; profiler ScrollDP, screenshot, formula, and disk all agreed. Docs: scroll decomposition invariant in architecture.md §6.2, pipeline hop 2 in doc/README.md, Phase 10 in development_plan.md.
133 lines
4.6 KiB
Go
133 lines
4.6 KiB
Go
package editor
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"math/rand"
|
|
"testing"
|
|
|
|
"pad/internal/io/pool/types"
|
|
"pad/internal/ui"
|
|
)
|
|
|
|
// This file proves the scroll-offset independence of tap-to-position.
|
|
//
|
|
// The invariant under test, for a no-wrap file where content line n occupies
|
|
// content-y [n*lh, (n+1)*lh):
|
|
//
|
|
// For every scroll offset s >= 0 and every tap point ptY inside the visible
|
|
// region, the cursor must land on the content line whose DRAWN range
|
|
// contains the tap. Drawn range of window display line j is
|
|
// [reg.Y - r + j*lh, reg.Y - r + (j+1)*lh), where r = s mod lh (the
|
|
// renderer shifts the windowed layout up by exactly r:
|
|
// `y := reg.Y - scrollOffset` in drawWrappedText, scrollOffset =
|
|
// Mod(ScrollOffset, lineHeight)).
|
|
//
|
|
// Independent ground truth: content_y = (ptY - reg.Y) + s, so the tapped
|
|
// content line is floor(content_y / lh). That formula uses only "the content
|
|
// is shifted up by s" — it does NOT reuse tapLocalY.
|
|
//
|
|
// The code path under test is HandleTapAt -> tapLocalY -> SetCursorFromPoint
|
|
// -> textPosFromLocalPoint, with a GlyphLayout fabricated the way the
|
|
// windowed layout is (window display line j = content line k+j, baseline
|
|
// (j+1)*lh, window-relative byte offsets) and IMEWindowStartByte set to the
|
|
// same line k = floor(s/lh) that visibleByteRangePrecise picks
|
|
// (startLine := int(scrollOffset / lineHeight)).
|
|
//
|
|
// If the window-start coupling, the draw shift, or tapLocalY's sub-line
|
|
// remainder ever disagree, this test fails for some (s, ptY) pair.
|
|
func TestTapPosition_ScrollInvariant_Property(t *testing.T) {
|
|
const (
|
|
lines = 500
|
|
bytesPerLine = 5 // "L123\n"
|
|
regionTop = float64(62) // editor region top, app dp (matches layoutFrame)
|
|
viewportH = float64(760) // app dp
|
|
)
|
|
lineHeight := float64(EditorLineHeight())
|
|
// Build the file once.
|
|
var file string
|
|
for i := 0; i < lines; i++ {
|
|
file += fmt.Sprintf("L%03d\n", i)
|
|
}
|
|
cb := NewChunkedBuffer("/prop.txt", DefaultChunkSize, nil, "")
|
|
cb.SetContent([]byte(file))
|
|
|
|
// Build the same line index the BuildLineIndex task produces.
|
|
offsets := []int32{0}
|
|
for i := 0; i < len(file); i++ {
|
|
if file[i] == '\n' {
|
|
offsets = append(offsets, int32(i+1))
|
|
}
|
|
}
|
|
cb.LineIndex = types.NewLineIndex(offsets, 0, int64(len(file)))
|
|
|
|
rng := rand.New(rand.NewSource(42))
|
|
maxS := float64(lines) * lineHeight
|
|
|
|
for tc := 0; tc < 4000; tc++ {
|
|
// Random scroll offset: a mix of small, large, integer-line and
|
|
// fractional-line values.
|
|
var s float64
|
|
switch tc % 4 {
|
|
case 0:
|
|
s = rng.Float64() * maxS // anywhere
|
|
case 1:
|
|
s = float64(int(rng.Float64() * maxS)) // exact integer dp
|
|
case 2:
|
|
s = float64(rng.Intn(lines)) * lineHeight // exact line boundary
|
|
case 3:
|
|
s = float64(rng.Intn(lines))*lineHeight + rng.Float64()*(lineHeight-1) // near boundary
|
|
}
|
|
sOff := ui.Dp(s)
|
|
|
|
// What layoutFrame/visibleByteRangePrecise does: window starts at
|
|
// content line k = floor(s / lh).
|
|
start, _, _ := cb.VisibleByteRange(sOff, 0, ui.Dp(viewportH), EditorLineHeight(), false, ui.GlyphLayout{}, nil)
|
|
if start%bytesPerLine != 0 {
|
|
t.Fatalf("test setup: window start %d not on a line boundary", start)
|
|
}
|
|
k := start / bytesPerLine
|
|
|
|
// Fabricate the windowed GlyphLayout exactly as the shaper would for
|
|
// a no-wrap file: window display line j = content line k+j.
|
|
nWin := int(math.Min(viewportH/lineHeight+2, float64(lines-k)))
|
|
var gl ui.GlyphLayout
|
|
for j := 0; j < nWin; j++ {
|
|
gl.ByteOffsets = append(gl.ByteOffsets, j*bytesPerLine)
|
|
gl.X = append(gl.X, 10)
|
|
gl.Y = append(gl.Y, ui.Dp(float64(j+1)*lineHeight))
|
|
gl.Advance = append(gl.Advance, 10)
|
|
}
|
|
|
|
TheState = NewState()
|
|
TheState.Editor.ChunkedBuffer = cb
|
|
TheState.Editor.GlyphLayout = gl
|
|
TheState.Editor.IMEWindowStartByte = start
|
|
TheState.ScrollOffset = sOff
|
|
TheState.EditorRegion = ui.Region{X: 10, Y: ui.Dp(regionTop), W: 390, H: ui.Dp(viewportH)}
|
|
|
|
// Random tap inside the visible region.
|
|
a := rng.Float64() * viewportH // tap y relative to region top
|
|
ptY := ui.Dp(regionTop + a)
|
|
|
|
HandleTapAt(15, ptY)
|
|
|
|
// Independent ground truth: content_y = a + s -> content line.
|
|
wantLine := int(math.Floor((a + s) / lineHeight))
|
|
if wantLine >= lines {
|
|
wantLine = lines - 1
|
|
}
|
|
// Taps below the last shaped window line clamp to it (no glyphs
|
|
// further down), matching textPosFromLocalPoint's documented clamp.
|
|
if k+nWin-1 < wantLine {
|
|
wantLine = k + nWin - 1
|
|
}
|
|
got := TheState.Editor.CursorPosition
|
|
wantLo, wantHi := wantLine*bytesPerLine, wantLine*bytesPerLine+bytesPerLine
|
|
if got < wantLo || got >= wantHi {
|
|
t.Fatalf("s=%.2f (k=%d) tap a=%.2f: cursor=%d, want line %d [bytes %d,%d)",
|
|
s, k, a, got, wantLine, wantLo, wantHi)
|
|
}
|
|
}
|
|
}
|