diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ed0a54ed..cb15aa529 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -273,6 +273,10 @@ jobs: path: .cache key: 0_${{ steps.set_cache.outputs.cache_name }}_${{ hashFiles('reqs/constraints.txt', 'reqs/dist.txt', 'reqs/test.txt') }} + # Required to install xkbcommon Python package. + - name: Install system dependencies + run: apt_get_install libxkbcommon-dev + - name: Setup Python environment run: setup_python_env -c reqs/constraints.txt -r reqs/dist.txt -r reqs/test.txt @@ -439,6 +443,10 @@ jobs: path: .cache key: 0_${{ steps.set_cache.outputs.cache_name }}_${{ hashFiles('reqs/constraints.txt', 'reqs/dist.txt', 'reqs/test.txt') }} + # Required to install xkbcommon Python package. + - name: Install system dependencies + run: apt_get_install libxkbcommon-dev + - name: Setup Python environment run: setup_python_env -c reqs/constraints.txt -r reqs/dist.txt -r reqs/test.txt @@ -493,6 +501,10 @@ jobs: path: .cache key: 0_${{ steps.set_cache.outputs.cache_name }}_${{ hashFiles('reqs/constraints.txt', 'reqs/dist.txt', 'reqs/test.txt') }} + # Required to install xkbcommon Python package. + - name: Install system dependencies + run: apt_get_install libxkbcommon-dev + - name: Setup Python environment run: setup_python_env -c reqs/constraints.txt -r reqs/dist.txt -r reqs/test.txt @@ -547,6 +559,10 @@ jobs: path: .cache key: 0_${{ steps.set_cache.outputs.cache_name }}_${{ hashFiles('reqs/constraints.txt', 'reqs/dist.txt', 'reqs/test.txt') }} + # Required to install xkbcommon Python package. + - name: Install system dependencies + run: apt_get_install libxkbcommon-dev + - name: Setup Python environment run: setup_python_env -c reqs/constraints.txt -r reqs/dist.txt -r reqs/test.txt @@ -601,6 +617,10 @@ jobs: path: .cache key: 0_${{ steps.set_cache.outputs.cache_name }}_${{ hashFiles('reqs/constraints.txt', 'reqs/dist.txt', 'reqs/test.txt') }} + # Required to install xkbcommon Python package. + - name: Install system dependencies + run: apt_get_install libxkbcommon-dev + - name: Setup Python environment run: setup_python_env -c reqs/constraints.txt -r reqs/dist.txt -r reqs/test.txt @@ -656,7 +676,7 @@ jobs: key: 0_${{ steps.set_cache.outputs.cache_name }}_${{ hashFiles('reqs/constraints.txt', 'reqs/dist.txt', 'reqs/dist_extra_gui_qt.txt', 'reqs/test.txt') }} - name: Install system dependencies - run: apt_get_install cmake libdbus-1-dev libdbus-glib-1-dev libudev-dev libusb-1.0-0-dev libegl-dev libxkbcommon-x11-0 + run: apt_get_install cmake libdbus-1-dev libdbus-glib-1-dev libudev-dev libusb-1.0-0-dev libegl-dev libxkbcommon-x11-0 libxkbcommon-dev - name: Setup Python environment run: setup_python_env -c reqs/constraints.txt -r reqs/dist.txt -r reqs/dist_extra_gui_qt.txt -r reqs/test.txt @@ -718,6 +738,10 @@ jobs: path: .cache key: 0_${{ steps.set_cache.outputs.cache_name }}_${{ hashFiles('reqs/constraints.txt', 'reqs/packaging.txt', 'reqs/setup.txt') }} + # Required to install xkbcommon Python package. + - name: Install system dependencies + run: apt_get_install libxkbcommon-dev + - name: Setup Python environment run: setup_python_env -c reqs/constraints.txt -r reqs/packaging.txt -r reqs/setup.txt @@ -805,6 +829,10 @@ jobs: path: .cache key: 0_${{ steps.set_cache.outputs.cache_name }}_${{ hashFiles('reqs/constraints.txt', 'reqs/code_quality.txt') }} + # Required to install xkbcommon Python package. + - name: Install system dependencies + run: apt_get_install libxkbcommon-dev + - name: Setup Python environment run: setup_python_env -c reqs/constraints.txt -r reqs/code_quality.txt @@ -862,7 +890,7 @@ jobs: key: 0_${{ steps.set_cache.outputs.cache_name }}_${{ hashFiles('reqs/constraints.txt', 'reqs/build.txt', 'reqs/setup.txt', 'reqs/dist_*.txt', 'linux/appimage/deps.sh') }} - name: Install system dependencies - run: apt_get_install cmake libdbus-1-dev libdbus-glib-1-dev libudev-dev libusb-1.0-0-dev libegl-dev libxkbcommon-x11-0 + run: apt_get_install cmake libdbus-1-dev libdbus-glib-1-dev libudev-dev libusb-1.0-0-dev libegl-dev libxkbcommon-x11-0 libxkbcommon-dev - name: Setup Python environment run: setup_python_env -c reqs/constraints.txt -r reqs/build.txt -r reqs/setup.txt diff --git a/.github/workflows/ci/workflow_template.yml b/.github/workflows/ci/workflow_template.yml index ba9756543..4088ac49e 100644 --- a/.github/workflows/ci/workflow_template.yml +++ b/.github/workflows/ci/workflow_template.yml @@ -144,10 +144,17 @@ jobs: run: setup_osx_python '<@ j.python @>' <% endif %> - <% if j.type in ['build', 'test_gui_qt'] and j.os == 'Linux' %> + <% if j.os == 'Linux' %> + <% if j.type in ['build', 'test_gui_qt'] %> + - name: Install system dependencies + run: apt_get_install cmake libdbus-1-dev libdbus-glib-1-dev libudev-dev libusb-1.0-0-dev libegl-dev libxkbcommon-x11-0 libxkbcommon-dev + + <% else %> + # Required to install xkbcommon Python package. - name: Install system dependencies - run: apt_get_install cmake libdbus-1-dev libdbus-glib-1-dev libudev-dev libusb-1.0-0-dev libegl-dev libxkbcommon-x11-0 + run: apt_get_install libxkbcommon-dev + <% endif %> <% endif %> <% if j.type != 'notarize' %> - name: Setup Python environment diff --git a/linux/README.md b/linux/README.md index 17f90702b..e94faf491 100644 --- a/linux/README.md +++ b/linux/README.md @@ -8,5 +8,6 @@ distribution corresponding packages): - Treal support: `libusb` (1.0) and `libudev` are needed by the [`hidapi` package](https://pypi.org/project/hidapi/). - log / notifications support: `libdbus` is needed. +- Uinput support: `libxkbcommon` is needed by the [`xkbcommon` package](https://pypi.org/project/xkbcommon) For the rest of the steps, follow the [developer guide](../doc/developer_guide.md). diff --git a/news.d/feature/1807.linux.md b/news.d/feature/1807.linux.md new file mode 100644 index 000000000..a8920a5b1 --- /dev/null +++ b/news.d/feature/1807.linux.md @@ -0,0 +1 @@ +Support determining keyboard layout from Wayland with the `wayland-auto` layout. \ No newline at end of file diff --git a/plover/config.py b/plover/config.py index 611a23379..50417741c 100644 --- a/plover/config.py +++ b/plover/config.py @@ -428,7 +428,7 @@ def _set(self, section, option, value): ), choice_option( "keyboard_layout", - ("qwerty", "qwertz", "colemak", "colemak-dh", "dvorak"), + ("qwerty", "qwertz", "colemak", "colemak-dh", "dvorak", "wayland-auto"), OUTPUT_CONFIG_SECTION, ), # Logging. diff --git a/plover/gui_qt/config_window.py b/plover/gui_qt/config_window.py index 67cbf7355..1facb644c 100644 --- a/plover/gui_qt/config_window.py +++ b/plover/gui_qt/config_window.py @@ -521,6 +521,7 @@ def __init__(self, engine): "colemak": "colemak", "colemak-dh": "colemak-dh", "dvorak": "dvorak", + "wayland-auto": "wayland-auto", }, ), _( diff --git a/plover/oslayer/linux/keyboardcontrol_uinput.py b/plover/oslayer/linux/keyboardcontrol_uinput.py index 8a8a51a4c..eb933cb3f 100644 --- a/plover/oslayer/linux/keyboardcontrol_uinput.py +++ b/plover/oslayer/linux/keyboardcontrol_uinput.py @@ -1,3 +1,7 @@ +import threading +import os +import selectors + from evdev import ( UInput, ecodes as e, @@ -7,330 +11,28 @@ InputEvent, KeyEvent, ) -import threading -import os -import selectors - from psutil import process_iter +from plover.oslayer.linux.keyboardlayout_wayland import ( + DEFAULT_LAYOUT, + GET_WAYLAND_KEYMAP_TIMEOUT_SECONDS, + HANDLED_EV_KEYCODE_TO_KEY, + LAYOUTS, + WAYLAND_AUTO_LAYOUT_NAME, + KeyCodeInfo, + generate_plover_keymap_from_xkb_keymap_and_modifiers, + get_modifier_keycodes, + ev_keycode_to_xkb_keycode, + get_wayland_keymap, +) from plover.output.keyboard import GenericKeyboardEmulation from plover.machine.keyboard_capture import Capture from plover.key_combo import parse_key_combo from plover import log -# Shared keys between all layouts -BASE_LAYOUT = { - # Modifiers - "alt_l": e.KEY_LEFTALT, - "alt_r": e.KEY_RIGHTALT, - "alt": e.KEY_LEFTALT, - "ctrl_l": e.KEY_LEFTCTRL, - "ctrl_r": e.KEY_RIGHTCTRL, - "ctrl": e.KEY_LEFTCTRL, - "control_l": e.KEY_LEFTCTRL, - "control_r": e.KEY_RIGHTCTRL, - "control": e.KEY_LEFTCTRL, - "shift_l": e.KEY_LEFTSHIFT, - "shift_r": e.KEY_RIGHTSHIFT, - "shift": e.KEY_LEFTSHIFT, - "super_l": e.KEY_LEFTMETA, - "super_r": e.KEY_RIGHTMETA, - "super": e.KEY_LEFTMETA, - # Numbers - "1": e.KEY_1, - "2": e.KEY_2, - "3": e.KEY_3, - "4": e.KEY_4, - "5": e.KEY_5, - "6": e.KEY_6, - "7": e.KEY_7, - "8": e.KEY_8, - "9": e.KEY_9, - "0": e.KEY_0, - # Symbols - " ": e.KEY_SPACE, - "\b": e.KEY_BACKSPACE, - "\n": e.KEY_ENTER, - # https://github.com/openstenoproject/plover/blob/9b5a357f1fb57cb0a9a8596ae12cd1e84fcff6c4/plover/oslayer/osx/keyboardcontrol.py#L75 - # https://gist.github.com/jfortin42/68a1fcbf7738a1819eb4b2eef298f4f8 - "return": e.KEY_ENTER, - "tab": e.KEY_TAB, - "backspace": e.KEY_BACKSPACE, - "delete": e.KEY_DELETE, - "escape": e.KEY_ESC, - "clear": e.KEY_CLEAR, - "minus": e.KEY_MINUS, - "equal": e.KEY_EQUAL, - "bracketleft": e.KEY_LEFTBRACE, - "bracketright": e.KEY_RIGHTBRACE, - "backslash": e.KEY_BACKSLASH, - "semicolon": e.KEY_SEMICOLON, - "apostrophe": e.KEY_APOSTROPHE, - "comma": e.KEY_COMMA, - "dot": e.KEY_DOT, - "slash": e.KEY_SLASH, - "grave": e.KEY_GRAVE, - "-": e.KEY_MINUS, - "=": e.KEY_EQUAL, - "[": e.KEY_LEFTBRACE, - "]": e.KEY_RIGHTBRACE, - "\\": e.KEY_BACKSLASH, - ";": e.KEY_SEMICOLON, - "'": e.KEY_APOSTROPHE, - ",": e.KEY_COMMA, - ".": e.KEY_DOT, - "/": e.KEY_SLASH, - "`": e.KEY_GRAVE, - # Navigation - "up": e.KEY_UP, - "down": e.KEY_DOWN, - "left": e.KEY_LEFT, - "right": e.KEY_RIGHT, - "page_up": e.KEY_PAGEUP, - "page_down": e.KEY_PAGEDOWN, - "home": e.KEY_HOME, - "insert": e.KEY_INSERT, - "end": e.KEY_END, - "space": e.KEY_SPACE, - "print": e.KEY_PRINT, - # Function keys - "fn": e.KEY_FN, - "f1": e.KEY_F1, - "f2": e.KEY_F2, - "f3": e.KEY_F3, - "f4": e.KEY_F4, - "f5": e.KEY_F5, - "f6": e.KEY_F6, - "f7": e.KEY_F7, - "f8": e.KEY_F8, - "f9": e.KEY_F9, - "f10": e.KEY_F10, - "f11": e.KEY_F11, - "f12": e.KEY_F12, - "f13": e.KEY_F13, - "f14": e.KEY_F14, - "f15": e.KEY_F15, - "f16": e.KEY_F16, - "f17": e.KEY_F17, - "f18": e.KEY_F18, - "f19": e.KEY_F19, - "f20": e.KEY_F20, - "f21": e.KEY_F21, - "f22": e.KEY_F22, - "f23": e.KEY_F23, - "f24": e.KEY_F24, - # Numpad - "kp_1": e.KEY_KP1, - "kp_2": e.KEY_KP2, - "kp_3": e.KEY_KP3, - "kp_4": e.KEY_KP4, - "kp_5": e.KEY_KP5, - "kp_6": e.KEY_KP6, - "kp_7": e.KEY_KP7, - "kp_8": e.KEY_KP8, - "kp_9": e.KEY_KP9, - "kp_0": e.KEY_KP0, - "kp_add": e.KEY_KPPLUS, - "kp_decimal": e.KEY_KPDOT, - "kp_delete": e.KEY_DELETE, # There is no KPDELETE - "kp_divide": e.KEY_KPSLASH, - "kp_enter": e.KEY_KPENTER, - "kp_equal": e.KEY_KPEQUAL, - "kp_multiply": e.KEY_KPASTERISK, - "kp_subtract": e.KEY_KPMINUS, - # Media keys - "audioraisevolume": e.KEY_VOLUMEUP, - "audiolowervolume": e.KEY_VOLUMEDOWN, - "monbrightnessup": e.KEY_BRIGHTNESSUP, - "monbrightnessdown": e.KEY_BRIGHTNESSDOWN, - "audiomute": e.KEY_MUTE, - "num_lock": e.KEY_NUMLOCK, - "eject": e.KEY_EJECTCD, - "audiopause": e.KEY_PAUSE, - "audioplay": e.KEY_PLAY, - "audionext": e.KEY_NEXT, - "audiorewind": e.KEY_REWIND, - "kbdbrightnessup": e.KEY_KBDILLUMUP, - "kbdbrightnessdown": e.KEY_KBDILLUMDOWN, -} - -DEFAULT_LAYOUT = "qwerty" -LAYOUTS = { - # Only specify keys that differ from qwerty - "qwerty": { - **BASE_LAYOUT, - # Top row - "q": e.KEY_Q, - "w": e.KEY_W, - "e": e.KEY_E, - "r": e.KEY_R, - "t": e.KEY_T, - "y": e.KEY_Y, - "u": e.KEY_U, - "i": e.KEY_I, - "o": e.KEY_O, - "p": e.KEY_P, - # Middle row - "a": e.KEY_A, - "s": e.KEY_S, - "d": e.KEY_D, - "f": e.KEY_F, - "g": e.KEY_G, - "h": e.KEY_H, - "j": e.KEY_J, - "k": e.KEY_K, - "l": e.KEY_L, - # Bottom row - "z": e.KEY_Z, - "x": e.KEY_X, - "c": e.KEY_C, - "v": e.KEY_V, - "b": e.KEY_B, - "n": e.KEY_N, - "m": e.KEY_M, - }, - "qwertz": { - **BASE_LAYOUT, - # Top row - "q": e.KEY_Q, - "w": e.KEY_W, - "e": e.KEY_E, - "r": e.KEY_R, - "t": e.KEY_T, - "z": e.KEY_Y, - "u": e.KEY_U, - "i": e.KEY_I, - "o": e.KEY_O, - "p": e.KEY_P, - # Middle row - "a": e.KEY_A, - "s": e.KEY_S, - "d": e.KEY_D, - "f": e.KEY_F, - "g": e.KEY_G, - "h": e.KEY_H, - "j": e.KEY_J, - "k": e.KEY_K, - "l": e.KEY_L, - # Bottom row - "y": e.KEY_Z, - "x": e.KEY_X, - "c": e.KEY_C, - "v": e.KEY_V, - "b": e.KEY_B, - "n": e.KEY_N, - "m": e.KEY_M, - }, - "colemak": { - **BASE_LAYOUT, - # Top row - "q": e.KEY_Q, - "w": e.KEY_W, - "f": e.KEY_E, - "p": e.KEY_R, - "g": e.KEY_T, - "j": e.KEY_Y, - "l": e.KEY_U, - "u": e.KEY_I, - "y": e.KEY_O, - # Middle row - "a": e.KEY_A, - "r": e.KEY_S, - "s": e.KEY_D, - "t": e.KEY_F, - "d": e.KEY_G, - "h": e.KEY_H, - "n": e.KEY_J, - "e": e.KEY_K, - "i": e.KEY_L, - "o": e.KEY_SEMICOLON, - # Bottom row - "z": e.KEY_Z, - "x": e.KEY_X, - "c": e.KEY_C, - "v": e.KEY_V, - "b": e.KEY_B, - "k": e.KEY_N, - "m": e.KEY_M, - }, - "colemak-dh": { - **BASE_LAYOUT, - # Top row - "q": e.KEY_Q, - "w": e.KEY_W, - "f": e.KEY_E, - "p": e.KEY_R, - "b": e.KEY_T, - "j": e.KEY_Y, - "l": e.KEY_U, - "u": e.KEY_I, - "y": e.KEY_O, - # Middle row - "a": e.KEY_A, - "r": e.KEY_S, - "s": e.KEY_D, - "t": e.KEY_F, - "g": e.KEY_G, - "m": e.KEY_H, - "n": e.KEY_J, - "e": e.KEY_K, - "i": e.KEY_L, - "o": e.KEY_SEMICOLON, - # Bottom row - "z": e.KEY_BACKSLASH, # less than-key - "x": e.KEY_Z, - "c": e.KEY_X, - "d": e.KEY_C, - "v": e.KEY_V, - "k": e.KEY_N, - "h": e.KEY_M, - }, - "dvorak": { - **BASE_LAYOUT, - # Top row - "'": e.KEY_Q, - ",": e.KEY_W, - ".": e.KEY_E, - "p": e.KEY_R, - "y": e.KEY_T, - "f": e.KEY_Y, - "g": e.KEY_U, - "c": e.KEY_I, - "r": e.KEY_O, - "l": e.KEY_P, - "/": e.KEY_LEFTBRACE, - "=": e.KEY_RIGHTBRACE, - # Middle row - "a": e.KEY_A, - "o": e.KEY_S, - "e": e.KEY_D, - "u": e.KEY_F, - "i": e.KEY_G, - "d": e.KEY_H, - "h": e.KEY_J, - "t": e.KEY_K, - "n": e.KEY_L, - "s": e.KEY_SEMICOLON, - "-": e.KEY_APOSTROPHE, - # Bottom row - ";": e.KEY_Z, - "q": e.KEY_X, - "j": e.KEY_C, - "k": e.KEY_V, - "x": e.KEY_B, - "b": e.KEY_N, - "m": e.KEY_M, - "w": e.KEY_COMMA, - "v": e.KEY_DOT, - "z": e.KEY_SLASH, - }, -} - -KEYCODE_TO_KEY = dict( - zip(LAYOUTS[DEFAULT_LAYOUT].values(), LAYOUTS[DEFAULT_LAYOUT].keys()) -) - -MODIFIER_KEY_CODES: set[int] = { +# EV keycodes of keys considered modifiers when not able to automatically be +# determined from the keymap (this feature isn't implemented yet). +DEFAULT_MODIFIER_EV_KEYCODES: set[int] = { e.KEY_LEFTSHIFT, e.KEY_RIGHTSHIFT, e.KEY_LEFTCTRL, @@ -343,11 +45,15 @@ class KeyboardEmulation(GenericKeyboardEmulation): + # Map of Plover key name to EV keycode and modifiers + _key_to_keycodeinfo: dict[str, KeyCodeInfo] + _can_send_unicode: bool = True + def __init__(self): super().__init__() # Initialize UInput with all keys available - self._res = util.find_ecodes_by_regex(r"KEY_.*") - self._ui = UInput(self._res) + res = util.find_ecodes_by_regex(r"KEY_.*") + self._ui = UInput(res) # Check that ibus or fcitx5 is running if not any(p.name() in ["ibus-daemon", "fcitx5"] for p in process_iter()): @@ -355,21 +61,61 @@ def __init__(self): "It appears that an input method, such as ibus or fcitx5, is not running on your system. Without this, some text may not be output correctly." ) + self._key_to_keycodeinfo = {} + def _update_layout(self, layout): + if layout == WAYLAND_AUTO_LAYOUT_NAME: + try: + keymap = get_wayland_keymap(GET_WAYLAND_KEYMAP_TIMEOUT_SECONDS) + modifier_index_to_xkb_keycode = get_modifier_keycodes(keymap) + + self._key_to_keycodeinfo = ( + generate_plover_keymap_from_xkb_keymap_and_modifiers( + keymap, modifier_index_to_xkb_keycode + ) + ) + log.debug("Retrieved Wayland keymap: %s", self._key_to_keycodeinfo) + + # Verify that no modifier requires modifiers to be pressed in the generated keymap + modifier_xkb_keycodes = set( + keycode + for keycodes in modifier_index_to_xkb_keycode + for keycode in keycodes + ) + log.debug( + "Modifier index to keycode: %s", modifier_index_to_xkb_keycode + ) + for key_info in self._key_to_keycodeinfo.values(): + if ( + ev_keycode_to_xkb_keycode(key_info.keycode) + in modifier_xkb_keycodes + and len(key_info.modifiers) > 0 + ): + log.warning( + f"Modifier {key_info.keycode} in retrieved Wayland keymap has modifiers itself. Please report this issue." + ) + except Exception as e: + log.error( + f"Failed to get Wayland keymap: {e}. Using default layout {DEFAULT_LAYOUT}." + ) + self._key_to_keycodeinfo = LAYOUTS[DEFAULT_LAYOUT] + + self._can_send_unicode = self._verify_can_send_unicode_key_combo() + if not self._can_send_unicode: + log.warning( + "At least one key in Ctrl+Shift+U is not available in the current keymap. Unicode input will not be available for special characters not in the keymap." + ) + return + if layout not in LAYOUTS: log.warning(f"Layout {layout} not supported. Falling back to qwerty.") - self._KEY_TO_KEYCODE = LAYOUTS.get(layout, LAYOUTS[DEFAULT_LAYOUT]) + self._key_to_keycodeinfo = LAYOUTS.get(layout, LAYOUTS[DEFAULT_LAYOUT]) def _get_key(self, key): - """Helper function to get the keycode and potential shift key for uppercase.""" - if key in self._KEY_TO_KEYCODE: - return (self._KEY_TO_KEYCODE[key], []) - elif key.lower() in self._KEY_TO_KEYCODE: - # mods is a list for the potential of expanding it in the future to include altgr - return ( - self._KEY_TO_KEYCODE[key.lower()], - [self._KEY_TO_KEYCODE["shift_l"]], - ) + """Helper function to get the keycode and potential modifiers for a key.""" + key_map_info = self._key_to_keycodeinfo.get(key, None) + if key_map_info is not None: + return (key_map_info.keycode, key_map_info.modifiers) return (None, []) def _press_key(self, key, state): @@ -405,11 +151,28 @@ def _send_char(self, char): for mod in mods: self._press_key(mod, False) - # Key press can not be emulated - send unicode symbol instead - else: + # Key press can not be emulated - send unicode symbol instead. + elif self._can_send_unicode: + # This check is needed in case the keymap layout (somehow) doesn't have one of ctrl+shift+u mapped, which + # would cause infinite recursion trying to send one of those keys using the Unicode input. + # Convert to hex and remove leading "0x" unicode_hex = hex(ord(char))[2:] self._send_unicode(unicode_hex) + else: + log.warning( + "Cannot send unicode character '%s' - unicode input not available", char + ) + + def _verify_can_send_unicode_key_combo(self) -> bool: + """Make sure the Unicode starter key combo is mapped (ctrl+shift+u).""" + if not self._get_key("control_l")[0]: + return False + if not self._get_key("shift")[0]: + return False + if not self._get_key("u")[0]: + return False + return True def send_string(self, string): for key in self.with_delay(list(string)): @@ -438,6 +201,8 @@ class KeyboardCapture(Capture): # Pipes to signal `_run` thread to stop _device_thread_read_pipe: int | None _device_thread_write_pipe: int | None + # EV keycodes of modifier keys + _modifier_ev_keycodes: set[int] def __init__(self): super().__init__() @@ -448,10 +213,9 @@ def __init__(self): self._device_thread_read_pipe = None self._device_thread_write_pipe = None - self._res = util.find_ecodes_by_regex(r"KEY_.*") - self._ui = UInput(self._res) + res = util.find_ecodes_by_regex(r"KEY_.*") + self._ui = UInput(res) self._suppressed_keys = set() - # The keycodes from evdev, e.g. e.KEY_A refers to the *physical* a, which corresponds with the qwerty layout. def _get_devices(self): input_devices = [InputDevice(path) for path in list_devices()] @@ -548,9 +312,9 @@ def _run(self): keys_pressed_with_modifier: set[int] = set() down_modifier_keys: set[int] = set() - def _process_key_event(event: InputEvent) -> tuple[str | None, bool]: + def _parse_key_event(event: InputEvent) -> tuple[str | None, bool]: """ - Processes an InputEvent to determine which key Plover should receive + Determine which key Plover should receive due to this event and whether the event should be suppressed. Considers pressed modifiers and Plover's suppressed keys. Returns a tuple of (key_to_send_to_plover, suppress) @@ -558,15 +322,15 @@ def _process_key_event(event: InputEvent) -> tuple[str | None, bool]: if not self._suppressed_keys: # No keys are suppressed # Always send to Plover so that it can handle global shortcuts like PLOVER_TOGGLE (PHROLG) - return KEYCODE_TO_KEY.get(event.code, None), False - if event.code in MODIFIER_KEY_CODES: + return HANDLED_EV_KEYCODE_TO_KEY.get(event.code, None), False + if event.code in DEFAULT_MODIFIER_EV_KEYCODES: # Can't use if-else because there is a third case: key_hold if event.value == KeyEvent.key_down: down_modifier_keys.add(event.code) elif event.value == KeyEvent.key_up: down_modifier_keys.discard(event.code) return None, False - key = KEYCODE_TO_KEY.get(event.code, None) + key = HANDLED_EV_KEYCODE_TO_KEY.get(event.code, None) if key is None: # Key is unhandled. Passthrough return None, False @@ -595,7 +359,7 @@ def _process_key_event(event: InputEvent) -> tuple[str | None, bool]: device: InputDevice = key.fileobj for event in device.read(): if event.type == e.EV_KEY: - key_to_send_to_plover, suppress = _process_key_event(event) + key_to_send_to_plover, suppress = _parse_key_event(event) if key_to_send_to_plover is not None: # Always send keys to Plover when no keys suppressed. # This is required for global shortcuts like diff --git a/plover/oslayer/linux/keyboardlayout_wayland.py b/plover/oslayer/linux/keyboardlayout_wayland.py new file mode 100644 index 000000000..b8bf8d50b --- /dev/null +++ b/plover/oslayer/linux/keyboardlayout_wayland.py @@ -0,0 +1,403 @@ +from dataclasses import dataclass +from typing import Sequence +import string +import contextlib +import mmap +import os +import threading + +from xkbcommon import xkb +from evdev import ecodes as e, util + +from plover.oslayer.linux.wayland_connection import ( + WaylandConnection, + wayland_keymap_event_loop, +) + + +@dataclass +class KeyCodeInfo: + keycode: int + # Other keycodes that must be held down with the keycode to send this key + modifiers: Sequence[int] = () + + +# Difference between xkbcommon keycodes and Linux EV keycodes. +# Subtract this value from xkbcommon keycodes to get Linux EV keycodes. +XKB_TO_EV_KEYCODE_OFFSET = 8 + +VALID_EV_KEYCODES: set[int] = set(util.find_ecodes_by_regex(r"KEY_.*")[1]) + +WAYLAND_AUTO_LAYOUT_NAME = "wayland-auto" + +GET_WAYLAND_KEYMAP_TIMEOUT_SECONDS = 5 + +# Additional aliases for xkbcommon keysyms to match names in Plover dictionaries. +# Keys beginning with "XF86" are handled as a special case during xkbcommon keymap processing. +# For each xkbcommon keysym, the lowercase of the symbol name is already added to the keymap by the code. +XKB_KEY_NAME_TO_ALIASES: dict[str, list[str]] = { + "Return": ["\n"], + "Control_L": ["ctrl", "ctrl_l"], + "Shift_L": ["shift"], + "Super_L": ["super", "windows", "command"], + "Alt_L": ["alt", "option"], + "Tab": ["\t"], + "Next": ["page_down"], + "Prior": ["page_up"], + "KP_Home": ["kp_7"], + "KP_Up": ["kp_8"], + "KP_Prior": ["kp_9"], + "KP_Left": ["kp_4"], + "KP_Begin": ["kp_5"], + "KP_Right": ["kp_6"], + "KP_End": ["kp_1"], + "KP_Down": ["kp_2"], + "KP_Next": ["kp_3"], + "KP_Insert": ["kp_0"], + "KP_Delete": ["kp_dot", "kp_decimal"], +} + + +def xkb_keycode_to_ev_keycode(keycode: int) -> int: + return keycode - XKB_TO_EV_KEYCODE_OFFSET + + +def ev_keycode_to_xkb_keycode(keycode: int) -> int: + return keycode + XKB_TO_EV_KEYCODE_OFFSET + + +def get_modifier_keycodes(keymap: xkb.Keymap) -> list[list[int]]: + """ + Returns a list of xkbcommon keycodes for each non-latched and non-locked modifier + in order of the modifier's index. + `result[i]` is the list of keycodes for the modifier with index `i`. + + Don't consider latched or locked modifiers (e.g. NumLock) + because Plover doesn't need to handle those for key combos. + """ + num_mods = keymap.num_mods() + modifier_index_to_keycodes: list[list[int]] = [[] for _ in range(num_mods)] + + for keycode in keymap: + if xkb_keycode_to_ev_keycode(keycode) not in VALID_EV_KEYCODES: + # Ignore keys that can't be sent or received by evdev + continue + # Simulate pressing the key + keyboard_state = xkb.KeyboardState(keymap) + key_state = keyboard_state.update_key(keycode, xkb.KeyDirection.XKB_KEY_DOWN) + # Check if pressing the key depresses a modifier + is_key_mod = (key_state & xkb.StateComponent.XKB_STATE_MODS_DEPRESSED) and not ( + (key_state & xkb.StateComponent.XKB_STATE_MODS_LOCKED) + or (key_state & xkb.StateComponent.XKB_STATE_MODS_LATCHED) + ) + if not is_key_mod: + continue + + num_layouts = keymap.num_layouts_for_key(keycode) + + for layout in range(0, num_layouts): + layout_is_active = keyboard_state.layout_index_is_active( + layout, xkb.StateComponent.XKB_STATE_LAYOUT_EFFECTIVE + ) + + if not layout_is_active: + continue + + for mod_index in range(num_mods): + is_mod_active = keyboard_state.mod_index_is_active( + mod_index, xkb.StateComponent.XKB_STATE_MODS_DEPRESSED + ) + if not is_mod_active: + continue + + modifier_index_to_keycodes[mod_index].append(keycode) + # Only consider the first active layout + break + + return modifier_index_to_keycodes + + +@contextlib.contextmanager +def fd_context(fd: int): + try: + yield fd + finally: + os.close(fd) + + +def get_wayland_keymap(timeout: float) -> xkb.Keymap: + """Get the current keymap from the default Wayland server""" + with WaylandConnection() as connection: + done = False + + def timeout_thread_function(): + import time + + time.sleep(timeout) + if not done: + connection.shutdown() + + timeout_thread = threading.Thread(target=timeout_thread_function) + timeout_thread.start() + + try: + keymap_fd, keymap_size = wayland_keymap_event_loop(connection) + done = True + except InterruptedError: + raise TimeoutError("Timeout retrieving keymap from Wayland") + with ( + fd_context(keymap_fd) as keymap_fd, + mmap.mmap( + keymap_fd, keymap_size, flags=mmap.MAP_PRIVATE, prot=mmap.PROT_READ + ) as keymap_file, + ): + xkb_context = xkb.Context() + return xkb_context.keymap_new_from_file(keymap_file) + + +def generate_plover_keymap_from_xkb_keymap_and_modifiers( + keymap: xkb.Keymap, modifier_index_to_xkb_keycode: list[list[int]] +) -> dict[str, KeyCodeInfo]: + """ + Generate a mapping of Plover key names (key names used in Plover dictionary entries) to `KeyCodeInfo`. + `modifier_index_to_keycode` should be the result of `get_modifier_keycodes`. + This is a parameter to avoid recomputing the modifier keycodes if they are needed multiple times. + """ + plover_key_to_keycode: dict[str, KeyCodeInfo] = {} + + layout_index = 0 + for xkb_keycode in iter(keymap): + try: + if xkb_keycode_to_ev_keycode(xkb_keycode) not in VALID_EV_KEYCODES: + # Ignore keys that can't be sent or received by evdev. + continue + # Levels are different outputs from the same key with different modifiers pressed. + level_count = keymap.num_levels_for_key(xkb_keycode, layout_index) + + for level in range(level_count): + key_syms_for_level = keymap.key_get_syms_by_level( + xkb_keycode, layout_index, level + ) + for key_sym in key_syms_for_level: + add_xkb_keysym_to_plover_keymap( + xkb_keycode, + key_sym, + level, + keymap, + plover_key_to_keycode, + layout_index, + modifier_index_to_xkb_keycode, + ) + + except xkb.XKBInvalidKeycode: + # Iter *should* return only valid, but still returns some invalid... + pass + + # The "Linefeed" symbol (xkb symbol 0xff0a) has the key string "\n". + # If Linefeed appears before the enter/return key when iterating over keys in the keymap (which is the case for qwerty), "\n" will be mapped to Linefeed rather than enter. + # Ensures that "\n" is mapped to the enter/return key instead of Linefeed. + if "return" in plover_key_to_keycode: + plover_key_to_keycode["\n"] = plover_key_to_keycode["return"] + + return plover_key_to_keycode + + +def generate_plover_keymap_from_xkb_keymap( + keymap: xkb.Keymap, +) -> dict[str, KeyCodeInfo]: + """ + Wrapper around `generate_plover_keymap_from_xkb_keymap_and_modifiers` that computes the modifiers for you. + """ + modifier_index_to_keycode = get_modifier_keycodes(keymap) + return generate_plover_keymap_from_xkb_keymap_and_modifiers( + keymap, modifier_index_to_keycode + ) + + +def add_xkb_keysym_to_plover_keymap( + xkb_keycode: int, + xkb_keysym: int, + level: int, + keymap: xkb.Keymap, + plover_key_to_keycode: dict[str, KeyCodeInfo], + layout_index: int, + modifier_index_to_keycode: list[list[int]], +): + keysym_name = xkb.keysym_get_name(xkb_keysym) + keysym_string = xkb.keysym_to_string(xkb_keysym) + + key_modifiers = get_modifiers_for_key_sym( + keymap, xkb_keycode, layout_index, level, modifier_index_to_keycode + ) + + if keysym_string is not None and keysym_string not in plover_key_to_keycode: + # Because we iterate levels in order, the lowest level and thus simplest set of modifiers for each symbol is added first. + # If multiple keys produce the same symbol, only add the first key in iteration order. Same for level_key_name and aliases below. + plover_key_to_keycode[keysym_string] = KeyCodeInfo( + xkb_keycode_to_ev_keycode(xkb_keycode), key_modifiers + ) + + for key_alias in XKB_KEY_NAME_TO_ALIASES.get(keysym_name, []): + if key_alias not in plover_key_to_keycode: + plover_key_to_keycode[key_alias] = KeyCodeInfo( + xkb_keycode_to_ev_keycode(xkb_keycode), key_modifiers + ) + + if keysym_name.startswith("XF86"): + plover_key_name = keysym_name[4:].lower() + # Add alias with "xf86" for keys starting with "XF86" to be consistent with X11 Plover. + if plover_key_name not in plover_key_to_keycode: + plover_key_to_keycode[plover_key_name] = KeyCodeInfo( + xkb_keycode_to_ev_keycode(xkb_keycode), key_modifiers + ) + + level_key_name_lower = keysym_name.lower() + if level_key_name_lower not in plover_key_to_keycode: + plover_key_to_keycode[level_key_name_lower] = KeyCodeInfo( + xkb_keycode_to_ev_keycode(xkb_keycode), key_modifiers + ) + + +def get_modifiers_for_key_sym( + keymap: xkb.Keymap, + xkb_keycode: int, + layout_index: int, + level: int, + modifier_index_to_keycode: list[list[int]], +) -> list[int]: + """Get one set of modifiers that are pressed to obtain the given key and level. + If multiple sets of modifiers produce the same key and level, an arbitrary one is returned.""" + modifier_masks_for_level = keymap.key_get_mods_for_level( + xkb_keycode, layout_index, level + ) + key_modifiers: list[int] = [] + # Identify sets of modifiers pressed to obtain the given key and level. + # Each `mask` is a bitfield of modifiers pressed. + for mask in modifier_masks_for_level: + modifier_index = 0 + while mask > 0: + if mask & 1: + modifier_keycodes = modifier_index_to_keycode[modifier_index] + if not modifier_keycodes: + # Invalid modifier index. Try the next mask. + key_modifiers.clear() + break + key_modifiers.append(xkb_keycode_to_ev_keycode(modifier_keycodes[0])) + mask >>= 1 + modifier_index += 1 + else: + # Iterated through all modifiers in a mask and found a valid set. + break + return key_modifiers + + +_context = xkb.Context() + +LAYOUTS = { + "qwerty": generate_plover_keymap_from_xkb_keymap( + _context.keymap_new_from_names(layout="us") + ), + "qwertz": generate_plover_keymap_from_xkb_keymap( + _context.keymap_new_from_names(layout="de") + ), + "dvorak": generate_plover_keymap_from_xkb_keymap( + _context.keymap_new_from_names(layout="us", variant="dvorak") + ), + "colemak": generate_plover_keymap_from_xkb_keymap( + _context.keymap_new_from_names(layout="us", variant="colemak") + ), + "colemak-dh": generate_plover_keymap_from_xkb_keymap( + _context.keymap_new_from_names(layout="us", variant="colemak_dh") + ), +} + +del _context + +DEFAULT_LAYOUT = "qwerty" +assert DEFAULT_LAYOUT in LAYOUTS, "Default layout not in defined layouts" + +# Linux EV keycode to Plover key name. Determines which keys can be handled and will be suppressed. +# Many key names are different from xkbcommon, so it's easier to define manually. +HANDLED_EV_KEYCODE_TO_KEY = { + e.KEY_F1: "F1", + e.KEY_F2: "F2", + e.KEY_F3: "F3", + e.KEY_F4: "F4", + e.KEY_F5: "F5", + e.KEY_F6: "F6", + e.KEY_F7: "F7", + e.KEY_F8: "F8", + e.KEY_F9: "F9", + e.KEY_F10: "F10", + e.KEY_F11: "F11", + e.KEY_F12: "F12", + e.KEY_GRAVE: "`", + e.KEY_0: "0", + e.KEY_1: "1", + e.KEY_2: "2", + e.KEY_3: "3", + e.KEY_4: "4", + e.KEY_5: "5", + e.KEY_6: "6", + e.KEY_7: "7", + e.KEY_8: "8", + e.KEY_9: "9", + e.KEY_MINUS: "-", + e.KEY_EQUAL: "=", + e.KEY_Q: "q", + e.KEY_W: "w", + e.KEY_E: "e", + e.KEY_R: "r", + e.KEY_T: "t", + e.KEY_Y: "y", + e.KEY_U: "u", + e.KEY_I: "i", + e.KEY_O: "o", + e.KEY_P: "p", + e.KEY_LEFTBRACE: "[", + e.KEY_RIGHTBRACE: "]", + e.KEY_BACKSLASH: "\\", + e.KEY_A: "a", + e.KEY_S: "s", + e.KEY_D: "d", + e.KEY_F: "f", + e.KEY_G: "g", + e.KEY_H: "h", + e.KEY_J: "j", + e.KEY_K: "k", + e.KEY_L: "l", + e.KEY_SEMICOLON: ";", + e.KEY_APOSTROPHE: "'", + e.KEY_Z: "z", + e.KEY_X: "x", + e.KEY_C: "c", + e.KEY_V: "v", + e.KEY_B: "b", + e.KEY_N: "n", + e.KEY_M: "m", + e.KEY_COMMA: ",", + e.KEY_DOT: ".", + e.KEY_SLASH: "/", + e.KEY_SPACE: "space", + e.KEY_BACKSPACE: "BackSpace", + e.KEY_DELETE: "Delete", + e.KEY_DOWN: "Down", + e.KEY_END: "End", + e.KEY_ESC: "Escape", + e.KEY_HOME: "Home", + e.KEY_LEFT: "Left", + e.KEY_PAGEDOWN: "Page_Down", + e.KEY_PAGEUP: "Page_Up", + e.KEY_ENTER: "Return", + e.KEY_RIGHT: "Right", + e.KEY_TAB: "Tab", + e.KEY_UP: "Up", +} + +# Make sure no keys missing. The last 3 are "\r\x0b\x0c" which don't need to be mapped. +assert all(c in LAYOUTS[DEFAULT_LAYOUT].keys() for c in string.printable[:-3]) + +if __name__ == "__main__": + xkb_keymap = get_wayland_keymap(GET_WAYLAND_KEYMAP_TIMEOUT_SECONDS) + plover_keymap = generate_plover_keymap_from_xkb_keymap(xkb_keymap) + print("Plover keymap", plover_keymap) diff --git a/plover/oslayer/linux/wayland_connection.py b/plover/oslayer/linux/wayland_connection.py new file mode 100644 index 000000000..08369d95c --- /dev/null +++ b/plover/oslayer/linux/wayland_connection.py @@ -0,0 +1,293 @@ +import array +import collections +import os +import selectors +import socket +import struct + +from plover import log + +WAYLAND_MESSAGE_HEADER_SIZE_BYTES = 8 + +# Wayland object IDs +DISPLAY_ID = 1 +REGISTRY_ID = 2 +SYNC_ID = 3 +SEAT_ID = 4 +KEYBOARD_ID = 5 + +# Wayland Opcodes +OPCODE_WL_DISPLAY_SYNC = 0 +OPCODE_WL_DISPLAY_GET_REGISTRY = 1 +OPCODE_WL_CALLBACK_DONE = 0 +OPCODE_WL_REGISTRY_GLOBAL = 0 +OPCODE_WL_REGISTRY_BIND = 0 +OPCODE_WL_SEAT_CAPABILITIES = 0 +OPCODE_WL_DISPLAY_ERROR = 0 +OPCODE_WL_DISPLAY_DELETE_ID = 1 +OPCODE_WL_KEYBOARD_KEYMAP = 0 + +WL_KEYBOARD_KEYMAP_FORMAT_XKB_V1 = 1 + + +def round_up_power_of_two(value: int, multiple: int): + """Round `value` up to the nearest multiple of `multiple`. + `multiple` must be positive and a power of 2""" + assert (multiple > 0) and ((multiple & (multiple - 1)) == 0), ( + "Multiple must be positive and a power of two" + ) + return (value + multiple - 1) & ~(multiple - 1) + + +class WaylandConnection: + """Context manager for connecting to the Wayland server on the default socket path. + + Useful references: + - https://wayland-book.com/ + - https://wayland.freedesktop.org/docs/html/ch04.html#sect-Protocol-Wire-Format + - https://wayland.app/protocols/wayland + """ + + fd_queue: collections.deque[int] + _wayland_socket: socket.socket + _shutdown_pipe_read: int + _shutdown_pipe_write: int + _selector: selectors.BaseSelector + + def __init__(self): + self.fd_queue = collections.deque() + self._shutdown_pipe_read, self._shutdown_pipe_write = os.pipe() + self._selector = selectors.DefaultSelector() + + def __enter__(self): + # Find socket path following libwayland (https://wayland-book.com/protocol-design/wire-protocol.html#transports) + xdg_runtime_dir = os.environ.get("XDG_RUNTIME_DIR", "") + wayland_display = os.environ.get("WAYLAND_DISPLAY", "wayland-0") + socket_path = os.path.join(xdg_runtime_dir, wayland_display) + + self._wayland_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self._wayland_socket.connect(socket_path) + + self._selector.register(self._wayland_socket, selectors.EVENT_READ) + self._selector.register(self._shutdown_pipe_read, selectors.EVENT_READ) + + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + try: + self._selector.close() + finally: + self._wayland_socket.shutdown(socket.SHUT_RDWR) + self._wayland_socket.close() + os.close(self._shutdown_pipe_read) + os.close(self._shutdown_pipe_write) + + def recv_message(self) -> tuple[int, int, int, bytearray]: + """Receive an event from the Wayland server. Blocks until a complete message is received. + + Returns: + A tuple of (object_id, length, opcode, event_data_bytes) + + length includes the message header. + """ + # The only event with fds that we care about is wl_keyboard::keymap which only has one fd + # In each message, we only need to receive at most one fd + # TODO: Unless messages with more delay when we received the keymap event fds? + MAX_FD_COUNT = 1 + event_header_bytes, fds = self._recv_fds_exact( + WAYLAND_MESSAGE_HEADER_SIZE_BYTES, MAX_FD_COUNT + ) + self.fd_queue.extend(fds) + object_id, length_and_opcode = struct.unpack("=II", event_header_bytes) + length = length_and_opcode >> 16 + assert length % 4 == 0, "Length of message must be a multiple of 4." + opcode = length_and_opcode & 0xFFFF + event_data_bytes, fds = self._recv_fds_exact( + length - WAYLAND_MESSAGE_HEADER_SIZE_BYTES, MAX_FD_COUNT + ) + self.fd_queue.extend(fds) + return object_id, length, opcode, event_data_bytes + + def send_message(self, object_id: int, opcode: int, data: bytes | bytearray): + """Send a request to the Wayland server. + + Args: + object_id: The ID of the object to send the request to. + opcode: The opcode of the request. + data: The data to send with the request. + """ + length = WAYLAND_MESSAGE_HEADER_SIZE_BYTES + len(data) + # Wayland messages are streams of 32-bit (4 byte) values + assert length % 4 == 0, "Length of message must be a multiple of 4." + # The length field is a 16-bit unsigned integer (the upper 16 bits of the 32-bit value) + assert length < 2**16, "Length of message must be less than 2^16." + length_and_opcode = (length << 16) | opcode + message = struct.pack("=II", object_id, length_and_opcode) + self._wayland_socket.sendall(message) + self._wayland_socket.sendall(data) + + def shutdown(self): + """Signal the Wayland connection to close and for the event loop to exit.""" + os.write(self._shutdown_pipe_write, b"\x00") + + def _recv_fds_exact(self, length: int, fd_count: int): + """Receive exactly `length` bytes from the Wayland server and up to `fd_count` file descriptors. + + Returns: + A tuple of (data bytes received, fds received) + Raises: + InterruptedError: if the connection is shut down using `WaylandConnection.shutdown()`. + """ + fds = array.array("i") + buffer = bytearray(length) + buffer_view = memoryview(buffer) + + if length < 0: + raise ValueError("Length must be non-negative.") + if fd_count < 0: + raise ValueError("FD count must be non-negative.") + + while length > 0: + for key, _ in self._selector.select(): + if key.fileobj == self._shutdown_pipe_read: + raise InterruptedError() + # Based on Python3 socket.recvmsg docs (https://docs.python.org/3/library/socket.html#socket.socket.recvmsg) + n, ancdata, flags, addr = self._wayland_socket.recvmsg_into( + [buffer_view], socket.CMSG_LEN(fd_count * fds.itemsize) + ) + for cmsg_level, cmsg_type, cmsg_data in ancdata: + if ( + cmsg_level == socket.SOL_SOCKET + and cmsg_type == socket.SCM_RIGHTS + ): + # Append data, ignoring any truncated integers at the end. + fds.frombytes( + cmsg_data[ + : len(cmsg_data) - (len(cmsg_data) % fds.itemsize) + ] + ) + # Advance write position in buffer + buffer_view = buffer_view[n:] + length -= n + + fds = list(fds) + + return buffer, fds + + +def wayland_keymap_event_loop(connection: WaylandConnection) -> tuple[int, int]: + """Get the keymap from the Wayland server. + See https://wayland.app/protocols/wayland for the opcodes and arguments + + Returns a tuple of (keymap_fd, keymap_size) as returned by the Wayland server. + """ + # wl_display::get_registry + # display id: DISPLAY_ID + # opcode: 1 + # new id for registry: REGISTRY_ID + connection.send_message( + DISPLAY_ID, OPCODE_WL_DISPLAY_GET_REGISTRY, struct.pack("=I", REGISTRY_ID) + ) + + # wl_display::sync + # display id: DISPLAY_ID + # opcode: 0 + # new_id for callback: SYNC_ID + connection.send_message( + DISPLAY_ID, OPCODE_WL_DISPLAY_SYNC, struct.pack("=I", SYNC_ID) + ) + + # Read all wl_display::get_registry events + while True: + object_id, length, opcode, event_data_bytes = connection.recv_message() + if object_id == SYNC_ID: + if opcode != OPCODE_WL_CALLBACK_DONE: + raise RuntimeError(f"Expected wl_callback::done opcode 0, got {opcode}") + break + elif object_id == REGISTRY_ID and opcode == OPCODE_WL_REGISTRY_GLOBAL: + # wl_registry::global + name, interface_length = struct.unpack("=II", event_data_bytes[:8]) + # -1 to skip null terminator + interface = event_data_bytes[8 : 8 + interface_length - 1].decode("utf-8") + version_start_index = round_up_power_of_two(8 + interface_length, 4) + version = struct.unpack( + "=I", event_data_bytes[version_start_index : version_start_index + 4] + )[0] + log.debug( + "Global: name=%d, interface=%s, version=%d", name, interface, version + ) + + if interface == "wl_seat": + seat_name = event_data_bytes + # Bind to seat using wl_registry::bind + # opcode 0 + # the new_id arg follows custom serialization rules (interface name, version, id). See the representation of new_id in https://wayland.freedesktop.org/docs/html/ch04.html#sect-Protocol-Wire-Format + # new id for seat: SEAT_ID + data = seat_name + struct.pack("=I", SEAT_ID) + connection.send_message(REGISTRY_ID, OPCODE_WL_REGISTRY_BIND, data) + else: + log.debug("Ignoring event for object %d, opcode %d", object_id, opcode) + + # Read wl_seat events + has_keyboard = False + while True: + object_id, length, opcode, event_data_bytes = connection.recv_message() + if object_id == SEAT_ID and opcode == OPCODE_WL_SEAT_CAPABILITIES: + # wl_seat::capabilities + if length != WAYLAND_MESSAGE_HEADER_SIZE_BYTES + 4: + raise RuntimeError( + f"Expected wl_seat::capabilities message to be {WAYLAND_MESSAGE_HEADER_SIZE_BYTES + 4} bytes, got {length}" + ) + + capabilities = struct.unpack("=I", event_data_bytes[:4])[0] + log.debug("wl_seat capabilities: %s", capabilities) + has_keyboard = capabilities & 2 + break + elif object_id == DISPLAY_ID and opcode == OPCODE_WL_DISPLAY_ERROR: + # wl_display::error + raise RuntimeError(f"Wayland error: {repr(event_data_bytes)}") + elif object_id == DISPLAY_ID and opcode == OPCODE_WL_DISPLAY_DELETE_ID: + # wl_display::delete_id + if length != WAYLAND_MESSAGE_HEADER_SIZE_BYTES + 4: + raise RuntimeError( + f"Expected wl_display::delete_id message to be {WAYLAND_MESSAGE_HEADER_SIZE_BYTES + 4} bytes, got {length}" + ) + id_num = struct.unpack("=I", event_data_bytes)[0] + if id_num == SEAT_ID: + raise RuntimeError("wl_seat was destroyed unexpectedly") + elif id_num == KEYBOARD_ID: + raise RuntimeError("wl_keyboard was destroyed unexpectedly") + else: + log.debug("Ignoring event for object %d, opcode %d", object_id, opcode) + + if not has_keyboard: + raise RuntimeError("Wayland seat has no keyboard") + + # wl_seat::get_keyboard + connection.send_message(SEAT_ID, 1, struct.pack("=I", KEYBOARD_ID)) + + # Wait for and process wl_keyboard::keymap + while True: + object_id, length, opcode, event_data_bytes = connection.recv_message() + if object_id == KEYBOARD_ID and opcode == OPCODE_WL_KEYBOARD_KEYMAP: + # wl_keyboard::keymap + if length != WAYLAND_MESSAGE_HEADER_SIZE_BYTES + 8: + raise RuntimeError( + f"Expected wl_keyboard::keymap message to be {WAYLAND_MESSAGE_HEADER_SIZE_BYTES + 8} bytes, got {length}" + ) + + keymap_format, keymap_size = struct.unpack("=II", event_data_bytes) + if keymap_format != WL_KEYBOARD_KEYMAP_FORMAT_XKB_V1: + raise RuntimeError(f"Unsupported keymap format: {keymap_format}") + + try: + fd = connection.fd_queue.popleft() + except IndexError: + raise RuntimeError("No keymap fd received") + + return fd, keymap_size + elif object_id == DISPLAY_ID and opcode == OPCODE_WL_DISPLAY_ERROR: + # wl_display::error + raise RuntimeError(f"Wayland error: {repr(event_data_bytes)}") + else: + log.debug("Ignoring event for object %d, opcode %d", object_id, opcode) diff --git a/reqs/dist.txt b/reqs/dist.txt index d0a44a42b..9502db8d4 100644 --- a/reqs/dist.txt +++ b/reqs/dist.txt @@ -13,6 +13,7 @@ python-xlib; ("linux" in sys_platform or "bsd" in sys_platform) evdev; "linux" in sys_platform or "bsd" in sys_platform packaging psutil; "linux" in sys_platform or "bsd" in sys_platform +xkbcommon<1.1; "linux" in sys_platform or "bsd" in sys_platform readme-renderer[md] requests-cache requests-futures