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