summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPierre Le Marre <dev@wismill.eu>2023-04-28 09:32:16 +0200
committerPeter Hutterer <peter.hutterer@who-t.net>2023-05-02 00:35:37 +0000
commit7061fd7e8269aac996ceccba8880b77a8d3ca77e (patch)
tree3ad3682c0707d0c9caa65b3dbb6b2bbf941ae0f9
parent6d7067ed281cc48e9dd85066ea0d65812619a3e4 (diff)
downloadxkeyboard-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.yml85
-rw-r--r--tests/test_regressions.py355
-rw-r--r--tests/xkbcommon/__init__.py458
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)