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 <one>'. 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 <jatinkrmalik@gmail.com>
This commit is contained in:
co-authored by
Jatin K Malik
parent
8ebc4ce2ba
commit
ca40f7d8b0
@@ -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
|
||||
|
||||
+336
-88
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user