"""Modifier-key input scheme (whisper-key style), as an alternative to combos. Default interaction: Ctrl+Win start recording Ctrl stop -> transcribe -> paste Alt stop -> transcribe -> paste -> Enter (auto-send) Esc cancel (discard, no transcription) Bare modifier taps are risky (a stray Ctrl+C could misfire), so stop/send/cancel are only armed *while recording* and only after the start modifiers are released. A push-to-talk variant records while the start chord is held and stops on release. Uses a low-level pynput Listener (press/release), not GlobalHotKeys. """ from __future__ import annotations def _token(key) -> str | None: """Canonical token for a pynput key: ctrl/alt/cmd/shift/esc or a char.""" from pynput import keyboard if isinstance(key, keyboard.Key): name = key.name for mod in ("ctrl", "alt", "cmd", "shift"): if name.startswith(mod): return mod return name # 'esc', 'space', 'enter', ... if isinstance(key, keyboard.KeyCode) and key.char: return key.char.lower() return None def parse_tokens(spec: str) -> frozenset[str]: """'+' -> {'ctrl','cmd'}; '' -> {'ctrl'}.""" return frozenset(p.strip().strip("<>").lower() for p in spec.split("+") if p.strip()) class ModifierScheme: def __init__(self, daemon, *, start: str, stop: str, send: str, cancel: str, push_to_talk: bool = False): self.daemon = daemon self.start = parse_tokens(start) self.stop = parse_tokens(stop) self.send = parse_tokens(send) self.cancel = parse_tokens(cancel) self.ptt = push_to_talk self._pressed: set[str] = set() self._state = "idle" # idle | arming | armed self._listener = None # -- listener lifecycle --------------------------------------------------- def start_listener(self): from pynput import keyboard self._listener = keyboard.Listener(on_press=self._on_press, on_release=self._on_release) self._listener.start() return self._listener def stop_listener(self) -> None: if self._listener is not None: self._listener.stop() self._listener = None # -- event handling ------------------------------------------------------- def _is(self, token: str, combo: frozenset[str]) -> bool: # A single-token trigger fires on that token; a chord needs all tokens held. if len(combo) == 1: return token in combo return combo.issubset(self._pressed) def _on_press(self, key) -> None: token = _token(key) if token is None: return self._pressed.add(token) # Cancel works while arming/armed AND when the daemon is recording via # wakeword (state stays "idle" in the scheme because wakeword bypasses # the key-press path entirely). if self._is(token, self.cancel) and ( self._state != "idle" or self.daemon.is_recording): self._state = "idle" self.daemon.cancel_dictation() return if self._state == "idle": if self.start.issubset(self._pressed): self._state = "arming" self.daemon.start_dictation() return if self._state == "armed": if self._is(token, self.send): self._state = "idle" self.daemon.finish_dictation(send_enter=True) elif self._is(token, self.stop): self._state = "idle" self.daemon.finish_dictation(send_enter=False) def _on_release(self, key) -> None: token = _token(key) if token is not None: self._pressed.discard(token) if self._state != "arming": return if self.ptt: # Push-to-talk: releasing the start chord stops and pastes. if not self.start.issubset(self._pressed): self._state = "idle" self.daemon.finish_dictation(send_enter=False) else: # Toggle: arm stop/send once the start modifiers are all released. if not (self._pressed & self.start): self._state = "armed"