diff --git a/src/vocalinux/text_injection/ibus_engine.py b/src/vocalinux/text_injection/ibus_engine.py index 05b172f..786c366 100644 --- a/src/vocalinux/text_injection/ibus_engine.py +++ b/src/vocalinux/text_injection/ibus_engine.py @@ -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 diff --git a/tests/test_ibus_active_engine_guard.py b/tests/test_ibus_active_engine_guard.py new file mode 100644 index 0000000..a2e90c6 --- /dev/null +++ b/tests/test_ibus_active_engine_guard.py @@ -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()