diff options
author | Pierre Le Marre <dev@wismill.eu> | 2023-04-28 09:32:16 +0200 |
---|---|---|
committer | Peter Hutterer <peter.hutterer@who-t.net> | 2023-05-02 00:35:37 +0000 |
commit | 7061fd7e8269aac996ceccba8880b77a8d3ca77e (patch) | |
tree | 3ad3682c0707d0c9caa65b3dbb6b2bbf941ae0f9 | |
parent | 6d7067ed281cc48e9dd85066ea0d65812619a3e4 (diff) | |
download | xkeyboard-config-7061fd7e8269aac996ceccba8880b77a8d3ca77e.tar.gz |
Add regression tests
- Create Python bindings to xkbcommon.
- Create a regression test framework using pytest.
- Add regression tests for issues 90, 346, 382 and 383.
- Document how to write tests.
- CI: Create a separate job for the libxkbcommon build that share
its artifacts.
- CI: Add the tests to the keymap_tests job.
-rw-r--r-- | .gitlab-ci.yml | 85 | ||||
-rw-r--r-- | tests/test_regressions.py | 355 | ||||
-rw-r--r-- | tests/xkbcommon/__init__.py | 458 |
3 files changed, 880 insertions, 18 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 6a944dd..6250935 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -34,6 +34,10 @@ variables: # string, but we use the date for human benefits. FDO_DISTRIBUTION_TAG: '2022-01-20.0' + # xkbcommon: needed to share artifacts between jobs + XKBCOMMON_DIR: 'libxkbcommon' + XKBCOMMON_BUILD_DIR: $XKBCOMMON_DIR/$BUILDDIR + stages: - prep @@ -111,7 +115,7 @@ meson_test: variables: NINJA_EXTRA_COMMAND: "test" -meston_dist: +meson_dist: extends: .meson_build stage: build variables: @@ -122,6 +126,41 @@ meston_dist: paths: - $BUILDDIR/meson-dist/xkeyboard-config-*.tar.xz + +# Download and build xkbcommon +xkbcommon build: + extends: .default_setup + stage: build + script: + # Ensure there are no leftovers + - rm -rf xorgproto libxkbcommon + # Get latest xorgproto so we definitely have all keysyms + - git clone --depth=1 https://gitlab.freedesktop.org/xorg/proto/xorgproto + - export X11_HEADERS_PREFIX="$PWD/xorgproto/" + # Get latest xkbcommon + - git clone --depth=1 https://github.com/xkbcommon/libxkbcommon "$XKBCOMMON_DIR" + - pushd "$XKBCOMMON_DIR" > /dev/null + - ./scripts/update-keysyms + - > + meson setup "$BUILDDIR" \ + -Denable-wayland=false \ + -Denable-x11=false \ + -Denable-docs=false \ + -Dxkb-config-root="$INSTDIR/share/X11/xkb" + - meson compile -C "$BUILDDIR" + artifacts: + when: on_success + name: xkbcommon build + expire_in: 3 hours + paths: + - $XKBCOMMON_BUILD_DIR/libxkbcommon.so* + - $XKBCOMMON_BUILD_DIR/xkbcli-compile-keymap + - $XKBCOMMON_BUILD_DIR/xkeyboard-config-test + exclude: + - $XKBCOMMON_BUILD_DIR/libxkbcommon.so*.[^0-9] + - $XKBCOMMON_BUILD_DIR/libxkbcommon.so*.[^0-9]/**/* + + # Checks for new evdev keycodes to be added to keycodes/evdev evdev keycode check: extends: @@ -197,31 +236,37 @@ xkbcli list check: paths: - rmlvo.yaml -# download libxkbcommon and run its layout test program. This will + +# Run the libxkbcommon layout test program. This will # run a basic keymap compile test against every combination of # layout/variant/option. Syntax errors will fail the test, check the # archived file for details. layout_tests: extends: .default_setup stage: test - needs: ["meson_install"] + needs: + - job: meson_install + artifacts: true + - job: xkbcommon build + artifacts: true script: # make sure the custom layout resolves to something - ln -s "$INSTDIR/share/X11/xkb/symbols/us" "$INSTDIR/share/X11/xkb/symbols/custom" # make sure the custom types resolves to something - ln -s "$INSTDIR/share/X11/xkb/types/basic" "$INSTDIR/share/X11/xkb/types/custom" - - rm -rf xorgproto libxkbcommon - # Get latest xorgproto so we definitely have all keysyms - - git clone --depth=1 https://gitlab.freedesktop.org/xorg/proto/xorgproto - - export X11_HEADERS_PREFIX="$PWD/xorgproto/" - - git clone --depth=1 https://github.com/xkbcommon/libxkbcommon - - pushd libxkbcommon > /dev/null - - ./scripts/update-keysyms - - meson builddir -Denable-wayland=false -Denable-x11=false -Denable-docs=false -Dxkb-config-root="$INSTDIR/share/X11/xkb" - - ninja -C builddir + # run xkbcommon test - echo Running test script - this will take several minutes - - ./builddir/xkeyboard-config-test --verbose "$INSTDIR/share/X11/xkb/rules/evdev.xml" > $INSTDIR/keymaps-success.yaml 2> $INSTDIR/keymaps-failed.yaml - - ./builddir/xkeyboard-config-test --verbose "$INSTDIR/share/X11/xkb/rules/evdev.extras.xml" >> $INSTDIR/keymaps-success.yaml 2>> $INSTDIR/keymaps-failed.yaml + - pushd "$XKBCOMMON_BUILD_DIR" > /dev/null + - > + "./xkeyboard-config-test" --verbose \ + "$INSTDIR/share/X11/xkb/rules/evdev.xml" \ + > $INSTDIR/keymaps-success.yaml \ + 2> $INSTDIR/keymaps-failed.yaml + - > + "./xkeyboard-config-test" --verbose \ + "$INSTDIR/share/X11/xkb/rules/evdev.extras.xml" \ + >> $INSTDIR/keymaps-success.yaml \ + 2>> $INSTDIR/keymaps-failed.yaml - popd > /dev/null after_script: - echo "Failed keymap compilations:" @@ -249,13 +294,17 @@ layout_tests: keymap_tests: extends: .default_setup stage: test + needs: + # use the installed tree from the meson_install job + - job: meson_install + artifacts: true + - job: xkbcommon build + artifacts: true script: - export XKB_CONFIG_ROOT="$INSTDIR/share/X11/xkb" + - export XKBCOMMON_LIB_PATH="$XKBCOMMON_BUILD_DIR/libxkbcommon.so" + - export PYTHONPATH="$PWD/tests:$PYTHONPATH" - pytest --junitxml=results.xml artifacts: reports: junit: results.xml - # use the installed tree from the meson_install job - needs: - - job: meson_install - artifacts: true diff --git a/tests/test_regressions.py b/tests/test_regressions.py new file mode 100644 index 0000000..b9a7bb4 --- /dev/null +++ b/tests/test_regressions.py @@ -0,0 +1,355 @@ +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import os +import re +from functools import reduce +from pathlib import Path +from typing import Optional + +import pytest +import xkbcommon +from xkbcommon import Mod1, Mod4, Mod5, ModifierMask, NoModifier, Shift + +############################################################################### +# pytest configuration +############################################################################### + +# You may skip this section and go to the section “How-to write tests” +# if you only intend to write new tests. +# +# How the test suite works +# ------------------------ +# +# Interfacing with xkbcommon requires: +# • Taking care of initialization and finalization of foreign objects. +# This is done using `xkbcommon.ForeignKeymap` and `xkbcommon.ForeignState` +# context managers. +# • Updating the state: this is down with `xkbcommon.State`. +# +# pytest fixtures: +# • The only fixture intended in the test code is `keymap`. +# • Other fixtures are just helpers that are used indirectly. +# • The intented use is documented in `TestSuiteDoc`. + + +KEYCODE_PATTERN = re.compile( + r"""[A-Z] # Start with an upper case letter + [A-Za-z0-9+\-]{1,3} # Followed by up to 3 characters + """, + re.VERBOSE, +) + +BASE_GROUP = 1 +BASE_LEVEL = 1 + + +def check_keycode(key: str) -> bool: + """Check keycode has the required syntax.""" + return bool(KEYCODE_PATTERN.match(key)) + + +class Keymap: + """Public test methods""" + + def __init__(self, state: xkbcommon.State): + self._state = state + + def press(self, key: str) -> xkbcommon.Result: + """Update the state by pressing a key""" + assert check_keycode(key), "key must be a [2-4]-character keycode" + return self._state.process_key_event( + key, xkbcommon.xkb_key_direction.XKB_KEY_DOWN + ) + + def release(self, key: str) -> xkbcommon.Result: + """Update the state by pressing a key""" + assert check_keycode(key), "key must be a [2-4]-character keycode" + return self._state.process_key_event( + key, xkbcommon.xkb_key_direction.XKB_KEY_UP + ) + + def tap(self, key: str) -> xkbcommon.Result: + """Update the state by tapping a key""" + assert check_keycode(key), "key must be a [2-4]-character keycode" + self.press(key) + return self.release(key) + + def tap_and_check( + self, key: str, keysym: str, group: int = BASE_GROUP, level: int = BASE_LEVEL + ) -> xkbcommon.Result: + """ + Check that taping a key produces the expected keysym in the + expected group and level. + """ + r = self.tap(key) + assert r.group == group + assert r.level == level + assert r.keysym == keysym + # Return the result for optional further tests + return r + + def key_down(self, *keys: str) -> _KeyDown: + """Update the state by holding some keys""" + assert all(map(check_keycode, keys)), "keys must be a [2-4]-character keycodes" + return _KeyDown(self, *keys) + + +# NOTE: Abusing Python’s context manager to enable nice test syntax +class _KeyDown: + """Context manager that will hold a key.""" + + def __init__(self, keymap: Keymap, *keys: str): + self.keys = keys + self.keymap = keymap + + def __enter__(self) -> xkbcommon.Result: + """Press the key in order, then return the last result.""" + return reduce( + lambda _, key: self.keymap.press(key), + self.keys, + xkbcommon.Result(0, 0, "", "", 0, NoModifier, NoModifier, ()), + ) + + def __exit__(self, *_): + for key in self.keys: + self.keymap.release(key) + + +@pytest.fixture(scope="session") +def xkb_base(): + """Get the xkeyboard-config directory from the environment.""" + path = os.environ.get("XKB_CONFIG_ROOT") + if path: + return Path(path) + else: + raise ValueError("XKB_CONFIG_ROOT environment variable is not defined") + + +# The following fixtures enable them to have default values (i.e. None). + + +@pytest.fixture(scope="function") +def rules(request: pytest.FixtureRequest): + return getattr(request, "param", None) + + +@pytest.fixture(scope="function") +def model(request: pytest.FixtureRequest): + return getattr(request, "param", None) + + +@pytest.fixture(scope="function") +def layout(request: pytest.FixtureRequest): + return getattr(request, "param", None) + + +@pytest.fixture(scope="function") +def variant(request: pytest.FixtureRequest): + return getattr(request, "param", None) + + +@pytest.fixture(scope="function") +def options(request: pytest.FixtureRequest): + return getattr(request, "param", None) + + +@pytest.fixture +def keymap( + xkb_base: Path, + rules: Optional[str], + model: Optional[str], + layout: Optional[str], + variant: Optional[str], + options: Optional[str], +): + """Load a keymap, and return a new state.""" + with xkbcommon.ForeignKeymap( + xkb_base, + rules=rules, + model=model, + layout=layout, + variant=variant, + options=options, + ) as km: + with xkbcommon.ForeignState(km) as state: + yield Keymap(state) + + +# Documented example +# The parameters RMLVO parameters “rules”, “model”, “layout”, “variant” and +# “options” are optional and are implicitely consumed by the keymap fixture. +@pytest.mark.parametrize("layout", ["us"]) +class TestSuiteDoc: + # The keymap argument is mandatory. It will: + # • Load the keymap corresponding to RMLVO input. + # • Initialize a new state + # • Return a convenient `Keymap` object, that will manage the + # low-level xkbcommon stuff and provide methods to safely change + # the state. + def test_example(self, keymap: Keymap): + # Use keymap to change keyboard state + r = keymap.press("AC01") + # The return value is used in assertions + assert r.keysym == "a" + # When the function returns, if will automatically run the + # cleanup code of the keymap fixture, i.e. the __exit__ + # function of `xkbcommon.ForeignKeymap` and + # `xkbcommon.ForeignKeymap`. + # See further examples in the section “How-to write tests”. + + +############################################################################### +# How-to write tests +############################################################################### + +# • Create one class per topic. It should have a meaningful name prefixed by +# `Test` and refering to the topic: e.g. TestCompatibilityOption1Option2. +# If there is a Gitlab issue it can be named after it: e.g. TestGitlabIssue382. +# • The intented use is commented in the following `TestExample` class. + + +# The RMLVO XKB configuration is set with parameters “rules”, “model”, “layout”, +# “variant” and “options”. They are optional and default to None. +@pytest.mark.parametrize("layout", ["de"]) +# Name prefixed with `Test`. +class TestExample: + # Define one function for each test. Its name must be prefixed by `test_`. + # The keymap argument is mandatory. It provides methods to safely + # change the keyboard state. + def test_example(self, keymap: Keymap): + # Use keymap to change keyboard state + r = keymap.press("LFSH") + # The return value is used in assertions + assert r.keysym == "Shift_L" + # We must not forget to release the key, if necessary: + keymap.release("LFSH") + # Or we could also use `Keymap.key_down` to achieve the same: + with keymap.key_down("LFSH") as r: + assert r.keysym == "Shift_L" + # Now we can check the impact of modifier on other keys. + # Manually: + r = keymap.tap("AC01") + assert r.level == 2 + assert r.keysym == "A" + # With helper function: + keymap.tap_and_check("AC01", "A", level=2) + # We can also use multiple keys: + with keymap.key_down("LFSH", "RALT") as r: + # In this case the result refer to the last key + assert r.keysym == "ISO_Level3_Shift" + r = keymap.tap_and_check("AC01", "AE", level=4) + # We can also check (real) modifiers directly + assert r.active_mods == Shift | Mod5 == r.consumed_mods + + +############################################################################### +# Regression Tests +############################################################################### + + +# https://gitlab.freedesktop.org/xkeyboard-config/xkeyboard-config/-/issues/382 +@pytest.mark.parametrize("layout,variant,options", [("us", "intl", "lv3:lwin_switch")]) +class TestIssue382: + @pytest.mark.parametrize("mod_key", ("RALT", "LWIN")) + def test_LevelThree(self, keymap: Keymap, mod_key: str): + """Both RALT and LWIN are LevelThree modifiers""" + with keymap.key_down(mod_key): + r = keymap.tap_and_check("AD01", "adiaeresis", level=3) + assert r.active_mods == Mod5 == r.consumed_mods + with keymap.key_down("LFSH"): + r = keymap.tap_and_check("AD01", "Adiaeresis", level=4) + assert r.active_mods == Shift | Mod5 == r.consumed_mods + + def test_ShiftAlt(self, keymap: Keymap): + """LALT+LFSH works as if there was no option""" + r = keymap.tap_and_check("AC10", "semicolon", level=1) + assert r.active_mods == NoModifier + with keymap.key_down("LFSH", "LALT"): + r = keymap.tap_and_check("AC10", "colon", level=2) + assert r.active_mods == Shift | Mod1 + assert r.consumed_mods == Shift + + +# https://gitlab.freedesktop.org/xkeyboard-config/xkeyboard-config/-/issues/90 +# https://gitlab.freedesktop.org/xkeyboard-config/xkeyboard-config/-/issues/346 +class TestIssues90And346: + @pytest.mark.parametrize( + "layout,key,keysyms", + [ + ("fi,us", "TLDE", ("section", "grave")), + ("dk,us", "TLDE", ("onehalf", "grave")), + ("fi,us,dk", "TLDE", ("section", "grave", "onehalf")), + ], + ) + @pytest.mark.parametrize( + "options,mod_key,mod", + [ + ("grp:win_space_toggle", "LWIN", Mod4), + ("grp:alt_space_toggle", "LALT", Mod1), + ], + ) + def test_group_switch_on_all_groups( + self, + keymap: Keymap, + mod_key: str, + mod: ModifierMask, + key: str, + keysyms: tuple[str], + ): + """LWIN/LALT + SPCE is a group switch on multiple groups""" + for group, keysym in enumerate(keysyms, start=1): + print(group, keysym) + keymap.tap_and_check(key, keysym, group=group) + self.switch_group(keymap, mod_key, mod, group % len(keysyms) + 1) + # Check the group wraps + keymap.tap_and_check(key, keysyms[0], group=1) + + @staticmethod + def switch_group(keymap: Keymap, mod_key: str, mod: ModifierMask, group: int): + with keymap.key_down(mod_key) as r: + assert r.group == 1 # only defined on first group + r = keymap.tap_and_check("SPCE", "ISO_Next_Group", group=group, level=2) + assert r.active_mods == mod == r.consumed_mods + + +# https://gitlab.freedesktop.org/xkeyboard-config/xkeyboard-config/-/issues/383 +@pytest.mark.parametrize("layout", ["us,ru"]) +@pytest.mark.parametrize( + "options,mod_key,mod", + [ + ("misc:typo,grp:win_space_toggle,lv3:ralt_switch", "LWIN", Mod4), + ("misc:typo,grp:alt_space_toggle,lv3:ralt_switch", "LALT", Mod1), + ], +) +class TestIssue383: + def test_group_switch(self, keymap: Keymap, mod_key: str, mod: ModifierMask): + """LWIN + SPCE is a group switch on both groups""" + # Start with us layout + self.check_keysyms(keymap, 1, "AC01", "a", "combining_acute") + # Switch to ru layout + self.switch_group(keymap, mod_key, mod, 2) + self.check_keysyms(keymap, 2, "AC01", "Cyrillic_ef", "combining_acute") + # Switch back to us layout + self.switch_group(keymap, mod_key, mod, 1) + self.check_keysyms(keymap, 1, "AC01", "a", "combining_acute") + + @staticmethod + def switch_group(keymap: Keymap, mod_key: str, mod: ModifierMask, group: int): + with keymap.key_down(mod_key) as r: + assert r.group == 1 # only defined on first group + r = keymap.tap_and_check("SPCE", "ISO_Next_Group", group=group, level=2) + assert r.active_mods == mod == r.consumed_mods + + @staticmethod + def check_keysyms( + keymap: Keymap, group: int, key: str, base_keysym: str, typo_keysym: str + ): + # Base keysym + keymap.tap_and_check(key, base_keysym, group=group, level=1) + # typo keysym + with keymap.key_down("RALT") as r: + assert r.group == 1 # only defined on first group + r = keymap.tap_and_check(key, typo_keysym, group=group, level=3) + assert r.active_mods == Mod5 == r.consumed_mods diff --git a/tests/xkbcommon/__init__.py b/tests/xkbcommon/__init__.py new file mode 100644 index 0000000..e2a2193 --- /dev/null +++ b/tests/xkbcommon/__init__.py @@ -0,0 +1,458 @@ +# SPDX-License-Identifier: MIT + +import os +from ctypes import ( + POINTER, + Structure, + _Pointer, + byref, + c_char, + c_char_p, + c_int, + c_size_t, + c_uint32, + cdll, + create_string_buffer, +) +from ctypes.util import find_library +from enum import Enum, IntFlag +from functools import reduce +from pathlib import Path +from sys import stdout +from typing import TYPE_CHECKING, Any, NamedTuple, Optional, TypeAlias + +############################################################################### +# Types +############################################################################### + + +class xkb_context(Structure): + pass + + +class xkb_rule_names(Structure): + _fields_ = [ + ("rules", POINTER(c_char)), + ("model", POINTER(c_char)), + ("layout", POINTER(c_char)), + ("variant", POINTER(c_char)), + ("options", POINTER(c_char)), + ] + + +class xkb_keymap(Structure): + pass + + +class xkb_state(Structure): + pass + + +class xkb_key_direction(Enum): + XKB_KEY_UP = c_int(0) + XKB_KEY_DOWN = c_int(1) + + +# [HACK] Typing ctypes correctly is difficult. The following works, but there +# could be another better way. +if TYPE_CHECKING: + xkb_context_p: TypeAlias = _Pointer[xkb_context] + xkb_rule_names_p: TypeAlias = _Pointer[xkb_rule_names] + xkb_keymap_p: TypeAlias = _Pointer[xkb_keymap] + xkb_state_p = _Pointer[xkb_state] +else: + xkb_context_p = Any + xkb_rule_names_p = Any + xkb_keymap_p = Any + xkb_state_p = Any +xkb_context_flags = c_int +xkb_keymap_compile_flags = c_int +xkb_keymap_format = c_int +xkb_keycode_t = c_uint32 +xkb_keysym_t = c_uint32 +xkb_mod_index_t = c_uint32 +xkb_led_index_t = c_uint32 +xkb_level_index_t = c_uint32 +xkb_layout_index_t = c_uint32 +xkb_state_component = c_int +xkb_consumed_mode = c_int + + +############################################################################### +# Constants +############################################################################### + + +XKB_CONTEXT_NO_DEFAULT_INCLUDES = 1 << 0 +XKB_CONTEXT_NO_ENVIRONMENT_NAMES = 1 << 1 +XKB_KEYCODE_INVALID = 0xFFFFFFFF +XKB_STATE_MODS_EFFECTIVE = 1 << 3 +XKB_CONSUMED_MODE_XKB = 0 +XKB_KEYMAP_FORMAT_TEXT_V1 = 1 + + +class ModifierMask(IntFlag): + """Built-in standard definitions of modifiers masks""" + + Shift = 1 << 0 + Lock = 1 << 1 + Control = 1 << 2 + Mod1 = 1 << 3 + Mod2 = 1 << 4 + Mod3 = 1 << 5 + Mod4 = 1 << 6 + Mod5 = 1 << 7 + + +NoModifier = ModifierMask(0) +Shift = ModifierMask.Shift +Lock = ModifierMask.Lock +Control = ModifierMask.Control +Mod1 = ModifierMask.Mod1 +Mod2 = ModifierMask.Mod2 +Mod3 = ModifierMask.Mod3 +Mod4 = ModifierMask.Mod4 +Mod5 = ModifierMask.Mod5 + + +############################################################################### +# Binding to libxkbcommon +############################################################################### + + +xkbcommon_path = os.environ.get("XKBCOMMON_LIB_PATH") + +if xkbcommon_path: + xkbcommon_path = str(Path(xkbcommon_path).resolve()) + xkbcommon = cdll.LoadLibrary(xkbcommon_path) +else: + xkbcommon_path = find_library("xkbcommon") + if xkbcommon_path: + xkbcommon = cdll.LoadLibrary(xkbcommon_path) + else: + raise OSError("Cannot load libxbcommon") + +xkbcommon.xkb_context_new.argtypes = [xkb_context_flags] +xkbcommon.xkb_context_new.restype = POINTER(xkb_context) + +xkbcommon.xkb_keymap_new_from_names.argtypes = [ + POINTER(xkb_context), + POINTER(xkb_rule_names), + xkb_keymap_compile_flags, +] +xkbcommon.xkb_keymap_new_from_names.restype = POINTER(xkb_keymap) + +xkbcommon.xkb_keymap_key_by_name.argtypes = [POINTER(xkb_keymap), c_char_p] +xkbcommon.xkb_keymap_key_by_name.restype = xkb_keycode_t + +xkbcommon.xkb_state_new.argtypes = [POINTER(xkb_keymap)] +xkbcommon.xkb_state_new.restype = POINTER(xkb_state) + +xkbcommon.xkb_state_get_keymap.argtypes = [POINTER(xkb_state)] +xkbcommon.xkb_state_get_keymap.restype = POINTER(xkb_keymap) + +xkbcommon.xkb_state_key_get_one_sym.argtypes = [POINTER(xkb_state), xkb_keycode_t] +xkbcommon.xkb_state_key_get_one_sym.restype = xkb_keysym_t + +xkbcommon.xkb_keymap_led_get_name.argtypes = [POINTER(xkb_keymap), xkb_led_index_t] +xkbcommon.xkb_keymap_led_get_name.restype = c_char_p + +xkbcommon.xkb_state_key_get_layout.argtypes = [POINTER(xkb_state), xkb_keycode_t] +xkbcommon.xkb_state_key_get_layout.restype = xkb_layout_index_t + +xkbcommon.xkb_state_key_get_level.argtypes = [ + POINTER(xkb_state), + xkb_keycode_t, + xkb_layout_index_t, +] +xkbcommon.xkb_state_key_get_level.restype = xkb_level_index_t + +xkbcommon.xkb_keymap_num_mods.argtypes = [POINTER(xkb_keymap)] +xkbcommon.xkb_keymap_num_mods.restype = xkb_mod_index_t + +xkbcommon.xkb_state_mod_index_is_active.argtypes = [ + POINTER(xkb_state), + xkb_mod_index_t, + xkb_state_component, +] +xkbcommon.xkb_state_mod_index_is_active.restype = c_int + +xkbcommon.xkb_state_mod_index_is_consumed2.argtypes = [ + POINTER(xkb_state), + xkb_keycode_t, + xkb_mod_index_t, + xkb_consumed_mode, +] +xkbcommon.xkb_state_mod_index_is_consumed2.restype = c_int + +xkbcommon.xkb_keymap_num_leds.argtypes = [POINTER(xkb_keymap)] +xkbcommon.xkb_keymap_num_leds.restype = xkb_led_index_t + +xkbcommon.xkb_state_led_index_is_active.argtypes = [POINTER(xkb_state), xkb_led_index_t] +xkbcommon.xkb_state_led_index_is_active.restype = c_int + + +def load_keymap( + xkb_config_root: Path, + rules=None, + model=None, + layout=None, + variant=None, + options=None, +) -> xkb_keymap_p: + # Create context + context = xkbcommon.xkb_context_new( + XKB_CONTEXT_NO_DEFAULT_INCLUDES | XKB_CONTEXT_NO_ENVIRONMENT_NAMES + ) + if not context: + raise ValueError("Couldn't create xkb context") + raw_path = create_string_buffer(str(xkb_config_root).encode("utf-8")) + xkbcommon.xkb_context_include_path_append(context, raw_path) + rmlvo = xkb_rule_names( + rules=create_string_buffer(rules.encode("utf-8")) if rules else None, + model=create_string_buffer(model.encode("utf-8")) if model else None, + layout=create_string_buffer(layout.encode("utf-8")) if layout else None, + variant=create_string_buffer(variant.encode("utf-8")) if variant else None, + options=create_string_buffer(options.encode("utf-8")) if options else None, + ) + # Load keymap + keymap = xkbcommon.xkb_keymap_new_from_names(context, byref(rmlvo), 0) + if not keymap: + raise ValueError( + f"Failed to compile RMLVO: {rules=}, {model=}, {layout=}, " + f"{variant=}, {options=}" + ) + + xkbcommon.xkb_context_unref(context) + return keymap + + +def unref_keymap(keymap: xkb_keymap_p) -> None: + xkbcommon.xkb_keymap_unref(keymap) + + +def new_state(keymap: xkb_keymap_p) -> xkb_state_p: + state = xkbcommon.xkb_state_new(keymap) + if not state: + raise ValueError("Cannot create state") + return state + + +def init_state( + xkeyboard_config_path: Path, + rules=None, + model=None, + layout=None, + variant=None, + options=None, +) -> xkb_state_p: + keymap = load_keymap(xkeyboard_config_path, rules, model, layout, variant, options) + return new_state(keymap) + + +def unref_state(state: xkb_state_p) -> None: + xkbcommon.xkb_state_unref(state) + + +def xkb_keymap_led_get_name(keymap: xkb_keymap_p, led: int) -> str: + return xkbcommon.xkb_keymap_led_get_name(keymap, led).decode("utf-8") + + +def xkb_keysym_get_name(keysym: int) -> str: + buf_len = 90 + buf = create_string_buffer(buf_len) + n = xkbcommon.xkb_keysym_get_name(keysym, buf, c_size_t(buf_len)) + if n > 0: + return buf.value.decode("utf-8") + elif n >= buf_len: + raise ValueError(f"Truncated: expected {buf_len}, got: {n + 1}.") + else: + raise ValueError(f"Unsupported keysym: {keysym}") + + +def xkb_state_key_get_utf8(state: xkb_state_p, key: int) -> str: + buf_len = 8 + buf = create_string_buffer(buf_len) + n = xkbcommon.xkb_state_key_get_utf8(state, key, buf, c_size_t(buf_len)) + if n >= buf_len: + raise ValueError(f"Truncated: expected {buf_len}, got: {n + 1}.") + else: + return buf.value.decode("utf-8") + + +############################################################################### +# Process key events +############################################################################### + + +class Result(NamedTuple): + keycode: int + layout: int + keysym: str + unicode: str + level: int + active_mods: ModifierMask + consumed_mods: ModifierMask + leds: tuple[str, ...] + + @property + def group(self) -> int: + """Alias for Result.layout""" + return self.layout + + +def process_key_event( + state: xkb_state_p, key: str, direction: xkb_key_direction +) -> Result: + keymap: xkb_keymap_p = xkbcommon.xkb_state_get_keymap(state) + keycode: int = xkbcommon.xkb_keymap_key_by_name( + keymap, create_string_buffer(key.encode("utf-8")) + ) + if keycode == XKB_KEYCODE_INVALID: + raise ValueError(f"Unsupported key name: {key}") + + # Modifiers + mods_count = xkbcommon.xkb_keymap_num_mods(keymap) + active_mods = reduce( + (lambda acc, m: acc | ModifierMask(1 << m)), + filter( + lambda m: xkbcommon.xkb_state_mod_index_is_active( + state, m, XKB_STATE_MODS_EFFECTIVE + ), + range(0, mods_count), + ), + ModifierMask(0), + ) + consumed_mods = reduce( + (lambda acc, m: acc | ModifierMask(1 << m)), + filter( + lambda m: xkbcommon.xkb_state_mod_index_is_active( + state, m, XKB_STATE_MODS_EFFECTIVE + ) + and xkbcommon.xkb_state_mod_index_is_consumed2( + state, keycode, m, XKB_CONSUMED_MODE_XKB + ), + range(0, mods_count), + ), + ModifierMask(0), + ) + + # Level + layout: int = xkbcommon.xkb_state_key_get_layout(state, keycode) + level: int = xkbcommon.xkb_state_key_get_level(state, keycode, layout) + + # Keysyms + # [TODO] multiple keysyms + # keysyms = (xkb_keysym_t * 2)() + # keysyms_count = xkb_state_key_get_syms(state, keycode, byref(keysyms)) + keysym = xkbcommon.xkb_state_key_get_one_sym(state, keycode) + keysym_str = xkb_keysym_get_name(keysym) + + # [TODO] Compose? + # Unicode + unicode = xkb_state_key_get_utf8(state, keycode) + + # LEDs + leds_count = xkbcommon.xkb_keymap_num_leds(keymap) + leds = tuple( + xkb_keymap_led_get_name(keymap, led) + for led in range(0, leds_count) + if xkbcommon.xkb_state_led_index_is_active(state, led) + ) + + # Update state + # [TODO] use return value? + xkbcommon.xkb_state_update_key(state, keycode, direction.value) + + # Return result + return Result( + keycode=keycode, + layout=layout + 1, + keysym=keysym_str, + unicode=unicode, + active_mods=active_mods, + consumed_mods=consumed_mods, + level=level + 1, + leds=leds, + ) + + +############################################################################### +# Pythonic interface +############################################################################### + + +class ForeignKeymap: + """ + Context manager to ensure proper handling of foreign `xkb_keymap` object. + + Intended use:: + + with ForeignKeymap(xkb_base, layout="de") as keymap: + with ForeignState(keymap) as state: + # Use state safely here + state.process_key_event(...) + """ + + def __init__( + self, + xkb_base: Path, + rules: Optional[str] = None, + model: Optional[str] = None, + layout: Optional[str] = None, + variant: Optional[str] = None, + options: Optional[str] = None, + ): + self.xkb_base = xkb_base + self._keymap = POINTER(xkb_keymap)() # NULL pointer + self.rules = rules + self.model = model + self.layout = layout + self.variant = variant + self.options = options + + def __enter__(self) -> xkb_keymap_p: + self._keymap = load_keymap( + self.xkb_base, + model=self.model, + layout=self.layout, + variant=self.variant, + options=self.options, + ) + return self._keymap + + def __exit__(self, exc_type, exc_val, exc_tb): + unref_keymap(self._keymap) + + +class State: + """ + Convenient interface to use `xkb_state`. + + Intended use: see `ForeignKeymap`. + """ + + def __init__(self, state: xkb_state_p): + self._state = state + + def process_key_event(self, key: str, direction: xkb_key_direction) -> Result: + return process_key_event(self._state, key, direction) + + +class ForeignState: + """ + Context manager to ensure proper handling of foreign `xkb_state` object. + + Intended use: see `ForeignKeymap`. + """ + + def __init__(self, keymap: xkb_keymap_p): + self._keymap = keymap + self._state = POINTER(xkb_state)() # NULL pointer + + def __enter__(self) -> State: + self._state = new_state(self._keymap) + return State(self._state) + + def __exit__(self, exc_type, exc_val, exc_tb): + unref_state(self._state) |