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