diff --git a/src/vocalinux/ui/keyboard_backends/evdev_backend.py b/src/vocalinux/ui/keyboard_backends/evdev_backend.py index 4a48d58..693b1b8 100644 --- a/src/vocalinux/ui/keyboard_backends/evdev_backend.py +++ b/src/vocalinux/ui/keyboard_backends/evdev_backend.py @@ -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]: diff --git a/src/vocalinux/ui/keyboard_backends/layout_key_map.py b/src/vocalinux/ui/keyboard_backends/layout_key_map.py new file mode 100644 index 0000000..21ced3d --- /dev/null +++ b/src/vocalinux/ui/keyboard_backends/layout_key_map.py @@ -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 diff --git a/tests/test_layout_key_map.py b/tests/test_layout_key_map.py new file mode 100644 index 0000000..ab3bad6 --- /dev/null +++ b/tests/test_layout_key_map.py @@ -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