Custom hotkeys: record any key or combination, with conflict detection - #21
Open
katkrasner wants to merge 1 commit into
Open
Custom hotkeys: record any key or combination, with conflict detection#21katkrasner wants to merge 1 commit into
katkrasner wants to merge 1 commit into
Conversation
The trigger was a fixed picker of nine modifiers. Settings now records whatever is pressed: a single modifier, a chord of modifiers, modifiers plus one regular key, or a bare key like F13. - HotkeyBinding: a side-aware modifier set plus an optional key, stored as JSON. Existing "hotkey"/"hotkeySecondary" preferences are read once and converted, so upgrades keep the configured key. - HotkeyMonitor tracks held modifiers and keys, resolving a modifier from the keycode of its own flagsChanged event and consulting the NX device bits only when the event carries them — virtual keyboards re-post events without those bits, where the bits alone can pin the wrong side. A bound regular key is swallowed while held so ⌃⌥D does not also type "d"; that needs Accessibility, and without it the old listen-only tap is used instead. - ShortcutRecorderField records a binding and disarms itself after six idle seconds, so a field left armed can't silently capture the next keystroke as your hotkey. The preset menu keeps the nine old choices and adds a reset. - HotkeyConflictChecker flags a binding that matches an enabled system shortcut (read live from com.apple.symbolichotkeys, so a remapped Spotlight is caught), is reserved above the event tap, duplicates the other slot, uses a repurposed fn, or is a bare character key that would stop typing. A missing Accessibility grant is called out too. Diagnostics land in ~/.fastword/hotkey.log: permission state, event tap mode, the active binding and the first modifier events, so the causes that look identical from outside can be told apart.
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.
What
The trigger was a fixed picker of nine modifiers. This makes the hotkey field record whatever you press:
⌥R,⌃L,fn⌃L⌥L,⇧R⌘R⌃⌥D,⌥SpaceF13,F18Click the field, press the combination, and it is saved on release (or as soon as a non-modifier key is hit). The
☰menu keeps the nine original presets as one-click choices plus a reset, so nothing gets slower for people who were happy with the picker.Notes on the implementation
Left and right are told apart from the keycode, not only the flags. A
flagsChangedevent names the exact key that changed. TheNX_DEVICE*KEYMASKbits are consulted only when the event actually carries them, because virtual keyboards (Karabiner and similar remapping tools) re-post events without those bits, and relying on the bits alone can pin the wrong side. This also fixes a pre-existing edge case: holding both⌥keys and releasing one previously left the trigger stuck down, sinceflags.contains(.maskAlternate)was still true.A bound regular key is swallowed while held, so
⌃⌥Ddoes not also type adinto whatever you are dictating into. This needs Accessibility; without it the tap falls back to the previous listen-only mode, where modifier-only hotkeys keep working and Settings says why.Backwards compatible. Existing
hotkey/hotkeySecondarypreferences are read once and converted, so an upgrade keeps the configured key. The legacy values are not overwritten until a new binding is saved.Conflict detection
Allowing any combination means people will pick combinations that something else already owns, and the failure mode is silent — the keys simply never arrive. So Settings warns when the recorded shortcut:
com.apple.symbolichotkeys, so a remapped Spotlight is reported correctly rather than assumed to be⌘Space.⌘⇥,⌘\``,⌥⌘⎋`) and can never arrive.fnwhile System Settings hasfnassigned to another action.A separate warning appears when Accessibility is missing and the shortcut contains a regular key.
The recorder field also disarms itself after six idle seconds. Left armed indefinitely it would swallow the next keystroke and silently save it as the hotkey, which is an easy way to lose your trigger without noticing.
Diagnostics
Writes
~/.fastword/hotkey.log: permission state, event tap mode, the active binding and the first modifier events. Four causes of "the hotkey does nothing" look identical from the outside — no permission, no event tap, keys taken by another app, binding simply not matching — and this tells them apart. Happy to drop this or put it behind a flag if you would rather not ship it.Testing
Built and run on macOS 26, Apple Silicon, with a Russian keyboard layout and Karabiner-Elements installed. Unit-checked the binding model and conflict detection (25 and 20 assertions respectively) covering side-aware modifier matching, the no-device-bits fallback, serialisation round-trips, layout-dependent key naming, and conflict detection against the live system shortcut table.
Not included here: a "test hotkey" button that confirms the keys actually reach the app. It is useful but the interaction still needs thought, so it is left for a separate PR.