fix(wayland): drive keyboard shortcuts with wtype and ydotool (#715)
`_inject_shortcut_with_wayland_tool` could not deliver a shortcut on
either backend:
- wtype was refused outright with "Keyboard shortcuts not supported
with wtype", but wtype does chord: `-M` presses a modifier, `-k`
types a key, `-m` releases it. Modifiers must be released explicitly,
as wtype only drops them when the process exits.
- ydotool was handed the shortcut string as-is, `ydotool key ctrl+z`.
`ydotool key` takes raw evdev keycodes ("29:1 44:1 44:0 29:0") and
documents non-interpretable values as only causing a delay, so this
was a silent no-op that still returned True.
Parse the shortcut into a list of `(modifiers, key)` steps and emit the
right form per tool. Tokens are classified by name rather than by
position, because the mappings in `ActionHandler._SHORTCUT_ACTIONS` are
not all modifiers-first: `select_line` is "Home+shift+End", meaning
press Home, then Shift+End -- two sequential steps, not one chord. Each
non-modifier token closes a step and consumes the modifiers seen since
the previous one.
An unmappable ydotool key now fails loudly instead of emitting a
partial sequence, and a trailing modifier with no key is rejected at
parse time.
The X11 and WAYLAND_XDOTOOL paths are untouched -- xdotool already
accepts the string form directly.
Verified on Hyprland 0.56.2 with wtype 0.4 and ydotool 1.0.4, injecting
into a GTK3 window that logs key-press events. `wtype -M ctrl -k z -m
ctrl` arrives as z with the control modifier set; the two-step form
arrives as Home with no modifier, then End with shift; the raw keycode
form arrives as Control_L followed by z with control. Run on its own,
`ydotool key ctrl+z` exits 0 and delivers no key event at all -- the
failure this fixes.
`test_wtype_not_supported` asserted the old refusal, so it is replaced
by tests pinning the emitted argv for both tools, the two-step case,
and the unmappable-key rejection.
This commit is contained in:
@@ -1731,23 +1731,210 @@ class TextInjector:
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if self.wayland_tool == "wtype":
|
||||
# wtype doesn't support key combinations directly, so we can't implement this easily
|
||||
logger.warning("Keyboard shortcuts not supported with wtype")
|
||||
try:
|
||||
steps = self._parse_shortcut(shortcut)
|
||||
except ValueError as e:
|
||||
logger.error(f"Cannot parse shortcut '{shortcut}': {e}")
|
||||
return False
|
||||
|
||||
# A held toggle/PTT modifier rewrites the chord we are about to send, so
|
||||
# wait it out first -- same reason and placement as _inject_with_wayland_tool.
|
||||
self._wait_for_modifiers_released()
|
||||
|
||||
if self.wayland_tool == "wtype":
|
||||
# wtype does support combinations: -M presses a modifier, -k types a
|
||||
# key, -m releases it. Modifiers must be released explicitly -- wtype
|
||||
# only auto-releases them when the process exits.
|
||||
cmd = ["wtype"]
|
||||
for modifiers, key in steps:
|
||||
for mod in modifiers:
|
||||
cmd += ["-M", self._WTYPE_MODIFIERS[mod]]
|
||||
cmd += ["-k", key]
|
||||
for mod in reversed(modifiers):
|
||||
cmd += ["-m", self._WTYPE_MODIFIERS[mod]]
|
||||
elif self.wayland_tool == "ydotool":
|
||||
try:
|
||||
cmd = ["ydotool", "key", shortcut]
|
||||
subprocess.run(cmd, check=True, stderr=subprocess.PIPE, text=True, env=host_env())
|
||||
logger.debug(f"Keyboard shortcut '{shortcut}' injected successfully")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"ydotool shortcut error: {e.stderr}")
|
||||
return False
|
||||
if not self._ensure_ydotoold():
|
||||
logger.warning("ydotoold not ready before shortcut injection")
|
||||
|
||||
if self._ydotool_uses_legacy_named_keys():
|
||||
# 0.1.x: one named chord token per step, all in a single call --
|
||||
# it accepts any number of key sequences as positional args.
|
||||
tokens = []
|
||||
for modifiers, key in steps:
|
||||
token = self._ydotool_legacy_token(modifiers, key)
|
||||
if token is None:
|
||||
logger.error(
|
||||
f"No ydotool 0.1.x key name for '{key}' in shortcut '{shortcut}'"
|
||||
)
|
||||
return False
|
||||
tokens.append(token)
|
||||
cmd = ["ydotool", "key"] + tokens
|
||||
else:
|
||||
# 1.x takes RAW KEYCODES only ("29:1 44:1 44:0 29:0"), never
|
||||
# "ctrl+z" -- it documents non-interpretable values as "only cause
|
||||
# a delay", so the old string form was a silent no-op.
|
||||
seq = []
|
||||
for modifiers, key in steps:
|
||||
codes = []
|
||||
for name in modifiers + [key]:
|
||||
code = self._KEYCODES.get(name.lower())
|
||||
if code is None:
|
||||
logger.error(f"No keycode for '{name}' in shortcut '{shortcut}'")
|
||||
return False
|
||||
codes.append(code)
|
||||
seq += [f"{c}:1" for c in codes] + [f"{c}:0" for c in reversed(codes)]
|
||||
cmd = ["ydotool", "key"] + seq
|
||||
else:
|
||||
logger.warning(f"Keyboard shortcuts not supported with {self.wayland_tool}")
|
||||
return False
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
cmd,
|
||||
check=True,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=max(3, len(steps) * 0.5),
|
||||
env=host_env(),
|
||||
)
|
||||
logger.debug(f"Keyboard shortcut '{shortcut}' injected successfully")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"{self.wayland_tool} shortcut error: {e.stderr}")
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(f"{self.wayland_tool} shortcut injection timed out for '{shortcut}'")
|
||||
return False
|
||||
|
||||
# Modifier aliases -> wtype's names ("shift", "capslock", "ctrl", "logo",
|
||||
# "win", "alt", "altgr" per wtype(1)).
|
||||
_WTYPE_MODIFIERS = {
|
||||
"ctrl": "ctrl",
|
||||
"control": "ctrl",
|
||||
"shift": "shift",
|
||||
"alt": "alt",
|
||||
"altgr": "altgr",
|
||||
"super": "logo",
|
||||
"meta": "logo",
|
||||
"win": "logo",
|
||||
"logo": "logo",
|
||||
}
|
||||
|
||||
# Raw evdev keycodes from linux/input-event-codes.h, for ydotool. Covers the
|
||||
# modifiers plus every key referenced by ActionHandler._SHORTCUT_ACTIONS.
|
||||
_KEYCODES = {
|
||||
"ctrl": 29,
|
||||
"control": 29,
|
||||
"shift": 42,
|
||||
"alt": 56,
|
||||
"altgr": 100,
|
||||
"super": 125,
|
||||
"meta": 125,
|
||||
"win": 125,
|
||||
"logo": 125,
|
||||
"a": 30,
|
||||
"c": 46,
|
||||
"v": 47,
|
||||
"x": 45,
|
||||
"y": 21,
|
||||
"z": 44,
|
||||
"home": 102,
|
||||
"end": 107,
|
||||
"left": 105,
|
||||
"right": 106,
|
||||
"up": 103,
|
||||
"down": 108,
|
||||
"backspace": 14,
|
||||
"delete": 111,
|
||||
"return": 28,
|
||||
"enter": 28,
|
||||
"tab": 15,
|
||||
"escape": 1,
|
||||
"space": 57,
|
||||
}
|
||||
|
||||
# ydotool 0.1.x resolves names through libevdevPlus' Table_ModifierKeys and
|
||||
# Table_FunctionKeys (uppercased). A name it does not know silently falls back
|
||||
# to that name's FIRST CHARACTER and still exits 0 -- "altgr" types "a" -- so
|
||||
# only names confirmed present in those tables may ever be emitted.
|
||||
# Deliberately absent: altgr, escape (0.1.x spells it ESC), space, return.
|
||||
_YDOTOOL_LEGACY_NAMES = {
|
||||
"ctrl": "ctrl",
|
||||
"control": "ctrl",
|
||||
"shift": "shift",
|
||||
"alt": "alt",
|
||||
"super": "super",
|
||||
"meta": "meta",
|
||||
"win": "super",
|
||||
"logo": "super",
|
||||
"a": "a",
|
||||
"c": "c",
|
||||
"v": "v",
|
||||
"x": "x",
|
||||
"y": "y",
|
||||
"z": "z",
|
||||
"home": "home",
|
||||
"end": "end",
|
||||
"left": "left",
|
||||
"right": "right",
|
||||
"up": "up",
|
||||
"down": "down",
|
||||
"backspace": "backspace",
|
||||
"delete": "delete",
|
||||
"enter": "enter",
|
||||
"tab": "tab",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _ydotool_legacy_token(cls, modifiers, key: str):
|
||||
"""Build one ydotool 0.1.x ``mod+mod+key`` chord token.
|
||||
|
||||
Returns:
|
||||
The token, or None if any name has no 0.1.x spelling.
|
||||
"""
|
||||
names = []
|
||||
for name in list(modifiers) + [key]:
|
||||
mapped = cls._YDOTOOL_LEGACY_NAMES.get(name.lower())
|
||||
if mapped is None:
|
||||
return None
|
||||
names.append(mapped)
|
||||
return "+".join(names)
|
||||
|
||||
@classmethod
|
||||
def _parse_shortcut(cls, shortcut: str):
|
||||
"""Parse a shortcut string into a list of ``(modifiers, key)`` steps.
|
||||
|
||||
Tokens are classified by name rather than position, because the mappings
|
||||
in ``ActionHandler._SHORTCUT_ACTIONS`` are not all "modifiers first" --
|
||||
``select_line`` is ``"Home+shift+End"``, which means *press Home, then
|
||||
Shift+End*, i.e. two sequential steps rather than one chord. Each
|
||||
non-modifier token closes a step, consuming the modifiers seen since the
|
||||
previous one.
|
||||
|
||||
Returns:
|
||||
e.g. "ctrl+shift+Right" -> [(["ctrl", "shift"], "Right")]
|
||||
"Home+shift+End" -> [([], "Home"), (["shift"], "End")]
|
||||
|
||||
Raises:
|
||||
ValueError: if the shortcut has no key, or ends with a dangling modifier.
|
||||
"""
|
||||
steps = []
|
||||
pending = []
|
||||
for token in shortcut.split("+"):
|
||||
token = token.strip()
|
||||
if not token:
|
||||
continue
|
||||
if token.lower() in cls._WTYPE_MODIFIERS:
|
||||
pending.append(token.lower())
|
||||
else:
|
||||
steps.append((pending, token))
|
||||
pending = []
|
||||
if pending:
|
||||
raise ValueError(f"trailing modifier(s) with no key: {'+'.join(pending)}")
|
||||
if not steps:
|
||||
raise ValueError("no key found")
|
||||
return steps
|
||||
|
||||
def _log_current_window_info(self):
|
||||
"""Log information about the current window/application for debugging."""
|
||||
try:
|
||||
|
||||
@@ -418,7 +418,7 @@ class TestTextInjector(unittest.TestCase):
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_inject_keyboard_shortcut_wayland_wtype(self):
|
||||
"""Test keyboard shortcut injection with wtype (not supported)."""
|
||||
"""wtype chords a shortcut with -M/-k/-m rather than refusing it."""
|
||||
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
|
||||
self.mock_which.side_effect = lambda cmd: ("/usr/bin/wtype" if cmd == "wtype" else None)
|
||||
|
||||
@@ -430,9 +430,14 @@ class TestTextInjector(unittest.TestCase):
|
||||
injector = TextInjector()
|
||||
injector.wayland_tool = "wtype"
|
||||
injector.environment = DesktopEnvironment.WAYLAND
|
||||
injector._wait_for_modifiers_released = MagicMock()
|
||||
|
||||
result = injector._inject_shortcut_with_wayland_tool("ctrl+z")
|
||||
self.assertFalse(result) # wtype doesn't support shortcuts
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(
|
||||
self.mock_subprocess.call_args[0][0],
|
||||
["wtype", "-M", "ctrl", "-k", "z", "-m", "ctrl"],
|
||||
)
|
||||
|
||||
def test_inject_keyboard_shortcut_wayland_ydotool(self):
|
||||
"""Test keyboard shortcut injection with ydotool."""
|
||||
@@ -444,6 +449,12 @@ class TestTextInjector(unittest.TestCase):
|
||||
injector = TextInjector()
|
||||
injector.wayland_tool = "ydotool"
|
||||
injector.environment = DesktopEnvironment.WAYLAND
|
||||
# Pin the dialect: unpinned, the probe reads a mocked `key --help`
|
||||
# and falls through to its legacy default, so this would silently
|
||||
# stop covering the 1.x keycode form.
|
||||
injector._ydotool_legacy_named_keys = False
|
||||
injector._ensure_ydotoold = MagicMock(return_value=True)
|
||||
injector._wait_for_modifiers_released = MagicMock()
|
||||
|
||||
mock_process = MagicMock()
|
||||
mock_process.returncode = 0
|
||||
@@ -451,6 +462,11 @@ class TestTextInjector(unittest.TestCase):
|
||||
|
||||
result = injector._inject_shortcut_with_wayland_tool("ctrl+z")
|
||||
self.assertTrue(result)
|
||||
# ctrl=29, z=44 -- press in order, release in reverse.
|
||||
self.assertEqual(
|
||||
self.mock_subprocess.call_args[0][0],
|
||||
["ydotool", "key", "29:1", "44:1", "44:0", "29:0"],
|
||||
)
|
||||
|
||||
def test_inject_keyboard_shortcut_failure(self):
|
||||
"""Test keyboard shortcut injection failure handling."""
|
||||
|
||||
+155
-14
@@ -682,23 +682,164 @@ class TestShortcutWithXdotool(unittest.TestCase):
|
||||
self.assertFalse(result)
|
||||
|
||||
|
||||
def _make_shortcut_injector(tool, legacy=False):
|
||||
"""Injector wired for _inject_shortcut_with_wayland_tool.
|
||||
|
||||
``_make_injector`` sets neither ``wayland_tool`` nor the dialect cache, and
|
||||
an unpinned dialect would run the real ``key --help`` probe.
|
||||
"""
|
||||
from vocalinux.text_injection.text_injector import DesktopEnvironment
|
||||
|
||||
obj = _make_injector(DesktopEnvironment.WAYLAND)
|
||||
obj.wayland_tool = tool
|
||||
obj._ydotool_legacy_named_keys = legacy
|
||||
# _ensure_ydotoold spawns a real Popen, which patch("subprocess.run") misses.
|
||||
obj._ensure_ydotoold = MagicMock(return_value=True)
|
||||
obj._wait_for_modifiers_released = MagicMock()
|
||||
return obj
|
||||
|
||||
|
||||
class TestShortcutWithWaylandTool(unittest.TestCase):
|
||||
def test_wtype_not_supported(self):
|
||||
from vocalinux.text_injection.text_injector import DesktopEnvironment
|
||||
|
||||
obj = _make_injector(DesktopEnvironment.WAYLAND)
|
||||
obj.wayland_tool = "wtype"
|
||||
result = obj._inject_shortcut_with_wayland_tool("ctrl+a")
|
||||
self.assertFalse(result) # wtype doesn't support shortcuts
|
||||
|
||||
def test_ydotool_success(self):
|
||||
from vocalinux.text_injection.text_injector import DesktopEnvironment
|
||||
|
||||
obj = _make_injector(DesktopEnvironment.WAYLAND)
|
||||
obj.wayland_tool = "ydotool"
|
||||
with patch("subprocess.run"):
|
||||
def test_wtype_chords_modifiers(self):
|
||||
obj = _make_shortcut_injector("wtype")
|
||||
with patch("subprocess.run") as mock_run:
|
||||
result = obj._inject_shortcut_with_wayland_tool("ctrl+a")
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(
|
||||
mock_run.call_args[0][0],
|
||||
["wtype", "-M", "ctrl", "-k", "a", "-m", "ctrl"],
|
||||
)
|
||||
|
||||
def test_ydotool_success(self):
|
||||
obj = _make_shortcut_injector("ydotool", legacy=False)
|
||||
with patch("subprocess.run") as mock_run:
|
||||
result = obj._inject_shortcut_with_wayland_tool("ctrl+a")
|
||||
self.assertTrue(result)
|
||||
# Raw keycodes, not the literal string "ctrl+a": ctrl=29, a=30.
|
||||
self.assertEqual(
|
||||
mock_run.call_args[0][0],
|
||||
["ydotool", "key", "29:1", "30:1", "30:0", "29:0"],
|
||||
)
|
||||
|
||||
def test_ydotool_legacy_uses_named_chord(self):
|
||||
"""0.1.x takes named sequences; raw 1.x codes would type digits there."""
|
||||
obj = _make_shortcut_injector("ydotool", legacy=True)
|
||||
with patch("subprocess.run") as mock_run:
|
||||
self.assertTrue(obj._inject_shortcut_with_wayland_tool("ctrl+a"))
|
||||
self.assertEqual(mock_run.call_args[0][0], ["ydotool", "key", "ctrl+a"])
|
||||
|
||||
def test_ydotool_legacy_multi_step_is_one_call(self):
|
||||
"""0.1.x takes any number of sequences, one per step, in a single call."""
|
||||
obj = _make_shortcut_injector("ydotool", legacy=True)
|
||||
with patch("subprocess.run") as mock_run:
|
||||
self.assertTrue(obj._inject_shortcut_with_wayland_tool("Home+shift+End"))
|
||||
self.assertEqual(
|
||||
mock_run.call_args[0][0],
|
||||
["ydotool", "key", "home", "shift+end"],
|
||||
)
|
||||
self.assertEqual(mock_run.call_count, 1)
|
||||
|
||||
def test_ydotool_legacy_rejects_name_with_no_0_1_x_spelling(self):
|
||||
"""0.1.x maps an unknown name to its first letter, so never send one."""
|
||||
obj = _make_shortcut_injector("ydotool", legacy=True)
|
||||
with patch("subprocess.run") as mock_run:
|
||||
self.assertFalse(obj._inject_shortcut_with_wayland_tool("altgr+a"))
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_sequential_steps_not_treated_as_one_chord(self):
|
||||
""" "Home+shift+End" is press Home, then Shift+End -- two steps."""
|
||||
obj = _make_shortcut_injector("wtype")
|
||||
with patch("subprocess.run") as mock_run:
|
||||
self.assertTrue(obj._inject_shortcut_with_wayland_tool("Home+shift+End"))
|
||||
self.assertEqual(
|
||||
mock_run.call_args[0][0],
|
||||
["wtype", "-k", "Home", "-M", "shift", "-k", "End", "-m", "shift"],
|
||||
)
|
||||
|
||||
def test_unknown_ydotool_keycode_fails_loudly(self):
|
||||
obj = _make_shortcut_injector("ydotool", legacy=False)
|
||||
with patch("subprocess.run") as mock_run:
|
||||
self.assertFalse(obj._inject_shortcut_with_wayland_tool("ctrl+F13"))
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_waits_for_modifiers_before_injecting(self):
|
||||
"""A held PTT modifier would otherwise rewrite the chord."""
|
||||
obj = _make_shortcut_injector("wtype")
|
||||
with patch("subprocess.run"):
|
||||
self.assertTrue(obj._inject_shortcut_with_wayland_tool("ctrl+a"))
|
||||
obj._wait_for_modifiers_released.assert_called_once()
|
||||
|
||||
def test_ydotool_ensures_daemon_but_continues_when_not_ready(self):
|
||||
"""0.1.x often has no daemon at all, so a warning must not abort."""
|
||||
obj = _make_shortcut_injector("ydotool", legacy=False)
|
||||
obj._ensure_ydotoold = MagicMock(return_value=False)
|
||||
with patch("subprocess.run") as mock_run:
|
||||
self.assertTrue(obj._inject_shortcut_with_wayland_tool("ctrl+a"))
|
||||
obj._ensure_ydotoold.assert_called_once()
|
||||
self.assertTrue(mock_run.called)
|
||||
|
||||
def test_timeout_returns_false(self):
|
||||
obj = _make_shortcut_injector("wtype")
|
||||
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("wtype", 3)):
|
||||
self.assertFalse(obj._inject_shortcut_with_wayland_tool("ctrl+a"))
|
||||
|
||||
def test_runs_with_host_env_and_a_timeout(self):
|
||||
obj = _make_shortcut_injector("wtype")
|
||||
with patch("subprocess.run") as mock_run:
|
||||
self.assertTrue(obj._inject_shortcut_with_wayland_tool("ctrl+a"))
|
||||
kwargs = mock_run.call_args[1]
|
||||
self.assertIn("env", kwargs)
|
||||
self.assertGreaterEqual(kwargs["timeout"], 3)
|
||||
|
||||
|
||||
class TestYdotoolLegacyToken(unittest.TestCase):
|
||||
def test_matches_mains_own_paste_constants(self):
|
||||
from vocalinux.text_injection.text_injector import TextInjector
|
||||
|
||||
self.assertEqual(TextInjector._ydotool_legacy_token(["ctrl"], "v"), "ctrl+v")
|
||||
self.assertEqual(TextInjector._ydotool_legacy_token(["ctrl", "shift"], "v"), "ctrl+shift+v")
|
||||
|
||||
def test_super_is_not_canonicalised_to_wtypes_logo(self):
|
||||
"""0.1.x has SUPER but no LOGO, and would type "l" for the latter."""
|
||||
from vocalinux.text_injection.text_injector import TextInjector
|
||||
|
||||
self.assertEqual(TextInjector._ydotool_legacy_token(["win"], "x"), "super+x")
|
||||
|
||||
def test_names_absent_from_0_1_x_are_rejected(self):
|
||||
from vocalinux.text_injection.text_injector import TextInjector
|
||||
|
||||
for name in ("altgr", "escape", "space", "return"):
|
||||
self.assertIsNone(TextInjector._ydotool_legacy_token([], name), name)
|
||||
|
||||
|
||||
class TestParseShortcut(unittest.TestCase):
|
||||
def test_single_chord(self):
|
||||
from vocalinux.text_injection.text_injector import TextInjector
|
||||
|
||||
self.assertEqual(
|
||||
TextInjector._parse_shortcut("ctrl+shift+Right"),
|
||||
[(["ctrl", "shift"], "Right")],
|
||||
)
|
||||
|
||||
def test_multi_step(self):
|
||||
from vocalinux.text_injection.text_injector import TextInjector
|
||||
|
||||
self.assertEqual(
|
||||
TextInjector._parse_shortcut("Home+shift+End"),
|
||||
[([], "Home"), (["shift"], "End")],
|
||||
)
|
||||
|
||||
def test_trailing_modifier_rejected(self):
|
||||
from vocalinux.text_injection.text_injector import TextInjector
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
TextInjector._parse_shortcut("ctrl+shift")
|
||||
|
||||
def test_empty_rejected(self):
|
||||
from vocalinux.text_injection.text_injector import TextInjector
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
TextInjector._parse_shortcut("")
|
||||
|
||||
|
||||
class TestCopyToClipboard(unittest.TestCase):
|
||||
|
||||
Reference in New Issue
Block a user