From 2c6acbc15eaf7b67fd98e13d8b552f033cd29d2d Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Mon, 7 Sep 2026 18:10:43 -0700 Subject: [PATCH] fix(inject): layout-aware clipboard paste for Neo and friends (#788) * fix(inject): resolve paste chord for non-QWERTY layouts ydotool was sending physical KEY_V (47) for Ctrl+V. On German Neo that key types p, so paste opened Print instead. Prefer wtype keysyms when usable; otherwise resolve the evdev key for Latin v via xkbcommon, and read KDE kxkbrc so Plasma Wayland Neo users are covered. Fixes #787 * fix(inject): read Plasma active layout from real session sources kxkbrc has no CurrentLayout/LayoutIndex, so multi-layout Neo always fell back to us at index 0 and paste still hit KEY_V. Prefer D-Bus getLayout or layout_memory.xml for the active layout, keep kxkbrc for the list only, and prefer KDE over leftover GNOME on Plasma. --- docs/USER_GUIDE.md | 2 + src/vocalinux/text_injection/text_injector.py | 236 ++++++++++-- .../ui/keyboard_backends/layout_key_map.py | 316 +++++++++++++++- tests/test_layout_key_map.py | 343 +++++++++++++++++- tests/test_paste_shortcut.py | 104 +++++- tests/test_text_injector.py | 46 ++- 6 files changed, 1012 insertions(+), 35 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 33ab6c7..6183855 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -44,6 +44,8 @@ Vocalinux capitalizes the start of dictation and letters after `.`, `!`, or `?`. When Vocalinux injects through the clipboard (the usual Wayland / ydotool path), it sends **Ctrl+V** in ordinary text fields and **Ctrl+Shift+V** in terminal emulator windows. Auto-detect works on X11 and on Hyprland, Sway, and niri. On GNOME or KDE Wayland, set **Settings → Dictation → Clipboard Paste Shortcut** to **Ctrl+Shift+V**. +On non-US layouts such as German Neo, that chord uses the key that types **v** on the active layout (not physical KEY_V). Plasma's active layout comes from layout memory or D-Bus, not from `kxkbrc` list order. The map is cached for the process lifetime — restart Vocalinux after switching layouts. Per-window Plasma layouts may be stale if D-Bus is unavailable. + Nested terminal panels inside an IDE are often invisible to window-class detection. If paste lands as a literal `^V` or does nothing, open **Settings → Dictation → Clipboard Paste Shortcut** and choose **Ctrl+Shift+V**. Choose **Ctrl+V** if a window was mis-detected as a terminal. ### Understanding the Status Icons diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py index 8769519..265a082 100644 --- a/src/vocalinux/text_injection/text_injector.py +++ b/src/vocalinux/text_injection/text_injector.py @@ -1389,13 +1389,15 @@ class TextInjector: def _inject_via_clipboard_paste(self, text: str) -> bool: """ - Inject text by copying to clipboard and simulating a paste with ydotool. + Inject text by copying to clipboard and simulating a paste chord. Ordinary text fields receive Ctrl+V. Terminal emulators typically bind paste to Ctrl+Shift+V, so auto-detect (or the Settings override) picks that chord instead. Workaround for ydotool's US-ASCII-only key events - (see issue #362). Saves the previous clipboard and restores it after a - short delay. Overlapping pastes share one restore target + (see issue #362). The chord itself is sent with wtype keysyms when + that tool is usable, otherwise with a layout-resolved ydotool keycode + for Latin 'v' (issue #787). Saves the previous clipboard and restores + it after a short delay. Overlapping pastes share one restore target (pre-first-injection content) and a generation counter so stale restore threads exit. @@ -1424,13 +1426,16 @@ class TextInjector: self._clipboard_restore_generation += 1 generation = self._clipboard_restore_generation - # Simulate paste via ydotool. Syntax differs by major version: + # Simulate paste. Prefer wtype keysyms (layout-independent) when that + # tool is usable; otherwise ydotool. ydotool syntax differs by version: # - 0.1.x (distro packages): named sequences, e.g. ctrl+v - # - 1.x (Flatpak build): keycode:value (29=LEFTCTRL, 42=LEFTSHIFT, 47=V) - # Passing 1.x codes to 0.1.x does not paste; it types garbage (e.g. "2442"). + # - 1.x (Flatpak build): keycode:value (29=LEFTCTRL, 42=LEFTSHIFT, + # and the evdev code that produces Latin 'v' on the active layout — + # KEY_V=47 only on QWERTY). Passing 1.x codes to 0.1.x does not + # paste; it types garbage (e.g. "2442"). try: use_terminal_paste = self._should_use_terminal_paste() - cmd = self._ydotool_ctrl_v_command(terminal=use_terminal_paste) + cmd = self._clipboard_paste_command(terminal=use_terminal_paste) logger.debug( "Simulating %s paste with: %s", "terminal" if use_terminal_paste else "standard", @@ -1485,12 +1490,46 @@ class TextInjector: return True - # ydotool 1.x (Flatpak pins v1.0.4): KEY_LEFTCTRL=29, KEY_LEFTSHIFT=42, KEY_V=47. - _YDOTOOL_V1_CTRL_V = ["ydotool", "key", "29:1", "47:1", "47:0", "29:0"] - _YDOTOOL_V1_CTRL_SHIFT_V = ["ydotool", "key", "29:1", "42:1", "47:1", "47:0", "42:0", "29:0"] - # ydotool 0.1.x (common distro packages): named key sequences. + # ydotool 1.x (Flatpak pins v1.0.4): KEY_LEFTCTRL=29, KEY_LEFTSHIFT=42. + # KEY_V=47 is the QWERTY physical key; non-QWERTY layouts (e.g. de(neo)) + # produce Latin 'v' on a different keycode and must not use 47 blindly. + _YDOTOOL_KEY_LEFTCTRL = 29 + _YDOTOOL_KEY_LEFTSHIFT = 42 + _YDOTOOL_KEY_V_QWERTY = 47 + # ydotool 0.1.x (common distro packages): named key sequences. ``v`` here + # is physical KEY_V, so non-QWERTY paste uses the linux name of the key + # that actually produces 'v' (see _ydotool_ctrl_v_command). _YDOTOOL_LEGACY_CTRL_V = ["ydotool", "key", "ctrl+v"] _YDOTOOL_LEGACY_CTRL_SHIFT_V = ["ydotool", "key", "ctrl+shift+v"] + # Kernel KEY_* letter names are QWERTY positions (KEY_W=17), not characters. + _EVDEV_LETTER_KEY_NAMES = { + 16: "q", + 17: "w", + 18: "e", + 19: "r", + 20: "t", + 21: "y", + 22: "u", + 23: "i", + 24: "o", + 25: "p", + 30: "a", + 31: "s", + 32: "d", + 33: "f", + 34: "g", + 35: "h", + 36: "j", + 37: "k", + 38: "l", + 44: "z", + 45: "x", + 46: "c", + 47: "v", + 48: "b", + 49: "n", + 50: "m", + } def _ydotool_uses_legacy_named_keys(self) -> bool: """Return True when the installed ydotool expects named key sequences.""" @@ -1532,11 +1571,131 @@ class TextInjector: self._ydotool_legacy_named_keys = uses_legacy return uses_legacy + def _evdev_keycode_for_paste_v(self) -> int: + """Return the evdev keycode that produces Latin 'v' on the active layout. + + Falls back to QWERTY KEY_V (47) when the layout map is missing or has + no 'v' entry. LEFTCTRL/LEFTSHIFT are not remapped. + """ + try: + from ..ui.keyboard_backends.layout_key_map import get_active_char_to_evdev_map + + char_map = get_active_char_to_evdev_map() + except Exception as exc: + logger.debug("Could not load layout map for paste 'v': %s", exc) + return self._YDOTOOL_KEY_V_QWERTY + if char_map and "v" in char_map: + try: + return int(char_map["v"]) + except (TypeError, ValueError): + logger.debug("Ignoring non-integer layout map entry for 'v': %r", char_map["v"]) + return self._YDOTOOL_KEY_V_QWERTY + + def _linux_key_name_for_evdev_code(self, code: int) -> Optional[str]: + """Return the ydotool 0.1.x key name for an evdev keycode. + + Named ``ctrl+v`` is physical KEY_V. On Neo, Latin 'v' is KEY_W (17), so + the legacy dialect must emit ``ctrl+w``. Names come from evdev.ecodes + when available; kernel KEY_* letter positions are the fallback. + """ + try: + from evdev import ecodes + + name = None + keys = getattr(ecodes, "keys", None) + if isinstance(keys, dict): + name = keys.get(code) + if name is None: + key_map = getattr(ecodes, "KEY", None) + if isinstance(key_map, dict): + name = key_map.get(code) + if name is None: + bytype = getattr(ecodes, "bytype", None) + ev_key = getattr(ecodes, "EV_KEY", None) + if isinstance(bytype, dict) and ev_key is not None: + name = bytype.get(ev_key, {}).get(code) + if isinstance(name, (list, tuple)): + name = next( + (item for item in name if isinstance(item, str) and item.startswith("KEY_")), + None, + ) + if isinstance(name, str) and name.startswith("KEY_") and len(name) > 4: + return name[4:].lower() + except Exception as exc: + logger.debug("evdev.ecodes lookup failed for keycode %s: %s", code, exc) + return self._EVDEV_LETTER_KEY_NAMES.get(code) + + def _ydotool_v1_paste_command(self, v_code: int, *, terminal: bool) -> list: + """ydotool 1.x press/release sequence for Ctrl(+Shift)+the key that types 'v'.""" + ctrl = self._YDOTOOL_KEY_LEFTCTRL + shift = self._YDOTOOL_KEY_LEFTSHIFT + if terminal: + return [ + "ydotool", + "key", + f"{ctrl}:1", + f"{shift}:1", + f"{v_code}:1", + f"{v_code}:0", + f"{shift}:0", + f"{ctrl}:0", + ] + return ["ydotool", "key", f"{ctrl}:1", f"{v_code}:1", f"{v_code}:0", f"{ctrl}:0"] + + def _wtype_usable_for_paste(self) -> bool: + """Return True when wtype can send a layout-independent paste keysym. + + Paste may use wtype even when the typing backend is ydotool. KDE Plasma + already treats wtype as an unreliable virtual-keyboard path, so skip it + there unless this session already selected wtype (startup probe passed). + """ + cached = getattr(self, "_wtype_paste_usable", None) + if cached is not None: + return bool(cached) + + if getattr(self, "wayland_tool", None) == "wtype": + self._wtype_paste_usable = True + return True + if not shutil.which("wtype"): + self._wtype_paste_usable = False + return False + if _is_kde_plasma_session(): + logger.debug("Skipping wtype paste chord on KDE Plasma (wtype is unreliable there)") + self._wtype_paste_usable = False + return False + try: + result = self._probe_wtype_support() + error_output = (result.stderr or "").lower() + usable = result.returncode == 0 and "compositor does not support" not in error_output + except Exception as exc: + logger.debug("wtype paste probe failed: %s", exc) + usable = False + self._wtype_paste_usable = usable + return usable + + def _wtype_paste_command(self, *, terminal: bool = False) -> list: + """wtype argv that types Latin 'v' as a keysym, with Ctrl (and Shift).""" + if terminal: + return ["wtype", "-M", "ctrl", "-M", "shift", "v"] + return ["wtype", "-M", "ctrl", "v"] + + def _clipboard_paste_command(self, *, terminal: bool = False) -> list: + """Return argv for the clipboard paste chord. + + Prefers wtype keysyms when that tool is usable (layout-independent). + Otherwise uses ydotool with a layout-resolved keycode for 'v'. + """ + if self._wtype_usable_for_paste(): + return self._wtype_paste_command(terminal=terminal) + return self._ydotool_ctrl_v_command(terminal=terminal) + def _ydotool_ctrl_v_command(self, *, terminal: bool = False) -> list: """Return argv to simulate paste for the installed ydotool. - ydotool 0.1.x expects ``key ctrl+v`` or ``key ctrl+shift+v``. ydotool 1.x - expects evdev press/release keycodes (29=LEFTCTRL, 42=LEFTSHIFT, 47=V). + ydotool 0.1.x expects ``key ctrl+v`` or ``key ctrl+shift+v`` (physical + KEY_V). ydotool 1.x expects evdev press/release keycodes (29=LEFTCTRL, + 42=LEFTSHIFT, plus the key that produces Latin 'v' on the active + layout — 47 only on QWERTY). Flatpak always ships pinned ydotool 1.0.4 under /app, so we use the keycode form there without probing. Host installs probe ``key --help``. @@ -1546,14 +1705,42 @@ class TextInjector: if cached is not None: return list(cached) + v_code = self._evdev_keycode_for_paste_v() if self._ydotool_uses_legacy_named_keys(): - cmd = ( - list(self._YDOTOOL_LEGACY_CTRL_SHIFT_V) - if terminal - else list(self._YDOTOOL_LEGACY_CTRL_V) - ) + if v_code == self._YDOTOOL_KEY_V_QWERTY: + cmd = ( + list(self._YDOTOOL_LEGACY_CTRL_SHIFT_V) + if terminal + else list(self._YDOTOOL_LEGACY_CTRL_V) + ) + else: + key_name = self._linux_key_name_for_evdev_code(v_code) + if key_name: + token = f"ctrl+shift+{key_name}" if terminal else f"ctrl+{key_name}" + cmd = ["ydotool", "key", token] + logger.info( + "ydotool 0.1.x paste uses %s (layout 'v' is evdev %s, not KEY_V=47)", + token, + v_code, + ) + else: + # Named ctrl+v would hit the wrong physical key. Prefer the + # 1.x keycode form; some builds accept it, and 0.1.x garbage + # is still better documented than silently opening Print. + cmd = self._ydotool_v1_paste_command(v_code, terminal=terminal) + logger.warning( + "ydotool 0.1.x has no name for layout 'v' keycode %s; " + "sending 1.x-style keycodes %s", + v_code, + cmd, + ) else: - cmd = list(self._YDOTOOL_V1_CTRL_SHIFT_V) if terminal else list(self._YDOTOOL_V1_CTRL_V) + cmd = self._ydotool_v1_paste_command(v_code, terminal=terminal) + if v_code != self._YDOTOOL_KEY_V_QWERTY: + logger.info( + "ydotool paste uses evdev %s for Latin 'v' (not QWERTY KEY_V=47)", + v_code, + ) setattr(self, cache_attr, cmd) return list(cmd) @@ -1668,13 +1855,20 @@ class TextInjector: # modify typed keys. self._wait_for_modifiers_released() - # Prefer clipboard + Ctrl+V for ydotool: one paste, layout-independent. + # Prefer clipboard + paste chord for ydotool: one chord instead of + # per-character evdev keycodes (those follow physical US key positions + # and scramble text on other layouts). The paste chord is not + # automatically layout-safe: wtype keysyms are, ydotool keycodes are + # only after resolving Latin 'v' for the active layout (#787). # Flatpak ships wl-copy (--socket=wayland) so native Wayland apps get # bulk paste; character-by-character type is only a fallback. if self.wayland_tool == "ydotool": if not self._ensure_ydotoold(): logger.warning("ydotoold not ready before injection") - logger.info("Using clipboard paste for ydotool (instant, layout-independent)") + logger.info( + "Using clipboard paste for ydotool " + "(single paste chord instead of per-character typing)" + ) if self._inject_via_clipboard_paste(text): return logger.warning( diff --git a/src/vocalinux/ui/keyboard_backends/layout_key_map.py b/src/vocalinux/ui/keyboard_backends/layout_key_map.py index b000cc0..5b9f168 100644 --- a/src/vocalinux/ui/keyboard_backends/layout_key_map.py +++ b/src/vocalinux/ui/keyboard_backends/layout_key_map.py @@ -8,13 +8,16 @@ On AZERTY, "a" is KEY_Q not KEY_A. This builds the map for the active layout. from __future__ import annotations import logging +import os import re import subprocess +import xml.etree.ElementTree as ET from ctypes import CDLL, POINTER, Structure, byref, c_char_p, c_int, c_uint32, c_void_p from functools import lru_cache from typing import Optional from ...utils.host_process import host_env +from ...utils.paths import xdg_config_home, xdg_data_home logger = logging.getLogger(__name__) @@ -145,11 +148,318 @@ def _detect_gnome_layout() -> tuple[str, str]: return "", "" +def _kconfig_key_name(raw_key: str) -> str: + """Strip KConfig type/locale suffixes such as ``[$i]`` from a key name.""" + key = raw_key.strip() + bracket = key.find("[") + if bracket != -1: + key = key[:bracket] + return key.strip() + + +def _read_kconfig_group(path: str, group: str) -> dict[str, str]: + """Parse one group from a KConfig/INI-style file into a key/value map.""" + values: dict[str, str] = {} + current = None + with open(path, encoding="utf-8") as handle: + for raw in handle: + line = raw.strip() + if not line or line.startswith("#") or line.startswith(";"): + continue + if line.startswith("[") and line.endswith("]"): + current = line[1:-1].strip() + continue + if current != group or "=" not in line: + continue + raw_key, _, raw_value = line.partition("=") + key = _kconfig_key_name(raw_key) + if key: + values[key] = raw_value.strip().strip("\"'") + return values + + +def _parse_kde_layout_token(token: str) -> tuple[str, str]: + """Parse a kxkbrc layout token such as ``de(neo)`` or ``de``.""" + token = token.strip().strip("\"'") + if not token: + return "", "" + if token.endswith(")") and "(" in token: + layout, _, rest = token.partition("(") + return layout.strip(), rest[:-1].strip() + return token, "" + + +def _csv_fields(raw: str) -> list[str]: + return [part.strip() for part in raw.split(",")] if raw else [] + + +def _is_plasma_session() -> bool: + """Return True when the session looks like KDE Plasma.""" + if os.environ.get("KDE_FULL_SESSION", "").lower() == "true": + return True + desktop = " ".join( + os.environ.get(var, "") + for var in ( + "XDG_CURRENT_DESKTOP", + "XDG_SESSION_DESKTOP", + "DESKTOP_SESSION", + "GDMSESSION", + ) + ).lower() + return "kde" in desktop or "plasma" in desktop + + +def _layout_pair_at(layouts: list[str], variants: list[str], index: int) -> tuple[str, str]: + """Return the LayoutList/VariantList pair at ``index``, or empty.""" + if index < 0 or index >= len(layouts): + return "", "" + layout = layouts[index] + if not layout: + return "", "" + variant = variants[index] if index < len(variants) else "" + return layout, variant + + +def _listed_layout_pairs(layouts: list[str], variants: list[str]) -> list[tuple[str, str]]: + pairs: list[tuple[str, str]] = [] + for index, layout in enumerate(layouts): + if not layout: + continue + variant = variants[index] if index < len(variants) else "" + pairs.append((layout, variant)) + return pairs + + +def _match_token_to_listed_pairs(token: str, pairs: list[tuple[str, str]]) -> tuple[str, str]: + """Match ``de(neo)`` / ``de`` against parallel LayoutList+VariantList entries. + + Exact ``layout+variant`` wins. A token with an empty variant (``de``) matches + the first listed pair with that layout name, so ``LayoutList=us,de`` + + ``VariantList=,neo`` still yields ``de``+``neo`` instead of dropping the + variant. A unique layout name in the list is the usual case of that rule. + """ + layout, variant = _parse_kde_layout_token(token) + if not layout: + return "", "" + for listed_layout, listed_variant in pairs: + if listed_layout == layout and listed_variant == variant: + return listed_layout, listed_variant + if variant: + return "", "" + for listed_layout, listed_variant in pairs: + if listed_layout == layout: + return listed_layout, listed_variant + return "", "" + + +def _layout_from_memory_token(token: str, pairs: list[tuple[str, str]]) -> tuple[str, str]: + matched = _match_token_to_listed_pairs(token, pairs) + if matched[0]: + return matched + return _parse_kde_layout_token(token) + + +def _kde_layout_memory_paths() -> list[str]: + data_home = xdg_data_home() + return [ + os.path.join(data_home, "kded6", "keyboard", "session", "layout_memory.xml"), + os.path.join(data_home, "kded5", "keyboard", "session", "layout_memory.xml"), + ] + + +def _read_kde_layout_memory(path: str) -> tuple[str, list[str]]: + """Return ``(SwitchMode, currentLayout tokens)`` from a layout_memory.xml.""" + try: + tree = ET.parse(path) + except FileNotFoundError: + return "", [] + except (OSError, ET.ParseError) as e: + logger.debug("layout_memory.xml parse failed (%s): %s", path, e) + return "", [] + + root = tree.getroot() + switch_mode = (root.get("SwitchMode") or "").strip() + tokens: list[str] = [] + for item in root.iter("item"): + raw = item.get("currentLayout") + if raw is None: + continue + token = raw.strip() + if token: + tokens.append(token) + return switch_mode, tokens + + +def _pick_layout_from_memory_tokens( + switch_mode: str, + tokens: list[str], + layouts: list[str], + variants: list[str], +) -> tuple[str, str]: + """Choose an active pair from layout_memory.xml tokens. + + Prefer SwitchMode=Global (or a single item). With several items, prefer the + first token that matches LayoutList+VariantList, else the first parseable + currentLayout. Per-window SwitchMode maps can be stale without D-Bus. + """ + if not tokens: + return "", "" + pairs = _listed_layout_pairs(layouts, variants) + if switch_mode.lower() == "global" or len(tokens) == 1: + return _layout_from_memory_token(tokens[0], pairs) + for token in tokens: + matched = _match_token_to_listed_pairs(token, pairs) + if matched[0]: + return matched + for token in tokens: + layout, variant = _parse_kde_layout_token(token) + if layout: + return layout, variant + return "", "" + + +def _detect_kde_layout_from_memory(layouts: list[str], variants: list[str]) -> tuple[str, str]: + for path in _kde_layout_memory_paths(): + switch_mode, tokens = _read_kde_layout_memory(path) + if not tokens: + continue + layout, variant = _pick_layout_from_memory_tokens(switch_mode, tokens, layouts, variants) + if layout: + logger.debug( + "KDE layout from %s: layout=%s variant=%s", + path, + layout, + variant or "(none)", + ) + return layout, variant + return "", "" + + +def _kde_dbus_layout_index() -> Optional[int]: + """Live Plasma layout index from ``org.kde.KeyboardLayouts.getLayout``. + + Unavailable in headless tests and in a Flatpak sandbox without D-Bus talk + permission. ``gdbus`` is a host binary, so the child env is ``host_env()``. + """ + try: + result = subprocess.run( + [ + "gdbus", + "call", + "--session", + "--dest", + "org.kde.keyboard", + "--object-path", + "/Layouts", + "--method", + "org.kde.KeyboardLayouts.getLayout", + ], + capture_output=True, + text=True, + timeout=2, + check=False, + env=host_env(), + ) + except (OSError, subprocess.SubprocessError) as e: + logger.debug("KDE layout D-Bus query failed: %s", e) + return None + if result.returncode != 0: + logger.debug( + "KDE layout D-Bus getLayout rc=%s out=%r", + result.returncode, + (result.stdout or "")[:200], + ) + return None + out = (result.stdout or "").strip() + match = re.search(r"uint(?:32|64)\s+(\d+)", out, re.IGNORECASE) + if not match: + match = re.search(r"\(\s*(\d+)\s*,", out) + if not match: + logger.debug("KDE layout D-Bus getLayout unparsed: %r", out) + return None + return int(match.group(1)) + + +def _detect_kde_layout() -> tuple[str, str]: + """Current XKB layout from KDE Plasma (Wayland-safe). + + ``kxkbrc`` ``[Layout]`` only lists configured sources (``LayoutList`` / + ``VariantList``). Plasma's ``keyboardsettings.kcfg`` has no + ``CurrentLayout`` or ``LayoutIndex``; those keys are ignored if present. + + Active layout, in order: + 1. D-Bus ``org.kde.keyboard`` ``/Layouts`` ``getLayout`` index into the lists + 2. ``$XDG_DATA_HOME/kded6/keyboard/session/layout_memory.xml`` + (``kded5`` fallback), matching ``currentLayout`` (e.g. ``de(neo)``) + 3. The sole ``LayoutList`` entry, when there is only one + 4. Empty (do not assume index 0) + + ``setxkbmap -query`` is not used: on Wayland it talks to XWayland, not the + compositor, and can report a stale or default US map (see + ``ibus_engine.get_current_xkb_layout`` and issue #474). + """ + values: dict[str, str] = {} + path = os.path.join(xdg_config_home(), "kxkbrc") + try: + values = _read_kconfig_group(path, "Layout") + except FileNotFoundError: + pass + except OSError as e: + logger.debug("kxkbrc layout query failed: %s", e) + + layouts = _csv_fields(values.get("LayoutList", "")) + variants = _csv_fields(values.get("VariantList", "")) + + if layouts: + index = _kde_dbus_layout_index() + if index is not None: + pair = _layout_pair_at(layouts, variants, index) + if pair[0]: + logger.debug( + "KDE layout from D-Bus index %s: layout=%s variant=%s", + index, + pair[0], + pair[1] or "(none)", + ) + return pair + logger.debug("KDE D-Bus layout index %s out of range for LayoutList", index) + + layout, variant = _detect_kde_layout_from_memory(layouts, variants) + if layout: + return layout, variant + + named_indexes = [i for i, name in enumerate(layouts) if name] + if len(named_indexes) == 1: + return _layout_pair_at(layouts, variants, named_indexes[0]) + return "", "" + + +def _detect_active_layout() -> tuple[str, str]: + """Best-effort active XKB layout from the session (Wayland-safe). + + Plasma sessions prefer KDE sources so leftover GNOME gsettings (often ``us``) + cannot win. Other desktops try GNOME first, then KDE. + """ + detectors = (_detect_kde_layout, _detect_gnome_layout) + if not _is_plasma_session(): + detectors = (_detect_gnome_layout, _detect_kde_layout) + for detect in detectors: + layout, variant = detect() + if layout: + return layout, variant + return "", "" + + @lru_cache(maxsize=1) def get_active_char_to_evdev_map() -> Optional[dict[str, int]]: - """Cached char→evdev map for the active layout; None → use US KEY_* names.""" - layout, variant = _detect_gnome_layout() - # ponytail: GNOME gsettings only; add setxkbmap/localectl if non-GNOME reports land + """Cached char→evdev map for the active layout; None → use US KEY_* names. + + Process-cached (``lru_cache``): a layout switch after startup is not + picked up. Plasma per-window ``SwitchMode`` can leave ``layout_memory.xml`` + stale when D-Bus ``getLayout`` is unavailable (headless tests, Flatpak + without talk permission). + """ + layout, variant = _detect_active_layout() if not layout or (layout == "us" and not variant): return None char_map = build_char_to_evdev_map(layout, variant) diff --git a/tests/test_layout_key_map.py b/tests/test_layout_key_map.py index ab3bad6..6aa9a5e 100644 --- a/tests/test_layout_key_map.py +++ b/tests/test_layout_key_map.py @@ -68,6 +68,18 @@ def test_xkb_fr_a_is_key_q(): assert char_map["q"] == ecodes.KEY_A +def test_xkb_de_neo_v_is_key_w(): + """German Neo v2: Latin v is physical KEY_W=17, KEY_V=47 types p (#787).""" + char_map = build_char_to_evdev_map("de", "neo") + if char_map is None: + pytest.skip("libxkbcommon or XKB data not available") + assert char_map["v"] == 17 + assert char_map["p"] == 47 + evdev = pytest.importorskip("evdev") + assert char_map["v"] == evdev.ecodes.KEY_W + assert char_map["p"] == evdev.ecodes.KEY_V + + # --- layout_key_map coverage: detection, cache, error paths --- @@ -170,18 +182,334 @@ def test_detect_gnome_layout_nonzero_exit(monkeypatch): assert lkm._detect_gnome_layout() == ("", "") -def test_get_active_map_us_is_none(monkeypatch): +_REALISTIC_KXKBRC = """[Layout] +DisplayNames=, +LayoutList=us,de +VariantList=,neo +Use=true +""" + +_NEO_LAYOUT_MEMORY = """ + + + +""" + + +def _write_kxkbrc(tmp_path, content: str = _REALISTIC_KXKBRC): + (tmp_path / "kxkbrc").write_text(content, encoding="utf-8") + + +def _write_layout_memory(tmp_path, xml: str, *, kded: str = "kded6"): + session = tmp_path / kded / "keyboard" / "session" + session.mkdir(parents=True, exist_ok=True) + (session / "layout_memory.xml").write_text(xml, encoding="utf-8") + + +def _isolate_kde_files(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) + monkeypatch.setattr(lkm, "_kde_dbus_layout_index", lambda: None) + + +def _clear_plasma_env(monkeypatch) -> None: + """Force the GNOME-first detector order; ignore a KDE CI host's session vars.""" + for var in ( + "XDG_CURRENT_DESKTOP", + "XDG_SESSION_DESKTOP", + "DESKTOP_SESSION", + "KDE_FULL_SESSION", + "GDMSESSION", + ): + monkeypatch.delenv(var, raising=False) + + +def test_match_token_empty_variant_uses_listed_variant(): + """Bare ``de`` must pick the listed neo pair, not parse as de+empty.""" + pairs = [("us", ""), ("de", "neo")] + assert lkm._match_token_to_listed_pairs("de", pairs) == ("de", "neo") + assert lkm._match_token_to_listed_pairs("de(neo)", pairs) == ("de", "neo") + assert lkm._match_token_to_listed_pairs("us", pairs) == ("us", "") + + +def test_match_token_with_variant_prefers_exact_pair(): + pairs = [("de", "qwertz"), ("de", "neo")] + assert lkm._match_token_to_listed_pairs("de(neo)", pairs) == ("de", "neo") + assert lkm._match_token_to_listed_pairs("de(qwertz)", pairs) == ("de", "qwertz") + assert lkm._match_token_to_listed_pairs("de", pairs) == ("de", "qwertz") + + +def test_match_token_unique_layout_name_uses_listed_variant(): + pairs = [("fr", "oss"), ("de", "neo")] + assert lkm._match_token_to_listed_pairs("de", pairs) == ("de", "neo") + assert lkm._match_token_to_listed_pairs("fr", pairs) == ("fr", "oss") + assert lkm._match_token_to_listed_pairs("it", pairs) == ("", "") + + +def test_detect_kde_layout_memory_neo_not_index_zero(tmp_path, monkeypatch): + """Neo active in layout_memory, but us is LayoutList index 0 (issue #787).""" + _isolate_kde_files(tmp_path, monkeypatch) + _write_kxkbrc(tmp_path) + _write_layout_memory(tmp_path, _NEO_LAYOUT_MEMORY) + assert lkm._detect_kde_layout() == ("de", "neo") + + +def test_detect_kde_layout_memory_bare_de_keeps_neo_variant(tmp_path, monkeypatch): + """layout_memory currentLayout=de (no (neo)) still resolves VariantList neo.""" + _isolate_kde_files(tmp_path, monkeypatch) + _write_kxkbrc(tmp_path) + _write_layout_memory( + tmp_path, + '' '\n', + ) + assert lkm._detect_kde_layout() == ("de", "neo") + + +def test_get_active_map_kde_neo_from_layout_memory(tmp_path, monkeypatch): + _isolate_kde_files(tmp_path, monkeypatch) + monkeypatch.setenv("XDG_CURRENT_DESKTOP", "KDE") monkeypatch.setattr(lkm, "_detect_gnome_layout", lambda: ("us", "")) + _write_kxkbrc(tmp_path) + _write_layout_memory(tmp_path, _NEO_LAYOUT_MEMORY) + char_map = lkm.get_active_char_to_evdev_map() + if char_map is None: + pytest.skip("libxkbcommon or XKB data not available") + assert char_map["v"] == 17 + assert char_map["p"] == 47 + + +def test_detect_kde_layout_missing_files(tmp_path, monkeypatch): + _isolate_kde_files(tmp_path, monkeypatch) + assert lkm._detect_kde_layout() == ("", "") + + +def test_detect_kde_layout_quoted_token(tmp_path, monkeypatch): + _isolate_kde_files(tmp_path, monkeypatch) + _write_kxkbrc( + tmp_path, + '[Layout]\nLayoutList[$i]="us,de"\nVariantList[$i]=",neo"\nUse=true\n', + ) + _write_layout_memory( + tmp_path, + '' + "\n", + ) + assert lkm._detect_kde_layout() == ("de", "neo") + + +def test_detect_kde_layout_memory_without_kxkbrc(tmp_path, monkeypatch): + _isolate_kde_files(tmp_path, monkeypatch) + _write_layout_memory(tmp_path, _NEO_LAYOUT_MEMORY) + assert lkm._detect_kde_layout() == ("de", "neo") + + +def test_detect_kde_layout_ignores_invented_kxkbrc_keys(tmp_path, monkeypatch): + """CurrentLayout/LayoutIndex are not in keyboardsettings.kcfg; do not use them.""" + _isolate_kde_files(tmp_path, monkeypatch) + _write_kxkbrc( + tmp_path, + "[Layout]\nLayoutList=us,de\nVariantList=,neo\n" + "CurrentLayout=de(neo)\nLayoutIndex=1\nUse=true\n", + ) + assert lkm._detect_kde_layout() == ("", "") + + +def test_detect_kde_layout_single_list_entry(tmp_path, monkeypatch): + _isolate_kde_files(tmp_path, monkeypatch) + _write_kxkbrc(tmp_path, "[Layout]\nLayoutList=de\nVariantList=neo\nUse=true\n") + assert lkm._detect_kde_layout() == ("de", "neo") + + +def test_detect_kde_layout_kded5_fallback(tmp_path, monkeypatch): + _isolate_kde_files(tmp_path, monkeypatch) + _write_kxkbrc(tmp_path) + _write_layout_memory(tmp_path, _NEO_LAYOUT_MEMORY, kded="kded5") + assert lkm._detect_kde_layout() == ("de", "neo") + + +def test_detect_kde_layout_prefers_kded6_over_kded5(tmp_path, monkeypatch): + _isolate_kde_files(tmp_path, monkeypatch) + _write_kxkbrc(tmp_path) + _write_layout_memory( + tmp_path, + '\n', + kded="kded6", + ) + _write_layout_memory( + tmp_path, + '\n', + kded="kded5", + ) + assert lkm._detect_kde_layout() == ("de", "neo") + + +def test_detect_kde_layout_malformed_memory_falls_through(tmp_path, monkeypatch): + _isolate_kde_files(tmp_path, monkeypatch) + _write_kxkbrc(tmp_path) + _write_layout_memory(tmp_path, " + + + + """, + ) + assert lkm._detect_kde_layout() == ("de", "neo") + + +def test_detect_kde_layout_multi_item_bare_de_keeps_neo(tmp_path, monkeypatch): + _isolate_kde_files(tmp_path, monkeypatch) + _write_kxkbrc(tmp_path) + _write_layout_memory( + tmp_path, + """ + + + + """, + ) + assert lkm._detect_kde_layout() == ("de", "neo") + + +def test_kde_dbus_layout_index_parses_uint32(monkeypatch): + def fake_run(cmd, **kwargs): + assert cmd[0] == "gdbus" + assert "--session" in cmd + assert "org.kde.keyboard" in cmd + assert "/Layouts" in cmd + assert "org.kde.KeyboardLayouts.getLayout" in cmd + assert kwargs.get("timeout") == 2 + assert kwargs.get("env") is not None + return types.SimpleNamespace(returncode=0, stdout="(uint32 1,)\n") + + monkeypatch.setattr(lkm.subprocess, "run", fake_run) + assert lkm._kde_dbus_layout_index() == 1 + + +def test_kde_dbus_layout_index_parses_bare_int(monkeypatch): + monkeypatch.setattr( + lkm.subprocess, + "run", + lambda *_a, **_k: types.SimpleNamespace(returncode=0, stdout="(1,)\n"), + ) + assert lkm._kde_dbus_layout_index() == 1 + + +def test_kde_dbus_layout_index_missing_gdbus(monkeypatch): + def boom(*_a, **_k): + raise FileNotFoundError("gdbus") + + monkeypatch.setattr(lkm.subprocess, "run", boom) + assert lkm._kde_dbus_layout_index() is None + + +def test_kde_dbus_layout_index_nonzero_exit(monkeypatch): + monkeypatch.setattr( + lkm.subprocess, + "run", + lambda *_a, **_k: types.SimpleNamespace(returncode=1, stdout=""), + ) + assert lkm._kde_dbus_layout_index() is None + + +def test_detect_kde_prefers_dbus_index_over_memory(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) + monkeypatch.setattr(lkm, "_kde_dbus_layout_index", lambda: 1) + _write_kxkbrc(tmp_path) + _write_layout_memory( + tmp_path, + '\n', + ) + assert lkm._detect_kde_layout() == ("de", "neo") + + +def test_detect_kde_dbus_out_of_range_falls_through_to_memory(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) + monkeypatch.setattr(lkm, "_kde_dbus_layout_index", lambda: 9) + _write_kxkbrc(tmp_path) + _write_layout_memory(tmp_path, _NEO_LAYOUT_MEMORY) + assert lkm._detect_kde_layout() == ("de", "neo") + + +def test_is_plasma_session_reads_desktop_vars(monkeypatch): + _clear_plasma_env(monkeypatch) + assert not lkm._is_plasma_session() + monkeypatch.setenv("XDG_SESSION_DESKTOP", "plasmawayland") + assert lkm._is_plasma_session() + _clear_plasma_env(monkeypatch) + monkeypatch.setenv("KDE_FULL_SESSION", "true") + assert lkm._is_plasma_session() + + +def test_detect_active_layout_prefers_gnome_outside_plasma(monkeypatch): + _clear_plasma_env(monkeypatch) + monkeypatch.setattr(lkm, "_detect_gnome_layout", lambda: ("fr", "oss")) + monkeypatch.setattr(lkm, "_detect_kde_layout", lambda: ("de", "neo")) + assert lkm._detect_active_layout() == ("fr", "oss") + + +def test_detect_active_layout_plasma_prefers_kde_over_gnome(monkeypatch): + monkeypatch.setenv("XDG_CURRENT_DESKTOP", "KDE") + monkeypatch.setattr(lkm, "_detect_gnome_layout", lambda: ("us", "")) + monkeypatch.setattr(lkm, "_detect_kde_layout", lambda: ("de", "neo")) + assert lkm._detect_active_layout() == ("de", "neo") + + +def test_detect_active_layout_falls_back_to_kde(monkeypatch): + _clear_plasma_env(monkeypatch) + monkeypatch.setattr(lkm, "_detect_gnome_layout", lambda: ("", "")) + monkeypatch.setattr(lkm, "_detect_kde_layout", lambda: ("de", "neo")) + assert lkm._detect_active_layout() == ("de", "neo") + + +def test_detect_active_layout_plasma_falls_back_to_gnome(monkeypatch): + monkeypatch.setenv("XDG_CURRENT_DESKTOP", "plasma") + monkeypatch.setattr(lkm, "_detect_kde_layout", lambda: ("", "")) + monkeypatch.setattr(lkm, "_detect_gnome_layout", lambda: ("fr", "oss")) + assert lkm._detect_active_layout() == ("fr", "oss") + + +def test_get_active_map_us_is_none(monkeypatch): + """GNOME us → None only outside Plasma (KDE neo must not win this path).""" + _clear_plasma_env(monkeypatch) + monkeypatch.setattr(lkm, "_detect_gnome_layout", lambda: ("us", "")) + monkeypatch.setattr(lkm, "_detect_kde_layout", lambda: ("de", "neo")) assert lkm.get_active_char_to_evdev_map() is None +def test_get_active_map_plasma_leftover_gnome_us_loses_to_kde_neo(monkeypatch): + """Leftover GNOME us must not hide Plasma neo (issue #787).""" + monkeypatch.setenv("XDG_CURRENT_DESKTOP", "KDE") + monkeypatch.setattr(lkm, "_detect_gnome_layout", lambda: ("us", "")) + monkeypatch.setattr(lkm, "_detect_kde_layout", lambda: ("de", "neo")) + char_map = lkm.get_active_char_to_evdev_map() + if char_map is None: + pytest.skip("libxkbcommon or XKB data not available") + assert char_map["v"] == 17 + assert char_map["p"] == 47 + + def test_get_active_map_empty_layout(monkeypatch): + _clear_plasma_env(monkeypatch) monkeypatch.setattr(lkm, "_detect_gnome_layout", lambda: ("", "")) + monkeypatch.setattr(lkm, "_detect_kde_layout", lambda: ("", "")) assert lkm.get_active_char_to_evdev_map() is None def test_get_active_map_loads_fr(monkeypatch): + _clear_plasma_env(monkeypatch) monkeypatch.setattr(lkm, "_detect_gnome_layout", lambda: ("fr", "")) + monkeypatch.setattr(lkm, "_detect_kde_layout", lambda: ("", "")) char_map = lkm.get_active_char_to_evdev_map() if char_map is None: pytest.skip("libxkbcommon or XKB data not available") @@ -191,11 +519,24 @@ def test_get_active_map_loads_fr(monkeypatch): def test_get_active_map_when_build_fails(monkeypatch): + _clear_plasma_env(monkeypatch) monkeypatch.setattr(lkm, "_detect_gnome_layout", lambda: ("fr", "")) + monkeypatch.setattr(lkm, "_detect_kde_layout", lambda: ("", "")) monkeypatch.setattr(lkm, "build_char_to_evdev_map", lambda *_a, **_k: None) assert lkm.get_active_char_to_evdev_map() is None +def test_get_active_map_uses_kde_when_gnome_empty(monkeypatch): + _clear_plasma_env(monkeypatch) + monkeypatch.setattr(lkm, "_detect_gnome_layout", lambda: ("", "")) + monkeypatch.setattr(lkm, "_detect_kde_layout", lambda: ("de", "neo")) + char_map = lkm.get_active_char_to_evdev_map() + if char_map is None: + pytest.skip("libxkbcommon or XKB data not available") + assert char_map["v"] == 17 + assert char_map["p"] == 47 + + def test_build_skips_keys_with_no_levels(monkeypatch): """Keys with zero levels are skipped (line covered via stub lib).""" diff --git a/tests/test_paste_shortcut.py b/tests/test_paste_shortcut.py index 5c9eacb..be18a2f 100644 --- a/tests/test_paste_shortcut.py +++ b/tests/test_paste_shortcut.py @@ -108,12 +108,17 @@ class TestShouldUseTerminalPaste(unittest.TestCase): self.assertEqual(obj._paste_shortcut_preference(), "ctrl+shift+v") +_NEO_CHAR_MAP = {"v": 17, "p": 47} # de(neo): Latin v is KEY_W, KEY_V types p +_LAYOUT_MAP = "vocalinux.ui.keyboard_backends.layout_key_map.get_active_char_to_evdev_map" + + class TestYdotoolTerminalPasteCommand(unittest.TestCase): """Terminal paste must use Ctrl+Shift+V in both ydotool dialects.""" + @patch(_LAYOUT_MAP, return_value=None) @patch("vocalinux.text_injection.text_injector.subprocess.run") @patch("vocalinux.text_injection.text_injector.shutil.which") - def test_legacy_named_sequence(self, mock_which, mock_run): + def test_legacy_named_sequence(self, mock_which, mock_run, _mock_map): mock_which.return_value = "/usr/bin/ydotool" mock_run.return_value = MagicMock( returncode=0, @@ -128,8 +133,9 @@ class TestYdotoolTerminalPasteCommand(unittest.TestCase): ) self.assertEqual(injector._ydotool_ctrl_v_command(), ["ydotool", "key", "ctrl+v"]) + @patch(_LAYOUT_MAP, return_value=None) @patch("vocalinux.text_injection.text_injector.shutil.which") - def test_flatpak_uses_shift_keycodes(self, mock_which): + def test_flatpak_uses_shift_keycodes(self, mock_which, _mock_map): mock_which.return_value = "/app/bin/ydotool" injector = _make_injector() with patch.dict("os.environ", {"FLATPAK_ID": "com.vocalinux.Vocalinux"}): @@ -142,6 +148,100 @@ class TestYdotoolTerminalPasteCommand(unittest.TestCase): ["ydotool", "key", "29:1", "47:1", "47:0", "29:0"], ) + @patch(_LAYOUT_MAP, return_value=_NEO_CHAR_MAP) + def test_ydotool_v1_uses_layout_keycode_for_v(self, _mock_map): + """de(neo) paste must hit KEY_W=17, not QWERTY KEY_V=47 (issue #787).""" + injector = _make_injector() + injector._ydotool_legacy_named_keys = False + self.assertEqual( + injector._ydotool_ctrl_v_command(), + ["ydotool", "key", "29:1", "17:1", "17:0", "29:0"], + ) + self.assertEqual( + injector._ydotool_ctrl_v_command(terminal=True), + ["ydotool", "key", "29:1", "42:1", "17:1", "17:0", "42:0", "29:0"], + ) + for cmd in ( + injector._ydotool_ctrl_v_command(), + injector._ydotool_ctrl_v_command(terminal=True), + ): + self.assertNotIn("47:1", cmd) + self.assertNotIn("47:0", cmd) + + @patch(_LAYOUT_MAP, return_value=None) + def test_ydotool_v1_us_map_uses_key_v(self, _mock_map): + injector = _make_injector() + injector._ydotool_legacy_named_keys = False + self.assertEqual( + injector._ydotool_ctrl_v_command(), + ["ydotool", "key", "29:1", "47:1", "47:0", "29:0"], + ) + + @patch(_LAYOUT_MAP, return_value={}) + def test_ydotool_v1_empty_map_uses_key_v(self, _mock_map): + injector = _make_injector() + injector._ydotool_legacy_named_keys = False + self.assertEqual( + injector._ydotool_ctrl_v_command(), + ["ydotool", "key", "29:1", "47:1", "47:0", "29:0"], + ) + + @patch(_LAYOUT_MAP, return_value=_NEO_CHAR_MAP) + def test_ydotool_legacy_uses_linux_name_for_layout_v(self, _mock_map): + """0.1.x named ctrl+v is physical KEY_V; Neo must emit ctrl+w.""" + injector = _make_injector() + injector._ydotool_legacy_named_keys = True + self.assertEqual(injector._ydotool_ctrl_v_command(), ["ydotool", "key", "ctrl+w"]) + self.assertEqual( + injector._ydotool_ctrl_v_command(terminal=True), + ["ydotool", "key", "ctrl+shift+w"], + ) + + def test_prefers_wtype_keysyms_for_paste_chord(self): + """Paste may use wtype keysyms even when typing stays on ydotool.""" + injector = _make_injector() + injector.wayland_tool = "ydotool" + with patch.object(injector, "_wtype_usable_for_paste", return_value=True): + cmd = injector._clipboard_paste_command() + terminal = injector._clipboard_paste_command(terminal=True) + self.assertEqual(cmd, ["wtype", "-M", "ctrl", "v"]) + self.assertEqual(terminal, ["wtype", "-M", "ctrl", "-M", "shift", "v"]) + self.assertIn("v", cmd) + self.assertNotIn("47", cmd) + self.assertNotIn("47:1", cmd) + + @patch("vocalinux.text_injection.text_injector.shutil.which") + @patch("vocalinux.text_injection.text_injector.subprocess.run") + def test_clipboard_paste_uses_wtype_argv_when_chosen(self, mock_run, mock_which): + mock_which.side_effect = lambda cmd: cmd in ("wl-copy", "wtype", "ydotool") + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + injector = _make_injector() + injector.wayland_tool = "ydotool" + with patch.object(injector, "_wtype_usable_for_paste", return_value=True): + with patch.object(injector, "_copy_to_clipboard", return_value=True): + with patch.object(injector, "_read_clipboard", return_value="old"): + with patch.object(injector, "_should_use_terminal_paste", return_value=False): + self.assertTrue(injector._inject_via_clipboard_paste("hello")) + paste_cmds = [c.args[0] for c in mock_run.call_args_list if c.args] + self.assertTrue( + any(c == ["wtype", "-M", "ctrl", "v"] for c in paste_cmds), + paste_cmds, + ) + self.assertFalse(any("47:1" in c for c in paste_cmds)) + + def test_wtype_paste_skipped_on_kde_when_typing_backend_is_ydotool(self): + injector = _make_injector() + injector.wayland_tool = "ydotool" + with patch( + "vocalinux.text_injection.text_injector._is_kde_plasma_session", + return_value=True, + ): + with patch( + "vocalinux.text_injection.text_injector.shutil.which", + side_effect=lambda cmd: "/usr/bin/wtype" if cmd == "wtype" else None, + ): + self.assertFalse(injector._wtype_usable_for_paste()) + @patch("vocalinux.text_injection.text_injector.shutil.which") @patch("vocalinux.text_injection.text_injector.subprocess.run") def test_clipboard_paste_sends_terminal_chord(self, mock_run, mock_which): diff --git a/tests/test_text_injector.py b/tests/test_text_injector.py index 8e71306..a67bc73 100644 --- a/tests/test_text_injector.py +++ b/tests/test_text_injector.py @@ -615,8 +615,8 @@ class TestTextInjector(unittest.TestCase): `ydotool type` emits positional evdev keycodes that get re-interpreted through the active keyboard layout (assumed US QWERTY), so typing ASCII - is scrambled on non-US layouts (e.g. AZERTY). Clipboard paste (Ctrl+V) - is layout-independent, so it is used unconditionally for ydotool. + is scrambled on non-US layouts (e.g. AZERTY). Clipboard paste is used + unconditionally for ydotool so the payload is not typed key-by-key. """ mock_which.side_effect = lambda x: x in ("ydotool", "wl-copy") mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") @@ -755,9 +755,15 @@ class TestTextInjector(unittest.TestCase): "wtype must NOT use clipboard-paste workaround", ) + @patch( + "vocalinux.ui.keyboard_backends.layout_key_map.get_active_char_to_evdev_map", + return_value=None, + ) @patch("vocalinux.text_injection.text_injector.subprocess.run") @patch("vocalinux.text_injection.text_injector.shutil.which") - def test_ydotool_ctrl_v_command_legacy_uses_named_sequence(self, mock_which, mock_run): + def test_ydotool_ctrl_v_command_legacy_uses_named_sequence( + self, mock_which, mock_run, _mock_map + ): """Distro ydotool 0.1.x expects ctrl+v, not keycode:value.""" mock_which.return_value = "/usr/bin/ydotool" mock_run.return_value = MagicMock( @@ -776,8 +782,12 @@ class TestTextInjector(unittest.TestCase): self.assertEqual(cmd, ["ydotool", "key", "ctrl+v"]) self.assertEqual(injector._ydotool_ctrl_v_command(), ["ydotool", "key", "ctrl+v"]) + @patch( + "vocalinux.ui.keyboard_backends.layout_key_map.get_active_char_to_evdev_map", + return_value=None, + ) @patch("vocalinux.text_injection.text_injector.shutil.which") - def test_ydotool_ctrl_v_command_flatpak_uses_keycodes(self, mock_which): + def test_ydotool_ctrl_v_command_flatpak_uses_keycodes(self, mock_which, _mock_map): """Flatpak pins ydotool 1.0.4; always use keycode:value form.""" mock_which.return_value = "/app/bin/ydotool" injector = TextInjector.__new__(TextInjector) @@ -785,9 +795,13 @@ class TestTextInjector(unittest.TestCase): cmd = injector._ydotool_ctrl_v_command() self.assertEqual(cmd, ["ydotool", "key", "29:1", "47:1", "47:0", "29:0"]) + @patch( + "vocalinux.ui.keyboard_backends.layout_key_map.get_active_char_to_evdev_map", + return_value=None, + ) @patch("vocalinux.text_injection.text_injector.subprocess.run") @patch("vocalinux.text_injection.text_injector.shutil.which") - def test_ydotool_ctrl_v_command_v1_help_uses_keycodes(self, mock_which, mock_run): + def test_ydotool_ctrl_v_command_v1_help_uses_keycodes(self, mock_which, mock_run, _mock_map): """Host ydotool 1.x (no plus-sequence help) uses press/release keycodes.""" mock_which.return_value = "/usr/local/bin/ydotool" mock_run.return_value = MagicMock(returncode=0, stdout="Usage: key N:1 N:0 ...", stderr="") @@ -796,9 +810,15 @@ class TestTextInjector(unittest.TestCase): cmd = injector._ydotool_ctrl_v_command() self.assertEqual(cmd, ["ydotool", "key", "29:1", "47:1", "47:0", "29:0"]) + @patch( + "vocalinux.ui.keyboard_backends.layout_key_map.get_active_char_to_evdev_map", + return_value=None, + ) @patch("vocalinux.text_injection.text_injector.subprocess.run") @patch("vocalinux.text_injection.text_injector.shutil.which") - def test_ydotool_ctrl_v_command_unknown_help_defaults_legacy(self, mock_which, mock_run): + def test_ydotool_ctrl_v_command_unknown_help_defaults_legacy( + self, mock_which, mock_run, _mock_map + ): """Unrecognized help text prefers named ctrl+v (safe on 0.1.x).""" mock_which.return_value = "/usr/bin/ydotool" mock_run.return_value = MagicMock(returncode=0, stdout="mystery help", stderr="") @@ -807,9 +827,15 @@ class TestTextInjector(unittest.TestCase): cmd = injector._ydotool_ctrl_v_command() self.assertEqual(cmd, ["ydotool", "key", "ctrl+v"]) + @patch( + "vocalinux.ui.keyboard_backends.layout_key_map.get_active_char_to_evdev_map", + return_value=None, + ) @patch("vocalinux.text_injection.text_injector.subprocess.run") @patch("vocalinux.text_injection.text_injector.shutil.which") - def test_ydotool_ctrl_v_command_probe_error_defaults_legacy(self, mock_which, mock_run): + def test_ydotool_ctrl_v_command_probe_error_defaults_legacy( + self, mock_which, mock_run, _mock_map + ): """If key --help fails, default to legacy named sequence.""" mock_which.return_value = "/usr/bin/ydotool" mock_run.side_effect = OSError("no ydotool") @@ -818,8 +844,12 @@ class TestTextInjector(unittest.TestCase): cmd = injector._ydotool_ctrl_v_command() self.assertEqual(cmd, ["ydotool", "key", "ctrl+v"]) + @patch( + "vocalinux.ui.keyboard_backends.layout_key_map.get_active_char_to_evdev_map", + return_value=None, + ) @patch("vocalinux.text_injection.text_injector.shutil.which") - def test_ydotool_ctrl_v_command_app_prefix_uses_keycodes(self, mock_which): + def test_ydotool_ctrl_v_command_app_prefix_uses_keycodes(self, mock_which, _mock_map): """/app/bin/ydotool (Flatpak path) always uses 1.x keycodes without FLATPAK_ID.""" mock_which.return_value = "/app/bin/ydotool" injector = TextInjector.__new__(TextInjector)