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:
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user