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.
387 lines
15 KiB
Go
387 lines
15 KiB
Go
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)
|
||
}
|
||
}
|