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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user