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.
This commit is contained in:
parent
0f1b6e6290
commit
180fa966c8
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -13,6 +13,10 @@
|
|||
cmd/pad/pad
|
||||
cmd/pad/classes
|
||||
|
||||
# touchinject harness build artifacts (regenerated by build.sh)
|
||||
tools/touchinject/build/
|
||||
tools/touchinject/dex/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
|
|
|||
|
|
@ -157,9 +157,13 @@ func run(w *app.Window) error {
|
|||
// applied to the main-owned find input (edge-triggered, see
|
||||
// Frame.FindClearSeq).
|
||||
var lastFindClearSeq int
|
||||
// lastFrameW/H hold the previous FrameEvent's window size (px); 0 = no
|
||||
// frame yet. Used to spot shrink frames (see ZeroWheelScroll below).
|
||||
var lastFrameW, lastFrameH int
|
||||
|
||||
for {
|
||||
switch e := w.Event().(type) {
|
||||
e := w.Event()
|
||||
switch e := e.(type) {
|
||||
case app.DestroyEvent:
|
||||
if prof != nil {
|
||||
prof.Stop()
|
||||
|
|
@ -237,6 +241,8 @@ func run(w *app.Window) error {
|
|||
curScale = 1 // no frame yet
|
||||
}
|
||||
curFontScale := frame.FontScale // 0 until the logic has the value
|
||||
// App-local pinch font scale for the editor text (1.0 = default).
|
||||
renderer.SetAppFontScale(frame.AppFontScale)
|
||||
renderer.Draw(gtx, frame.Elems, curScale)
|
||||
glyphLayout := renderer.GlyphLayout()
|
||||
// Send search query update to the logic goroutine when it changes.
|
||||
|
|
@ -255,7 +261,19 @@ func run(w *app.Window) error {
|
|||
}
|
||||
newFind := findEditor.Text()
|
||||
sendFind := newFind != frame.FindQuery
|
||||
// On a frame that shrinks the window (the IME opening under
|
||||
// adjustResize), Gio's window synthesizes a scroll-to-focus
|
||||
// pointer.Scroll via RevealFocus — it reads the focused field's
|
||||
// stale pre-resize bounds and nudges the editor content. Flag the
|
||||
// frame so CheckGestures drains that one synthetic event before the
|
||||
// scroll gesture consumes it (Renderer.ZeroWheelScroll); finger
|
||||
// scroll and the flinger are unaffected, and normal frames are
|
||||
// untouched.
|
||||
renderer.ZeroWheelScroll = lastFrameH > 0 &&
|
||||
(e.Size.Y < lastFrameH || e.Size.X < lastFrameW)
|
||||
lastFrameW, lastFrameH = e.Size.X, e.Size.Y
|
||||
events := renderer.CheckGestures(e.Source, gtx.Metric)
|
||||
renderer.ZeroWheelScroll = false
|
||||
// Keep frames flowing while a long press is pending: a stationary
|
||||
// finger generates no pointer events, so without this the window
|
||||
// would sleep and the long-press threshold would never be reached.
|
||||
|
|
@ -384,6 +402,7 @@ func run(w *app.Window) error {
|
|||
WindowStartByte: frame.WindowStartByte,
|
||||
WindowStartLine: frame.WindowStartLine,
|
||||
EditSeq: frame.EditSeq,
|
||||
ScrollOffset: frame.ScrollOffset,
|
||||
}
|
||||
}
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -111,6 +111,23 @@ There are TWO independent scale factors, not one:
|
|||
(window start, sub-line remainder, tap mapping, scroll clamp, caret,
|
||||
handles, highlight). Change it and the line pitch on screen changes
|
||||
(57 px/line at 1.3× vs 44 px/line at 1.0× on this AVD).
|
||||
- **App-local pinch font scale** (a third factor, in-app only): a two-finger
|
||||
pinch in the editor multiplies a continuous float32 scale (1.0 default,
|
||||
clamped 0.5–3.0, never rounded) on top of the two factors above; the
|
||||
rendered line pitch is `16.8 × fontScale × appFontScale` dp. The logic
|
||||
side folds it into `EffectiveLineHeight()` (system × app) and the
|
||||
renderer multiplies the editor's sp size by it. The pinch CENTER is the
|
||||
anchor: the logic captures the **content point** there (the glyph byte +
|
||||
offset from its baseline, with a line/fragment/sub-line fallback) and
|
||||
re-anchors it under every newly shaped layout — including the re-wrap a
|
||||
few frames after the font change — so the character under the fingers
|
||||
holds still, not merely its (rewrap-moved) visual line. It is persisted in
|
||||
the relaunch session (`AppFontScale`), and
|
||||
the session's `ScrollSub` is stored as a *fraction* of the line height so
|
||||
scroll restore is font-independent. Test hook: `scripts/emu.sh cmd pinch
|
||||
<F>` (relative, anchored at the editor region center) / `fontsize <F>`
|
||||
(absolute, top-anchored) drive the same `HandleFontPinch` path a real
|
||||
pinch delivers.
|
||||
|
||||
On-device verification notes:
|
||||
|
||||
|
|
|
|||
|
|
@ -1048,3 +1048,298 @@ Verified on device: with the exclusion active, horizontal drags from the
|
|||
left-edge start handle no longer trigger `startBackNavigation`
|
||||
(`dumpsys window` shows the exclusion region; logcat shows zero
|
||||
back-gesture previews).
|
||||
|
||||
## 18. Pinch-to-change-font-size, continuous (2026-08-22)
|
||||
|
||||
A two-finger pinch in the editor now changes the app's font size smoothly,
|
||||
without snapping to whole points. The app-local scale is a float32
|
||||
(default 1.0, clamped 0.5–3.0) layered on top of the system user font
|
||||
setting; it is never rounded anywhere in the pipeline.
|
||||
|
||||
**Renderer (`internal/ui`).** Gio v0.10 has no two-finger pinch primitive,
|
||||
so the Renderer owns a probe event tag clipped to the editor text region
|
||||
(next to the long-press probe). It tracks the active pointers across frames
|
||||
(window-px positions keyed by pointer ID) and emits one relative factor per
|
||||
frame — `pinchDist(cur)/pinchDist(prev)` — as `ui.FontPinchEvent` to the
|
||||
editor's new `ui.Pinch` interaction handler. `pinchDist` (two lowest-ID
|
||||
pointers) is a pure function, unit-tested. While a pinch is active, scroll
|
||||
emission is suppressed so the first finger does not drag the text.
|
||||
`drawWrappedText` multiplies the editor's sp size by the frame's
|
||||
`AppFontScale` (main goroutine feeds it via `SetAppFontScale` before
|
||||
Draw); ascent/line-height/highlight/caret/handles all follow because they
|
||||
derive from the same size.
|
||||
|
||||
**Logic (`internal/editor`).** `State.appFontScale` + `HandleFontPinch`
|
||||
(multiply by the per-frame factor and clamp). The anchor is the pinch
|
||||
CENTER, not the viewport top — and it is a **content point**, not a layout
|
||||
point: `State.captureContentPin` names the glyph under the midpoint (the
|
||||
ABSOLUTE buffer byte — the layout's `ByteOffsets` are window-relative, so
|
||||
the capture adds `IMEWindowStartByte` — plus the point's offset from that
|
||||
glyph's baseline), captured under the pre-change layout. A rewrap moves
|
||||
the *text* of a visual line (the same fragment index holds different bytes
|
||||
after the rewrap), so pinning (line, fragment) would leave a different
|
||||
character at the center; naming the byte does not. The offset is applied
|
||||
in two phases: (1) immediately, `rescaleScrollAnchored` rescales `S + m`
|
||||
about the center (continuous, valid until rewrap lands); (2) on **every
|
||||
newly shaped layout at the current scale** — the re-shape after the font
|
||||
change and the rewrap corrections that follow it — `refineContentPin`
|
||||
recomputes the offset from the pinned byte's fresh baseline:
|
||||
`S' = vk·lh + Y + Dy − m`, where `vk = VisualsBefore(WindowStartLine)` is
|
||||
the window's FIRST visual line (the window top sits at content `vk·lh`,
|
||||
NOT `floor(S/lh)·lh` — a different line whenever the viewport top lands
|
||||
mid-way through a wrapped logical line) and `Y` is the byte's baseline in
|
||||
the fresh layout (located by its absolute byte, window start from
|
||||
`LayoutFeedback.WindowStartByte`). That lands the byte exactly on the
|
||||
center and is a fixed point when the layout already agrees (no drift, no
|
||||
oscillation). Two stale-data traps had to be closed: the scale change
|
||||
**invalidates the last shaped layout** (`invalidateShapedLayout`) —
|
||||
otherwise the next frame computes its window start with the OLD line
|
||||
height and the NEW rescaled offset, a window ~10k lines off — and
|
||||
`refreshFontPin` skips feedback shaped at a different scale (during a
|
||||
pinch every frame changes the scale, so all but the latest feedback are
|
||||
stale). While armed (2 s, refreshed by feedback) the pin rides every
|
||||
frame; a (line, fragment, sub-line) anchor (`captureFontPin`/`applyFontPin`)
|
||||
stands in for points off any glyph; an edit (EditSeq mismatch), a scroll,
|
||||
or the timeout disarms it. `SetAppFontScale` (the `fontsize` debug
|
||||
command) keeps the top-anchored behavior (no fingers to center on) and
|
||||
invalidates the stale layout too. `EffectiveLineHeight()` is now system ×
|
||||
app; every geometry consumer (window start, tap mapping, scroll clamp,
|
||||
restore) was already routed through it. `Frame.AppFontScale` carries the
|
||||
value to the renderer; `LayoutFeedback.ScrollOffset` carries the shaped
|
||||
scroll back (used by the fallback path and diagnostics).
|
||||
|
||||
**Persistence.** The session snapshot gains `AppFontScale`, and
|
||||
`ScrollSub` is now stored as a *fraction* of the line height (font-
|
||||
independent; legacy Dp values > 1 are converted on restore).
|
||||
|
||||
**Testing.** `pinch_font_test.go` (continuous product of small factors,
|
||||
clamp at both ends, center-anchor invariance, glyph hit-testing, content-
|
||||
pin capture, the refine keeping the pinned BYTE on the center across a
|
||||
rewrap that moves it to another fragment, the fixed-point property, the
|
||||
(line, fragment) fallback, fragment clamp on pinch-out, bad-data no-op),
|
||||
`pinch_test.go` (pinchDist/pinchMid geometry,
|
||||
factor-series telescoping). On the emulator
|
||||
(`scripts/emu.sh cmd pinch <F>` / `fontsize <F>` — one-shot commands that
|
||||
drive the same `HandleFontPinch` path a real pinch delivers, since adb has
|
||||
no two-finger input; `pinch` anchors at the editor-region center): five
|
||||
×1.05 steps produced line pitches 44→46→49→51→54→56 px (autocorrelation-
|
||||
measured) — continuous, no whole-point snapping; on a 40k-line wrapped
|
||||
file scrolled to the middle, a pinch in/out cycle (×1.5 → ×0.75 →
|
||||
×1.125) exercised the rewrap in BOTH directions (1→2 and 2→1 fragments
|
||||
per line) and the pin converged to a fixed point within 2–3 layout-
|
||||
feedback frames at every step, keeping the captured BYTE's line on the
|
||||
region center (verified against the app's own geometry, not the pixels);
|
||||
the ground-truth tap test (tap a line, type a marker, read the file)
|
||||
passed at a 1.125× scale after two rewrapping pinches; `fontsize` keeps
|
||||
the top anchor across a 1→2 rewrap; font scale, file, cursor and scroll
|
||||
all survive a full restart; 0.5/3.0 clamps hold; one-finger scroll is
|
||||
unaffected (and takes over the viewport, disarming the pin).
|
||||
|
||||
**Bugs found by the emulator round (both would have passed the unit
|
||||
suite).** (1) `GlyphLayout.ByteOffsets` are window-relative, not absolute:
|
||||
capturing the pin's byte without adding the window start made the pin
|
||||
chase a moving offset and never converge. (2) The scale change left the
|
||||
old `GlyphLayout` in place; the next frame computed its window start with
|
||||
the OLD line height and the NEW rescaled offset — a window ~10k lines
|
||||
from the viewport (visible as a ~1000-line jump). Fixed by
|
||||
`invalidateShapedLayout()` on every scale change.
|
||||
|
||||
**Bug found by the on-device round (the emulator round could never have
|
||||
found it — adb has no two-finger input, and the debug `pinch` command
|
||||
bypasses the probe entirely).** On the phone a real two-finger pinch did
|
||||
nothing. On-device logcat (probe event logs + per-frame event counts)
|
||||
showed the scroll gesture receiving every finger move while both probe
|
||||
tags received zero events. Root cause: the probes were declared as
|
||||
`struct{}` fields of the Renderer. The unnamed fieldless `struct{}` is a
|
||||
SINGLE canonical Go type, so `pressProbe`, `pinchProbe` (and a
|
||||
diagnostic third) were the SAME tag value. Gio's router keys handlers by
|
||||
tag value, so all three `event.Op` registrations collapsed into one
|
||||
handler; the press probe's drain (which runs first in `CheckGestures`)
|
||||
consumed every event for that tag and the pinch probe was structurally
|
||||
starved. Fixed by giving each probe its own named type
|
||||
(`pressProbeTag`, `pinchProbeTag`), with a regression test
|
||||
(`TestProbeTagIdentity`) asserting the tags remain distinct map keys, and
|
||||
`TestRealDrawOpsProbeHit`, which runs the real `Renderer.Draw` op stream
|
||||
through a real `input.Router` and asserts the probe tags receive the
|
||||
pointer press. Verified on the Pixel 9 Pro: 927 probe events across a
|
||||
multi-pinch session, 137 per-frame factors emitted and applied (net
|
||||
scale 1.70×, font visibly enlarged), scroll suppressed mid-pinch,
|
||||
anchor held.
|
||||
|
||||
## 19. Pinch tracker: explicit pair, two-mover formation, slow frames (2026-08-23)
|
||||
|
||||
The §18 probe design ("two lowest-ID pointers") was replaced by an explicit
|
||||
pair state machine (`internal/ui/pinch_tracker.go`, pure and unit-tested;
|
||||
the renderer's `consumePinchProbe` is now a thin adapter that feeds events,
|
||||
executes the tracker's grabs, and emits its factor). Four on-device failure
|
||||
modes drove the rewrite, plus a whole class of slow-frame bugs only visible
|
||||
on the ~1 fps emulator:
|
||||
|
||||
1. **Single-finger scroll changed the font** — the pair was re-derived from
|
||||
whatever pointers happened to be present, so a scroll finger got paired
|
||||
with a stale pointer and its drags became "pinch".
|
||||
2. **Scroll-down enlarged the font** — same root: the scroll finger's
|
||||
distance to a stale second pointer grows as it moves.
|
||||
3. **Two fingers produced a sudden zoom before the pinch** — the baseline
|
||||
(`prevDist`) survived from the previous pinch, so a new pinch 2.5× wider
|
||||
emitted 2.5× on its first frame.
|
||||
4. **Pinch-out stopped and became a scroll** — the pair was not explicit;
|
||||
once a finger moved past the scroll slop the router handed the pointer
|
||||
to the scroll gesture and the "pair" silently switched composition.
|
||||
|
||||
**Explicit, stable pair + grabs.** When the pair forms, the adapter issues
|
||||
`pointer.GrabCmd` for BOTH fingers (exclusive delivery to the probe:
|
||||
releases arrive even off-clip, and scroll/click are dropped with a Cancel —
|
||||
so the pair can never be stolen mid-gesture, failure mode 4). The pair's
|
||||
composition and its baseline are never re-derived from ambient pointers.
|
||||
`factor()` = current pair distance / previous frame's distance, one factor
|
||||
per frame (the Android driver replays historical samples — several drags per
|
||||
frame — so the font sees one factor per frame, not per sample). A sanity
|
||||
clamp drops factors outside 0.1–10 and advances the baseline, so a
|
||||
teleporting pointer (ID-reuse noise) cannot jump the font. When a pair
|
||||
finger lifts, the other becomes the **survivor**: it stays grabbed (Gio
|
||||
v0.10 has no release-grab) and its drags are forwarded as a plain scroll
|
||||
delta (`survivorScroll`), so the finger is not dead. A second finger that
|
||||
returns re-forms the pair with a fresh baseline.
|
||||
|
||||
**Formation requires TWO MOVING fingers — and nothing else.** The dominant
|
||||
real-world case is a palm edge already down when the two pinch fingers
|
||||
land; a static rule about "which finger is the palm" (the oldest? the
|
||||
newest? the still one?) cannot survive both palm-first and palm-last hand
|
||||
lands. Movement is the only signal that works for both: while 2–3 fresh
|
||||
fingers are down the tracker is *pending*; the pair forms — at `factor()`
|
||||
time, after the whole frame's events, never per-event (per-event locking
|
||||
in the first mover pair seen would pair a finger with a drifting palm) —
|
||||
when two pending fingers have each moved more than `pinchMoveEps` (10 px)
|
||||
from where they pressed, and it is the mover pair whose **distance**
|
||||
changed most (a drifting palm's distance to a finger changes little; a
|
||||
pinch's does). A lone mover is a scroll, never a pair; a unison movement
|
||||
(a two-finger slide) leaves the distance unchanged and forms nothing.
|
||||
Consequences verified: a resting (pruned, >300 ms) or still palm can never
|
||||
enter the distance; the three fresh-fingers case pairs the pinch fingers;
|
||||
the re-form candidate (a finger landing on a survivor) must also move
|
||||
before it becomes the pair.
|
||||
|
||||
**Baseline = the PRESS distance.** Any spread that happened before the pair
|
||||
starts is owed, not lost: the formation frame emits `d_current /
|
||||
d_press`, and a pinch that breaks before its first `factor()` settles the
|
||||
same owed factor at the break.
|
||||
|
||||
**Slow frames (the ~1 fps emulator batches a whole gesture into one
|
||||
drain).** (a) *Born-and-dead in one frame*: presses, drags and BOTH
|
||||
releases in one drain — pending releases are held **lazy** (`released`
|
||||
map) until `factor()`: the pair forms at the fingers' final positions,
|
||||
then breaks there (no survivor when both released), settling the owed
|
||||
factor. (b) *Pair broke mid-frame*: `brokeFactor`/`brokeMid` are settled
|
||||
at the break (against `prevDist`, or the press distance when fresh) and
|
||||
emitted by the subsequent `factor()` call, which would otherwise see
|
||||
`on==false` and drop the frame's movement.
|
||||
|
||||
**Emulator multi-touch injection.** adb has no two-finger input, so the
|
||||
failure modes could not be tested end-to-end until `tools/touchinject`: a
|
||||
platform-signed (AOSP test key, `INJECT_EVENTS` granted) toy app whose
|
||||
broadcast receiver injects a scripted `MotionEvent` stream
|
||||
(`down/move/up/wait`, display px) through
|
||||
`InputManager.injectInputEvent` — `/dev/uhid` is a dead end (no kernel
|
||||
module in the image). Two known flakes, both documented in the harness:
|
||||
the receiver process is "cached" and the 1.5 GB emulator OOM-kills it
|
||||
mid-script occasionally (the harness verifies `=== done` in logcat and
|
||||
re-runs; service routing is blocked by Android 12+ background-start
|
||||
restrictions, and the AVD's locked bootloader blocks the system-app
|
||||
escalation); and burst drags (all moves within one frame's drain) do not
|
||||
scroll — the app is on-demand-rendering at ~1 fps, so scroll tests space
|
||||
the moves ~80 ms apart, which also matches what a real finger produces
|
||||
over several frames.
|
||||
|
||||
**End-to-end results (real injected MotionEvents, big wrapped file).**
|
||||
Single-finger drag: zero factors, scale unchanged (font), content scrolls.
|
||||
Two-finger pinch-out 300→596 px: exactly one factor 596/300 = 1.98667,
|
||||
font ~2×. Palm-first three fingers (palm resting and still, pinch fingers
|
||||
landing 80/120 ms later): pair is the two pinch fingers — the factor
|
||||
tracks the pinch, the palm never enters the distance. Lift one finger
|
||||
mid-pinch: the factor stream stops at the lift (font frozen), the
|
||||
survivor's 600 px drag scrolls the content 600/3.5 = 171.4 dp. The
|
||||
one-frame leak at formation (the pair's own drags of the formation frame
|
||||
still reach scroll, since the grabs commit next frame) is bounded by the
|
||||
scroll slop — the deliberate cost of not grabbing on press, which would
|
||||
kill single-finger scrolls.
|
||||
|
||||
**Testing.** `pinch_test.go` now covers: factor-series telescoping
|
||||
(baseline = press distance), single-finger never scales, resting/stale
|
||||
palm excluded, extra finger during an active pinch ignored, fresh baseline
|
||||
per pinch, sanity clamp, the three slow-frame shapes (full pinch in one
|
||||
frame, stationary born-dead, born-and-dead), palm-first three fingers,
|
||||
and survivor scroll + re-form (candidate must move).
|
||||
`real_draw_probe_test.go` runs the real `Renderer.Draw` op stream through
|
||||
a real `input.Router`: press frame (pending, nothing), formation frame
|
||||
(grabs + owed factor, one-frame scroll leak), post-formation frames (scroll
|
||||
sees nothing of the pair), off-clip survival, release via the grab,
|
||||
survivor scroll forwarding, re-form.
|
||||
|
||||
## 20. IME open: content must not shift (2026-08-23)
|
||||
|
||||
**Bug.** With the soft keyboard open (adjustResize), the editor content
|
||||
jumped up by exactly 32 dp (112 px) every time the keyboard appeared.
|
||||
Top-anchored layout keeps the window start line put when only the
|
||||
viewport height changes, so the shift was not our layout: KBW
|
||||
instrumentation of every `ScrollOffset` writer showed
|
||||
`HandleScroll` receiving a single +112 px delta at the resize frame.
|
||||
|
||||
**Root cause (Gio, not the app).** `gioui.org/app` `window.go`, on every
|
||||
frame whose viewport *shrank*, calls `Router.RevealFocus(viewport)` —
|
||||
"scroll the focused widget into view". For a text editor the focused
|
||||
field's registered bounds (stale — from the pre-resize, taller frame)
|
||||
extend below the new viewport, so RevealFocus synthesizes a
|
||||
`pointer.Scroll` event (`Source: Touch`, position (0,0), Y = the nudge)
|
||||
delivered to the focused field's scroll handler. `gesture.Scroll`
|
||||
consumes it like any wheel scroll → `HandleScroll` → the 32 dp jump.
|
||||
The event is invisible to the app: it never enters the pointer queue
|
||||
(no `MotionEvent` on the Android side), it is manufactured by the router
|
||||
during `processEvent(frameEvent)`, before the app's frame handler runs.
|
||||
Reproduced at the router level: `RevealFocus` on a shrunken viewport
|
||||
queues exactly one scroll event for the gesture's tag.
|
||||
|
||||
**Why not the obvious fixes.**
|
||||
- Zeroing the scroll *range* on the shrink frame does nothing: the router
|
||||
UNIONs scroll ranges into the handler's filter across frames
|
||||
(`pointerFilter.Add`/`Merge`), so the historical max can never shrink
|
||||
back to zero — the clamp stays at ±∞ forever.
|
||||
- Patching `app/window.go` to drop the shrink→RevealFocus call would mean
|
||||
shipping a forked gioui (the build constraint is clean v0.10.0).
|
||||
- `adjustNothing` removes the resize but hides the cursor line under the
|
||||
keyboard.
|
||||
|
||||
**Fix (app-side, two files).**
|
||||
- `cmd/pad/main.go`: on each `FrameEvent`, detect a shrink
|
||||
(`e.Size` smaller than the previous frame's) and set
|
||||
`renderer.ZeroWheelScroll` for that one frame.
|
||||
- `internal/ui/render.go` (`CheckGestures`): when flagged, drain
|
||||
`pointer.Scroll` events for the editor scroll gesture's tag
|
||||
(`q.Event(pointer.Filter{Target: reg.scroll, Kinds: pointer.Scroll})`)
|
||||
before `gesture.Scroll.Update` consumes anything. Only the synthesized
|
||||
nudge matches: finger scroll is `pointer.Drag`, inertia is the
|
||||
flinger, and on a phone there is no trackpad wheel. Normal frames are
|
||||
untouched.
|
||||
|
||||
**Verification.**
|
||||
- `reveal_focus_drain_test.go`: real `input.Router` + real
|
||||
`Renderer.Draw` ops; the focused field (KeyDown interaction required —
|
||||
it records the `event.Op` tag reference, and a per-frame
|
||||
`key.FocusFilter` consumer marks the handler focusable, else the key
|
||||
queue clears the focus each frame), shrunken viewport, `RevealFocus` →
|
||||
exactly one synthetic scroll queued for the gesture tag; the drain
|
||||
terminates and `gesture.Scroll.Update` then returns 0.
|
||||
- Emulator E2E (real injected taps, keyboard really opens, window
|
||||
2560→1527 px): pre-fix the scroll gesture emitted delta=112 on the
|
||||
shrink frame and a screenshot cross-correlation showed a 112 px content
|
||||
shift; post-fix the drain consumes the event, the gesture delta is 0,
|
||||
and the cross-correlation shift is 0 (corr 0.987). The perf-CSV
|
||||
(ScrollDP per logic frame) shows no 32 dp step when the keyboard opens
|
||||
on the final build.
|
||||
|
||||
**Notes.** The `aosp_atd` emulator later started ANR-ing Pad on first
|
||||
frame — the ANR trace shows the main thread in
|
||||
`GioView.onFrameCallback` → `glDeleteBuffers` → gfxstream guest →
|
||||
`madvise` (91 s system time): the emulated GPU's buffer-free path,
|
||||
unrelated to input handling. Final verification therefore used a small
|
||||
file (fast first frame) plus the router-level test.
|
||||
|
|
|
|||
28
doc/spec.md
28
doc/spec.md
|
|
@ -60,6 +60,34 @@ elsewhere.
|
|||
hardware keyboard, arrow keys, Home/End, and Page Up/Down move the cursor
|
||||
(verified on the Android emulator; Gio's mobile focus-navigation default
|
||||
for arrow keys is overridden — see architecture.md §2.1).
|
||||
- **Soft keyboard does not shift content:** opening or closing the IME
|
||||
resizes the window (adjustResize); the editor is top-anchored, so the
|
||||
visible text stays put. Gio's window otherwise synthesizes a scroll-to-
|
||||
focus nudge on any frame the viewport shrinks (RevealFocus, aimed at the
|
||||
focused field's stale pre-resize bounds), which would shift the content
|
||||
up; the app drains that one synthetic scroll on the shrink frame
|
||||
(development_plan.md §20).
|
||||
- **Pinch to change font size:** a two-finger pinch inside the editor text
|
||||
changes the app's font size continuously — the rendered size tracks the
|
||||
inter-finger distance with no snapping to whole points (the scale is a
|
||||
float32 multiplied by the per-frame distance ratio, clamped to 0.5×–3.0×
|
||||
of the 14sp base). It is an *app-local* scale layered on top of the
|
||||
system user font setting. The CONTENT UNDER THE PINCH CENTER STAYS
|
||||
FIXED: the logic captures the **content point** under the finger
|
||||
midpoint — the glyph byte and the point's offset from that glyph's
|
||||
baseline — and re-derives the scroll offset to keep that point on the
|
||||
center. A content point, not a layout point: when the font change
|
||||
re-wraps a line, a *visual* line's text moves (fragment 2 of the new
|
||||
wrap is different text), so pinning (line, fragment) would leave a
|
||||
different character at the center. The pin therefore names the
|
||||
character itself (byte + baseline offset) and re-anchors it under every
|
||||
newly shaped layout — the re-wrap included — which is what keeps the
|
||||
character under the fingers through the rewrap. (A (line, fragment,
|
||||
sub-line) anchor stands in as fallback for points off any glyph.)
|
||||
Single-finger scroll is suppressed mid-pinch (the pinch owns the two
|
||||
fingers) and takes over the viewport on the first scroll after the
|
||||
pinch. The scale is part of the relaunch session (§2.4) and survives
|
||||
restarts.
|
||||
- **Text selection:** two input paths. **Touch** (the Android-native model):
|
||||
long-press selects the word under the finger (on a blank spot it places the
|
||||
caret and offers a paste-only menu); double-tap selects the word; the
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ type Frame struct {
|
|||
Elems []ui.Element
|
||||
Scale float32
|
||||
FontScale float32 // user font-size setting the logic bookkeeping used
|
||||
// AppFontScale is the app-local pinch font scale (1.0 = default, 0 =
|
||||
// not set yet). The renderer multiplies the editor font size by it;
|
||||
// FontScale is already folded into gtx.Metric (PxPerSp).
|
||||
AppFontScale float32
|
||||
FocusedElementID string
|
||||
Query string
|
||||
// FindQuery: the in-file search query the logic goroutine has processed
|
||||
|
|
@ -36,6 +40,10 @@ type Frame struct {
|
|||
// FindClearSeq mirrors EditorState.Find.ClearSeq: main wipes the widget
|
||||
// input once per NEW value (the X button cleared the logic-side query).
|
||||
FindClearSeq int
|
||||
// ScrollOffset is the editor scroll offset this frame's elements laid
|
||||
// out at, shipped with the shaped glyph layout (LayoutFeedback) so the
|
||||
// logic can express layout positions in content coordinates.
|
||||
ScrollOffset ui.Dp
|
||||
// WindowStartByte / WindowStartLine / EditSeq: the editor window this
|
||||
// frame's elements describe. The main goroutine forwards them with the
|
||||
// shaped glyph layout (LayoutFeedback) so the logic goroutine can apply
|
||||
|
|
@ -61,10 +69,12 @@ func (l *Logic) frameOf(elems []ui.Element) Frame {
|
|||
Elems: elems,
|
||||
Scale: l.state.scale,
|
||||
FontScale: l.state.fontScale,
|
||||
AppFontScale: l.state.appFontScale,
|
||||
FocusedElementID: l.state.FocusedElementID,
|
||||
Query: l.state.Browser.Query,
|
||||
FindQuery: l.state.Editor.Find.Query,
|
||||
FindClearSeq: l.state.Editor.Find.ClearSeq,
|
||||
ScrollOffset: l.state.ScrollOffset,
|
||||
WindowStartByte: l.state.Editor.IMEWindowStartByte,
|
||||
WindowStartLine: l.state.WindowStartLine,
|
||||
WindowText: l.state.Editor.IMEWindowText,
|
||||
|
|
|
|||
|
|
@ -126,6 +126,17 @@ type Logic struct {
|
|||
restoreScrollLine int
|
||||
restoreScrollSub float64
|
||||
restorePinDeadline time.Time
|
||||
// fontPin/fontPinM implement the pinch font-pin (see
|
||||
// setFontPin/refreshFontPin in session.go): the CONTENT point under the
|
||||
// pinch center (glyph byte + offset from its baseline, with a
|
||||
// line/fragment/sub-line fallback) and the center's region-relative Y
|
||||
// (dp). While armed, every shaped layout re-derives the scroll offset to
|
||||
// keep that content point under the center as the font scale — and, a
|
||||
// few frames later, the rewrap — changes.
|
||||
fontPin contentPin
|
||||
fontPinM float64
|
||||
fontPinArmed bool
|
||||
fontPinDeadline time.Time
|
||||
scaleSeen bool
|
||||
restoreContentLanded bool
|
||||
sessionSaver func(SessionState)
|
||||
|
|
@ -307,6 +318,12 @@ func (l *Logic) Run() {
|
|||
l.refreshRestorePin()
|
||||
}
|
||||
}
|
||||
// Pinch font-pin (see refreshFontPin): the fresh layout may
|
||||
// have rewrapped the pinned line; re-derive so the pinned
|
||||
// content point stays under the pinch center.
|
||||
if l.fontPinArmed {
|
||||
l.refreshFontPin(fb)
|
||||
}
|
||||
// Search settle (see EditorState.findSettle): the shaping above
|
||||
// may have corrected the wrap counts around a find-jumped
|
||||
// viewport; re-scroll while the correction still matters.
|
||||
|
|
@ -372,6 +389,7 @@ func (l *Logic) openFile(path string) {
|
|||
if l.restoreFile != "" && l.restoreFile != path {
|
||||
l.abortRestore() // a different open cancels the in-flight one
|
||||
}
|
||||
l.releaseFontPin() // the pin names lines of the previous file
|
||||
// Discard find results for the previous file; the query is kept
|
||||
// (see EditorState.findReset) and re-scanned against the new file.
|
||||
TheState.Editor.findReset()
|
||||
|
|
@ -492,10 +510,11 @@ func (l *Logic) EnableDebugCmdPoll(dir string) {
|
|||
}()
|
||||
}
|
||||
|
||||
// applyDebugCmd applies a one-shot debug scroll command from the cmd-file
|
||||
// poller. Commands: "open <path>" (any page), and, on the editor page,
|
||||
// "top", "bottom", "frac <0..1>", "dp <int>". Must be called on the logic
|
||||
// goroutine.
|
||||
// applyDebugCmd applies a one-shot debug command from the cmd-file poller.
|
||||
// Commands: "open <path>" (any page), and, on the editor page, "top",
|
||||
// "bottom", "frac <0..1>", "dp <int>", "pinch <factor>" (relative app font
|
||||
// scale, as the renderer's pinch probe would deliver) and "fontsize <v>"
|
||||
// (absolute app font scale). Must be called on the logic goroutine.
|
||||
func (l *Logic) applyDebugCmd(cmd string) {
|
||||
s := l.state
|
||||
fields := strings.Fields(cmd)
|
||||
|
|
@ -515,6 +534,45 @@ func (l *Logic) applyDebugCmd(cmd string) {
|
|||
l.emitFrame() // OpenFile only mutates state (the tap path emits via its handler)
|
||||
return
|
||||
}
|
||||
// App-local font scale (pinch zoom). Drives the full logic->frame->render
|
||||
// path the same way a real pinch does (HandleFontPinch is the handler a
|
||||
// FontPinchEvent carries); adb has no two-finger input, so these
|
||||
// commands are the on-emulator test hook. `pinch` anchors at the CENTER
|
||||
// of the editor region (where a real pinch usually starts); `fontsize`
|
||||
// is an absolute top-anchored set.
|
||||
if fields[0] == "pinch" || fields[0] == "fontsize" {
|
||||
if s.page != EditorPage {
|
||||
log.Printf("DebugCmd: %q ignored (not on editor page)", cmd)
|
||||
return
|
||||
}
|
||||
if len(fields) < 2 {
|
||||
log.Printf("DebugCmd: %s needs a value", fields[0])
|
||||
return
|
||||
}
|
||||
f, err := strconv.ParseFloat(fields[1], 32)
|
||||
if err != nil || f <= 0 {
|
||||
log.Printf("DebugCmd: bad %s value %q", fields[0], fields[1])
|
||||
return
|
||||
}
|
||||
if fields[0] == "pinch" {
|
||||
if f > 100 { // a single frame's pinch never spans this much
|
||||
log.Printf("DebugCmd: pinch factor out of range: %v", f)
|
||||
return
|
||||
}
|
||||
HandleFontPinch(ui.FontPinchEvent{
|
||||
Scale: float32(f),
|
||||
Center: ui.Point{
|
||||
X: s.EditorRegion.X + s.EditorRegion.W/2,
|
||||
Y: s.EditorRegion.Y + s.EditorRegion.H/2,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
SetAppFontScale(float32(f))
|
||||
}
|
||||
log.Printf("DebugCmd: %q -> appFontScale=%.4f scroll=%d", cmd, s.appFontScale, int(s.ScrollOffset))
|
||||
l.emitFrame()
|
||||
return
|
||||
}
|
||||
if s.page != EditorPage {
|
||||
log.Printf("DebugCmd: %q ignored (not on editor page)", cmd)
|
||||
return
|
||||
|
|
@ -555,6 +613,7 @@ func (l *Logic) applyDebugCmd(cmd string) {
|
|||
if target > s.MaxScroll {
|
||||
target = s.MaxScroll
|
||||
}
|
||||
l.releaseFontPin() // a debug scroll takes over the viewport
|
||||
s.ScrollOffset = target
|
||||
log.Printf("DebugCmd: %q -> scroll=%d maxScroll=%d", cmd, int(s.ScrollOffset), int(s.MaxScroll))
|
||||
l.emitFrame()
|
||||
|
|
|
|||
386
internal/editor/pinch_font_test.go
Normal file
386
internal/editor/pinch_font_test.go
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
package editor
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"pad/internal/ui"
|
||||
)
|
||||
|
||||
// These tests cover the app-local pinch font scale: EffectiveLineHeight must
|
||||
// follow the product of the system font scale and the app scale, and the
|
||||
// pinch handler must apply the relative factor continuously (no rounding to
|
||||
// whole points) while keeping the viewport top anchored (scroll offset
|
||||
// rescaled in lockstep with the line height).
|
||||
|
||||
func TestEffectiveLineHeight_FollowsAppFontScale(t *testing.T) {
|
||||
TheLogic = nil
|
||||
TheState = NewState()
|
||||
defer func() { TheState.appFontScale = 1.0 }()
|
||||
|
||||
cases := []struct {
|
||||
sys, app float32
|
||||
want float64
|
||||
}{
|
||||
{0, 1.0, float64(EditorLineHeight())}, // both unknown/default
|
||||
{1.3, 1.0, float64(EditorLineHeight()) * 1.3},
|
||||
{1.0, 1.7, float64(EditorLineHeight()) * 1.7},
|
||||
{1.3, 1.7, float64(EditorLineHeight()) * 1.3 * 1.7},
|
||||
{1.0, 0.5, float64(EditorLineHeight()) * 0.5},
|
||||
}
|
||||
for _, c := range cases {
|
||||
TheState.fontScale = c.sys
|
||||
TheState.appFontScale = c.app
|
||||
got := float64(EffectiveLineHeight())
|
||||
if math.Abs(got-c.want) > 1e-5 {
|
||||
t.Errorf("sys=%v app=%v: EffectiveLineHeight=%v want %v", c.sys, c.app, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFontPinch_ContinuousAndAnchored(t *testing.T) {
|
||||
TheLogic = nil
|
||||
TheState = NewState()
|
||||
defer func() { TheState.appFontScale = 1.0; TheState.ScrollOffset = 0 }()
|
||||
|
||||
// Start scrolled to a line boundary: 100 lines at the default pitch.
|
||||
lh := float64(EffectiveLineHeight())
|
||||
TheState.ScrollOffset = ui.Dp(100 * lh)
|
||||
|
||||
// A sequence of small relative factors: the product is 1.1^5 ~ 1.61051,
|
||||
// a non-representable float32 — rounding to whole points (or to any
|
||||
// fixed step) would not land here.
|
||||
for i := 0; i < 5; i++ {
|
||||
HandleFontPinch(ui.FontPinchEvent{Scale: 1.1})
|
||||
}
|
||||
want := float64(1.1) * 1.1 * 1.1 * 1.1 * 1.1
|
||||
if math.Abs(float64(TheState.appFontScale)-want) > 1e-6 {
|
||||
t.Errorf("appFontScale=%v want %v (continuous, no snapping)", TheState.appFontScale, want)
|
||||
}
|
||||
// The scroll offset rescaled by the same ratio: the same line (100) at
|
||||
// the same sub-line fraction (0) stays on top.
|
||||
wantOff := 100 * lh * want
|
||||
if got := float64(TheState.ScrollOffset); math.Abs(got-wantOff) > 1e-3 {
|
||||
t.Errorf("ScrollOffset=%v want %v (anchor preserved)", got, wantOff)
|
||||
}
|
||||
// EffectiveLineHeight follows the new scale.
|
||||
wantLH := lh * want
|
||||
if got := float64(EffectiveLineHeight()); math.Abs(got-wantLH) > 1e-5 {
|
||||
t.Errorf("EffectiveLineHeight=%v want %v", got, wantLH)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFontPinch_Clamps(t *testing.T) {
|
||||
TheLogic = nil
|
||||
TheState = NewState()
|
||||
defer func() { TheState.appFontScale = 1.0; TheState.ScrollOffset = 0 }()
|
||||
|
||||
// Zoom out past the minimum.
|
||||
HandleFontPinch(ui.FontPinchEvent{Scale: 0.1})
|
||||
if TheState.appFontScale != MinAppFontScale {
|
||||
t.Errorf("appFontScale=%v want MinAppFontScale %v", TheState.appFontScale, MinAppFontScale)
|
||||
}
|
||||
// Zoom in past the maximum.
|
||||
HandleFontPinch(ui.FontPinchEvent{Scale: 100})
|
||||
if TheState.appFontScale != MaxAppFontScale {
|
||||
t.Errorf("appFontScale=%v want MaxAppFontScale %v", TheState.appFontScale, MaxAppFontScale)
|
||||
}
|
||||
// At the maximum, further zoom-in is a no-op (including the scroll).
|
||||
TheState.ScrollOffset = ui.Dp(123)
|
||||
HandleFontPinch(ui.FontPinchEvent{Scale: 1.5})
|
||||
if TheState.appFontScale != MaxAppFontScale {
|
||||
t.Errorf("appFontScale=%v want MaxAppFontScale %v", TheState.appFontScale, MaxAppFontScale)
|
||||
}
|
||||
if float64(TheState.ScrollOffset) != 123 {
|
||||
t.Errorf("ScrollOffset=%v want 123 (unchanged at the clamp)", TheState.ScrollOffset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAppFontScale_Absolute(t *testing.T) {
|
||||
TheLogic = nil
|
||||
TheState = NewState()
|
||||
defer func() { TheState.appFontScale = 1.0; TheState.ScrollOffset = 0 }()
|
||||
|
||||
lh := float64(EffectiveLineHeight())
|
||||
TheState.ScrollOffset = ui.Dp(50 * lh)
|
||||
SetAppFontScale(2.0)
|
||||
if TheState.appFontScale != 2.0 {
|
||||
t.Fatalf("appFontScale=%v want 2.0", TheState.appFontScale)
|
||||
}
|
||||
if got, want := float64(TheState.ScrollOffset), 50*lh*2.0; math.Abs(got-want) > 1e-3 {
|
||||
t.Errorf("ScrollOffset=%v want %v", got, want)
|
||||
}
|
||||
SetAppFontScale(0) // clamps to the minimum
|
||||
if TheState.appFontScale != MinAppFontScale {
|
||||
t.Errorf("appFontScale=%v want MinAppFontScale", TheState.appFontScale)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFontPinch_IgnoresBadData(t *testing.T) {
|
||||
TheLogic = nil
|
||||
TheState = NewState()
|
||||
defer func() { TheState.appFontScale = 1.0 }()
|
||||
HandleFontPinch("not an event")
|
||||
HandleFontPinch(ui.FontPinchEvent{Scale: 0})
|
||||
HandleFontPinch(ui.FontPinchEvent{Scale: -2})
|
||||
if TheState.appFontScale != 1.0 {
|
||||
t.Errorf("appFontScale=%v want unchanged 1.0", TheState.appFontScale)
|
||||
}
|
||||
}
|
||||
|
||||
// The pinch CENTER stays fixed: the continuous content coordinate under the
|
||||
// center (display-line units) is invariant through the scale change. A
|
||||
// top-anchored zoom would move that coordinate by m*(1-ratio) display
|
||||
// lines — for a center 400dp below the region top at ×1.5 that is ~59
|
||||
// display lines off, which is exactly what this test rejects.
|
||||
func TestHandleFontPinch_CenterAnchor(t *testing.T) {
|
||||
TheLogic = nil
|
||||
TheState = NewState()
|
||||
defer func() {
|
||||
TheState.appFontScale = 1.0
|
||||
TheState.ScrollOffset = 0
|
||||
TheState.EditorRegion = ui.Region{}
|
||||
}()
|
||||
TheState.EditorRegion = ui.Region{Y: 100, H: 800}
|
||||
|
||||
lh := float64(EffectiveLineHeight())
|
||||
TheState.ScrollOffset = ui.Dp(100 * lh)
|
||||
// Center 400dp below the region top.
|
||||
const m = 400.0
|
||||
wantU := (float64(TheState.ScrollOffset) + m) / lh // content coord under the center
|
||||
|
||||
HandleFontPinch(ui.FontPinchEvent{Scale: 1.5, Center: ui.Point{Y: 100 + m}})
|
||||
|
||||
lhNew := float64(EffectiveLineHeight())
|
||||
gotU := (float64(TheState.ScrollOffset) + m) / lhNew
|
||||
// Tolerance is float32-scale + ui.Dp rounding accumulated over two
|
||||
// conversions — still 4 orders of magnitude smaller than the ~59-line
|
||||
// drift a top-anchored zoom would produce here.
|
||||
if math.Abs(gotU-wantU) > 1e-4 {
|
||||
t.Errorf("content under center: u=%v want %v (center must stay fixed)", gotU, wantU)
|
||||
}
|
||||
// Top-anchor sanity: the TOP moved (that is the point of center anchoring).
|
||||
if float64(TheState.ScrollOffset) == 100*lh*1.5 {
|
||||
t.Errorf("scroll=%v equals the top-anchored value; the center was not the anchor", TheState.ScrollOffset)
|
||||
}
|
||||
}
|
||||
|
||||
// With word wrap, the anchor must be the LOGICAL line under the center, not
|
||||
// the display line: when the font change re-wraps the lines above the
|
||||
// anchor (their fragment counts grow), the display-line index under the
|
||||
// center changes, but the same logical line / fragment / sub-line must stay
|
||||
// under it. applyFontPin is what the logic re-runs as rewrap corrections
|
||||
// land (the font-pin).
|
||||
func TestFontPin_SurvivesRewrap(t *testing.T) {
|
||||
TheLogic = nil
|
||||
TheState = NewState()
|
||||
defer func() {
|
||||
TheState.appFontScale = 1.0
|
||||
TheState.ScrollOffset = 0
|
||||
TheState.Editor.ChunkedBuffer = nil
|
||||
}()
|
||||
|
||||
// 1000 logical lines, each wrapped into 2 fragments at the current size.
|
||||
w := NewWrapIndex(1000)
|
||||
for i := 0; i < 1000; i++ {
|
||||
w.Set(i, 2)
|
||||
}
|
||||
TheState.Editor.ChunkedBuffer = &ChunkedBuffer{WrapIndex: w}
|
||||
|
||||
lh := float64(EffectiveLineHeight())
|
||||
const m = 200.0 // region top (EditorRegion zero) + 200dp
|
||||
// Anchor: logical line 60, its 1st fragment, 0.5 down it.
|
||||
TheState.ScrollOffset = ui.Dp(float64(w.VisualsBefore(60))*lh + 0.5*lh - m)
|
||||
|
||||
line, frag, sub, ok := TheState.captureFontPin(m)
|
||||
if !ok || line != 60 || frag != 0 || math.Abs(sub-0.5) > 1e-5 {
|
||||
t.Fatalf("capture: line=%d frag=%d sub=%v ok=%v, want line 60 frag 0 sub 0.5", line, frag, sub, ok)
|
||||
}
|
||||
|
||||
// The font grows ×1.5 (the line under the fingers keeps its 2 fragments
|
||||
// for now): the same point stays under m.
|
||||
TheState.appFontScale = 1.5
|
||||
TheState.applyFontPin(line, frag, sub, m)
|
||||
lhNew := float64(EffectiveLineHeight())
|
||||
u := (float64(TheState.ScrollOffset) + m) / lhNew
|
||||
if got := int(w.LineForVisual(int32(u))); got != 60 {
|
||||
t.Errorf("after scale: line under center=%d want 60", got)
|
||||
}
|
||||
|
||||
// Rewrap lands: every line now wraps into 3 fragments. The display-line
|
||||
// index under the center moves from 120.5 to 180.5, but re-applying the
|
||||
// pin must keep logical line 60 (fragment 0, sub 0.5) under the center.
|
||||
for i := 0; i < 1000; i++ {
|
||||
w.Set(i, 3)
|
||||
}
|
||||
TheState.applyFontPin(line, frag, sub, m)
|
||||
u = (float64(TheState.ScrollOffset) + m) / lhNew
|
||||
if got := int(w.LineForVisual(int32(u))); got != 60 {
|
||||
t.Errorf("after rewrap: line under center=%d want 60 (display-line anchoring would fail here)", got)
|
||||
}
|
||||
// And a display-line anchor (what NOT to do) would be off: at the new
|
||||
// counts, display line 120.5 is logical line 40, not 60.
|
||||
if got := int(w.LineForVisual(120)); got == 60 {
|
||||
t.Errorf("test is stale: display line 120 unexpectedly maps to line 60")
|
||||
}
|
||||
}
|
||||
|
||||
// fabLayout builds a GlyphLayout the way the shaper produces one: glyphsPerFrag
|
||||
// glyphs per fragment, baseline of fragment f at (f+0.8)*lh (ascent < lh, the
|
||||
// shaper's convention), 20dp advance each.
|
||||
func fabLayout(lh float64, fragments, glyphsPerFrag int, byte0 int) ui.GlyphLayout {
|
||||
gl := ui.GlyphLayout{LineHeight: ui.Dp(lh)}
|
||||
for f := 0; f < fragments; f++ {
|
||||
for g := 0; g < glyphsPerFrag; g++ {
|
||||
gl.ByteOffsets = append(gl.ByteOffsets, byte0+f*glyphsPerFrag+g)
|
||||
gl.X = append(gl.X, ui.Dp(10+20*float64(g)))
|
||||
gl.Y = append(gl.Y, ui.Dp((float64(f)+0.8)*lh))
|
||||
gl.Advance = append(gl.Advance, ui.Dp(20))
|
||||
}
|
||||
}
|
||||
return gl
|
||||
}
|
||||
|
||||
func TestGlyphAtLocalPoint(t *testing.T) {
|
||||
gl := fabLayout(16.8, 2, 4, 0) // bytes 0-3 on fragment 0, 4-7 on fragment 1
|
||||
if i, ok := glyphAtLocalPoint(gl, 50, 25); !ok || i != 6 {
|
||||
t.Errorf("(50,25): i=%d ok=%v, want glyph 6 (byte 6, X=50 on fragment 1)", i, ok)
|
||||
}
|
||||
if _, ok := glyphAtLocalPoint(gl, 5, 25); ok {
|
||||
t.Error("(5,25): left margin has no glyph, want not-ok")
|
||||
}
|
||||
if i, ok := glyphAtLocalPoint(gl, 1000, 25); !ok || i != 7 {
|
||||
t.Errorf("(1000,25): past the line end pins its last glyph, got i=%d ok=%v want 7", i, ok)
|
||||
}
|
||||
if _, ok := glyphAtLocalPoint(ui.GlyphLayout{}, 50, 25); ok {
|
||||
t.Error("empty layout: want not-ok")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureContentPin(t *testing.T) {
|
||||
TheLogic = nil
|
||||
TheState = NewState()
|
||||
defer func() {
|
||||
TheState.appFontScale = 1.0
|
||||
TheState.ScrollOffset = 0
|
||||
TheState.Editor.GlyphLayout = ui.GlyphLayout{}
|
||||
}()
|
||||
TheState.Editor.GlyphLayout = fabLayout(16.8, 2, 4, 0)
|
||||
TheState.ScrollOffset = 0
|
||||
|
||||
// Point at (x=50, y=25): fragment 1 (baseline 30.24), the glyph at X=50.
|
||||
pin := TheState.captureContentPin(50, 25)
|
||||
if !pin.HaveGlyph || pin.Byte != 6 {
|
||||
t.Fatalf("pin: byte=%d haveGlyph=%v, want byte 6", pin.Byte, pin.HaveGlyph)
|
||||
}
|
||||
if math.Abs(pin.Dy-(25-30.24)) > 1e-5 { // ui.Dp stores float32-precision values
|
||||
t.Errorf("dy=%v want %v (offset from the glyph baseline)", pin.Dy, 25-30.24)
|
||||
}
|
||||
// Fallback anchor (no WrapIndex): line 1, sub = 25/16.8 - 1 ~ 0.488.
|
||||
if pin.Line != 1 || pin.Frag != 0 || math.Abs(pin.Sub-(25/16.8-1)) > 1e-6 {
|
||||
t.Errorf("fallback: line=%d frag=%d sub=%v, want line 1 frag 0 sub %v", pin.Line, pin.Frag, pin.Sub, 25/16.8-1)
|
||||
}
|
||||
}
|
||||
|
||||
// The REFINEMENT is what makes the content point stay fixed across a rewrap:
|
||||
// the pinned byte moves to a different fragment in the new layout, and the
|
||||
// refined offset places that byte's baseline (plus the captured dy) exactly
|
||||
// at the pinch center — where the (line, fragment) anchor would leave a
|
||||
// different character there.
|
||||
func TestRefineContentPin_RewrapKeepsContentPoint(t *testing.T) {
|
||||
const m = 25.0 // region-relative pinch center
|
||||
const dy = 25.0 - 30.24 // from TestCaptureContentPin's point (above baseline)
|
||||
|
||||
// OLD layout (font 1.0x): byte 6 on fragment 1. (Capture would have
|
||||
// returned Byte=6, Dy=dy.)
|
||||
_ = fabLayout(16.8, 2, 4, 0)
|
||||
|
||||
// Font grows to 1.5x: the line re-wraps to 3 fragments x 3 glyphs; byte 6
|
||||
// lands on fragment 2 (a DIFFERENT fragment than the old fragment 1 it
|
||||
// was captured on... old frag index was 1, new is 2).
|
||||
glNew := fabLayout(25.2, 3, 3, 0)
|
||||
// The new layout is shaped for the window starting at document byte 0,
|
||||
// whose first visual line is visual line 3 of the document (vk=3):
|
||||
// the window top sits at content 3*25.2.
|
||||
vk := 3
|
||||
|
||||
off, ok := refineContentPin(glNew, vk, 0, dy, m, 6)
|
||||
if !ok {
|
||||
t.Fatal("refine: want ok")
|
||||
}
|
||||
// The pinned point (byte 6's baseline + dy) must sit exactly at m.
|
||||
contentY := float64(off) + m // content coordinate of the region-relative m
|
||||
// byte 6's baseline: window y = (2+0.8)*25.2, window top at content vk*lh.
|
||||
baselineContent := float64(vk)*float64(glNew.LineHeight) + float64(glNew.Y[6])
|
||||
if math.Abs(baselineContent+dy-contentY) > 1e-5 { // float32-precision layout Y
|
||||
t.Errorf("pinned point at content %v, want %v (baseline %v + dy)", contentY, baselineContent+dy, baselineContent)
|
||||
}
|
||||
// (A (line, fragment) anchor would have failed here: the byte captured on
|
||||
// the old fragment 1 sits on the NEW fragment 2, a full line further down.
|
||||
// Pinning the old fragment index would leave a different character at the
|
||||
// center — exactly the drift the content pin exists to remove.)
|
||||
}
|
||||
|
||||
// Steady state: a layout shaped at offset S with the pinned point at the
|
||||
// center must refine back to exactly S (the fixed point — no drift, no
|
||||
// oscillation while the pin is armed).
|
||||
func TestRefineContentPin_FixedPoint(t *testing.T) {
|
||||
const m = 25.0
|
||||
lh := ui.Dp(25.2)
|
||||
S := ui.Dp(100)
|
||||
vk, r := scrollDecompose(S, lh) // window's first visual line = v0 (no wrap)
|
||||
const dy = 3.0
|
||||
// Place byte 4 at window y = m + r - dy so the point sits at m when the
|
||||
// window (top at content vk*lh) is shaped at S.
|
||||
wantY := m + r - dy
|
||||
gl := ui.GlyphLayout{LineHeight: lh}
|
||||
for b := 0; b < 6; b++ {
|
||||
gl.ByteOffsets = append(gl.ByteOffsets, b)
|
||||
gl.X = append(gl.X, ui.Dp(10+20*float64(b%3)))
|
||||
gl.Y = append(gl.Y, ui.Dp(wantY)) // one line's worth for this test
|
||||
gl.Advance = append(gl.Advance, ui.Dp(20))
|
||||
}
|
||||
off, ok := refineContentPin(gl, int(vk), 0, dy, m, 4)
|
||||
if !ok {
|
||||
t.Fatal("refine: want ok")
|
||||
}
|
||||
if math.Abs(float64(off)-float64(S)) > 1e-9 {
|
||||
t.Errorf("fixed point: refine(S)=%v want S=%v (no drift)", off, S)
|
||||
}
|
||||
}
|
||||
|
||||
// Pinch-out clamp of the pinned fragment: a line that re-wraps to FEWER
|
||||
// fragments must not pin a fragment that no longer exists.
|
||||
func TestFontPin_FragmentClampedOnPinchOut(t *testing.T) {
|
||||
TheLogic = nil
|
||||
TheState = NewState()
|
||||
defer func() {
|
||||
TheState.appFontScale = 1.0
|
||||
TheState.ScrollOffset = 0
|
||||
TheState.Editor.ChunkedBuffer = nil
|
||||
}()
|
||||
w := NewWrapIndex(100)
|
||||
for i := 0; i < 100; i++ {
|
||||
w.Set(i, 3)
|
||||
}
|
||||
TheState.Editor.ChunkedBuffer = &ChunkedBuffer{WrapIndex: w}
|
||||
|
||||
// Pin line 5, fragment 2 (the third fragment).
|
||||
TheState.appFontScale = 1.0
|
||||
const m = 100.0
|
||||
TheState.applyFontPin(5, 2, 0.25, m)
|
||||
|
||||
// Pinch out: the line now wraps into 2 fragments.
|
||||
for i := 0; i < 100; i++ {
|
||||
w.Set(i, 2)
|
||||
}
|
||||
TheState.appFontScale = 0.8
|
||||
TheState.applyFontPin(5, 2, 0.25, m) // frag 2 must clamp to 1
|
||||
lh := float64(EffectiveLineHeight())
|
||||
u := (float64(TheState.ScrollOffset) + m) / lh
|
||||
// The anchor is now line 5, its LAST fragment (index 1), 0.25 down it.
|
||||
wantU := float64(w.VisualsBefore(5)+1) + 0.25
|
||||
if math.Abs(u-wantU) > 1e-6 {
|
||||
t.Errorf("anchor u=%v want %v (last remaining fragment of line 5)", u, wantU)
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ package editor
|
|||
|
||||
import (
|
||||
"log"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"pad/internal/ui"
|
||||
|
|
@ -56,12 +57,14 @@ type SessionState struct {
|
|||
Cursor int // cursor byte offset
|
||||
Scroll float64 // editor scroll offset, Dp
|
||||
ScrollLine int // logical line at the viewport top (-1 = unknown)
|
||||
ScrollSub float64 // Scroll's sub-line remainder, Dp (0 <= r < lineHeight)
|
||||
ScrollSub float64 // Scroll's sub-line remainder as a FRACTION of the line height (0 <= f < 1); font-independent. Snapshots written before the pinch feature stored Dp instead; restore converts values > 1.
|
||||
SelStart int // selection start byte (-1 = no selection)
|
||||
SelEnd int // selection end byte, exclusive
|
||||
FindQuery string // find bar query ("" = none)
|
||||
FindVisible bool // find bar was open
|
||||
FindCurByte int // start byte of the current find match (-1 = none)
|
||||
// AppFontScale is the app-local pinch font scale (0 = default 1.0).
|
||||
AppFontScale float64
|
||||
}
|
||||
|
||||
// SetSessionSaver registers the callback that persists snapshots (the cmd
|
||||
|
|
@ -106,7 +109,11 @@ func (l *Logic) SnapshotSession() SessionState {
|
|||
if w := cb.WrapIndex; w != nil {
|
||||
scrollLine = w.LineForVisual(int32(v0))
|
||||
}
|
||||
scrollSub = r0
|
||||
// Store the sub-line remainder as a fraction of the line height so
|
||||
// it stays valid if the font scale changes between save and restore
|
||||
// (r0 < lh always, so the fraction is < 1 — that is also the legacy
|
||||
// Dp discriminator used on restore).
|
||||
scrollSub = r0 / float64(lh)
|
||||
}
|
||||
return SessionState{
|
||||
File: e.Filename,
|
||||
|
|
@ -119,6 +126,7 @@ func (l *Logic) SnapshotSession() SessionState {
|
|||
FindQuery: f.Query,
|
||||
FindVisible: f.Visible,
|
||||
FindCurByte: curByte,
|
||||
AppFontScale: float64(s.appFontScale),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -229,6 +237,17 @@ func (l *Logic) BeginRestore(s SessionState) {
|
|||
// struct).
|
||||
l.restoreScroll = ui.Dp(s.Scroll)
|
||||
l.restoreScrollArmed = s.Scroll > 0
|
||||
// The app-local font scale lands immediately, BEFORE any layout: the
|
||||
// restore's line-based offset math reads it through EffectiveLineHeight.
|
||||
if as := s.AppFontScale; as > 0 {
|
||||
if as < MinAppFontScale {
|
||||
as = MinAppFontScale
|
||||
}
|
||||
if as > MaxAppFontScale {
|
||||
as = MaxAppFontScale
|
||||
}
|
||||
l.state.appFontScale = float32(as)
|
||||
}
|
||||
l.state.page = EditorPage
|
||||
l.state.FocusedElementID = "editor_text"
|
||||
l.state.justOpenedAt = time.Now()
|
||||
|
|
@ -297,12 +316,19 @@ func (l *Logic) maybeApplyRestoreScroll() bool {
|
|||
if cb := l.state.Editor.ChunkedBuffer; cb != nil && cb.WrapIndex != nil && s.ScrollLine < cb.WrapIndex.Len() {
|
||||
base = float64(cb.WrapIndex.VisualsBefore(s.ScrollLine))
|
||||
}
|
||||
l.restoreScroll = ui.Dp(base*float64(lh) + s.ScrollSub)
|
||||
sub := s.ScrollSub
|
||||
if sub > 1 {
|
||||
// Legacy snapshot: the sub-line remainder was stored in Dp, not
|
||||
// as a fraction. Convert with the current line height (the
|
||||
// error is a fraction of one sub-line line, at most).
|
||||
sub = sub / float64(lh)
|
||||
}
|
||||
l.restoreScroll = ui.Dp(base*float64(lh) + sub*float64(lh))
|
||||
// Arm the line-pin (see refreshRestorePin): wrap-count corrections
|
||||
// landing below this line would shift the mapping and drag the
|
||||
// viewport off the restored line while the index settles.
|
||||
l.restoreScrollLine = s.ScrollLine
|
||||
l.restoreScrollSub = float64(s.ScrollSub)
|
||||
l.restoreScrollSub = sub // fraction of the line height
|
||||
l.restorePinDeadline = time.Now().Add(restorePinTimeout)
|
||||
} else {
|
||||
l.restoreScrollLine = -1
|
||||
|
|
@ -358,13 +384,95 @@ func (l *Logic) refreshRestorePin() {
|
|||
// stale and would under-clamp the re-derived offset; the layout pass of
|
||||
// the emitted frame clamps to the fresh value. An edit shrinking the
|
||||
// file mid-pin is covered the same way.
|
||||
off := ui.Dp(float64(w.VisualsBefore(l.restoreScrollLine))*float64(lh) + l.restoreScrollSub)
|
||||
off := ui.Dp(float64(w.VisualsBefore(l.restoreScrollLine))*float64(lh) + l.restoreScrollSub*float64(lh))
|
||||
if off != l.state.ScrollOffset {
|
||||
l.state.ScrollOffset = off
|
||||
l.emitFrame()
|
||||
}
|
||||
}
|
||||
|
||||
// fontPinTimeout bounds the pinch font-pin: rewrap corrections keep landing
|
||||
// for a while after the last pinch frame (shaping lags the font change);
|
||||
// after this long the user has moved on and the pin stands down.
|
||||
const fontPinTimeout = 2 * time.Second
|
||||
|
||||
// setFontPin arms/updates the pinch font-pin from a pre-change capture
|
||||
// (see captureContentPin: the glyph under the pinch center + offset from its
|
||||
// baseline) and applies the IMMEDIATE anchor: the continuous content
|
||||
// coordinate under the center scaled by the font ratio. Exact until the
|
||||
// rewrap lands; the shaped-layout feedback (refreshFontPin) then snaps the
|
||||
// pinned character exactly onto the center. Must be called on the logic
|
||||
// goroutine.
|
||||
func (l *Logic) setFontPin(pin contentPin, m float64, ratio float64) {
|
||||
l.fontPin = pin
|
||||
l.fontPinM = m
|
||||
l.fontPinArmed = true
|
||||
l.fontPinDeadline = time.Now().Add(fontPinTimeout)
|
||||
s := l.state
|
||||
s.rescaleScrollAnchored(ratio, float64(s.EditorRegion.Y)+m)
|
||||
// No emitFrame here: the caller (input path or debug command) emits the
|
||||
// frame carrying the new scale and the rescaled offset.
|
||||
}
|
||||
|
||||
// refreshFontPin re-derives the scroll offset from a freshly shaped layout so
|
||||
// the pinned content point stays under the pinch center (see
|
||||
// refineContentPin). Runs on every layout feedback while armed: the font
|
||||
// change's own re-shaping is the first, and rewrap corrections follow it. If
|
||||
// the pinned byte is not in the shaped window (no layout yet, or the point
|
||||
// is in a blank margin), the (line, fragment, sub-line) fallback anchor
|
||||
// stands in. An edit landing since the capture (EditSeq mismatch) or leaving
|
||||
// the editor page disarms the pin: its byte no longer names the same content.
|
||||
// No MaxScroll clamp: the layout pass of the emitted frame clamps to the
|
||||
// fresh value. Must be called on the logic goroutine.
|
||||
func (l *Logic) refreshFontPin(fb ui.LayoutFeedback) {
|
||||
if !l.fontPinArmed {
|
||||
return
|
||||
}
|
||||
if time.Now().After(l.fontPinDeadline) {
|
||||
l.fontPinArmed = false
|
||||
return
|
||||
}
|
||||
s := l.state
|
||||
if s.page != EditorPage || fb.EditSeq != l.fontPin.EditSeq {
|
||||
l.fontPinArmed = false
|
||||
return
|
||||
}
|
||||
l.fontPinDeadline = time.Now().Add(fontPinTimeout)
|
||||
// Skip feedback shaped at a DIFFERENT scale: during a pinch every frame
|
||||
// changes the scale, so all but the latest feedback carry layouts whose
|
||||
// LineHeight no longer matches the geometry the pin computes in. A
|
||||
// stale-scale layout would place the point by the old geometry for one
|
||||
// frame (a visible jump) before the next corrects it.
|
||||
if fb.GlyphLayout.LineHeight > 0 &&
|
||||
math.Abs(float64(fb.GlyphLayout.LineHeight)-float64(EffectiveLineHeight())) > 0.5 {
|
||||
return
|
||||
}
|
||||
old := s.ScrollOffset
|
||||
vk := -1
|
||||
if cb := s.Editor.ChunkedBuffer; cb != nil && cb.WrapIndex != nil && fb.WindowStartLine >= 0 {
|
||||
vk = int(cb.WrapIndex.VisualsBefore(fb.WindowStartLine))
|
||||
}
|
||||
if l.fontPin.HaveGlyph && vk >= 0 {
|
||||
if off, ok := refineContentPin(fb.GlyphLayout, vk, fb.WindowStartByte, l.fontPin.Dy, l.fontPinM, l.fontPin.Byte); ok {
|
||||
s.ScrollOffset = off
|
||||
} else {
|
||||
s.applyFontPin(l.fontPin.Line, l.fontPin.Frag, l.fontPin.Sub, l.fontPinM)
|
||||
}
|
||||
} else {
|
||||
s.applyFontPin(l.fontPin.Line, l.fontPin.Frag, l.fontPin.Sub, l.fontPinM)
|
||||
}
|
||||
if s.ScrollOffset != old {
|
||||
l.emitFrame()
|
||||
}
|
||||
}
|
||||
|
||||
// releaseFontPin disarms the pinch font-pin: the user (a scroll, a debug
|
||||
// command, a file switch) has taken over the viewport. Must be called on
|
||||
// the logic goroutine.
|
||||
func (l *Logic) releaseFontPin() {
|
||||
l.fontPinArmed = false
|
||||
}
|
||||
|
||||
// applyRestorePositions clamps the restored snapshot's cursor and selection
|
||||
// to a file of n bytes and applies them. Must be called on the logic
|
||||
// goroutine.
|
||||
|
|
|
|||
|
|
@ -17,6 +17,15 @@ import (
|
|||
// EditorFontSize is the font size used for editor text.
|
||||
const EditorFontSize = 14 // unit.Sp
|
||||
|
||||
// App-local font-size scale bounds (pinch zoom), applied on top of the
|
||||
// system user font scale. The scale itself is a continuous float32 — it is
|
||||
// never rounded to a whole point value; the bounds only stop the pinch from
|
||||
// leaving the usable range.
|
||||
const (
|
||||
MinAppFontScale = 0.5
|
||||
MaxAppFontScale = 3.0
|
||||
)
|
||||
|
||||
// EditorLineHeightScale is the baseline-to-baseline spacing multiplier.
|
||||
const EditorLineHeightScale = 1.2
|
||||
|
||||
|
|
@ -190,6 +199,7 @@ type State struct {
|
|||
PixelHeight int // raw pixel height from Gio ConfigEvent
|
||||
scale float32
|
||||
fontScale float32 // user font-size setting (PxPerSp/PxPerDp); 0 = unknown -> 1.0
|
||||
appFontScale float32 // app-local pinch font scale (1.0 = default); 0 = unknown -> 1.0
|
||||
page Page // current page (Browser or Editor)
|
||||
WordWrap bool
|
||||
ScrollOffset ui.Dp // vertical scroll position in Dp
|
||||
|
|
@ -232,6 +242,7 @@ type State struct {
|
|||
func NewState() *State {
|
||||
return &State{
|
||||
scale: 1.0,
|
||||
appFontScale: 1.0,
|
||||
page: BrowserPage, // Reverted to BrowserPage
|
||||
WordWrap: true, // Enable word wrap by default
|
||||
lastEvictionTime: time.Now(),
|
||||
|
|
@ -278,17 +289,306 @@ func stateFontScale() float32 {
|
|||
}
|
||||
|
||||
// EffectiveLineHeight is the editor line height in density-dp WITH the user
|
||||
// font-size setting applied. The shaper draws baselines at
|
||||
// Sp(EditorFontSize*LineHeightScale) physical px, which is
|
||||
// EditorLineHeight()*fontScale density-dp. Every piece of geometry
|
||||
// bookkeeping (window start, sub-line remainder, tap mapping, scroll
|
||||
// clamping, cursor vertical move) must use this value rather than the raw
|
||||
// EditorLineHeight; at a non-default font setting the two differ by the
|
||||
// font-size setting AND the app-local pinch scale applied. The shaper draws
|
||||
// baselines at Sp(EditorFontSize*appFontScale*LineHeightScale) physical px,
|
||||
// which is EditorLineHeight()*effectiveFontScale density-dp. Every piece of
|
||||
// geometry bookkeeping (window start, sub-line remainder, tap mapping,
|
||||
// scroll clamping, cursor vertical move) must use this value rather than the
|
||||
// raw EditorLineHeight; at a non-default font setting the two differ by the
|
||||
// font scale, which would misplace taps by up to (fontScale-1) viewportfuls
|
||||
// of lines and make scroll clamping stop short of (or run past) the file
|
||||
// ends.
|
||||
func EffectiveLineHeight() ui.Dp {
|
||||
return EffectiveLineHeightAt(stateFontScale())
|
||||
return EffectiveLineHeightAt(effectiveFontScale())
|
||||
}
|
||||
|
||||
// effectiveFontScale is the TOTAL font scale of the rendered line pitch:
|
||||
// the system user font scale times the app-local pinch scale. The system
|
||||
// part is already folded into gtx.Metric on the render side; the logic side
|
||||
// needs the product for its dp bookkeeping.
|
||||
func effectiveFontScale() float32 {
|
||||
fs := stateFontScale()
|
||||
as := float32(1)
|
||||
if TheState != nil && TheState.appFontScale > 0 {
|
||||
as = TheState.appFontScale
|
||||
}
|
||||
return fs * as
|
||||
}
|
||||
|
||||
// rescaleScrollAnchored scales the scroll offset by ratio while keeping the
|
||||
// content point under app-local Y anchorY fixed on screen. The document is
|
||||
// uniformly scaled by the font change (every line height and the sub-line
|
||||
// offset scale by the same factor), so the content coordinate under the
|
||||
// anchor scales by ratio; the new offset re-places that scaled coordinate
|
||||
// under the same app point. With anchorY at the region top this degenerates
|
||||
// to the plain top-anchor (new = old * ratio). A zero EditorRegion (not
|
||||
// laid out yet) likewise degenerates to the top anchor.
|
||||
func (s *State) rescaleScrollAnchored(ratio float64, anchorY float64) {
|
||||
regionTop := float64(s.EditorRegion.Y)
|
||||
dy := anchorY - regionTop
|
||||
contentY := float64(s.ScrollOffset) + dy
|
||||
s.ScrollOffset = ui.Dp(contentY*ratio - dy)
|
||||
}
|
||||
|
||||
// glyphAtLocalPoint returns the index of the glyph a window-frame point
|
||||
// (x, y in Dp; y relative to the window top, the same frame as GlyphLayout.Y)
|
||||
// sits on: the display line identified from y, and on that line the last
|
||||
// glyph whose X is at or before x. ok=false when the layout is empty, the
|
||||
// line has no glyph, or x is in the left margin before the line's first
|
||||
// glyph. (A point past the line's END still pins that line's last glyph:
|
||||
// the content there is the line itself.)
|
||||
func glyphAtLocalPoint(gl ui.GlyphLayout, x, y float64) (int, bool) {
|
||||
lh := float64(gl.LineHeight)
|
||||
if lh <= 0 || len(gl.ByteOffsets) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
line := int(y / lh)
|
||||
if line < 0 {
|
||||
line = 0
|
||||
}
|
||||
// The baseline of display line j sits in (j*lh, (j+1)*lh]; all glyphs on
|
||||
// a line share one exact shaper value, so a range test finds the line's
|
||||
// baseline.
|
||||
base := -1.0
|
||||
for _, gy := range gl.Y {
|
||||
if f := float64(gy); f > float64(line)*lh && f <= float64(line+1)*lh {
|
||||
base = f
|
||||
break
|
||||
}
|
||||
}
|
||||
if base < 0 {
|
||||
return 0, false
|
||||
}
|
||||
best := -1
|
||||
for i, gy := range gl.Y {
|
||||
if float64(gy) != base {
|
||||
continue
|
||||
}
|
||||
if float64(gl.X[i]) <= x+1e-9 {
|
||||
best = i
|
||||
}
|
||||
}
|
||||
return best, best >= 0
|
||||
}
|
||||
|
||||
// contentPin is the anchor a pinch holds: a CONTENT point, not a layout
|
||||
// point. Byte/Dy name the glyph (ABSOLUTE buffer byte) under the fingers and
|
||||
// the point's offset from that glyph's baseline — both invariant under
|
||||
// rewrap, where a visual line is not (a rewrapped fragment holds different
|
||||
// text at the same fragment index). Line/Frag/Sub is the (logical line,
|
||||
// fragment, sub-line) fallback anchor for frames without a shaped glyph
|
||||
// under the point. EditSeq invalidates the pin on edits.
|
||||
type contentPin struct {
|
||||
Byte int
|
||||
Dy float64
|
||||
Line int
|
||||
Frag int
|
||||
Sub float64
|
||||
HaveGlyph bool
|
||||
EditSeq uint64
|
||||
}
|
||||
|
||||
// invalidateShapedLayout drops the last shaped GlyphLayout. Call it whenever
|
||||
// the SHAPING INPUTS change outside an edit (a font-scale change): the old
|
||||
// layout's LineHeight/X/Y belong to the old size, and every consumer that
|
||||
// falls back on it (window-start line, max scroll, tap mapping) would run
|
||||
// the new scroll offset through the OLD line height for a frame or two —
|
||||
// enough to put the shaped window ten thousand lines from the viewport.
|
||||
// The next frame re-shapes and the feedback refills it; until then the
|
||||
// logic-side geometry uses EffectiveLineHeight(), which tracks the scale.
|
||||
func (s *State) invalidateShapedLayout() {
|
||||
s.Editor.GlyphLayout = ui.GlyphLayout{}
|
||||
}
|
||||
|
||||
// captureContentPin identifies the content point at region-relative (x, y)
|
||||
// (dp from the editor region's left/top): the glyph under the point and the
|
||||
// point's offset from its baseline, plus the (line, fragment, sub-line)
|
||||
// fallback anchor. Must be called BEFORE the font change it will anchor.
|
||||
func (s *State) captureContentPin(x, y float64) contentPin {
|
||||
pin := contentPin{Byte: -1}
|
||||
// Window-frame y (the GlyphLayout frame): the point's region-relative y
|
||||
// plus the sub-line draw offset, the same convention as tapLocalY.
|
||||
_, r := scrollVisualDecompose()
|
||||
localY := y + r
|
||||
gl := s.Editor.GlyphLayout
|
||||
if i, ok := glyphAtLocalPoint(gl, x, localY); ok {
|
||||
// ByteOffsets are window-relative; IMEWindowStartByte is the absolute
|
||||
// byte of the window's first byte (the same value the Frame ships as
|
||||
// WindowStartByte).
|
||||
pin.Byte = s.Editor.IMEWindowStartByte + gl.ByteOffsets[i]
|
||||
pin.Dy = localY - float64(gl.Y[i])
|
||||
pin.HaveGlyph = true
|
||||
}
|
||||
if line, frag, sub, ok := s.captureFontPin(y); ok {
|
||||
pin.Line, pin.Frag, pin.Sub = line, frag, sub
|
||||
}
|
||||
pin.EditSeq = s.Editor.EditSeq
|
||||
return pin
|
||||
}
|
||||
|
||||
// captureFontPin identifies the fallback layout anchor at region-relative Y
|
||||
// m (dp from the top of the editor text region): the LOGICAL line under the
|
||||
// point (through the current WrapIndex), the display line (wrap fragment) of
|
||||
// that line the point is on, and the sub-line fraction within that fragment.
|
||||
func (s *State) captureFontPin(m float64) (line, frag int, sub float64, ok bool) {
|
||||
lh := float64(EffectiveLineHeight())
|
||||
if lh <= 0 {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
u := (float64(s.ScrollOffset) + m) / lh // continuous display-line coordinate under the point
|
||||
k := int(u)
|
||||
if k < 0 {
|
||||
k = 0
|
||||
}
|
||||
sub = u - float64(k)
|
||||
if cb := s.Editor.ChunkedBuffer; cb != nil && cb.WrapIndex != nil && k < cb.WrapIndex.Len() {
|
||||
l := cb.WrapIndex.LineForVisual(int32(k))
|
||||
base := int(cb.WrapIndex.VisualsBefore(l))
|
||||
frag = k - base
|
||||
if frag < 0 {
|
||||
frag = 0
|
||||
}
|
||||
return l, frag, sub, true
|
||||
}
|
||||
return k, 0, sub, true // no wrap index: display line == logical line
|
||||
}
|
||||
|
||||
// refineContentPin computes the scroll offset that places the pinned CONTENT
|
||||
// point — absolute buffer byte byteOff, dy below its baseline — at
|
||||
// region-relative Y m, given a freshly shaped layout (gl) for the window
|
||||
// starting at windowStartByte (both from the same LayoutFeedback). The window
|
||||
// top (layout y=0) sits at the top of the window's FIRST visual line, whose
|
||||
// content coordinate is VisualsBefore(windowStartLine)*lh — the vk argument
|
||||
// (NOT floor(shapedScroll/lh), which is a different line whenever the
|
||||
// viewport top lands mid-way through a wrapped logical line) — so the glyph's
|
||||
// content coordinate is vk*lh + Y + dy, and setting the offset to that minus
|
||||
// m puts the point on the center exactly. This is what keeps the CHARACTER
|
||||
// under the fingers when a rewrap moves it to a different fragment: the byte
|
||||
// is invariant, its Y is read from the fresh layout. ok=false when the layout
|
||||
// is unusable or the byte is not in the shaped window (then the caller falls
|
||||
// back to the (line, frag, sub) anchor).
|
||||
func refineContentPin(gl ui.GlyphLayout, vk, windowStartByte int, dy, m float64, byteOff int) (ui.Dp, bool) {
|
||||
lh := gl.LineHeight
|
||||
if lh <= 0 || len(gl.ByteOffsets) == 0 || byteOff < 0 || vk < 0 {
|
||||
return 0, false
|
||||
}
|
||||
i := sort.Search(len(gl.ByteOffsets), func(i int) bool { return windowStartByte+gl.ByteOffsets[i] >= byteOff })
|
||||
if i >= len(gl.ByteOffsets) || windowStartByte+gl.ByteOffsets[i] != byteOff {
|
||||
return 0, false
|
||||
}
|
||||
contentY := float64(vk)*float64(lh) + float64(gl.Y[i]) + dy
|
||||
return ui.Dp(contentY - m), true
|
||||
}
|
||||
|
||||
// applyFontPin sets the scroll offset so the fallback anchor (logical line L,
|
||||
// its frag-th display line, sub-line fraction sub within it) sits at
|
||||
// region-relative Y m, under the CURRENT WrapIndex and line height. This is
|
||||
// the pin's stand-in for frames without a shaped glyph under the point; it is
|
||||
// re-runnable as wrap-count corrections land (the same mechanism as the
|
||||
// restore line-pin, aimed at a point mid-viewport). No MaxScroll clamp here:
|
||||
// the layout pass of the emitted frame clamps to the fresh value (a stale
|
||||
// MaxScroll would under-clamp).
|
||||
func (s *State) applyFontPin(line, frag int, sub, m float64) {
|
||||
lh := float64(EffectiveLineHeight())
|
||||
if lh <= 0 {
|
||||
return
|
||||
}
|
||||
base := float64(line)
|
||||
if cb := s.Editor.ChunkedBuffer; cb != nil && cb.WrapIndex != nil && line >= 0 && line < cb.WrapIndex.Len() {
|
||||
base = float64(cb.WrapIndex.VisualsBefore(line))
|
||||
// The line's fragment count may have shrunk (pinch out): keep the
|
||||
// pinned fragment inside the line's new fragment range.
|
||||
if line+1 < cb.WrapIndex.Len() {
|
||||
count := int(cb.WrapIndex.VisualsBefore(line+1) - cb.WrapIndex.VisualsBefore(line))
|
||||
if count > 0 && frag >= count {
|
||||
frag = count - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
s.ScrollOffset = ui.Dp((base+float64(frag)+sub)*lh - m)
|
||||
}
|
||||
|
||||
// HandleFontPinch applies one frame's relative two-finger pinch factor to
|
||||
// the app-local font scale (ui.FontPinchEvent, delivered by the renderer's
|
||||
// pinch probe). The scale is a continuous float — multiplied by the
|
||||
// per-frame distance ratio, clamped to [MinAppFontScale, MaxAppFontScale],
|
||||
// never rounded — so the text size tracks the fingers smoothly. The content
|
||||
// under the pinch CENTER (event's Center point) stays anchored: the Logic
|
||||
// pins the logical line + fragment + sub-line under the center and
|
||||
// re-derives the scroll offset under it as the font — and, a few frames
|
||||
// later, the rewrap counts — change (see Logic.applyFontPinch).
|
||||
func HandleFontPinch(data any) {
|
||||
f, ok := data.(ui.FontPinchEvent)
|
||||
log.Printf("PINCH logic HandleFontPinch scale=%.4f center=(%.0f,%.0f) ok=%v", f.Scale, f.Center.X, f.Center.Y, ok)
|
||||
if !ok || f.Scale <= 0 {
|
||||
return
|
||||
}
|
||||
s := TheState
|
||||
// Region-relative (x, y) of the pinch center. Capture the CONTENT point
|
||||
// the fingers are on (the glyph under the point + offset from its
|
||||
// baseline) under the PRE-change layout; the scale change, then the
|
||||
// re-derivation, keep that same content point under the center.
|
||||
x := float64(f.Center.X - s.EditorRegion.X)
|
||||
m := float64(f.Center.Y - s.EditorRegion.Y)
|
||||
pin := s.captureContentPin(x, m)
|
||||
old := s.appFontScale
|
||||
if old <= 0 {
|
||||
old = 1
|
||||
}
|
||||
ns := old * f.Scale
|
||||
if ns < MinAppFontScale {
|
||||
ns = MinAppFontScale
|
||||
}
|
||||
if ns > MaxAppFontScale {
|
||||
ns = MaxAppFontScale
|
||||
}
|
||||
if ns == s.appFontScale {
|
||||
return // no change: nothing to re-derive
|
||||
}
|
||||
s.appFontScale = ns
|
||||
// The last shaped layout belongs to the old size (see
|
||||
// invalidateShapedLayout): without this, the next frame computes its
|
||||
// window start with the OLD line height and the new (rescaled) offset —
|
||||
// a window ten thousand lines from the viewport — and the pin chases it.
|
||||
s.invalidateShapedLayout()
|
||||
ratio := float64(ns) / float64(old)
|
||||
if TheLogic != nil {
|
||||
TheLogic.releaseRestorePin() // the user takes over the viewport
|
||||
TheLogic.setFontPin(pin, m, ratio) // re-derives the offset under it
|
||||
} else {
|
||||
// Test harness without a Logic: the immediate continuous anchor.
|
||||
s.rescaleScrollAnchored(ratio, float64(s.EditorRegion.Y)+m)
|
||||
}
|
||||
}
|
||||
|
||||
// SetAppFontScale sets the app-local font scale to an absolute value
|
||||
// (clamped to [MinAppFontScale, MaxAppFontScale]) with the VIEWPORT TOP as
|
||||
// the anchor (no fingers are involved). Used by the one-shot debug command.
|
||||
func SetAppFontScale(v float32) {
|
||||
s := TheState
|
||||
old := s.appFontScale
|
||||
if old <= 0 {
|
||||
old = 1
|
||||
}
|
||||
if v < MinAppFontScale {
|
||||
v = MinAppFontScale
|
||||
}
|
||||
if v > MaxAppFontScale {
|
||||
v = MaxAppFontScale
|
||||
}
|
||||
if v == s.appFontScale {
|
||||
return // no change
|
||||
}
|
||||
s.appFontScale = v
|
||||
// Same stale-layout hazard as HandleFontPinch: the window start for the
|
||||
// next frame must be computed with the NEW line height.
|
||||
s.invalidateShapedLayout()
|
||||
ratio := float64(s.appFontScale) / float64(old)
|
||||
s.rescaleScrollAnchored(ratio, float64(s.EditorRegion.Y))
|
||||
if TheLogic != nil {
|
||||
TheLogic.releaseRestorePin()
|
||||
TheLogic.releaseFontPin()
|
||||
}
|
||||
}
|
||||
|
||||
// EffectiveLineHeightAt is EffectiveLineHeight for an explicit font scale
|
||||
|
|
@ -352,6 +652,7 @@ func HandleScroll(data any) {
|
|||
delta := data.(int) // pixels
|
||||
if TheLogic != nil {
|
||||
TheLogic.releaseRestorePin() // the user takes over the viewport
|
||||
TheLogic.releaseFontPin()
|
||||
}
|
||||
TheState.ScrollOffset += ui.ToDp(ui.Px(delta), TheState.scale)
|
||||
if TheState.ScrollOffset < 0 {
|
||||
|
|
@ -2250,6 +2551,9 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
|||
}
|
||||
}},
|
||||
{Gesture: ui.SelDrag, Handler: HandleSelDragEvt},
|
||||
// Two-finger pinch changes the app-local font scale continuously
|
||||
// (the renderer owns the probe; see ui.Pinch).
|
||||
{Gesture: ui.Pinch, Handler: HandleFontPinch},
|
||||
},
|
||||
)
|
||||
// While the find bar is open, key focus belongs to the main-owned
|
||||
|
|
|
|||
|
|
@ -900,6 +900,11 @@ const (
|
|||
// The renderer registers the underlying gesture.Drag ops itself (it owns
|
||||
// the handle geometry); this interaction just delivers the logic handler.
|
||||
SelDrag
|
||||
// Pinch is a two-finger pinch inside the element's text region. The
|
||||
// renderer owns the probe (it needs raw two-pointer geometry that no
|
||||
// single gesture primitive in Gio v0.10 provides); this interaction just
|
||||
// delivers the logic handler, which receives FontPinchEvent.
|
||||
Pinch
|
||||
)
|
||||
|
||||
// Interaction pairs a gesture type with a handler function.
|
||||
|
|
@ -950,6 +955,19 @@ type SelectionDragEvent struct {
|
|||
// SelectionDragEnd is emitted when a selection/caret drag is released.
|
||||
type SelectionDragEnd struct{}
|
||||
|
||||
// FontPinchEvent carries one frame's relative two-finger pinch factor
|
||||
// (current inter-finger distance / previous frame's distance, both in px).
|
||||
// The logic applies it as a multiplier to the app-local font scale; the
|
||||
// value is a plain float32 ratio with no rounding, so the font size is
|
||||
// continuous, never snapped to whole points. Center is the pinch midpoint
|
||||
// (the average of the two fingers) in app-local window Dp — the same space
|
||||
// as Point; the logic anchors the content under this point so it stays put
|
||||
// while the font scales.
|
||||
type FontPinchEvent struct {
|
||||
Scale float32
|
||||
Center Point
|
||||
}
|
||||
|
||||
// MenuItem is one button in the selection menu.
|
||||
// X/Y/W/H are relative to the Menu region.
|
||||
type MenuItem struct {
|
||||
|
|
|
|||
510
internal/ui/pinch_test.go
Normal file
510
internal/ui/pinch_test.go
Normal file
|
|
@ -0,0 +1,510 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gioui.org/f32"
|
||||
"gioui.org/io/pointer"
|
||||
)
|
||||
|
||||
// pev builds a synthetic pointer event. Time is explicit: the Android driver
|
||||
// supplies it per event and the freshness window runs on it.
|
||||
func pev(kind pointer.Kind, id pointer.ID, x, y float32, at time.Duration) pointer.Event {
|
||||
return pointer.Event{Kind: kind, PointerID: id, Position: f32.Point{X: x, Y: y}, Time: at}
|
||||
}
|
||||
|
||||
// ms/s are time helpers so test timelines read as intended (a bare integer
|
||||
// constant is nanoseconds — the first draft's "2000ms" was 2µs).
|
||||
func ms(n int) time.Duration { return time.Duration(n) * time.Millisecond }
|
||||
func s(n int) time.Duration { return time.Duration(n) * time.Second }
|
||||
|
||||
// runFrame feeds a batch of events (one frame's drain) through the tracker
|
||||
// and returns the frame's factor, the grabs it asked for, and the
|
||||
// survivor-finger scroll, mirroring consumePinchProbe.
|
||||
func runFrame(t *pinchTracker, evts ...pointer.Event) (f float32, mid f32.Point, ok bool, grabs []pointer.ID, scroll int) {
|
||||
for _, e := range evts {
|
||||
if s := t.step(e); len(s.grabs) > 0 {
|
||||
grabs = append(grabs, s.grabs...)
|
||||
}
|
||||
}
|
||||
f, mid, ok, g2 := t.factor()
|
||||
grabs = append(grabs, g2...)
|
||||
scroll = t.survivorScroll()
|
||||
return
|
||||
}
|
||||
|
||||
// approx reports whether a and b are within 1e-3.
|
||||
func approx(a, b float64) bool { return math.Abs(a-b) < 1e-3 }
|
||||
|
||||
// A clean two-finger pinch: the pair forms when BOTH fingers move (the
|
||||
// two-mover rule), owes the spread that happened by then (baseline = press
|
||||
// distance), then one factor per frame whose product telescopes to the total
|
||||
// distance ratio — the property that makes the font track the fingers.
|
||||
func TestTrackerPinchFactorSeries(t *testing.T) {
|
||||
var tr pinchTracker
|
||||
// Frame 1: both fingers down 200px apart. Pending: no pair, no grabs,
|
||||
// no factor (a press alone is not a pinch).
|
||||
_, _, ok, grabs, _ := runFrame(&tr,
|
||||
pev(pointer.Press, 0, 100, 100, 0),
|
||||
pev(pointer.Press, 1, 300, 100, 1),
|
||||
)
|
||||
if ok || len(grabs) != 0 {
|
||||
t.Fatalf("press-only frame: ok=%v grabs=%v want nothing", ok, grabs)
|
||||
}
|
||||
// Frames 2-4: the pair spreads 200 -> 240 -> 300 -> 250. Frame 2 is the
|
||||
// formation: both fingers moved 20px (movers), grabs issued, and the
|
||||
// owed factor is measured against the PRESS distance (200).
|
||||
f2, _, ok2, g2, _ := runFrame(&tr,
|
||||
pev(pointer.Drag, 0, 80, 100, 10),
|
||||
pev(pointer.Drag, 1, 320, 100, 11),
|
||||
)
|
||||
if !ok2 || !approx(float64(f2), 240.0/200) {
|
||||
t.Fatalf("f2=%v ok=%v want %v", f2, ok2, 240/200)
|
||||
}
|
||||
if len(g2) != 2 {
|
||||
t.Fatalf("formation frame must grab both fingers, got %v", g2)
|
||||
}
|
||||
f3, mid3, ok3, _, _ := runFrame(&tr,
|
||||
pev(pointer.Drag, 0, 50, 100, 20),
|
||||
pev(pointer.Drag, 1, 350, 100, 21),
|
||||
)
|
||||
if !ok3 || !approx(float64(f3), 300.0/240) {
|
||||
t.Fatalf("f3=%v ok=%v want %v", f3, ok3, 300/240)
|
||||
}
|
||||
if mid3 != (f32.Point{X: 200, Y: 100}) {
|
||||
t.Fatalf("mid3=%v want (200,100)", mid3)
|
||||
}
|
||||
f4, _, ok4, _, _ := runFrame(&tr,
|
||||
pev(pointer.Drag, 0, 125, 100, 30),
|
||||
pev(pointer.Drag, 1, 375, 100, 31),
|
||||
)
|
||||
if !ok4 || !approx(float64(f4), 250.0/300) {
|
||||
t.Fatalf("f4=%v ok=%v want %v", f4, ok4, 250.0/300)
|
||||
}
|
||||
total := f2 * f3 * f4
|
||||
if !approx(float64(total), 250.0/200) {
|
||||
t.Fatalf("product=%v want total ratio %v", total, 250/200)
|
||||
}
|
||||
}
|
||||
|
||||
// A single finger — press, hold, long scroll — must never produce a factor
|
||||
// or a grab. This is the device regression "one-finger scroll changes the
|
||||
// font": the old code paired the scroll finger with whatever stale pointer
|
||||
// was in the map.
|
||||
func TestTrackerSingleFingerNeverScales(t *testing.T) {
|
||||
var tr pinchTracker
|
||||
// A pinch earlier in the session...
|
||||
runFrame(&tr,
|
||||
pev(pointer.Press, 0, 100, 100, 0),
|
||||
pev(pointer.Press, 1, 300, 100, 1),
|
||||
)
|
||||
runFrame(&tr,
|
||||
pev(pointer.Drag, 0, 80, 100, 10),
|
||||
pev(pointer.Drag, 1, 320, 100, 11),
|
||||
)
|
||||
runFrame(&tr,
|
||||
pev(pointer.Release, 0, 80, 100, 20),
|
||||
pev(pointer.Release, 1, 320, 100, 21),
|
||||
)
|
||||
// ...now a single finger scrolls for many frames.
|
||||
for i := 0; i < 20; i++ {
|
||||
y := float32(400 - i*20)
|
||||
_, _, ok, grabs, scroll := runFrame(&tr,
|
||||
pev(pointer.Drag, 5, 200, y, time.Duration(100+i*10)))
|
||||
if ok || len(grabs) != 0 || scroll != 0 {
|
||||
t.Fatalf("frame %d: single finger produced factor ok=%v grabs=%v scroll=%v",
|
||||
i, ok, grabs, scroll)
|
||||
}
|
||||
}
|
||||
// Fresh pointer IDs (Android resets them after full release) — still nothing.
|
||||
_, _, ok, grabs, _ := runFrame(&tr,
|
||||
pev(pointer.Press, 0, 200, 500, 500),
|
||||
)
|
||||
if ok || len(grabs) != 0 {
|
||||
t.Fatalf("fresh single finger: ok=%v grabs=%v", ok, grabs)
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
_, _, ok, grabs, _ = runFrame(&tr,
|
||||
pev(pointer.Drag, 0, 200, float32(500-i*15), time.Duration(510+i*10)))
|
||||
if ok || len(grabs) != 0 {
|
||||
t.Fatalf("scroll frame %d: ok=%v grabs=%v", i, ok, grabs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Device regression "sudden dramatic zoom before pinching": a finger that
|
||||
// has been RESTING (palm edge, parked pinky) must not be paired with a new
|
||||
// one. The freshness window prunes it; the pair forms only from fresh
|
||||
// fingers.
|
||||
func TestTrackerRestingFingerNotPaired(t *testing.T) {
|
||||
var tr pinchTracker
|
||||
// Palm rests at t=0.
|
||||
_, _, ok, grabs, _ := runFrame(&tr,
|
||||
pev(pointer.Press, 3, 600, 800, 0))
|
||||
if ok || len(grabs) != 0 {
|
||||
t.Fatal("one resting finger must not pinch")
|
||||
}
|
||||
// At t=2s the thumb lands: the palm is stale (>300ms) and pruned.
|
||||
_, _, ok, grabs, _ = runFrame(&tr,
|
||||
pev(pointer.Press, 0, 100, 100, s(2)),
|
||||
pev(pointer.Drag, 3, 605, 800, s(2)+ms(1)), // palm still there, drifting
|
||||
)
|
||||
if ok || len(grabs) != 0 {
|
||||
t.Fatalf("resting palm + new finger must not form a pair (ok=%v grabs=%v)", ok, grabs)
|
||||
}
|
||||
// At t=2.1s the index lands: two fresh fingers -> pending (formation
|
||||
// waits for a third finger or the first movement; the palm is stale).
|
||||
_, _, _, grabs, _ = runFrame(&tr,
|
||||
pev(pointer.Press, 1, 300, 100, s(2)+ms(100)))
|
||||
if len(grabs) != 0 {
|
||||
t.Fatalf("pending pair must not grab yet, grabs=%v", grabs)
|
||||
}
|
||||
// The palm keeps drifting and the pair starts moving: the pair is
|
||||
// (thumb, index) and its distance is unaffected by the palm.
|
||||
f, _, ok, grabs, _ := runFrame(&tr,
|
||||
pev(pointer.Drag, 3, 640, 810, s(2)+ms(200)), // palm moves (pruned, ignored)
|
||||
pev(pointer.Drag, 0, 80, 100, s(2)+ms(201)),
|
||||
pev(pointer.Drag, 1, 320, 100, s(2)+ms(202)),
|
||||
)
|
||||
if len(grabs) != 2 {
|
||||
t.Fatalf("thumb+index must form the pair on movement, grabs=%v", grabs)
|
||||
}
|
||||
if !ok || !approx(float64(f), 240.0/200) {
|
||||
t.Fatalf("f=%v ok=%v want %v (palm must not enter the distance)", f, ok, 240/200)
|
||||
}
|
||||
}
|
||||
|
||||
// Device regression "pinch dies and becomes scroll": the pair must survive
|
||||
// finger movement past the scroll slop. In the router this is guaranteed by
|
||||
// the grabs (scroll is dropped from the pair's path); here we assert the
|
||||
// state machine keeps emitting factors for a pair that moves a lot, and
|
||||
// ignores drags of pointers that are not the pair.
|
||||
func TestTrackerPinchSurvivesLargeMoves(t *testing.T) {
|
||||
var tr pinchTracker
|
||||
runFrame(&tr,
|
||||
pev(pointer.Press, 0, 100, 100, 0),
|
||||
pev(pointer.Press, 1, 300, 100, 1))
|
||||
// Big outward moves (far past the ~30px scroll slop), 5 frames.
|
||||
want := []float32{1.5, 1.4, 1.3, 1.2, 1.1}
|
||||
d := 200.0
|
||||
for i := 0; i < 5; i++ {
|
||||
d *= float64(want[i])
|
||||
half := d / 2
|
||||
_, _, ok, _, _ := runFrame(&tr,
|
||||
pev(pointer.Drag, 0, 200-float32(half), 100, time.Duration(10+i*10)),
|
||||
pev(pointer.Drag, 1, 200+float32(half), 100, time.Duration(11+i*10)),
|
||||
)
|
||||
if !ok {
|
||||
t.Fatalf("frame %d: factor stopped (pinch died)", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lifting one finger of the pair: no more factors (the pair is gone), the
|
||||
// survivor's drags come out as scroll (forwarded), and returning a second
|
||||
// finger re-forms the pair with a fresh baseline.
|
||||
func TestTrackerSurvivorScrollAndReform(t *testing.T) {
|
||||
var tr pinchTracker
|
||||
runFrame(&tr,
|
||||
pev(pointer.Press, 0, 100, 100, 0),
|
||||
pev(pointer.Press, 1, 300, 100, 1))
|
||||
// Pinch in a bit.
|
||||
runFrame(&tr,
|
||||
pev(pointer.Drag, 0, 80, 100, 10),
|
||||
pev(pointer.Drag, 1, 320, 100, 11))
|
||||
// Finger 0 lifts. Finger 1 survives.
|
||||
_, _, ok, _, _ := runFrame(&tr,
|
||||
pev(pointer.Release, 0, 80, 100, 20))
|
||||
if ok {
|
||||
t.Fatal("broken pair must not emit a factor")
|
||||
}
|
||||
// The survivor scrolls: 40px up over two frames -> +40, +25.
|
||||
_, _, _, _, scroll := runFrame(&tr,
|
||||
pev(pointer.Drag, 1, 320, 60, 30))
|
||||
if scroll != 40 {
|
||||
t.Fatalf("survivor scroll=%d want 40", scroll)
|
||||
}
|
||||
_, _, _, _, scroll = runFrame(&tr,
|
||||
pev(pointer.Drag, 1, 320, 35, 40))
|
||||
if scroll != 25 {
|
||||
t.Fatalf("survivor scroll=%d want 25", scroll)
|
||||
}
|
||||
// A second finger returns: CANDIDATE for a re-form; the pair re-forms
|
||||
// only when the new finger MOVES (two-mover rule — a palm resting on
|
||||
// the survivor is not a pinch).
|
||||
_, _, ok, grabs, _ := runFrame(&tr,
|
||||
pev(pointer.Press, 2, 50, 35, 50))
|
||||
if ok || len(grabs) != 0 {
|
||||
t.Fatalf("candidate press must not re-form yet (ok=%v grabs=%v)", ok, grabs)
|
||||
}
|
||||
f, _, ok, grabs, _ := runFrame(&tr,
|
||||
pev(pointer.Drag, 1, 320, 35, 60),
|
||||
pev(pointer.Drag, 2, 30, 35, 61)) // 20px from press: a real second finger
|
||||
if len(grabs) != 1 || grabs[0] != 2 {
|
||||
t.Fatalf("re-form grabs=%v want [2] (only the new finger)", grabs)
|
||||
}
|
||||
// Baseline = the distance at the candidate's PRESS (270 = 320-50); the
|
||||
// spread to 290 by re-form time is owed, not lost.
|
||||
if !ok || !approx(float64(f), 290.0/270) {
|
||||
t.Fatalf("post-reform f=%v ok=%v want %v", f, ok, 290/270)
|
||||
}
|
||||
// Survivor lifts: fully idle again.
|
||||
_, _, ok, _, scroll = runFrame(&tr,
|
||||
pev(pointer.Release, 1, 320, 35, 70))
|
||||
if ok || scroll != 0 {
|
||||
t.Fatal("idle state must emit nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// Device regression "dramatic zoom before the pinch starts": a NEW pinch
|
||||
// must never emit a factor on its formation frame, even if the previous
|
||||
// pinch ended at a very different distance (stale baseline).
|
||||
func TestTrackerFreshBaselineEachPinch(t *testing.T) {
|
||||
var tr pinchTracker
|
||||
// Pinch A at ~200px.
|
||||
runFrame(&tr,
|
||||
pev(pointer.Press, 0, 100, 100, 0),
|
||||
pev(pointer.Press, 1, 300, 100, 1))
|
||||
runFrame(&tr,
|
||||
pev(pointer.Release, 0, 100, 100, 10),
|
||||
pev(pointer.Release, 1, 300, 100, 11))
|
||||
// Pinch B starts 500px apart (the fingers landed far apart). The old
|
||||
// code would have emitted 500/200 = 2.5x on the first frame.
|
||||
_, _, ok, _, _ := runFrame(&tr,
|
||||
pev(pointer.Press, 0, 0, 100, 100),
|
||||
pev(pointer.Press, 1, 500, 100, 101))
|
||||
if ok {
|
||||
t.Fatal("second pinch's formation frame must not emit a factor (stale baseline)")
|
||||
}
|
||||
f, _, ok, _, _ := runFrame(&tr,
|
||||
pev(pointer.Drag, 0, -20, 100, 110),
|
||||
pev(pointer.Drag, 1, 520, 100, 111))
|
||||
if !ok || !approx(float64(f), 540.0/500) {
|
||||
t.Fatalf("f=%v ok=%v want %v (baseline = this pinch's own start)", f, ok, 540/500)
|
||||
}
|
||||
}
|
||||
|
||||
// A third finger landing DURING an active pinch is ignored (palm rest): the
|
||||
// pair is fixed, its distance is untouched, and no extra grabs are issued.
|
||||
func TestTrackerExtraFingerDuringPinchIgnored(t *testing.T) {
|
||||
var tr pinchTracker
|
||||
runFrame(&tr,
|
||||
pev(pointer.Press, 0, 100, 100, 0),
|
||||
pev(pointer.Press, 1, 300, 100, 1))
|
||||
// Palm lands and sits.
|
||||
_, _, ok, grabs, _ := runFrame(&tr,
|
||||
pev(pointer.Press, 4, 700, 900, 10))
|
||||
if ok || len(grabs) != 0 {
|
||||
t.Fatalf("extra finger during pinch: ok=%v grabs=%v", ok, grabs)
|
||||
}
|
||||
// Palm drifts a bit; the two pinch fingers move (forming the pair); the
|
||||
// palm is not in the pair and its distance is untouched.
|
||||
f, _, ok, grabs, _ := runFrame(&tr,
|
||||
pev(pointer.Drag, 4, 710, 910, 20), // 14px: a mover, but its distance to the fingers changes little
|
||||
pev(pointer.Drag, 0, 80, 100, 21),
|
||||
pev(pointer.Drag, 1, 320, 100, 22))
|
||||
if len(grabs) != 2 {
|
||||
t.Fatalf("pinch fingers must form the pair, grabs=%v", grabs)
|
||||
}
|
||||
if !ok || !approx(float64(f), 240.0/200) {
|
||||
t.Fatalf("f=%v ok=%v want %v", f, ok, 240/200)
|
||||
}
|
||||
// Palm lifts: still nothing.
|
||||
_, _, ok, _, _ = runFrame(&tr,
|
||||
pev(pointer.Release, 4, 750, 950, 30))
|
||||
if ok {
|
||||
t.Fatal("palm release must not emit a factor")
|
||||
}
|
||||
}
|
||||
|
||||
// A global cancel (app switch) breaks the pair with no survivor.
|
||||
func TestTrackerCancelBreaksPair(t *testing.T) {
|
||||
var tr pinchTracker
|
||||
runFrame(&tr,
|
||||
pev(pointer.Press, 0, 100, 100, 0),
|
||||
pev(pointer.Press, 1, 300, 100, 1))
|
||||
runFrame(&tr,
|
||||
pev(pointer.Drag, 0, 80, 100, 10),
|
||||
pev(pointer.Drag, 1, 320, 100, 11))
|
||||
// App switch: cancels for both pointers.
|
||||
_, _, ok, _, scroll := runFrame(&tr,
|
||||
pev(pointer.Cancel, 0, 0, 0, 20),
|
||||
pev(pointer.Cancel, 1, 0, 0, 21))
|
||||
if ok || scroll != 0 {
|
||||
t.Fatal("cancel must break the pair cleanly")
|
||||
}
|
||||
// The cancelled drags must not resurrect anything.
|
||||
_, _, ok, grabs, _ := runFrame(&tr,
|
||||
pev(pointer.Drag, 0, 70, 100, 30))
|
||||
if ok || len(grabs) != 0 {
|
||||
t.Fatal("post-cancel drags must be inert")
|
||||
}
|
||||
}
|
||||
|
||||
// A finger that leaves the region pre-pinch is no longer a candidate:
|
||||
// press-leave-press must not form a pair.
|
||||
func TestTrackerLeaveCancelsCandidate(t *testing.T) {
|
||||
var tr pinchTracker
|
||||
runFrame(&tr,
|
||||
pev(pointer.Press, 0, 100, 100, 0))
|
||||
runFrame(&tr,
|
||||
pev(pointer.Drag, 0, 500, 1500, 10), // leaves the region
|
||||
pev(pointer.Leave, 0, 500, 1500, 11))
|
||||
_, _, ok, grabs, _ := runFrame(&tr,
|
||||
pev(pointer.Press, 1, 300, 100, 20))
|
||||
if ok || len(grabs) != 0 {
|
||||
t.Fatalf("leaving finger must not pair with a new one (ok=%v grabs=%v)", ok, grabs)
|
||||
}
|
||||
}
|
||||
|
||||
// A pair broken by one finger lifting, then the survivor lifting too: the
|
||||
// state must be fully idle (a later single-finger scroll emits nothing).
|
||||
func TestTrackerFullyIdleAfterSurvivorLift(t *testing.T) {
|
||||
var tr pinchTracker
|
||||
runFrame(&tr,
|
||||
pev(pointer.Press, 0, 100, 100, 0),
|
||||
pev(pointer.Press, 1, 300, 100, 1))
|
||||
runFrame(&tr,
|
||||
pev(pointer.Release, 0, 100, 100, 10))
|
||||
runFrame(&tr,
|
||||
pev(pointer.Drag, 1, 300, 60, 20)) // survivor scrolls
|
||||
runFrame(&tr,
|
||||
pev(pointer.Release, 1, 300, 60, 30))
|
||||
for i := 0; i < 5; i++ {
|
||||
_, _, ok, grabs, scroll := runFrame(&tr,
|
||||
pev(pointer.Drag, 1, 300, float32(60-i*20), time.Duration(40+i*10)))
|
||||
if ok || len(grabs) != 0 || scroll != 0 {
|
||||
t.Fatalf("frame %d: not idle (ok=%v grabs=%v scroll=%v)", i, ok, grabs, scroll)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The sanity clamp: a factor outside 0.1..10 (teleporting pair) is dropped
|
||||
// and the baseline advances, so one bad frame cannot jump the font.
|
||||
func TestTrackerSanityClamp(t *testing.T) {
|
||||
var tr pinchTracker
|
||||
runFrame(&tr,
|
||||
pev(pointer.Press, 0, 100, 100, 0),
|
||||
pev(pointer.Press, 1, 300, 100, 1))
|
||||
// Teleport: finger 0 jumps 3000px (ID-reuse noise) while finger 1 moves
|
||||
// 20px: both are movers, the pair forms, distance 200 -> 2800, factor
|
||||
// 14x — must be dropped, baseline advanced.
|
||||
f, _, ok, _, _ := runFrame(&tr,
|
||||
pev(pointer.Drag, 0, 3100, 100, ms(10)),
|
||||
pev(pointer.Drag, 1, 320, 100, ms(11)))
|
||||
if ok {
|
||||
t.Fatalf("implausible factor emitted: %v", f)
|
||||
}
|
||||
// Next frame is sane relative to the NEW baseline (2800px).
|
||||
f, _, ok, _, _ = runFrame(&tr,
|
||||
pev(pointer.Drag, 0, 3050, 100, ms(20)),
|
||||
pev(pointer.Drag, 1, 325, 100, ms(21)))
|
||||
if !ok || !approx(float64(f), 2725.0/2780) {
|
||||
t.Fatalf("f=%v ok=%v want %v", f, ok, 2725.0/2780)
|
||||
}
|
||||
}
|
||||
|
||||
// A slow frame: the whole pinch (formation + spread + both releases) can
|
||||
// arrive in ONE frame's drain (the Android driver replays historical samples
|
||||
// and a low frame rate batches events). The factors must still be owed:
|
||||
// the formation-frame movement relative to the formation distance, and the
|
||||
// final movement settled when the pair breaks.
|
||||
func TestTrackerSlowFrameFullPinch(t *testing.T) {
|
||||
var tr pinchTracker
|
||||
// Frame 1: both Presses (200px apart) and drags out to 300px, all in
|
||||
// one drain. The pair forms at 200 and moved to 300 this frame:
|
||||
// factor 300/200 owed.
|
||||
f, _, ok, grabs, _ := runFrame(&tr,
|
||||
pev(pointer.Press, 0, 100, 100, 0),
|
||||
pev(pointer.Press, 1, 300, 100, 1),
|
||||
pev(pointer.Drag, 0, 50, 100, 2),
|
||||
pev(pointer.Drag, 1, 350, 100, 3))
|
||||
if !ok || !approx(float64(f), 300.0/200) {
|
||||
t.Fatalf("f1=%v ok=%v want %v (formation-frame movement is owed)", f, ok, 300.0/200)
|
||||
}
|
||||
if len(grabs) != 2 {
|
||||
t.Fatalf("grabs=%v want 2", grabs)
|
||||
}
|
||||
// Frame 2: spread to 400px, then both fingers lift — same drain. The
|
||||
// 400/300 factor must be settled at the break, not lost.
|
||||
f, _, ok, _, _ = runFrame(&tr,
|
||||
pev(pointer.Drag, 0, 0, 100, 10),
|
||||
pev(pointer.Drag, 1, 400, 100, 11),
|
||||
pev(pointer.Release, 0, 0, 100, 12),
|
||||
pev(pointer.Release, 1, 400, 100, 13))
|
||||
if !ok || !approx(float64(f), 400.0/300) {
|
||||
t.Fatalf("f2=%v ok=%v want %v (break settles the owed factor)", f, ok, 400.0/300)
|
||||
}
|
||||
// Fully idle after the break.
|
||||
if _, _, ok, _, scroll := runFrame(&tr,
|
||||
pev(pointer.Drag, 1, 400, 80, 20)); ok || scroll != 0 {
|
||||
t.Fatal("tracker must be idle after both releases")
|
||||
}
|
||||
}
|
||||
|
||||
// A stationary pair that forms and then breaks without moving owes nothing.
|
||||
func TestTrackerSlowFrameNoMovement(t *testing.T) {
|
||||
var tr pinchTracker
|
||||
if _, _, ok, _, _ := runFrame(&tr,
|
||||
pev(pointer.Press, 0, 100, 100, 0),
|
||||
pev(pointer.Press, 1, 300, 100, 1),
|
||||
pev(pointer.Release, 0, 100, 100, 2),
|
||||
pev(pointer.Release, 1, 300, 100, 3)); ok {
|
||||
t.Fatal("stationary pair that breaks must not emit a factor")
|
||||
}
|
||||
}
|
||||
|
||||
// The worst case (a ~1fps emulator frame): the ENTIRE pinch — formation,
|
||||
// spread, and both releases — lands in one frame's drain. The owed factor
|
||||
// is settled at the break against the formation distance.
|
||||
func TestTrackerBornAndDeadInOneFrame(t *testing.T) {
|
||||
var tr pinchTracker
|
||||
f, _, ok, grabs, _ := runFrame(&tr,
|
||||
pev(pointer.Press, 0, 100, 100, 0),
|
||||
pev(pointer.Press, 1, 300, 100, 1),
|
||||
pev(pointer.Drag, 0, 0, 100, 2),
|
||||
pev(pointer.Drag, 1, 400, 100, 3),
|
||||
pev(pointer.Release, 0, 0, 100, 4),
|
||||
pev(pointer.Release, 1, 400, 100, 5))
|
||||
if !ok || !approx(float64(f), 400.0/200) {
|
||||
t.Fatalf("f=%v ok=%v want %v", f, ok, 400.0/200)
|
||||
}
|
||||
if len(grabs) != 2 {
|
||||
t.Fatalf("grabs=%v want 2", grabs)
|
||||
}
|
||||
if _, _, ok, _, _ := runFrame(&tr,
|
||||
pev(pointer.Drag, 1, 400, 80, 10)); ok {
|
||||
t.Fatal("idle tracker must not emit")
|
||||
}
|
||||
}
|
||||
|
||||
// Palm-first three-finger: the palm edge is resting and still, then the two
|
||||
// pinch fingers land (all within the freshness window). The pair must be the
|
||||
// two NEWEST fingers — the pinch pair — not the palm.
|
||||
func TestTrackerPalmFirstThreeFingers(t *testing.T) {
|
||||
var tr pinchTracker
|
||||
// Palm rests at t=0 (still), pinch fingers land 80/120ms later.
|
||||
// Pending: no pair yet (movement decides).
|
||||
_, _, _, grabs, _ := runFrame(&tr,
|
||||
pev(pointer.Press, 3, 720, 400, 0),
|
||||
pev(pointer.Press, 0, 570, 1200, ms(80)),
|
||||
pev(pointer.Press, 1, 870, 1200, ms(120)))
|
||||
if len(grabs) != 0 {
|
||||
t.Fatalf("pending: no grabs yet, got %v", grabs)
|
||||
}
|
||||
// The pinch spreads 300 -> 596 while the palm stays put: the two MOVERS
|
||||
// (the pinch fingers) form the pair, and the factor must track the
|
||||
// pinch pair — not the palm.
|
||||
f, _, ok, grabs, _ := runFrame(&tr,
|
||||
pev(pointer.Drag, 0, 422, 1200, ms(200)),
|
||||
pev(pointer.Drag, 1, 1018, 1200, ms(201)),
|
||||
pev(pointer.Drag, 3, 720, 400, ms(202))) // palm still
|
||||
if len(grabs) != 2 || (grabs[0] != 0 && grabs[0] != 1) || (grabs[1] != 0 && grabs[1] != 1) {
|
||||
t.Fatalf("pair must be the two pinch fingers, grabs=%v", grabs)
|
||||
}
|
||||
if !ok || !approx(float64(f), 596.0/300) {
|
||||
t.Fatalf("f=%v ok=%v want %v (palm must not enter the distance)", f, ok, 596.0/300)
|
||||
}
|
||||
}
|
||||
470
internal/ui/pinch_tracker.go
Normal file
470
internal/ui/pinch_tracker.go
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"gioui.org/f32"
|
||||
"gioui.org/io/pointer"
|
||||
)
|
||||
|
||||
// pinchFreshWindow is how recently BOTH pair fingers must have pressed for a
|
||||
// pinch to start. A finger that has been resting (palm edge, pinky parked on
|
||||
// the screen) is not a pinch candidate; without this window a resting third
|
||||
// finger could be paired with a new one and the font would follow a
|
||||
// single-finger scroll.
|
||||
const pinchFreshWindow = 300 * time.Millisecond
|
||||
|
||||
// pinchMoveEps is how far (window px, from the press position) a pending
|
||||
// finger must travel to count as "moving" for pair formation. It filters
|
||||
// touch jitter while still registering an intentional pinch start quickly.
|
||||
const pinchMoveEps = 10.0
|
||||
|
||||
// Pair formation requires TWO MOVING fingers, and nothing else. A pinch is
|
||||
// two fingers moving apart (or together); a scroll is one finger moving;
|
||||
// a resting palm is a finger that never moves. No static rule about which
|
||||
// finger is "the palm" (first? last? still?) survives both palm-first and
|
||||
// palm-last hand lands — movement is the only signal that works for both.
|
||||
// While 2-3 fresh fingers are down the tracker is "pending": it forms the
|
||||
// pair the moment two of them have moved more than pinchMoveEps from where
|
||||
// they pressed (the pair = those two). A lone mover is a scroll, never a
|
||||
// pinch, and cannot form a pair; the pending state cancels on any release
|
||||
// that leaves fewer than two fingers.
|
||||
|
||||
// pinchTracker is the pure state machine for the editor's two-finger pinch
|
||||
// (font scaling). It is independent of the Gio input router so the whole
|
||||
// gesture — including the failure modes found on device (pair switching,
|
||||
// stale baselines, scroll-grab starvation) — is unit-testable with synthetic
|
||||
// pointer-event sequences.
|
||||
//
|
||||
// Design: the pair is EXPLICIT and stable. When two fresh fingers are down
|
||||
// inside the region, they are named as the pair and the adapter issues
|
||||
// pointer.GrabCmds for both (exclusive event delivery: releases always
|
||||
// arrive, even off-clip, and scroll/click are dropped with a Cancel). The
|
||||
// per-frame factor is current pair distance / previous frame's pair
|
||||
// distance. Neither the pair's composition nor its baseline is ever
|
||||
// re-derived from whatever pointers happen to be present — that re-derivation
|
||||
// (two lowest IDs + stale prevDist) is what made single-finger scrolls scale
|
||||
// the font on the phone.
|
||||
type pinchTracker struct {
|
||||
// Pre-pinch ("pending"): fresh fingers down in the region, no pair yet.
|
||||
// The pair forms when TWO of them have moved more than pinchMoveEps
|
||||
// from where they pressed (see the comment above the constants).
|
||||
observed map[pointer.ID]f32.Point
|
||||
observedAt map[pointer.ID]time.Duration
|
||||
// Position each pending finger had when it PRESSED: the formation
|
||||
// baseline (formDist) is the press distance, so a pinch that has
|
||||
// already spread by the time the pair starts owes that spread; and the
|
||||
// displacement from here is the "mover" measurement.
|
||||
pressPos map[pointer.ID]f32.Point
|
||||
// Pending fingers that released in the CURRENT frame's drain, held lazy
|
||||
// until factor() (which forms the pair — and may break it again — at
|
||||
// the fingers' final positions). A normal active-pair release breaks
|
||||
// immediately in step(); this map is only for releases that arrive
|
||||
// while the pair is still pending.
|
||||
released map[pointer.ID]bool
|
||||
// Reform candidate: after a pair broke, the survivor is still grabbed
|
||||
// and a NEW finger has landed. The pair re-forms (survivor + new) only
|
||||
// when the new finger moves (pinchMoveEps) — the same two-mover rule;
|
||||
// a palm landing on the survivor is not a pinch. reformPos is where
|
||||
// the candidate PRESSED (the mover reference).
|
||||
reformID pointer.ID
|
||||
reformPos f32.Point
|
||||
reformBase float32 // pair distance at the candidate's press
|
||||
reformHave bool
|
||||
|
||||
// The active (grabbed) pair.
|
||||
pair [2]pointer.ID
|
||||
pos [2]f32.Point
|
||||
on bool
|
||||
prevDist float32
|
||||
// The pair's distance at the moment of (re)formation: the distance of
|
||||
// the two press positions. The Android driver replays
|
||||
// historical samples, so drags of the forming pair can land in the same
|
||||
// frame as the pair's formation; movement relative to the formation
|
||||
// distance is real and owed.
|
||||
formDist float32
|
||||
// Set on the pair's formation frame (start or re-form): the baseline
|
||||
// is being established, so factor() emits nothing unless the pair
|
||||
// moved from its formation positions this frame.
|
||||
fresh bool
|
||||
// The pair broke (a finger lifted) during the current frame's drain.
|
||||
// factor() runs AFTER the drain, so on a slow frame the pair's drags
|
||||
// and the breaking release arrive together and the frame's factor
|
||||
// would be lost (on==false by the time factor() runs). The factor is
|
||||
// settled here instead.
|
||||
brokeFactor float32
|
||||
brokeMid f32.Point
|
||||
|
||||
// A pair finger was released while the other is still down (still
|
||||
// grabbed by the probe; Gio v0.10 has no release-grab). The survivor's
|
||||
// drags are forwarded as scroll so the finger is not dead.
|
||||
survOn bool
|
||||
survID pointer.ID
|
||||
survPos f32.Point
|
||||
// Accumulated survivor scroll for the current frame (window px,
|
||||
// gesture.Scroll convention: positive = content scrolls up).
|
||||
survScroll int
|
||||
}
|
||||
|
||||
// pinchStep is the outcome of one pointer event.
|
||||
type pinchStep struct {
|
||||
// Grabs the adapter must issue (pointer.GrabCmd per ID). Only the
|
||||
// survivor re-form path emits grabs per event; initial pair formation
|
||||
// is decided in factor() (after the whole frame's events).
|
||||
grabs []pointer.ID
|
||||
}
|
||||
|
||||
// step feeds one pointer event into the tracker. The per-frame factor is not
|
||||
// part of the step result: the adapter calls factor() once after draining
|
||||
// the frame's events, because the Android driver replays historical samples
|
||||
// (several drags per frame) and the font must see one factor per frame.
|
||||
func (t *pinchTracker) step(pe pointer.Event) pinchStep {
|
||||
var s pinchStep
|
||||
id := pe.PointerID
|
||||
switch pe.Kind {
|
||||
case pointer.Press:
|
||||
switch {
|
||||
case t.on:
|
||||
// Extra finger during an active pinch (palm rest, third
|
||||
// finger): ignore — the pair is fixed.
|
||||
case t.survOn && t.reformHave && id == t.reformID:
|
||||
// The reform candidate lifted before moving: not a pinch.
|
||||
t.reformHave = false
|
||||
case t.survOn:
|
||||
// A new finger lands while the survivor is down: CANDIDATE for
|
||||
// a re-form; it must MOVE to become the pair (the same two-mover
|
||||
// rule — a palm resting on the survivor is not a pinch). The
|
||||
// formation baseline is the distance at this PRESS (any spread
|
||||
// by re-form time is owed).
|
||||
t.reformID = id
|
||||
t.reformPos = pe.Position
|
||||
t.reformBase = pairDist(t.survPos, pe.Position)
|
||||
t.reformHave = true
|
||||
default:
|
||||
if t.observed == nil {
|
||||
t.observed = make(map[pointer.ID]f32.Point)
|
||||
t.observedAt = make(map[pointer.ID]time.Duration)
|
||||
t.pressPos = make(map[pointer.ID]f32.Point)
|
||||
}
|
||||
t.observed[id] = pe.Position
|
||||
t.observedAt[id] = pe.Time
|
||||
t.pressPos[id] = pe.Position
|
||||
// Prune stale fingers (resting palm/pinky); a pruned palm is
|
||||
// out of the game entirely.
|
||||
t.pruneStale(pe.Time)
|
||||
// (Two or more fingers down is the PENDING state; the pair
|
||||
// forms only when two of them are movers, decided in factor().)
|
||||
}
|
||||
case pointer.Drag:
|
||||
switch {
|
||||
case t.on:
|
||||
if i := t.pairIndex(id); i >= 0 {
|
||||
t.pos[i] = pe.Position
|
||||
}
|
||||
case t.survOn && id == t.survID:
|
||||
t.survScroll += int(t.survPos.Y - pe.Position.Y)
|
||||
t.survPos = pe.Position
|
||||
case t.survOn && t.reformHave && id == t.reformID:
|
||||
// The reform candidate moves: if it clears the epsilon from
|
||||
// where it pressed it is a real second finger — re-form the
|
||||
// pair (survivor + new).
|
||||
dx, dy := pe.Position.X-t.reformPos.X, pe.Position.Y-t.reformPos.Y
|
||||
if dx*dx+dy*dy > pinchMoveEps*pinchMoveEps {
|
||||
t.pair = [2]pointer.ID{t.survID, id}
|
||||
t.pos = [2]f32.Point{t.survPos, pe.Position}
|
||||
t.survOn = false
|
||||
t.reformHave = false
|
||||
t.on = true
|
||||
t.fresh = true
|
||||
t.formDist = t.reformBase
|
||||
s.grabs = []pointer.ID{id} // survivor already grabbed
|
||||
}
|
||||
default:
|
||||
if _, ok := t.pressPos[id]; ok {
|
||||
t.observed[id] = pe.Position
|
||||
// Formation is NOT decided here: a per-event decision would
|
||||
// lock in the first mover pair seen (e.g. (finger, drifting
|
||||
// palm)) before the second pinch finger's drag lands in the
|
||||
// same drain. factor() decides, after the full frame.
|
||||
}
|
||||
}
|
||||
case pointer.Release, pointer.Cancel:
|
||||
switch {
|
||||
case t.on:
|
||||
if i := t.pairIndex(id); i >= 0 {
|
||||
t.breakPair(i)
|
||||
}
|
||||
case t.survOn && id == t.survID:
|
||||
// The survivor lifts too: fully idle (the candidate, if any,
|
||||
// was a lone finger — never a pair).
|
||||
t.survOn = false
|
||||
t.reformHave = false
|
||||
default:
|
||||
// Pending finger goes up: held LAZY until factor() — on a slow
|
||||
// frame the whole pinch (drags AND releases) can land in one
|
||||
// drain, and the pair must form at the fingers' final positions
|
||||
// before the break settles the owed factor.
|
||||
if _, ok := t.observed[id]; ok {
|
||||
t.markReleased(id)
|
||||
}
|
||||
}
|
||||
case pointer.Leave:
|
||||
// Only meaningful pre-pinch: a finger that leaves the editor
|
||||
// region is no longer a pinch candidate. (A grabbed pair finger
|
||||
// keeps delivering through the grab; its Leave is ignored.)
|
||||
if t.survOn && t.reformHave && id == t.reformID {
|
||||
t.reformHave = false
|
||||
} else if t.observed != nil {
|
||||
if _, ok := t.observed[id]; ok {
|
||||
t.markReleased(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// breakPair settles the frame's owed factor (if any) and breaks the pair.
|
||||
// The surviving finger stays grabbed by the probe (no release-grab in v0.10)
|
||||
// and becomes the survivor — UNLESS it too released in this same drain
|
||||
// (slow frame: a born-and-dead pair), in which case the tracker goes fully
|
||||
// idle. Settling here (before factor() runs) is what keeps the factor on a
|
||||
// frame where the drags and the breaking release arrive together.
|
||||
func (t *pinchTracker) breakPair(i int) {
|
||||
base := t.prevDist
|
||||
if base == 0 && t.fresh {
|
||||
base = t.formDist
|
||||
}
|
||||
if d := pairDist(t.pos[0], t.pos[1]); d > 0 && base > 0 && d != base {
|
||||
if f := d / base; f >= 0.1 && f <= 10 {
|
||||
t.brokeFactor = f
|
||||
t.brokeMid = pairMid(t.pos[0], t.pos[1])
|
||||
}
|
||||
}
|
||||
t.on = false
|
||||
t.prevDist = 0
|
||||
other := 1 - i
|
||||
if !t.released[t.pair[other]] {
|
||||
t.survOn = true
|
||||
t.survID = t.pair[other]
|
||||
t.survPos = t.pos[other]
|
||||
}
|
||||
}
|
||||
|
||||
// markReleased lazily records a pending finger's release (see the Release
|
||||
// case). Cleared by factor() after it has formed (and possibly broken) the
|
||||
// pair.
|
||||
func (t *pinchTracker) markReleased(id pointer.ID) {
|
||||
if t.released == nil {
|
||||
t.released = make(map[pointer.ID]bool)
|
||||
}
|
||||
t.released[id] = true
|
||||
}
|
||||
|
||||
// dropObserved removes a pending finger for good.
|
||||
func (t *pinchTracker) dropObserved(id pointer.ID) {
|
||||
if t.observed == nil {
|
||||
return
|
||||
}
|
||||
delete(t.observed, id)
|
||||
delete(t.observedAt, id)
|
||||
delete(t.pressPos, id)
|
||||
delete(t.released, id)
|
||||
}
|
||||
|
||||
// movers returns the pending fingers that moved more than pinchMoveEps from
|
||||
// where they pressed, most-displaced first.
|
||||
func (t *pinchTracker) movers() []pointer.ID {
|
||||
type dm struct {
|
||||
id pointer.ID
|
||||
d float64
|
||||
}
|
||||
var list []dm
|
||||
for id, pp := range t.pressPos {
|
||||
dx := float64(t.observed[id].X - pp.X)
|
||||
dy := float64(t.observed[id].Y - pp.Y)
|
||||
if d := math.Sqrt(dx*dx + dy*dy); d > pinchMoveEps {
|
||||
list = append(list, dm{id, d})
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(list); i++ {
|
||||
for j := i + 1; j < len(list); j++ {
|
||||
if list[j].d > list[i].d {
|
||||
list[i], list[j] = list[j], list[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]pointer.ID, 0, len(list))
|
||||
for _, e := range list {
|
||||
out = append(out, e.id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tryFormPair forms the pair from the pending fingers if at least TWO are
|
||||
// movers (two moving fingers = a pinch; one = a scroll, never a pair). Of
|
||||
// the mover pairs, the one whose DISTANCE changed most wins: a pinch's pair
|
||||
// distance changes, while a drifting palm's distance to a finger changes
|
||||
// little. A mover pair whose distance does not change (fingers moving in
|
||||
// unison) is a slide, not a pinch. Returns the IDs to grab, or nil when no
|
||||
// pair forms.
|
||||
func (t *pinchTracker) tryFormPairAny() []pointer.ID {
|
||||
m := t.movers()
|
||||
if len(m) < 2 {
|
||||
return nil
|
||||
}
|
||||
bestA, bestB := m[0], m[1]
|
||||
bestChange := -1.0
|
||||
for i := 0; i < len(m); i++ {
|
||||
for j := i + 1; j < len(m); j++ {
|
||||
a, b := m[i], m[j]
|
||||
now := float64(pairDist(t.observed[a], t.observed[b]))
|
||||
press := float64(pairDist(t.pressPos[a], t.pressPos[b]))
|
||||
if chg := math.Abs(now - press); chg > bestChange {
|
||||
bestChange = chg
|
||||
bestA, bestB = a, b
|
||||
}
|
||||
}
|
||||
}
|
||||
if bestChange <= pinchMoveEps {
|
||||
return nil // unison movement: a slide, not a pinch
|
||||
}
|
||||
a, b := bestA, bestB
|
||||
t.pair = [2]pointer.ID{a, b}
|
||||
t.pos = [2]f32.Point{t.observed[a], t.observed[b]}
|
||||
t.on = true
|
||||
t.fresh = true
|
||||
// Baseline = the PRESS distance of the pair: any spread that happened
|
||||
// before the pair started is owed, not lost.
|
||||
t.formDist = pairDist(t.pressPos[a], t.pressPos[b])
|
||||
t.observed = nil
|
||||
t.observedAt = nil
|
||||
t.pressPos = nil
|
||||
return []pointer.ID{a, b}
|
||||
}
|
||||
|
||||
// pairIndex returns 0/1 if id is a member of the active pair, else -1.
|
||||
func (t *pinchTracker) pairIndex(id pointer.ID) int {
|
||||
if !t.on {
|
||||
return -1
|
||||
}
|
||||
if t.pair[0] == id {
|
||||
return 0
|
||||
}
|
||||
if t.pair[1] == id {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// pruneStale drops observed fingers that pressed more than
|
||||
// pinchFreshWindow before now.
|
||||
func (t *pinchTracker) pruneStale(now time.Duration) {
|
||||
for id, at := range t.observedAt {
|
||||
if now-at > pinchFreshWindow {
|
||||
delete(t.observed, id)
|
||||
delete(t.observedAt, id)
|
||||
delete(t.pressPos, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// factor returns the current frame's relative pinch scale and the pair
|
||||
// midpoint (window px), or ok=false when no factor applies (no active pair,
|
||||
// first frame of a pinch, or an implausible jump). It must be called once
|
||||
// per frame, after all of the frame's events have been stepped.
|
||||
func (t *pinchTracker) factor() (f float32, mid f32.Point, ok bool, grabs []pointer.ID) {
|
||||
// Pending pair formation is decided HERE, after the whole frame's
|
||||
// events (not per event): a per-event decision would lock in the first
|
||||
// mover pair seen — e.g. (finger, drifting palm) — before the second
|
||||
// pinch finger's drag lands in the same drain.
|
||||
if !t.on && !t.survOn && t.observed != nil {
|
||||
if g := t.tryFormPairAny(); g != nil {
|
||||
grabs = g
|
||||
// Slow frame: a pair member may have released in this same
|
||||
// drain (the release arrived while the pair was still pending):
|
||||
// break it now at the fingers' final positions.
|
||||
for i, id := range t.pair {
|
||||
if t.released[id] {
|
||||
t.breakPair(i)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pending fingers that released this frame are dropped for good.
|
||||
if t.released != nil {
|
||||
for id := range t.released {
|
||||
t.dropObserved(id)
|
||||
}
|
||||
t.released = nil
|
||||
}
|
||||
if !t.on {
|
||||
// The pair broke during this frame's drain: emit the settled
|
||||
// factor (on a slow frame the drags and the breaking release can
|
||||
// arrive together, and factor() runs only after the drain).
|
||||
if t.brokeFactor > 0 {
|
||||
f, mid, ok = t.brokeFactor, t.brokeMid, true
|
||||
t.brokeFactor = 0
|
||||
t.brokeMid = f32.Point{}
|
||||
}
|
||||
return f, mid, ok, grabs
|
||||
}
|
||||
d := pairDist(t.pos[0], t.pos[1])
|
||||
if t.fresh {
|
||||
// Formation frame: establish the baseline. Emit only if the pair
|
||||
// already moved from its formation positions this frame (the
|
||||
// Android driver replays historical samples, so drags can batch
|
||||
// with the formation Press).
|
||||
t.fresh = false
|
||||
t.prevDist = d
|
||||
if t.formDist > 0 && d != t.formDist {
|
||||
f = d / t.formDist
|
||||
if f < 0.1 || f > 10 {
|
||||
return 0, f32.Point{}, false, grabs
|
||||
}
|
||||
return f, pairMid(t.pos[0], t.pos[1]), true, grabs
|
||||
}
|
||||
return 0, f32.Point{}, false, grabs
|
||||
}
|
||||
if t.prevDist > 0 && d > 0 && d != t.prevDist {
|
||||
// No-movement guard: a stationary pair emits nothing (an f=1.0
|
||||
// factor would churn the font pin's re-layout for no change).
|
||||
f = d / t.prevDist
|
||||
// Sanitize: a real pinch moves millimeters between frames; a
|
||||
// factor this far off 1 is noise, not a finger.
|
||||
if f < 0.1 || f > 10 {
|
||||
t.prevDist = d
|
||||
return 0, f32.Point{}, false, grabs
|
||||
}
|
||||
t.prevDist = d
|
||||
return f, pairMid(t.pos[0], t.pos[1]), true, grabs
|
||||
}
|
||||
t.prevDist = d
|
||||
return 0, f32.Point{}, false, grabs
|
||||
}
|
||||
|
||||
// survivorScroll returns and resets the frame's accumulated survivor-finger
|
||||
// scroll (window px, gesture.Scroll convention: positive = content scrolls
|
||||
// up).
|
||||
func (t *pinchTracker) survivorScroll() int {
|
||||
d := t.survScroll
|
||||
t.survScroll = 0
|
||||
return d
|
||||
}
|
||||
|
||||
// reset clears all state (the editor left the screen).
|
||||
func (t *pinchTracker) reset() {
|
||||
*t = pinchTracker{}
|
||||
}
|
||||
|
||||
// pairDist is the distance between two pair points (window px).
|
||||
func pairDist(a, b f32.Point) float32 {
|
||||
dx, dy := a.X-b.X, a.Y-b.Y
|
||||
return float32(math.Sqrt(float64(dx*dx + dy*dy)))
|
||||
}
|
||||
|
||||
// pairMid is the midpoint of two pair points (window px).
|
||||
func pairMid(a, b f32.Point) f32.Point {
|
||||
return f32.Point{X: (a.X + b.X) / 2, Y: (a.Y + b.Y) / 2}
|
||||
}
|
||||
281
internal/ui/real_draw_probe_test.go
Normal file
281
internal/ui/real_draw_probe_test.go
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -62,6 +62,17 @@ type scrollReg struct {
|
|||
handler func(any)
|
||||
}
|
||||
|
||||
// Probe tags for the raw-pointer probes (long-press, pinch). Each probe
|
||||
// needs its OWN named type: the unnamed fieldless struct{} is a single
|
||||
// canonical Go type, so distinct `struct{}` fields are the SAME value.
|
||||
// Gio's router keys handlers by the tag value, so two `struct{}` probes
|
||||
// collapse into one handler and whichever probe drains first (the press
|
||||
// probe, which is consumed before the pinch probe) consumes every event,
|
||||
// starving the other — this is why pinch received nothing on device while
|
||||
// long-press appeared to work.
|
||||
type pressProbeTag struct{}
|
||||
type pinchProbeTag struct{}
|
||||
|
||||
// Renderer consumes a slice of elements and draws them.
|
||||
//
|
||||
// The Renderer is owned by the main goroutine. It is the home of any state
|
||||
|
|
@ -80,6 +91,17 @@ type Renderer struct {
|
|||
lastLineY Dp // last line baseline offset from text origin, in Dp (derived from GlyphLayout)
|
||||
glyphLayout GlyphLayout // captured per-glyph layout from last drawWrappedText
|
||||
|
||||
// ZeroWheelScroll, set by main on a frame whose window size shrank, makes
|
||||
// CheckGestures drain any pointer.Scroll events queued for the editor's
|
||||
// scroll gesture before consuming gestures. Gio's window calls RevealFocus
|
||||
// on any frame the viewport shrinks (e.g. the IME opening under
|
||||
// adjustResize) and synthesizes a pointer.Scroll nudge to bring the focused
|
||||
// field's (stale, pre-resize) bounds into view; consumed by gesture.Scroll
|
||||
// that nudge shifts the editor content. The drain kills only the
|
||||
// synthesized event: finger scroll (pointer.Drag) and the flinger are
|
||||
// untouched, and normal frames consume pointer.Scroll as before.
|
||||
ZeroWheelScroll bool
|
||||
|
||||
// IME dedup (main-owned, persistent across frames). Re-pushing an unchanged
|
||||
// snippet or selection every frame resets the IME's composition and caret,
|
||||
// which desyncs fast commits; push only on change, as widget.Editor does in
|
||||
|
|
@ -114,12 +136,40 @@ type Renderer struct {
|
|||
// scroll/drag then, not a press). longPressID gates the long-press to the
|
||||
// editor's click reg (browser rows etc. don't long-press). ppLast is in
|
||||
// f32.Point because pointer.Event.Position is window-space f32.
|
||||
pressProbe struct{}
|
||||
pressProbe pressProbeTag
|
||||
ppLast f32.Point
|
||||
ppActive bool
|
||||
ppMoved bool
|
||||
longPressID string
|
||||
|
||||
// Pinch-to-change-font-size (editor text region). Gio v0.10 has no
|
||||
// two-finger pinch primitive: pinchProbe is a raw event tag inside the
|
||||
// editor clip, and pinchT (pinch_tracker.go) is the gesture's state
|
||||
// machine. When two fresh fingers are down the tracker names them as an
|
||||
// EXPLICIT pair and the adapter grabs both (pointer.GrabCmd): exclusive
|
||||
// event delivery (releases always arrive, even off-clip; scroll/click
|
||||
// are dropped with a Cancel, which also stops the first finger dragging
|
||||
// the text mid-pinch). The per-frame factor is the pair-distance ratio
|
||||
// (FontPinchEvent to pinchHandler). The pair is never re-derived from
|
||||
// whatever pointers happen to be present: that re-derivation (two
|
||||
// lowest IDs) let a resting third finger pair with a live one and made
|
||||
// single-finger scrolls scale the font on the phone. When one pair
|
||||
// finger lifts, the survivor stays grabbed (v0.10 has no release-grab)
|
||||
// and its drags are forwarded as scroll via pinchScrollHandler. State
|
||||
// is dropped when the editor leaves the screen (see Draw).
|
||||
pinchProbe pinchProbeTag
|
||||
pinchT pinchTracker
|
||||
pinchHandler func(any)
|
||||
pinchScrollHandler func(any)
|
||||
pinchProbeOn bool
|
||||
|
||||
// appFontScale is the app-local font-size multiplier (pinch zoom;
|
||||
// 1.0 = default, 0 = not set yet). The main goroutine feeds it from the
|
||||
// frame's snapshot via SetAppFontScale before Draw; drawWrappedText
|
||||
// multiplies the editor font size by it. The system user font scale is
|
||||
// separate and already folded into gtx.Metric/gtx.Sp.
|
||||
appFontScale float32
|
||||
|
||||
// Selection / caret drag handles (0 = start, 1 = end, 2 = body, 3 = caret
|
||||
// handle). Registered clipped in drawWrappedText only while a selection or
|
||||
// caret handle is visible; a gesture.Drag grabs the pointer once movement
|
||||
|
|
@ -155,6 +205,17 @@ type Renderer struct {
|
|||
gestureExclusions [][4]int
|
||||
}
|
||||
|
||||
// SetAppFontScale sets the app-local font-size multiplier for subsequent
|
||||
// draw passes (1.0 = default; <= 0 is treated as 1). Main-goroutine-only;
|
||||
// call before Draw (see appFontScale).
|
||||
func (r *Renderer) SetAppFontScale(v float32) {
|
||||
if v > 0 {
|
||||
r.appFontScale = v
|
||||
} else {
|
||||
r.appFontScale = 1
|
||||
}
|
||||
}
|
||||
|
||||
// GestureExclusions returns the handle grab boxes collected for the last
|
||||
// frame (see gestureExclusions).
|
||||
func (r *Renderer) GestureExclusions() [][4]int { return r.gestureExclusions }
|
||||
|
|
@ -238,6 +299,12 @@ func (r *Renderer) toDp(px Px) Dp {
|
|||
func (r *Renderer) Draw(gtx layout.Context, elems []Element, scale float32) {
|
||||
r.scale = scale
|
||||
r.focusSeenThisFrame = false // per-frame reset for FocusCmd dedup
|
||||
if !r.pinchProbeOn {
|
||||
// No editor text in the previous frame: drop all pinch state so a
|
||||
// later pinch starts clean.
|
||||
r.pinchT.reset()
|
||||
}
|
||||
r.pinchProbeOn = false
|
||||
// Gio sets constraints to layout.Exact(windowSize), so Min==Max. Use (0,0) as Min.
|
||||
winW := gtx.Constraints.Max.X
|
||||
winH := gtx.Constraints.Max.Y
|
||||
|
|
@ -343,6 +410,12 @@ func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent {
|
|||
// Long-press motion probe first: it must see the press/drag events before
|
||||
// the click loop decides about a long press.
|
||||
r.consumePressProbe(q)
|
||||
// Pinch probe: drain the raw pointer events, grab/release the pair,
|
||||
// and emit the frame's scale factor and any survivor-finger scroll.
|
||||
// Runs before the click/scroll loops so the pinch is applied in the
|
||||
// same input batch that carried the finger moves, and the pair's
|
||||
// grabs are queued ahead of any competing scroll grab.
|
||||
events = append(events, r.consumePinchProbe(q)...)
|
||||
for id, reg := range r.clicks {
|
||||
// Drain every queued event for this gesture in this frame.
|
||||
// gesture.Click returns one event per Update call, but on Android a
|
||||
|
|
@ -467,9 +540,25 @@ func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent {
|
|||
// gesture.Scroll.Update returns scroll delta in pixels.
|
||||
// ScrollY range: Min = -scrollOffset (remaining above), Max = large (content height unknown yet).
|
||||
// With Min==Max==0, clampSplit consumes zero scroll.
|
||||
// Update runs unconditionally (it keeps the gesture's flinger state
|
||||
// healthy). Emission is suppressed while a pinch owns the pair: the
|
||||
// pair is grabbed (scroll is dropped from its path) once the grabs
|
||||
// commit, and this guard covers the one-frame window before that.
|
||||
if r.ZeroWheelScroll {
|
||||
// RevealFocus (see ZeroWheelScroll) queued a synthetic
|
||||
// pointer.Scroll on this shrink frame; consume it here so the
|
||||
// gesture never sees it. Scroll-range clamping is no use: the
|
||||
// router UNIONs ranges across frames and the historical max
|
||||
// can never shrink back to zero.
|
||||
for {
|
||||
if _, ok := q.Event(pointer.Filter{Target: reg.scroll, Kinds: pointer.Scroll}); !ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
delta := reg.scroll.Update(m, q, time.Now(), gesture.Vertical,
|
||||
pointer.ScrollRange{}, pointer.ScrollRange{Min: -(1 << 30), Max: 1 << 30})
|
||||
if delta != 0 {
|
||||
if delta != 0 && !r.pinchT.on {
|
||||
events = append(events, InputEvent{
|
||||
Handler: reg.handler,
|
||||
Data: delta,
|
||||
|
|
@ -480,11 +569,63 @@ func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent {
|
|||
return events
|
||||
}
|
||||
|
||||
// consumePinchProbe drains the pinch probe's raw pointer events into the
|
||||
// tracker, issues the pair's grabs, and returns this frame's events: the
|
||||
// scale factor (when the active pair moved) and the survivor finger's
|
||||
// forwarded scroll (when a pair broke with one finger still down).
|
||||
func (r *Renderer) consumePinchProbe(q input.Source) []InputEvent {
|
||||
var events []InputEvent
|
||||
for {
|
||||
evt, ok := q.Event(pointer.Filter{Target: r.pinchProbe, Kinds: pointer.Press | pointer.Drag | pointer.Release | pointer.Cancel | pointer.Leave})
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
pe, ok := evt.(pointer.Event)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if s := r.pinchT.step(pe); len(s.grabs) > 0 {
|
||||
for _, id := range s.grabs {
|
||||
q.Execute(pointer.GrabCmd{Tag: r.pinchProbe, ID: id})
|
||||
}
|
||||
}
|
||||
}
|
||||
// The tracker may have formed the pair during factor() (after the full
|
||||
// frame's events); issue those grabs now. Either way they commit
|
||||
// before any scroll grab queued later in this frame (FIFO command
|
||||
// queue), so the pair wins the race even if a finger is already past
|
||||
// the scroll slop.
|
||||
f, mid, ok, grabs := r.pinchT.factor()
|
||||
for _, id := range grabs {
|
||||
q.Execute(pointer.GrabCmd{Tag: r.pinchProbe, ID: id})
|
||||
}
|
||||
if ok && r.pinchHandler != nil {
|
||||
events = append(events, InputEvent{
|
||||
Handler: r.pinchHandler,
|
||||
Data: FontPinchEvent{
|
||||
Scale: f,
|
||||
Center: Point{X: r.toDp(Px(mid.X)), Y: r.toDp(Px(mid.Y))},
|
||||
},
|
||||
})
|
||||
}
|
||||
if d := r.pinchT.survivorScroll(); d != 0 && r.pinchScrollHandler != nil {
|
||||
events = append(events, InputEvent{
|
||||
Handler: r.pinchScrollHandler,
|
||||
Data: d,
|
||||
})
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
// consumePressProbe drains the raw pointer events of the long-press probe
|
||||
// and updates ppLast/ppMoved. It runs before the click loop each frame.
|
||||
// Leave ends the pending press: a finger that drifts off the editor region
|
||||
// must not keep a long press armed (its release would never come to the
|
||||
// probe if it was grabbed by scroll, so without this the timer could fire
|
||||
// for a finger that is long gone).
|
||||
func (r *Renderer) consumePressProbe(q input.Source) {
|
||||
for {
|
||||
evt, ok := q.Event(pointer.Filter{Target: r.pressProbe, Kinds: pointer.Press | pointer.Drag | pointer.Release | pointer.Cancel})
|
||||
evt, ok := q.Event(pointer.Filter{Target: r.pressProbe, Kinds: pointer.Press | pointer.Drag | pointer.Release | pointer.Cancel | pointer.Leave})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
|
@ -505,7 +646,7 @@ func (r *Renderer) consumePressProbe(q input.Source) {
|
|||
}
|
||||
}
|
||||
r.ppLast = pe.Position
|
||||
case pointer.Release, pointer.Cancel:
|
||||
case pointer.Release, pointer.Cancel, pointer.Leave:
|
||||
r.ppMoved = false
|
||||
r.ppActive = false
|
||||
}
|
||||
|
|
@ -595,6 +736,18 @@ func (r *Renderer) drawElement(gtx layout.Context, e Element) {
|
|||
// clipped in drawWrappedText where the handle geometry is known.
|
||||
r.registerInteraction(interactive.ID(), interaction, gtx)
|
||||
}
|
||||
if interaction.Gesture == Pinch {
|
||||
// The renderer owns the probe (raw two-pointer geometry);
|
||||
// this records the logic handlers. The scroll handler is
|
||||
// the survivor-finger's forwarding target: after a pinch
|
||||
// breaks with one finger still down, that finger stays
|
||||
// grabbed by the probe (v0.10 has no release-grab), so its
|
||||
// drags are emitted as plain scroll deltas.
|
||||
r.pinchHandler = interaction.Handler
|
||||
if reg, ok := r.scrolls[interactive.ID()]; ok {
|
||||
r.pinchScrollHandler = reg.handler
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
e.Draw(gtx, r)
|
||||
|
|
@ -783,19 +936,28 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
|
|||
}
|
||||
r.gestureExclusions = nil // rebuilt from this frame's handle boxes
|
||||
|
||||
// App-local font-size multiplier (pinch zoom; SetAppFontScale keeps it
|
||||
// > 0). It multiplies the sp font size directly, so the value is a
|
||||
// continuous float — no rounding to whole points anywhere.
|
||||
appScale := r.appFontScale
|
||||
if appScale <= 0 {
|
||||
appScale = 1
|
||||
}
|
||||
size := float32(r.theme.FontSize) * appScale
|
||||
|
||||
// Fixed line height based on font size, not glyph metrics.
|
||||
lineHeightSp := unit.Sp(float32(r.theme.FontSize) * LineHeightScale)
|
||||
lineHeightSp := unit.Sp(size * LineHeightScale)
|
||||
// User font-size setting (sp per dp). The shaper draws baselines at
|
||||
// Sp(...) physical px, so the RENDERED line pitch in density-dp is
|
||||
// lineHeightSp × fontScale. Every dp-space value below (line height,
|
||||
// ascent) uses the scaled form so caret/handles/highlight follow the
|
||||
// drawn glyphs; the logic side tracks the same factor via
|
||||
// EffectiveLineHeight (ScaleEvent.FontScale).
|
||||
// EffectiveLineHeight (ScaleEvent.FontScale × app font scale).
|
||||
fontScale := float32(1)
|
||||
if gtx.Metric.PxPerDp > 0 && gtx.Metric.PxPerSp > 0 {
|
||||
fontScale = gtx.Metric.PxPerSp / gtx.Metric.PxPerDp
|
||||
}
|
||||
ascent := Dp(float32(r.theme.FontSize) * fontScale)
|
||||
ascent := Dp(size * fontScale)
|
||||
lineH := Dp(float32(lineHeightSp) * fontScale)
|
||||
// Wrap disabled: shape with unlimited width so lines extend past the
|
||||
// region (clipped by textClip below) instead of wrapping.
|
||||
|
|
@ -804,7 +966,7 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
|
|||
maxWidthPx = int(r.toPx(wrapWidth))
|
||||
}
|
||||
params := text.Parameters{
|
||||
PxPerEm: fixed.I(gtx.Sp(r.theme.FontSize)),
|
||||
PxPerEm: fixed.I(gtx.Sp(unit.Sp(size))),
|
||||
MinWidth: 0,
|
||||
MaxWidth: maxWidthPx,
|
||||
MaxLines: 0, // unlimited - wrap at MaxWidth
|
||||
|
|
@ -952,6 +1114,10 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
|
|||
// inside the editor region.
|
||||
r.selDragsOn = [4]bool{}
|
||||
event.Op(gtx.Ops, r.pressProbe)
|
||||
// Pinch probe: same clip as the press probe, so a pinch only registers
|
||||
// when both fingers' presses/moves land in the editor text region.
|
||||
event.Op(gtx.Ops, r.pinchProbe)
|
||||
r.pinchProbeOn = true
|
||||
r.longPressID = "editor_text"
|
||||
if r.selDragHandler != nil && (selStart >= 0 && selEnd > selStart || caretDrag) {
|
||||
// handleAt mirrors the cursor computation above: window-relative byte
|
||||
|
|
|
|||
133
internal/ui/reveal_focus_drain_test.go
Normal file
133
internal/ui/reveal_focus_drain_test.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
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)
|
||||
}
|
||||
29
internal/ui/tag_identity_test.go
Normal file
29
internal/ui/tag_identity_test.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gioui.org/font/gofont"
|
||||
"gioui.org/text"
|
||||
)
|
||||
|
||||
// Regression test for the pinch-on-device bug: the probes were declared as
|
||||
// `struct{}` fields. The unnamed fieldless struct{} is a single canonical Go
|
||||
// type, so all three probes were the SAME tag value and Gio's router merged
|
||||
// them into one handler — the press probe (drained first) consumed every
|
||||
// event and the pinch probe was starved. Each probe now has its own named
|
||||
// type and must be a distinct map key.
|
||||
func TestProbeTagIdentity(t *testing.T) {
|
||||
r := New(Theme{FontSize: 14}, text.NewShaper(text.WithCollection(gofont.Collection())))
|
||||
m := map[interface{}]int{}
|
||||
m[r.pressProbe] = 1
|
||||
m[r.pinchProbe] = 2
|
||||
t.Logf("distinct probe tag keys: %d", len(m))
|
||||
if len(m) != 2 {
|
||||
t.Fatalf("probe tags must be distinct values, got %d distinct keys", len(m))
|
||||
}
|
||||
var a, b interface{} = r.pressProbe, r.pinchProbe
|
||||
if a == b {
|
||||
t.Fatal("pressProbe and pinchProbe must not be equal interface values")
|
||||
}
|
||||
}
|
||||
|
|
@ -91,6 +91,11 @@ type LayoutFeedback struct {
|
|||
WindowStartByte int // absolute byte offset of the window's first byte
|
||||
WindowStartLine int // logical line the window starts at (-1: none)
|
||||
EditSeq uint64 // editor content-edit counter at frame time
|
||||
// ScrollOffset is the editor scroll offset (Dp) the frame this layout
|
||||
// was shaped from carried. The logic needs it to express layout positions
|
||||
// (window-relative) in content coordinates: the window top is the
|
||||
// shaped scroll's sub-line remainder above the region top.
|
||||
ScrollOffset Dp
|
||||
}
|
||||
|
||||
type GlyphLayout struct {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,11 @@
|
|||
# # first tap right after launch/open is
|
||||
# # sometimes swallowed — re-tap.
|
||||
# scripts/emu.sh type TEXT # type into the focused field (spaces ok)
|
||||
# scripts/emu.sh cmd <top|bottom|frac F|dp N> # one-shot editor debug command
|
||||
# scripts/emu.sh cmd <top|bottom|frac F|dp N|pinch F|fontsize F>
|
||||
# # one-shot editor debug command
|
||||
# # (pinch F = relative app font scale
|
||||
# # anchored at the region center;
|
||||
# # fontsize F = absolute, top-anchored)
|
||||
# scripts/emu.sh perf on|off # enable/disable the in-app profiler
|
||||
# scripts/emu.sh perf pull [FILE] # pull logic_frames.csv (default ./logic_frames.csv)
|
||||
# scripts/emu.sh push FILE # push FILE -> /storage/emulated/0/Notes/
|
||||
|
|
@ -30,7 +34,8 @@
|
|||
# the CSV is truncated on app relaunch, so pull it before restarting the
|
||||
# app if you are accumulating data.
|
||||
# - One-shot editor debug commands are read from /storage/emulated/0/PadPerf/cmd
|
||||
# (scroll: top | bottom | "frac 0.5" | "dp 1234").
|
||||
# (scroll: top | bottom | "frac 0.5" | "dp 1234"; app font scale:
|
||||
# "pinch 1.1" relative | "fontsize 1.5" absolute).
|
||||
# - Test files go under /storage/emulated/0/Notes/.
|
||||
# - The emulator OOMs above ~2.5 GB RSS on this VM; kill it if it wedges.
|
||||
#
|
||||
|
|
@ -138,7 +143,7 @@ cmd_up() {
|
|||
kill -9 "$qp" 2>/dev/null || true
|
||||
sleep 2
|
||||
fi
|
||||
start_emulator
|
||||
start_emulator "$@"
|
||||
local rc=0
|
||||
wait_device_online "$ONLINE_TIMEOUT" || rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
|
|
@ -163,7 +168,7 @@ cmd_up() {
|
|||
sleep 1
|
||||
j=$((j + 1))
|
||||
done
|
||||
start_emulator -no-snapshot-load
|
||||
start_emulator -no-snapshot-load "$@"
|
||||
# Note: full BOOT_TIMEOUT here — a cold boot legitimately takes far
|
||||
# longer than the ONLINE_TIMEOUT used for the first (snapshot) attempt.
|
||||
wait_device_online "$BOOT_TIMEOUT" || { tail -n 5 "$EMU_LOG" >&2; die "device never came online (cold boot); see $EMU_LOG"; }
|
||||
|
|
|
|||
11
tools/touchinject/AndroidManifest.xml
Normal file
11
tools/touchinject/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="touch.inject">
|
||||
|
||||
<uses-permission android:name="android.permission.INJECT_EVENTS"/>
|
||||
|
||||
<application>
|
||||
<service android:name=".Injector" android:exported="true"/>
|
||||
<receiver android:name=".Injector$ScriptReceiver" android:exported="true"/>
|
||||
</application>
|
||||
</manifest>
|
||||
223
tools/touchinject/Injector.java
Normal file
223
tools/touchinject/Injector.java
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
package touch.inject;
|
||||
|
||||
import android.app.Service;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.hardware.input.InputManager;
|
||||
import android.os.IBinder;
|
||||
import android.os.SystemClock;
|
||||
import android.util.Log;
|
||||
import android.view.MotionEvent;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Multi-touch gesture injector for the Pad emulator.
|
||||
*
|
||||
* Runs as a privileged system app (/system/priv-app) so that the
|
||||
* signature|privileged INJECT_EVENTS permission is granted; it then injects
|
||||
* real MotionEvents through InputManager — the exact path a physical touch
|
||||
* screen takes — which is what the adb `input` applet cannot do (it has no
|
||||
* multi-touch support).
|
||||
*
|
||||
* Usage:
|
||||
* adb shell am startservice -n touch.inject/.Injector -e script "SCRIPT"
|
||||
*
|
||||
* SCRIPT is a whitespace-separated sequence of:
|
||||
* down <finger> <x> <y> finger (1-based) touches at (x,y) px
|
||||
* move <finger> <x> <y> finger moves to (x,y) px
|
||||
* up <finger> finger lifts
|
||||
* wait <ms> pause
|
||||
*
|
||||
* Progress is logged to logcat under the "TouchInject" tag.
|
||||
*/
|
||||
public class Injector extends Service {
|
||||
public static final String TAG = "TouchInject";
|
||||
|
||||
/** Shell-triggerable entry point: works even while the app is stopped
|
||||
* (the shell can broadcast to an explicit component).
|
||||
* KNOWN FLAKE: the process is "cached" while the thread runs and the
|
||||
* 1.5GB emulator OOM-killed it once mid-script (during a 200ms wait),
|
||||
* losing the final UP. Service routing is NOT an alternative — Android
|
||||
* 12+ blocks background startService from a receiver, and the AVD's
|
||||
* locked bootloader blocks the system-app escalation. The harness
|
||||
* therefore verifies "=== done" in logcat after every script and
|
||||
* re-runs on failure; scripts should stay short. */
|
||||
public static class ScriptReceiver extends BroadcastReceiver {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
String script = intent.getStringExtra("script");
|
||||
if (script == null) {
|
||||
return;
|
||||
}
|
||||
InputManager im = (InputManager)
|
||||
context.getSystemService(Context.INPUT_SERVICE);
|
||||
new Thread(() -> execute(im, script), "TouchInject").start();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
String script = intent != null ? intent.getStringExtra("script") : null;
|
||||
if (script == null) {
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
InputManager im = (InputManager) getSystemService(INPUT_SERVICE);
|
||||
new Thread(() -> execute(im, script), "TouchInject").start();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void execute(InputManager im, String script) {
|
||||
Log.i(TAG, "=== script: " + script);
|
||||
// finger number (1-based) -> [x, y]; insertion order = pointer index.
|
||||
LinkedHashMap<Integer, float[]> fingers = new LinkedHashMap<>();
|
||||
long downTime = 0;
|
||||
String[] toks = script.split("\\s+");
|
||||
int i = 0;
|
||||
while (i < toks.length) {
|
||||
String cmd = toks[i++];
|
||||
try {
|
||||
switch (cmd) {
|
||||
case "down": {
|
||||
int f = Integer.parseInt(toks[i++]);
|
||||
float x = Float.parseFloat(toks[i++]);
|
||||
float y = Float.parseFloat(toks[i++]);
|
||||
int action = fingers.isEmpty()
|
||||
? MotionEvent.ACTION_DOWN
|
||||
: MotionEvent.ACTION_POINTER_DOWN;
|
||||
int idx = fingers.size();
|
||||
fingers.put(f, new float[]{x, y});
|
||||
inject(im, action, idx, fingers, downTime);
|
||||
if (downTime == 0) {
|
||||
downTime = SystemClock.uptimeMillis();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "move": {
|
||||
int f = Integer.parseInt(toks[i++]);
|
||||
float x = Float.parseFloat(toks[i++]);
|
||||
float y = Float.parseFloat(toks[i++]);
|
||||
float[] p = fingers.get(f);
|
||||
if (p == null) {
|
||||
Log.w(TAG, "move of unknown finger " + f + " (skipped)");
|
||||
break;
|
||||
}
|
||||
p[0] = x;
|
||||
p[1] = y;
|
||||
// Generic ACTION_MOVE for all moves (1 or N pointers):
|
||||
// InputFlinger accepts it and GioView treats every
|
||||
// pointer as a MOVE.
|
||||
inject(im, MotionEvent.ACTION_MOVE, 0, fingers, downTime);
|
||||
break;
|
||||
}
|
||||
case "up": {
|
||||
int f = Integer.parseInt(toks[i++]);
|
||||
if (!fingers.containsKey(f)) {
|
||||
Log.w(TAG, "up of unknown finger " + f + " (skipped)");
|
||||
break;
|
||||
}
|
||||
boolean last = fingers.size() == 1;
|
||||
int action = last
|
||||
? MotionEvent.ACTION_UP
|
||||
: MotionEvent.ACTION_POINTER_UP;
|
||||
int idx = indexOf(fingers, f);
|
||||
// The lifted pointer must still be part of the event.
|
||||
inject(im, action, idx, fingers, downTime);
|
||||
fingers.remove(f);
|
||||
if (fingers.isEmpty()) {
|
||||
downTime = 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "wait": {
|
||||
int ms = Integer.parseInt(toks[i++]);
|
||||
Thread.sleep(ms);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
Log.e(TAG, "unknown command: " + cmd);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "script error at '" + cmd + "': " + e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "=== done");
|
||||
}
|
||||
|
||||
private static int indexOf(LinkedHashMap<Integer, float[]> m, int f) {
|
||||
int idx = 0;
|
||||
for (Integer k : m.keySet()) {
|
||||
if (k == f) {
|
||||
return idx;
|
||||
}
|
||||
idx++;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// INJECT_INPUT_EVENT_MODE_WAIT_FOR_RESULT. The SDK stub jar omits the
|
||||
// constant (and injectInputEvent itself), so both go through reflection;
|
||||
// the method is a public API at runtime on the device.
|
||||
private static final int INJECT_WAIT_FOR_RESULT = 1;
|
||||
|
||||
private static void inject(InputManager im, int action, int actionIndex,
|
||||
Map<Integer, float[]> fingers, long downTime) {
|
||||
int n = fingers.size();
|
||||
int[] ids = new int[n];
|
||||
MotionEvent.PointerCoords[] coords = new MotionEvent.PointerCoords[n];
|
||||
int j = 0;
|
||||
for (Map.Entry<Integer, float[]> e : fingers.entrySet()) {
|
||||
ids[j] = e.getKey() - 1; // pointer id (0-based)
|
||||
MotionEvent.PointerCoords c = new MotionEvent.PointerCoords();
|
||||
c.x = e.getValue()[0];
|
||||
c.y = e.getValue()[1];
|
||||
c.pressure = 1f;
|
||||
c.size = 1f;
|
||||
// TOOL_TYPE_FINGER. The compile-time android.jar lacks
|
||||
// PointerCoords.setToolType, but the device runtime (API 24+)
|
||||
// has it, so call it reflectively.
|
||||
try {
|
||||
java.lang.reflect.Method stt = MotionEvent.PointerCoords.class
|
||||
.getMethod("setToolType", int.class);
|
||||
stt.invoke(c, 1);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
coords[j] = c;
|
||||
j++;
|
||||
}
|
||||
long now = SystemClock.uptimeMillis();
|
||||
int fullAction = action == MotionEvent.ACTION_MOVE
|
||||
? action
|
||||
: action | (actionIndex << MotionEvent.ACTION_POINTER_INDEX_SHIFT);
|
||||
MotionEvent ev = MotionEvent.obtain(downTime, now, fullAction, n, ids, coords,
|
||||
0 /*edgeFlags*/, 1f /*xPrecision*/, 1f /*yPrecision*/,
|
||||
0 /*metaState*/, 0 /*deviceId*/,
|
||||
0x4000003 /*SOURCE_TOUCH|SOURCE_CLASS_MASK*/, 0 /*displayId*/);
|
||||
boolean ok = doInject(im, ev);
|
||||
Log.i(TAG, String.format("inject action=%d idx=%d n=%d ids=%s ok=%b",
|
||||
action, actionIndex, n, java.util.Arrays.toString(ids), ok));
|
||||
ev.recycle();
|
||||
}
|
||||
|
||||
private static boolean doInject(InputManager im, MotionEvent ev) {
|
||||
try {
|
||||
java.lang.reflect.Method m = InputManager.class.getMethod(
|
||||
"injectInputEvent", android.view.InputEvent.class, int.class);
|
||||
Object r = m.invoke(im, ev, INJECT_WAIT_FOR_RESULT);
|
||||
return r instanceof Boolean && (Boolean) r;
|
||||
} catch (Exception e) {
|
||||
Throwable c = e.getCause() != null ? e.getCause() : e;
|
||||
Log.e(TAG, "injectInputEvent failed: " + c);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
48
tools/touchinject/build.sh
Executable file
48
tools/touchinject/build.sh
Executable file
|
|
@ -0,0 +1,48 @@
|
|||
#!/usr/bin/env bash
|
||||
# Builds touchinject.apk (a privileged multi-touch gesture injector) from
|
||||
# Injector.java using the local Android SDK. The APK must be installed under
|
||||
# /system/priv-app for the INJECT_EVENTS (signature|privileged) permission to
|
||||
# be granted — see the bottom of this file.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
SDK=/home/gmp/android-sdk
|
||||
BT="$SDK/build-tools/35.0.0"
|
||||
PLAT="$SDK/platforms/android-35/android.jar"
|
||||
|
||||
rm -rf build
|
||||
mkdir -p build/classes
|
||||
javac -nowarn -source 1.8 -target 1.8 -classpath "$PLAT" -d build/classes Injector.java
|
||||
"$BT/d8" --release --min-api 24 --lib "$PLAT" --output build build/classes/touch/inject/*.class
|
||||
# aapt2 resolves the android: namespace against the framework resource
|
||||
# table, which is shipped inside android.jar (resources.arsc).
|
||||
"$BT/aapt2" link --manifest AndroidManifest.xml -I "$PLAT" \
|
||||
--min-sdk-version 24 --target-sdk-version 35 -o build/base.apk
|
||||
cp build/base.apk build/touchinject-unsigned.apk
|
||||
jar uf build/touchinject-unsigned.apk -C build classes.dex
|
||||
|
||||
if [ ! -f build/ts.jks ]; then
|
||||
keytool -genkeypair -keystore build/ts.jks -storepass android -keypass android \
|
||||
-alias ts -dname "CN=TouchInject, OU=Dev" -keyalg RSA -keysize 2048 -validity 10000
|
||||
fi
|
||||
"$BT/apksigner" sign --ks build/ts.jks --ks-pass pass:android --key-pass pass:android \
|
||||
--out touchinject.apk build/touchinject-unsigned.apk
|
||||
"$BT/apksigner" verify --print-certs touchinject.apk | head -1
|
||||
echo "=== DONE: $(pwd)/touchinject.apk"
|
||||
|
||||
# --- Install as a privileged system app (emulator, rooted) ---------------
|
||||
# SER=emulator-5554
|
||||
# adb -s $SER root && adb -s $SER remount
|
||||
# adb -s $SER shell mkdir -p /system/priv-app/TouchInject
|
||||
# adb -s $SER push touchinject.apk /system/priv-app/TouchInject/TouchInject.apk
|
||||
# adb -s $SER shell chmod 644 /system/priv-app/TouchInject/TouchInject.apk
|
||||
# adb -s $SER reboot # permission grant is evaluated at install/boot
|
||||
# adb -s $SER wait-for-device && adb -s $SER shell 'while [ -z $(getprop sys.boot_completed) ]; do sleep 1; done'
|
||||
# adb -s $SER shell dumpsys package touch.inject | grep -A2 INJECT_EVENTS # granted=true
|
||||
#
|
||||
# --- Inject a gesture ------------------------------------------------------
|
||||
# adb -s $SER shell am startservice -n touch.inject/.Injector \
|
||||
# -e script "down 1 400 1000 wait 100 down 2 700 1200 wait 100 \
|
||||
# move 1 380 980 move 2 720 1220 wait 50 \
|
||||
# move 1 340 940 move 2 760 1260 wait 50 up 1 up 2"
|
||||
# adb -s $SER logcat -d -s TouchInject
|
||||
128
tools/touchinject/run_tests.sh
Executable file
128
tools/touchinject/run_tests.sh
Executable file
|
|
@ -0,0 +1,128 @@
|
|||
#!/usr/bin/env bash
|
||||
# Pinch e2e test harness for the Pad emulator (aosp_atd AVD).
|
||||
#
|
||||
# The platform-signed touch.inject app (real MotionEvents through
|
||||
# InputManager — the only multi-touch path that works in the emulator)
|
||||
# drives the renderer's pinch probe; each gesture below is ONE script in
|
||||
# ONE process (the injector's finger map is per-process).
|
||||
#
|
||||
# KNOWN FLAKES (both verified, both handled below):
|
||||
# 1. The injector process is "cached" while its script thread runs and the
|
||||
# 1.5 GB emulator OOM-kills it mid-script occasionally — the script
|
||||
# never reaches "=== done". ti() checks logcat for the done line and
|
||||
# re-runs (a re-run starts a fresh gesture; a stuck pointer from a
|
||||
# half-run is replaced by the next ACTION_DOWN).
|
||||
# 2. The app is on-demand-rendering at ~1 fps: a burst of moves within
|
||||
# one frame's drain does not scroll (single-frame delta pattern).
|
||||
# Scroll tests therefore space the moves >= 40 ms apart — which is
|
||||
# also what a real finger produces over several frames.
|
||||
#
|
||||
# READING RESULTS: AppFontScale in the session file is the reliable pinch
|
||||
# signal. Scroll in the session is NOT (the restore re-derives it from the
|
||||
# pinned line anchor); for scroll tests compare the before/after
|
||||
# screenshots instead.
|
||||
SER=emulator-5554
|
||||
export ANDROID_SERIAL=$SER
|
||||
|
||||
ti() {
|
||||
local script="$1"
|
||||
for attempt in 1 2 3; do
|
||||
adb logcat -c 2>/dev/null
|
||||
adb shell 'am broadcast -n "touch.inject/.Injector$ScriptReceiver" --es script "'"$script"'"' >/dev/null 2>&1
|
||||
sleep 4
|
||||
if adb shell logcat -d 2>/dev/null | grep -aq "TouchInject: === done"; then
|
||||
return 0
|
||||
fi
|
||||
echo " (injection incomplete, attempt $attempt — retrying)"
|
||||
done
|
||||
echo " !! injection failed after 3 attempts"
|
||||
return 1
|
||||
}
|
||||
# ensure Pad is in the foreground (the ATD launcher intermittently holds
|
||||
# focus after a cold start; a backgrounded app receives no touch)
|
||||
fg() {
|
||||
for i in 1 2 3 4 5; do
|
||||
FOC=$(adb shell 'dumpsys window | grep mCurrentFocus')
|
||||
case "$FOC" in *pad.pad*) return 0;; esac
|
||||
adb shell am start -n pad.pad/org.gioui.GioActivity >/dev/null 2>&1
|
||||
sleep 8
|
||||
done
|
||||
echo " !! Pad never gained focus: $FOC"
|
||||
return 1
|
||||
}
|
||||
dbg() { adb shell "echo '$1' > /sdcard/PadPerf/cmd"; sleep 1.5; }
|
||||
shot() { adb exec-out screencap -p > "$1"; }
|
||||
# force-stop (flushes the session), print it, relaunch + reopen the file
|
||||
measure() {
|
||||
adb shell am force-stop pad.pad
|
||||
sleep 4
|
||||
echo "session: $(adb shell cat /sdcard/Pad/session.json 2>/dev/null)"
|
||||
adb shell am start -n pad.pad/org.gioui.GioActivity >/dev/null 2>&1
|
||||
sleep 4
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
t1) # single-finger scroll must NEVER change the font (device regression)
|
||||
echo "=== T1: single-finger scroll (font must not change) ==="
|
||||
fg || exit 1
|
||||
shot /tmp/t1_before.png
|
||||
S="down 1 720 1400 wait 80"
|
||||
for y in 1320 1240 1160 1080 1000 920 840 760 680 600 520 440 360 280 200; do
|
||||
S="$S move 1 720 $y wait 40"
|
||||
done
|
||||
S="$S wait 100 up 1"
|
||||
ti "$S"
|
||||
sleep 2
|
||||
shot /tmp/t1_after.png # compare to before: content moved, same font size
|
||||
measure
|
||||
;;
|
||||
t2) # two-finger pinch-out: font must scale, center anchored
|
||||
echo "=== T2: two-finger pinch-out (font must grow) ==="
|
||||
fg || exit 1
|
||||
shot /tmp/t2_before.png
|
||||
S="down 1 570 1200 wait 40 down 2 870 1200 wait 60"
|
||||
for i in 1 2 3 4 5 6 7 8; do
|
||||
x1=$((570 - i*37)); x2=$((870 + i*37))
|
||||
S="$S move 1 $x1 1200 move 2 $x2 1200 wait 50"
|
||||
done
|
||||
S="$S wait 150 up 1 up 2"
|
||||
ti "$S"
|
||||
sleep 1
|
||||
shot /tmp/t2_after.png
|
||||
measure
|
||||
;;
|
||||
t3) # palm-first: the resting finger lands FIRST, the pinch pair is the
|
||||
# two that move — the palm must never enter the distance
|
||||
echo "=== T3: palm-first three fingers (pinch pair = the two movers) ==="
|
||||
fg || exit 1
|
||||
shot /tmp/t3_before.png
|
||||
S="down 1 720 400 wait 100 down 2 570 1200 wait 60 down 3 870 1200 wait 150"
|
||||
for i in 1 2 3 4 5 6 7 8; do
|
||||
x2=$((570 - i*37)); x3=$((870 + i*37))
|
||||
S="$S move 2 $x2 1200 move 3 $x3 1200 wait 50"
|
||||
done
|
||||
S="$S wait 150 up 2 up 3 wait 50 up 1"
|
||||
ti "$S"
|
||||
sleep 1
|
||||
shot /tmp/t3_after.png
|
||||
measure
|
||||
;;
|
||||
t4) # lift one finger mid-pinch: survivor must SCROLL, not zoom
|
||||
echo "=== T4: lift finger mid-pinch (survivor scrolls, no more zoom) ==="
|
||||
fg || exit 1
|
||||
shot /tmp/t4_before.png
|
||||
S="down 1 570 1200 wait 40 down 2 870 1200 wait 60 move 1 533 1200 move 2 907 1200 wait 80 up 2 wait 80"
|
||||
for y in 1280 1360 1440 1520 1600 1680 1760 1840 1920 2000; do
|
||||
S="$S move 1 533 $y wait 40" # survivor (finger 1) keeps its own x
|
||||
done
|
||||
S="$S wait 100 up 1"
|
||||
ti "$S"
|
||||
sleep 1
|
||||
shot /tmp/t4_after.png # compare to before: content scrolled, font frozen
|
||||
measure
|
||||
;;
|
||||
reset) # back to a known state (one command per file — no pipes)
|
||||
dbg "fontsize 1"; dbg "top"
|
||||
;;
|
||||
*) echo "usage: $0 t1|t2|t3|t4|reset" ;;
|
||||
esac
|
||||
Loading…
Reference in New Issue
Block a user