src/events/GestureTriggerKernelSpecs.md
GestureTriggerKernel is the pure state machine for the trackpad gesture trigger: the decision,
taken while the switcher is closed, of whether the fingers on the trackpad just performed
AltTab's summon swipe. GestureTracker is its bookkeeping — where each finger of the current gesture
started, so travelled distance can be measured.
TrackpadEvents stays the adapter: it owns the event taps, maps NSTouch into GestureTouch
values, and applies the outcome (haptics, showUiOrCycleSelection, swallowing the event). Once the
switcher is open the kernel is not consulted at all — NavigationSwipeDetector handles stepping the
selection, and the kernel's state is left untouched until the session ends.
Distances are fractions of the trackpad surface, the way NSTouch.normalizedPosition reports them.
"The trackpad gesture stops working, several times a day, and only relaunching AltTab fixes it." The trigger keeps state that outlives a single gesture, and two pieces of it could hold a value that made every later swipe fail. Neither is reachable from a live QA pass, and neither left a trace in the log, so the only way to hold the behaviour still is to test the state machine directly.
Root cause — recycled touch identities. GestureTracker keys start positions by
NSTouch.identity. From NSTouch.h: "while touch identities may be re-used, they are unique during
the life of the touch". Start positions were never removed when a finger left the trackpad, so a
recycled identity inherited an unrelated finger's start position from an earlier gesture, and the
travel measured from it was meaningless — typically large, and typically off the gesture's axis.
Why it never recovered — the swipeStillPossible latch. Travelling too far across the gesture's
axis cancels the swipe until the fingers are raised (imitating the native Space swipe). That flag is
read by the entry guard before anything can recompute it, so it is a latch by design. But nothing
on the "fingers raised" path cleared it: the fingersDown <= 1 branch reset only the "another gesture
claimed these fingers" flag, and TrackpadEvents.reset deliberately skipped the trigger state
("no need to call TriggerSwipeDetector.reset; it does it itself when triggering" — it only does so on
a successful trigger). One bogus off-axis measurement therefore killed the gesture permanently.
That combination also explains the reporter's two observations: "a longer drag would sometimes
work" (a stale start position offsets the measurement, so extra travel can still clear the
threshold) and "sometimes it recovers" (a 2-finger scroll trips the requiredFingers mismatch
branch, which was the one path that did reset the trigger).
Both of these predate the extraction; the pure kernel is just where they became visible and testable.
A long off-axis wander was forgiven mid-gesture. Swipe up and down repeatedly, then horizontally,
and the switcher triggered. swipeStillPossible is meant to hold until the fingers are raised, but the
"wrong finger count" branch called a reset that cleared it along with the start positions — and that
branch is hit constantly, because a single finger pausing takes activeTouches.count off
requiredFingers. Split into rebaseTrigger (start positions only, for a pause) and resetTrigger
(also the verdict, only when the gesture ends).
userHasDoneAnotherGesture latches only on more active fingers than requiredFingers, which is
what prevents a 4→3 trigger. The mirror case is deliberately not covered: with a 3-finger gesture, a
2-finger scroll followed by a third finger does trigger the switcher, and the comment that used to sit
here claiming otherwise ("prevents 2->3 trigger") was aspirational.
Widening the condition to count != requiredFingers was implemented and reverted on 2026-08-17.
It works, but it is not safe on its own: fingers never land on the same event, so a 3-finger gesture
passes through 1 and 2 active fingers on the way in, and a finger pausing mid-swipe drops the count
again. Keeping that from spending the gesture's own session needs a per-configuration travel baseline,
and the whole apparatus buys less than it costs. testFingersArrivingOneByOneStillTrigger is the guard
that keeps the simple rule honest.
GestureTracker.prune(toTouchesDown:) drops start positions for fingers that are no longer on the
trackpad, called on every event. It keys off the touches that are down, not the ones being
measured, so a finger that pauses for one event keeps its start position.GestureTracker.isNewGesture also treats a .began touch as a new gesture, rather than inferring
newness only from whether a start position happens to be on file.fingersDown <= 1 and reset() both
go through the same path. maxFingersDownDuringTrigger is cleared with it, since it describes one
trigger.TrackpadEvents logs its two gesture taps being disabled, naming the tap and the cause, the way
KeyboardEvents already does. A dead gesture tap presents identically to this bug from the
outside, and the log used to be silent about it.Per event, while the switcher is closed:
fingersDown <= 1 — at most one finger is pointer mode, not a gesture. The gesture is over:
reset everything, ignore. (Guarded so it costs nothing on the many events where state is already
clean.)requiredFingers are active and any of
them has travelled minSwipeDistance, the user is doing a system swipe. Latches until the fingers
are raised, which prevents a 4→3 trigger. Fewer fingers deliberately don't latch; see the section
above. Ignore.activeTouches.count != requiredFingers: re-base the trigger (start
positions only) and ignore. This branch was the accidental cure for the #5137 latch, and it was also
forgiving off-axis wanders; it must not clear any verdict about the session.swipeStillPossible, a gesture already under way (the first frame
only records start positions), a non-empty set of readable distances, every touch within
maxSwipeDistanceInWrongDirection across the axis, and every touch past minSwipeDistance along
it. Then .trigger, and reset so the same gesture can't fire twice.Constants: minSwipeDistance 0.015, maxSwipeDistanceInWrongDirection 0.1.
Mirrors GestureTriggerKernelTests.swift 1:1.
GestureTracker: an identity means nothing once the finger is up.began seen → new gesture, and
distances measured from 0.1. The root-cause guard; without pruning this reads as the old touches
still moving and measures −0.4..began touch re-bases even with a start position on
file.normalizedPosition threw
(#5499) contributes no distance.swipeStillPossible latch (#5137)reset() (what TrackpadEvents.reset calls when a session
ends) clears it too; the pre-fix code deliberately skipped it..trigger.requiredFingers follows the preference.maxFingersDownDuringTrigger!= variant was reverted.