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.
282 lines
10 KiB
Go
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)
|
|
}
|
|
}
|