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:
Benjamin Eisele
2026-09-02 01:18:59 -07:00
committed by GitHub
parent 6684f6f584
commit a62c5fa70d
3 changed files with 371 additions and 27 deletions
+198 -11
View File
@@ -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:
+18 -2
View File
@@ -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
View File
@@ -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):