fix(ibus): require a real IM engine on Wayland (#478) (#491)

* fix(ibus): require a real IM engine on Wayland (#478)

On Wayland a bare xkb:* IBus engine is a layout passthrough that never
delivers commit_text() to applications, so selecting the IBus backend
there silently no-ops (niri/sway, and GNOME/Mutter via the env-var
branch). Require a real IM engine before treating IBus as active on
Wayland, regardless of how it was detected; X11/XWayland (XIM) is
unchanged. Complements #486's compositor denylist.

* test(ibus): bind #478 patches to the module under test

Patch get_current_engine/is_ibus_daemon_running via patch.object on a
held ibus_engine reference and call is_ibus_active_input_method through
it, so the patched functions and the function under test always share
one module object. Fixes flakiness in the full CI suite where other test
files re-import ibus_engine and the dotted-string patch bound a
different module object.
This commit is contained in:
Jatin K Malik
2026-06-30 20:19:01 -07:00
committed by GitHub
parent 943d2b5cc1
commit 6bb6d8802e
2 changed files with 165 additions and 40 deletions
+74 -40
View File
@@ -152,39 +152,51 @@ def is_ibus_daemon_running() -> bool:
return False
def _is_real_ibus_engine(engine: Optional[str]) -> bool:
"""Return True only for a real IBus IM engine, not a bare XKB layout.
IBus reports a bare ``xkb:*`` engine (e.g. ``xkb:us::eng``) when it is doing
keyboard-layout passthrough with no input-method client attached. In that
state ``commit_text()`` succeeds at the IBus layer but never reaches the
focused application, so treating it as active causes a silent no-op on
Wayland (see issue #478). Real IM engines (anthy, libpinyin, hangul, ...) do
not use the ``xkb:`` prefix.
"""
if not engine:
return False
engine = engine.strip()
if not engine or engine.lower() == "no global engine":
return False
return not engine.startswith("xkb:")
def is_ibus_active_input_method() -> bool:
"""
Check if IBus is the currently active input method.
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.
Checks environment variables and, where they are not set (common on
Wayland), whether the IBus daemon is running with an engine.
On Wayland there is an extra requirement: IBus can only deliver
``commit_text()`` to applications through a *real* IM engine. A bare
``xkb:*`` engine is a layout passthrough, so on compositors that don't fully
bridge IBus to native clients (niri, sway, and even GNOME/Mutter with only
an ``xkb`` engine) injection silently reaches nothing. We therefore require a
real engine on Wayland regardless of how IBus was detected (issue #478). On
X11/XWayland IBus reaches apps through XIM, so that check is not applied
there.
Returns:
True if IBus appears to be the active input method, False otherwise
"""
# Check GTK_IM_MODULE for GTK applications
gtk_im = os.environ.get("GTK_IM_MODULE", "").lower()
if gtk_im and "ibus" in gtk_im:
logger.debug(f"IBus detected as active input method via GTK_IM_MODULE={gtk_im}")
return True
# Check QT_IM_MODULE for Qt applications
qt_im = os.environ.get("QT_IM_MODULE", "").lower()
if qt_im and "ibus" in qt_im:
logger.debug(f"IBus detected as active input method via QT_IM_MODULE={qt_im}")
return True
# On X11/XWayland, check XMODIFIERS for XIM compatibility
xmodifiers = os.environ.get("XMODIFIERS", "").lower()
if "@im=ibus" in xmodifiers:
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):
ibus_via_env = ("ibus" in gtk_im) or ("ibus" in qt_im) or ("@im=ibus" in xmodifiers)
# If another (non-IBus) input method is explicitly configured, respect it.
if not ibus_via_env and (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'}, "
@@ -193,26 +205,48 @@ def is_ibus_active_input_method() -> bool:
)
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
daemon_running = is_ibus_daemon_running()
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"daemon running: {is_ibus_daemon_running()})"
)
# Neither an IBus env-var signal nor a running daemon -> IBus is not active.
if not ibus_via_env and not daemon_running:
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'}, daemon running: False)"
)
return False
if _is_wayland_session():
# On Wayland a bare xkb:* engine can't deliver commits to apps, so
# require a real IM engine regardless of how IBus was detected (#478).
engine = get_current_engine() if daemon_running else None
if not _is_real_ibus_engine(engine):
logger.debug(
"IBus is present on Wayland but the current engine "
f"({engine or 'none'}) is a bare XKB layout or unset; "
"commit_text() would not reach applications, so treating IBus "
"as inactive (see #478)."
)
return False
logger.debug(f"IBus detected as active input method on Wayland (engine: {engine})")
return True
# X11 / XWayland: IBus reaches apps via XIM. An env-var signal, or a running
# daemon with any engine (KDE Plasma with legacy env vars unset), counts.
if ibus_via_env:
logger.debug("IBus detected as active input method via environment variables")
return True
engine = get_current_engine()
if engine:
logger.debug(
"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 (daemon has no engine)")
return False
+91
View File
@@ -0,0 +1,91 @@
"""Regression tests for issue #478.
On Wayland, IBus only delivers commit_text() to applications through a *real*
IM engine. A bare ``xkb:*`` engine (or none) is a layout passthrough, so
selecting the IBus backend there is a silent no-op. is_ibus_active_input_method()
must require a real engine on Wayland regardless of how IBus was detected, while
leaving X11/XWayland (XIM) behavior unchanged.
Note: we patch via ``patch.object`` on a held module reference (and call the
function through that same reference) rather than by dotted string, so the
patched ``get_current_engine`` / ``is_ibus_daemon_running`` and the function
under test always share one module object even when other test files re-import
``ibus_engine`` during a full-suite run.
"""
import sys
import unittest
from unittest.mock import MagicMock, patch
# Mock GI so importing ibus_engine does not require a real IBus/GTK stack.
_mock_gi = MagicMock()
_mock_gi_repo = MagicMock()
_mock_gi_repo.IBus = MagicMock()
_mock_gi_repo.GLib = MagicMock()
_mock_gi_repo.GObject = MagicMock()
_mock_gi_repo.IBus.Engine = MagicMock
_mock_gi_repo.GLib.MainLoop = MagicMock
sys.modules["gi"] = _mock_gi
sys.modules["gi.repository"] = _mock_gi_repo
for _key in list(sys.modules.keys()):
if "vocalinux" in _key and "ibus_engine" in _key:
del sys.modules[_key]
import vocalinux.text_injection.ibus_engine as ibus_engine # noqa: E402
class TestIsRealIBusEngine(unittest.TestCase):
def test_real_engines(self):
for engine in ("anthy", "libpinyin", "hangul", "mozc-jp"):
self.assertTrue(ibus_engine._is_real_ibus_engine(engine), engine)
def test_layout_only_or_missing_engines(self):
for engine in ("xkb:us::eng", "xkb:de::ger", "xkb:gb::eng", "", None, "No global engine"):
self.assertFalse(ibus_engine._is_real_ibus_engine(engine), repr(engine))
class TestWaylandRequiresRealEngine(unittest.TestCase):
@patch.object(ibus_engine, "get_current_engine", return_value="xkb:us::eng")
@patch.object(ibus_engine, "is_ibus_daemon_running", return_value=True)
@patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "QT_IM_MODULE": "ibus"}, clear=True)
def test_bare_xkb_engine_inactive_even_with_env_var(self, *_):
# GNOME/Mutter: QT_IM_MODULE=ibus but only a bare xkb engine -> no-op.
self.assertFalse(ibus_engine.is_ibus_active_input_method())
@patch.object(ibus_engine, "get_current_engine", return_value="anthy")
@patch.object(ibus_engine, "is_ibus_daemon_running", return_value=True)
@patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}, clear=True)
def test_real_engine_is_active(self, *_):
self.assertTrue(ibus_engine.is_ibus_active_input_method())
@patch.object(ibus_engine, "get_current_engine", return_value=None)
@patch.object(ibus_engine, "is_ibus_daemon_running", return_value=True)
@patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "GTK_IM_MODULE": "ibus"}, clear=True)
def test_no_engine_is_inactive(self, *_):
self.assertFalse(ibus_engine.is_ibus_active_input_method())
class TestX11BehaviorUnchanged(unittest.TestCase):
@patch.object(ibus_engine, "get_current_engine", return_value="xkb:us::eng")
@patch.object(ibus_engine, "is_ibus_daemon_running", return_value=True)
@patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}, clear=True)
def test_daemon_with_xkb_engine_still_active_on_x11(self, *_):
# XIM delivers even with a bare xkb engine, so the daemon fallback stands.
self.assertTrue(ibus_engine.is_ibus_active_input_method())
@patch.object(ibus_engine, "get_current_engine", return_value=None)
@patch.object(ibus_engine, "is_ibus_daemon_running", return_value=False)
@patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11", "GTK_IM_MODULE": "ibus"}, clear=True)
def test_env_var_is_active_on_x11(self, *_):
self.assertTrue(ibus_engine.is_ibus_active_input_method())
@patch.object(ibus_engine, "get_current_engine", return_value=None)
@patch.object(ibus_engine, "is_ibus_daemon_running", return_value=False)
@patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11", "GTK_IM_MODULE": "fcitx"}, clear=True)
def test_other_im_configured_is_inactive(self, *_):
self.assertFalse(ibus_engine.is_ibus_active_input_method())
if __name__ == "__main__":
unittest.main()