Pad/internal/editor/pinch_font_test.go
Greg Pomerantz 180fa966c8 Pinch-to-font-size (continuous, content-point pinned) + IME-open scroll fix
Two feature bodies accumulated in the working tree:

1. Pinch to change the app font size, continuously (no snapping):
   - internal/ui/pinch_tracker.go: logic-free touch state machine.
     Two-mover formation (the resting palm can land first or last;
     movement is the only signal valid for both), pair = the mover
     pair whose distance changed most, baseline = press distance
     (formDist), lazy pending releases, survivor-scroll forwarding
     after a pair break. Robust to ~1 fps frames: a whole pinch can
     land in one drain (formDist/brokeFactor/lazy releases).
   - render.go: pinch probe (raw pointer events) + grab lifecycle so
     the pair is exclusive (scroll sees nothing of the pair) and the
     survivor's finger keeps working as a scroll after the pinch.
   - state.go/logic.go/session.go/frame.go: app-local float font
     scale, content-point pin (buffer byte + offset from baseline,
     not a layout point, so rewrap keeps the same character under
     the center), restore/font pins, session persistence.
   - pinch_test.go, pinch_font_test.go, tag_identity_test.go,
     real_draw_probe_test.go: unit + real-Renderer/real-Router tests.

2. Soft keyboard must not shift content:
   - Root cause: gioui.org/app calls Router.RevealFocus on any frame
     the viewport shrinks (IME open under adjustResize) and
     synthesizes a pointer.Scroll nudge aimed at the focused field's
     stale pre-resize bounds; gesture.Scroll consumed it -> a 32 dp
     content jump.
   - Fix: main.go flags the shrink frame; render.go drains that one
     synthetic scroll for the gesture's tag before Update (scroll-
     range clamping cannot work: the router UNIONs ranges across
     frames). Finger scroll (pointer.Drag) and the flinger are
     untouched. reveal_focus_drain_test.go reproduces RevealFocus at
     the router level and verifies the drain + zero delta.

Also: tools/touchinject (platform-signed emulator multi-touch
injection harness + e2e script, adb has no two-finger input),
docs (spec 2.2 + development_plan 18-20), .gitignore, gofmt.
2026-08-23 09:00:51 -04:00

387 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package editor
import (
"math"
"testing"
"pad/internal/ui"
)
// These tests cover the app-local pinch font scale: EffectiveLineHeight must
// follow the product of the system font scale and the app scale, and the
// pinch handler must apply the relative factor continuously (no rounding to
// whole points) while keeping the viewport top anchored (scroll offset
// rescaled in lockstep with the line height).
func TestEffectiveLineHeight_FollowsAppFontScale(t *testing.T) {
TheLogic = nil
TheState = NewState()
defer func() { TheState.appFontScale = 1.0 }()
cases := []struct {
sys, app float32
want float64
}{
{0, 1.0, float64(EditorLineHeight())}, // both unknown/default
{1.3, 1.0, float64(EditorLineHeight()) * 1.3},
{1.0, 1.7, float64(EditorLineHeight()) * 1.7},
{1.3, 1.7, float64(EditorLineHeight()) * 1.3 * 1.7},
{1.0, 0.5, float64(EditorLineHeight()) * 0.5},
}
for _, c := range cases {
TheState.fontScale = c.sys
TheState.appFontScale = c.app
got := float64(EffectiveLineHeight())
if math.Abs(got-c.want) > 1e-5 {
t.Errorf("sys=%v app=%v: EffectiveLineHeight=%v want %v", c.sys, c.app, got, c.want)
}
}
}
func TestHandleFontPinch_ContinuousAndAnchored(t *testing.T) {
TheLogic = nil
TheState = NewState()
defer func() { TheState.appFontScale = 1.0; TheState.ScrollOffset = 0 }()
// Start scrolled to a line boundary: 100 lines at the default pitch.
lh := float64(EffectiveLineHeight())
TheState.ScrollOffset = ui.Dp(100 * lh)
// A sequence of small relative factors: the product is 1.1^5 ~ 1.61051,
// a non-representable float32 — rounding to whole points (or to any
// fixed step) would not land here.
for i := 0; i < 5; i++ {
HandleFontPinch(ui.FontPinchEvent{Scale: 1.1})
}
want := float64(1.1) * 1.1 * 1.1 * 1.1 * 1.1
if math.Abs(float64(TheState.appFontScale)-want) > 1e-6 {
t.Errorf("appFontScale=%v want %v (continuous, no snapping)", TheState.appFontScale, want)
}
// The scroll offset rescaled by the same ratio: the same line (100) at
// the same sub-line fraction (0) stays on top.
wantOff := 100 * lh * want
if got := float64(TheState.ScrollOffset); math.Abs(got-wantOff) > 1e-3 {
t.Errorf("ScrollOffset=%v want %v (anchor preserved)", got, wantOff)
}
// EffectiveLineHeight follows the new scale.
wantLH := lh * want
if got := float64(EffectiveLineHeight()); math.Abs(got-wantLH) > 1e-5 {
t.Errorf("EffectiveLineHeight=%v want %v", got, wantLH)
}
}
func TestHandleFontPinch_Clamps(t *testing.T) {
TheLogic = nil
TheState = NewState()
defer func() { TheState.appFontScale = 1.0; TheState.ScrollOffset = 0 }()
// Zoom out past the minimum.
HandleFontPinch(ui.FontPinchEvent{Scale: 0.1})
if TheState.appFontScale != MinAppFontScale {
t.Errorf("appFontScale=%v want MinAppFontScale %v", TheState.appFontScale, MinAppFontScale)
}
// Zoom in past the maximum.
HandleFontPinch(ui.FontPinchEvent{Scale: 100})
if TheState.appFontScale != MaxAppFontScale {
t.Errorf("appFontScale=%v want MaxAppFontScale %v", TheState.appFontScale, MaxAppFontScale)
}
// At the maximum, further zoom-in is a no-op (including the scroll).
TheState.ScrollOffset = ui.Dp(123)
HandleFontPinch(ui.FontPinchEvent{Scale: 1.5})
if TheState.appFontScale != MaxAppFontScale {
t.Errorf("appFontScale=%v want MaxAppFontScale %v", TheState.appFontScale, MaxAppFontScale)
}
if float64(TheState.ScrollOffset) != 123 {
t.Errorf("ScrollOffset=%v want 123 (unchanged at the clamp)", TheState.ScrollOffset)
}
}
func TestSetAppFontScale_Absolute(t *testing.T) {
TheLogic = nil
TheState = NewState()
defer func() { TheState.appFontScale = 1.0; TheState.ScrollOffset = 0 }()
lh := float64(EffectiveLineHeight())
TheState.ScrollOffset = ui.Dp(50 * lh)
SetAppFontScale(2.0)
if TheState.appFontScale != 2.0 {
t.Fatalf("appFontScale=%v want 2.0", TheState.appFontScale)
}
if got, want := float64(TheState.ScrollOffset), 50*lh*2.0; math.Abs(got-want) > 1e-3 {
t.Errorf("ScrollOffset=%v want %v", got, want)
}
SetAppFontScale(0) // clamps to the minimum
if TheState.appFontScale != MinAppFontScale {
t.Errorf("appFontScale=%v want MinAppFontScale", TheState.appFontScale)
}
}
func TestHandleFontPinch_IgnoresBadData(t *testing.T) {
TheLogic = nil
TheState = NewState()
defer func() { TheState.appFontScale = 1.0 }()
HandleFontPinch("not an event")
HandleFontPinch(ui.FontPinchEvent{Scale: 0})
HandleFontPinch(ui.FontPinchEvent{Scale: -2})
if TheState.appFontScale != 1.0 {
t.Errorf("appFontScale=%v want unchanged 1.0", TheState.appFontScale)
}
}
// The pinch CENTER stays fixed: the continuous content coordinate under the
// center (display-line units) is invariant through the scale change. A
// top-anchored zoom would move that coordinate by m*(1-ratio) display
// lines — for a center 400dp below the region top at ×1.5 that is ~59
// display lines off, which is exactly what this test rejects.
func TestHandleFontPinch_CenterAnchor(t *testing.T) {
TheLogic = nil
TheState = NewState()
defer func() {
TheState.appFontScale = 1.0
TheState.ScrollOffset = 0
TheState.EditorRegion = ui.Region{}
}()
TheState.EditorRegion = ui.Region{Y: 100, H: 800}
lh := float64(EffectiveLineHeight())
TheState.ScrollOffset = ui.Dp(100 * lh)
// Center 400dp below the region top.
const m = 400.0
wantU := (float64(TheState.ScrollOffset) + m) / lh // content coord under the center
HandleFontPinch(ui.FontPinchEvent{Scale: 1.5, Center: ui.Point{Y: 100 + m}})
lhNew := float64(EffectiveLineHeight())
gotU := (float64(TheState.ScrollOffset) + m) / lhNew
// Tolerance is float32-scale + ui.Dp rounding accumulated over two
// conversions — still 4 orders of magnitude smaller than the ~59-line
// drift a top-anchored zoom would produce here.
if math.Abs(gotU-wantU) > 1e-4 {
t.Errorf("content under center: u=%v want %v (center must stay fixed)", gotU, wantU)
}
// Top-anchor sanity: the TOP moved (that is the point of center anchoring).
if float64(TheState.ScrollOffset) == 100*lh*1.5 {
t.Errorf("scroll=%v equals the top-anchored value; the center was not the anchor", TheState.ScrollOffset)
}
}
// With word wrap, the anchor must be the LOGICAL line under the center, not
// the display line: when the font change re-wraps the lines above the
// anchor (their fragment counts grow), the display-line index under the
// center changes, but the same logical line / fragment / sub-line must stay
// under it. applyFontPin is what the logic re-runs as rewrap corrections
// land (the font-pin).
func TestFontPin_SurvivesRewrap(t *testing.T) {
TheLogic = nil
TheState = NewState()
defer func() {
TheState.appFontScale = 1.0
TheState.ScrollOffset = 0
TheState.Editor.ChunkedBuffer = nil
}()
// 1000 logical lines, each wrapped into 2 fragments at the current size.
w := NewWrapIndex(1000)
for i := 0; i < 1000; i++ {
w.Set(i, 2)
}
TheState.Editor.ChunkedBuffer = &ChunkedBuffer{WrapIndex: w}
lh := float64(EffectiveLineHeight())
const m = 200.0 // region top (EditorRegion zero) + 200dp
// Anchor: logical line 60, its 1st fragment, 0.5 down it.
TheState.ScrollOffset = ui.Dp(float64(w.VisualsBefore(60))*lh + 0.5*lh - m)
line, frag, sub, ok := TheState.captureFontPin(m)
if !ok || line != 60 || frag != 0 || math.Abs(sub-0.5) > 1e-5 {
t.Fatalf("capture: line=%d frag=%d sub=%v ok=%v, want line 60 frag 0 sub 0.5", line, frag, sub, ok)
}
// The font grows ×1.5 (the line under the fingers keeps its 2 fragments
// for now): the same point stays under m.
TheState.appFontScale = 1.5
TheState.applyFontPin(line, frag, sub, m)
lhNew := float64(EffectiveLineHeight())
u := (float64(TheState.ScrollOffset) + m) / lhNew
if got := int(w.LineForVisual(int32(u))); got != 60 {
t.Errorf("after scale: line under center=%d want 60", got)
}
// Rewrap lands: every line now wraps into 3 fragments. The display-line
// index under the center moves from 120.5 to 180.5, but re-applying the
// pin must keep logical line 60 (fragment 0, sub 0.5) under the center.
for i := 0; i < 1000; i++ {
w.Set(i, 3)
}
TheState.applyFontPin(line, frag, sub, m)
u = (float64(TheState.ScrollOffset) + m) / lhNew
if got := int(w.LineForVisual(int32(u))); got != 60 {
t.Errorf("after rewrap: line under center=%d want 60 (display-line anchoring would fail here)", got)
}
// And a display-line anchor (what NOT to do) would be off: at the new
// counts, display line 120.5 is logical line 40, not 60.
if got := int(w.LineForVisual(120)); got == 60 {
t.Errorf("test is stale: display line 120 unexpectedly maps to line 60")
}
}
// fabLayout builds a GlyphLayout the way the shaper produces one: glyphsPerFrag
// glyphs per fragment, baseline of fragment f at (f+0.8)*lh (ascent < lh, the
// shaper's convention), 20dp advance each.
func fabLayout(lh float64, fragments, glyphsPerFrag int, byte0 int) ui.GlyphLayout {
gl := ui.GlyphLayout{LineHeight: ui.Dp(lh)}
for f := 0; f < fragments; f++ {
for g := 0; g < glyphsPerFrag; g++ {
gl.ByteOffsets = append(gl.ByteOffsets, byte0+f*glyphsPerFrag+g)
gl.X = append(gl.X, ui.Dp(10+20*float64(g)))
gl.Y = append(gl.Y, ui.Dp((float64(f)+0.8)*lh))
gl.Advance = append(gl.Advance, ui.Dp(20))
}
}
return gl
}
func TestGlyphAtLocalPoint(t *testing.T) {
gl := fabLayout(16.8, 2, 4, 0) // bytes 0-3 on fragment 0, 4-7 on fragment 1
if i, ok := glyphAtLocalPoint(gl, 50, 25); !ok || i != 6 {
t.Errorf("(50,25): i=%d ok=%v, want glyph 6 (byte 6, X=50 on fragment 1)", i, ok)
}
if _, ok := glyphAtLocalPoint(gl, 5, 25); ok {
t.Error("(5,25): left margin has no glyph, want not-ok")
}
if i, ok := glyphAtLocalPoint(gl, 1000, 25); !ok || i != 7 {
t.Errorf("(1000,25): past the line end pins its last glyph, got i=%d ok=%v want 7", i, ok)
}
if _, ok := glyphAtLocalPoint(ui.GlyphLayout{}, 50, 25); ok {
t.Error("empty layout: want not-ok")
}
}
func TestCaptureContentPin(t *testing.T) {
TheLogic = nil
TheState = NewState()
defer func() {
TheState.appFontScale = 1.0
TheState.ScrollOffset = 0
TheState.Editor.GlyphLayout = ui.GlyphLayout{}
}()
TheState.Editor.GlyphLayout = fabLayout(16.8, 2, 4, 0)
TheState.ScrollOffset = 0
// Point at (x=50, y=25): fragment 1 (baseline 30.24), the glyph at X=50.
pin := TheState.captureContentPin(50, 25)
if !pin.HaveGlyph || pin.Byte != 6 {
t.Fatalf("pin: byte=%d haveGlyph=%v, want byte 6", pin.Byte, pin.HaveGlyph)
}
if math.Abs(pin.Dy-(25-30.24)) > 1e-5 { // ui.Dp stores float32-precision values
t.Errorf("dy=%v want %v (offset from the glyph baseline)", pin.Dy, 25-30.24)
}
// Fallback anchor (no WrapIndex): line 1, sub = 25/16.8 - 1 ~ 0.488.
if pin.Line != 1 || pin.Frag != 0 || math.Abs(pin.Sub-(25/16.8-1)) > 1e-6 {
t.Errorf("fallback: line=%d frag=%d sub=%v, want line 1 frag 0 sub %v", pin.Line, pin.Frag, pin.Sub, 25/16.8-1)
}
}
// The REFINEMENT is what makes the content point stay fixed across a rewrap:
// the pinned byte moves to a different fragment in the new layout, and the
// refined offset places that byte's baseline (plus the captured dy) exactly
// at the pinch center — where the (line, fragment) anchor would leave a
// different character there.
func TestRefineContentPin_RewrapKeepsContentPoint(t *testing.T) {
const m = 25.0 // region-relative pinch center
const dy = 25.0 - 30.24 // from TestCaptureContentPin's point (above baseline)
// OLD layout (font 1.0x): byte 6 on fragment 1. (Capture would have
// returned Byte=6, Dy=dy.)
_ = fabLayout(16.8, 2, 4, 0)
// Font grows to 1.5x: the line re-wraps to 3 fragments x 3 glyphs; byte 6
// lands on fragment 2 (a DIFFERENT fragment than the old fragment 1 it
// was captured on... old frag index was 1, new is 2).
glNew := fabLayout(25.2, 3, 3, 0)
// The new layout is shaped for the window starting at document byte 0,
// whose first visual line is visual line 3 of the document (vk=3):
// the window top sits at content 3*25.2.
vk := 3
off, ok := refineContentPin(glNew, vk, 0, dy, m, 6)
if !ok {
t.Fatal("refine: want ok")
}
// The pinned point (byte 6's baseline + dy) must sit exactly at m.
contentY := float64(off) + m // content coordinate of the region-relative m
// byte 6's baseline: window y = (2+0.8)*25.2, window top at content vk*lh.
baselineContent := float64(vk)*float64(glNew.LineHeight) + float64(glNew.Y[6])
if math.Abs(baselineContent+dy-contentY) > 1e-5 { // float32-precision layout Y
t.Errorf("pinned point at content %v, want %v (baseline %v + dy)", contentY, baselineContent+dy, baselineContent)
}
// (A (line, fragment) anchor would have failed here: the byte captured on
// the old fragment 1 sits on the NEW fragment 2, a full line further down.
// Pinning the old fragment index would leave a different character at the
// center — exactly the drift the content pin exists to remove.)
}
// Steady state: a layout shaped at offset S with the pinned point at the
// center must refine back to exactly S (the fixed point — no drift, no
// oscillation while the pin is armed).
func TestRefineContentPin_FixedPoint(t *testing.T) {
const m = 25.0
lh := ui.Dp(25.2)
S := ui.Dp(100)
vk, r := scrollDecompose(S, lh) // window's first visual line = v0 (no wrap)
const dy = 3.0
// Place byte 4 at window y = m + r - dy so the point sits at m when the
// window (top at content vk*lh) is shaped at S.
wantY := m + r - dy
gl := ui.GlyphLayout{LineHeight: lh}
for b := 0; b < 6; b++ {
gl.ByteOffsets = append(gl.ByteOffsets, b)
gl.X = append(gl.X, ui.Dp(10+20*float64(b%3)))
gl.Y = append(gl.Y, ui.Dp(wantY)) // one line's worth for this test
gl.Advance = append(gl.Advance, ui.Dp(20))
}
off, ok := refineContentPin(gl, int(vk), 0, dy, m, 4)
if !ok {
t.Fatal("refine: want ok")
}
if math.Abs(float64(off)-float64(S)) > 1e-9 {
t.Errorf("fixed point: refine(S)=%v want S=%v (no drift)", off, S)
}
}
// Pinch-out clamp of the pinned fragment: a line that re-wraps to FEWER
// fragments must not pin a fragment that no longer exists.
func TestFontPin_FragmentClampedOnPinchOut(t *testing.T) {
TheLogic = nil
TheState = NewState()
defer func() {
TheState.appFontScale = 1.0
TheState.ScrollOffset = 0
TheState.Editor.ChunkedBuffer = nil
}()
w := NewWrapIndex(100)
for i := 0; i < 100; i++ {
w.Set(i, 3)
}
TheState.Editor.ChunkedBuffer = &ChunkedBuffer{WrapIndex: w}
// Pin line 5, fragment 2 (the third fragment).
TheState.appFontScale = 1.0
const m = 100.0
TheState.applyFontPin(5, 2, 0.25, m)
// Pinch out: the line now wraps into 2 fragments.
for i := 0; i < 100; i++ {
w.Set(i, 2)
}
TheState.appFontScale = 0.8
TheState.applyFontPin(5, 2, 0.25, m) // frag 2 must clamp to 1
lh := float64(EffectiveLineHeight())
u := (float64(TheState.ScrollOffset) + m) / lh
// The anchor is now line 5, its LAST fragment (index 1), 0.25 down it.
wantU := float64(w.VisualsBefore(5)+1) + 0.25
if math.Abs(u-wantU) > 1e-6 {
t.Errorf("anchor u=%v want %v (last remaining fragment of line 5)", u, wantU)
}
}