Skip to content

grabber: route tap-hold events only to the sending device's rules - #59

Open
bowdi wants to merge 1 commit into
jackielii:mainfrom
bowdi:bugfix/taphold-route-by-device
Open

bowdi wants to merge 1 commit into
jackielii:mainfrom
bowdi:bugfix/taphold-route-by-device

Conversation

@bowdi

@bowdi bowdi commented Sep 24, 2026 •

Copy link
Copy Markdown

The bug

.remap <key> [device X] { … } rules are per device, but the grabber runs every tap-hold engine whose source key matches, whichever keyboard sent the event. With the same key remapped on two keyboards, one tap runs both engines, and each emits the tap key.

With a config like this and all three keyboards connected, a single caps tap types escape once per rule:

.remap caps_lock [device builtin] {
    tap  : escape
    hold : lctrl
}
.remap caps_lock [device bolt] {
    tap  : escape
    hold : lctrl
}
.remap caps_lock [device unifying] {
    tap  : escape
    hold : lctrl
}

Holds don't show it: every engine presses the same modifier, and KbState drops a press of a key that's already down. Taps do, because each engine finishes its own down and up before the next one runs.

HidSeize.Event carries no device, so the dispatch loop in seizeInputCallback had nothing to filter on. It has worked this way since the loop was written; a config with one rule per key never notices.

The fix

  • HidSeize resolves the device that sent each value to its index in the seize matches. The input value callback's sender is the IOHIDDevice (IOKitUser hid.subproj/IOHIDDevice.c passes the device through). A FIFO built-in reports no VendorID/ProductID, so it reads as 0/0 and resolves to the (0,0) alias, the same way matchPredicate selects it. The index is cached per device in a fixed table, filled on the device's first value and emptied on removal, so a keystroke costs a pointer compare and no allocation.
  • Event.device carries the index. Each engine slot records its rule's device index, and both loops in seizeInputCallback (the "is this some slot's source" check and the feed) skip slots bound to another device.
  • An event whose device can't be resolved still reaches every slot, as before, so an unexpected device keeps working rather than going silent. The --seize-test harness has no device on its slot and behaves as before.

Verification

It needs two keyboards, each with a block-form .remap on the same key. skhd --list-devices prints the .device blocks.

  1. Open a terminal and run cat -v, which prints each escape as ^[.
  2. Tap the remapped key once on one keyboard, then once on the other.

On Apple Silicon, macOS 26.6.2, with caps_lock tap-hold rules on all three keyboards (tap escape; the hold side doesn't affect a tap): before this change a caps tap produced more than one escape. With this branch and all three rules active, running the steps above, each tap produces one.

The pure parts have unit tests: matchIndex (a 0/0 built-in, exact VID/PID, an unknown device) and sameDevice (a rule hears only its own device; an unknown on either side hears everything).

What I haven't verified

  • Two keyboards with the same VendorID/ProductID resolve to the same match, so their rules can't be told apart. That's already true of the seize match itself.
  • Cross-device hold arbitration (arbitrateHoldCommit) still considers every slot. A layer-hold pending on one keyboard is still forced by a hold committing on another. I left it as is; it's a separate question from which engine a key reaches.

Notes

`.remap <key> [device X] { … }` rules are per device, but the grabber
fed every seized key event to every engine with a matching source key,
whichever keyboard sent it. With the same key remapped on two keyboards,
one tap ran both engines and each emitted the tap key, so a caps_lock
rule on three keyboards typed its tap key three times.

HidSeize now resolves the sending IOHIDDevice (the input value
callback's sender) to its index in the seize matches, caches it per
device, and carries it on the event. Each engine slot records its rule's
device index and only sees events from that device. An event whose
device can't be resolved still reaches every slot, as before.
@bowdi
bowdi marked this pull request as ready for review September 24, 2026 20:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant