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: