feat(text-injection): extend IBus support to X11 for non-US keyboard layouts (#227)

IBus sends Unicode directly via commit_text(), bypassing keyboard layout
issues entirely. This provides the same reliable text injection on X11
that we already have on Wayland.

Changes:
- Add X11_IBUS desktop environment enum
- Prefer IBus on both X11 and Wayland (before falling back to xdotool)
- Add tests for X11 IBus preference and fallback behavior

This supersedes the layout-switching approach in PR #206, which only
worked on X11 and had overhead from layout switching.

Closes #199
This commit is contained in:
Jatin K Malik
2026-02-17 22:40:20 -08:00
committed by GitHub
parent 13991e16e6
commit 79c3ca7f88
2 changed files with 86 additions and 19 deletions
+33 -18
View File
@@ -25,6 +25,7 @@ class DesktopEnvironment(Enum):
"""Enum representing the desktop environment."""
X11 = "x11"
X11_IBUS = "x11-ibus" # X11 with IBus engine (preferred for non-US layouts)
WAYLAND = "wayland"
WAYLAND_XDOTOOL = "wayland-xdotool" # Wayland with XWayland fallback
WAYLAND_IBUS = "wayland-ibus" # Wayland with IBus engine (preferred)
@@ -130,24 +131,28 @@ class TextInjector:
def _check_dependencies(self):
"""Check for the required tools for text injection."""
# Prefer IBus on both X11 and Wayland - it sends Unicode directly,
# bypassing keyboard layout issues entirely
if is_ibus_available():
try:
self._ibus_injector = IBusTextInjector(auto_activate=True)
if self.environment == DesktopEnvironment.X11:
self.environment = DesktopEnvironment.X11_IBUS
else:
self.environment = DesktopEnvironment.WAYLAND_IBUS
logger.info(
f"Using IBus for {self.environment.value} text injection (best compatibility)"
)
return
except Exception as e:
logger.warning(f"IBus initialization failed: {e}, trying alternatives")
if self.environment == DesktopEnvironment.X11:
# Check for xdotool
if not shutil.which("xdotool"):
logger.error("xdotool not found. Please install it with: sudo apt install xdotool")
raise RuntimeError("Missing required dependency: xdotool")
else:
# For Wayland, prefer IBus as it works universally with any keyboard layout
# IBus is usually pre-installed on GNOME systems
if is_ibus_available():
try:
self._ibus_injector = IBusTextInjector(auto_activate=True)
self.environment = DesktopEnvironment.WAYLAND_IBUS
self.wayland_tool = "ibus"
logger.info("Using IBus for Wayland text injection (best compatibility)")
return
except Exception as e:
logger.warning(f"IBus initialization failed: {e}, trying alternatives")
# Fallback: Check for wtype or ydotool for Wayland
wtype_available = shutil.which("wtype") is not None
ydotool_available = shutil.which("ydotool") is not None
@@ -157,7 +162,10 @@ class TextInjector:
# Verify ydotoold daemon is running before selecting ydotool
try:
subprocess.run(
["ydotool", "type", ""], check=True, stderr=subprocess.PIPE, timeout=2
["ydotool", "type", ""],
check=True,
stderr=subprocess.PIPE,
timeout=2,
)
self.wayland_tool = "ydotool"
logger.info(f"Using {self.wayland_tool} for Wayland text injection")
@@ -247,8 +255,10 @@ class TextInjector:
logger.debug(f"Text to inject: '{text}'")
try:
if self.environment == DesktopEnvironment.WAYLAND_IBUS:
# Use IBus for text injection
if (
self.environment == DesktopEnvironment.WAYLAND_IBUS
or self.environment == DesktopEnvironment.X11_IBUS
):
if self._ibus_injector is not None:
return self._ibus_injector.inject_text(text)
else:
@@ -360,7 +370,12 @@ class TextInjector:
logger.debug(f"Injecting chunk {chunk_num}/{total_chunks}: '{chunk}'")
subprocess.run(
cmd, env=env, check=True, stderr=subprocess.PIPE, text=True, timeout=5
cmd,
env=env,
check=True,
stderr=subprocess.PIPE,
text=True,
timeout=5,
)
# Add a larger delay between chunks
@@ -374,7 +389,7 @@ class TextInjector:
except subprocess.CalledProcessError as chunk_error:
if retry < max_retries:
logger.warning(
f"Retrying text injection (attempt {retry+1}/{max_retries}): "
f"Retrying text injection (attempt {retry + 1}/{max_retries}): "
f"{chunk_error.stderr}"
)
time.sleep(0.5) # Wait before retry
@@ -384,7 +399,7 @@ class TextInjector:
except subprocess.TimeoutExpired:
if retry < max_retries:
logger.warning(
f"Text injection timeout, retrying (attempt {retry+1}/{max_retries})"
f"Text injection timeout, retrying (attempt {retry + 1}/{max_retries})"
)
time.sleep(0.5)
else:
+53 -1
View File
@@ -582,6 +582,52 @@ class TestTextInjectorWithIBus(unittest.TestCase):
# Should be using IBus
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_IBUS)
@patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True)
@patch("vocalinux.text_injection.ibus_engine.is_ibus_available")
@patch("vocalinux.text_injection.ibus_engine.IBusTextInjector")
def test_x11_prefers_ibus(self, mock_injector_class, mock_ibus_available):
"""Test that X11 environment prefers IBus when available."""
mock_ibus_available.return_value = True
mock_injector_instance = MagicMock()
mock_injector_class.return_value = mock_injector_instance
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
from vocalinux.text_injection.text_injector import (
DesktopEnvironment,
TextInjector,
)
with patch(
"vocalinux.text_injection.text_injector.is_ibus_available",
return_value=True,
):
with patch(
"vocalinux.text_injection.text_injector.IBusTextInjector",
mock_injector_class,
):
injector = TextInjector()
self.assertEqual(injector.environment, DesktopEnvironment.X11_IBUS)
@patch("vocalinux.text_injection.text_injector.is_ibus_available")
def test_x11_fallback_when_ibus_unavailable(self, mock_ibus_available):
"""Test X11 falls back to xdotool when IBus unavailable."""
mock_ibus_available.return_value = False
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
self.mock_which.side_effect = lambda cmd: (
"/usr/bin/xdotool" if cmd == "xdotool" else None
)
from vocalinux.text_injection.text_injector import (
DesktopEnvironment,
TextInjector,
)
injector = TextInjector()
self.assertEqual(injector.environment, DesktopEnvironment.X11)
@patch("vocalinux.text_injection.text_injector.is_ibus_available")
def test_wayland_fallback_when_ibus_unavailable(self, mock_ibus_available):
"""Test Wayland falls back to other tools when IBus unavailable."""
@@ -640,7 +686,7 @@ class TestTextInjectorWithIBus(unittest.TestCase):
class TestDesktopEnvironmentEnumWithIBus(unittest.TestCase):
"""Tests for DesktopEnvironment enum including IBus variant."""
"""Tests for DesktopEnvironment enum including IBus variants."""
def test_enum_includes_wayland_ibus(self):
"""Test that WAYLAND_IBUS enum value exists."""
@@ -648,6 +694,12 @@ class TestDesktopEnvironmentEnumWithIBus(unittest.TestCase):
self.assertEqual(DesktopEnvironment.WAYLAND_IBUS.value, "wayland-ibus")
def test_enum_includes_x11_ibus(self):
"""Test that X11_IBUS enum value exists."""
from vocalinux.text_injection.text_injector import DesktopEnvironment
self.assertEqual(DesktopEnvironment.X11_IBUS.value, "x11-ibus")
if __name__ == "__main__":
unittest.main()