diff --git a/src/vocalinux/text_injection/ibus_engine.py b/src/vocalinux/text_injection/ibus_engine.py index 42dddce..83387a5 100644 --- a/src/vocalinux/text_injection/ibus_engine.py +++ b/src/vocalinux/text_injection/ibus_engine.py @@ -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"], diff --git a/tests/test_ibus_engine.py b/tests/test_ibus_engine.py index f74bcf0..1f66315 100644 --- a/tests/test_ibus_engine.py +++ b/tests/test_ibus_engine.py @@ -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()