fix(ibus): detect IBus on Wayland without env vars and fix text injection (#381)

Fix two related IBus issues on KDE Plasma Wayland (and similar DEs):

1. IBus not detected without legacy environment variables:
   On Wayland, IBus is configured via the DE's Virtual Keyboard setting
   and IBus itself recommends unsetting GTK_IM_MODULE / QT_IM_MODULE.
   The detection logic only checked these env vars, so it never found
   IBus on a properly configured Wayland session.

   Fix: When no IM env vars are set, check if ibus-daemon is running
   and has an active engine. If another IM (e.g. Fcitx) is explicitly
   configured via env vars, respect that and don't override.

2. IBus text injection silently fails on Wayland:
   After switching to the Vocalinux IBus engine, the code called
   setxkbmap to capture/restore the keyboard layout. On Wayland,
   setxkbmap does not reflect the compositor's state and returns
   incorrect results (e.g. 'us' when the user has 'de'). Calling
   setxkbmap to restore this wrong layout interfered with IBus's
   ability to commit text, causing silent injection failures.

   Fix: Skip setxkbmap-based XKB layout management entirely on
   Wayland sessions. The Wayland compositor manages keyboard layouts
   natively, and setxkbmap can only cause harm there.

Fixes #380
This commit is contained in:
Jatin K Malik
2026-04-03 15:47:55 -07:00
committed by GitHub
parent 1c2fb4dadf
commit 93032471c3
2 changed files with 151 additions and 10 deletions
+48 -6
View File
@@ -156,10 +156,11 @@ def is_ibus_active_input_method() -> bool:
"""
Check if IBus is the currently active input method.
This checks environment variables to determine if IBus is configured
as the active input method for GTK and Qt applications. On some systems,
IBus may be installed and the daemon running, but not be the active
input method (e.g., when using ydotool, Fcitx, or other input methods).
This checks environment variables and, on Wayland where env vars may not
be set, also checks if the IBus daemon is running and actively managing
an engine. On some desktop environments (e.g., KDE Plasma Wayland), IBus
is configured via the DE's Virtual Keyboard setting and IBus itself
recommends unsetting the legacy env vars.
Returns:
True if IBus appears to be the active input method, False otherwise
@@ -182,11 +183,35 @@ def is_ibus_active_input_method() -> bool:
logger.debug(f"IBus detected as active input method via XMODIFIERS={xmodifiers}")
return True
# If another input method is explicitly configured, respect that
if gtk_im or qt_im or (xmodifiers and "@im=" in xmodifiers):
logger.debug(
"Another input method is explicitly configured "
f"(GTK_IM_MODULE={gtk_im or 'not set'}, "
f"QT_IM_MODULE={qt_im or 'not set'}, "
f"XMODIFIERS={xmodifiers or 'not set'})"
)
return False
# No env vars set at all — common on Wayland (KDE Plasma, etc.) where
# IBus is configured via the DE's Virtual Keyboard setting and IBus
# recommends unsetting GTK_IM_MODULE / QT_IM_MODULE.
# Check if ibus-daemon is running and has an active engine.
if is_ibus_daemon_running():
engine = get_current_engine()
if engine:
logger.debug(
f"IBus detected as active input method via running daemon "
f"(no env vars set, current engine: {engine})"
)
return True
logger.debug(
"IBus does not appear to be the active input method "
f"(GTK_IM_MODULE={gtk_im or 'not set'}, "
f"QT_IM_MODULE={qt_im or 'not set'}, "
f"XMODIFIERS={xmodifiers or 'not set'})"
f"XMODIFIERS={xmodifiers or 'not set'}, "
f"daemon running: {is_ibus_daemon_running()})"
)
return False
@@ -338,6 +363,11 @@ def get_current_engine() -> Optional[str]:
return None
def _is_wayland_session() -> bool:
"""Check if the current session is running under Wayland."""
return os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland"
def get_current_xkb_layout() -> tuple:
"""
Get the current XKB keyboard layout, variant, and options.
@@ -347,9 +377,21 @@ def get_current_xkb_layout() -> tuple:
especially when the user configured their keyboard via desktop environment
settings or setxkbmap directly rather than through IBus.
On Wayland, setxkbmap does not reflect the compositor's keyboard state
and can return incorrect results. Returns empty values in that case to
signal that XKB layout management should be skipped.
Returns:
A tuple of (layout, variant, option). Defaults to ("us", "", "") on error.
A tuple of (layout, variant, option). Returns ("", "", "") on Wayland
or on error, ("us", "", "") as X11 default.
"""
if _is_wayland_session():
logger.debug(
"Wayland session detected — skipping setxkbmap query. "
"Keyboard layout is managed by the Wayland compositor."
)
return "", "", ""
try:
result = subprocess.run(
["setxkbmap", "-query"],
+103 -4
View File
@@ -954,16 +954,46 @@ class TestIsIbusActiveInputMethod(unittest.TestCase):
result = is_ibus_active_input_method()
self.assertFalse(result)
def test_not_active_when_no_env_vars_set(self):
"""Test returns False when no input method env vars are set."""
@patch("vocalinux.text_injection.ibus_engine.is_ibus_daemon_running", return_value=False)
def test_not_active_when_no_env_vars_and_no_daemon(self, mock_daemon):
"""Test returns False when no env vars set and daemon is not running."""
with patch.dict(os.environ, {}, clear=True):
from vocalinux.text_injection.ibus_engine import is_ibus_active_input_method
result = is_ibus_active_input_method()
self.assertFalse(result)
def test_empty_string_env_vars_return_false(self):
"""Test returns False when env vars are empty strings."""
@patch("vocalinux.text_injection.ibus_engine.get_current_engine", return_value="xkb:us::eng")
@patch("vocalinux.text_injection.ibus_engine.is_ibus_daemon_running", return_value=True)
def test_active_via_daemon_when_no_env_vars(self, mock_daemon, mock_engine):
"""Test returns True when no env vars but daemon is running with active engine."""
with patch.dict(os.environ, {}, clear=True):
from vocalinux.text_injection.ibus_engine import is_ibus_active_input_method
result = is_ibus_active_input_method()
self.assertTrue(result)
@patch("vocalinux.text_injection.ibus_engine.get_current_engine", return_value=None)
@patch("vocalinux.text_injection.ibus_engine.is_ibus_daemon_running", return_value=True)
def test_not_active_when_daemon_running_but_no_engine(self, mock_daemon, mock_engine):
"""Test returns False when daemon is running but no engine is active."""
with patch.dict(os.environ, {}, clear=True):
from vocalinux.text_injection.ibus_engine import is_ibus_active_input_method
result = is_ibus_active_input_method()
self.assertFalse(result)
def test_not_active_when_other_im_configured(self):
"""Test returns False when another IM is explicitly configured, even if daemon runs."""
with patch.dict(os.environ, {"GTK_IM_MODULE": "fcitx"}, clear=True):
from vocalinux.text_injection.ibus_engine import is_ibus_active_input_method
result = is_ibus_active_input_method()
self.assertFalse(result)
@patch("vocalinux.text_injection.ibus_engine.is_ibus_daemon_running", return_value=False)
def test_empty_string_env_vars_return_false(self, mock_daemon):
"""Test returns False when env vars are empty strings and daemon not running."""
with patch.dict(
os.environ,
{"GTK_IM_MODULE": "", "QT_IM_MODULE": "", "XMODIFIERS": ""},
@@ -1141,5 +1171,74 @@ class TestTextInjectorWithIbusActiveInputMethod(unittest.TestCase):
self.assertEqual(injector.wayland_tool, "ydotool")
class TestWaylandXkbLayoutSkipping(unittest.TestCase):
"""Tests for skipping setxkbmap operations on Wayland sessions."""
def test_get_current_xkb_layout_returns_empty_on_wayland(self):
"""Test that get_current_xkb_layout returns empty tuple on Wayland."""
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "wayland"}, clear=False):
from vocalinux.text_injection.ibus_engine import get_current_xkb_layout
layout, variant, option = get_current_xkb_layout()
self.assertEqual(layout, "")
self.assertEqual(variant, "")
self.assertEqual(option, "")
@patch("subprocess.run")
def test_get_current_xkb_layout_queries_setxkbmap_on_x11(self, mock_run):
"""Test that get_current_xkb_layout uses setxkbmap on X11."""
mock_run.return_value = MagicMock(
returncode=0,
stdout="layout: de\nvariant: nodeadkeys\noptions: ctrl:nocaps\n",
)
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "x11"}, clear=False):
from vocalinux.text_injection.ibus_engine import get_current_xkb_layout
layout, variant, option = get_current_xkb_layout()
self.assertEqual(layout, "de")
self.assertEqual(variant, "nodeadkeys")
self.assertEqual(option, "ctrl:nocaps")
mock_run.assert_called_once()
@patch("subprocess.run")
def test_get_current_xkb_layout_skips_subprocess_on_wayland(self, mock_run):
"""Test that setxkbmap is never called on Wayland."""
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "wayland"}, clear=False):
from vocalinux.text_injection.ibus_engine import get_current_xkb_layout
get_current_xkb_layout()
mock_run.assert_not_called()
def test_restore_xkb_layout_skips_empty_layout(self):
"""Test that restore_xkb_layout returns False for empty layout string."""
from vocalinux.text_injection.ibus_engine import restore_xkb_layout
result = restore_xkb_layout("", "", "")
self.assertFalse(result)
def test_is_wayland_session_detects_wayland(self):
"""Test _is_wayland_session returns True for Wayland."""
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "wayland"}, clear=False):
from vocalinux.text_injection.ibus_engine import _is_wayland_session
self.assertTrue(_is_wayland_session())
def test_is_wayland_session_detects_x11(self):
"""Test _is_wayland_session returns False for X11."""
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "x11"}, clear=False):
from vocalinux.text_injection.ibus_engine import _is_wayland_session
self.assertFalse(_is_wayland_session())
def test_is_wayland_session_returns_false_when_unset(self):
"""Test _is_wayland_session returns False when env var not set."""
env = os.environ.copy()
env.pop("XDG_SESSION_TYPE", None)
with patch.dict(os.environ, env, clear=True):
from vocalinux.text_injection.ibus_engine import _is_wayland_session
self.assertFalse(_is_wayland_session())
if __name__ == "__main__":
unittest.main()