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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = """<!DOCTYPE LayoutMap>
|
||||
<LayoutMap version="1.0" SwitchMode="Global">
|
||||
<item currentLayout="de(neo)"/>
|
||||
</LayoutMap>
|
||||
"""
|
||||
|
||||
|
||||
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,
|
||||
'<LayoutMap version="1.0" SwitchMode="Global">' '<item currentLayout="de"/></LayoutMap>\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,
|
||||
'<LayoutMap version="1.0" SwitchMode="Global">'
|
||||
"<item currentLayout=\"'de(neo)'\"/></LayoutMap>\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,
|
||||
'<LayoutMap SwitchMode="Global"><item currentLayout="de(neo)"/></LayoutMap>\n',
|
||||
kded="kded6",
|
||||
)
|
||||
_write_layout_memory(
|
||||
tmp_path,
|
||||
'<LayoutMap SwitchMode="Global"><item currentLayout="us"/></LayoutMap>\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, "<not-xml", kded="kded6")
|
||||
_write_layout_memory(tmp_path, _NEO_LAYOUT_MEMORY, kded="kded5")
|
||||
assert lkm._detect_kde_layout() == ("de", "neo")
|
||||
|
||||
|
||||
def test_detect_kde_layout_multi_item_matches_list(tmp_path, monkeypatch):
|
||||
_isolate_kde_files(tmp_path, monkeypatch)
|
||||
_write_kxkbrc(tmp_path)
|
||||
_write_layout_memory(
|
||||
tmp_path,
|
||||
"""<LayoutMap version="1.0" SwitchMode="WinClass">
|
||||
<item currentLayout="fr" ownerKey="unused"/>
|
||||
<item currentLayout="de(neo)" ownerKey="konsole"/>
|
||||
</LayoutMap>
|
||||
""",
|
||||
)
|
||||
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,
|
||||
"""<LayoutMap version="1.0" SwitchMode="WinClass">
|
||||
<item currentLayout="fr" ownerKey="unused"/>
|
||||
<item currentLayout="de" ownerKey="konsole"/>
|
||||
</LayoutMap>
|
||||
""",
|
||||
)
|
||||
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,
|
||||
'<LayoutMap SwitchMode="Global"><item currentLayout="us"/></LayoutMap>\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)."""
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user