Pad/internal/ui/real_draw_probe_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

282 lines
10 KiB
Go

package ui
// Definitive host test: run the app's REAL Renderer.Draw op stream (a
// realistic editor frame: status bar, editor TextField, bottom bar) through
// the real input.Router with the app's per-frame protocol (drain before
// commit), queue a touch press inside the editor text, and check which tags
// receive it: the scroll tag (known-good on the phone) as control, plus the
// pressProbe/pinchProbe tags (dead on the phone).
import (
"image"
"testing"
"time"
"gioui.org/f32"
"gioui.org/font/gofont"
"gioui.org/io/event"
"gioui.org/io/input"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/text"
"gioui.org/unit"
)
func TestRealDrawOpsProbeHit(t *testing.T) {
shp := text.NewShaper(text.WithCollection(gofont.Collection()))
r := New(Theme{FontSize: 14}, shp)
noop := func(any) {}
const (
wW = 411
wH = 914
)
editorRegion := Region{X: 10, Y: 52, W: wW - 20, H: 700}
editor := NewTextField("editor_text", "hello world\nsecond line\nthird line", editorRegion, true, editorRegion.W, 0, 5, -1, -1, []Interaction{
{Gesture: Scroll, Handler: noop},
{Gesture: Tap, Handler: noop},
{Gesture: Pinch, Handler: noop},
})
editor.Focused = true
editor.ShowIMESeq = 7
statusBar := NewContainer(Region{X: 0, Y: 0, W: wW, H: 52}, Color{R: 240, G: 240, B: 240, A: 255}, []Element{
NewLabel("←", 16, Region{X: 5, Y: 5, W: 40, H: 42}, AlignStart, "back_id", nil),
NewLabel("/storage/emulated/0/Notes/test.txt", 14, Region{X: 50, Y: 5, W: 300, H: 42}, AlignStart, "path_id", nil),
})
bottomBar := NewContainer(Region{X: 0, Y: wH - 40, W: wW, H: 40}, Color{R: 240, G: 240, B: 240, A: 255}, []Element{
NewLabel("Saved", 14, Region{X: 5, Y: wH - 35, W: 100, H: 30}, AlignStart, "saved_id", nil),
})
elems := []Element{statusBar, editor, bottomBar}
m := unit.Metric{PxPerDp: 1, PxPerSp: 1}
gtxFor := func(ops *op.Ops) layout.Context {
return layout.Context{
Ops: ops,
Metric: m,
Constraints: layout.Constraints{Min: image.Point{}, Max: image.Point{X: wW, Y: wH}},
}
}
var rtr input.Router
probeK := pointer.Press | pointer.Drag | pointer.Release | pointer.Cancel
pinchF := pointer.Filter{Target: event.Tag(r.pinchProbe), Kinds: probeK}
pressF := pointer.Filter{Target: event.Tag(r.pressProbe), Kinds: probeK}
// Frame 1: record the real app ops and commit.
{
var ops op.Ops
r.Draw(gtxFor(&ops), elems, 1.0)
if _, ok := r.scrolls["editor_text"]; !ok {
t.Fatal("scroll reg not created for editor_text")
}
scroll := r.scrolls["editor_text"].scroll
t.Logf("scroll tag = %p", scroll)
// Pre-merge filters (app calls Event every frame; first call here).
rtr.Source().Event(pinchF)
rtr.Source().Event(pressF)
rtr.Source().Event(pointer.Filter{Target: scroll, Kinds: probeK})
rtr.Frame(&ops)
}
// A touch press lands in the editor text region.
pos := f32.Pt(100, 100)
rtr.Queue(pointer.Event{Kind: pointer.Press, Source: pointer.Touch, Position: pos})
t.Logf("queued press at %v", pos)
// Frame 2: draw again, then drain (app protocol: consume before commit).
{
var ops op.Ops
r.Draw(gtxFor(&ops), elems, 1.0)
drain := func(name string, f pointer.Filter) {
for {
e, ok := rtr.Source().Event(f)
if !ok {
break
}
if pe, ok := e.(pointer.Event); ok {
t.Logf("frame2: %-12s got kind=%v pos=%v", name, pe.Kind, pe.Position)
}
}
}
drain("pressProbe", pressF)
drain("pinchProbe", pinchF)
drain("scroll", pointer.Filter{Target: r.scrolls["editor_text"].scroll, Kinds: probeK})
rtr.Frame(&ops)
}
}
// TestRealDrawPinchGrabLifecycle runs the REAL app frame loop (draw ops ->
// CheckGestures -> commit) through the real input.Router with a real
// two-finger pinch and verifies the grab semantics that fix the on-device
// jank: the pair is tracked exclusively (the probe keeps getting drags even
// off-clip, scroll gets nothing), a factor is emitted per moved frame, a
// release breaks the pair, and the survivor finger's drags come out as plain
// scroll (forwarded), so the finger is not dead after a pinch.
func TestRealDrawPinchGrabLifecycle(t *testing.T) {
shp := text.NewShaper(text.WithCollection(gofont.Collection()))
r := New(Theme{FontSize: 14}, shp)
var pinchEvents []any
var scrollEvents []any
editor := NewTextField("editor_text", "hello world\nsecond line\nthird line",
Region{X: 10, Y: 52, W: 391, H: 700}, true, 391, 0, 5, -1, -1, []Interaction{
{Gesture: Scroll, Handler: func(d any) { scrollEvents = append(scrollEvents, d) }},
{Gesture: Tap, Handler: func(any) {}},
{Gesture: Pinch, Handler: func(d any) { pinchEvents = append(pinchEvents, d) }},
})
editor.Focused = true
elems := []Element{editor}
m := unit.Metric{PxPerDp: 1, PxPerSp: 1}
gtxFor := func(ops *op.Ops) layout.Context {
return layout.Context{
Ops: ops,
Metric: m,
Constraints: layout.Constraints{Min: image.Point{}, Max: image.Point{X: 411, Y: 914}},
}
}
var rtr input.Router
var prevOps op.Ops
havePrev := false
// ptEv builds a router-queueable pointer event. "Drag" is written as
// Move: the router only accepts Press/Move/Release/Cancel/Scroll and
// converts a pressed pointer's Move into a Drag before delivery.
ptEv := func(kind pointer.Kind, id pointer.ID, x, y float32, at time.Duration) pointer.Event {
if kind == pointer.Drag {
kind = pointer.Move
}
return pointer.Event{Kind: kind, Source: pointer.Touch, PointerID: id, Position: f32.Point{X: x, Y: y}, Time: at}
}
// runFrame mirrors the app's loop: commit the previous frame's ops
// (w.Event), queue this frame's pointer events, draw, CheckGestures.
// It also observes what the SCROLL tag receives this frame (drained
// before CheckGestures so the observation is lossless).
runFrame := func(evts ...pointer.Event) (events []InputEvent, scrollKinds []pointer.Kind) {
if havePrev {
rtr.Frame(&prevOps)
}
for _, e := range evts {
rtr.Queue(e)
}
var ops op.Ops
r.Draw(gtxFor(&ops), elems, 1.0)
for {
e, ok := rtr.Source().Event(pointer.Filter{
Target: r.scrolls["editor_text"].scroll,
Kinds: pointer.Press | pointer.Drag | pointer.Release | pointer.Cancel,
})
if !ok {
break
}
if pe, ok := e.(pointer.Event); ok {
scrollKinds = append(scrollKinds, pe.Kind)
}
}
events = r.CheckGestures(rtr.Source(), m)
// The app's main loop dispatches each event to its logic handler;
// mirror that so the capture handlers see them.
for _, e := range events {
e.Handler(e.Data)
}
prevOps, havePrev = ops, true
return events, scrollKinds
}
// Setup frame: register the ops.
runFrame()
hasKind := func(kinds []pointer.Kind, k pointer.Kind) bool {
for _, x := range kinds {
if x == k {
return true
}
}
return false
}
// Two fingers press 200px apart inside the editor text. Pending: no
// pair yet (the pair forms when BOTH move), no pinch events, no grabs.
_, scrollKinds := runFrame(
ptEv(pointer.Press, 0, 100, 100, time.Millisecond),
ptEv(pointer.Press, 1, 300, 100, 2*time.Millisecond))
if len(pinchEvents) != 0 {
t.Fatalf("press frame emitted a pinch event: %v", pinchEvents)
}
if hasKind(scrollKinds, pointer.Drag) {
t.Fatalf("scroll saw a drag on the press frame: %v", scrollKinds)
}
// The pair spreads 200 -> 240: this is the FORMATION frame — both
// fingers moved (the two-mover rule), the pair forms and the grabs are
// issued. One factor (1.2) is owed against the press distance. The
// pair's drags of THIS frame still reach scroll (the grabs commit on
// the next frame): a one-frame leak bounded by the scroll slop — the
// cost of not grabbing on press (which would kill single-finger
// scrolls). From the NEXT frame on, scroll must see nothing of the pair.
runFrame(
ptEv(pointer.Drag, 0, 80, 100, 10*time.Millisecond),
ptEv(pointer.Drag, 1, 320, 100, 11*time.Millisecond))
if len(pinchEvents) != 1 {
t.Fatalf("formation frame: pinch events=%d want 1 (%v)", len(pinchEvents), pinchEvents)
}
fpe, ok := pinchEvents[0].(FontPinchEvent)
if !ok || fpe.Scale < 1.19 || fpe.Scale > 1.21 {
t.Fatalf("scale=%v want ~1.2", pinchEvents[0])
}
// The pair keeps spreading: from here the grabs are active and SCROLL
// sees no drag of the pair (a Cancel for the dropped press may arrive).
_, scrollKinds = runFrame(
ptEv(pointer.Drag, 0, 50, 100, 15*time.Millisecond),
ptEv(pointer.Drag, 1, 350, 100, 16*time.Millisecond))
if len(pinchEvents) != 2 {
t.Fatalf("mid-pinch frame: pinch events=%d want 2 (%v)", len(pinchEvents), pinchEvents)
}
if hasKind(scrollKinds, pointer.Drag) {
t.Fatalf("scroll saw the pair's drags after formation: %v (grab failed)", scrollKinds)
}
// Finger 0 drags FAR OUTSIDE the editor region: the grab keeps it
// delivering to the probe (no stale pointer, no lost release).
_, scrollKinds = runFrame(
ptEv(pointer.Drag, 0, 2, 890, 20*time.Millisecond), // off-clip
ptEv(pointer.Drag, 1, 330, 100, 21*time.Millisecond))
if len(pinchEvents) != 3 {
t.Fatalf("off-clip frame: pinch events=%d want 3 (pair must survive off-clip)", len(pinchEvents))
}
if hasKind(scrollKinds, pointer.Drag) {
t.Fatalf("scroll saw the off-clip drag: %v", scrollKinds)
}
// Finger 0 lifts (off-clip): the release still arrives via the grab.
// The pair breaks; no pinch event.
before := len(pinchEvents)
runFrame(ptEv(pointer.Release, 0, 2, 890, 30*time.Millisecond))
if len(pinchEvents) != before {
t.Fatal("broken pair emitted a pinch event")
}
// The survivor (finger 1) scrolls 30px up: forwarded as a plain scroll
// delta to the editor's scroll handler — the finger is not dead.
scrollBefore := len(scrollEvents)
runFrame(ptEv(pointer.Drag, 1, 330, 70, 40*time.Millisecond))
if len(scrollEvents) != scrollBefore+1 {
t.Fatalf("survivor scroll not forwarded: events=%d want %d", len(scrollEvents), scrollBefore+1)
}
if d, ok := scrollEvents[scrollBefore].(int); !ok || d != 30 {
t.Fatalf("survivor delta=%v want 30 (px, scroll-up positive)", scrollEvents[scrollBefore])
}
// A second finger returns: candidate for a re-form (no event on the
// press); it must MOVE to become the pair (two-mover rule).
runFrame(ptEv(pointer.Press, 2, 50, 70, 50*time.Millisecond))
before = len(pinchEvents)
runFrame(
ptEv(pointer.Drag, 1, 340, 70, 60*time.Millisecond),
ptEv(pointer.Drag, 2, 30, 70, 61*time.Millisecond)) // 20px from press
if len(pinchEvents) != before+1 {
t.Fatalf("re-formed pair: pinch events=%d want %d", len(pinchEvents), before+1)
}
}