fix(ibus): sync XWayland layout from GNOME after scoped inject (#742)

Super+Space during recording on GNOME Wayland updates Mutter and IBus
but can leave XWayland on the previous map. Scoped inject restores the
IBus engine and skips setxkbmap (#474), so X apps keep the old layout.

After engine restore, copy GNOME's current XKB source onto XWayland
when DISPLAY is set. Do not replay an empty capture and do not call
restore_xkb_layout for this. Sessions with no X display are left alone.
This commit is contained in:
Jatin K Malik
2026-08-28 23:29:53 -07:00
committed by GitHub
parent 9c3ed49317
commit 65ce9daa19
3 changed files with 339 additions and 6 deletions
+95
View File
@@ -1056,6 +1056,101 @@ class TestIBusTextInjector(unittest.TestCase):
self.assertTrue(injector.inject_text("hello"))
mock_restore_xkb.assert_not_called()
@patch("vocalinux.text_injection.ibus_engine.sync_xwayland_layout_from_gnome")
@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="xkb:us::eng")
@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_syncs_xwayland_from_gnome_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,
mock_sync,
):
"""Wayland/empty capture syncs XWayland from GNOME after engine restore (#738)."""
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")
parent.attach_mock(mock_sync, "sync")
injector = IBusTextInjector(auto_activate=False)
self.assertTrue(injector.inject_text("hello"))
mock_restore_xkb.assert_not_called()
mock_sync.assert_called_once_with()
self.assertEqual(
[call.args[0] for call in mock_switch.call_args_list],
[ENGINE_NAME, "xkb:us::eng"],
)
self.assertEqual(
[c[0] for c in parent.mock_calls],
["get_xkb", "switch", "switch", "sync"],
)
@patch("vocalinux.text_injection.ibus_engine.sync_xwayland_layout_from_gnome")
@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_does_not_sync_xwayland_when_xkb_was_captured(
self,
mock_ensure_dir,
mock_socket_path,
mock_socket_cls,
mock_switch,
mock_is_active,
mock_get_current,
mock_get_xkb,
mock_restore_xkb,
mock_sync,
):
"""X11 captured layouts still go through restore_xkb_layout, not the Wayland sync."""
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("ola"))
mock_restore_xkb.assert_called_once_with("br", "", "")
mock_sync.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)
+180 -5
View File
@@ -1,9 +1,13 @@
"""Regression tests for issue #474.
"""Regression tests for issues #474 and #738.
On Wayland, Vocalinux must not call ``setxkbmap``. It only reaches the XWayland
X11 server, so re-applying a layout there leaves XWayland (and XWayland/Electron
apps) on the wrong layout while native Wayland apps and ``localectl`` stay
correct — the "layout flips from de to us after dictation" symptom.
``restore_xkb_layout`` must not call ``setxkbmap`` on Wayland. That tool only
reaches the XWayland X11 server, so re-applying a captured (or default us)
layout leaves XWayland apps on the wrong map while native Wayland apps and
``localectl`` stay correct.
After scoped IBus restore, ``sync_xwayland_layout_from_gnome`` may call
``setxkbmap`` to copy GNOME's live XKB source onto XWayland when DISPLAY is
set. That path must not go through ``restore_xkb_layout``.
"""
import sys
@@ -55,5 +59,176 @@ class TestRestoreXkbLayoutWayland(unittest.TestCase):
mock_run.assert_not_called()
class TestSyncXwaylandLayoutFromGnome(unittest.TestCase):
"""XWayland must follow GNOME's live source after scoped inject (#738)."""
def _sync(self):
# Import at call time so patches hit the module other test files reloaded.
from vocalinux.text_injection.ibus_engine import sync_xwayland_layout_from_gnome
return sync_xwayland_layout_from_gnome()
@patch("vocalinux.text_injection.ibus_engine.subprocess.run")
@patch("vocalinux.text_injection.ibus_engine._get_gnome_current_source")
@patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}, clear=True)
def test_no_display_does_not_call_setxkbmap(self, mock_source, mock_run):
self.assertFalse(self._sync())
mock_source.assert_not_called()
mock_run.assert_not_called()
@patch("vocalinux.text_injection.ibus_engine.subprocess.run")
@patch(
"vocalinux.text_injection.ibus_engine._get_gnome_current_source",
return_value=("xkb", "us"),
)
@patch.dict(
"os.environ",
{"XDG_SESSION_TYPE": "wayland", "DISPLAY": " "},
clear=True,
)
def test_blank_display_does_not_call_setxkbmap(self, mock_source, mock_run):
self.assertFalse(self._sync())
mock_source.assert_not_called()
mock_run.assert_not_called()
@patch("vocalinux.text_injection.ibus_engine.subprocess.run")
@patch(
"vocalinux.text_injection.ibus_engine._get_gnome_current_source",
return_value=("xkb", "us"),
)
@patch.dict(
"os.environ",
{"XDG_SESSION_TYPE": "x11", "DISPLAY": ":0"},
clear=True,
)
def test_x11_does_not_sync(self, mock_source, mock_run):
self.assertFalse(self._sync())
mock_source.assert_not_called()
mock_run.assert_not_called()
@patch("vocalinux.text_injection.ibus_engine.subprocess.run")
@patch("vocalinux.text_injection.ibus_engine._get_gnome_current_source", return_value=None)
@patch.dict(
"os.environ",
{"XDG_SESSION_TYPE": "wayland", "DISPLAY": ":0"},
clear=True,
)
def test_missing_gnome_source_is_noop(self, mock_source, mock_run):
self.assertFalse(self._sync())
mock_run.assert_not_called()
@patch("vocalinux.text_injection.ibus_engine.subprocess.run")
@patch(
"vocalinux.text_injection.ibus_engine._get_gnome_current_source",
return_value=("ibus", "libpinyin"),
)
@patch.dict(
"os.environ",
{"XDG_SESSION_TYPE": "wayland", "DISPLAY": ":0"},
clear=True,
)
def test_ibus_source_does_not_call_setxkbmap(self, mock_source, mock_run):
self.assertFalse(self._sync())
mock_run.assert_not_called()
@patch("vocalinux.text_injection.ibus_engine.restore_xkb_layout")
@patch("vocalinux.text_injection.ibus_engine.subprocess.run")
@patch(
"vocalinux.text_injection.ibus_engine._get_gnome_current_source",
return_value=("xkb", "us"),
)
@patch.dict(
"os.environ",
{"XDG_SESSION_TYPE": "wayland", "DISPLAY": ":0"},
clear=True,
)
def test_applies_gnome_layout_without_restore_xkb_layout(
self, mock_source, mock_run, mock_restore
):
mock_run.return_value = MagicMock(returncode=0, stderr="")
self.assertTrue(self._sync())
mock_restore.assert_not_called()
mock_run.assert_called_once_with(
["setxkbmap", "-layout", "us"],
capture_output=True,
text=True,
timeout=2,
)
@patch("vocalinux.text_injection.ibus_engine.subprocess.run")
@patch(
"vocalinux.text_injection.ibus_engine._get_gnome_current_source",
return_value=("xkb", "us+altgr-intl"),
)
@patch.dict(
"os.environ",
{"XDG_SESSION_TYPE": "wayland", "DISPLAY": ":0"},
clear=True,
)
def test_applies_layout_variant(self, mock_source, mock_run):
mock_run.return_value = MagicMock(returncode=0, stderr="")
self.assertTrue(self._sync())
mock_run.assert_called_once_with(
["setxkbmap", "-layout", "us", "-variant", "altgr-intl"],
capture_output=True,
text=True,
timeout=2,
)
@patch("vocalinux.text_injection.ibus_engine.subprocess.run")
@patch(
"vocalinux.text_injection.ibus_engine._get_gnome_current_source",
return_value=("xkb", "us"),
)
@patch.dict(
"os.environ",
{"XDG_SESSION_TYPE": "wayland", "DISPLAY": ":0"},
clear=True,
)
def test_setxkbmap_failure_returns_false(self, mock_source, mock_run):
mock_run.return_value = MagicMock(returncode=1, stderr="Cannot open display")
self.assertFalse(self._sync())
@patch("vocalinux.text_injection.ibus_engine.subprocess.run")
@patch(
"vocalinux.text_injection.ibus_engine._get_gnome_current_source",
return_value=("xkb", "us"),
)
@patch.dict(
"os.environ",
{"XDG_SESSION_TYPE": "wayland", "DISPLAY": ":0"},
clear=True,
)
def test_missing_setxkbmap_returns_false(self, mock_source, mock_run):
mock_run.side_effect = FileNotFoundError("setxkbmap")
self.assertFalse(self._sync())
@patch("vocalinux.text_injection.ibus_engine.subprocess.run")
@patch.dict(
"os.environ",
{
"XDG_SESSION_TYPE": "wayland",
"DISPLAY": ":0",
"XDG_CURRENT_DESKTOP": "GNOME",
},
clear=True,
)
def test_uses_gnome_mru_sources(self, mock_run):
mock_run.side_effect = [
MagicMock(
returncode=0,
stdout="[('xkb', 'us'), ('xkb', 'ru')]",
stderr="",
),
MagicMock(returncode=0, stderr=""),
]
self.assertTrue(self._sync())
self.assertEqual(mock_run.call_args_list[0].args[0][-1], "mru-sources")
self.assertEqual(
mock_run.call_args_list[1].args[0],
["setxkbmap", "-layout", "us"],
)
if __name__ == "__main__":
unittest.main()