diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py index 6e2cd68..5d3b50b 100644 --- a/src/vocalinux/text_injection/text_injector.py +++ b/src/vocalinux/text_injection/text_injector.py @@ -632,16 +632,86 @@ class TextInjector: logger.error(f"xdotool error: {e.stderr}") raise + def _has_non_ascii(self, text: str) -> bool: + """Check if text contains any non-ASCII characters.""" + try: + text.encode("ascii") + return False + except UnicodeEncodeError: + return True + + def _inject_via_clipboard_paste(self, text: str) -> bool: + """ + Inject text by copying to clipboard and simulating Ctrl+V with ydotool. + + This is the workaround for ydotool's inability to type non-ASCII/Unicode + characters (accented letters, CJK, etc.) because ydotool simulates evdev + key events which only cover US ASCII keycodes. See issue #362. + + Note: this temporarily overwrites the user's clipboard. There is no + attempt to restore it afterward, as there is no safe race-free way to + do so on Wayland. + + Returns: + True if successful, False otherwise + """ + logger.debug( + "Using clipboard-paste injection for non-ASCII text " + "(user clipboard will be temporarily overwritten)" + ) + + if not self._copy_to_clipboard(text): + logger.warning("Could not copy text to clipboard for paste injection") + return False + + # Simulate Ctrl+V via ydotool using evdev keycodes: + # KEY_LEFTCTRL=29, KEY_V=47; value 1=press, 0=release. + # wtype is intentionally not handled here: wtype uses the Wayland + # virtual-keyboard protocol which supports Unicode natively, so it + # never needs the clipboard-paste workaround. + try: + subprocess.run( + ["ydotool", "key", "29:1", "47:1", "47:0", "29:0"], + check=True, + stderr=subprocess.PIPE, + text=True, + timeout=3, + ) + logger.info(f"Text injected via clipboard paste: '{text[:20]}...' ({len(text)} chars)") + return True + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: + logger.warning(f"Paste simulation failed: {e}") + return False + def _inject_with_wayland_tool(self, text: str): """ Inject text using a Wayland-compatible tool (wtype or ydotool). + For ydotool: if the text contains non-ASCII characters (accented + letters like á, é, ú, CJK characters, etc.), uses clipboard-based + injection instead, because ydotool simulates evdev key events which + only cover US ASCII keycodes. See issue #362. + Args: text: The text to inject Raises: subprocess.CalledProcessError: If the tool fails, with stderr captured """ + # ydotool can only handle ASCII characters because it works at the + # evdev keycode level. For non-ASCII text, use clipboard paste instead. + if self.wayland_tool == "ydotool" and self._has_non_ascii(text): + logger.info( + "Text contains non-ASCII characters, using clipboard paste " + "for ydotool (evdev keycodes are ASCII-only)" + ) + if self._inject_via_clipboard_paste(text): + return + logger.warning( + "Clipboard paste failed, falling back to ydotool type " + "(non-ASCII characters may be dropped)" + ) + if self.wayland_tool == "wtype": cmd = ["wtype", text] else: # ydotool diff --git a/tests/test_text_injector.py b/tests/test_text_injector.py index 92b4285..213258e 100644 --- a/tests/test_text_injector.py +++ b/tests/test_text_injector.py @@ -486,6 +486,249 @@ class TestTextInjector(unittest.TestCase): # After failure, should have switched to WAYLAND_XDOTOOL self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_XDOTOOL) + def test_has_non_ascii_with_ascii_text(self): + """Test _has_non_ascii returns False for pure ASCII text.""" + with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}): + injector = TextInjector() + self.assertFalse(injector._has_non_ascii("Hello world")) + self.assertFalse(injector._has_non_ascii("123 test!")) + + def test_has_non_ascii_with_accented_text(self): + """Test _has_non_ascii returns True for accented characters (#362).""" + with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}): + injector = TextInjector() + self.assertTrue(injector._has_non_ascii("Esdrújula")) + self.assertTrue(injector._has_non_ascii("crème brûlée")) + self.assertTrue(injector._has_non_ascii("café")) + self.assertTrue(injector._has_non_ascii("niño")) + + @patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=False) + @patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False) + @patch("vocalinux.text_injection.text_injector.shutil.which") + @patch("vocalinux.text_injection.text_injector.subprocess.run") + def test_ydotool_non_ascii_uses_clipboard_paste( + self, mock_run, mock_which, mock_ibus_avail, mock_ibus_active + ): + """Test that ydotool uses clipboard paste for non-ASCII text (#362).""" + mock_which.side_effect = lambda x: x in ("ydotool", "wl-copy") + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}): + injector = TextInjector() + injector.wayland_tool = "ydotool" + injector.environment = DesktopEnvironment.WAYLAND + + # Reset call list so init calls don't interfere + mock_run.reset_mock() + injector._inject_with_wayland_tool("Esdrújula") + + # Should have called wl-copy and ydotool key (Ctrl+V), not ydotool type + calls = [c.args[0] for c in mock_run.call_args_list] + has_wl_copy = any(c[0] == "wl-copy" for c in calls) + has_ydotool_key = any(c[:2] == ["ydotool", "key"] for c in calls) + has_ydotool_type = any(c[:2] == ["ydotool", "type"] for c in calls) + + self.assertTrue(has_wl_copy, "Should use wl-copy for clipboard") + self.assertTrue(has_ydotool_key, "Should use ydotool key for Ctrl+V") + self.assertFalse(has_ydotool_type, "Should NOT use ydotool type for non-ASCII") + + @patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=False) + @patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False) + @patch("vocalinux.text_injection.text_injector.shutil.which") + @patch("vocalinux.text_injection.text_injector.subprocess.run") + def test_ydotool_ascii_uses_type_directly( + self, mock_run, mock_which, mock_ibus_avail, mock_ibus_active + ): + """Test that ydotool still uses type for plain ASCII text.""" + mock_which.side_effect = lambda x: x in ("ydotool", "wl-copy") + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}): + injector = TextInjector() + injector.wayland_tool = "ydotool" + injector.environment = DesktopEnvironment.WAYLAND + + mock_run.reset_mock() + injector._inject_with_wayland_tool("Hello world") + + calls = [c.args[0] for c in mock_run.call_args_list if c.args] + self.assertTrue( + any(c[:2] == ["ydotool", "type"] for c in calls), + "Should use ydotool type for ASCII text", + ) + self.assertFalse( + any(c[0] == "wl-copy" for c in calls), + "Should NOT invoke clipboard for ASCII text", + ) + + @patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=False) + @patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False) + @patch("vocalinux.text_injection.text_injector.shutil.which") + @patch("vocalinux.text_injection.text_injector.subprocess.run") + def test_ydotool_non_ascii_falls_back_to_type_when_clipboard_fails( + self, mock_run, mock_which, mock_ibus_avail, mock_ibus_active + ): + """Test ydotool falls back to type when clipboard paste fails (#362).""" + mock_which.side_effect = lambda x: x == "ydotool" # no wl-copy + + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}): + injector = TextInjector() + injector.wayland_tool = "ydotool" + injector.environment = DesktopEnvironment.WAYLAND + + injector._inject_with_wayland_tool("café") + + # Should fall back to ydotool type since no clipboard tool available + calls = [str(c) for c in mock_run.call_args_list] + has_ydotool_type = any("'type'" in c for c in calls) + self.assertTrue(has_ydotool_type, "Should fall back to ydotool type") + + @patch("vocalinux.text_injection.text_injector.shutil.which") + @patch("vocalinux.text_injection.text_injector.subprocess.run") + def test_clipboard_paste_uses_xclip_fallback(self, mock_run, mock_which): + """Test clipboard paste falls back to xclip when wl-copy is unavailable (#362).""" + mock_which.side_effect = lambda x: x in ("xclip", "ydotool") + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}): + injector = TextInjector() + injector.wayland_tool = "ydotool" + injector.environment = DesktopEnvironment.WAYLAND + + result = injector._inject_via_clipboard_paste("café") + + self.assertTrue(result) + calls = [c.args[0] for c in mock_run.call_args_list if c.args] + has_xclip = any(c[0] == "xclip" for c in calls) + self.assertTrue(has_xclip, "Should use xclip as fallback") + + @patch("vocalinux.text_injection.text_injector.shutil.which") + @patch("vocalinux.text_injection.text_injector.subprocess.run") + def test_clipboard_paste_uses_xsel_fallback(self, mock_run, mock_which): + """Test clipboard paste falls back to xsel when wl-copy/xclip unavailable (#362).""" + mock_which.side_effect = lambda x: x in ("xsel", "ydotool") + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}): + injector = TextInjector() + injector.wayland_tool = "ydotool" + injector.environment = DesktopEnvironment.WAYLAND + + result = injector._inject_via_clipboard_paste("café") + + self.assertTrue(result) + calls = [c.args[0] for c in mock_run.call_args_list if c.args] + has_xsel = any(c[0] == "xsel" for c in calls) + self.assertTrue(has_xsel, "Should use xsel as fallback") + + @patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=False) + @patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False) + @patch("vocalinux.text_injection.text_injector.shutil.which") + @patch("vocalinux.text_injection.text_injector.subprocess.run") + def test_wtype_injects_unicode_directly( + self, mock_run, mock_which, mock_ibus_avail, mock_ibus_active + ): + """wtype supports Unicode natively and must NOT use the clipboard-paste path.""" + mock_which.side_effect = lambda x: x in ("wtype", "wl-copy") + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}): + injector = TextInjector() + injector.wayland_tool = "wtype" + injector.environment = DesktopEnvironment.WAYLAND + + mock_run.reset_mock() + injector._inject_with_wayland_tool("café") + + calls = [c.args[0] for c in mock_run.call_args_list if c.args] + self.assertTrue( + any(c[0] == "wtype" and c[1] == "café" for c in calls), + "wtype should inject text directly", + ) + self.assertFalse( + any(c[0] == "wl-copy" for c in calls), + "wtype must NOT use clipboard-paste workaround", + ) + + @patch("vocalinux.text_injection.text_injector.shutil.which") + @patch("vocalinux.text_injection.text_injector.subprocess.run") + def test_clipboard_paste_returns_false_on_paste_failure(self, mock_run, mock_which): + """Test clipboard paste returns False when Ctrl+V simulation fails (#362).""" + mock_which.side_effect = lambda x: x in ("wl-copy", "ydotool") + + def side_effect(*args, **kwargs): + cmd = args[0] + if cmd[0] == "wl-copy": + return MagicMock(returncode=0) + # ydotool key fails + raise subprocess.CalledProcessError(1, cmd) + + mock_run.side_effect = side_effect + + with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}): + injector = TextInjector() + injector.wayland_tool = "ydotool" + injector.environment = DesktopEnvironment.WAYLAND + + result = injector._inject_via_clipboard_paste("café") + + self.assertFalse(result) + + @patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=False) + @patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False) + @patch("vocalinux.text_injection.text_injector.shutil.which") + def test_clipboard_paste_returns_false_when_no_clipboard_tools( + self, mock_which, mock_ibus_avail, mock_ibus_active + ): + """Test clipboard paste returns False when no clipboard tools available.""" + # ydotool available for init, but no clipboard tools (wl-copy/xclip/xsel) + mock_which.side_effect = lambda x: x if x == "ydotool" else None + + with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}): + injector = TextInjector() + injector.wayland_tool = "ydotool" + injector.environment = DesktopEnvironment.WAYLAND + + result = injector._inject_via_clipboard_paste("café") + + self.assertFalse(result) + + @patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=False) + @patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False) + @patch("vocalinux.text_injection.text_injector.shutil.which") + @patch("vocalinux.text_injection.text_injector.subprocess.run") + def test_inject_text_ydotool_non_ascii_end_to_end( + self, mock_run, mock_which, mock_ibus_avail, mock_ibus_active + ): + """inject_text routes accented text through clipboard-paste on Wayland+ydotool (#362).""" + mock_which.side_effect = lambda x: x in ("ydotool", "wl-copy") + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}): + injector = TextInjector() + injector.wayland_tool = "ydotool" + injector.environment = DesktopEnvironment.WAYLAND + + mock_run.reset_mock() + result = injector.inject_text("Esdrújula") + + self.assertTrue(result) + calls = [c.args[0] for c in mock_run.call_args_list if c.args] + self.assertTrue( + any(c[0] == "wl-copy" for c in calls), + "Should copy to clipboard via wl-copy", + ) + self.assertTrue( + any(c[:2] == ["ydotool", "key"] for c in calls), + "Should simulate Ctrl+V via ydotool key", + ) + self.assertFalse( + any(c[:2] == ["ydotool", "type"] for c in calls), + "Should NOT call ydotool type for non-ASCII text", + ) + class TestDesktopEnvironmentEnum(unittest.TestCase): """Tests for DesktopEnvironment enum."""