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)