From ca40f7d8b051d0d207c902df83beba867af30584 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:48:17 -0700 Subject: [PATCH] fix(text-injection): keep Alt+Shift/Win+Space layout switching on GNOME Wayland (#876) * fix(text-injection): restore full GNOME XKB map on XWayland after inject sync_xwayland_layout_from_gnome wrote only the single active input source via 'setxkbmap -layout '. That left XWayland with a one-group keymap: Alt+Shift 'grp:' toggles and the group index Mutter drives for Win+Space became no-ops for every X11/XWayland client until relogin. Apply GNOME's complete XKB map instead: every xkb input source in 'sources' order plus all 'xkb-options', and only when the live rules differ (which also keeps inject from resetting the active group). Refs #848 * fix(text-injection): harden XWayland repair against bad reads and non-GNOME sessions Three Greptile P1s on f005b6c: - _read_gnome_input_sources_key now returns [] for a successfully read empty list, reserving None for unreadable keys. _get_gnome_xkb_keymap bails when the xkb-options read fails instead of treating it as an empty list, so a transient gsettings failure can no longer make the repair clear live XWayland options via 'setxkbmap -option ""'. - _get_gnome_xkb_keymap restores the GNOME-session guard the previous source reader had, so a stale org.gnome.desktop.input-sources schema on another Wayland desktop is never applied to that desktop's XWayland server. - _query_xserver_xkb_rules catches OSError, not just FileNotFoundError: launch failures like PermissionError run inside inject_text's finally and must not surface after a successful commit. --------- Co-authored-by: Jatin K Malik --- src/vocalinux/text_injection/ibus_engine.py | 173 ++++++-- tests/test_ibus_xkb_wayland.py | 424 ++++++++++++++++---- 2 files changed, 479 insertions(+), 118 deletions(-) diff --git a/src/vocalinux/text_injection/ibus_engine.py b/src/vocalinux/text_injection/ibus_engine.py index 55b1d8a..2db1bde 100644 --- a/src/vocalinux/text_injection/ibus_engine.py +++ b/src/vocalinux/text_injection/ibus_engine.py @@ -361,7 +361,12 @@ def _is_gnome_session() -> bool: def _read_gnome_input_sources_key(key: str) -> Optional[list]: - """Return the parsed list for a gsettings org.gnome.desktop.input-sources key, or None.""" + """Return the parsed list for a gsettings org.gnome.desktop.input-sources key. + + Returns None when the key cannot be read or parsed. A successfully read + empty list comes back as ``[]`` so callers can tell a failed read from a + configured-empty list. + """ try: result = subprocess.run( ["gsettings", "get", "org.gnome.desktop.input-sources", key], @@ -377,10 +382,12 @@ def _read_gnome_input_sources_key(key: str) -> Optional[list]: import ast sources_text = result.stdout.strip() - if sources_text.startswith("@a(ss) "): - sources_text = sources_text[len("@a(ss) ") :] + # gsettings prints a type annotation on empty arrays — "@a(ss) []" for + # input sources, "@as []" for xkb-options — which literal_eval rejects. + if sources_text.startswith("@"): + sources_text = sources_text.split(" ", 1)[1] if " " in sources_text else "[]" sources = ast.literal_eval(sources_text) - if not isinstance(sources, (list, tuple)) or not sources: + if not isinstance(sources, (list, tuple)): return None return list(sources) except (subprocess.SubprocessError, OSError, TypeError, ValueError, SyntaxError) as e: @@ -677,15 +684,108 @@ def _x11_display_available() -> bool: return bool(display and display.strip()) +def _get_gnome_xkb_keymap() -> Optional[tuple[list, list, list]]: + """Return GNOME's complete XKB map as (layouts, variants, options). + + Layouts and variants come from every ``xkb`` entry in GNOME's ``sources`` + list, kept in list order — that is also the XKB group order Mutter + signals to XWayland on an input-source switch. Options come from the + ``xkb-options`` key (e.g. ``grp:alt_shift_toggle``). + """ + if not _is_gnome_session(): + return None + + sources = _read_gnome_input_sources_key("sources") + if not sources: + return None + + layouts: list = [] + variants: list = [] + for source in sources: + if not isinstance(source, (list, tuple)) or len(source) != 2: + continue + source_type, source_id = source + if source_type != "xkb" or not isinstance(source_id, str) or not source_id: + continue + layout, _, variant = source_id.partition("+") + if layout: + layouts.append(layout) + variants.append(variant) + + if not layouts: + return None + + # The repair write clears XKB options with 'setxkbmap -option ""' before + # re-adding them, so a failed options read must not look like an empty + # list — it would drop the live XWayland options it could not see. + options_list = _read_gnome_input_sources_key("xkb-options") + if options_list is None: + logger.debug("GNOME xkb-options unreadable; not syncing the XWayland map") + return None + options = [option for option in options_list if isinstance(option, str) and option] + return layouts, variants, options + + +def _query_xserver_xkb_rules() -> Optional[tuple[str, str, str]]: + """Return (layout, variant, options) of the X server's current keymap. + + Unlike ``get_current_xkb_layout`` this queries regardless of session + type: on Wayland it reads the XWayland map, which is what we repair. + """ + try: + result = subprocess.run( + ["setxkbmap", "-query"], capture_output=True, text=True, timeout=2, env=host_env() + ) + except (subprocess.SubprocessError, OSError) as e: + logger.debug(f"Could not query the X server XKB rules: {e}") + return None + if result.returncode != 0: + return None + + layout, variant, options = "", "", "" + for line in result.stdout.splitlines(): + key, sep, value = line.partition(":") + if not sep: + continue + if key.strip() == "layout": + layout = value.strip() + elif key.strip() == "variant": + variant = value.strip() + elif key.strip() == "options": + options = value.strip() + return layout, variant, options + + +def _xkb_list(value: str) -> list: + """Split a setxkbmap comma list into non-empty entries.""" + return [part for part in value.split(",") if part] + + def sync_xwayland_layout_from_gnome() -> bool: - """Push GNOME's current XKB source onto XWayland after scoped IBus inject. + """Restore GNOME's complete XKB keymap on XWayland after scoped 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. + On GNOME Wayland, Mutter hands XWayland one keymap holding every + configured layout as a group plus all ``xkb-options``; input-source + switches only move the active group inside it. The Vocalinux engine + round-trip (``ibus engine xkb:*`` applies a single-layout map) — and any + plain ``setxkbmap -layout X`` — leaves XWayland with a one-group map: + XKB group toggles like ``grp:alt_shift_toggle`` have no second group to + reach, and Mutter's group-index updates address a group that no longer + exists, so X11 clients lose both Alt+Shift and Super+Space switching + until relogin while native Wayland apps keep working (#848). - Native Wayland sessions with no X DISPLAY are left alone. + To repair that — and keep the #738 guarantee that XWayland follows the + live GNOME source after the engine round-trip — write the *full* GNOME + configuration: every ``xkb`` source in ``sources`` order plus all + ``xkb-options``. The write only happens when the live rules differ; + rewriting an already-correct map would reset the active group to the + first layout. After a repair write the active group starts at the first + source until Mutter's next group event, which any keypress or focus + change produces. + + Non-GNOME sessions and native Wayland sessions with no X DISPLAY are + left alone — a gsettings schema copied onto another desktop can hold + stale sources that do not describe that desktop's keymap. """ if not _is_wayland_session(): return False @@ -693,26 +793,34 @@ def sync_xwayland_layout_from_gnome() -> bool: logger.debug("No X DISPLAY; skipping XWayland layout sync (see #738)") return False - source = _get_gnome_current_source() - if not source: + keymap = _get_gnome_xkb_keymap() + if not keymap: return False + layouts, variants, options = keymap - 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 + current = _query_xserver_xkb_rules() + if current is not None: + cur_layout, cur_variant, cur_options = current + cur_variants = cur_variant.split(",") if cur_variant else [] + cur_variants += [""] * (len(layouts) - len(cur_variants)) + if ( + _xkb_list(cur_layout) == layouts + and cur_variants[: len(layouts)] == variants + and sorted(_xkb_list(cur_options)) == sorted(options) + ): + logger.debug("XWayland already has GNOME's complete XKB map; not rewriting it") + return True - layout, _, variant = source_id.partition("+") - if not layout: - return False + # Clear inherited XKB options first: setxkbmap otherwise merges new + # options into whatever the server already had. + cmd = ["setxkbmap", "-option", ""] + for option in options: + cmd.extend(["-option", option]) + cmd.extend(["-layout", ",".join(layouts)]) + if any(variants): + cmd.extend(["-variant", ",".join(variants)]) try: - cmd = ["setxkbmap", "-layout", layout] - if variant: - cmd.extend(["-variant", variant]) result = subprocess.run( cmd, capture_output=True, @@ -721,7 +829,10 @@ def sync_xwayland_layout_from_gnome() -> bool: env=host_env(), ) if result.returncode == 0: - logger.debug(f"Synced XWayland layout to GNOME source '{source_id}' (see #738)") + logger.debug( + f"Synced XWayland keymap to GNOME XKB sources " + f"({','.join(layouts)}, options: {','.join(options)}) (see #738, #848)" + ) return True logger.debug(f"setxkbmap failed while syncing XWayland: {result.stderr}") except (subprocess.SubprocessError, FileNotFoundError, OSError) as e: @@ -1659,10 +1770,12 @@ class IBusTextInjector: 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. + # Compositor is source of truth. Restore GNOME's full XKB + # configuration on XWayland so neither a mid-recording + # Super+Space (#738) nor a single-layout map left by the + # engine round-trip (#848) can strand X apps on a stale or + # incomplete keymap. 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_xkb_wayland.py b/tests/test_ibus_xkb_wayland.py index 6788c9d..c443262 100644 --- a/tests/test_ibus_xkb_wayland.py +++ b/tests/test_ibus_xkb_wayland.py @@ -1,4 +1,4 @@ -"""Regression tests for issues #474 and #738. +"""Regression tests for issues #474, #738 and #848. ``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) @@ -6,8 +6,12 @@ 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``. +``setxkbmap`` to restore GNOME's complete XKB configuration on XWayland — +every xkb input source plus all xkb-options. Writing only the current +source's layout strands XWayland on a single-group keymap, which kills both +XKB group toggles (Alt+Shift) and Mutter's group-index switching +(Super+Space) for X11 clients until relogin (#848). That path must not go +through ``restore_xkb_layout``. """ import sys @@ -32,6 +36,15 @@ for _key in list(sys.modules.keys()): from vocalinux.text_injection.ibus_engine import restore_xkb_layout # noqa: E402 +def _gnome_reader(sources, options): + """Fake _read_gnome_input_sources_key returning per-key lists.""" + + def _read(key): + return {"sources": sources, "xkb-options": options}.get(key) + + return _read + + class TestRestoreXkbLayoutWayland(unittest.TestCase): """restore_xkb_layout must be a no-op on Wayland (issue #474).""" @@ -60,7 +73,12 @@ class TestRestoreXkbLayoutWayland(unittest.TestCase): class TestSyncXwaylandLayoutFromGnome(unittest.TestCase): - """XWayland must follow GNOME's live source after scoped inject (#738).""" + """XWayland gets GNOME's complete XKB map after scoped inject (#738, #848).""" + + _SOURCES = [("xkb", "us"), ("xkb", "ru")] + _OPTIONS = ["grp:alt_shift_toggle", "terminate:ctrl_alt_bksp"] + _WAYLAND_XENV = {"XDG_SESSION_TYPE": "wayland", "DISPLAY": ":0"} + _GNOME_WAYLAND_ENV = {**_WAYLAND_XENV, "XDG_CURRENT_DESKTOP": "GNOME"} def _sync(self): # Import at call time so patches hit the module other test files reloaded. @@ -69,87 +87,140 @@ class TestSyncXwaylandLayoutFromGnome(unittest.TestCase): 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("vocalinux.text_injection.ibus_engine._read_gnome_input_sources_key") + @patch("vocalinux.text_injection.ibus_engine._query_xserver_xkb_rules") @patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}, clear=True) - def test_no_display_does_not_call_setxkbmap(self, mock_source, mock_run): + def test_no_display_does_not_call_setxkbmap(self, mock_query, mock_read, mock_run): self.assertFalse(self._sync()) - mock_source.assert_not_called() + mock_read.assert_not_called() + mock_query.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("vocalinux.text_injection.ibus_engine._read_gnome_input_sources_key") + @patch("vocalinux.text_injection.ibus_engine._query_xserver_xkb_rules") @patch.dict( "os.environ", {"XDG_SESSION_TYPE": "wayland", "DISPLAY": " "}, clear=True, ) - def test_blank_display_does_not_call_setxkbmap(self, mock_source, mock_run): + def test_blank_display_does_not_call_setxkbmap(self, mock_query, mock_read, mock_run): self.assertFalse(self._sync()) - mock_source.assert_not_called() + mock_read.assert_not_called() + mock_query.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("vocalinux.text_injection.ibus_engine._read_gnome_input_sources_key") + @patch("vocalinux.text_injection.ibus_engine._query_xserver_xkb_rules") @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): + def test_x11_does_not_sync(self, mock_query, mock_read, mock_run): self.assertFalse(self._sync()) + mock_read.assert_not_called() + mock_query.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=("ibus", "libpinyin"), + "vocalinux.text_injection.ibus_engine._read_gnome_input_sources_key", + return_value=None, ) + @patch("vocalinux.text_injection.ibus_engine._query_xserver_xkb_rules") + @patch.dict("os.environ", _GNOME_WAYLAND_ENV, clear=True) + def test_missing_gnome_config_is_noop(self, mock_query, mock_read, mock_run): + self.assertFalse(self._sync()) + mock_query.assert_not_called() + mock_run.assert_not_called() + + @patch("vocalinux.text_injection.ibus_engine.subprocess.run") + @patch("vocalinux.text_injection.ibus_engine._read_gnome_input_sources_key") + @patch("vocalinux.text_injection.ibus_engine._query_xserver_xkb_rules") @patch.dict( "os.environ", - {"XDG_SESSION_TYPE": "wayland", "DISPLAY": ":0"}, + {"XDG_SESSION_TYPE": "wayland", "DISPLAY": ":0", "XDG_CURRENT_DESKTOP": "KDE"}, clear=True, ) - def test_ibus_source_does_not_call_setxkbmap(self, mock_source, mock_run): + def test_non_gnome_wayland_does_not_sync(self, mock_query, mock_read, mock_run): + """A stale GNOME schema must never reach another desktop's XWayland.""" self.assertFalse(self._sync()) + mock_read.assert_not_called() + mock_query.assert_not_called() + mock_run.assert_not_called() + + @patch("vocalinux.text_injection.ibus_engine.subprocess.run") + @patch( + "vocalinux.text_injection.ibus_engine._read_gnome_input_sources_key", + side_effect=_gnome_reader(_SOURCES, None), + ) + @patch("vocalinux.text_injection.ibus_engine._query_xserver_xkb_rules") + @patch.dict("os.environ", _GNOME_WAYLAND_ENV, clear=True) + def test_failed_options_read_does_not_clear_options(self, mock_query, mock_read, mock_run): + """A failed xkb-options read must not trigger an 'setxkbmap -option ""' write.""" + self.assertFalse(self._sync()) + mock_query.assert_not_called() + mock_run.assert_not_called() + + @patch("vocalinux.text_injection.ibus_engine.subprocess.run") + @patch( + "vocalinux.text_injection.ibus_engine._read_gnome_input_sources_key", + side_effect=_gnome_reader([("ibus", "libpinyin")], []), + ) + @patch("vocalinux.text_injection.ibus_engine._query_xserver_xkb_rules") + @patch.dict("os.environ", _GNOME_WAYLAND_ENV, clear=True) + def test_ibus_only_sources_is_noop(self, mock_query, mock_read, mock_run): + self.assertFalse(self._sync()) + mock_query.assert_not_called() + mock_run.assert_not_called() + + @patch("vocalinux.text_injection.ibus_engine.subprocess.run") + @patch( + "vocalinux.text_injection.ibus_engine._read_gnome_input_sources_key", + side_effect=_gnome_reader(_SOURCES, _OPTIONS), + ) + @patch( + "vocalinux.text_injection.ibus_engine._query_xserver_xkb_rules", + return_value=("us,ru", "", "grp:alt_shift_toggle,terminate:ctrl_alt_bksp"), + ) + @patch.dict("os.environ", _GNOME_WAYLAND_ENV, clear=True) + def test_complete_map_is_not_rewritten(self, mock_query, mock_read, mock_run): + """An already-correct XWayland map is left alone so the active group survives.""" + self.assertTrue(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"), + "vocalinux.text_injection.ibus_engine._read_gnome_input_sources_key", + side_effect=_gnome_reader(_SOURCES, _OPTIONS), ) - @patch.dict( - "os.environ", - {"XDG_SESSION_TYPE": "wayland", "DISPLAY": ":0"}, - clear=True, + @patch( + "vocalinux.text_injection.ibus_engine._query_xserver_xkb_rules", + return_value=("us", "", "grp:alt_shift_toggle,terminate:ctrl_alt_bksp"), ) - def test_applies_gnome_layout_without_restore_xkb_layout( - self, mock_source, mock_run, mock_restore + @patch.dict("os.environ", _GNOME_WAYLAND_ENV, clear=True) + def test_single_layout_map_rewritten_with_full_gnome_map( + self, mock_query, mock_read, mock_run, mock_restore ): + """A clobbered single-layout map (#848) is replaced by all sources+options.""" 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"], + [ + "setxkbmap", + "-option", + "", + "-option", + "grp:alt_shift_toggle", + "-option", + "terminate:ctrl_alt_bksp", + "-layout", + "us,ru", + ], capture_output=True, text=True, timeout=2, @@ -158,19 +229,31 @@ class TestSyncXwaylandLayoutFromGnome(unittest.TestCase): @patch("vocalinux.text_injection.ibus_engine.subprocess.run") @patch( - "vocalinux.text_injection.ibus_engine._get_gnome_current_source", - return_value=("xkb", "us+altgr-intl"), + "vocalinux.text_injection.ibus_engine._read_gnome_input_sources_key", + side_effect=_gnome_reader( + [("xkb", "us+altgr-intl"), ("xkb", "ru+phonetic")], ["grp:alt_shift_toggle"] + ), ) - @patch.dict( - "os.environ", - {"XDG_SESSION_TYPE": "wayland", "DISPLAY": ":0"}, - clear=True, + @patch( + "vocalinux.text_injection.ibus_engine._query_xserver_xkb_rules", + return_value=("us,ru", "", "grp:alt_shift_toggle"), ) - def test_applies_layout_variant(self, mock_source, mock_run): + @patch.dict("os.environ", _GNOME_WAYLAND_ENV, clear=True) + def test_variants_written_with_layouts(self, mock_query, mock_read, 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"], + [ + "setxkbmap", + "-option", + "", + "-option", + "grp:alt_shift_toggle", + "-layout", + "us,ru", + "-variant", + "altgr-intl,phonetic", + ], capture_output=True, text=True, timeout=2, @@ -179,58 +262,223 @@ class TestSyncXwaylandLayoutFromGnome(unittest.TestCase): @patch("vocalinux.text_injection.ibus_engine.subprocess.run") @patch( - "vocalinux.text_injection.ibus_engine._get_gnome_current_source", - return_value=("xkb", "us"), + "vocalinux.text_injection.ibus_engine._read_gnome_input_sources_key", + side_effect=_gnome_reader([("xkb", "us"), ("xkb", "ru+phonetic")], []), ) - @patch.dict( - "os.environ", - {"XDG_SESSION_TYPE": "wayland", "DISPLAY": ":0"}, - clear=True, + @patch( + "vocalinux.text_injection.ibus_engine._query_xserver_xkb_rules", + return_value=("us", "", ""), ) - def test_setxkbmap_failure_returns_false(self, mock_source, mock_run): + @patch.dict("os.environ", _GNOME_WAYLAND_ENV, clear=True) + def test_empty_variant_position_is_padded(self, mock_query, mock_read, mock_run): + mock_run.return_value = MagicMock(returncode=0, stderr="") + self.assertTrue(self._sync()) + mock_run.assert_called_once_with( + ["setxkbmap", "-option", "", "-layout", "us,ru", "-variant", ",phonetic"], + capture_output=True, + text=True, + timeout=2, + env=ANY, + ) + + @patch("vocalinux.text_injection.ibus_engine.subprocess.run") + @patch( + "vocalinux.text_injection.ibus_engine._read_gnome_input_sources_key", + side_effect=_gnome_reader([("xkb", "us"), ("ibus", "libpinyin"), ("xkb", "ru")], []), + ) + @patch( + "vocalinux.text_injection.ibus_engine._query_xserver_xkb_rules", + return_value=("us", "", ""), + ) + @patch.dict("os.environ", _GNOME_WAYLAND_ENV, clear=True) + def test_non_xkb_sources_are_filtered(self, mock_query, mock_read, mock_run): + mock_run.return_value = MagicMock(returncode=0, stderr="") + self.assertTrue(self._sync()) + mock_run.assert_called_once_with( + ["setxkbmap", "-option", "", "-layout", "us,ru"], + capture_output=True, + text=True, + timeout=2, + env=ANY, + ) + + @patch("vocalinux.text_injection.ibus_engine.subprocess.run") + @patch( + "vocalinux.text_injection.ibus_engine._read_gnome_input_sources_key", + side_effect=_gnome_reader(_SOURCES, _OPTIONS), + ) + @patch( + "vocalinux.text_injection.ibus_engine._query_xserver_xkb_rules", + return_value=None, + ) + @patch.dict("os.environ", _GNOME_WAYLAND_ENV, clear=True) + def test_query_failure_still_writes(self, mock_query, mock_read, mock_run): + mock_run.return_value = MagicMock(returncode=0, stderr="") + self.assertTrue(self._sync()) + mock_run.assert_called_once_with( + [ + "setxkbmap", + "-option", + "", + "-option", + "grp:alt_shift_toggle", + "-option", + "terminate:ctrl_alt_bksp", + "-layout", + "us,ru", + ], + capture_output=True, + text=True, + timeout=2, + env=ANY, + ) + + @patch("vocalinux.text_injection.ibus_engine.subprocess.run") + @patch( + "vocalinux.text_injection.ibus_engine._read_gnome_input_sources_key", + side_effect=_gnome_reader(_SOURCES, []), + ) + @patch( + "vocalinux.text_injection.ibus_engine._query_xserver_xkb_rules", + return_value=("us", "", ""), + ) + @patch.dict("os.environ", _GNOME_WAYLAND_ENV, clear=True) + def test_setxkbmap_failure_returns_false(self, mock_query, mock_read, 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"), + "vocalinux.text_injection.ibus_engine._read_gnome_input_sources_key", + side_effect=_gnome_reader(_SOURCES, []), ) - @patch.dict( - "os.environ", - {"XDG_SESSION_TYPE": "wayland", "DISPLAY": ":0"}, - clear=True, + @patch( + "vocalinux.text_injection.ibus_engine._query_xserver_xkb_rules", + return_value=("us", "", ""), ) - def test_missing_setxkbmap_returns_false(self, mock_source, mock_run): + @patch.dict("os.environ", _GNOME_WAYLAND_ENV, clear=True) + def test_missing_setxkbmap_returns_false(self, mock_query, mock_read, 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") + +@patch.dict("os.environ", {"XDG_CURRENT_DESKTOP": "GNOME"}) +class TestGetGnomeXkbKeymap(unittest.TestCase): + """_get_gnome_xkb_keymap parses GNOME sources and xkb-options (#848).""" + + def _keymap(self): + from vocalinux.text_injection.ibus_engine import _get_gnome_xkb_keymap + + return _get_gnome_xkb_keymap() + + @patch("vocalinux.text_injection.ibus_engine._read_gnome_input_sources_key") + def test_parses_layouts_variants_and_options(self, mock_read): + mock_read.side_effect = lambda key: { + "sources": [("xkb", "us"), ("xkb", "ru+phonetic")], + "xkb-options": ["grp:alt_shift_toggle"], + }.get(key) self.assertEqual( - mock_run.call_args_list[1].args[0], - ["setxkbmap", "-layout", "us"], + self._keymap(), + (["us", "ru"], ["", "phonetic"], ["grp:alt_shift_toggle"]), ) + @patch("vocalinux.text_injection.ibus_engine._read_gnome_input_sources_key") + def test_missing_sources_returns_none(self, mock_read): + mock_read.return_value = None + self.assertIsNone(self._keymap()) + + @patch("vocalinux.text_injection.ibus_engine._read_gnome_input_sources_key") + def test_no_xkb_sources_returns_none(self, mock_read): + mock_read.side_effect = lambda key: { + "sources": [("ibus", "libpinyin")], + "xkb-options": [], + }.get(key) + self.assertIsNone(self._keymap()) + + @patch("vocalinux.text_injection.ibus_engine._read_gnome_input_sources_key") + def test_empty_options_returns_empty_options(self, mock_read): + mock_read.side_effect = lambda key: { + "sources": [("xkb", "us"), ("xkb", "ru")], + "xkb-options": [], + }.get(key) + self.assertEqual(self._keymap(), (["us", "ru"], ["", ""], [])) + + @patch("vocalinux.text_injection.ibus_engine._read_gnome_input_sources_key") + def test_failed_options_read_returns_none(self, mock_read): + """A failed options read is not a configured-empty option list.""" + mock_read.side_effect = lambda key: { + "sources": [("xkb", "us"), ("xkb", "ru")], + "xkb-options": None, + }.get(key) + self.assertIsNone(self._keymap()) + + @patch.dict("os.environ", {"XDG_CURRENT_DESKTOP": "KDE"}) + @patch("vocalinux.text_injection.ibus_engine._read_gnome_input_sources_key") + def test_non_gnome_session_returns_none(self, mock_read): + self.assertIsNone(self._keymap()) + mock_read.assert_not_called() + + @patch("vocalinux.text_injection.ibus_engine._read_gnome_input_sources_key") + def test_malformed_source_entries_are_skipped(self, mock_read): + mock_read.side_effect = lambda key: { + "sources": [("xkb", "us"), "bogus", ("xkb",), ("xkb", "ru")], + "xkb-options": ["grp:alt_shift_toggle", "", 42], + }.get(key) + self.assertEqual( + self._keymap(), + (["us", "ru"], ["", ""], ["grp:alt_shift_toggle"]), + ) + + +class TestQueryXserverXkbRules(unittest.TestCase): + """_query_xserver_xkb_rules parses setxkbmap -query output (#848).""" + + def _query(self): + from vocalinux.text_injection.ibus_engine import _query_xserver_xkb_rules + + return _query_xserver_xkb_rules() + + @patch("vocalinux.text_injection.ibus_engine.subprocess.run") + def test_parses_layout_variant_options(self, mock_run): + mock_run.return_value = MagicMock( + returncode=0, + stdout=( + "rules: evdev\n" + "model: pc105\n" + "layout: us,ru\n" + "variant: ,phonetic\n" + "options: grp:alt_shift_toggle,terminate:ctrl_alt_bksp\n" + ), + stderr="", + ) + self.assertEqual( + self._query(), + ("us,ru", ",phonetic", "grp:alt_shift_toggle,terminate:ctrl_alt_bksp"), + ) + + @patch("vocalinux.text_injection.ibus_engine.subprocess.run") + def test_missing_fields_default_empty(self, mock_run): + mock_run.return_value = MagicMock( + returncode=0, stdout="rules: evdev\nlayout: us\n", stderr="" + ) + self.assertEqual(self._query(), ("us", "", "")) + + @patch("vocalinux.text_injection.ibus_engine.subprocess.run") + def test_failure_returns_none(self, mock_run): + mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="bad") + self.assertIsNone(self._query()) + + @patch("vocalinux.text_injection.ibus_engine.subprocess.run") + def test_missing_setxkbmap_returns_none(self, mock_run): + mock_run.side_effect = FileNotFoundError("setxkbmap") + self.assertIsNone(self._query()) + + @patch("vocalinux.text_injection.ibus_engine.subprocess.run") + def test_oserror_does_not_escape(self, mock_run): + """Launch errors like PermissionError must not escape inject's finally.""" + mock_run.side_effect = PermissionError("setxkbmap") + self.assertIsNone(self._query()) + if __name__ == "__main__": unittest.main()