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) } }