src/switcher/KeyRepeatTimerSpecs.md
KeyRepeatTimerTestable.shouldApplyArtificialRepeat is the pure decision kernel for the artificial
key-repeat (hold-to-cycle for modifier-only shortcuts). KeyRepeatTimer owns the DispatchSource
timer; on each tick it asks this kernel whether the tick should actually advance the selection.
The artificial repeat exists because a modifier-only shortcut (e.g. ⌥⇥ where ⇥ has no keycode of its
own once ⌥ is the hold) gets no OS key-repeat, so AltTab synthesizes one. The timer is armed at
show-invoke time with the user's InitialKeyRepeat delay + KeyRepeat rate.
When the user summons the switcher right after a fullscreen/Space transition, the WindowServer is busy
settling the transition and can't paint AltTab's .canJoinAllSpaces panel until it finishes — the
panel's pixels can land ~500ms after the timer was armed. The timer's background DispatchSource
keeps ticking through that gap; the queued ticks then all fire the instant the panel appears and jump
the selection several tiles, with no input from the user (they'd pressed once and were waiting for the
switcher to show).
So the initial-delay grace must be measured from when the panel was actually visible, not from arm time. A fast, normal summon is unaffected (visible within ~tens of ms of arming).
Anchoring only on SwitcherSession.panelBecameVisibleAt — our own panel's WindowServer orderedIn —
made the fallback the NORMAL path, because that notification never arrives. Order-in is only delivered
for wids in the per-window opt-in set (WindowServerEvents.wsWindows, mandatory since Sequoia), and the
panel is not in it. So every tick fell through and hold-to-cycle started at
armedAt + initialDelay + 1s: measured 1377ms against a system InitialKeyRepeat of 417ms, on
every hold, felt by every user. (Carbon hotkeys fire once per press and ignore auto-repeat keyDowns —
verified in the log: 14 auto-repeat keyDowns produced exactly one state:down — so the artificial repeat
is the only thing that can advance the selection while the key is held.)
The panel was NOT added to the opt-in set to fix this: that would feed our own panel's order/geometry
events into the reducer as inputs for an untracked wid, which is a real risk for a timing nicety.
Instead TilesPanel.show() sets SwitcherSession.panelShownAt as it orders the panel front — an anchor
that cannot go missing. It is slightly EARLY (the WindowServer paints after the order-front returns), so
the visible signal stays above it as the correction rather than replacing it, and the tradeoff is stated
plainly: on a genuinely slow show the grace now starts at the order-front instead of at the paint, which
can let ONE advance land early. That is the price of not being a second late on every ordinary summon.
The visibility anchor covers a stall BEFORE the panel is presented. A stall after it does the same damage,
and the anchor cannot see it: panelShownAt is set as the panel is ordered front, while the expensive part
of a summon (thumbnails for every window, the discovery and Space passes an idle app has to redo cold) runs
after. Reported live as: the first alt-tab after 5-10 idle minutes opens with the selection ~8 tiles into the
list instead of on the previous window, whatever the list length — a count set by elapsed time and the repeat
rate, not by the window set.
Two things made a backlog possible:
now, which by then satisfied the grace.Both are answered:
KeyRepeat).This was masked until v11.4.4: with no anchor at all, every tick took the initialDelay + 1s fallback, which
swallowed backlogs shorter than ~1.4s.
Inputs are systemUptime timestamps. firedAt is when the timer fired, now when the main thread got to it.
now - firedAt >= max(repeatRate, 20ms) → refuse, whatever the anchors say.panelBecameVisibleAt ?? panelShownAt) → apply iff now - anchor >= initialDelay.
The WindowServer's visible timestamp wins when present, being the true pixels-on-screen moment; the show
timestamp is the guaranteed stand-in.initialDelay + missedVisibleSignalBudget (1s), so a missing anchor can never wedge hold-to-cycle
permanently. A genuine safety net now, rather than the path every tick took.The late budget is floored at 20ms because defaults write -g KeyRepeat 0 is legal, and a zero-length budget
would refuse every tick.
Mirrors KeyRepeatTimerTests.swift 1:1.
panelShownAt, not from arm + 1s.>=).initialDelay + 1s.>= 0.4 + 1), so a missed visible signal doesn't wedge hold-to-cycle.KeyRepeat tolerates a proportionally longer wait.KeyRepeat 0 working.