* fix(hotkeys): resolve combo letters via active XKB layout Settings capture stores character tokens from GDK keyvals (e.g. alt+a), but the Wayland/evdev path mapped letters to fixed US KEY_* codes. On AZERTY that made Alt+A listen for the key labeled Q. Resolve letter tokens through libxkbcommon for the active layout so recording and listening agree. Fixes #513 * refactor(hotkeys): slim layout-aware key resolution Drop unused resolve helper, dual knobs, setxkbmap/localectl probes, and redundant tests. Keep gsettings + libxkbcommon map and the AZERTY combo path. * test(hotkeys): drop redundant AZERTY resolve-only case Keep fire/no-fire combo coverage and the XKB fr map check. * test(hotkeys): cover layout map detection and error paths Raise patch coverage for layout_key_map / evdev_code_for_key so codecov patch target (80%) is met.
This commit is contained in:
@@ -27,6 +27,7 @@ except ImportError:
|
||||
EVDEV_AVAILABLE = False
|
||||
|
||||
from .base import DEFAULT_SHORTCUT, DEFAULT_SHORTCUT_MODE, KeyboardBackend, parse_shortcut
|
||||
from .layout_key_map import get_active_char_to_evdev_map
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -92,18 +93,26 @@ _NAMED_EVDEV_KEYS = {
|
||||
|
||||
|
||||
def evdev_code_for_key(token: str) -> Optional[int]:
|
||||
"""Resolve a canonical main-key token (e.g. "r", "f5", "space") to an evdev code."""
|
||||
"""Resolve a canonical main-key token (e.g. "r", "f5", "space") to an evdev code.
|
||||
|
||||
Letters are character-semantic (GDK keyval). Non-US layouts remap via the
|
||||
active XKB map so AZERTY "a" hits KEY_Q, not US KEY_A.
|
||||
"""
|
||||
if not EVDEV_AVAILABLE or not token:
|
||||
return None
|
||||
name = _NAMED_EVDEV_KEYS.get(token)
|
||||
if name is None:
|
||||
if len(token) == 1 and token.isalnum():
|
||||
name = f"KEY_{token.upper()}"
|
||||
elif re.fullmatch(r"f\d+", token):
|
||||
name = f"KEY_{token.upper()}"
|
||||
if name is None:
|
||||
if name is not None:
|
||||
return getattr(ecodes, name, None)
|
||||
if len(token) == 1:
|
||||
layout_map = get_active_char_to_evdev_map()
|
||||
if layout_map and token in layout_map:
|
||||
return layout_map[token]
|
||||
if token.isalnum():
|
||||
return getattr(ecodes, f"KEY_{token.upper()}", None)
|
||||
return None
|
||||
return getattr(ecodes, name, None)
|
||||
if re.fullmatch(r"f\d+", token):
|
||||
return getattr(ecodes, f"KEY_{token.upper()}", None)
|
||||
return None
|
||||
|
||||
|
||||
def find_keyboard_devices() -> list[str]:
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Layout-aware char → Linux/evdev keycodes via libxkbcommon.
|
||||
|
||||
Settings store character tokens (GDK keyval); evdev matches physical KEY_*.
|
||||
On AZERTY, "a" is KEY_Q not KEY_A. This builds the map for the active layout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_XKB_EVDEV_OFFSET = 8
|
||||
_xkb_lib: object = None # None = untried, False = missing, else CDLL
|
||||
|
||||
|
||||
class _XkbRuleNames(Structure):
|
||||
_fields_ = [
|
||||
("rules", c_char_p),
|
||||
("model", c_char_p),
|
||||
("layout", c_char_p),
|
||||
("variant", c_char_p),
|
||||
("options", c_char_p),
|
||||
]
|
||||
|
||||
|
||||
def _load_xkbcommon():
|
||||
global _xkb_lib
|
||||
if _xkb_lib is not None:
|
||||
return _xkb_lib
|
||||
try:
|
||||
lib = CDLL("libxkbcommon.so.0")
|
||||
lib.xkb_context_new.argtypes = [c_int]
|
||||
lib.xkb_context_new.restype = c_void_p
|
||||
lib.xkb_keymap_new_from_names.argtypes = [c_void_p, POINTER(_XkbRuleNames), c_int]
|
||||
lib.xkb_keymap_new_from_names.restype = c_void_p
|
||||
lib.xkb_keymap_min_keycode.argtypes = [c_void_p]
|
||||
lib.xkb_keymap_min_keycode.restype = c_uint32
|
||||
lib.xkb_keymap_max_keycode.argtypes = [c_void_p]
|
||||
lib.xkb_keymap_max_keycode.restype = c_uint32
|
||||
lib.xkb_keymap_num_layouts_for_key.argtypes = [c_void_p, c_uint32]
|
||||
lib.xkb_keymap_num_layouts_for_key.restype = c_uint32
|
||||
lib.xkb_keymap_num_levels_for_key.argtypes = [c_void_p, c_uint32, c_uint32]
|
||||
lib.xkb_keymap_num_levels_for_key.restype = c_uint32
|
||||
lib.xkb_keymap_key_get_syms_by_level.argtypes = [
|
||||
c_void_p,
|
||||
c_uint32,
|
||||
c_uint32,
|
||||
c_uint32,
|
||||
POINTER(POINTER(c_uint32)),
|
||||
]
|
||||
lib.xkb_keymap_key_get_syms_by_level.restype = c_int
|
||||
lib.xkb_keysym_to_utf32.argtypes = [c_uint32]
|
||||
lib.xkb_keysym_to_utf32.restype = c_uint32
|
||||
lib.xkb_keymap_unref.argtypes = [c_void_p]
|
||||
lib.xkb_context_unref.argtypes = [c_void_p]
|
||||
_xkb_lib = lib
|
||||
except OSError as e:
|
||||
logger.debug("libxkbcommon not available: %s", e)
|
||||
_xkb_lib = False
|
||||
return _xkb_lib
|
||||
|
||||
|
||||
def build_char_to_evdev_map(layout: str, variant: str = "") -> Optional[dict[str, int]]:
|
||||
"""Unshifted (level 0) char → evdev keycode for an XKB layout, or None."""
|
||||
if not layout:
|
||||
return None
|
||||
lib = _load_xkbcommon()
|
||||
if not lib:
|
||||
return None
|
||||
|
||||
ctx = lib.xkb_context_new(0)
|
||||
if not ctx:
|
||||
return None
|
||||
|
||||
names = _XkbRuleNames(
|
||||
None,
|
||||
None,
|
||||
layout.encode("utf-8"),
|
||||
variant.encode("utf-8") if variant else None,
|
||||
None,
|
||||
)
|
||||
keymap = lib.xkb_keymap_new_from_names(ctx, byref(names), 0)
|
||||
if not keymap:
|
||||
lib.xkb_context_unref(ctx)
|
||||
return None
|
||||
|
||||
char_map: dict[str, int] = {}
|
||||
try:
|
||||
for xkb_code in range(
|
||||
lib.xkb_keymap_min_keycode(keymap),
|
||||
lib.xkb_keymap_max_keycode(keymap) + 1,
|
||||
):
|
||||
if lib.xkb_keymap_num_layouts_for_key(keymap, xkb_code) == 0:
|
||||
continue
|
||||
if lib.xkb_keymap_num_levels_for_key(keymap, xkb_code, 0) == 0:
|
||||
continue
|
||||
syms_ptr = POINTER(c_uint32)()
|
||||
n_syms = lib.xkb_keymap_key_get_syms_by_level(keymap, xkb_code, 0, 0, byref(syms_ptr))
|
||||
if n_syms <= 0:
|
||||
continue
|
||||
utf32 = lib.xkb_keysym_to_utf32(syms_ptr[0])
|
||||
if utf32 < 32 or utf32 > 0x10FFFF:
|
||||
continue
|
||||
ch = chr(utf32)
|
||||
key = ch.lower() if ch.isalpha() else ch
|
||||
if key not in char_map:
|
||||
char_map[key] = int(xkb_code) - _XKB_EVDEV_OFFSET
|
||||
finally:
|
||||
lib.xkb_keymap_unref(keymap)
|
||||
lib.xkb_context_unref(ctx)
|
||||
|
||||
return char_map or None
|
||||
|
||||
|
||||
def _detect_gnome_layout() -> tuple[str, str]:
|
||||
"""Current XKB source from GNOME gsettings (works on Wayland)."""
|
||||
try:
|
||||
for key in ("mru-sources", "sources"):
|
||||
result = subprocess.run(
|
||||
["gsettings", "get", "org.gnome.desktop.input-sources", key],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
continue
|
||||
match = re.search(r"\(\s*'xkb'\s*,\s*'([^']+)'\s*\)", result.stdout)
|
||||
if not match:
|
||||
continue
|
||||
layout, _, variant = match.group(1).partition("+")
|
||||
return layout, variant
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
logger.debug("gsettings layout query failed: %s", e)
|
||||
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
|
||||
if not layout or (layout == "us" and not variant):
|
||||
return None
|
||||
char_map = build_char_to_evdev_map(layout, variant)
|
||||
if char_map:
|
||||
logger.info(
|
||||
"Loaded layout-aware key map for XKB layout=%s variant=%s (%d chars)",
|
||||
layout,
|
||||
variant or "(none)",
|
||||
len(char_map),
|
||||
)
|
||||
return char_map
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Layout-aware combo main-key resolution (issue #513)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from vocalinux.ui.keyboard_backends import layout_key_map as lkm
|
||||
from vocalinux.ui.keyboard_backends.layout_key_map import build_char_to_evdev_map
|
||||
|
||||
evdev_backend = pytest.importorskip("vocalinux.ui.keyboard_backends.evdev_backend")
|
||||
|
||||
# Minimal AZERTY fr positions for #513 (a ↔ q swap).
|
||||
AZERTY = {"a": 16, "q": 30} # KEY_Q, KEY_A
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_layout_cache():
|
||||
lkm.get_active_char_to_evdev_map.cache_clear()
|
||||
yield
|
||||
lkm.get_active_char_to_evdev_map.cache_clear()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not evdev_backend.EVDEV_AVAILABLE, reason="evdev not available")
|
||||
class TestAzertyCombo:
|
||||
def _event(self, code, value):
|
||||
return types.SimpleNamespace(code=code, value=value, type=1)
|
||||
|
||||
def _backend(self, monkeypatch, shortcut="alt+a"):
|
||||
monkeypatch.setattr(evdev_backend, "get_active_char_to_evdev_map", lambda: AZERTY)
|
||||
return evdev_backend.EvdevKeyboardBackend(shortcut=shortcut, mode="toggle")
|
||||
|
||||
def test_alt_a_fires_on_physical_a(self, monkeypatch):
|
||||
from evdev import ecodes
|
||||
|
||||
from vocalinux.ui.keyboard_backends.evdev_backend import KEY_LEFTALT
|
||||
|
||||
backend = self._backend(monkeypatch)
|
||||
assert backend._combo_main_code == ecodes.KEY_Q
|
||||
fired = threading.Event()
|
||||
backend.register_toggle_callback(fired.set)
|
||||
backend._handle_key_event(self._event(KEY_LEFTALT, 1), None)
|
||||
backend._handle_key_event(self._event(ecodes.KEY_Q, 1), None)
|
||||
assert fired.wait(1.0)
|
||||
|
||||
def test_alt_a_does_not_fire_on_key_a(self, monkeypatch):
|
||||
from evdev import ecodes
|
||||
|
||||
from vocalinux.ui.keyboard_backends.evdev_backend import KEY_LEFTALT
|
||||
|
||||
backend = self._backend(monkeypatch)
|
||||
fired = threading.Event()
|
||||
backend.register_toggle_callback(fired.set)
|
||||
backend._handle_key_event(self._event(KEY_LEFTALT, 1), None)
|
||||
backend._handle_key_event(self._event(ecodes.KEY_A, 1), None)
|
||||
assert not fired.wait(0.2)
|
||||
|
||||
|
||||
def test_xkb_fr_a_is_key_q():
|
||||
char_map = build_char_to_evdev_map("fr")
|
||||
if char_map is None:
|
||||
pytest.skip("libxkbcommon or XKB data not available")
|
||||
from evdev import ecodes
|
||||
|
||||
assert char_map["a"] == ecodes.KEY_Q
|
||||
assert char_map["q"] == ecodes.KEY_A
|
||||
|
||||
|
||||
# --- layout_key_map coverage: detection, cache, error paths ---
|
||||
|
||||
|
||||
def test_build_empty_layout_returns_none():
|
||||
assert build_char_to_evdev_map("") is None
|
||||
|
||||
|
||||
def test_build_without_libxkbcommon(monkeypatch):
|
||||
monkeypatch.setattr(lkm, "_load_xkbcommon", lambda: False)
|
||||
assert build_char_to_evdev_map("fr") is None
|
||||
|
||||
|
||||
def test_build_context_failure(monkeypatch):
|
||||
class Lib:
|
||||
def xkb_context_new(self, _flags):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(lkm, "_load_xkbcommon", lambda: Lib())
|
||||
assert build_char_to_evdev_map("fr") is None
|
||||
|
||||
|
||||
def test_build_keymap_failure(monkeypatch):
|
||||
unref_called = []
|
||||
|
||||
class Lib:
|
||||
def xkb_context_new(self, _flags):
|
||||
return 1
|
||||
|
||||
def xkb_keymap_new_from_names(self, *_args):
|
||||
return None
|
||||
|
||||
def xkb_context_unref(self, ctx):
|
||||
unref_called.append(ctx)
|
||||
|
||||
monkeypatch.setattr(lkm, "_load_xkbcommon", lambda: Lib())
|
||||
assert build_char_to_evdev_map("fr") is None
|
||||
assert unref_called == [1]
|
||||
|
||||
|
||||
def test_load_xkbcommon_oserror(monkeypatch):
|
||||
prev = lkm._xkb_lib
|
||||
|
||||
def boom(_name):
|
||||
raise OSError("missing lib")
|
||||
|
||||
try:
|
||||
lkm._xkb_lib = None
|
||||
monkeypatch.setattr(lkm, "CDLL", boom)
|
||||
assert lkm._load_xkbcommon() is False
|
||||
assert lkm._xkb_lib is False
|
||||
# second call hits the cached False branch
|
||||
assert lkm._load_xkbcommon() is False
|
||||
finally:
|
||||
lkm._xkb_lib = prev
|
||||
|
||||
|
||||
def test_detect_gnome_layout_parses_variant(monkeypatch):
|
||||
def fake_run(cmd, **_kwargs):
|
||||
return types.SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout="[('xkb', 'fr+oss'), ('xkb', 'us')]\n",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(lkm.subprocess, "run", fake_run)
|
||||
assert lkm._detect_gnome_layout() == ("fr", "oss")
|
||||
|
||||
|
||||
def test_detect_gnome_layout_falls_back_to_sources(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, **_kwargs):
|
||||
key = cmd[-1]
|
||||
calls.append(key)
|
||||
if key == "mru-sources":
|
||||
return types.SimpleNamespace(returncode=0, stdout="@as []\n")
|
||||
return types.SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout="[('xkb', 'de')]\n",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(lkm.subprocess, "run", fake_run)
|
||||
assert lkm._detect_gnome_layout() == ("de", "")
|
||||
assert calls == ["mru-sources", "sources"]
|
||||
|
||||
|
||||
def test_detect_gnome_layout_gsettings_missing(monkeypatch):
|
||||
def boom(*_a, **_k):
|
||||
raise OSError("no gsettings")
|
||||
|
||||
monkeypatch.setattr(lkm.subprocess, "run", boom)
|
||||
assert lkm._detect_gnome_layout() == ("", "")
|
||||
|
||||
|
||||
def test_detect_gnome_layout_nonzero_exit(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
lkm.subprocess,
|
||||
"run",
|
||||
lambda *_a, **_k: types.SimpleNamespace(returncode=1, stdout=""),
|
||||
)
|
||||
assert lkm._detect_gnome_layout() == ("", "")
|
||||
|
||||
|
||||
def test_get_active_map_us_is_none(monkeypatch):
|
||||
monkeypatch.setattr(lkm, "_detect_gnome_layout", lambda: ("us", ""))
|
||||
assert lkm.get_active_char_to_evdev_map() is None
|
||||
|
||||
|
||||
def test_get_active_map_empty_layout(monkeypatch):
|
||||
monkeypatch.setattr(lkm, "_detect_gnome_layout", lambda: ("", ""))
|
||||
assert lkm.get_active_char_to_evdev_map() is None
|
||||
|
||||
|
||||
def test_get_active_map_loads_fr(monkeypatch):
|
||||
monkeypatch.setattr(lkm, "_detect_gnome_layout", lambda: ("fr", ""))
|
||||
char_map = lkm.get_active_char_to_evdev_map()
|
||||
if char_map is None:
|
||||
pytest.skip("libxkbcommon or XKB data not available")
|
||||
from evdev import ecodes
|
||||
|
||||
assert char_map["a"] == ecodes.KEY_Q
|
||||
|
||||
|
||||
def test_get_active_map_when_build_fails(monkeypatch):
|
||||
monkeypatch.setattr(lkm, "_detect_gnome_layout", lambda: ("fr", ""))
|
||||
monkeypatch.setattr(lkm, "build_char_to_evdev_map", lambda *_a, **_k: None)
|
||||
assert lkm.get_active_char_to_evdev_map() is None
|
||||
|
||||
|
||||
def test_build_skips_keys_with_no_levels(monkeypatch):
|
||||
"""Keys with zero levels are skipped (line covered via stub lib)."""
|
||||
|
||||
class Lib:
|
||||
def xkb_context_new(self, _f):
|
||||
return 1
|
||||
|
||||
def xkb_keymap_new_from_names(self, *_a):
|
||||
return 2
|
||||
|
||||
def xkb_keymap_min_keycode(self, _km):
|
||||
return 10
|
||||
|
||||
def xkb_keymap_max_keycode(self, _km):
|
||||
return 10
|
||||
|
||||
def xkb_keymap_num_layouts_for_key(self, _km, _kc):
|
||||
return 1
|
||||
|
||||
def xkb_keymap_num_levels_for_key(self, _km, _kc, _layout):
|
||||
return 0
|
||||
|
||||
def xkb_keymap_unref(self, _km):
|
||||
pass
|
||||
|
||||
def xkb_context_unref(self, _ctx):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(lkm, "_load_xkbcommon", lambda: Lib())
|
||||
assert build_char_to_evdev_map("fr") is None
|
||||
|
||||
|
||||
# --- evdev_code_for_key branches on the new layout path ---
|
||||
|
||||
|
||||
@pytest.mark.skipif(not evdev_backend.EVDEV_AVAILABLE, reason="evdev not available")
|
||||
def test_evdev_code_uses_active_layout_map(monkeypatch):
|
||||
from evdev import ecodes
|
||||
|
||||
monkeypatch.setattr(evdev_backend, "get_active_char_to_evdev_map", lambda: AZERTY)
|
||||
assert evdev_backend.evdev_code_for_key("a") == ecodes.KEY_Q
|
||||
assert evdev_backend.evdev_code_for_key("q") == ecodes.KEY_A
|
||||
|
||||
|
||||
@pytest.mark.skipif(not evdev_backend.EVDEV_AVAILABLE, reason="evdev not available")
|
||||
def test_evdev_code_us_fallback_letter(monkeypatch):
|
||||
from evdev import ecodes
|
||||
|
||||
monkeypatch.setattr(evdev_backend, "get_active_char_to_evdev_map", lambda: None)
|
||||
assert evdev_backend.evdev_code_for_key("a") == ecodes.KEY_A
|
||||
|
||||
|
||||
@pytest.mark.skipif(not evdev_backend.EVDEV_AVAILABLE, reason="evdev not available")
|
||||
def test_evdev_code_named_and_function_keys(monkeypatch):
|
||||
from evdev import ecodes
|
||||
|
||||
monkeypatch.setattr(evdev_backend, "get_active_char_to_evdev_map", lambda: AZERTY)
|
||||
assert evdev_backend.evdev_code_for_key("space") == ecodes.KEY_SPACE
|
||||
assert evdev_backend.evdev_code_for_key("f5") == ecodes.KEY_F5
|
||||
assert evdev_backend.evdev_code_for_key("") is None
|
||||
assert evdev_backend.evdev_code_for_key("notakey") is None
|
||||
# single non-alnum char with no layout entry → None (not KEY_*)
|
||||
monkeypatch.setattr(evdev_backend, "get_active_char_to_evdev_map", lambda: None)
|
||||
assert evdev_backend.evdev_code_for_key("@") is None
|
||||
Reference in New Issue
Block a user