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

134 lines
4.3 KiB
Go

package ui
import (
"image"
"testing"
"time"
"gioui.org/font/gofont"
"gioui.org/gesture"
"gioui.org/io/event"
"gioui.org/io/input"
"gioui.org/io/key"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/text"
"gioui.org/unit"
)
// TestRevealFocusDrainTermination reproduces the IME-open scenario at the
// router level: the focused editor field has registered (stale, taller)
// bounds, the window's viewport shrinks, and the framework calls
// Router.RevealFocus which synthesizes a pointer.Scroll for the focused
// field's scroll handler. The app's shrink-frame fix (Renderer
// ZeroWheelScroll) drains pointer.Scroll events for the gesture's tag before
// gesture.Scroll.Update consumes them. This test verifies:
// 1. RevealFocus really does queue a scroll event for the gesture tag,
// 2. the drain loop terminates (bounded iteration count),
// 3. after the drain, gesture.Scroll.Update returns 0 (content does not
// move), and
// 4. on a normal (non-shrink) frame the drain is not needed and the
// gesture consumes whatever scroll it would have consumed anyway.
func TestRevealFocusDrainTermination(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: wH - 92}
// KeyDown is required: it is what makes drawElement record the
// event.Op reference for the tag, without which the key queue clears
// the FocusCmd target ("tag has no event.Op references").
editor := NewTextField("editor_text", "hello world\nsecond line\nthird line", editorRegion, true, editorRegion.W, 0, 5, -1, -1, []Interaction{
{Gesture: Scroll, Handler: noop},
{Gesture: KeyDown, Handler: noop},
})
editor.Focused = true
elems := []Element{editor}
m := unit.Metric{PxPerDp: 1, PxPerSp: 1}
var rtr input.Router
gtxFor := func(ops *op.Ops) layout.Context {
return layout.Context{
Ops: ops,
Metric: m,
Source: rtr.Source(),
Constraints: layout.Constraints{Min: image.Point{}, Max: image.Point{X: wW, Y: wH}},
}
}
// The app consumes a key.FocusFilter for the focused field every frame
// (main.go); that is what marks the handler focusable so keyQueue.Frame
// keeps the focus across frames.
drainFocus := func(q input.Source) {
for {
if _, ok := q.Event(key.FocusFilter{Target: event.Tag("editor_text")}); !ok {
break
}
}
}
// Frame 1: register handlers, focus the field (Draw issues the FocusCmd).
{
var ops op.Ops
gtx := gtxFor(&ops)
r.Draw(gtx, elems, 1.0)
drainFocus(gtx.Source)
rtr.Frame(&ops)
}
scroll := r.scrolls["editor_text"].scroll
t.Logf("focused(editor_text) after frame1: %v", rtr.Source().Focused(event.Tag("editor_text")))
// Frame 2: the app consumes gestures every frame; gesture.Scroll.Update
// registers the Scroll kind in the handler's filter, which Router.Deliver
// (used by RevealFocus/ScrollFocus) requires to match.
{
var ops op.Ops
gtx := gtxFor(&ops)
r.Draw(gtx, elems, 1.0)
drainFocus(gtx.Source)
_ = scroll.Update(m, gtx.Source, time.Now(), gesture.Vertical,
pointer.ScrollRange{}, pointer.ScrollRange{Min: -(1 << 30), Max: 1 << 30})
rtr.Frame(&ops)
}
// Simulate the keyboard opening: the framework shrinks the viewport and
// calls RevealFocus with the new (smaller) viewport.
shrunk := image.Rectangle{Min: image.Point{}, Max: image.Point{X: wW, Y: wH - 400}}
rtr.RevealFocus(shrunk)
// The app's shrink-frame fix: drain pointer.Scroll for the gesture tag.
q := rtr.Source()
drained := 0
for {
evt, ok := q.Event(pointer.Filter{Target: scroll, Kinds: pointer.Scroll})
if !ok {
break
}
drained++
if pe, ok := evt.(pointer.Event); ok {
t.Logf("drained synthetic scroll: %+v", pe.Scroll)
}
if drained > 10 {
t.Fatalf("drain loop did not terminate within 10 iterations")
}
}
if drained == 0 {
t.Fatalf("RevealFocus did not queue a scroll event for the scroll gesture tag")
}
// The gesture must now see no scroll (content stays put).
var ops op.Ops
r.Draw(gtxFor(&ops), elems, 1.0)
delta := scroll.Update(m, q, time.Now(), gesture.Vertical,
pointer.ScrollRange{}, pointer.ScrollRange{Min: -(1 << 30), Max: 1 << 30})
if delta != 0 {
t.Fatalf("gesture consumed %d px of scroll after drain; content would shift", delta)
}
rtr.Frame(&ops)
}