fix(text-injection): handle non-ASCII characters with ydotool via clipboard paste (fixes #362) (#376)

* fix(text-injection): handle non-ASCII characters with ydotool via clipboard paste (fixes #362)

ydotool simulates evdev key events which only map to US ASCII keycodes.
Non-ASCII characters (accented letters like á, é, ú, ñ, ü, etc.) get
silently dropped during injection, so 'Esdrújula' becomes 'Esdrjula'.

When ydotool is the active Wayland injection tool and the text contains
non-ASCII characters, the new code:
1. Copies text to clipboard via wl-copy (or xclip/xsel fallback)
2. Simulates Ctrl+V via ydotool key to paste it
3. Falls back to regular ydotool type if clipboard tools unavailable

This preserves all Unicode characters perfectly. ASCII-only text still
uses ydotool type directly for lower latency.

* style: fix black formatting in text_injector.py

* test: add coverage for clipboard paste paths (xclip/xsel/wtype fallbacks)

Covers the uncovered paths flagged by Codecov:
- xclip fallback when wl-copy unavailable
- xsel fallback when wl-copy/xclip unavailable
- wtype Ctrl+V paste simulation
- Paste failure (Ctrl+V fails after clipboard copy)
- No clipboard tools available

* refactor(text-injection): eliminate duplication and dead code in clipboard paste path

- _inject_via_clipboard_paste now delegates to _copy_to_clipboard instead
  of reimplementing wl-copy/xclip/xsel selection (removes ~30 lines of
  duplication identified in code review)
- Remove unreachable wtype branch from _inject_via_clipboard_paste; wtype
  handles Unicode natively via Wayland virtual-keyboard protocol so it
  never calls this method
- Drop unnecessary time.sleep(0.05) after clipboard write; subprocess.run
  for wl-copy is synchronous so the sleep was a no-op
- Add debug log noting the clipboard will be temporarily overwritten so
  operators can observe the side-effect without diving into the code

test improvements:
- Fix fragile string-repr assertion in test_ydotool_ascii_uses_type_directly;
  now compares actual list slices and verifies wl-copy is NOT called
- Replace test_clipboard_paste_with_wtype with test_wtype_injects_unicode_directly
  which actively asserts wtype calls the tool directly without clipboard
- Add test_inject_text_ydotool_non_ascii_end_to_end covering the full
  inject_text -> _inject_with_wayland_tool -> _inject_via_clipboard_paste
  stack for accented text on Wayland+ydotool (fixes #362)
- Remove now-unnecessary @patch('time.sleep') decorators from three tests
This commit is contained in:
Jatin K Malik
2026-04-05 18:12:35 -07:00
committed by GitHub
parent 410f6b27d8
commit bd96772307
2 changed files with 313 additions and 0 deletions
@@ -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
+243
View File
@@ -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."""