fix(text-injection): wait for modifiers to release before Wayland injection (#494)

* fix(text-injection): wait for shortcut modifiers to release before Wayland inject

Injection emits real key events, so a held modifier from a toggle/PTT shortcut
(e.g. Alt from Alt+R) turns the simulated Ctrl+V paste into Ctrl+Alt+V and
nothing pastes — while the clipboard still shows the text. Because transcription
can finish in tens of ms, the user is often still holding the key, causing
intermittent paste failures.

Poll evdev for held modifier keycodes and defer injection until they clear
(bounded by VOCALINUX_INJECT_MODIFIER_WAIT, default 1.0s, 0 disables). Returns
immediately when nothing is held, so no added latency in the common case.

* style: black-format injection modifier-wait test

* test: cover _held_modifier_keycodes evdev scanning (patch coverage)

* test: cover error paths in _held_modifier_keycodes

* fix(text-injection): treat non-finite modifier-wait env as default

float('inf') parses cleanly and is > 0, so it slipped past the guard and made
the wait deadline infinite -- a held modifier could block injection forever
(nan was nonsensical too). Sanitize VOCALINUX_INJECT_MODIFIER_WAIT via a helper
that falls back to the 1.0s default for non-finite values. Regression tests
added, including that 'inf' still terminates.

Addresses maintainer review on PR #494.
This commit is contained in:
Ben Williams
2026-07-13 14:40:04 -07:00
committed by GitHub
parent a9a6ae3c51
commit a2a5c569c5
2 changed files with 354 additions and 0 deletions
@@ -6,6 +6,7 @@ application, supporting both X11 and Wayland environments.
"""
import logging
import math
import os
import shutil
import subprocess
@@ -969,6 +970,96 @@ class TextInjector:
logger.warning(f"Paste simulation failed: {e}")
return False
# evdev keycodes for modifier keys. If any of these is still physically held
# when a Wayland injection fires, the injected keystrokes are modified: a
# held Alt turns the Ctrl+V paste into Ctrl+Alt+V (nothing pastes), and a
# held modifier turns typed letters into shortcuts. Because toggle/PTT
# shortcuts are themselves modifiers (e.g. Alt+R) and transcription can
# finish in tens of milliseconds, the user is often still holding the key
# when injection starts, causing intermittent "nothing pasted" failures.
_MODIFIER_KEYCODES = frozenset({29, 97, 56, 100, 42, 54, 125, 126})
def _held_modifier_keycodes(self) -> set:
"""Return modifier keycodes currently held on any physical keyboard."""
try:
import evdev
from evdev import ecodes
except ImportError:
return set()
held: set = set()
try:
for path in evdev.list_devices():
try:
device = evdev.InputDevice(path)
except (OSError, PermissionError):
continue
try:
if ecodes.EV_KEY not in device.capabilities():
continue
held |= set(device.active_keys()) & self._MODIFIER_KEYCODES
except OSError:
continue
finally:
device.close()
except Exception as e:
logger.debug(f"Could not read modifier key state: {e}")
return held
_DEFAULT_INJECT_MODIFIER_WAIT = 1.0
def _injection_modifier_wait_seconds(self) -> float:
"""Return the sanitized max seconds to wait for modifiers to release.
Reads VOCALINUX_INJECT_MODIFIER_WAIT and falls back to the default for
anything unusable: a non-numeric value, or a non-finite one. In
particular ``inf`` parses fine and is positive, so without this guard it
would make the wait deadline infinite and block injection forever while a
modifier stays held.
"""
raw = os.environ.get("VOCALINUX_INJECT_MODIFIER_WAIT")
if raw is None:
return self._DEFAULT_INJECT_MODIFIER_WAIT
try:
value = float(raw)
except ValueError:
return self._DEFAULT_INJECT_MODIFIER_WAIT
if not math.isfinite(value):
logger.warning(
"VOCALINUX_INJECT_MODIFIER_WAIT=%r is not a finite number; " "using default %.1fs",
raw,
self._DEFAULT_INJECT_MODIFIER_WAIT,
)
return self._DEFAULT_INJECT_MODIFIER_WAIT
return value
def _wait_for_modifiers_released(self) -> None:
"""Wait briefly for shortcut modifier keys to be released before injecting.
Returns immediately if no modifier is held (the common case), so this
adds no latency unless the user is still holding their toggle/PTT
shortcut. Bounded by VOCALINUX_INJECT_MODIFIER_WAIT seconds (default 1.0;
set to 0 to disable). Best-effort: if evdev/permissions are unavailable
it simply proceeds.
"""
max_wait = self._injection_modifier_wait_seconds()
if max_wait <= 0:
return
deadline = time.monotonic() + max_wait
waited = False
while time.monotonic() < deadline:
if not self._held_modifier_keycodes():
if waited:
logger.debug("Modifier keys released; proceeding with injection")
return
waited = True
time.sleep(0.015)
logger.debug(
f"Modifier keys still held after {max_wait:.2f}s; injecting anyway "
"(paste/typing may be affected)"
)
def _inject_with_wayland_tool(self, text: str):
"""
Inject text using a Wayland-compatible tool (wtype or ydotool).
@@ -984,6 +1075,11 @@ class TextInjector:
Raises:
subprocess.CalledProcessError: If the tool fails, with stderr captured
"""
# Wait for the shortcut modifier(s) to be released first, so a held Alt
# doesn't turn the Ctrl+V paste into Ctrl+Alt+V (nothing pastes) or
# modify typed keys.
self._wait_for_modifiers_released()
# ydotool emits *positional* evdev keycodes, so `ydotool type` is
# re-interpreted through the active keyboard layout, which it assumes is
# US QWERTY. On any other layout (AZERTY, QWERTZ, Dvorak, ...) the text