Tap, Hold, or Something Else Entirely
The Cherry MX Brown under my left pinky travels 2mm before actuating. That’s straightforward — contact closure, signal on column 3 row 2, the letter ‘A’ gets reported to the host. But I’ve mapped that same key to emit ‘A’ on tap and Ctrl on hold. Now the firmware has a problem: when the switch closes, it doesn’t know which outcome I want. It has to wait.
QMK’s tap-dance feature implements this as a finite state machine. Each key starts in an IDLE state. On press, it transitions to PRESSED and starts a timer. If I release before the threshold (usually 200ms), it transitions through TAPPED and emits the tap action. If I hold past the threshold, it transitions to HELD and emits the hold action. Double-tap? That’s another path through the state graph — release-within-threshold followed by another press resets the timer and counts the taps.
Here’s a minimal state machine in Scheme that distinguishes tap from hold:
(define (key-fsm state event time)
(let ((mode (car state))
(started (cadr state)))
(case mode
((idle)
(if (eq? event 'press) (list 'pressed time) (list 'idle 0)))
((pressed)
(cond ((and (eq? event 'release) (< (- time started) 200))
(display "tap\n")
(list 'idle 0))
((or (and (eq? event 'release) (>= (- time started) 200))
(>= (- time started) 200))
(display "hold\n")
(if (eq? event 'release) (list 'idle 0) (list 'holding started)))
(else state)))
((holding)
(if (eq? event 'release) (list 'idle 0) state)))))
;; Simulation: press at t=0, release at t=150 → tap
(define s (key-fsm '(idle 0) 'press 0))
(set! s (key-fsm s 'release 150)) ; prints: tap
The Java version makes the states explicit as an enum, which maps more directly to how embedded C handles this:
enum KeyState { IDLE, PRESSED, HOLDING }
class TapHoldKey {
KeyState state = KeyState.IDLE;
long pressTime;
void update(String event, long now) {
switch (state) {
case IDLE -> { if (event.equals("press")) { state = KeyState.PRESSED; pressTime = now; }}
case PRESSED -> {
if (event.equals("release")) {
System.out.println(now - pressTime < 200 ? "tap" : "hold");
state = KeyState.IDLE;
} else if (now - pressTime >= 200) {
System.out.println("hold");
state = KeyState.HOLDING;
}
}
case HOLDING -> { if (event.equals("release")) state = KeyState.IDLE; }
}
}
}
Moore and Mealy formalized state machines in the mid-1950s. By the 1960s, they were the standard model for sequential logic in hardware design. The QMK codebase uses the same abstraction six decades later — each key’s state lives in an array, and the main loop ticks through transitions on every matrix scan.
I’ve got the sampler pack of switches on the bench, sixty tiny state machines of a different kind. Each one implements a simpler automaton: open → actuated → open. But stack the firmware on top, and suddenly that mechanical transition feeds into a temporal pattern recogniser that has to guess my intent from timing alone. The switch doesn’t know if I’m tapping or holding. Neither does the firmware — not immediately. It waits, observes, and commits only when the evidence is sufficient.
I set my tap threshold to 185ms after testing. Any faster and I get false holds when I’m typing quickly. Any slower and intentional holds feel laggy. The state machine doesn’t care about my preferences. It just follows the graph.