Skip to content

feat: custom keyboard/mouse hotkeys via low-level hooks - #13

Merged
DavidHruby1 merged 13 commits into
DavidHruby1:mainfrom
KristianP26:feat/custom-hotkeys
Jun 4, 2026
Merged

DavidHruby1 merged 13 commits into
DavidHruby1:mainfrom
KristianP26:feat/custom-hotkeys

Conversation

@KristianP26

Copy link
Copy Markdown
Contributor

Summary

Adds custom hotkeys: bind any keyboard key/combination or a mouse side/middle button as the push-to-talk trigger, via a press-to-record UI, alongside the existing presets.

The Win32 RegisterHotKey listener is replaced with global low-level hooks (WH_KEYBOARD_LL + WH_MOUSE_LL), which unlocks arbitrary keys + mouse buttons, native press/release detection (no GetAsyncKeyState polling), and swallowing the matched trigger so it doesn't reach the app underneath.

What changed

  • config.py — new pure Hotkey value object (modifiers + one key/mouse trigger), serialized to a single canonical string (ctrl+alt+key:0x20, ctrl+mouse:x1). Methods: to_canonical, to_label, validate, parse. The 7 legacy preset strings auto-migrate on load. Presets in HOTKEY_OPTIONS switched to canonical values. Removed the obsolete RegisterHotKey artifacts (MOD_*, HotkeyBinding, HOTKEY_BINDINGS).
  • hotkey.pyHotkeyListener rewritten to install LL keyboard + mouse hooks. The matching core (_on_kb_event/_on_mouse_event) is pure and OS-independent, so it is unit-tested without Win32.
  • settings_dialog.pyHotkeyCaptureEdit press-to-record widget (Qt key/mouse events; Esc cancels), presets combo + "Custom…" entry, live validation feedback.
  • main.py — parses the stored canonical string into a Hotkey when building the listener.
  • utils.pyHOTKEY_INVALID / HOTKEY_HOOK_FAILED error codes.

Safety rules (validation)

  • Bare everyday keys (letters, Space) require a modifier.
  • Function keys, Scroll Lock/Pause/Insert/PrintScreen/Menu/NumLock, and mouse side/middle buttons may bind on their own.
  • Left/right mouse buttons and modifier-only triggers are rejected.

Tests

New: test_hotkey_model.py, test_hotkey_listener.py, test_settings_hotkey.py. Updated: test_mappings.py, test_tray_menu.py. Full suite: 60 passing.

python -m compileall src/
python -m unittest discover -s tests

@DavidHruby1 DavidHruby1 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  1. P1: HotkeyListener.stop() can miss shutdown and leak active hooks/threads.
    src/hotkey.py:87-98, src/hotkey.py:224-238
    start() returns before the hook thread has definitely created a message queue and installed hooks. stop() only posts WM_QUIT if _thread_id is already set, ignores PostThreadMessageW failure, then sets _thread = None after a timed join even if the thread is still alive. If stop happens during startup or before the queue accepts thread messages, the listener can remain blocked in GetMessageW with hooks installed. This can happen on quick shutdown or settings changes. Needs a ready event/message-queue synchronization, PostThreadMessageW result handling/retry, and should not clear _thread unless the thread actually exited.
  2. P2: Mouse hotkey recording is not actually global within the dialog.
    src/settings_dialog.py:675-712
    HotkeyCaptureEdit.start_recording() calls grabKeyboard() but never grabs mouse input. mousePressEvent() will only see side/middle button presses delivered to that QLineEdit, so users likely must hover/click directly over the capture field despite the UI saying "press keys or a mouse button". Use grabMouse()/releaseMouse() or an event filter on the dialog/application while recording, and cover it with a test.

Merge Readiness
Not ready to merge. CI is green and the overall design is reasonable, but the listener shutdown race is a merge blocker because it can leave duplicate/global hooks running.

Checks Observed
PR #13 is open, non-draft, mergeable, and CI passes: compile (ubuntu / py3.12), test (windows / py3.11), test (windows / py3.12).

@KristianP26
KristianP26 force-pushed the feat/custom-hotkeys branch from e45330c to eb8c173 Compare June 4, 2026 10:28
@KristianP26

Copy link
Copy Markdown
Contributor Author

Thanks for the review — both findings addressed, and the branch is rebased onto main.

P1 — HotkeyListener start/stop shutdown race

  • Added a threading.Event readiness handshake: the loop thread force-creates its message queue (PeekMessageW) and attempts hook install before setting _ready. start() now blocks on _ready (so the listener is live on return), and stop() waits on it (so WM_QUIT can't be dropped during a startup race).
  • stop() now handles the PostThreadMessageW result with a retry loop, and only clears _thread/_thread_id once the thread has actually exited — otherwise it logs an error and keeps the reference (no false "stopped").
  • Covered by Windows lifecycle tests (test_start_then_stop_exits_cleanly, test_repeated_start_stop, test_stop_without_start_is_safe) that run on the windows-latest CI job.

P2 — mouse capture not global in the dialog

  • HotkeyCaptureEdit.start_recording() now calls grabMouse() (and releaseMouse() on stop) alongside the keyboard grab, so side/middle-button presses are captured anywhere while recording. Covered by a grab/release test plus a synthetic side-button capture test.

CI is green (compile + Windows tests).

@DavidHruby1 DavidHruby1 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous findings are addressed. CI is green and this is ready to merge.

@DavidHruby1
DavidHruby1 merged commit c32228a into DavidHruby1:main Jun 4, 2026
3 checks passed
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.

2 participants