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.
142 lines
4.5 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|