5 Recipes
dxtr edited this page 2026-10-12 01:27:12 +02:00

Recipes

Each recipe assumes

(defpackage #:my-app
  (:use #:cl)
  (:local-nicknames (#:xkb #:cl-xkbcommon)))
(in-package #:my-app)

Text input

key-press-text gives the text a key press types, with dead keys and compose sequences handled:

(xkb:key-press-text state compose keycode)

Call it for presses only, never for releases. With update-key, call it before updating the state for the same press. See Compose and Dead Keys.

key-press-text returns control characters too: Control+C on a US layout types (code-char 3). A text field usually ignores characters below space and DEL, and keys pressed with Control, Alt or Super, and handles them as shortcuts instead:

(defun typed-text (state compose keycode)
  "Return the text a press of KEYCODE should insert into a text field, or NIL."
  (let ((text (xkb:key-press-text state compose keycode)))
    (and text
         (notany (lambda (modifier) (member modifier '(:control :alt :super)))
                 (xkb:active-modifiers state))
         (notany (lambda (character) (or (char< character #\Space) (char= character #\Rubout)))
                 text)
         text)))

Keyboard shortcuts

key-shortcut returns what to match a shortcut on: the keysym of the key, and the modifiers held for it.

(xkb:key-shortcut state keycode)   ; => keysym, modifiers

The modifiers leave out those the key consumed, and the locks. On a US layout:

Keys held key-shortcut of A
Control a, (:control)
Control, Shift A, (:control)
Alt a, (:alt)
Control, with Caps Lock on a, (:control)
Control, with Num Lock on a, (:control)

Shift is part of the keysym, so Control+Shift+S is Control+S. The keysym is read as if Caps Lock were off, so with Caps Lock on, Control+S is still Control+s and not Control+Shift+S. Num Lock still decides what keypad keys produce, KP_1 or KP_End.

Write bindings with the character the key types, through character-keysym, and with keysym names through find-keysym for keys that type none:

(defparameter *bindings*
  (list (list (xkb:character-keysym #\s) '(:control) 'save)
        (list (xkb:character-keysym #\S) '(:control) 'save-as)  ; Control+Shift+S
        (list (xkb:character-keysym #\[) '(:control) 'outdent)
        (list (xkb:find-keysym "F1") '() 'help)))

(defun find-binding (state keycode)
  (multiple-value-bind (keysym modifiers) (xkb:key-shortcut state keycode)
    (third (find-if (lambda (binding)
                      (and (eql keysym (first binding))
                           (null (set-exclusive-or modifiers (second binding)))))
                    *bindings*))))

The keysym depends on the active layout. On a Russian layout Control+S produces Cyrillic_yeru, which matches no binding written with Latin letters. Applications that need layout-independent shortcuts match on key names, from key-name, instead.

Wayland clients

A Wayland compositor does most of the work: it sends the keymap as text, tracks modifiers, and sends the masks. This example uses cl-wl, with its core protocol package cl-wl/wayland used and the nickname wl for cl-wl. Any Wayland binding gives the same events.

(defvar *context* (xkb:make-context))
(defvar *compose-table*                 ; NIL if the locale has none
  (xkb:make-compose-table *context* :errorp nil))

(defclass keyboard ()
  ((state :initform nil :accessor keyboard-state :type (or null xkb:keyboard-state))
   (compose :initform (and *compose-table* (xkb:make-compose-state *compose-table*))
            :reader keyboard-compose :type (or null xkb:compose-state)))
  (:documentation "One wl_keyboard's keyboard state and compose state."))

(defun keyboard-keymap-changed (keyboard text)
  "Make KEYBOARD follow the keymap TEXT, and destroy the one it followed."
  (let ((old (keyboard-state keyboard)))
    (setf (keyboard-state keyboard)
          (xkb:make-keyboard-state (xkb:make-keymap-from-string *context* text)))
    (when old
      (let ((keymap (xkb:keyboard-state-keymap old)))
        (xkb:destroy-keyboard-state old)
        (xkb:destroy-keymap keymap)))))

(defun listen-to-keyboard (wl-keyboard keyboard)
  (wl:set-event-handlers
   wl-keyboard
   :keymap (lambda (proxy format fd size)
             (declare (ignore proxy))
             (if (eq format :xkb-v1)
                 (keyboard-keymap-changed keyboard (wl:read-keymap fd size))
                 (wl:close-fd fd)))
   :modifiers (lambda (proxy serial depressed latched locked group)
                (declare (ignore proxy serial))
                (xkb:update-modifiers (keyboard-state keyboard)
                                      depressed latched locked group))
   :leave (lambda (proxy serial surface)
            (declare (ignore proxy serial surface))
            (when (keyboard-compose keyboard)
              (xkb:compose-reset (keyboard-compose keyboard))))
   :key (lambda (proxy serial time key key-state)
          (declare (ignore proxy serial time))
          (when (eq key-state :pressed)
            (let ((text (xkb:key-press-text (keyboard-state keyboard)
                                            (keyboard-compose keyboard)
                                            (xkb:evdev-keycode key))))
              (when text
                (format t "~&Typed ~S~%" text)))))))

Then, for the seat's keyboard:

(listen-to-keyboard (wl-seat-get-keyboard seat) (make-instance 'keyboard))

Points to note:

  • Use update-modifiers and never update-key. The compositor has already applied the keys to the masks.
  • wl_keyboard.key and wl_keyboard.enter carry evdev codes: evdev-keycode turns them into the xkb keycodes cl-xkbcommon takes.
  • wl:read-keymap maps the keymap's file descriptor as version 7 of wl_keyboard requires, reads it and closes it. A file descriptor you do not read is still yours to close, with wl:close-fd.
  • A compositor may send a new keymap at any time, for example when the user changes layouts in the settings. Make a new keyboard state for it then; keyboard-state-keymap gives the old state's keymap, to destroy with it.
  • Reset the compose state on wl_keyboard.leave, so that a sequence does not carry over to another window.

cl-windowing's Wayland backend is a complete example, with key repeat.

evdev and other raw input

Where the platform reports only key presses, as evdev does, the application tracks modifiers through update-key:

(defun handle-evdev-key (state compose code pressed)
  "Handle the evdev key CODE going down (PRESSED true) or up."
  (let ((keycode (xkb:evdev-keycode code)))
    (when pressed
      (let ((text (xkb:key-press-text state compose keycode)))
        (when text (format t "~&Typed ~S~%" text))))
    (xkb:update-key state keycode (if pressed :press :release))))

Ask what the key produces before update-key, so that a modifier key's own press does not count. Make the keymap with make-keymap, from the user's layout settings.

evdev also reports repeats of held keys (value 2). Do not pass those to update-key. Either type them with (xkb:key-press-text state nil keycode), or drop them and run your own repeat timer, as described below.

Key repeat

libxkbcommon says which keys repeat, but does not repeat them. The application runs a timer:

  1. When a key is pressed and (xkb:key-repeats-p keymap keycode) is true, start a timer for that key, after the delay.
  2. Each time the timer fires, after the delay and then at the rate, handle the key as if it were pressed again. For its text, use (xkb:key-press-text state nil keycode), so that a repeat does not feed the compose state.
  3. Stop the timer when that key is released, when another key that repeats is pressed, or when the keyboard loses focus.

With Wayland, wl_keyboard.repeat_info gives the rate in keys per second and the delay in milliseconds, and a rate of 0 means no repeat. Elsewhere, use the platform's settings. 25 per second after 600 ms is a common default.

Do not call update-key for repeats. The key is still held, and its state has not changed.

Several keyboards

A keyboard state follows one keyboard. With several keyboards that should act as one, as on a desktop, the platform usually merges them already, and Wayland gives one wl_keyboard per seat. Where you read devices yourself, keep one keyboard state per device, or feed all devices into one keyboard state if modifiers on one keyboard should affect keys on another.

A keymap can be shared by all the keyboard states that use the same layout.

Saving and restoring a keymap

(with-open-file (out "keymap.xkb" :direction :output :external-format :utf-8
                                  :if-exists :supersede)
  (write-string (xkb:keymap-string keymap) out))

(xkb:make-keymap-from-string context (uiop:read-file-string "keymap.xkb"))