fix(ibus): restore XKB layout after scoped injection on X11 (#665)

Scoped IBus injection (#457) temporarily switches to the Vocalinux
engine but only restored the previous IBus engine, not the XKB map.
On X11 that left non-US layouts (e.g. Brazilian ABNT2) stuck on us
after dictation. Capture XKB before the switch and re-apply it in
the inject_text finally block (Wayland remains a no-op via #474).

Fixes #664
This commit is contained in:
Jatin K Malik
2026-08-11 02:32:46 +05:30
committed by GitHub
parent 068530535b
commit 36b2d923c6
2 changed files with 102 additions and 0 deletions
@@ -1409,6 +1409,10 @@ class IBusTextInjector:
logger.info(f"Starting IBus text injection: '{text[:20]}...' (length: {len(text)})")
restore_engine: Optional[str] = None
# Capture before any engine switch. Switching to Vocalinux can override
# the system XKB map to us; scoped injection must restore it after
# switching back (#292, #664). Empty on Wayland (no-op restore).
captured_xkb = get_current_xkb_layout()
if not is_engine_active():
current_engine = get_current_engine()
if not current_engine:
@@ -1539,6 +1543,13 @@ class IBusTextInjector:
"no valid engine was captured (see #497)"
)
# Engine switch can leave XKB on us even after restoring the IBus
# engine (setxkbmap vs IBus). Re-apply the pre-switch map on X11;
# Wayland capture is empty so this is a no-op (#474, #664).
layout, variant, option = captured_xkb
if layout:
restore_xkb_layout(layout, variant, option)
return False
+91
View File
@@ -965,6 +965,97 @@ class TestIBusTextInjector(unittest.TestCase):
[ENGINE_NAME, "libpinyin"],
)
@patch("vocalinux.text_injection.ibus_engine.restore_xkb_layout")
@patch(
"vocalinux.text_injection.ibus_engine.get_current_xkb_layout",
return_value=("br", "", ""),
)
@patch("vocalinux.text_injection.ibus_engine.get_current_engine", return_value="xkb:br::por")
@patch("vocalinux.text_injection.ibus_engine.is_engine_active", return_value=False)
@patch("vocalinux.text_injection.ibus_engine.switch_engine", return_value=True)
@patch("socket.socket")
@patch("vocalinux.text_injection.ibus_engine.SOCKET_PATH")
@patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True)
@patch("vocalinux.text_injection.ibus_engine.ensure_ibus_dir")
def test_inject_text_restores_xkb_layout_after_scoped_activation(
self,
mock_ensure_dir,
mock_socket_path,
mock_socket_cls,
mock_switch,
mock_is_active,
mock_get_current,
mock_get_xkb,
mock_restore_xkb,
):
"""Scoped injection must restore XKB after the Vocalinux engine switch (#664)."""
from vocalinux.text_injection.ibus_engine import ENGINE_NAME, IBusTextInjector
mock_socket_path.exists.return_value = True
mock_sock = MagicMock()
mock_sock.__enter__.return_value = mock_sock
mock_sock.__exit__.return_value = None
mock_sock.recv.return_value = b"OK"
mock_socket_cls.return_value = mock_sock
parent = MagicMock()
parent.attach_mock(mock_get_xkb, "get_xkb")
parent.attach_mock(mock_switch, "switch")
parent.attach_mock(mock_restore_xkb, "restore_xkb")
injector = IBusTextInjector(auto_activate=False)
result = injector.inject_text("olá")
self.assertTrue(result)
mock_get_xkb.assert_called_once()
mock_restore_xkb.assert_called_once_with("br", "", "")
self.assertEqual(
[call.args[0] for call in mock_switch.call_args_list],
[ENGINE_NAME, "xkb:br::por"],
)
# Capture before switch; restore after engine restore.
self.assertEqual(
[c[0] for c in parent.mock_calls],
["get_xkb", "switch", "switch", "restore_xkb"],
)
@patch("vocalinux.text_injection.ibus_engine.restore_xkb_layout")
@patch(
"vocalinux.text_injection.ibus_engine.get_current_xkb_layout",
return_value=("", "", ""),
)
@patch("vocalinux.text_injection.ibus_engine.get_current_engine", return_value="libpinyin")
@patch("vocalinux.text_injection.ibus_engine.is_engine_active", return_value=False)
@patch("vocalinux.text_injection.ibus_engine.switch_engine", return_value=True)
@patch("socket.socket")
@patch("vocalinux.text_injection.ibus_engine.SOCKET_PATH")
@patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True)
@patch("vocalinux.text_injection.ibus_engine.ensure_ibus_dir")
def test_inject_text_skips_xkb_restore_when_layout_empty(
self,
mock_ensure_dir,
mock_socket_path,
mock_socket_cls,
mock_switch,
mock_is_active,
mock_get_current,
mock_get_xkb,
mock_restore_xkb,
):
"""Wayland/empty capture must not call setxkbmap restore (#474)."""
from vocalinux.text_injection.ibus_engine import IBusTextInjector
mock_socket_path.exists.return_value = True
mock_sock = MagicMock()
mock_sock.__enter__.return_value = mock_sock
mock_sock.__exit__.return_value = None
mock_sock.recv.return_value = b"OK"
mock_socket_cls.return_value = mock_sock
injector = IBusTextInjector(auto_activate=False)
self.assertTrue(injector.inject_text("hello"))
mock_restore_xkb.assert_not_called()
@patch("vocalinux.text_injection.ibus_engine.get_current_engine", return_value="xkb:de::deu")
@patch("vocalinux.text_injection.ibus_engine.is_engine_active", return_value=False)
@patch("vocalinux.text_injection.ibus_engine.switch_engine", return_value=True)