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.
134 lines
4.3 KiB
Go
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)
|
|
}
|