blitztext-app-linux/linux/blitztext/inputmode.py
mARTin-B78 a104f2cfc4 Add modifier-key input scheme (whisper-key style)
New inputmode.py: Ctrl+Win start, Ctrl stop+paste, Alt stop+paste+Enter, Esc
cancel; toggle and push-to-talk variants. Low-level pynput Listener with arming
so stop/send/cancel only fire while recording and only after the start chord is
released — a stray Ctrl tap while idle does nothing (unit-tested).

Daemon split into start/finish/cancel_dictation (+ send_enter -> press Enter),
with start_input/stop_input dispatching between "modifiers" and "hotkeys" modes.
New [input] config section; paste.press_enter for auto-send. Default mode is
"modifiers"; voice-keyword routing still applies to what you say.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 20:26:59 +02:00

113 lines
4.0 KiB
Python

"""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','cmd'}; '<ctrl>' -> {'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)
if self._state == "idle":
if self.start.issubset(self._pressed):
self._state = "arming"
self.daemon.start_dictation()
return
# Cancel works while arming or armed.
if self._is(token, self.cancel):
self._state = "idle"
self.daemon.cancel_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"