Pad/internal/editor/font_scale_test.go
Greg Pomerantz b24aa26446 Track Android user font scale in all line-height geometry (Phase 11)
Density (pure hi-DPI) was already scale-free: all bookkeeping is in
density-dp and the scale enters only at the px<->dp boundary. But Android
also has a second axis, the user font-size setting (PxPerSp = fontScale *
PxPerDp), and the shaper draws baselines in sp. At a non-default font
scale the rendered line pitch is 16.8*fontScale dp while every logic-side
consumer used the raw 16.8 dp: taps would misplace by up to (fontScale-1)
viewportfuls of lines and scroll clamping would stop short of the bottom.

- ScaleEvent.FontScale + Frame.FontScale closed loop (main reads
  gtx.Metric, logic tracks it in State.fontScale).
- EffectiveLineHeight()/EffectiveLineHeightAt(): the font-scale-applied
  line height, now used by every consumer (window start, sub-line
  remainder, tap mapping, scroll clamp, page size, cursor vertical move,
  menu position, chunk-prefetch fallbacks).
- Renderer: GlyphLayout.LineHeight, caret, selection handles, and
  highlight all use the scaled ascent/line-height from gtx.Metric.
- font_scale_test.go: 2000-pair tap property test at fontScale 1.3 with
  the glyph layout fabricated at the scaled pitch (independent ground
  truth), plus EffectiveLineHeight unit test.
- On-device: tap markers landed on exactly the tapped line at font_scale
  1.3 (fsline060/080/081) and 0.8 (fsline039); rendered pitch measured
  57/35/44 px at 1.3/0.8/1.0 (matches 16.8*fs*2.625); settled-position
  window start k = floor(s/lh_eff) verified against the visible top line.
- Docs: architecture.md 6.2 font-scale axis, README two-scale note +
  profiler 2s flush staleness note, development plan v10 Phase 11.
2026-08-17 10:59:34 -04:00

142 lines
4.5 KiB
Go

package editor
import (
"fmt"
"math"
"math/rand"
"testing"
"pad/internal/io/pool/types"
"pad/internal/ui"
)
// These tests prove the tap/scroll bookkeeping follows the RENDERED line
// pitch under a non-default user font-size setting. On Android,
// Metric.PxPerSp = fontScale * PxPerDp, and the shaper draws baselines at
// Sp(fontSize*LineHeightScale) physical px, so the rendered line pitch in
// density-dp is EditorLineHeight()*fontScale. Every consumer must use
// EffectiveLineHeight (window start, sub-line remainder, tap mapping, scroll
// clamp); at fontScale != 1 the raw EditorLineHeight would misplace taps by
// up to (fontScale-1) viewportfuls of lines.
func TestEffectiveLineHeight_FollowsFontScale(t *testing.T) {
TheState = NewState()
defer func() { TheState.fontScale = 0 }()
cases := []struct {
fs float32
want float64
}{
{0, float64(EditorLineHeight())}, // unknown -> 1.0
{1, float64(EditorLineHeight())}, // default
{1.3, float64(EditorLineHeight()) * 1.3},
{0.8, float64(EditorLineHeight()) * 0.8},
}
for _, c := range cases {
TheState.fontScale = c.fs
got := float64(EffectiveLineHeight())
if math.Abs(got-c.want) > 1e-5 { // float32 ui.Dp rounding
t.Errorf("fontScale=%v: EffectiveLineHeight=%v want %v", c.fs, got, c.want)
}
}
}
// Generalizes TestTapPosition_ScrollInvariant_Property to fontScale=1.3:
// the windowed layout is fabricated the way the shaper produces it with the
// scaled line pitch (baseline of display line j = (j+1)*lh, lh already
// font-scaled), and the tapped line must equal the drawn line under the
// finger for random scroll offsets and finger positions.
func TestTapPosition_FontScale_Property(t *testing.T) {
const (
lines = 400
bytesPerLine = 5
regionTop = float64(62)
viewportH = float64(760)
fs = float32(1.3)
)
var file string
for i := 0; i < lines; i++ {
file += fmt.Sprintf("L%03d\n", i)
}
cb := NewChunkedBuffer("/fs.txt", DefaultChunkSize, nil, "")
cb.SetContent([]byte(file))
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)))
lh := float64(EffectiveLineHeightAt(fs))
if math.Abs(lh-float64(EditorLineHeight())*float64(fs)) > 1e-5 { // float32 ui.Dp rounding
t.Fatalf("effective line height %v != %v*%v", lh, float64(EditorLineHeight()), fs)
}
rng := rand.New(rand.NewSource(7))
maxS := float64(lines) * lh
for tc := 0; tc < 2000; tc++ {
var s float64
switch tc % 4 {
case 0:
s = rng.Float64() * maxS
case 1:
s = float64(int(rng.Float64() * maxS))
case 2:
s = float64(rng.Intn(lines)) * lh
case 3:
s = float64(rng.Intn(lines))*lh + rng.Float64()*(lh-1)
}
sOff := ui.Dp(s)
// What layoutFrame/visibleByteRangePrecise does with the effective
// line height: window starts at content line k = floor(s/lh).
start, _, _ := cb.VisibleByteRange(sOff, 0, ui.Dp(viewportH), EffectiveLineHeightAt(fs), 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
// with the scaled line pitch: display line j = content line k+j.
nWin := int(math.Min(viewportH/lh+2, float64(lines-k)))
var gl ui.GlyphLayout
gl.LineHeight = EffectiveLineHeightAt(fs)
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)*lh))
gl.Advance = append(gl.Advance, 10)
}
TheState = NewState()
TheState.fontScale = fs
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)}
a := rng.Float64() * viewportH // tap y relative to region top
HandleTapAt(15, ui.Dp(regionTop+a))
// Independent ground truth: content_y = a + s -> content line.
wantLine := int(math.Floor((a + s) / lh))
if wantLine >= lines {
wantLine = lines - 1
}
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("fs=%v s=%.2f (k=%d) tap a=%.2f: cursor=%d, want line %d [bytes %d,%d)",
fs, s, k, a, got, wantLine, wantLo, wantHi)
}
}
}