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