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:
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user