From 7179bcb33f28542a13ef458d7a0efadfa0fa1a19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristi=C3=A1n=20Partl?= Date: Thu, 4 Jun 2026 08:41:17 +0200 Subject: [PATCH 01/12] feat(hotkey): add invalid/hook-failed error codes --- src/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/utils.py b/src/utils.py index a7382b1..e7110ad 100644 --- a/src/utils.py +++ b/src/utils.py @@ -26,6 +26,8 @@ class AppError(Enum): NO_SPEECH = "No speech detected. Try speaking louder or closer." INJECTION_FAILED = "Could not type text. Focus may have changed." HOTKEY_CONFLICT = "Hotkey conflict. Choose a different hotkey." + HOTKEY_INVALID = "That key combination can't be used. Add a modifier or pick another key." + HOTKEY_HOOK_FAILED = "Could not install the global hotkey listener." UNSUPPORTED_PLATFORM = "This feature is only available on Windows." KEY_STORAGE_FAILED = "Could not save or load API keys securely." STARTUP_REGISTRATION_FAILED = "Could not update Windows startup setting." From 1ea25d6c31ef48e340d0c9b5142ff9ad96168346 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristi=C3=A1n=20Partl?= Date: Thu, 4 Jun 2026 08:42:32 +0200 Subject: [PATCH 02/12] feat(hotkey): add Hotkey value object with parse/label/validate --- src/config.py | 169 +++++++++++++++++++++++++++++++------ tests/test_hotkey_model.py | 71 ++++++++++++++++ 2 files changed, 213 insertions(+), 27 deletions(-) create mode 100644 tests/test_hotkey_model.py diff --git a/src/config.py b/src/config.py index 4dad3ea..a1620fc 100644 --- a/src/config.py +++ b/src/config.py @@ -37,20 +37,16 @@ DEFAULT_RMS_THRESHOLD = 5.0 -MOD_CONTROL = 0x0002 -MOD_ALT = 0x0001 -MOD_SHIFT = 0x0004 -MOD_NOREPEAT = 0x4000 - -# App-level options shared by settings and tray menus. +# App-level options shared by settings and tray menus. Values are canonical +# Hotkey strings (see Hotkey.to_canonical); labels come from Hotkey.to_label. HOTKEY_OPTIONS: list[tuple[str, str]] = [ - ("ctrl_alt_space", "Ctrl+Alt+Space"), - ("ctrl_shift_space", "Ctrl+Shift+Space"), - ("ctrl_alt_d", "Ctrl+Alt+D"), - ("ctrl_alt_s", "Ctrl+Alt+S"), - ("ctrl_alt_v", "Ctrl+Alt+V"), - ("scroll_lock", "Scroll Lock"), - ("pause", "Pause"), + ("ctrl+alt+key:0x20", "Ctrl+Alt+Space"), + ("ctrl+shift+key:0x20", "Ctrl+Shift+Space"), + ("ctrl+alt+key:0x44", "Ctrl+Alt+D"), + ("ctrl+alt+key:0x53", "Ctrl+Alt+S"), + ("ctrl+alt+key:0x56", "Ctrl+Alt+V"), + ("key:0x91", "Scroll Lock"), + ("key:0x13", "Pause"), ] POST_KEY_OPTIONS: list[tuple[str, str]] = [ @@ -62,23 +58,142 @@ ] -@dataclass(frozen=True) -class HotkeyBinding: - modifiers: int - vk: int - - -HOTKEY_BINDINGS: dict[str, HotkeyBinding] = { - "ctrl_alt_space": HotkeyBinding(MOD_CONTROL | MOD_ALT | MOD_NOREPEAT, 0x20), - "ctrl_shift_space": HotkeyBinding(MOD_CONTROL | MOD_SHIFT | MOD_NOREPEAT, 0x20), - "ctrl_alt_d": HotkeyBinding(MOD_CONTROL | MOD_ALT | MOD_NOREPEAT, 0x44), - "ctrl_alt_s": HotkeyBinding(MOD_CONTROL | MOD_ALT | MOD_NOREPEAT, 0x53), - "ctrl_alt_v": HotkeyBinding(MOD_CONTROL | MOD_ALT | MOD_NOREPEAT, 0x56), - "scroll_lock": HotkeyBinding(MOD_NOREPEAT, 0x91), - "pause": HotkeyBinding(MOD_NOREPEAT, 0x13), +# Mouse trigger ids (our own discriminators, not Win32 constants). +MOUSE_X1 = 1 # "back" side button (XBUTTON1) +MOUSE_X2 = 2 # "forward" side button (XBUTTON2) +MOUSE_MIDDLE = 3 # middle / wheel button + +_MOUSE_TOKEN_TO_CODE = {"x1": MOUSE_X1, "x2": MOUSE_X2, "middle": MOUSE_MIDDLE} +_MOUSE_CODE_TO_TOKEN = {v: k for k, v in _MOUSE_TOKEN_TO_CODE.items()} +_MOUSE_CODE_TO_LABEL = {MOUSE_X1: "Mouse Back", MOUSE_X2: "Mouse Forward", MOUSE_MIDDLE: "Mouse Middle"} + +# Canonical modifier order for serialization/labels. +_MOD_ORDER = ("ctrl", "alt", "shift", "win") +_MOD_LABEL = {"ctrl": "Ctrl", "alt": "Alt", "shift": "Shift", "win": "Win"} + +# Win32 virtual-key codes that ARE modifiers (generic + L/R variants). +# A trigger key may never be one of these. +MODIFIER_VKS = frozenset({0x10, 0x11, 0x12, 0x5B, 0x5C, 0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5}) + +# Map a modifier VK (as reported by the LL keyboard hook) to its canonical name. +MODIFIER_VK_TO_NAME = { + 0x10: "shift", 0xA0: "shift", 0xA1: "shift", + 0x11: "ctrl", 0xA2: "ctrl", 0xA3: "ctrl", + 0x12: "alt", 0xA4: "alt", 0xA5: "alt", + 0x5B: "win", 0x5C: "win", +} + +# Keys safe to bind alone (won't eat normal typing / clicking). +SAFE_STANDALONE_KEYS = frozenset( + set(range(0x70, 0x88)) # F1..F24 + | {0x91, # Scroll Lock + 0x13, # Pause + 0x2D, # Insert + 0x2C, # PrintScreen + 0x5D, # Apps / Menu + 0x90} # Num Lock +) + +# Human-readable names for common VK codes (labels only). +_VK_NAMES = { + 0x08: "Backspace", 0x09: "Tab", 0x0D: "Enter", 0x13: "Pause", + 0x1B: "Esc", 0x20: "Space", 0x21: "Page Up", 0x22: "Page Down", + 0x23: "End", 0x24: "Home", 0x25: "Left", 0x26: "Up", 0x27: "Right", + 0x28: "Down", 0x2C: "PrintScreen", 0x2D: "Insert", 0x2E: "Delete", + 0x5D: "Menu", 0x90: "Num Lock", 0x91: "Scroll Lock", +} +_VK_NAMES.update({c: chr(c) for c in range(0x30, 0x3A)}) # 0-9 +_VK_NAMES.update({c: chr(c) for c in range(0x41, 0x5B)}) # A-Z +_VK_NAMES.update({0x70 + i: f"F{i + 1}" for i in range(24)}) # F1..F24 + + +def _vk_label(vk: int) -> str: + return _VK_NAMES.get(vk, f"Key 0x{vk:02X}") + + +# Legacy preset keys (pre-custom-hotkey format) -> (modifier string, VK code). +_LEGACY_HOTKEYS = { + "ctrl_alt_space": ("ctrl+alt", 0x20), + "ctrl_shift_space": ("ctrl+shift", 0x20), + "ctrl_alt_d": ("ctrl+alt", 0x44), + "ctrl_alt_s": ("ctrl+alt", 0x53), + "ctrl_alt_v": ("ctrl+alt", 0x56), + "scroll_lock": ("", 0x91), + "pause": ("", 0x13), } +@dataclass(frozen=True) +class Hotkey: + """A push-to-talk binding: a set of modifiers + a single key or mouse trigger. + + ``mods`` is a subset of {"ctrl","alt","shift","win"}. ``kind`` is "key" or + "mouse". ``code`` is a Win32 virtual-key code (kind="key") or one of the + ``MOUSE_*`` ids (kind="mouse"). + """ + + mods: frozenset + kind: str + code: int + + def to_canonical(self) -> str: + prefix = "".join(f"{m}+" for m in _MOD_ORDER if m in self.mods) + if self.kind == "mouse": + token = _MOUSE_CODE_TO_TOKEN.get(self.code, str(self.code)) + return f"{prefix}mouse:{token}" + return f"{prefix}key:0x{self.code:02X}" + + def to_label(self) -> str: + prefix = "".join(f"{_MOD_LABEL[m]}+" for m in _MOD_ORDER if m in self.mods) + if self.kind == "mouse": + return prefix + _MOUSE_CODE_TO_LABEL.get(self.code, f"Mouse {self.code}") + return prefix + _vk_label(self.code) + + def validate(self) -> str | None: + """Return an error message if this binding is unsafe, else None.""" + if self.kind == "mouse": + if self.code not in _MOUSE_CODE_TO_TOKEN: + return "Only the side or middle mouse buttons can be used." + return None + if self.code in MODIFIER_VKS: + return "Pick a non-modifier key, then add Ctrl/Alt/Shift as modifiers." + if self.code in SAFE_STANDALONE_KEYS: + return None + if not self.mods: + return "Add a modifier (Ctrl/Alt/Shift) or choose a function key." + return None + + @classmethod + def parse(cls, value: str) -> "Hotkey | None": + """Parse a canonical string or a legacy preset key. None if invalid.""" + if not value: + return None + if value in _LEGACY_HOTKEYS: + mod_str, code = _LEGACY_HOTKEYS[value] + mods = frozenset(p for p in mod_str.split("+") if p) + return cls(mods, "key", code) + + parts = value.split("+") + trigger = parts[-1] + mod_parts = parts[:-1] + if any(m not in _MOD_ORDER for m in mod_parts): + return None + mods = frozenset(mod_parts) + + if trigger.startswith("mouse:"): + token = trigger[len("mouse:"):] + if token not in _MOUSE_TOKEN_TO_CODE: + return None + return cls(mods, "mouse", _MOUSE_TOKEN_TO_CODE[token]) + if trigger.startswith("key:"): + try: + code = int(trigger[len("key:"):], 16) + except ValueError: + return None + return cls(mods, "key", code) + return None + + @dataclass(frozen=True) class ProviderConfig: api_key: str = "" diff --git a/tests/test_hotkey_model.py b/tests/test_hotkey_model.py new file mode 100644 index 0000000..586bf2d --- /dev/null +++ b/tests/test_hotkey_model.py @@ -0,0 +1,71 @@ +import unittest + +from src.config import ( + Hotkey, + MOUSE_X1, + MOUSE_X2, + MOUSE_MIDDLE, +) + + +class HotkeyModelTests(unittest.TestCase): + def test_canonical_roundtrip_key_with_mods(self): + hk = Hotkey(frozenset({"ctrl", "alt"}), "key", 0x20) + self.assertEqual(hk.to_canonical(), "ctrl+alt+key:0x20") + self.assertEqual(Hotkey.parse("ctrl+alt+key:0x20"), hk) + + def test_canonical_orders_mods_consistently(self): + a = Hotkey(frozenset({"alt", "ctrl"}), "key", 0x44) + b = Hotkey(frozenset({"ctrl", "alt"}), "key", 0x44) + self.assertEqual(a.to_canonical(), b.to_canonical()) + self.assertEqual(a.to_canonical(), "ctrl+alt+key:0x44") + + def test_canonical_roundtrip_bare_key(self): + hk = Hotkey(frozenset(), "key", 0x91) # Scroll Lock + self.assertEqual(hk.to_canonical(), "key:0x91") + self.assertEqual(Hotkey.parse("key:0x91"), hk) + + def test_canonical_roundtrip_mouse(self): + hk = Hotkey(frozenset({"ctrl"}), "mouse", MOUSE_X1) + self.assertEqual(hk.to_canonical(), "ctrl+mouse:x1") + self.assertEqual(Hotkey.parse("ctrl+mouse:x1"), hk) + self.assertEqual(Hotkey.parse("mouse:x2"), Hotkey(frozenset(), "mouse", MOUSE_X2)) + self.assertEqual(Hotkey.parse("mouse:middle"), Hotkey(frozenset(), "mouse", MOUSE_MIDDLE)) + + def test_parse_legacy_keys_migrate(self): + self.assertEqual(Hotkey.parse("ctrl_alt_space"), Hotkey(frozenset({"ctrl", "alt"}), "key", 0x20)) + self.assertEqual(Hotkey.parse("scroll_lock"), Hotkey(frozenset(), "key", 0x91)) + self.assertEqual(Hotkey.parse("pause"), Hotkey(frozenset(), "key", 0x13)) + + def test_parse_invalid_returns_none(self): + self.assertIsNone(Hotkey.parse("")) + self.assertIsNone(Hotkey.parse("garbage")) + self.assertIsNone(Hotkey.parse("ctrl+mouse:x9")) + + def test_label_human_readable(self): + self.assertEqual(Hotkey(frozenset({"ctrl", "alt"}), "key", 0x20).to_label(), "Ctrl+Alt+Space") + self.assertEqual(Hotkey(frozenset(), "key", 0x91).to_label(), "Scroll Lock") + self.assertEqual(Hotkey(frozenset({"ctrl"}), "mouse", MOUSE_X1).to_label(), "Ctrl+Mouse Back") + self.assertEqual(Hotkey(frozenset(), "mouse", MOUSE_MIDDLE).to_label(), "Mouse Middle") + + def test_validate_ok_with_modifier(self): + self.assertIsNone(Hotkey(frozenset({"ctrl", "alt"}), "key", 0x20).validate()) + + def test_validate_ok_safe_standalone(self): + self.assertIsNone(Hotkey(frozenset(), "key", 0x91).validate()) # Scroll Lock + self.assertIsNone(Hotkey(frozenset(), "key", 0x70).validate()) # F1 + self.assertIsNone(Hotkey(frozenset(), "mouse", MOUSE_X1).validate()) + + def test_validate_rejects_bare_normal_key(self): + self.assertIsNotNone(Hotkey(frozenset(), "key", 0x20).validate()) # bare Space + self.assertIsNotNone(Hotkey(frozenset(), "key", 0x41).validate()) # bare A + + def test_validate_rejects_modifier_as_trigger(self): + self.assertIsNotNone(Hotkey(frozenset({"ctrl"}), "key", 0x11).validate()) # trigger is Ctrl + + def test_validate_rejects_unknown_mouse_button(self): + self.assertIsNotNone(Hotkey(frozenset(), "mouse", 99).validate()) + + +if __name__ == "__main__": + unittest.main() From 2c64d89c54b0002001ffa343af0cf12ce11796cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristi=C3=A1n=20Partl?= Date: Thu, 4 Jun 2026 08:43:22 +0200 Subject: [PATCH 03/12] feat(hotkey): store hotkeys as canonical strings with legacy migration --- src/config.py | 14 +++++++++----- tests/test_mappings.py | 44 +++++++++++++++++++----------------------- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/config.py b/src/config.py index a1620fc..3c947f3 100644 --- a/src/config.py +++ b/src/config.py @@ -232,7 +232,7 @@ class ConfigValidationIssue: @dataclass class AppConfig: - hotkey: str = "ctrl_alt_space" + hotkey: str = "ctrl+alt+key:0x20" recording_mode: str = "hold" # "hold" | "toggle" post_type_key: str = "none" # "none" | "enter" | "tab" | "space" | "backspace" start_with_windows: bool = False @@ -467,8 +467,11 @@ def load_config() -> AppConfig: setattr(cfg, key, val) _load_secrets(cfg) - if cfg.hotkey not in HOTKEY_BINDINGS: - cfg.hotkey = "ctrl_alt_space" + parsed_hotkey = Hotkey.parse(cfg.hotkey) + if parsed_hotkey is None or parsed_hotkey.validate() is not None: + cfg.hotkey = "ctrl+alt+key:0x20" + else: + cfg.hotkey = parsed_hotkey.to_canonical() if cfg.post_type_key not in {key for key, _label in POST_KEY_OPTIONS}: cfg.post_type_key = "none" return cfg @@ -506,8 +509,9 @@ def validate_config(cfg: AppConfig) -> list[ConfigValidationIssue]: """Return all startup/settings validation issues for the current config.""" issues: list[ConfigValidationIssue] = [] - if cfg.hotkey not in HOTKEY_BINDINGS: - issues.append(ConfigValidationIssue("Choose a supported global hotkey.", 0)) + parsed_hotkey = Hotkey.parse(cfg.hotkey) + if parsed_hotkey is None or parsed_hotkey.validate() is not None: + issues.append(ConfigValidationIssue("Choose a valid global hotkey.", 0)) stt = cfg.stt_provider() stt_fallback = cfg.stt_fallback_provider() diff --git a/tests/test_mappings.py b/tests/test_mappings.py index d55d9ea..f06b33d 100644 --- a/tests/test_mappings.py +++ b/tests/test_mappings.py @@ -1,37 +1,33 @@ import unittest from src.config import ( - HOTKEY_BINDINGS, HOTKEY_OPTIONS, - MOD_ALT, - MOD_CONTROL, - MOD_NOREPEAT, POST_KEY_OPTIONS, AppConfig, + Hotkey, ) class MappingTests(unittest.TestCase): - def test_hotkey_options_do_not_offer_bare_modifiers(self) -> None: - option_keys = {key for key, _label in HOTKEY_OPTIONS} - - self.assertNotIn("ctrl", option_keys) - self.assertNotIn("alt", option_keys) - self.assertNotIn("f13", option_keys) - self.assertNotIn("f14", option_keys) - - def test_default_hotkey_is_laptop_friendly(self) -> None: - self.assertEqual(AppConfig().hotkey, "ctrl_alt_space") - self.assertEqual(HOTKEY_OPTIONS[0], ("ctrl_alt_space", "Ctrl+Alt+Space")) - - def test_hotkey_options_have_bindings_with_no_repeat(self) -> None: - for key, _label in HOTKEY_OPTIONS: - self.assertIn(key, HOTKEY_BINDINGS) - self.assertTrue(HOTKEY_BINDINGS[key].modifiers & MOD_NOREPEAT) - - default = HOTKEY_BINDINGS["ctrl_alt_space"] - self.assertTrue(default.modifiers & MOD_CONTROL) - self.assertTrue(default.modifiers & MOD_ALT) + def test_default_hotkey_is_ctrl_alt_space(self) -> None: + self.assertEqual(AppConfig().hotkey, "ctrl+alt+key:0x20") + parsed = Hotkey.parse(AppConfig().hotkey) + self.assertEqual(parsed, Hotkey(frozenset({"ctrl", "alt"}), "key", 0x20)) + + def test_first_preset_is_ctrl_alt_space(self) -> None: + self.assertEqual(HOTKEY_OPTIONS[0], ("ctrl+alt+key:0x20", "Ctrl+Alt+Space")) + + def test_all_presets_parse_validate_and_relabel(self) -> None: + for value, label in HOTKEY_OPTIONS: + hk = Hotkey.parse(value) + self.assertIsNotNone(hk, f"preset {value!r} must parse") + self.assertIsNone(hk.validate(), f"preset {value!r} must be valid") + self.assertEqual(hk.to_label(), label, f"preset {value!r} label mismatch") + + def test_presets_do_not_offer_bare_modifiers(self) -> None: + for value, _label in HOTKEY_OPTIONS: + hk = Hotkey.parse(value) + self.assertNotIn(hk.code, (0x10, 0x11, 0x12), "no bare modifier presets") def test_post_key_options_include_none(self) -> None: self.assertIn(("none", "None"), POST_KEY_OPTIONS) From 49d85455e02d6f9df85d39b8cbf187b82b5d4cad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristi=C3=A1n=20Partl?= Date: Thu, 4 Jun 2026 08:44:41 +0200 Subject: [PATCH 04/12] feat(hotkey): replace RegisterHotKey with low-level keyboard/mouse hooks --- src/hotkey.py | 423 +++++++++++++++++----------------- tests/test_hotkey_listener.py | 106 +++++++++ 2 files changed, 313 insertions(+), 216 deletions(-) create mode 100644 tests/test_hotkey_listener.py diff --git a/src/hotkey.py b/src/hotkey.py index 5c61156..a893c6e 100644 --- a/src/hotkey.py +++ b/src/hotkey.py @@ -1,281 +1,277 @@ -"""Global hotkey listener using Win32 RegisterHotKey + message-only window.""" +"""Global hotkey listener using Win32 low-level hooks (WH_KEYBOARD_LL + WH_MOUSE_LL). + +Supports arbitrary keys, mouse side/middle buttons, hold/toggle modes, and +swallowing the trigger event. The matching core (_on_kb_event / _on_mouse_event) +is pure and OS-independent; only start()/stop() touch Win32. +""" from __future__ import annotations import logging import platform import threading -import time from enum import Enum -from src.config import HOTKEY_BINDINGS, HotkeyBinding +from src.config import ( + Hotkey, + MODIFIER_VK_TO_NAME, + MOUSE_MIDDLE, + MOUSE_X1, + MOUSE_X2, +) from src.utils import AppError, ScreamerError, SignalBridge log = logging.getLogger(__name__) +# Win32 message constants (also imported by tests). +WM_QUIT = 0x0012 +WM_KEYDOWN = 0x0100 +WM_KEYUP = 0x0101 +WM_SYSKEYDOWN = 0x0104 +WM_SYSKEYUP = 0x0105 +WM_MBUTTONDOWN = 0x0207 +WM_MBUTTONUP = 0x0208 +WM_XBUTTONDOWN = 0x020B +WM_XBUTTONUP = 0x020C + +WH_KEYBOARD_LL = 13 +WH_MOUSE_LL = 14 +HC_ACTION = 0 + +_KEY_DOWN = frozenset({WM_KEYDOWN, WM_SYSKEYDOWN}) +_KEY_UP = frozenset({WM_KEYUP, WM_SYSKEYUP}) + +# XBUTTON discriminators in the high word of MSLLHOOKSTRUCT.mouseData. +_XBUTTON1 = 0x0001 +_XBUTTON2 = 0x0002 + + class HotkeyMode(Enum): HOLD = "hold" TOGGLE = "toggle" class HotkeyListener: - """Win32 RegisterHotKey-based hotkey listener with hold/toggle modes.""" + """Low-level-hook hotkey listener with hold/toggle modes and trigger suppression.""" - def __init__(self, key: str, mode: HotkeyMode, bridge: SignalBridge) -> None: - self._key = key + def __init__(self, hotkey: Hotkey, mode: HotkeyMode, bridge: SignalBridge) -> None: + self._hotkey = hotkey self._mode = mode self._bridge = bridge self._thread: threading.Thread | None = None + self._thread_id: int = 0 self._stop_event = threading.Event() - self._hwnd = None - self._hotkey_id = 1 - self._release_lock = threading.Lock() - self._release_thread: threading.Thread | None = None - self._release_watch_active = False + # Matching state. + self._held: set[str] = set() + self._armed = False + # Keep ctypes callbacks alive across the message loop's lifetime. + self._kb_proc = None + self._mouse_proc = None + self._kb_hook = None + self._mouse_hook = None + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ def start(self) -> None: - """Create message-only window, RegisterHotKey, GetMessage pump in daemon thread.""" if platform.system() != "Windows": - raise ScreamerError(AppError.UNSUPPORTED_PLATFORM, "RegisterHotKey requires Windows") - - import ctypes - import ctypes.wintypes - + raise ScreamerError(AppError.UNSUPPORTED_PLATFORM, "Low-level hooks require Windows") self._stop_event.clear() + self._held.clear() + self._armed = False self._thread = threading.Thread(target=self._message_loop, daemon=True) self._thread.start() - log.info("HotkeyListener started: key=%s mode=%s", self._key, self._mode.value) + log.info("HotkeyListener started: %s mode=%s", self._hotkey.to_canonical(), self._mode.value) def stop(self) -> None: - """Post WM_QUIT, join thread, unregister hotkey.""" if platform.system() != "Windows": return - self._stop_event.set() - if self._hwnd is not None: + if self._thread_id: import ctypes - import ctypes.wintypes - ctypes.windll.user32.PostMessageW(self._hwnd, 0x0010, 0, 0) # WM_QUIT + ctypes.windll.user32.PostThreadMessageW(self._thread_id, WM_QUIT, 0, 0) if self._thread is not None: self._thread.join(timeout=5.0) self._thread = None + self._thread_id = 0 log.info("HotkeyListener stopped") def set_mode(self, mode: HotkeyMode) -> None: self._mode = mode + self._armed = False log.info("Hotkey mode changed to %s", mode.value) + # ------------------------------------------------------------------ + # Pure matching core (OS-independent; unit-tested) + # ------------------------------------------------------------------ + + def _on_kb_event(self, wparam: int, vk: int) -> bool: + """Handle a keyboard hook event. Return True to suppress (swallow) it.""" + mod = MODIFIER_VK_TO_NAME.get(vk) + if mod is not None: + if wparam in _KEY_DOWN: + self._held.add(mod) + elif wparam in _KEY_UP: + self._held.discard(mod) + return False # modifiers always pass through + + if self._hotkey.kind != "key" or vk != self._hotkey.code: + return False + + if wparam in _KEY_DOWN: + return self._trigger_down() + if wparam in _KEY_UP: + return self._trigger_up() + return False + + def _on_mouse_event(self, wparam: int, mouse_data: int) -> bool: + """Handle a mouse hook event. Return True to suppress (swallow) it.""" + if wparam == WM_MBUTTONDOWN: + btn, is_down = MOUSE_MIDDLE, True + elif wparam == WM_MBUTTONUP: + btn, is_down = MOUSE_MIDDLE, False + elif wparam in (WM_XBUTTONDOWN, WM_XBUTTONUP): + high = (mouse_data >> 16) & 0xFFFF + if high == _XBUTTON1: + btn = MOUSE_X1 + elif high == _XBUTTON2: + btn = MOUSE_X2 + else: + return False + is_down = wparam == WM_XBUTTONDOWN + else: + return False # left/right/move/wheel — never our trigger + + if self._hotkey.kind != "mouse" or btn != self._hotkey.code: + return False + return self._trigger_down() if is_down else self._trigger_up() + + def _trigger_down(self) -> bool: + if self._armed: + return True # autorepeat / duplicate down while held + if self._held != self._hotkey.mods: + return False + self._armed = True + self._bridge.hotkey_pressed.emit() + return True + + def _trigger_up(self) -> bool: + if not self._armed: + return False + self._armed = False + if self._mode == HotkeyMode.HOLD: + self._bridge.hotkey_released.emit() + return True + + # ------------------------------------------------------------------ + # Win32 message loop + hook installation + # ------------------------------------------------------------------ + def _message_loop(self) -> None: - """Run in a daemon thread: create message-only window, register hotkey, pump messages.""" import ctypes import ctypes.wintypes user32 = ctypes.windll.user32 # type: ignore[attr-defined] kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] - lresult_type = getattr(ctypes.wintypes, "LRESULT", ctypes.c_ssize_t) - WNDPROC = ctypes.WINFUNCTYPE( - lresult_type, - ctypes.wintypes.HWND, - ctypes.wintypes.UINT, - ctypes.wintypes.WPARAM, - ctypes.wintypes.LPARAM, - ) - - try: - wnd_class_type = ctypes.wintypes.WNDCLASSEXW - except AttributeError: - class WNDCLASSEXW(ctypes.Structure): - _fields_ = [ - ("cbSize", ctypes.wintypes.UINT), - ("style", ctypes.wintypes.UINT), - ("lpfnWndProc", WNDPROC), - ("cbClsExtra", ctypes.c_int), - ("cbWndExtra", ctypes.c_int), - ("hInstance", ctypes.c_void_p), - ("hIcon", ctypes.c_void_p), - ("hCursor", ctypes.c_void_p), - ("hbrBackground", ctypes.c_void_p), - ("lpszMenuName", ctypes.c_wchar_p), - ("lpszClassName", ctypes.c_wchar_p), - ("hIconSm", ctypes.c_void_p), - ] - - wnd_class_type = WNDCLASSEXW - - _declare_win32_functions(ctypes, user32, kernel32, wnd_class_type, lresult_type) - - # Create a message-only window. - wnd_proc = WNDPROC(self._wnd_proc) - wnd_class = wnd_class_type() - wnd_class.cbSize = ctypes.sizeof(wnd_class_type) - wnd_class.lpfnWndProc = wnd_proc - wnd_class.hInstance = kernel32.GetModuleHandleW(None) - wnd_class.lpszClassName = "ScreamerHotkeyWindow" - - atom = user32.RegisterClassExW(ctypes.byref(wnd_class)) - if not atom: - ERROR_CLASS_ALREADY_EXISTS = 1410 - last_error = kernel32.GetLastError() - if last_error != ERROR_CLASS_ALREADY_EXISTS: - log.error("RegisterClassExW failed: error=%d", last_error) - return - - if self._stop_event.is_set(): - return - - # HWND_MESSAGE parent = -3 for message-only window. - HWND_MESSAGE = ctypes.wintypes.HWND(-3) - self._hwnd = user32.CreateWindowExW( - 0, wnd_class.lpszClassName, "ScreamerHotkey", 0, - 0, 0, 0, 0, HWND_MESSAGE, None, wnd_class.hInstance, None, + lresult = getattr(ctypes.wintypes, "LRESULT", ctypes.c_ssize_t) + ulong_ptr = getattr(ctypes.wintypes, "ULONG_PTR", ctypes.c_size_t) + HOOKPROC = ctypes.WINFUNCTYPE( + lresult, ctypes.c_int, ctypes.wintypes.WPARAM, ctypes.wintypes.LPARAM ) - if not self._hwnd: - log.error("CreateWindowExW failed") - return - - if self._stop_event.is_set(): - user32.DestroyWindow(self._hwnd) - self._hwnd = None - return - # Register the hotkey. - binding = HOTKEY_BINDINGS.get(self._key.lower()) - if binding is None: - log.error("Unknown hotkey: %s", self._key) + class KBDLLHOOKSTRUCT(ctypes.Structure): + _fields_ = [ + ("vkCode", ctypes.wintypes.DWORD), + ("scanCode", ctypes.wintypes.DWORD), + ("flags", ctypes.wintypes.DWORD), + ("time", ctypes.wintypes.DWORD), + ("dwExtraInfo", ulong_ptr), + ] + + class POINT(ctypes.Structure): + _fields_ = [("x", ctypes.wintypes.LONG), ("y", ctypes.wintypes.LONG)] + + class MSLLHOOKSTRUCT(ctypes.Structure): + _fields_ = [ + ("pt", POINT), + ("mouseData", ctypes.wintypes.DWORD), + ("flags", ctypes.wintypes.DWORD), + ("time", ctypes.wintypes.DWORD), + ("dwExtraInfo", ulong_ptr), + ] + + _declare_win32_functions(ctypes, user32, kernel32, HOOKPROC, lresult) + + def kb_callback(ncode, wparam, lparam): + if ncode == HC_ACTION: + kb = ctypes.cast(lparam, ctypes.POINTER(KBDLLHOOKSTRUCT)).contents + if self._on_kb_event(wparam, kb.vkCode): + return 1 + return user32.CallNextHookEx(None, ncode, wparam, lparam) + + def mouse_callback(ncode, wparam, lparam): + if ncode == HC_ACTION: + ms = ctypes.cast(lparam, ctypes.POINTER(MSLLHOOKSTRUCT)).contents + if self._on_mouse_event(wparam, ms.mouseData): + return 1 + return user32.CallNextHookEx(None, ncode, wparam, lparam) + + self._kb_proc = HOOKPROC(kb_callback) + self._mouse_proc = HOOKPROC(mouse_callback) + + self._thread_id = kernel32.GetCurrentThreadId() + hmod = kernel32.GetModuleHandleW(None) + + self._kb_hook = user32.SetWindowsHookExW(WH_KEYBOARD_LL, self._kb_proc, hmod, 0) + self._mouse_hook = user32.SetWindowsHookExW(WH_MOUSE_LL, self._mouse_proc, hmod, 0) + if not self._kb_hook or not self._mouse_hook: + log.error("SetWindowsHookEx failed: kb=%s mouse=%s", self._kb_hook, self._mouse_hook) + self._bridge.error_occurred.emit(AppError.HOTKEY_HOOK_FAILED) + self._uninstall(user32) return - HOTKEY_ID = self._hotkey_id - if not user32.RegisterHotKey(self._hwnd, HOTKEY_ID, binding.modifiers, binding.vk): - log.error( - "RegisterHotKey failed for key=%s modifiers=0x%04X vk=0x%02X (conflict?)", - self._key, - binding.modifiers, - binding.vk, - ) - self._bridge.error_occurred.emit(AppError.HOTKEY_CONFLICT) - return + log.info("Hooks installed for %s", self._hotkey.to_canonical()) - log.info( - "Registered hotkey: key=%s modifiers=0x%04X vk=0x%02X id=%d", - self._key, - binding.modifiers, - binding.vk, - HOTKEY_ID, - ) - - # Message pump — blocks until WM_QUIT. msg = ctypes.wintypes.MSG() - while user32.GetMessageW(ctypes.byref(msg), self._hwnd, 0, 0) > 0: + while user32.GetMessageW(ctypes.byref(msg), None, 0, 0) > 0: user32.TranslateMessage(ctypes.byref(msg)) user32.DispatchMessageW(ctypes.byref(msg)) - user32.UnregisterHotKey(self._hwnd, HOTKEY_ID) - user32.DestroyWindow(self._hwnd) - self._hwnd = None + self._uninstall(user32) log.info("Message loop exited") - def _wnd_proc(self, hwnd: int, msg: int, wparam: int, lparam: int) -> int: - """Window procedure: dispatch WM_HOTKEY to press/release callbacks.""" - import ctypes - import ctypes.wintypes - - WM_HOTKEY = 0x0312 - WM_CLOSE = 0x0010 - user32 = ctypes.windll.user32 # type: ignore[attr-defined] - - if msg == WM_CLOSE: - user32.PostQuitMessage(0) - return 0 - - if msg == WM_HOTKEY: - log.debug("WM_HOTKEY received: id=%d", wparam) - self._bridge.hotkey_pressed.emit() - - if self._mode == HotkeyMode.HOLD: - binding = HOTKEY_BINDINGS.get(self._key.lower()) - if binding is not None: - self._start_release_watch(binding) - - return user32.DefWindowProcW(hwnd, msg, wparam, lparam) - - def _start_release_watch(self, binding: HotkeyBinding) -> None: - """Poll key release outside the window procedure so the pump stays responsive.""" - with self._release_lock: - if self._release_watch_active: - return - self._release_watch_active = True - self._release_thread = threading.Thread( - target=self._watch_release, - args=(binding.vk,), - daemon=True, - ) - self._release_thread.start() - - def _watch_release(self, vk: int) -> None: - import ctypes - - user32 = ctypes.windll.user32 # type: ignore[attr-defined] - while not self._stop_event.is_set(): - state = user32.GetAsyncKeyState(vk) - if not (state & 0x8000): # high-order bit = key is down - break - time.sleep(0.05) - - with self._release_lock: - self._release_watch_active = False - - if not self._stop_event.is_set(): - self._bridge.hotkey_released.emit() + def _uninstall(self, user32) -> None: + if self._kb_hook: + user32.UnhookWindowsHookEx(self._kb_hook) + self._kb_hook = None + if self._mouse_hook: + user32.UnhookWindowsHookEx(self._mouse_hook) + self._mouse_hook = None -def _declare_win32_functions(ctypes, user32, kernel32, wnd_class_type, lresult_type) -> None: - """Declare the Win32 ABI once before any ctypes calls cross the boundary.""" +def _declare_win32_functions(ctypes, user32, kernel32, hookproc, lresult) -> None: wintypes = ctypes.wintypes - kernel32.GetModuleHandleW.restype = ctypes.c_void_p kernel32.GetModuleHandleW.argtypes = [wintypes.LPCWSTR] - kernel32.GetLastError.restype = wintypes.DWORD - kernel32.GetLastError.argtypes = [] - - atom_type = getattr(wintypes, "ATOM", wintypes.WORD) - user32.RegisterClassExW.restype = atom_type - user32.RegisterClassExW.argtypes = [ctypes.POINTER(wnd_class_type)] - user32.CreateWindowExW.restype = wintypes.HWND - user32.CreateWindowExW.argtypes = [ - wintypes.DWORD, - wintypes.LPCWSTR, - wintypes.LPCWSTR, - wintypes.DWORD, - ctypes.c_int, - ctypes.c_int, - ctypes.c_int, - ctypes.c_int, - wintypes.HWND, - ctypes.c_void_p, - ctypes.c_void_p, - ctypes.c_void_p, - ] - user32.RegisterHotKey.restype = wintypes.BOOL - user32.RegisterHotKey.argtypes = [wintypes.HWND, ctypes.c_int, wintypes.UINT, wintypes.UINT] + kernel32.GetCurrentThreadId.restype = wintypes.DWORD + kernel32.GetCurrentThreadId.argtypes = [] + + user32.SetWindowsHookExW.restype = ctypes.c_void_p + user32.SetWindowsHookExW.argtypes = [ctypes.c_int, hookproc, ctypes.c_void_p, wintypes.DWORD] + user32.UnhookWindowsHookEx.restype = wintypes.BOOL + user32.UnhookWindowsHookEx.argtypes = [ctypes.c_void_p] + user32.CallNextHookEx.restype = lresult + user32.CallNextHookEx.argtypes = [ctypes.c_void_p, ctypes.c_int, wintypes.WPARAM, wintypes.LPARAM] user32.GetMessageW.restype = wintypes.BOOL user32.GetMessageW.argtypes = [ctypes.POINTER(wintypes.MSG), wintypes.HWND, wintypes.UINT, wintypes.UINT] user32.TranslateMessage.restype = wintypes.BOOL user32.TranslateMessage.argtypes = [ctypes.POINTER(wintypes.MSG)] - user32.DispatchMessageW.restype = lresult_type + user32.DispatchMessageW.restype = lresult user32.DispatchMessageW.argtypes = [ctypes.POINTER(wintypes.MSG)] - user32.PostMessageW.restype = wintypes.BOOL - user32.PostMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM] - user32.PostQuitMessage.restype = None - user32.PostQuitMessage.argtypes = [ctypes.c_int] - user32.DestroyWindow.restype = wintypes.BOOL - user32.DestroyWindow.argtypes = [wintypes.HWND] - user32.UnregisterHotKey.restype = wintypes.BOOL - user32.UnregisterHotKey.argtypes = [wintypes.HWND, ctypes.c_int] - user32.GetAsyncKeyState.restype = ctypes.c_short - user32.GetAsyncKeyState.argtypes = [ctypes.c_int] - user32.DefWindowProcW.restype = lresult_type - user32.DefWindowProcW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM] + user32.PostThreadMessageW.restype = wintypes.BOOL + user32.PostThreadMessageW.argtypes = [wintypes.DWORD, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM] # --------------------------------------------------------------------------- @@ -292,19 +288,14 @@ def _declare_win32_functions(ctypes, user32, kernel32, wnd_class_type, lresult_t from PySide6.QtWidgets import QApplication import sys + from src.config import Hotkey + app = QApplication(sys.argv) bridge = SignalBridge() + bridge.hotkey_pressed.connect(lambda: print("PRESSED")) + bridge.hotkey_released.connect(lambda: print("RELEASED")) - def on_pressed(): - print("PRESSED") - - def on_released(): - print("RELEASED") - - bridge.hotkey_pressed.connect(on_pressed) - bridge.hotkey_released.connect(on_released) - - listener = HotkeyListener("scroll_lock", HotkeyMode.HOLD, bridge) + listener = HotkeyListener(Hotkey(frozenset(), "key", 0x91), HotkeyMode.HOLD, bridge) listener.start() print("Press Scroll Lock to test (Ctrl+C to quit)...") try: diff --git a/tests/test_hotkey_listener.py b/tests/test_hotkey_listener.py new file mode 100644 index 0000000..3747146 --- /dev/null +++ b/tests/test_hotkey_listener.py @@ -0,0 +1,106 @@ +import os +import unittest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtWidgets import QApplication + +from src.config import Hotkey, MOUSE_X1 +from src.hotkey import ( + HotkeyListener, + HotkeyMode, + WM_KEYDOWN, + WM_KEYUP, + WM_XBUTTONDOWN, + WM_XBUTTONUP, +) +from src.utils import SignalBridge + +_app = QApplication.instance() or QApplication([]) + + +def _listener(hotkey, mode): + bridge = SignalBridge() + pressed = [] + released = [] + bridge.hotkey_pressed.connect(lambda: pressed.append(1)) + bridge.hotkey_released.connect(lambda: released.append(1)) + return HotkeyListener(hotkey, mode, bridge), pressed, released + + +VK_LCTRL = 0xA2 +VK_LALT = 0xA4 + + +class HoldKeyTests(unittest.TestCase): + def test_full_combo_press_and_release(self): + hk = Hotkey(frozenset({"ctrl", "alt"}), "key", 0x20) + listener, pressed, released = _listener(hk, HotkeyMode.HOLD) + + self.assertFalse(listener._on_kb_event(WM_KEYDOWN, VK_LCTRL)) # modifier passes + self.assertFalse(listener._on_kb_event(WM_KEYDOWN, VK_LALT)) + self.assertTrue(listener._on_kb_event(WM_KEYDOWN, 0x20)) # trigger suppressed + self.assertEqual(pressed, [1]) + self.assertEqual(released, []) + + self.assertTrue(listener._on_kb_event(WM_KEYUP, 0x20)) # release suppressed + self.assertEqual(released, [1]) + + def test_autorepeat_does_not_re_emit(self): + hk = Hotkey(frozenset({"ctrl", "alt"}), "key", 0x20) + listener, pressed, _ = _listener(hk, HotkeyMode.HOLD) + listener._on_kb_event(WM_KEYDOWN, VK_LCTRL) + listener._on_kb_event(WM_KEYDOWN, VK_LALT) + listener._on_kb_event(WM_KEYDOWN, 0x20) + listener._on_kb_event(WM_KEYDOWN, 0x20) # autorepeat + listener._on_kb_event(WM_KEYDOWN, 0x20) # autorepeat + self.assertEqual(pressed, [1]) + + def test_wrong_modifiers_do_not_fire(self): + hk = Hotkey(frozenset({"ctrl", "alt"}), "key", 0x20) + listener, pressed, _ = _listener(hk, HotkeyMode.HOLD) + listener._on_kb_event(WM_KEYDOWN, VK_LCTRL) # only ctrl, alt missing + self.assertFalse(listener._on_kb_event(WM_KEYDOWN, 0x20)) # not suppressed + self.assertEqual(pressed, []) + + def test_extra_modifier_blocks_match(self): + hk = Hotkey(frozenset({"ctrl"}), "key", 0x20) + listener, pressed, _ = _listener(hk, HotkeyMode.HOLD) + listener._on_kb_event(WM_KEYDOWN, VK_LCTRL) + listener._on_kb_event(WM_KEYDOWN, VK_LALT) # extra alt held + self.assertFalse(listener._on_kb_event(WM_KEYDOWN, 0x20)) + self.assertEqual(pressed, []) + + +class ToggleKeyTests(unittest.TestCase): + def test_toggle_emits_pressed_each_time_no_released(self): + hk = Hotkey(frozenset(), "key", 0x91) # Scroll Lock, no mods + listener, pressed, released = _listener(hk, HotkeyMode.TOGGLE) + self.assertTrue(listener._on_kb_event(WM_KEYDOWN, 0x91)) + self.assertTrue(listener._on_kb_event(WM_KEYUP, 0x91)) + self.assertTrue(listener._on_kb_event(WM_KEYDOWN, 0x91)) + self.assertEqual(pressed, [1, 1]) + self.assertEqual(released, []) + + +class MouseTests(unittest.TestCase): + def test_mouse_x1_with_ctrl(self): + hk = Hotkey(frozenset({"ctrl"}), "mouse", MOUSE_X1) + listener, pressed, released = _listener(hk, HotkeyMode.HOLD) + listener._on_kb_event(WM_KEYDOWN, VK_LCTRL) + # mouseData high word = XBUTTON1 (0x0001) + self.assertTrue(listener._on_mouse_event(WM_XBUTTONDOWN, 0x0001 << 16)) + self.assertEqual(pressed, [1]) + self.assertTrue(listener._on_mouse_event(WM_XBUTTONUP, 0x0001 << 16)) + self.assertEqual(released, [1]) + + def test_left_button_ignored(self): + hk = Hotkey(frozenset({"ctrl"}), "mouse", MOUSE_X1) + listener, pressed, _ = _listener(hk, HotkeyMode.HOLD) + listener._on_kb_event(WM_KEYDOWN, VK_LCTRL) + self.assertFalse(listener._on_mouse_event(0x0201, 0)) # WM_LBUTTONDOWN + self.assertEqual(pressed, []) + + +if __name__ == "__main__": + unittest.main() From 6e5e85f236df77fb21a85d085b4384d45fa9d2f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristi=C3=A1n=20Partl?= Date: Thu, 4 Jun 2026 08:46:14 +0200 Subject: [PATCH 05/12] feat(hotkey): add press-to-capture hotkey UI in settings --- src/settings_dialog.py | 174 +++++++++++++++++++++++++++++++++- tests/test_settings_hotkey.py | 54 +++++++++++ 2 files changed, 224 insertions(+), 4 deletions(-) create mode 100644 tests/test_settings_hotkey.py diff --git a/src/settings_dialog.py b/src/settings_dialog.py index 1f802a3..d26bc75 100644 --- a/src/settings_dialog.py +++ b/src/settings_dialog.py @@ -10,7 +10,8 @@ import logging from typing import Callable -from PySide6.QtCore import Qt +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QKeyEvent, QMouseEvent from PySide6.QtWidgets import ( QCheckBox, QComboBox, @@ -36,6 +37,10 @@ AppConfig, DEFAULT_LLM_SYSTEM_PROMPT, HOTKEY_OPTIONS, + Hotkey, + MOUSE_MIDDLE, + MOUSE_X1, + MOUSE_X2, POST_KEY_OPTIONS, import_from_env, load_config, @@ -151,11 +156,31 @@ def _build_general_tab(self) -> None: tab = QWidget() form = QFormLayout(tab) + self._captured_hotkey: Hotkey | None = None + self._hotkey_combo = QComboBox() for key, label in HOTKEY_OPTIONS: self._hotkey_combo.addItem(label, key) + self._hotkey_combo.addItem("Custom…", "__custom__") + self._hotkey_combo.activated.connect(self._on_hotkey_preset_chosen) form.addRow("Hotkey:", self._hotkey_combo) + self._hotkey_capture = HotkeyCaptureEdit() + self._hotkey_capture.captured.connect(self._on_hotkey_captured) + self._hotkey_capture.cancelled.connect(self._stop_hotkey_recording) + self._hotkey_record_btn = QPushButton("Record") + self._hotkey_record_btn.setCheckable(True) + self._hotkey_record_btn.clicked.connect(self._on_hotkey_record_clicked) + capture_row = QHBoxLayout() + capture_row.addWidget(self._hotkey_capture, 1) + capture_row.addWidget(self._hotkey_record_btn) + form.addRow("", capture_row) + + self._hotkey_error = QLabel("") + self._hotkey_error.setStyleSheet("color: #c0392b;") + self._hotkey_error.setVisible(False) + form.addRow("", self._hotkey_error) + self._mode_hold = QRadioButton("Hold to talk") self._mode_toggle = QRadioButton("Toggle") mode_row = QHBoxLayout() @@ -176,6 +201,56 @@ def _build_general_tab(self) -> None: self._tabs.addTab(tab, "General") + # --- Hotkey capture interaction ----------------------------------- + + def _set_captured_hotkey(self, hotkey: Hotkey) -> None: + """Store a validated hotkey and reflect it in combo + capture field.""" + self._captured_hotkey = hotkey + self._hotkey_capture.show_hotkey(hotkey) + self._hotkey_error.setVisible(False) + canonical = hotkey.to_canonical() + idx = _combo_index(self._hotkey_combo, canonical) + self._hotkey_combo.setCurrentIndex( + idx if idx >= 0 else _combo_index(self._hotkey_combo, "__custom__") + ) + + def _on_hotkey_preset_chosen(self, index: int) -> None: + data = self._hotkey_combo.itemData(index) + if data == "__custom__": + self._start_hotkey_recording() + return + hotkey = Hotkey.parse(data) + if hotkey is not None: + self._set_captured_hotkey(hotkey) + + def _start_hotkey_recording(self) -> None: + self._hotkey_record_btn.setChecked(True) + self._hotkey_record_btn.setText("Cancel") + self._hotkey_error.setVisible(False) + self._hotkey_capture.start_recording() + + def _stop_hotkey_recording(self) -> None: + self._hotkey_record_btn.setChecked(False) + self._hotkey_record_btn.setText("Record") + self._hotkey_capture.stop_recording() + if self._captured_hotkey is not None: + self._hotkey_capture.show_hotkey(self._captured_hotkey) + + def _on_hotkey_record_clicked(self, checked: bool) -> None: + if checked: + self._start_hotkey_recording() + else: + self._stop_hotkey_recording() + + def _on_hotkey_captured(self, hotkey: Hotkey) -> None: + error = hotkey.validate() + if error is not None: + self._hotkey_error.setText(error) + self._hotkey_error.setVisible(True) + return # stay in recording so the user can try again + self._set_captured_hotkey(hotkey) + self._stop_hotkey_recording() + # --- STT tab ------------------------------------------------------- def _build_stt_tab(self) -> None: @@ -299,8 +374,8 @@ def _build_audio_tab(self) -> None: def _populate(self, cfg: AppConfig) -> None: """Fill all widgets from *cfg*.""" # General - idx = _combo_index(self._hotkey_combo, cfg.hotkey) - self._hotkey_combo.setCurrentIndex(max(idx, 0)) + hotkey = Hotkey.parse(cfg.hotkey) or Hotkey(frozenset({"ctrl", "alt"}), "key", 0x20) + self._set_captured_hotkey(hotkey) self._mode_hold.setChecked(cfg.recording_mode == "hold") self._mode_toggle.setChecked(cfg.recording_mode != "hold") idx = _combo_index(self._post_key_combo, cfg.post_type_key) @@ -342,7 +417,8 @@ def _collect(self) -> None: cfg = self._working # General - cfg.hotkey = self._hotkey_combo.currentData() + if self._captured_hotkey is not None: + cfg.hotkey = self._captured_hotkey.to_canonical() cfg.recording_mode = "toggle" if self._mode_toggle.isChecked() else "hold" cfg.post_type_key = self._post_key_combo.currentData() cfg.start_with_windows = self._startup_check.isChecked() @@ -546,6 +622,96 @@ def _add_provider_fields( return key, url, model, headers +def _mods_from_qt(modifiers) -> frozenset: + """Map Qt.KeyboardModifiers to our canonical modifier-name set.""" + mods = set() + if modifiers & Qt.ControlModifier: + mods.add("ctrl") + if modifiers & Qt.AltModifier: + mods.add("alt") + if modifiers & Qt.ShiftModifier: + mods.add("shift") + if modifiers & Qt.MetaModifier: + mods.add("win") + return frozenset(mods) + + +_QT_MOUSE_TO_CODE = { + Qt.BackButton: MOUSE_X1, + Qt.ForwardButton: MOUSE_X2, + Qt.MiddleButton: MOUSE_MIDDLE, +} + +# Qt key codes that are modifiers (ignored as a trigger during capture). +# Stored as ints so membership works regardless of enum/int return type. +_QT_MODIFIER_KEYS = frozenset( + int(k) for k in (Qt.Key_Control, Qt.Key_Alt, Qt.Key_Shift, Qt.Key_Meta, Qt.Key_AltGr) +) + + +def _mouse_button_to_code(button): + """Map a Qt.MouseButton to a MOUSE_* code, or None if not bindable.""" + return _QT_MOUSE_TO_CODE.get(button) + + +class HotkeyCaptureEdit(QLineEdit): + """Read-only field that records the next key/mouse chord while recording. + + Emits ``captured`` with a Hotkey on a complete chord. Keyboard chords finalize + on the first non-modifier key; mouse chords finalize on a side/middle click. + """ + + captured = Signal(object) # Hotkey + cancelled = Signal() # Esc pressed during recording + + def __init__(self) -> None: + super().__init__() + self.setReadOnly(True) + self._recording = False + + def is_recording(self) -> bool: + return self._recording + + def start_recording(self) -> None: + self._recording = True + self.setText("press keys or a mouse button…") + self.setFocus(Qt.OtherFocusReason) + self.grabKeyboard() + + def stop_recording(self) -> None: + self._recording = False + self.releaseKeyboard() + + def show_hotkey(self, hotkey: Hotkey) -> None: + self.setText(hotkey.to_label()) + + def keyPressEvent(self, event: QKeyEvent) -> None: + if not self._recording: + super().keyPressEvent(event) + return + event.accept() + if int(event.key()) == int(Qt.Key_Escape): + self.cancelled.emit() + return + if event.isAutoRepeat() or int(event.key()) in _QT_MODIFIER_KEYS: + return + vk = event.nativeVirtualKey() + if not vk: + return + self.captured.emit(Hotkey(_mods_from_qt(event.modifiers()), "key", vk)) + + def mousePressEvent(self, event: QMouseEvent) -> None: + if not self._recording: + super().mousePressEvent(event) + return + code = _mouse_button_to_code(event.button()) + if code is None: + event.accept() # swallow left/right; only side/middle bind + return + event.accept() + self.captured.emit(Hotkey(_mods_from_qt(event.modifiers()), "mouse", code)) + + def _combo_index(combo: QComboBox, data: str) -> int: for i in range(combo.count()): if combo.itemData(i) == data: diff --git a/tests/test_settings_hotkey.py b/tests/test_settings_hotkey.py new file mode 100644 index 0000000..7e8b170 --- /dev/null +++ b/tests/test_settings_hotkey.py @@ -0,0 +1,54 @@ +import os +import unittest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QApplication + +from src.config import AppConfig, Hotkey, MOUSE_X1 +from src.settings_dialog import ( + SettingsDialog, + _mods_from_qt, + _mouse_button_to_code, +) + +_app = QApplication.instance() or QApplication([]) + + +class QtConversionTests(unittest.TestCase): + def test_mods_from_qt(self): + mods = _mods_from_qt(Qt.ControlModifier | Qt.AltModifier) + self.assertEqual(mods, frozenset({"ctrl", "alt"})) + self.assertEqual(_mods_from_qt(Qt.NoModifier), frozenset()) + self.assertEqual(_mods_from_qt(Qt.MetaModifier), frozenset({"win"})) + + def test_mouse_button_to_code(self): + self.assertEqual(_mouse_button_to_code(Qt.BackButton), MOUSE_X1) + self.assertIsNone(_mouse_button_to_code(Qt.LeftButton)) + + +class PopulateCollectTests(unittest.TestCase): + def test_roundtrip_preset(self): + cfg = AppConfig() # ctrl+alt+key:0x20 + dlg = SettingsDialog(cfg, devices=[], calibrate_fn=lambda *a, **k: None) + try: + dlg._collect() + self.assertEqual(dlg.get_config().hotkey, "ctrl+alt+key:0x20") + finally: + dlg.deleteLater() + + def test_roundtrip_custom(self): + cfg = AppConfig() + cfg.hotkey = "ctrl+mouse:x1" + dlg = SettingsDialog(cfg, devices=[], calibrate_fn=lambda *a, **k: None) + try: + self.assertEqual(dlg._captured_hotkey, Hotkey(frozenset({"ctrl"}), "mouse", MOUSE_X1)) + dlg._collect() + self.assertEqual(dlg.get_config().hotkey, "ctrl+mouse:x1") + finally: + dlg.deleteLater() + + +if __name__ == "__main__": + unittest.main() From 6e232133e7000e08eed0ea249f6c66059c0395b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristi=C3=A1n=20Partl?= Date: Thu, 4 Jun 2026 08:46:50 +0200 Subject: [PATCH 06/12] feat(hotkey): build listener from parsed custom hotkey --- src/main.py | 4 +++- tests/test_tray_menu.py | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main.py b/src/main.py index b35bd6f..ef3a358 100644 --- a/src/main.py +++ b/src/main.py @@ -28,6 +28,7 @@ HOTKEY_OPTIONS, POST_KEY_OPTIONS, AppConfig, + Hotkey, import_from_env, load_config, save_config, @@ -249,7 +250,8 @@ def _rebuild_menu(self) -> None: def _make_listener(self) -> None: """Create and start a HotkeyListener from current config, storing it on self.""" mode = HotkeyMode.TOGGLE if self._config.recording_mode == "toggle" else HotkeyMode.HOLD - self._hotkey = HotkeyListener(self._config.hotkey, mode, self._bridge) + hotkey = Hotkey.parse(self._config.hotkey) or Hotkey(frozenset({"ctrl", "alt"}), "key", 0x20) + self._hotkey = HotkeyListener(hotkey, mode, self._bridge) self._hotkey.start() def _build_hotkey(self) -> None: diff --git a/tests/test_tray_menu.py b/tests/test_tray_menu.py index b0c1ce7..bc2c406 100644 --- a/tests/test_tray_menu.py +++ b/tests/test_tray_menu.py @@ -124,13 +124,13 @@ def test_set_hotkey_rebuilds_by_default_but_can_skip(self): tray_app._restart_hotkey = lambda: restarts.append("restarted") with patch("src.main.save_config"): - tray_app._set_hotkey("ctrl_alt_space") + tray_app._set_hotkey("ctrl+alt+key:0x20") self.assertEqual(rebuilds, ["rebuilt"]) self.assertEqual(restarts, ["restarted"]) rebuilds.clear() restarts.clear() - tray_app._set_hotkey("ctrl_shift_space", rebuild_menu=False) + tray_app._set_hotkey("ctrl+shift+key:0x20", rebuild_menu=False) self.assertEqual(rebuilds, []) self.assertEqual(restarts, ["restarted"]) From 9451955db2e8ba330b29a6b96f5887f9ecfa3391 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristi=C3=A1n=20Partl?= Date: Thu, 4 Jun 2026 08:47:53 +0200 Subject: [PATCH 07/12] docs(hotkey): document custom hotkey model and low-level hooks --- CLAUDE.md | 80 ++++++++++++++++++++++++++++++++++++++++++ docs/IMPLEMENTATION.md | 37 +++++++++++++------ 2 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..31b2e40 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,80 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +Screamer is a Windows desktop push-to-talk dictation tool. Hold a hotkey, speak, release; audio is recorded at 16 kHz mono, sent to a Whisper-compatible STT endpoint, optionally cleaned up by an LLM rewrite, and typed into the active window via Win32 `SendInput`. It runs as a system-tray app with a settings dialog. Stack: Python 3 + PySide6 (Qt), `sounddevice`, `numpy`, `httpx`. Packaged with PyInstaller. + +> Note: `docs/OVERVIEW.md` is a stale pre-fork scouting note (it references PyQt6 and a `transcriber.py` that no longer exists). The authoritative design doc is `docs/IMPLEMENTATION.md` + `docs/PLAN.md`, which match the current code. + +## Commands + +```powershell +# Dev setup +python -m venv .venv; .\.venv\Scripts\Activate.ps1 +pip install -r requirements.txt + +# Run the tray app +python -m src.main + +# Build a Windows .exe (creates .venv, installs deps, runs PyInstaller) +.\build_windows.ps1 # output: dist\Screamer\Screamer.exe + +# Verification (must pass on any OS) +python -m compileall src/ +python -c "import src; print('OK')" +``` + +### Per-module smoke tests + +There is **no pytest suite**. Each backend module has a `__main__` block used as its smoke test: + +```powershell +python -m src.icons # writes 3 test PNGs (32x32) +python -m src.config # prints defaults, DPAPI roundtrip, creates APP_DIR +python -m src.audio # records 3s -> test.wav, prints duration + RMS +python -m src.hotkey # prints pressed/released (Windows only) +python -m src.injector "hello" # types into active window (Windows only) +python -m src.stt test.wav # transcribes (needs API config) +python -m src.rewrite "test sentense" # corrects text (needs API config) +python -m src.settings_dialog # launches the 4-tab dialog standalone +``` + +CLI scripts resolve credentials in this order: `load_config()` (QSettings + DPAPI) → backfill empty fields from a `.env` at cwd via `import_from_env()` → if still empty, print a setup message to stderr and `exit(1)`. No hardcoded provider defaults. + +## Architecture + +The codebase is a strict DAG rooted at `main.py` (the composition root). These dependency rules are load-bearing — preserve them when editing: + +- **`main.py` imports everything; nothing imports `main.py`.** It owns the tray icon, the `idle → recording → processing → idle` state machine, and the worker thread lifecycle. +- **The five backend modules (`audio`, `hotkey`, `stt`, `rewrite`, `injector`) must NOT import each other.** They may import only `utils.py`. +- **`stt.py` and `rewrite.py` receive `AppConfig` as a parameter** — they do not import `config.py`. `audio.py` receives device id / name / RMS threshold from `main.py`. +- **Qt (PySide6) lives only in `utils.py`, `icons.py`, `settings_dialog.py`, `main.py`.** The backend modules are Qt-free. +- **`settings_dialog.py` imports only `config.py` and `utils.py`.** It edits a *copy* of the config; the original is untouched until accept. + +### Threading model + +- The Qt main thread owns all UI. Recording start/stop runs on the main thread. +- The full pipeline (`transcribe → rewrite → type_text`) runs in `_WorkerThread` (a `QThread`) so it never blocks the UI. It checks a `threading.Event` (`cancel_event`) before each blocking step and emits results back via `finished_signal`. +- The hotkey listener runs its own daemon thread with a Win32 `GetMessage` pump. It communicates to the Qt main thread through `SignalBridge` (the `QObject`-with-`Signal` bridge in `utils.py`) — this cross-thread signal pattern is how worker/hotkey threads safely touch the UI. + +### Error handling + +Backend code raises `ScreamerError(AppError.X, detail=...)` — never bare `print()` or swallowed exceptions. `AppError` (in `utils.py`) is an enum whose `.value` is a user-facing message. `main.py` surfaces these as tray balloon notifications. Non-fatal issues (fallback used, rewrite failed) are carried as `PipelineResult.warnings` rather than raised. When adding a new failure mode, add an `AppError` enum member rather than inventing an ad-hoc message. + +### Config & secrets + +- Plain settings persist via `QSettings` (IniFormat). API-key fields are encrypted with **Windows DPAPI** before being written (see `_SECRET_FIELDS` in `config.py`). +- All app data lives under `%LOCALAPPDATA%/Screamer/` (`APP_DIR` in `utils.py`). Logs go to a rotating `screamer.log` there. +- **Never log `api_key` values. Never log transcript text unless `setup_logging(debug=True)`.** + +### Platform guards + +Windows-first, but every module must **import** cleanly on any OS (agents may run on Linux/macOS). Windows-only runtime paths (`hotkey.py`, `injector.py`, DPAPI in `config.py`) guard Win32 calls behind `platform.system() == "Windows"` and raise `ScreamerError(AppError.UNSUPPORTED_PLATFORM)` at *runtime* rather than crashing at import time. DPAPI roundtrip, `RegisterHotKey`, and `SendInput` can only be fully verified on Windows. + +## Conventions + +- Public API surface of each module is fixed by the contracts in `docs/IMPLEMENTATION.md`. Phase 2 (`main.py`, `settings_dialog.py`) wires Phase 1 modules using only those exports — if you change a backend signature, update that doc. +- No new third-party dependencies and no new modules beyond the 10 in `src/` without a strong reason; the project is deliberately small. +- Hotkeys are `config.Hotkey` value objects (modifiers + one key/mouse trigger), serialized to a canonical string (`ctrl+alt+key:0x20`, `ctrl+mouse:x1`); legacy preset keys auto-migrate via `Hotkey.parse`. Presets live in `HOTKEY_OPTIONS` (`config.py`); the listener uses low-level hooks (`WH_KEYBOARD_LL`/`WH_MOUSE_LL`) and swallows the matched trigger. Add safe-bind-alone keys via `SAFE_STANDALONE_KEYS` in `config.py`. diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 60724e6..d18804b 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -79,13 +79,24 @@ DEFAULT_LLM_SYSTEM_PROMPT: str = ( DEFAULT_RMS_THRESHOLD: float = 5.0 ```python -@dataclass(frozen=True) -class HotkeyBinding: - modifiers: int - vk: int +MOUSE_X1 = 1; MOUSE_X2 = 2; MOUSE_MIDDLE = 3 # mouse trigger ids + +HOTKEY_OPTIONS: list[tuple[str, str]] # (canonical_string, display_label) preset pairs +SAFE_STANDALONE_KEYS: frozenset[int] # VKs bindable without a modifier (F-keys, locks, etc.) +MODIFIER_VK_TO_NAME: dict[int, str] # LL-hook modifier VK → "ctrl"/"alt"/"shift"/"win" -HOTKEY_OPTIONS: list[tuple[str, str]] # (key, display_label) pairs for combo hotkeys -HOTKEY_BINDINGS: dict[str, HotkeyBinding] # maps hotkey name → HotkeyBinding +@dataclass(frozen=True) +class Hotkey: + """Modifiers + a single key/mouse trigger. Serialized to one canonical string.""" + mods: frozenset # subset of {"ctrl","alt","shift","win"} + kind: str # "key" | "mouse" + code: int # Win32 VK (kind="key") or a MOUSE_* id (kind="mouse") + + def to_canonical(self) -> str: ... # "ctrl+alt+key:0x20", "ctrl+mouse:x1", "key:0x91" + def to_label(self) -> str: ... # "Ctrl+Alt+Space", "Mouse Back" + def validate(self) -> str | None: ... # error message if unsafe, else None + @classmethod + def parse(cls, value: str) -> "Hotkey | None": ... # canonical OR legacy preset key @dataclass(frozen=True) class ProviderConfig: @@ -106,7 +117,7 @@ class ConfigValidationIssue: @dataclass class AppConfig: - hotkey: str = "ctrl_alt_space" + hotkey: str = "ctrl+alt+key:0x20" # canonical Hotkey string (see Hotkey.parse) recording_mode: str = "hold" # "hold" | "toggle" post_type_key: str = "none" # "none" | "enter" | "tab" | "space" | "backspace" start_with_windows: bool = False @@ -198,13 +209,17 @@ class HotkeyMode(Enum): HOLD = "hold"; TOGGLE = "toggle" class HotkeyListener: - def __init__(self, key: str, mode: HotkeyMode, bridge: SignalBridge): ... + def __init__(self, hotkey: Hotkey, mode: HotkeyMode, bridge: SignalBridge): ... def start(self) -> None: ... - """Create message-only window, RegisterHotKey, GetMessage pump in daemon thread. - Emits bridge.hotkey_pressed / bridge.hotkey_released.""" + """Install WH_KEYBOARD_LL + WH_MOUSE_LL global hooks + GetMessage pump in a daemon thread. + Matches modifiers + trigger, swallows the matched trigger event (returns 1 from the hook). + Emits bridge.hotkey_pressed / bridge.hotkey_released; SetWindowsHookEx failure → + bridge.error_occurred(AppError.HOTKEY_HOOK_FAILED).""" def stop(self) -> None: ... - """Post WM_QUIT, join thread, unregister hotkey.""" + """PostThreadMessage WM_QUIT, join thread, unhook both hooks.""" def set_mode(self, mode: HotkeyMode) -> None: ... + # Pure, OS-independent matching core (unit-tested without Win32): + # _on_kb_event(wparam, vk) -> bool ; _on_mouse_event(wparam, mouse_data) -> bool ``` ### stt.py From 9feb8bce242cf9344b9a670d975a96f44e863ccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristi=C3=A1n=20Partl?= Date: Thu, 4 Jun 2026 08:49:16 +0200 Subject: [PATCH 08/12] docs(hotkey): document custom hotkey recording in README --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3128ef5..e7ec014 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ The LLM rewrite step is optional. Leave it off if you want raw transcription. ## Hotkeys -Available hotkey options: +Quick-pick presets: ```text Ctrl+Alt+Space @@ -121,6 +121,12 @@ Pause Default: `Ctrl+Alt+Space` +Or set a **custom hotkey**: in Settings, click **Record** and press any key +combination, a function key, or a mouse side/middle button. Bare everyday keys +need a modifier (Ctrl/Alt/Shift); function keys, lock/pause keys, and mouse +side/middle buttons may be bound on their own. The matched trigger is swallowed +so it won't reach the app underneath. + ## For developers Run from source: @@ -177,7 +183,7 @@ Screamer is built for Windows. It depends on Windows-specific features including: -- global hotkeys via `RegisterHotKey` +- global hotkeys (keyboard or mouse) via low-level hooks (`WH_KEYBOARD_LL`/`WH_MOUSE_LL`) - text injection via `SendInput` - tray integration - DPAPI key storage From 4477c2a9effbbdbd25d407a857f86fa582702f68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristi=C3=A1n=20Partl?= Date: Thu, 4 Jun 2026 08:49:17 +0200 Subject: [PATCH 09/12] docs(hotkey): add custom hotkeys implementation plan --- .../plans/2026-06-04-custom-hotkeys.md | 1386 +++++++++++++++++ 1 file changed, 1386 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-04-custom-hotkeys.md diff --git a/docs/superpowers/plans/2026-06-04-custom-hotkeys.md b/docs/superpowers/plans/2026-06-04-custom-hotkeys.md new file mode 100644 index 0000000..3313b96 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-custom-hotkeys.md @@ -0,0 +1,1386 @@ +# Custom Hotkeys Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let the user bind any keyboard key/combination *or* a mouse side/middle button as the push-to-talk hotkey, captured via a press-to-record UI, while keeping the existing presets. + +**Architecture:** Replace the Win32 `RegisterHotKey` listener with global low-level hooks (`WH_KEYBOARD_LL` + `WH_MOUSE_LL`) so arbitrary keys and mouse buttons work, releases are detected natively (no `GetAsyncKeyState` polling), and the trigger can be swallowed. A new pure `Hotkey` value object in `config.py` (modifiers + a single key/mouse trigger) is the single representation, serialized to one canonical string for QSettings, with backward-compatible parsing of the 7 legacy preset keys. The settings dialog gains a press-to-capture widget (Qt key/mouse events; no global hook needed while the dialog is focused). Validation (pure, testable) blocks dangerous bindings. + +**Tech Stack:** Python 3, PySide6 (Qt), Win32 via `ctypes` (`SetWindowsHookEx`/`CallNextHookEx`/`UnhookWindowsHookEx`/`PostThreadMessageW`), `unittest`. + +--- + +## File Structure + +- `src/utils.py` — add two `AppError` members. No other change. +- `src/config.py` — add `Hotkey` value object + constants (mouse ids, modifier-VK maps, safe-standalone sets, VK name table) + legacy migration. Remove the obsolete `RegisterHotKey` artifacts (`MOD_*`, `HotkeyBinding`, `HOTKEY_BINDINGS`). Switch `HOTKEY_OPTIONS` values and the `AppConfig.hotkey` default to canonical strings. Update `load_config`/`validate_config`. +- `src/hotkey.py` — rewrite `HotkeyListener` to install LL hooks; extract a pure matching core (`_on_kb_event`/`_on_mouse_event`/`_trigger_down`/`_trigger_up`) that is OS-independent and unit-tested. +- `src/settings_dialog.py` — add `HotkeyCaptureEdit` widget + pure conversion helpers + presets-combo-with-Custom + populate/collect wiring. +- `src/main.py` — parse `config.hotkey` → `Hotkey` when building the listener; keep the rest. +- Tests: `tests/test_hotkey_model.py` (new), `tests/test_hotkey_listener.py` (new), `tests/test_settings_hotkey.py` (new), update `tests/test_mappings.py` and `tests/test_tray_menu.py`. +- Docs: `docs/IMPLEMENTATION.md` (contract update), `CLAUDE.md` (hotkey note). + +Run the whole suite with: `python -m unittest discover -s tests -v` +Compile check: `python -m compileall src/` + +--- + +## Task 1: AppError members (`utils.py`) + +**Files:** +- Modify: `src/utils.py:28-29` + +- [ ] **Step 1: Add two enum members** + +In `src/utils.py`, replace the `HOTKEY_CONFLICT` line with the conflict line plus two new members: + +```python + HOTKEY_CONFLICT = "Hotkey conflict. Choose a different hotkey." + HOTKEY_INVALID = "That key combination can't be used. Add a modifier or pick another key." + HOTKEY_HOOK_FAILED = "Could not install the global hotkey listener." +``` + +- [ ] **Step 2: Verify import** + +Run: `python -c "from src.utils import AppError; print(AppError.HOTKEY_INVALID.value, AppError.HOTKEY_HOOK_FAILED.value)"` +Expected: prints both messages, no error. + +- [ ] **Step 3: Commit** + +```bash +git add src/utils.py +git commit -m "feat(hotkey): add invalid/hook-failed error codes" +``` + +--- + +## Task 2: `Hotkey` value object + constants (`config.py`) + +**Files:** +- Modify: `src/config.py` (constants block ~39-78) +- Test: `tests/test_hotkey_model.py` (create) + +The model is pure (no Qt, no Win32) so it is fully unit-testable on any OS. + +- [ ] **Step 1: Write failing tests** + +Create `tests/test_hotkey_model.py`: + +```python +import unittest + +from src.config import ( + Hotkey, + MOUSE_X1, + MOUSE_X2, + MOUSE_MIDDLE, +) + + +class HotkeyModelTests(unittest.TestCase): + def test_canonical_roundtrip_key_with_mods(self): + hk = Hotkey(frozenset({"ctrl", "alt"}), "key", 0x20) + self.assertEqual(hk.to_canonical(), "ctrl+alt+key:0x20") + self.assertEqual(Hotkey.parse("ctrl+alt+key:0x20"), hk) + + def test_canonical_orders_mods_consistently(self): + a = Hotkey(frozenset({"alt", "ctrl"}), "key", 0x44) + b = Hotkey(frozenset({"ctrl", "alt"}), "key", 0x44) + self.assertEqual(a.to_canonical(), b.to_canonical()) + self.assertEqual(a.to_canonical(), "ctrl+alt+key:0x44") + + def test_canonical_roundtrip_bare_key(self): + hk = Hotkey(frozenset(), "key", 0x91) # Scroll Lock + self.assertEqual(hk.to_canonical(), "key:0x91") + self.assertEqual(Hotkey.parse("key:0x91"), hk) + + def test_canonical_roundtrip_mouse(self): + hk = Hotkey(frozenset({"ctrl"}), "mouse", MOUSE_X1) + self.assertEqual(hk.to_canonical(), "ctrl+mouse:x1") + self.assertEqual(Hotkey.parse("ctrl+mouse:x1"), hk) + self.assertEqual(Hotkey.parse("mouse:x2"), Hotkey(frozenset(), "mouse", MOUSE_X2)) + self.assertEqual(Hotkey.parse("mouse:middle"), Hotkey(frozenset(), "mouse", MOUSE_MIDDLE)) + + def test_parse_legacy_keys_migrate(self): + self.assertEqual(Hotkey.parse("ctrl_alt_space"), Hotkey(frozenset({"ctrl", "alt"}), "key", 0x20)) + self.assertEqual(Hotkey.parse("scroll_lock"), Hotkey(frozenset(), "key", 0x91)) + self.assertEqual(Hotkey.parse("pause"), Hotkey(frozenset(), "key", 0x13)) + + def test_parse_invalid_returns_none(self): + self.assertIsNone(Hotkey.parse("")) + self.assertIsNone(Hotkey.parse("garbage")) + self.assertIsNone(Hotkey.parse("ctrl+mouse:x9")) + + def test_label_human_readable(self): + self.assertEqual(Hotkey(frozenset({"ctrl", "alt"}), "key", 0x20).to_label(), "Ctrl+Alt+Space") + self.assertEqual(Hotkey(frozenset(), "key", 0x91).to_label(), "Scroll Lock") + self.assertEqual(Hotkey(frozenset({"ctrl"}), "mouse", MOUSE_X1).to_label(), "Ctrl+Mouse Back") + self.assertEqual(Hotkey(frozenset(), "mouse", MOUSE_MIDDLE).to_label(), "Mouse Middle") + + def test_validate_ok_with_modifier(self): + self.assertIsNone(Hotkey(frozenset({"ctrl", "alt"}), "key", 0x20).validate()) + + def test_validate_ok_safe_standalone(self): + self.assertIsNone(Hotkey(frozenset(), "key", 0x91).validate()) # Scroll Lock + self.assertIsNone(Hotkey(frozenset(), "key", 0x70).validate()) # F1 + self.assertIsNone(Hotkey(frozenset(), "mouse", MOUSE_X1).validate()) + + def test_validate_rejects_bare_normal_key(self): + self.assertIsNotNone(Hotkey(frozenset(), "key", 0x20).validate()) # bare Space + self.assertIsNotNone(Hotkey(frozenset(), "key", 0x41).validate()) # bare A + + def test_validate_rejects_modifier_as_trigger(self): + self.assertIsNotNone(Hotkey(frozenset({"ctrl"}), "key", 0x11).validate()) # trigger is Ctrl + + def test_validate_rejects_unknown_mouse_button(self): + self.assertIsNotNone(Hotkey(frozenset(), "mouse", 99).validate()) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m unittest tests.test_hotkey_model -v` +Expected: FAIL — `ImportError: cannot import name 'Hotkey'`. + +- [ ] **Step 3: Implement the model** + +In `src/config.py`, replace the entire block from `MOD_CONTROL = 0x0002` (line 39) through the `HOTKEY_BINDINGS = { ... }` dict (ending line 78) with the following. (This deletes `MOD_*`, `HotkeyBinding`, and `HOTKEY_BINDINGS`, which the new LL-hook listener no longer needs.) + +```python +# Mouse trigger ids (our own discriminators, not Win32 constants). +MOUSE_X1 = 1 # "back" side button (XBUTTON1) +MOUSE_X2 = 2 # "forward" side button (XBUTTON2) +MOUSE_MIDDLE = 3 # middle / wheel button + +_MOUSE_TOKEN_TO_CODE = {"x1": MOUSE_X1, "x2": MOUSE_X2, "middle": MOUSE_MIDDLE} +_MOUSE_CODE_TO_TOKEN = {v: k for k, v in _MOUSE_TOKEN_TO_CODE.items()} +_MOUSE_CODE_TO_LABEL = {MOUSE_X1: "Mouse Back", MOUSE_X2: "Mouse Forward", MOUSE_MIDDLE: "Mouse Middle"} + +# Canonical modifier order for serialization/labels. +_MOD_ORDER = ("ctrl", "alt", "shift", "win") +_MOD_LABEL = {"ctrl": "Ctrl", "alt": "Alt", "shift": "Shift", "win": "Win"} + +# Win32 virtual-key codes that ARE modifiers (generic + L/R variants). +# A trigger key may never be one of these. +MODIFIER_VKS = frozenset({0x10, 0x11, 0x12, 0x5B, 0x5C, 0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5}) + +# Map a modifier VK (as reported by the LL keyboard hook) to its canonical name. +MODIFIER_VK_TO_NAME = { + 0x10: "shift", 0xA0: "shift", 0xA1: "shift", + 0x11: "ctrl", 0xA2: "ctrl", 0xA3: "ctrl", + 0x12: "alt", 0xA4: "alt", 0xA5: "alt", + 0x5B: "win", 0x5C: "win", +} + +# Keys safe to bind alone (won't eat normal typing / clicking). +SAFE_STANDALONE_KEYS = frozenset( + set(range(0x70, 0x88)) # F1..F24 + | {0x91, # Scroll Lock + 0x13, # Pause + 0x2D, # Insert + 0x2C, # PrintScreen + 0x5D, # Apps / Menu + 0x90} # Num Lock +) + +# Human-readable names for common VK codes (labels only). +_VK_NAMES = { + 0x08: "Backspace", 0x09: "Tab", 0x0D: "Enter", 0x13: "Pause", + 0x1B: "Esc", 0x20: "Space", 0x21: "Page Up", 0x22: "Page Down", + 0x23: "End", 0x24: "Home", 0x25: "Left", 0x26: "Up", 0x27: "Right", + 0x28: "Down", 0x2C: "PrintScreen", 0x2D: "Insert", 0x2E: "Delete", + 0x5D: "Menu", 0x90: "Num Lock", 0x91: "Scroll Lock", +} +_VK_NAMES.update({c: chr(c) for c in range(0x30, 0x3A)}) # 0-9 +_VK_NAMES.update({c: chr(c) for c in range(0x41, 0x5B)}) # A-Z +_VK_NAMES.update({0x70 + i: f"F{i + 1}" for i in range(24)}) # F1..F24 + + +def _vk_label(vk: int) -> str: + return _VK_NAMES.get(vk, f"Key 0x{vk:02X}") + + +# Legacy preset keys (pre-custom-hotkey format) → canonical Hotkey. +_LEGACY_HOTKEYS = { + "ctrl_alt_space": ("ctrl+alt", 0x20), + "ctrl_shift_space": ("ctrl+shift", 0x20), + "ctrl_alt_d": ("ctrl+alt", 0x44), + "ctrl_alt_s": ("ctrl+alt", 0x53), + "ctrl_alt_v": ("ctrl+alt", 0x56), + "scroll_lock": ("", 0x91), + "pause": ("", 0x13), +} + + +@dataclass(frozen=True) +class Hotkey: + """A push-to-talk binding: a set of modifiers + a single key or mouse trigger. + + ``mods`` is a subset of {"ctrl","alt","shift","win"}. ``kind`` is "key" or + "mouse". ``code`` is a Win32 virtual-key code (kind="key") or one of the + ``MOUSE_*`` ids (kind="mouse"). + """ + + mods: frozenset + kind: str + code: int + + def to_canonical(self) -> str: + prefix = "".join(f"{m}+" for m in _MOD_ORDER if m in self.mods) + if self.kind == "mouse": + token = _MOUSE_CODE_TO_TOKEN.get(self.code, str(self.code)) + return f"{prefix}mouse:{token}" + return f"{prefix}key:0x{self.code:02X}" + + def to_label(self) -> str: + prefix = "".join(f"{_MOD_LABEL[m]}+" for m in _MOD_ORDER if m in self.mods) + if self.kind == "mouse": + return prefix + _MOUSE_CODE_TO_LABEL.get(self.code, f"Mouse {self.code}") + return prefix + _vk_label(self.code) + + def validate(self) -> str | None: + """Return an error message if this binding is unsafe, else None.""" + if self.kind == "mouse": + if self.code not in _MOUSE_CODE_TO_TOKEN: + return "Only the side or middle mouse buttons can be used." + return None + if self.code in MODIFIER_VKS: + return "Pick a non-modifier key, then add Ctrl/Alt/Shift as modifiers." + if self.code in SAFE_STANDALONE_KEYS: + return None + if not self.mods: + return "Add a modifier (Ctrl/Alt/Shift) or choose a function key." + return None + + @classmethod + def parse(cls, value: str) -> "Hotkey | None": + """Parse a canonical string or a legacy preset key. None if invalid.""" + if not value: + return None + if value in _LEGACY_HOTKEYS: + mod_str, code = _LEGACY_HOTKEYS[value] + mods = frozenset(p for p in mod_str.split("+") if p) + return cls(mods, "key", code) + + parts = value.split("+") + trigger = parts[-1] + mod_parts = parts[:-1] + if any(m not in _MOD_ORDER for m in mod_parts): + return None + mods = frozenset(mod_parts) + + if trigger.startswith("mouse:"): + token = trigger[len("mouse:"):] + if token not in _MOUSE_TOKEN_TO_CODE: + return None + return cls(mods, "mouse", _MOUSE_TOKEN_TO_CODE[token]) + if trigger.startswith("key:"): + try: + code = int(trigger[len("key:"):], 16) + except ValueError: + return None + return cls(mods, "key", code) + return None +``` + +Also update the module imports note: `Hotkey` uses `frozenset` from builtins; `dataclass` is already imported (line 9). No new imports needed. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m unittest tests.test_hotkey_model -v` +Expected: PASS (all 12 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/config.py tests/test_hotkey_model.py +git commit -m "feat(hotkey): add Hotkey value object with parse/label/validate" +``` + +--- + +## Task 3: Presets, defaults, load/validate integration (`config.py`) + +**Files:** +- Modify: `src/config.py` (`HOTKEY_OPTIONS` ~45-53, `AppConfig.hotkey` line 115, `load_config` ~350-351, `validate_config` ~389-390) +- Test: update `tests/test_mappings.py` + +- [ ] **Step 1: Rewrite the mappings tests** + +Replace the whole body of `tests/test_mappings.py` with: + +```python +import unittest + +from src.config import ( + HOTKEY_OPTIONS, + POST_KEY_OPTIONS, + AppConfig, + Hotkey, +) + + +class MappingTests(unittest.TestCase): + def test_default_hotkey_is_ctrl_alt_space(self) -> None: + self.assertEqual(AppConfig().hotkey, "ctrl+alt+key:0x20") + parsed = Hotkey.parse(AppConfig().hotkey) + self.assertEqual(parsed, Hotkey(frozenset({"ctrl", "alt"}), "key", 0x20)) + + def test_first_preset_is_ctrl_alt_space(self) -> None: + self.assertEqual(HOTKEY_OPTIONS[0], ("ctrl+alt+key:0x20", "Ctrl+Alt+Space")) + + def test_all_presets_parse_validate_and_relabel(self) -> None: + for value, label in HOTKEY_OPTIONS: + hk = Hotkey.parse(value) + self.assertIsNotNone(hk, f"preset {value!r} must parse") + self.assertIsNone(hk.validate(), f"preset {value!r} must be valid") + self.assertEqual(hk.to_label(), label, f"preset {value!r} label mismatch") + + def test_presets_do_not_offer_bare_modifiers(self) -> None: + for value, _label in HOTKEY_OPTIONS: + hk = Hotkey.parse(value) + self.assertNotIn(hk.code, (0x10, 0x11, 0x12), "no bare modifier presets") + + def test_post_key_options_include_none(self) -> None: + self.assertIn(("none", "None"), POST_KEY_OPTIONS) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `python -m unittest tests.test_mappings -v` +Expected: FAIL — preset values are still legacy strings like `"ctrl_alt_space"`. + +- [ ] **Step 3: Update `HOTKEY_OPTIONS` to canonical strings** + +In `src/config.py`, replace the `HOTKEY_OPTIONS` list (lines 45-53) with: + +```python +# App-level options shared by settings and tray menus. Values are canonical +# Hotkey strings (see Hotkey.to_canonical); labels come from Hotkey.to_label. +HOTKEY_OPTIONS: list[tuple[str, str]] = [ + ("ctrl+alt+key:0x20", "Ctrl+Alt+Space"), + ("ctrl+shift+key:0x20", "Ctrl+Shift+Space"), + ("ctrl+alt+key:0x44", "Ctrl+Alt+D"), + ("ctrl+alt+key:0x53", "Ctrl+Alt+S"), + ("ctrl+alt+key:0x56", "Ctrl+Alt+V"), + ("key:0x91", "Scroll Lock"), + ("key:0x13", "Pause"), +] +``` + +- [ ] **Step 4: Update the `AppConfig.hotkey` default** + +In `src/config.py` line 115, change: + +```python + hotkey: str = "ctrl_alt_space" +``` + +to: + +```python + hotkey: str = "ctrl+alt+key:0x20" +``` + +- [ ] **Step 5: Update `load_config` fallback** + +In `src/config.py`, replace lines 350-351: + +```python + if cfg.hotkey not in HOTKEY_BINDINGS: + cfg.hotkey = "ctrl_alt_space" +``` + +with (normalizes legacy values to canonical and falls back if unparseable/invalid): + +```python + parsed_hotkey = Hotkey.parse(cfg.hotkey) + if parsed_hotkey is None or parsed_hotkey.validate() is not None: + cfg.hotkey = "ctrl+alt+key:0x20" + else: + cfg.hotkey = parsed_hotkey.to_canonical() +``` + +- [ ] **Step 6: Update `validate_config`** + +In `src/config.py`, replace lines 389-390: + +```python + if cfg.hotkey not in HOTKEY_BINDINGS: + issues.append(ConfigValidationIssue("Choose a supported global hotkey.", 0)) +``` + +with: + +```python + parsed_hotkey = Hotkey.parse(cfg.hotkey) + if parsed_hotkey is None or parsed_hotkey.validate() is not None: + issues.append(ConfigValidationIssue("Choose a valid global hotkey.", 0)) +``` + +- [ ] **Step 7: Run tests** + +Run: `python -m unittest tests.test_mappings tests.test_hotkey_model -v` +Expected: PASS. + +- [ ] **Step 8: Verify config smoke + compile** + +Run: `python -m compileall src/config.py` then `python -c "from src.config import load_config, AppConfig; c=AppConfig(); print(c.hotkey)"` +Expected: prints `ctrl+alt+key:0x20`, no error. + +- [ ] **Step 9: Commit** + +```bash +git add src/config.py tests/test_mappings.py +git commit -m "feat(hotkey): store hotkeys as canonical strings with legacy migration" +``` + +--- + +## Task 4: LL-hook listener (`hotkey.py`) + +**Files:** +- Rewrite: `src/hotkey.py` +- Test: `tests/test_hotkey_listener.py` (create) + +The matching core is pure (touches only `self._held`, `self._armed`, `self._mode`, `self._hotkey`, `self._bridge`) so it is tested without Win32. Win32 install lives in `start()`/`_message_loop`. + +- [ ] **Step 1: Write failing tests for the matching core** + +Create `tests/test_hotkey_listener.py`: + +```python +import os +import unittest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtWidgets import QApplication + +from src.config import Hotkey, MOUSE_X1 +from src.hotkey import ( + HotkeyListener, + HotkeyMode, + WM_KEYDOWN, + WM_KEYUP, + WM_XBUTTONDOWN, + WM_XBUTTONUP, +) +from src.utils import SignalBridge + +_app = QApplication.instance() or QApplication([]) + + +def _listener(hotkey, mode): + bridge = SignalBridge() + pressed = [] + released = [] + bridge.hotkey_pressed.connect(lambda: pressed.append(1)) + bridge.hotkey_released.connect(lambda: released.append(1)) + return HotkeyListener(hotkey, mode, bridge), pressed, released + + +VK_LCTRL = 0xA2 +VK_LALT = 0xA4 + + +class HoldKeyTests(unittest.TestCase): + def test_full_combo_press_and_release(self): + hk = Hotkey(frozenset({"ctrl", "alt"}), "key", 0x20) + listener, pressed, released = _listener(hk, HotkeyMode.HOLD) + + self.assertFalse(listener._on_kb_event(WM_KEYDOWN, VK_LCTRL)) # modifier passes + self.assertFalse(listener._on_kb_event(WM_KEYDOWN, VK_LALT)) + self.assertTrue(listener._on_kb_event(WM_KEYDOWN, 0x20)) # trigger suppressed + self.assertEqual(pressed, [1]) + self.assertEqual(released, []) + + self.assertTrue(listener._on_kb_event(WM_KEYUP, 0x20)) # release suppressed + self.assertEqual(released, [1]) + + def test_autorepeat_does_not_re_emit(self): + hk = Hotkey(frozenset({"ctrl", "alt"}), "key", 0x20) + listener, pressed, _ = _listener(hk, HotkeyMode.HOLD) + listener._on_kb_event(WM_KEYDOWN, VK_LCTRL) + listener._on_kb_event(WM_KEYDOWN, VK_LALT) + listener._on_kb_event(WM_KEYDOWN, 0x20) + listener._on_kb_event(WM_KEYDOWN, 0x20) # autorepeat + listener._on_kb_event(WM_KEYDOWN, 0x20) # autorepeat + self.assertEqual(pressed, [1]) + + def test_wrong_modifiers_do_not_fire(self): + hk = Hotkey(frozenset({"ctrl", "alt"}), "key", 0x20) + listener, pressed, _ = _listener(hk, HotkeyMode.HOLD) + listener._on_kb_event(WM_KEYDOWN, VK_LCTRL) # only ctrl, alt missing + self.assertFalse(listener._on_kb_event(WM_KEYDOWN, 0x20)) # not suppressed + self.assertEqual(pressed, []) + + def test_extra_modifier_blocks_match(self): + hk = Hotkey(frozenset({"ctrl"}), "key", 0x20) + listener, pressed, _ = _listener(hk, HotkeyMode.HOLD) + listener._on_kb_event(WM_KEYDOWN, VK_LCTRL) + listener._on_kb_event(WM_KEYDOWN, VK_LALT) # extra alt held + self.assertFalse(listener._on_kb_event(WM_KEYDOWN, 0x20)) + self.assertEqual(pressed, []) + + +class ToggleKeyTests(unittest.TestCase): + def test_toggle_emits_pressed_each_time_no_released(self): + hk = Hotkey(frozenset(), "key", 0x91) # Scroll Lock, no mods + listener, pressed, released = _listener(hk, HotkeyMode.TOGGLE) + self.assertTrue(listener._on_kb_event(WM_KEYDOWN, 0x91)) + self.assertTrue(listener._on_kb_event(WM_KEYUP, 0x91)) + self.assertTrue(listener._on_kb_event(WM_KEYDOWN, 0x91)) + self.assertEqual(pressed, [1, 1]) + self.assertEqual(released, []) + + +class MouseTests(unittest.TestCase): + def test_mouse_x1_with_ctrl(self): + hk = Hotkey(frozenset({"ctrl"}), "mouse", MOUSE_X1) + listener, pressed, released = _listener(hk, HotkeyMode.HOLD) + listener._on_kb_event(WM_KEYDOWN, VK_LCTRL) + # mouseData high word = XBUTTON1 (0x0001) + self.assertTrue(listener._on_mouse_event(WM_XBUTTONDOWN, 0x0001 << 16)) + self.assertEqual(pressed, [1]) + self.assertTrue(listener._on_mouse_event(WM_XBUTTONUP, 0x0001 << 16)) + self.assertEqual(released, [1]) + + def test_left_button_ignored(self): + hk = Hotkey(frozenset({"ctrl"}), "mouse", MOUSE_X1) + listener, pressed, _ = _listener(hk, HotkeyMode.HOLD) + listener._on_kb_event(WM_KEYDOWN, VK_LCTRL) + self.assertFalse(listener._on_mouse_event(0x0201, 0)) # WM_LBUTTONDOWN + self.assertEqual(pressed, []) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `python -m unittest tests.test_hotkey_listener -v` +Expected: FAIL — `ImportError` (new symbols / changed constructor not present yet). + +- [ ] **Step 3: Rewrite `src/hotkey.py`** + +Replace the ENTIRE file `src/hotkey.py` with: + +```python +"""Global hotkey listener using Win32 low-level hooks (WH_KEYBOARD_LL + WH_MOUSE_LL). + +Supports arbitrary keys, mouse side/middle buttons, hold/toggle modes, and +swallowing the trigger event. The matching core (_on_kb_event / _on_mouse_event) +is pure and OS-independent; only start()/stop() touch Win32. +""" + +from __future__ import annotations + +import logging +import platform +import threading +from enum import Enum + +from src.config import ( + Hotkey, + MODIFIER_VK_TO_NAME, + MOUSE_MIDDLE, + MOUSE_X1, + MOUSE_X2, +) +from src.utils import AppError, ScreamerError, SignalBridge + +log = logging.getLogger(__name__) + +# Win32 message constants (also imported by tests). +WM_QUIT = 0x0012 +WM_KEYDOWN = 0x0100 +WM_KEYUP = 0x0101 +WM_SYSKEYDOWN = 0x0104 +WM_SYSKEYUP = 0x0105 +WM_MBUTTONDOWN = 0x0207 +WM_MBUTTONUP = 0x0208 +WM_XBUTTONDOWN = 0x020B +WM_XBUTTONUP = 0x020C + +WH_KEYBOARD_LL = 13 +WH_MOUSE_LL = 14 +HC_ACTION = 0 + +_KEY_DOWN = frozenset({WM_KEYDOWN, WM_SYSKEYDOWN}) +_KEY_UP = frozenset({WM_KEYUP, WM_SYSKEYUP}) + +# XBUTTON discriminators in the high word of MSLLHOOKSTRUCT.mouseData. +_XBUTTON1 = 0x0001 +_XBUTTON2 = 0x0002 + + +class HotkeyMode(Enum): + HOLD = "hold" + TOGGLE = "toggle" + + +class HotkeyListener: + """Low-level-hook hotkey listener with hold/toggle modes and trigger suppression.""" + + def __init__(self, hotkey: Hotkey, mode: HotkeyMode, bridge: SignalBridge) -> None: + self._hotkey = hotkey + self._mode = mode + self._bridge = bridge + self._thread: threading.Thread | None = None + self._thread_id: int = 0 + self._stop_event = threading.Event() + # Matching state. + self._held: set[str] = set() + self._armed = False + # Keep ctypes callbacks alive across the message loop's lifetime. + self._kb_proc = None + self._mouse_proc = None + self._kb_hook = None + self._mouse_hook = None + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def start(self) -> None: + if platform.system() != "Windows": + raise ScreamerError(AppError.UNSUPPORTED_PLATFORM, "Low-level hooks require Windows") + self._stop_event.clear() + self._held.clear() + self._armed = False + self._thread = threading.Thread(target=self._message_loop, daemon=True) + self._thread.start() + log.info("HotkeyListener started: %s mode=%s", self._hotkey.to_canonical(), self._mode.value) + + def stop(self) -> None: + if platform.system() != "Windows": + return + self._stop_event.set() + if self._thread_id: + import ctypes + ctypes.windll.user32.PostThreadMessageW(self._thread_id, WM_QUIT, 0, 0) + if self._thread is not None: + self._thread.join(timeout=5.0) + self._thread = None + self._thread_id = 0 + log.info("HotkeyListener stopped") + + def set_mode(self, mode: HotkeyMode) -> None: + self._mode = mode + self._armed = False + log.info("Hotkey mode changed to %s", mode.value) + + # ------------------------------------------------------------------ + # Pure matching core (OS-independent; unit-tested) + # ------------------------------------------------------------------ + + def _on_kb_event(self, wparam: int, vk: int) -> bool: + """Handle a keyboard hook event. Return True to suppress (swallow) it.""" + mod = MODIFIER_VK_TO_NAME.get(vk) + if mod is not None: + if wparam in _KEY_DOWN: + self._held.add(mod) + elif wparam in _KEY_UP: + self._held.discard(mod) + return False # modifiers always pass through + + if self._hotkey.kind != "key" or vk != self._hotkey.code: + return False + + if wparam in _KEY_DOWN: + return self._trigger_down() + if wparam in _KEY_UP: + return self._trigger_up() + return False + + def _on_mouse_event(self, wparam: int, mouse_data: int) -> bool: + """Handle a mouse hook event. Return True to suppress (swallow) it.""" + if wparam == WM_MBUTTONDOWN: + btn, is_down = MOUSE_MIDDLE, True + elif wparam == WM_MBUTTONUP: + btn, is_down = MOUSE_MIDDLE, False + elif wparam in (WM_XBUTTONDOWN, WM_XBUTTONUP): + high = (mouse_data >> 16) & 0xFFFF + if high == _XBUTTON1: + btn = MOUSE_X1 + elif high == _XBUTTON2: + btn = MOUSE_X2 + else: + return False + is_down = wparam == WM_XBUTTONDOWN + else: + return False # left/right/move/wheel — never our trigger + + if self._hotkey.kind != "mouse" or btn != self._hotkey.code: + return False + return self._trigger_down() if is_down else self._trigger_up() + + def _trigger_down(self) -> bool: + if self._armed: + return True # autorepeat / duplicate down while held + if self._held != self._hotkey.mods: + return False + self._armed = True + self._bridge.hotkey_pressed.emit() + return True + + def _trigger_up(self) -> bool: + if not self._armed: + return False + self._armed = False + if self._mode == HotkeyMode.HOLD: + self._bridge.hotkey_released.emit() + return True + + # ------------------------------------------------------------------ + # Win32 message loop + hook installation + # ------------------------------------------------------------------ + + def _message_loop(self) -> None: + import ctypes + import ctypes.wintypes + + user32 = ctypes.windll.user32 # type: ignore[attr-defined] + kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] + + lresult = getattr(ctypes.wintypes, "LRESULT", ctypes.c_ssize_t) + ulong_ptr = getattr(ctypes.wintypes, "ULONG_PTR", ctypes.c_size_t) + HOOKPROC = ctypes.WINFUNCTYPE( + lresult, ctypes.c_int, ctypes.wintypes.WPARAM, ctypes.wintypes.LPARAM + ) + + class KBDLLHOOKSTRUCT(ctypes.Structure): + _fields_ = [ + ("vkCode", ctypes.wintypes.DWORD), + ("scanCode", ctypes.wintypes.DWORD), + ("flags", ctypes.wintypes.DWORD), + ("time", ctypes.wintypes.DWORD), + ("dwExtraInfo", ulong_ptr), + ] + + class POINT(ctypes.Structure): + _fields_ = [("x", ctypes.wintypes.LONG), ("y", ctypes.wintypes.LONG)] + + class MSLLHOOKSTRUCT(ctypes.Structure): + _fields_ = [ + ("pt", POINT), + ("mouseData", ctypes.wintypes.DWORD), + ("flags", ctypes.wintypes.DWORD), + ("time", ctypes.wintypes.DWORD), + ("dwExtraInfo", ulong_ptr), + ] + + _declare_win32_functions(ctypes, user32, kernel32, HOOKPROC, lresult) + + def kb_callback(ncode, wparam, lparam): + if ncode == HC_ACTION: + kb = ctypes.cast(lparam, ctypes.POINTER(KBDLLHOOKSTRUCT)).contents + if self._on_kb_event(wparam, kb.vkCode): + return 1 + return user32.CallNextHookEx(None, ncode, wparam, lparam) + + def mouse_callback(ncode, wparam, lparam): + if ncode == HC_ACTION: + ms = ctypes.cast(lparam, ctypes.POINTER(MSLLHOOKSTRUCT)).contents + if self._on_mouse_event(wparam, ms.mouseData): + return 1 + return user32.CallNextHookEx(None, ncode, wparam, lparam) + + self._kb_proc = HOOKPROC(kb_callback) + self._mouse_proc = HOOKPROC(mouse_callback) + + self._thread_id = kernel32.GetCurrentThreadId() + hmod = kernel32.GetModuleHandleW(None) + + self._kb_hook = user32.SetWindowsHookExW(WH_KEYBOARD_LL, self._kb_proc, hmod, 0) + self._mouse_hook = user32.SetWindowsHookExW(WH_MOUSE_LL, self._mouse_proc, hmod, 0) + if not self._kb_hook or not self._mouse_hook: + log.error("SetWindowsHookEx failed: kb=%s mouse=%s", self._kb_hook, self._mouse_hook) + self._bridge.error_occurred.emit(AppError.HOTKEY_HOOK_FAILED) + self._uninstall(user32) + return + + log.info("Hooks installed for %s", self._hotkey.to_canonical()) + + msg = ctypes.wintypes.MSG() + while user32.GetMessageW(ctypes.byref(msg), None, 0, 0) > 0: + user32.TranslateMessage(ctypes.byref(msg)) + user32.DispatchMessageW(ctypes.byref(msg)) + + self._uninstall(user32) + log.info("Message loop exited") + + def _uninstall(self, user32) -> None: + if self._kb_hook: + user32.UnhookWindowsHookEx(self._kb_hook) + self._kb_hook = None + if self._mouse_hook: + user32.UnhookWindowsHookEx(self._mouse_hook) + self._mouse_hook = None + + +def _declare_win32_functions(ctypes, user32, kernel32, hookproc, lresult) -> None: + wintypes = ctypes.wintypes + kernel32.GetModuleHandleW.restype = ctypes.c_void_p + kernel32.GetModuleHandleW.argtypes = [wintypes.LPCWSTR] + kernel32.GetCurrentThreadId.restype = wintypes.DWORD + kernel32.GetCurrentThreadId.argtypes = [] + + user32.SetWindowsHookExW.restype = ctypes.c_void_p + user32.SetWindowsHookExW.argtypes = [ctypes.c_int, hookproc, ctypes.c_void_p, wintypes.DWORD] + user32.UnhookWindowsHookEx.restype = wintypes.BOOL + user32.UnhookWindowsHookEx.argtypes = [ctypes.c_void_p] + user32.CallNextHookEx.restype = lresult + user32.CallNextHookEx.argtypes = [ctypes.c_void_p, ctypes.c_int, wintypes.WPARAM, wintypes.LPARAM] + user32.GetMessageW.restype = wintypes.BOOL + user32.GetMessageW.argtypes = [ctypes.POINTER(wintypes.MSG), wintypes.HWND, wintypes.UINT, wintypes.UINT] + user32.TranslateMessage.restype = wintypes.BOOL + user32.TranslateMessage.argtypes = [ctypes.POINTER(wintypes.MSG)] + user32.DispatchMessageW.restype = lresult + user32.DispatchMessageW.argtypes = [ctypes.POINTER(wintypes.MSG)] + user32.PostThreadMessageW.restype = wintypes.BOOL + user32.PostThreadMessageW.argtypes = [wintypes.DWORD, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM] + + +# --------------------------------------------------------------------------- +# CLI smoke test +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + if platform.system() != "Windows": + print("HotkeyListener requires Windows.") + print("On non-Windows: start() raises ScreamerError(UNSUPPORTED_PLATFORM).") + print("Import test passed — no crash at import time.") + raise SystemExit(0) + + from PySide6.QtWidgets import QApplication + import sys + + from src.config import Hotkey + + app = QApplication(sys.argv) + bridge = SignalBridge() + bridge.hotkey_pressed.connect(lambda: print("PRESSED")) + bridge.hotkey_released.connect(lambda: print("RELEASED")) + + listener = HotkeyListener(Hotkey(frozenset(), "key", 0x91), HotkeyMode.HOLD, bridge) + listener.start() + print("Press Scroll Lock to test (Ctrl+C to quit)...") + try: + app.exec() + except KeyboardInterrupt: + pass + finally: + listener.stop() +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m unittest tests.test_hotkey_listener -v` +Expected: PASS (all tests). + +- [ ] **Step 5: Compile + import check** + +Run: `python -m compileall src/hotkey.py` and `python -c "import src.hotkey; print('OK')"` +Expected: `OK`. + +- [ ] **Step 6: Commit** + +```bash +git add src/hotkey.py tests/test_hotkey_listener.py +git commit -m "feat(hotkey): replace RegisterHotKey with low-level keyboard/mouse hooks" +``` + +--- + +## Task 5: Capture UI in settings dialog (`settings_dialog.py`) + +**Files:** +- Modify: `src/settings_dialog.py` (imports; `_build_general_tab` ~150-177; `_populate` ~302-303; `_collect` ~345; helpers near `_combo_index` ~549) +- Test: `tests/test_settings_hotkey.py` (create) + +- [ ] **Step 1: Write failing tests for pure helpers + populate/collect** + +Create `tests/test_settings_hotkey.py`: + +```python +import os +import unittest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QApplication + +from src.config import AppConfig, Hotkey, MOUSE_X1 +from src.settings_dialog import ( + SettingsDialog, + _mods_from_qt, + _mouse_button_to_code, +) + +_app = QApplication.instance() or QApplication([]) + + +class QtConversionTests(unittest.TestCase): + def test_mods_from_qt(self): + mods = _mods_from_qt(Qt.ControlModifier | Qt.AltModifier) + self.assertEqual(mods, frozenset({"ctrl", "alt"})) + self.assertEqual(_mods_from_qt(Qt.NoModifier), frozenset()) + self.assertEqual(_mods_from_qt(Qt.MetaModifier), frozenset({"win"})) + + def test_mouse_button_to_code(self): + self.assertEqual(_mouse_button_to_code(Qt.BackButton), MOUSE_X1) + self.assertIsNone(_mouse_button_to_code(Qt.LeftButton)) + + +class PopulateCollectTests(unittest.TestCase): + def test_roundtrip_preset(self): + cfg = AppConfig() # ctrl+alt+key:0x20 + dlg = SettingsDialog(cfg, devices=[], calibrate_fn=lambda *a, **k: None) + try: + dlg._collect() + self.assertEqual(dlg.get_config().hotkey, "ctrl+alt+key:0x20") + finally: + dlg.deleteLater() + + def test_roundtrip_custom(self): + cfg = AppConfig() + cfg.hotkey = "ctrl+mouse:x1" + dlg = SettingsDialog(cfg, devices=[], calibrate_fn=lambda *a, **k: None) + try: + self.assertEqual(dlg._captured_hotkey, Hotkey(frozenset({"ctrl"}), "mouse", MOUSE_X1)) + dlg._collect() + self.assertEqual(dlg.get_config().hotkey, "ctrl+mouse:x1") + finally: + dlg.deleteLater() + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `python -m unittest tests.test_settings_hotkey -v` +Expected: FAIL — `ImportError` for `_mods_from_qt` / `_mouse_button_to_code`. + +- [ ] **Step 3: Add imports** + +In `src/settings_dialog.py`, after the `QtCore` import line (13), add `QtGui` import, and add `Signal` to the QtCore import. Replace line 13: + +```python +from PySide6.QtCore import Qt +``` + +with: + +```python +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QKeyEvent, QMouseEvent +``` + +Add to the `src.config` import block (lines 35-45) the `Hotkey`, `MOUSE_MIDDLE`, `MOUSE_X1`, `MOUSE_X2` names: + +```python +from src.config import ( + AppConfig, + DEFAULT_LLM_SYSTEM_PROMPT, + HOTKEY_OPTIONS, + Hotkey, + MOUSE_MIDDLE, + MOUSE_X1, + MOUSE_X2, + POST_KEY_OPTIONS, + import_from_env, + load_config, + reset_config, + save_config, + validate_config, +) +``` + +- [ ] **Step 4: Add the pure helpers + capture widget** + +In `src/settings_dialog.py`, immediately before `def _combo_index(` (line 549), insert: + +```python +def _mods_from_qt(modifiers) -> frozenset: + """Map Qt.KeyboardModifiers to our canonical modifier-name set.""" + mods = set() + if modifiers & Qt.ControlModifier: + mods.add("ctrl") + if modifiers & Qt.AltModifier: + mods.add("alt") + if modifiers & Qt.ShiftModifier: + mods.add("shift") + if modifiers & Qt.MetaModifier: + mods.add("win") + return frozenset(mods) + + +_QT_MOUSE_TO_CODE = { + Qt.BackButton: MOUSE_X1, + Qt.ForwardButton: MOUSE_X2, + Qt.MiddleButton: MOUSE_MIDDLE, +} + +# Qt key codes that are modifiers (ignored as a trigger during capture). +# Stored as ints so membership works regardless of enum/int return type. +_QT_MODIFIER_KEYS = frozenset( + int(k) for k in (Qt.Key_Control, Qt.Key_Alt, Qt.Key_Shift, Qt.Key_Meta, Qt.Key_AltGr) +) + + +def _mouse_button_to_code(button): + """Map a Qt.MouseButton to a MOUSE_* code, or None if not bindable.""" + return _QT_MOUSE_TO_CODE.get(button) + + +class HotkeyCaptureEdit(QLineEdit): + """Read-only field that records the next key/mouse chord while recording. + + Emits ``captured`` with a Hotkey on a complete chord. Keyboard chords finalize + on the first non-modifier key; mouse chords finalize on a side/middle click. + """ + + captured = Signal(object) # Hotkey + cancelled = Signal() # Esc pressed during recording + + def __init__(self) -> None: + super().__init__() + self.setReadOnly(True) + self._recording = False + + def is_recording(self) -> bool: + return self._recording + + def start_recording(self) -> None: + self._recording = True + self.setText("press keys or a mouse button…") + self.setFocus(Qt.OtherFocusReason) + self.grabKeyboard() + + def stop_recording(self) -> None: + self._recording = False + self.releaseKeyboard() + + def show_hotkey(self, hotkey: Hotkey) -> None: + self.setText(hotkey.to_label()) + + def keyPressEvent(self, event: QKeyEvent) -> None: + if not self._recording: + super().keyPressEvent(event) + return + event.accept() + if int(event.key()) == int(Qt.Key_Escape): + self.cancelled.emit() + return + if event.isAutoRepeat() or int(event.key()) in _QT_MODIFIER_KEYS: + return + vk = event.nativeVirtualKey() + if not vk: + return + self.captured.emit(Hotkey(_mods_from_qt(event.modifiers()), "key", vk)) + + def mousePressEvent(self, event: QMouseEvent) -> None: + if not self._recording: + super().mousePressEvent(event) + return + code = _mouse_button_to_code(event.button()) + if code is None: + event.accept() # swallow left/right; only side/middle bind + return + event.accept() + self.captured.emit(Hotkey(_mods_from_qt(event.modifiers()), "mouse", code)) +``` + +- [ ] **Step 5: Build the capture row in `_build_general_tab`** + +In `src/settings_dialog.py`, replace the hotkey combo block (lines 154-157): + +```python + self._hotkey_combo = QComboBox() + for key, label in HOTKEY_OPTIONS: + self._hotkey_combo.addItem(label, key) + form.addRow("Hotkey:", self._hotkey_combo) +``` + +with: + +```python + self._captured_hotkey: Hotkey | None = None + + self._hotkey_combo = QComboBox() + for key, label in HOTKEY_OPTIONS: + self._hotkey_combo.addItem(label, key) + self._hotkey_combo.addItem("Custom…", "__custom__") + self._hotkey_combo.activated.connect(self._on_hotkey_preset_chosen) + form.addRow("Hotkey:", self._hotkey_combo) + + self._hotkey_capture = HotkeyCaptureEdit() + self._hotkey_capture.captured.connect(self._on_hotkey_captured) + self._hotkey_capture.cancelled.connect(self._stop_hotkey_recording) + self._hotkey_record_btn = QPushButton("Record") + self._hotkey_record_btn.setCheckable(True) + self._hotkey_record_btn.clicked.connect(self._on_hotkey_record_clicked) + capture_row = QHBoxLayout() + capture_row.addWidget(self._hotkey_capture, 1) + capture_row.addWidget(self._hotkey_record_btn) + form.addRow("", capture_row) + + self._hotkey_error = QLabel("") + self._hotkey_error.setStyleSheet("color: #c0392b;") + self._hotkey_error.setVisible(False) + form.addRow("", self._hotkey_error) +``` + +- [ ] **Step 6: Add the interaction handlers** + +In `src/settings_dialog.py`, immediately after `_build_general_tab` ends (before `# --- STT tab ---` comment near line 179), insert: + +```python + # --- Hotkey capture interaction ----------------------------------- + + def _set_captured_hotkey(self, hotkey: Hotkey) -> None: + """Store a validated hotkey and reflect it in combo + capture field.""" + self._captured_hotkey = hotkey + self._hotkey_capture.show_hotkey(hotkey) + self._hotkey_error.setVisible(False) + canonical = hotkey.to_canonical() + idx = _combo_index(self._hotkey_combo, canonical) + self._hotkey_combo.setCurrentIndex( + idx if idx >= 0 else _combo_index(self._hotkey_combo, "__custom__") + ) + + def _on_hotkey_preset_chosen(self, index: int) -> None: + data = self._hotkey_combo.itemData(index) + if data == "__custom__": + self._start_hotkey_recording() + return + hotkey = Hotkey.parse(data) + if hotkey is not None: + self._set_captured_hotkey(hotkey) + + def _start_hotkey_recording(self) -> None: + self._hotkey_record_btn.setChecked(True) + self._hotkey_record_btn.setText("Cancel") + self._hotkey_error.setVisible(False) + self._hotkey_capture.start_recording() + + def _stop_hotkey_recording(self) -> None: + self._hotkey_record_btn.setChecked(False) + self._hotkey_record_btn.setText("Record") + self._hotkey_capture.stop_recording() + if self._captured_hotkey is not None: + self._hotkey_capture.show_hotkey(self._captured_hotkey) + + def _on_hotkey_record_clicked(self, checked: bool) -> None: + if checked: + self._start_hotkey_recording() + else: + self._stop_hotkey_recording() + + def _on_hotkey_captured(self, hotkey: Hotkey) -> None: + error = hotkey.validate() + if error is not None: + self._hotkey_error.setText(error) + self._hotkey_error.setVisible(True) + return # stay in recording so the user can try again + self._set_captured_hotkey(hotkey) + self._stop_hotkey_recording() +``` + +- [ ] **Step 7: Wire populate + collect** + +In `src/settings_dialog.py` `_populate`, replace lines 302-303: + +```python + idx = _combo_index(self._hotkey_combo, cfg.hotkey) + self._hotkey_combo.setCurrentIndex(max(idx, 0)) +``` + +with: + +```python + hotkey = Hotkey.parse(cfg.hotkey) or Hotkey(frozenset({"ctrl", "alt"}), "key", 0x20) + self._set_captured_hotkey(hotkey) +``` + +In `_collect`, replace line 345: + +```python + cfg.hotkey = self._hotkey_combo.currentData() +``` + +with: + +```python + if self._captured_hotkey is not None: + cfg.hotkey = self._captured_hotkey.to_canonical() +``` + +- [ ] **Step 8: Run tests** + +Run: `python -m unittest tests.test_settings_hotkey -v` +Expected: PASS. + +- [ ] **Step 9: Compile + import** + +Run: `python -m compileall src/settings_dialog.py` and `python -c "import src.settings_dialog; print('OK')"` +Expected: `OK`. + +- [ ] **Step 10: Commit** + +```bash +git add src/settings_dialog.py tests/test_settings_hotkey.py +git commit -m "feat(hotkey): add press-to-capture hotkey UI in settings" +``` + +--- + +## Task 6: Wire the listener in `main.py` + +**Files:** +- Modify: `src/main.py` (config import block 27-35; `_make_listener` ~248-252) +- Test: update `tests/test_tray_menu.py` hotkey values to canonical + +- [ ] **Step 1: Update the failing tray test values** + +In `tests/test_tray_menu.py` `test_set_hotkey_rebuilds_by_default_but_can_skip` (lines 127 & 133), change the legacy strings to canonical: + +```python + tray_app._set_hotkey("ctrl+alt+key:0x20") +``` +and +```python + tray_app._set_hotkey("ctrl+shift+key:0x20", rebuild_menu=False) +``` + +- [ ] **Step 2: Run to confirm current behavior still references old listener** + +Run: `python -m unittest tests.test_tray_menu -v` +Expected: PASS already (these tests mock `_restart_hotkey`), but values are now canonical. If it fails, it's an unrelated import error — fix in Step 3/4 first. + +- [ ] **Step 3: Import `Hotkey` in main** + +In `src/main.py`, add `Hotkey` to the `src.config` import block (lines 27-35): + +```python +from src.config import ( + HOTKEY_OPTIONS, + POST_KEY_OPTIONS, + AppConfig, + Hotkey, + import_from_env, + load_config, + save_config, + validate_config, +) +``` + +- [ ] **Step 4: Parse the hotkey when building the listener** + +In `src/main.py`, replace `_make_listener` (lines 248-252): + +```python + def _make_listener(self) -> None: + """Create and start a HotkeyListener from current config, storing it on self.""" + mode = HotkeyMode.TOGGLE if self._config.recording_mode == "toggle" else HotkeyMode.HOLD + self._hotkey = HotkeyListener(self._config.hotkey, mode, self._bridge) + self._hotkey.start() +``` + +with: + +```python + def _make_listener(self) -> None: + """Create and start a HotkeyListener from current config, storing it on self.""" + mode = HotkeyMode.TOGGLE if self._config.recording_mode == "toggle" else HotkeyMode.HOLD + hotkey = Hotkey.parse(self._config.hotkey) or Hotkey(frozenset({"ctrl", "alt"}), "key", 0x20) + self._hotkey = HotkeyListener(hotkey, mode, self._bridge) + self._hotkey.start() +``` + +- [ ] **Step 5: Run tray tests + full suite** + +Run: `python -m unittest discover -s tests -v` +Expected: PASS (all tests across all files). + +- [ ] **Step 6: Compile + import main** + +Run: `python -m compileall src/` and `python -c "import src.main; print('OK')"` +Expected: `OK`. + +- [ ] **Step 7: Commit** + +```bash +git add src/main.py tests/test_tray_menu.py +git commit -m "feat(hotkey): build listener from parsed custom hotkey" +``` + +--- + +## Task 7: Docs + final verification + +**Files:** +- Modify: `docs/IMPLEMENTATION.md` (hotkey/config/settings contract), `CLAUDE.md` (hotkey note) + +- [ ] **Step 1: Update `docs/IMPLEMENTATION.md`** + +Find the hotkey section and the `HotkeyListener` / `HOTKEY_BINDINGS` contract. Replace references to `HotkeyListener(key: str, ...)` and `HOTKEY_BINDINGS` with the new contract: +- `HotkeyListener(hotkey: Hotkey, mode: HotkeyMode, bridge: SignalBridge)` +- `config.Hotkey` value object: `mods: frozenset`, `kind: "key"|"mouse"`, `code: int`; methods `to_canonical()`, `to_label()`, `validate() -> str|None`, classmethod `parse(str) -> Hotkey|None`. +- Listener now uses `WH_KEYBOARD_LL` + `WH_MOUSE_LL` (not `RegisterHotKey`); trigger is suppressed; legacy preset strings auto-migrate. + +(Edit prose to match; exact wording follows the existing doc's style.) + +- [ ] **Step 2: Update `CLAUDE.md` hotkey line** + +In `CLAUDE.md`, in the Conventions section, replace the line: + +``` +- Hotkey strings map to Win32 virtual key codes via `_VK_MAP` in `hotkey.py`; add new hotkey options there and in `HOTKEY_OPTIONS` in `settings_dialog.py`. +``` + +with: + +``` +- Hotkeys are `config.Hotkey` value objects (modifiers + one key/mouse trigger), serialized to a canonical string (`ctrl+alt+key:0x20`, `ctrl+mouse:x1`). Presets live in `HOTKEY_OPTIONS` (`config.py`); the listener uses low-level hooks (`WH_KEYBOARD_LL`/`WH_MOUSE_LL`). Add safe-standalone keys via `SAFE_STANDALONE_KEYS` in `config.py`. +``` + +- [ ] **Step 3: Final full verification** + +Run all three: +```bash +python -m compileall src/ +python -c "import src; print('OK')" +python -m unittest discover -s tests -v +``` +Expected: compile OK, `OK`, and all tests PASS. + +- [ ] **Step 4: Commit** + +```bash +git add docs/IMPLEMENTATION.md CLAUDE.md +git commit -m "docs(hotkey): document custom hotkey model and low-level hooks" +``` + +--- + +## Notes / Decisions baked in + +- **Suppression:** the matched trigger down/up is swallowed (returns 1 from the hook); modifiers always pass through. +- **Safety:** bare normal keys (letters/space) need a modifier; only F-keys, lock/pause/insert/printscreen/menu/numlock and mouse side/middle buttons may bind alone; left/right mouse and modifier-only triggers are rejected. +- **Backward compatibility:** the 7 legacy preset strings parse and are re-serialized to canonical on load. +- **Boundaries preserved:** `config.py` stays Qt-free and Win32-free (pure model); Qt→model conversion lives in `settings_dialog.py`; Win32 lives in `hotkey.py`; `hotkey.py` importing `config` matches the existing pattern. +- **Tray submenu:** still shows presets; when the active hotkey is custom, no preset radio is checked (custom is configured via Settings). This is intentional minimalism, not a gap. From ff11d92e569a7e4e655d11f2b90118595974431f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristi=C3=A1n=20Partl?= Date: Thu, 4 Jun 2026 12:26:43 +0200 Subject: [PATCH 10/12] fix(hotkey): close start/stop shutdown race that could leak global hooks --- src/hotkey.py | 66 ++++++++++++++++++++++++++++++----- tests/test_hotkey_listener.py | 31 ++++++++++++++++ 2 files changed, 88 insertions(+), 9 deletions(-) diff --git a/src/hotkey.py b/src/hotkey.py index a893c6e..1630aa1 100644 --- a/src/hotkey.py +++ b/src/hotkey.py @@ -10,6 +10,7 @@ import logging import platform import threading +import time from enum import Enum from src.config import ( @@ -37,6 +38,7 @@ WH_KEYBOARD_LL = 13 WH_MOUSE_LL = 14 HC_ACTION = 0 +PM_NOREMOVE = 0x0000 _KEY_DOWN = frozenset({WM_KEYDOWN, WM_SYSKEYDOWN}) _KEY_UP = frozenset({WM_KEYUP, WM_SYSKEYUP}) @@ -61,6 +63,7 @@ def __init__(self, hotkey: Hotkey, mode: HotkeyMode, bridge: SignalBridge) -> No self._thread: threading.Thread | None = None self._thread_id: int = 0 self._stop_event = threading.Event() + self._ready = threading.Event() # Matching state. self._held: set[str] = set() self._armed = False @@ -78,24 +81,51 @@ def start(self) -> None: if platform.system() != "Windows": raise ScreamerError(AppError.UNSUPPORTED_PLATFORM, "Low-level hooks require Windows") self._stop_event.clear() + self._ready.clear() self._held.clear() self._armed = False self._thread = threading.Thread(target=self._message_loop, daemon=True) self._thread.start() + # Block until the loop thread has created its message queue and attempted + # to install the hooks, so callers know the listener is live and so a + # subsequent stop() can reliably post WM_QUIT. + if not self._ready.wait(timeout=5.0): + log.warning("Hotkey listener did not signal readiness within 5s") log.info("HotkeyListener started: %s mode=%s", self._hotkey.to_canonical(), self._mode.value) def stop(self) -> None: if platform.system() != "Windows": return self._stop_event.set() - if self._thread_id: - import ctypes - ctypes.windll.user32.PostThreadMessageW(self._thread_id, WM_QUIT, 0, 0) - if self._thread is not None: - self._thread.join(timeout=5.0) + thread = self._thread + if thread is None: + self._thread_id = 0 + return + + # The loop thread sets _ready once its message queue exists; wait so the + # WM_QUIT below is delivered instead of dropped during a startup race. + self._ready.wait(timeout=5.0) + + import ctypes + + user32 = ctypes.windll.user32 # type: ignore[attr-defined] + tid = self._thread_id + if tid: + # Retry until the post is accepted or the thread has exited. + for _ in range(100): + if not thread.is_alive(): + break + if user32.PostThreadMessageW(tid, WM_QUIT, 0, 0): + break + time.sleep(0.02) + + thread.join(timeout=5.0) + if thread.is_alive(): + log.error("Hotkey thread did not exit; hooks may remain installed") + else: self._thread = None - self._thread_id = 0 - log.info("HotkeyListener stopped") + self._thread_id = 0 + log.info("HotkeyListener stopped") def set_mode(self, mode: HotkeyMode) -> None: self._mode = mode @@ -224,9 +254,20 @@ def mouse_callback(ncode, wparam, lparam): self._thread_id = kernel32.GetCurrentThreadId() hmod = kernel32.GetModuleHandleW(None) + # Force-create this thread's message queue up front so a racing stop() + # can deliver WM_QUIT via PostThreadMessageW even before the pump runs. + msg = ctypes.wintypes.MSG() + user32.PeekMessageW(ctypes.byref(msg), None, 0, 0, PM_NOREMOVE) + self._kb_hook = user32.SetWindowsHookExW(WH_KEYBOARD_LL, self._kb_proc, hmod, 0) self._mouse_hook = user32.SetWindowsHookExW(WH_MOUSE_LL, self._mouse_proc, hmod, 0) - if not self._kb_hook or not self._mouse_hook: + hooks_ok = bool(self._kb_hook and self._mouse_hook) + + # Signal readiness once the queue exists and hooks were attempted, so + # start() can return and stop() can post WM_QUIT — even on failure. + self._ready.set() + + if not hooks_ok: log.error("SetWindowsHookEx failed: kb=%s mouse=%s", self._kb_hook, self._mouse_hook) self._bridge.error_occurred.emit(AppError.HOTKEY_HOOK_FAILED) self._uninstall(user32) @@ -234,7 +275,6 @@ def mouse_callback(ncode, wparam, lparam): log.info("Hooks installed for %s", self._hotkey.to_canonical()) - msg = ctypes.wintypes.MSG() while user32.GetMessageW(ctypes.byref(msg), None, 0, 0) > 0: user32.TranslateMessage(ctypes.byref(msg)) user32.DispatchMessageW(ctypes.byref(msg)) @@ -266,6 +306,14 @@ def _declare_win32_functions(ctypes, user32, kernel32, hookproc, lresult) -> Non user32.CallNextHookEx.argtypes = [ctypes.c_void_p, ctypes.c_int, wintypes.WPARAM, wintypes.LPARAM] user32.GetMessageW.restype = wintypes.BOOL user32.GetMessageW.argtypes = [ctypes.POINTER(wintypes.MSG), wintypes.HWND, wintypes.UINT, wintypes.UINT] + user32.PeekMessageW.restype = wintypes.BOOL + user32.PeekMessageW.argtypes = [ + ctypes.POINTER(wintypes.MSG), + wintypes.HWND, + wintypes.UINT, + wintypes.UINT, + wintypes.UINT, + ] user32.TranslateMessage.restype = wintypes.BOOL user32.TranslateMessage.argtypes = [ctypes.POINTER(wintypes.MSG)] user32.DispatchMessageW.restype = lresult diff --git a/tests/test_hotkey_listener.py b/tests/test_hotkey_listener.py index 3747146..5112fb4 100644 --- a/tests/test_hotkey_listener.py +++ b/tests/test_hotkey_listener.py @@ -1,4 +1,5 @@ import os +import platform import unittest os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") @@ -102,5 +103,35 @@ def test_left_button_ignored(self): self.assertEqual(pressed, []) +class LifecycleTests(unittest.TestCase): + def test_stop_without_start_is_safe(self): + hk = Hotkey(frozenset(), "key", 0x91) + listener, _p, _r = _listener(hk, HotkeyMode.HOLD) + # Must not raise or hang regardless of platform. + listener.stop() + self.assertIsNone(listener._thread) + + @unittest.skipUnless(platform.system() == "Windows", "LL hooks require Windows") + def test_start_then_stop_exits_cleanly(self): + hk = Hotkey(frozenset(), "key", 0x91) # Scroll Lock + listener, _p, _r = _listener(hk, HotkeyMode.HOLD) + listener.start() + self.assertTrue(listener._ready.is_set(), "start() must wait for readiness") + listener.stop() + self.assertIsNone(listener._thread, "thread reference cleared after clean exit") + self.assertEqual(listener._thread_id, 0) + self.assertIsNone(listener._kb_hook) + self.assertIsNone(listener._mouse_hook) + + @unittest.skipUnless(platform.system() == "Windows", "LL hooks require Windows") + def test_repeated_start_stop(self): + hk = Hotkey(frozenset(), "key", 0x91) + listener, _p, _r = _listener(hk, HotkeyMode.HOLD) + for _ in range(3): + listener.start() + listener.stop() + self.assertIsNone(listener._thread) + + if __name__ == "__main__": unittest.main() From 070db4510a2733f962f3afbc9a65d9766594c39d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristi=C3=A1n=20Partl?= Date: Thu, 4 Jun 2026 12:27:17 +0200 Subject: [PATCH 11/12] fix(hotkey): grab mouse during capture so side buttons record anywhere --- src/settings_dialog.py | 2 ++ tests/test_settings_hotkey.py | 52 ++++++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/settings_dialog.py b/src/settings_dialog.py index d26bc75..1b729f4 100644 --- a/src/settings_dialog.py +++ b/src/settings_dialog.py @@ -677,9 +677,11 @@ def start_recording(self) -> None: self.setText("press keys or a mouse button…") self.setFocus(Qt.OtherFocusReason) self.grabKeyboard() + self.grabMouse() def stop_recording(self) -> None: self._recording = False + self.releaseMouse() self.releaseKeyboard() def show_hotkey(self, hotkey: Hotkey) -> None: diff --git a/tests/test_settings_hotkey.py b/tests/test_settings_hotkey.py index 7e8b170..3b9a1c2 100644 --- a/tests/test_settings_hotkey.py +++ b/tests/test_settings_hotkey.py @@ -3,11 +3,13 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") -from PySide6.QtCore import Qt +from PySide6.QtCore import QEvent, QPointF, Qt +from PySide6.QtGui import QMouseEvent from PySide6.QtWidgets import QApplication from src.config import AppConfig, Hotkey, MOUSE_X1 from src.settings_dialog import ( + HotkeyCaptureEdit, SettingsDialog, _mods_from_qt, _mouse_button_to_code, @@ -50,5 +52,53 @@ def test_roundtrip_custom(self): dlg.deleteLater() +class _SpyCapture(HotkeyCaptureEdit): + def __init__(self): + super().__init__() + self.events = [] + + def grabKeyboard(self): + self.events.append("grab_kb") + + def grabMouse(self): + self.events.append("grab_mouse") + + def releaseKeyboard(self): + self.events.append("rel_kb") + + def releaseMouse(self): + self.events.append("rel_mouse") + + +class CaptureGrabTests(unittest.TestCase): + def test_start_recording_grabs_mouse_and_keyboard(self): + edit = _SpyCapture() + edit.start_recording() + self.assertIn("grab_kb", edit.events) + self.assertIn("grab_mouse", edit.events) + + def test_stop_recording_releases_mouse_and_keyboard(self): + edit = _SpyCapture() + edit.start_recording() + edit.stop_recording() + self.assertIn("rel_mouse", edit.events) + self.assertIn("rel_kb", edit.events) + + def test_side_button_press_emits_hotkey(self): + edit = _SpyCapture() + edit.start_recording() + captured = [] + edit.captured.connect(captured.append) + ev = QMouseEvent( + QEvent.Type.MouseButtonPress, + QPointF(0, 0), + Qt.BackButton, + Qt.BackButton, + Qt.NoModifier, + ) + edit.mousePressEvent(ev) + self.assertEqual(captured, [Hotkey(frozenset(), "mouse", MOUSE_X1)]) + + if __name__ == "__main__": unittest.main() From eb8c173592fe50dc1023e702646f9321d8701325 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristi=C3=A1n=20Partl?= Date: Thu, 4 Jun 2026 12:27:39 +0200 Subject: [PATCH 12/12] docs: add PR #13 review-fix plan --- .../plans/2026-06-04-hotkey-review-fixes.md | 372 ++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-04-hotkey-review-fixes.md diff --git a/docs/superpowers/plans/2026-06-04-hotkey-review-fixes.md b/docs/superpowers/plans/2026-06-04-hotkey-review-fixes.md new file mode 100644 index 0000000..7cd2d29 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-hotkey-review-fixes.md @@ -0,0 +1,372 @@ +# PR #13 Review Fixes Implementation Plan (hotkey shutdown race + mouse capture grab) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Address the two findings in DavidHruby1's review of PR #13: (P1) `HotkeyListener` start/stop shutdown race that can leak global hooks/threads, and (P2) mouse-button hotkey recording that isn't grabbed globally in the settings dialog. + +**Architecture:** Add a `threading.Event` readiness handshake between `start()` and the hook thread (queue created + hooks attempted before `start()` returns and before `stop()` posts `WM_QUIT`), make `stop()` post `WM_QUIT` with retry on the now-guaranteed queue and only clear thread state once the thread has actually exited. For the dialog, grab the mouse (not just the keyboard) while recording so global side/middle-button presses are captured anywhere. + +**Tech Stack:** Python `threading`, Win32 via `ctypes` (`PeekMessageW`, `PostThreadMessageW`, LL hooks), PySide6 (`grabMouse`/`releaseMouse`), `unittest`. + +--- + +## Context (verified against the rebased branch) + +- `src/hotkey.py`: `start()` (77-85) spawns the thread and returns immediately; `_thread_id` (224) is set inside `_message_loop` only after the thread runs. `stop()` (87-98) posts `WM_QUIT` only `if self._thread_id`, ignores the `PostThreadMessageW` result, then sets `_thread = None` unconditionally after a timed join. +- `src/settings_dialog.py`: `HotkeyCaptureEdit.start_recording()` (675-679) calls `grabKeyboard()` only; `stop_recording()` (681-683) calls `releaseKeyboard()` only. `mousePressEvent` (703-712) handles side/middle buttons but only those routed to the widget. +- Branch was rebased onto `origin/main` (`c274310`); suite is green (68 tests). The dev host is Windows, so the Win32 start/stop tests run locally and on the `windows-latest` CI job. + +## File Structure + +- Modify: `src/hotkey.py` — readiness event, queue pre-creation, robust `stop()`, `PeekMessageW` declaration, `import time`. +- Modify: `src/settings_dialog.py` — `grabMouse()`/`releaseMouse()` in start/stop recording. +- Modify: `tests/test_hotkey_listener.py` — Windows start/stop lifecycle tests. +- Modify: `tests/test_settings_hotkey.py` — mouse-grab + side-button capture tests. + +--- + +## Task 1: P1 — fix `HotkeyListener` start/stop shutdown race + +**Files:** +- Modify: `src/hotkey.py` +- Test: `tests/test_hotkey_listener.py` + +- [ ] **Step 1: Write failing/lifecycle tests** + +Append to `tests/test_hotkey_listener.py` (add `import platform` at the top of the file): + +```python +class LifecycleTests(unittest.TestCase): + def test_stop_without_start_is_safe(self): + hk = Hotkey(frozenset(), "key", 0x91) + listener, _p, _r = _listener(hk, HotkeyMode.HOLD) + # Must not raise or hang regardless of platform. + listener.stop() + self.assertIsNone(listener._thread) + + @unittest.skipUnless(platform.system() == "Windows", "LL hooks require Windows") + def test_start_then_stop_exits_cleanly(self): + hk = Hotkey(frozenset(), "key", 0x91) # Scroll Lock + listener, _p, _r = _listener(hk, HotkeyMode.HOLD) + listener.start() + self.assertTrue(listener._ready.is_set(), "start() must wait for readiness") + listener.stop() + self.assertIsNone(listener._thread, "thread reference cleared after clean exit") + self.assertEqual(listener._thread_id, 0) + self.assertIsNone(listener._kb_hook) + self.assertIsNone(listener._mouse_hook) + + @unittest.skipUnless(platform.system() == "Windows", "LL hooks require Windows") + def test_repeated_start_stop(self): + hk = Hotkey(frozenset(), "key", 0x91) + listener, _p, _r = _listener(hk, HotkeyMode.HOLD) + for _ in range(3): + listener.start() + listener.stop() + self.assertIsNone(listener._thread) +``` + +- [ ] **Step 2: Run tests to see the lifecycle failures** + +Run: `.venv/Scripts/python.exe -m unittest tests.test_hotkey_listener -v` +Expected: `test_stop_without_start_is_safe` FAILS or hangs is avoided (currently `stop()` returns early because `_thread` is None — it may already pass); the `_ready` attribute references in the Windows tests FAIL with `AttributeError: '_ready'` (not implemented yet). + +- [ ] **Step 3: Add `import time` and a `PM_NOREMOVE` constant** + +In `src/hotkey.py`, change the imports block: + +```python +import logging +import platform +import threading +from enum import Enum +``` +to: +```python +import logging +import platform +import threading +import time +from enum import Enum +``` + +And add a constant near the other Win32 message constants (after `HC_ACTION = 0`): + +```python +PM_NOREMOVE = 0x0000 +``` + +- [ ] **Step 4: Add the readiness event in `__init__`** + +In `src/hotkey.py` `__init__`, after `self._stop_event = threading.Event()`: + +```python + self._stop_event = threading.Event() + self._ready = threading.Event() +``` + +- [ ] **Step 5: Make `start()` wait for readiness** + +Replace `start()`: + +```python + def start(self) -> None: + if platform.system() != "Windows": + raise ScreamerError(AppError.UNSUPPORTED_PLATFORM, "Low-level hooks require Windows") + self._stop_event.clear() + self._ready.clear() + self._held.clear() + self._armed = False + self._thread = threading.Thread(target=self._message_loop, daemon=True) + self._thread.start() + # Block until the loop thread has created its message queue and attempted + # to install the hooks, so callers know the listener is live and so a + # subsequent stop() can reliably post WM_QUIT. + if not self._ready.wait(timeout=5.0): + log.warning("Hotkey listener did not signal readiness within 5s") + log.info("HotkeyListener started: %s mode=%s", self._hotkey.to_canonical(), self._mode.value) +``` + +- [ ] **Step 6: Make `stop()` robust** + +Replace `stop()`: + +```python + def stop(self) -> None: + if platform.system() != "Windows": + return + self._stop_event.set() + thread = self._thread + if thread is None: + self._thread_id = 0 + return + + # The loop thread sets _ready once its message queue exists; wait so the + # WM_QUIT below is delivered instead of dropped during a startup race. + self._ready.wait(timeout=5.0) + + import ctypes + + user32 = ctypes.windll.user32 # type: ignore[attr-defined] + tid = self._thread_id + if tid: + # Retry until the post is accepted or the thread has exited. + for _ in range(100): + if not thread.is_alive(): + break + if user32.PostThreadMessageW(tid, WM_QUIT, 0, 0): + break + time.sleep(0.02) + + thread.join(timeout=5.0) + if thread.is_alive(): + log.error("Hotkey thread did not exit; hooks may remain installed") + else: + self._thread = None + self._thread_id = 0 + log.info("HotkeyListener stopped") +``` + +- [ ] **Step 7: Pre-create the message queue and signal readiness in `_message_loop`** + +In `src/hotkey.py` `_message_loop`, replace the block from `self._thread_id = kernel32.GetCurrentThreadId()` through the `log.info("Hooks installed ...")` line and the message-pump `while` loop: + +```python + self._kb_proc = HOOKPROC(kb_callback) + self._mouse_proc = HOOKPROC(mouse_callback) + + self._thread_id = kernel32.GetCurrentThreadId() + hmod = kernel32.GetModuleHandleW(None) + + # Force-create this thread's message queue up front so a racing stop() + # can deliver WM_QUIT via PostThreadMessageW even before the pump runs. + msg = ctypes.wintypes.MSG() + user32.PeekMessageW(ctypes.byref(msg), None, 0, 0, PM_NOREMOVE) + + self._kb_hook = user32.SetWindowsHookExW(WH_KEYBOARD_LL, self._kb_proc, hmod, 0) + self._mouse_hook = user32.SetWindowsHookExW(WH_MOUSE_LL, self._mouse_proc, hmod, 0) + hooks_ok = bool(self._kb_hook and self._mouse_hook) + + # Signal readiness once the queue exists and hooks were attempted, so + # start() can return and stop() can post WM_QUIT — even on failure. + self._ready.set() + + if not hooks_ok: + log.error("SetWindowsHookEx failed: kb=%s mouse=%s", self._kb_hook, self._mouse_hook) + self._bridge.error_occurred.emit(AppError.HOTKEY_HOOK_FAILED) + self._uninstall(user32) + return + + log.info("Hooks installed for %s", self._hotkey.to_canonical()) + + while user32.GetMessageW(ctypes.byref(msg), None, 0, 0) > 0: + user32.TranslateMessage(ctypes.byref(msg)) + user32.DispatchMessageW(ctypes.byref(msg)) + + self._uninstall(user32) + log.info("Message loop exited") +``` + +- [ ] **Step 8: Declare `PeekMessageW`** + +In `_declare_win32_functions`, add (next to the other `user32` declarations): + +```python + user32.PeekMessageW.restype = wintypes.BOOL + user32.PeekMessageW.argtypes = [ + ctypes.POINTER(wintypes.MSG), + wintypes.HWND, + wintypes.UINT, + wintypes.UINT, + wintypes.UINT, + ] +``` + +- [ ] **Step 9: Run tests + lint + format** + +Run: +``` +.venv/Scripts/python.exe -m unittest tests.test_hotkey_listener -v +.venv/Scripts/python.exe -m ruff check src/ tests/ +.venv/Scripts/python.exe -m ruff format --check src/ tests/ +``` +Expected: all listener tests pass (including the Windows lifecycle tests on this host); ruff clean. If `format --check` flags the edited files, run `.venv/Scripts/python.exe -m ruff format src/ tests/` and re-check. + +- [ ] **Step 10: Commit** + +```bash +git add src/hotkey.py tests/test_hotkey_listener.py +git commit -m "fix(hotkey): close start/stop shutdown race that could leak global hooks" +``` + +--- + +## Task 2: P2 — grab the mouse while recording a hotkey + +**Files:** +- Modify: `src/settings_dialog.py` +- Test: `tests/test_settings_hotkey.py` + +- [ ] **Step 1: Write failing tests** + +Append to `tests/test_settings_hotkey.py` (add imports `from PySide6.QtCore import QEvent, QPointF`, `from PySide6.QtGui import QMouseEvent`, and `HotkeyCaptureEdit` to the `src.settings_dialog` import). Use a subclass to spy on the grab/release calls (robust against PySide6 method shadowing): + +```python +class _SpyCapture(HotkeyCaptureEdit): + def __init__(self): + super().__init__() + self.events = [] + + def grabKeyboard(self): + self.events.append("grab_kb") + + def grabMouse(self): + self.events.append("grab_mouse") + + def releaseKeyboard(self): + self.events.append("rel_kb") + + def releaseMouse(self): + self.events.append("rel_mouse") + + +class CaptureGrabTests(unittest.TestCase): + def test_start_recording_grabs_mouse_and_keyboard(self): + edit = _SpyCapture() + edit.start_recording() + self.assertIn("grab_kb", edit.events) + self.assertIn("grab_mouse", edit.events) + + def test_stop_recording_releases_mouse_and_keyboard(self): + edit = _SpyCapture() + edit.start_recording() + edit.stop_recording() + self.assertIn("rel_mouse", edit.events) + self.assertIn("rel_kb", edit.events) + + def test_side_button_press_emits_hotkey(self): + edit = _SpyCapture() + edit.start_recording() + captured = [] + edit.captured.connect(captured.append) + ev = QMouseEvent( + QEvent.Type.MouseButtonPress, + QPointF(0, 0), + Qt.BackButton, + Qt.BackButton, + Qt.NoModifier, + ) + edit.mousePressEvent(ev) + self.assertEqual(captured, [Hotkey(frozenset(), "mouse", MOUSE_X1)]) +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: `.venv/Scripts/python.exe -m unittest tests.test_settings_hotkey -v` +Expected: `test_start_recording_grabs_mouse_and_keyboard` FAILS (only "kb" recorded — `grabMouse` not called yet). + +- [ ] **Step 3: Grab/release the mouse in the widget** + +In `src/settings_dialog.py`, replace `start_recording` and `stop_recording`: + +```python + def start_recording(self) -> None: + self._recording = True + self.setText("press keys or a mouse button…") + self.setFocus(Qt.OtherFocusReason) + self.grabKeyboard() + self.grabMouse() + + def stop_recording(self) -> None: + self._recording = False + self.releaseMouse() + self.releaseKeyboard() +``` + +- [ ] **Step 4: Run tests + lint + format** + +Run: +``` +.venv/Scripts/python.exe -m unittest tests.test_settings_hotkey -v +.venv/Scripts/python.exe -m ruff check src/ tests/ +.venv/Scripts/python.exe -m ruff format --check src/ tests/ +``` +Expected: all pass; ruff clean (run `ruff format` if needed). + +- [ ] **Step 5: Commit** + +```bash +git add src/settings_dialog.py tests/test_settings_hotkey.py +git commit -m "fix(hotkey): grab mouse during capture so side buttons record anywhere" +``` + +--- + +## Task 3: Full verification + plan doc + +- [ ] **Step 1: Whole-suite + import + compile** + +Run: +``` +.venv/Scripts/python.exe -m compileall src/ tests/ +.venv/Scripts/python.exe -c "import src; print('OK')" +.venv/Scripts/python.exe -m unittest discover -s tests +``` +Expected: compile OK, `OK`, all tests pass. + +- [ ] **Step 2: Commit the plan** + +```bash +git add docs/superpowers/plans/2026-06-04-hotkey-review-fixes.md +git commit -m "docs: add PR #13 review-fix plan" +``` + +--- + +## Notes / Decisions baked in + +- **Readiness handshake closes the race precisely as the reviewer asked:** the queue is created (via `PeekMessageW`) and hooks are attempted *before* `_ready` is set; `start()` waits for it (so the listener is live on return) and `stop()` waits for it (so `WM_QUIT` lands). +- **`stop()` no longer lies about shutdown:** it retries `PostThreadMessageW`, and only clears `_thread`/`_thread_id` if the thread actually exited; otherwise it logs an error and keeps the reference (no false "stopped"). +- **Win32 lifecycle tests run on this Windows host and the `windows-latest` CI job**; they are `skipUnless(Windows)` so the ubuntu `compile` job still imports cleanly. +- **Mouse grab:** `grabMouse()` routes global mouse presses to the capture field while recording, so side/middle buttons bind without hovering the field; released on stop. Covered by a stubbed-grab test plus a synthetic side-button capture test. +- Scope stays on the two review findings; no unrelated changes.