9 Backend Protocol
dxtr edited this page 2026-10-18 16:15:20 +02:00

Backend Protocol

This page is for writing a backend, such as the coming X11 one. Programs never need it.

A backend is an ASDF system named cl-windowing/NAME, in its own directory and package. It:

  1. registers itself with define-backend;
  2. subclasses window-system, window and popup;
  3. implements the backend- generic functions;
  4. reports what the platform sends with the reporting functions.

The core keeps everything backends share: the event queue, the lock, the list of windows, the checks for closed objects, the resize handshake, the rules popups follow, and what a window's cursor, pointer mode and text input are. The Wayland backend, in wayland/, is the example to follow; the Win32 backend, in win32/, shows a backend with a thread of its own.

Registering

(win:define-backend :x11 "X11, through libxcb.")

define-backend registers the keyword when the file loads. open-window-system then accepts it as :backend and calls backend-open with it. Loading a backend again replaces its entry.

open-window-system given no :backend uses the platform's backend, :win32 on Windows and :wayland elsewhere, as platform-backend in src/windows.lisp says. A backend that is to be a platform's default changes it there.

Classes

(defclass x11-window-system (win:window-system)
  ((connection :initarg :connection :reader connection :type cl-ffi:foreign-pointer
               :documentation "The connection to the X server."))
  (:documentation "A connection to an X server."))

(defclass x11-window (win:window)
  ((id :initarg :id :reader window-id :type (unsigned-byte 32)
       :documentation "The X window ID."))
  (:documentation "An X11 top-level window."))

The base classes take these initargs:

Class Initargs
window-system :backend, the backend's keyword
window :window-system, :title, :width, :height, and for a dialog :parent and :modal-p
popup the core's, which backend-create-popup receives as a list to pass on, with the backend's own

A popup is a window, so the backend's popup class subclasses both its window class and popup, as wayland-popup subclasses wayland-window and popup.

Generic functions to implement

Some are called holding the window system's lock, and some without it, so that a backend can hand them to a thread of its own and wait, as the Win32 backend does; those take the lock themselves around what they share.

Generic function Lock Contract
(backend-open backend &key ...) Not held Specialise on (eql :name). Connect and return an instance of your window system class. Take your options as keys. Signal window-system-error if the platform cannot be reached, and release anything made before failing.
(backend-close window-system) Not held Disconnect. Its windows are destroyed already.
(backend-process-events window-system timeout) Take it yourself Wait at most timeout seconds, or forever if nil, for the platform; then handle what arrived, reporting it. Hold the lock, with with-window-system-lock, around everything but the wait.
(backend-create-window window-system &key title width height app-id parent modal) Not held Create the platform's window, asking for width by height units, and return an instance of your window class, made with :parent parent :modal-p modal. With parent, a top-level window of yours, the window is a dialog of it: tell the platform, and with modal, tell it the dialog is modal; the core drops the input that starts in the parent meanwhile. The core adds the window to the window system's list, and to the parent's dialogs.
(backend-create-popup parent initargs) Not held Create a popup on parent, an instance of your popup class made with initargs and your own. popup-asked-size, popup-placement, popup-grab-p and popup-pointer-input-p say what it is to be; place it so, and report where it went with popup-configured.
(backend-reposition-popup popup) Not held Place the popup again, at popup-asked-size and as popup-placement says now, and report where it went.
(backend-destroy-window window) Not held Destroy the platform's side of the window, which the core already counts as destroyed.
(backend-set-title window title) Not held Set the platform window's title.
(backend-set-window-size window width height) Not held Ask for the size, and return true; the size it gets is reported with window-configured. Return nil, asking nothing, while the platform sizes the window itself, as while it is maximised.
(backend-set-fullscreen window fullscreen) Not held Ask for fullscreen or for the window as it was; report what the platform does with window-fullscreen-changed, and the size with window-configured.
(backend-pointer-mode-changed window) Not held Hold the pointer as window-pointer-mode says now.
(backend-show-cursor window) Not held Show window-cursor now, if the pointer is over the window.
(backend-text-input-changed window) Not held Tell the input method what text-input-active-p, text-input-caret and text-input-surrounding return now.
(backend-clipboard-text window-system timeout) Not held Return the clipboard's text, or nil if it holds none or its owner does not send it within timeout seconds.
(backend-set-clipboard-text window-system text) Not held Put the text, with #\Newline between lines, on the clipboard.
(backend-acknowledge-configure window serial) Held Tell the platform that the next frame follows the proposal serial identifies.
(backend-adopt-scale window width height scale) Held Tell the platform that the next frame is drawn at scale and shown at width by height units. Called just before that frame.
(backend-capabilities window-system) Not held Return a list of capability keywords; see Window Systems and Windows.
(backend-vulkan-extensions window-system) Not held Return the Vulkan instance extensions a surface needs, as strings.
(backend-vulkan-target window) Held Return a keyword naming the platform, then the native objects a surface is made from, as foreign pointers.

The functions called without the lock on a window may find it destroyed meanwhile, by another thread, which leaves them nothing to do. Those that report a change, backend-show-cursor, backend-pointer-mode-changed and backend-text-input-changed, read the window's state rather than remembering what changed, so that two threads changing it end with the last one's.

Take the lock with with-window-system-lock:

(win:with-window-system-lock (window-system)
  (win:queue-event window-system :close window))

It is recursive: a thread that holds it may take it again. Never wait holding it for another thread that takes it, as for a thread of the backend's own, nor hold it around a call the core makes without it, such as create-window. window-system-lock returns the lock itself.

Translate every platform error into window-system-error, with a message that says what went wrong and what to check. The Wayland backend wraps its calls in a macro that does this.

Waiting without the lock

backend-process-events must not hold the lock while it waits, so that a render thread can adopt a size meanwhile. The Wayland backend's, in outline, without its error translation and without the loop that waits again when the clock wakes it a little early:

(defmethod win:backend-process-events ((window-system wayland-window-system) timeout)
  (let ((display (display window-system)))
    (let ((wait (win:with-window-system-lock (window-system)
                  (wl:dispatch-pending display)
                  (wl:flush display)
                  ;; Wake up in time to repeat a held key, or to show the
                  ;; next frame of an animated cursor.
                  (earliest timeout (repeat-timeout window-system)
                            (cursor-timeout window-system)))))
      ;; Wait without the lock, so other threads may use the display.
      (let ((read (wl:read-events display :timeout wait)))
        (win:with-window-system-lock (window-system)
          (when read
            (wl:dispatch-pending display))
          (repeat-keys window-system)
          (animate-cursor window-system)
          (wl:flush display))))))

earliest stands for taking the smallest of the timeouts that are not nil.

If the platform's API cannot wait without blocking other callers, wait on the connection's file descriptor instead, and read only once it is ready.

Reporting

Call these from backend-process-events, holding the lock. They allocate nothing in steady state.

Function Reports
(window-configured window serial width height) The platform proposes a size. serial identifies the proposal and is handed back to backend-acknowledge-configure. A width or height of 0 keeps the window's own. A serial of nil reports a size the window chose itself, where the platform leaves that to it, as for set-window-size on Wayland: there is nothing to acknowledge. The core runs the handshake and queues the :resize.
(window-scale-changed window scale) The platform wants the window drawn at scale, a positive rational. Joins the handshake.
(window-close-requested window) The user asked to close the window. Queues :close.
(window-decorations-changed window decorations) Who draws decorations now: :server-side or :none.
(window-fullscreen-changed window fullscreen) The platform made the window fullscreen, or no longer.
(popup-configured popup serial x y width height) The platform placed the popup at x, y in its parent's coordinates, width by height, as window-configured reports a size.
(popup-dismissed popup) The platform dismissed the popup. Queues :close, unless it was dismissed already.
(queue-event window-system type window &key ...) Any other event: the keys are the event's fields (:key, :symbol, :scancode, :modifiers, :repeat-p, :text, :button, :x, :y, :dx, :dy, :scroll-x, :scroll-y, :width, :height, :scale, :caret-start, :caret-end, :delete-before, :delete-after). It drops the input that starts in a window a modal dialog keeps it from, as Dialogs lists, so a backend reports everything and leaves that to the core.

queue-event takes :modifiers as a mask. Build it from modifier-bit:

(logior (win:modifier-bit :shift) (win:modifier-bit :control))

evdev-key and evdev-button turn the Linux kernel's key and button codes into cl-windowing's names, as Wayland and X11 report them; the Win32 backend maps Windows' scancodes to those codes first, so that keys have the same names everywhere.

Helpers

  • (place-popup popup left top width height) returns where a popup goes, at the size it was asked for, within a rectangle in its parent's coordinates, such as the part of the screen windows may cover, by xdg_positioner's rules. For a platform that does not place popups itself, as the Win32 backend uses it.
  • (size-in-pixels size scale) is how a size in units becomes pixels: rounded halfway away from zero, as Wayland rounds a toplevel's size. For a platform that sizes windows in pixels.
  • popup-asked-size returns the size a popup was last asked for.

Report physical keys as :key and the platform's code as :scancode. Work out :symbol and text from the layout, and follow the rules on Keyboard: no :text while Control, Alt or Super is held, and none for control characters.

Vulkan

backend-vulkan-target returns the platform keyword first, then the native objects:

(defmethod win:backend-vulkan-target ((window wayland-window))
  (values :wayland
          (wl:proxy-pointer (display (win:window-window-system window)))
          (wl:proxy-pointer (surface window))))

cl-windowing/vulkan turns those into a surface with the platform's vkCreate…SurfaceKHR: :wayland with the wl_display and wl_surface, and :win32 with the HINSTANCE and HWND. A new platform needs a case there too.

These two generic functions are the only place native objects leave a backend. Never expose them elsewhere.

Implementation-specific code

cl-windowing/sys is the internal package for what cl-windowing takes from SBCL beyond standard Common Lisp: locks and threads. Add to it rather than call SBCL's extensions elsewhere.

Testing a backend

A backend needs its own tests in cl-windowing/test. The Wayland tests run against cl-wl's in-process compositor, cl-wl/test-compositor, which drives configures, scales, input, input methods, cursors and the clipboard from the test, without a desktop session. The Vulkan tests present to a headless Weston. The Win32 tests run on Windows, against the desktop, and are left out elsewhere by feature expressions in cl-windowing.asd.