Conversation
`.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
marked this pull request as ready for review
September 24, 2026 20:00
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
Holds don't show it: every engine presses the same modifier, and
KbStatedrops 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.Eventcarries no device, so the dispatch loop inseizeInputCallbackhad 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
HidSeizeresolves the device that sent each value to its index in the seize matches. The input value callback'ssenderis theIOHIDDevice(IOKitUserhid.subproj/IOHIDDevice.cpasses 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 waymatchPredicateselects 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.devicecarries the index. Each engine slot records its rule's device index, and both loops inseizeInputCallback(the "is this some slot's source" check and the feed) skip slots bound to another device.--seize-testharness has no device on its slot and behaves as before.Verification
It needs two keyboards, each with a block-form
.remapon the same key.skhd --list-devicesprints the.deviceblocks.cat -v, which prints each escape as^[.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) andsameDevice(a rule hears only its own device; an unknown on either side hears everything).What I haven't verified
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
src/grabber/HidSeize.zig. Keeping both sets resolves it. It merges cleanly with agent: re-apply remaps and re-forward grabber rules when a keyboard re-enumerates #58.