diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py index 6f80c83..af12b88 100644 --- a/src/vocalinux/text_injection/text_injector.py +++ b/src/vocalinux/text_injection/text_injector.py @@ -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: diff --git a/tests/test_text_injector.py b/tests/test_text_injector.py index 5e20716..8e71306 100644 --- a/tests/test_text_injector.py +++ b/tests/test_text_injector.py @@ -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.""" diff --git a/tests/test_text_injector_ext.py b/tests/test_text_injector_ext.py index b148d1b..1e4068d 100644 --- a/tests/test_text_injector_ext.py +++ b/tests/test_text_injector_ext.py @@ -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):