diff --git a/src/vocalinux/text_injection/ibus_engine.py b/src/vocalinux/text_injection/ibus_engine.py index ab4460c..8c34363 100644 --- a/src/vocalinux/text_injection/ibus_engine.py +++ b/src/vocalinux/text_injection/ibus_engine.py @@ -622,6 +622,63 @@ def restore_xkb_layout(layout: str, variant: str = "", option: str = "") -> bool return False +def _x11_display_available() -> bool: + """True when an X11 display is advertised (XWayland or native X).""" + display = os.environ.get("DISPLAY") + return bool(display and display.strip()) + + +def sync_xwayland_layout_from_gnome() -> bool: + """Push GNOME's current XKB source onto XWayland after scoped IBus inject. + + On GNOME Wayland, Super+Space updates Mutter and IBus immediately, but + XWayland can keep the previous map after the Vocalinux engine round-trip + (issue #738). ``restore_xkb_layout()`` stays a no-op on Wayland (#474); + this helper reads the live GNOME source instead of a captured layout. + + Native Wayland sessions with no X DISPLAY are left alone. + """ + if not _is_wayland_session(): + return False + if not _x11_display_available(): + logger.debug("No X DISPLAY; skipping XWayland layout sync (see #738)") + return False + + source = _get_gnome_current_source() + if not source: + return False + + source_type, source_id = source + if source_type != "xkb" or not source_id: + logger.debug( + f"GNOME current source is {source_type}:{source_id}; " + "not an XKB map, skipping XWayland sync" + ) + return False + + layout, _, variant = source_id.partition("+") + if not layout: + return False + + try: + cmd = ["setxkbmap", "-layout", layout] + if variant: + cmd.extend(["-variant", variant]) + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=2, + ) + if result.returncode == 0: + logger.debug(f"Synced XWayland layout to GNOME source '{source_id}' (see #738)") + return True + logger.debug(f"setxkbmap failed while syncing XWayland: {result.stderr}") + except (subprocess.SubprocessError, FileNotFoundError, OSError) as e: + logger.debug(f"Could not sync XWayland layout: {e}") + return False + + def _invoke_parent_destroy(parent_destroy: Callable[[], None], instance: object) -> None: """Call the parent engine destroy, working around PyGObject's GType binding. @@ -1545,10 +1602,16 @@ class IBusTextInjector: # 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). + # Wayland capture is empty so restore_xkb_layout is a no-op (#474, #664). layout, variant, option = captured_xkb if layout: restore_xkb_layout(layout, variant, option) + else: + # Compositor is source of truth. Copy GNOME's live XKB source + # onto XWayland so Super+Space during recording cannot leave + # X apps on the previous map (#738). Do not replay the empty + # capture and do not go through restore_xkb_layout. + sync_xwayland_layout_from_gnome() return False diff --git a/tests/test_ibus_engine.py b/tests/test_ibus_engine.py index 66d0941..ca973ee 100644 --- a/tests/test_ibus_engine.py +++ b/tests/test_ibus_engine.py @@ -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) diff --git a/tests/test_ibus_xkb_wayland.py b/tests/test_ibus_xkb_wayland.py index f426937..293a4f9 100644 --- a/tests/test_ibus_xkb_wayland.py +++ b/tests/test_ibus_xkb_wayland.py @@ -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()