Skip to content

Custom hotkeys: record any key or combination, with conflict detection - #21

Open
katkrasner wants to merge 1 commit into
VasenevEA:mainfrom
katkrasner:custom-hotkeys
Open

Custom hotkeys: record any key or combination, with conflict detection#21
katkrasner wants to merge 1 commit into
VasenevEA:mainfrom
katkrasner:custom-hotkeys

Conversation

@katkrasner

Copy link
Copy Markdown

What

The trigger was a fixed picker of nine modifiers. This makes the hotkey field record whatever you press:

  • a single modifier — ⌥R, ⌃L, fn
  • a chord of modifiers — ⌃L⌥L, ⇧R⌘R
  • modifiers plus a regular key — ⌃⌥D, ⌥Space
  • a bare key — F13, F18

Click 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 flagsChanged event names the exact key that changed. The NX_DEVICE*KEYMASK bits 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, since flags.contains(.maskAlternate) was still true.

A bound regular key is swallowed while held, so ⌃⌥D does not also type a d into 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 / hotkeySecondary preferences 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:

  • matches an enabled macOS shortcut. Read live from com.apple.symbolichotkeys, so a remapped Spotlight is reported correctly rather than assumed to be ⌘Space.
  • is reserved above the event tap (⌘⇥, ⌘\``, ⌥⌘⎋`) and can never arrive.
  • duplicates FastWord's own second key.
  • uses fn while System Settings has fn assigned to another action.
  • is a bare character key, which would stop that character typing anywhere.

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.

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.
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