Native windows and input for Common Lisp on Wayland, Windows and X11 — no SDL or GLFW. Vulkan-ready.
  • Common Lisp 100%
Find a file
dxtr 95a87f8fde
All checks were successful
CI / test (push) Successful in 1m37s
Move the pins on the workspace's libraries
To the latest commits of cl-ffi, whose allocate boxes no pointer, and
of the bindings that pin it, of Pyssel's step 25.
2026-10-19 21:31:32 +02:00
.forgejo/workflows Run on SBCL only 2026-10-17 04:31:34 +02:00
src Declare the arguments' full domain types 2026-10-18 16:23:22 +02:00
tests Signal the Win32 thread's stop before sending it a request 2026-10-18 16:30:07 +02:00
vulkan Declare the arguments' full domain types 2026-10-18 16:23:22 +02:00
wayland Declare the arguments' full domain types 2026-10-18 16:23:22 +02:00
win32 Signal the Win32 thread's stop before sending it a request 2026-10-18 16:30:07 +02:00
.gitattributes Keep LF line endings in every checkout 2026-10-16 15:49:18 +02:00
.gitignore Pin the dependencies with stift in place of qlot 2026-10-16 11:19:57 +02:00
cl-windowing.asd Add dialogs: windows made on another, modal or not 2026-10-17 13:09:48 +02:00
CLAUDE.md Follow the CLAUDE.md template's Types section 2026-10-18 16:23:38 +02:00
LICENSE Set up cl-windowing: windows, resizing and Vulkan surfaces on Wayland 2026-10-10 04:21:01 +02:00
README.org Declare the arguments' full domain types 2026-10-18 16:23:22 +02:00
stiftfile Move the pins on the workspace's libraries 2026-10-19 21:31:32 +02:00

cl-windowing

Native windows and input for Common Lisp on Wayland, Windows and X11, without SDL or GLFW. Vulkan-ready.

(asdf:load-system :cl-windowing/wayland)    ; on Windows, :cl-windowing/win32

(defpackage #:example
  (:use #:cl)
  (:local-nicknames (#:win #:cl-windowing)))
(in-package #:example)

(win:with-window-system (window-system)
  (win:with-window (window window-system :title "Hello" :width 800 :height 600)
    (loop until (win:window-configured-p window)
          do (win:process-events window-system :timeout 1))
    (let ((event (win:make-event)))
      (loop while (win:poll-event window-system event)
            collect (list (win:event-type event)
                          (win:event-width event) (win:event-height event))))))
;; => ((:RESIZE 800 600))
;; On Windows, the window gets the keyboard at once:
;; => ((:RESIZE 800 600) (:FOCUS-IN 0 0))

cl-windowing talks to the platform's window system directly, through a backend:

  • cl-windowing: the backend-independent API: window systems, windows and events.
  • cl-windowing/wayland: the Wayland backend, on libwayland-client through cl-wl. Windows are xdg-shell toplevels, with server-side decorations through xdg-decoration where the compositor offers it, and dialogs marked as such through xdg-dialog.
  • cl-windowing/win32: the Windows backend, on user32 through cl-win32. Windows have the system's title bars and borders. Needs Windows 10 version 1607 or later.
  • cl-windowing/vulkan: Vulkan surfaces for windows, through cl-vk.

Status

Early work, on Wayland and Windows: windows, dialogs and popups, resizing by the user and by the program, fullscreen, scaling, close requests, keyboard and pointer input, confining and capturing the pointer, input methods, the pointer's cursor, the clipboard's text, and Vulkan surfaces. X11 comes next.

Windows and events

open-window-system connects to the window system with the platform's backend, :win32 on Windows and :wayland elsewhere, or the one given as :backend. create-window opens a top-level window; it appears once something is drawn in it. Draw nothing before window-configured-p is true, when the window system has given the window its first size: until then window-pixel-size is 0 by 0.

process-events reads what the window system sends; call it regularly from one thread, the event thread. It waits up to :timeout seconds (0 by default, nil for no limit). poll-event copies the oldest event into an event made once with make-event, and allocates nothing; copy-event copies one event into another, to keep it or hand it on. Events are:

Type Meaning
:resize the window system proposes a new size, event-width by event-height, or a new scale, event-scale
:close the user asked to close the window, or the window system dismissed a popup
:focus-in, :focus-out the window gained or lost the keyboard
:key-press, :key-release a key went down, repeated, or came up
:text text was typed, or chosen in an input method: event-text
:preedit, :delete-surrounding an input method's work; see "Input methods"
:pointer-enter, :pointer-leave, :pointer-motion the pointer came in, left, or moved: event-x, event-y, and for a motion event-dx, event-dy, how far
:button-press, :button-release a button went down or up: event-button
:scroll a wheel turned or a touchpad scrolled: event-scroll-x, event-scroll-y

window-system-capabilities says what the window system can do beyond the basics: :server-side-decorations, :clipboard, :pointer-capture, and :keyboard and :pointer while the seat has them. Ask instead of assuming: compositors and machines differ. window-decorations says whether a window has decorations.

Input

Physical keys, what the layout makes of them, and the text typed are three separate things:

  • event-key is the physical key, named for its place on a US keyboard whatever the layout: :a, :digit-1, :left-shift, :f1, :up, :keypad-enter. Use it for things that depend on where keys are, such as WASD movement.
  • event-symbol is the key the layout makes of it, in the same names, or nil if it is none of them. On a French keyboard the key right of Caps Lock is physically :a and logically :q. Use it for shortcuts, such as Control+Z.
  • :text events carry what was typed, after dead keys and compose sequences: a dead acute then e types é. They are not sent while Control, Alt or Super is held, because those keys make shortcuts.

event-scancode is the platform's own code for a key, for remapping keys without names. event-repeat-p is true for a press repeated because the key is held. The window system repeats keys at the rate the platform asks for. (event-modifier-p event :shift) says whether a modifier was held: :shift, :control, :alt, :super, :caps-lock or :num-lock. event-modifiers is the mask of them all, one modifier-bit each.

Pointer positions are in the window's coordinates, from its top left. Buttons are :left, :right, :middle, :back, :forward, or the platform's number for others. Scrolling is in steps, positive right and down: one notch of a wheel is 1.0. Touchpads scroll continuously, at 10 of the window's units per step.

Text in event-text belongs to the event and is overwritten by the next poll-event into it: copy it, or the whole event with copy-event, to keep it.

On Wayland, input comes from the compositor's first seat, and keymaps go through cl-xkbcommon.

On Windows, keys are named by their scancodes, and the layout's virtual keys give event-symbol; text comes as Windows translates key presses, dead keys included. AltGr is not reported as Control and Alt, though Windows sends it as both: it types characters, and they arrive as :text. While a button is held, the window keeps the pointer's events, even when the pointer leaves it; a window that loses the pointer meanwhile, as to Alt+Tab or to a modal dialog, gets the button's release at once, since Windows sends none.

The design follows Wayland's rules, so that every backend can meet them: there are no global window coordinates and no positioning of top-level windows, and popups are placed against a rectangle of their parent.

Popups

A popup is a window made on another, its parent, for a menu, a list to choose from or a tooltip. Windows have no places on the screen that a program can know, so a popup is placed against a rectangle of its parent, such as the button that opens it, and the window system puts it there, moving it where it would not fit on the screen:

;; A menu below its button, at 20, 10 and 80 by 24 in the window.
(win:create-popup window 160 240 :rectangle '(20 10 80 24) :grab t)
;; A submenu beside its item, in the menu.
(win:create-popup menu 160 120 :rectangle '(0 48 160 24) :anchor :top-right :grab t)
;; A tooltip centred below the pointer, which lets the pointer through.
(win:create-popup window 120 30 :rectangle (list x y 0 0) :anchor :bottom
                                :popup-anchor :top :offset-y 16 :pointer-input nil)

:anchor is the point of the rectangle and :popup-anchor the point of the popup put there: corners such as :bottom-left, the middles of edges such as :top, or :center. By default the popup's top left goes at the rectangle's bottom left, so it opens below. Where it would not fit, the window system moves it as :adjustments allow: by default it flips to the other side of the rectangle, then slides along the screen's edge; :resize-x and :resize-y let it shrink. popup-position says where it went, in the parent's coordinates. The rectangle, of the type rectangle, is a list (x y width height), rounded out to whole units and cut to the parent's size.

A popup is a window like any other, with a size, a scale and events of its own: draw nothing before window-configured-p is true. Its size can change as it is placed, with a :resize event; reposition-popup places it again, at another size or against another rectangle.

A popup that grabs, with :grab t, gets the keyboard while it is open, and the window system dismisses it when the user clicks outside the program's windows or switches to another program: it gets a :close event, popup-dismissed-p is then true, and it should be destroyed. Make one in answer to a button or key press. Popups that grab form one chain: each is made on the newest one that grabs, or the first on a top-level window while none grabs, and popup-error is signalled otherwise, so close a menu before opening another elsewhere. Popups that do not grab may be made on any window.

destroy-window destroys a window's popups first, the newest first, as Wayland requires, and window-popups lists them. The parent must have been drawn before a popup is made on it.

A program that draws popups itself, inside one of its windows, as a game in fullscreen draws its menus, places them by the same rules with place-rectangle, within the window rather than the screen:

(win:place-rectangle 200 300 '(0 0 640 480) :rectangle '(100 450 80 24))
;; => 100, 150, 200, 300: no room below the rectangle, so above it

On Wayland popups are xdg-shell popups, placed by the compositor; a popup that grabs takes the grab with the serial of the latest key or button press. Moving one with reposition-popup needs xdg_wm_base version 3, which every current compositor offers. On Windows popups are windows without frames owned by their parent's window, never activated; the backend places them on the work area of the display, and places them again as their parent moves. A popup that grabs gets the keys typed while the program has the focus, and is dismissed when the focus goes elsewhere or the user presses a button on a title bar. Input methods do not compose in popups on Windows.

Dialogs

A dialog is a top-level window made on another, its parent, as one that asks which file to open. create-window makes one with :parent: it stays above its parent, opens over it, and is destroyed with it. With :modal t too, the user answers it before going back to the parent:

(let ((dialog (win:create-window window-system :title "Open" :width 480 :height 320
                                               :parent window :modal t)))
  ;; Draw it and handle its events until the user answers, then:
  (win:destroy-window dialog))

While a modal dialog is open, its parent and the parent's popups get no input that starts there: no key presses, text, button presses, scrolls, pointer motion or :close requests. Releases still come, so a key or button held when the dialog opened is released, and so do :focus-in, :focus-out and the pointer's coming and going. window-parent, window-modal-p and window-dialogs say how windows are related. dialog-error is signalled for a modal window without a parent, and for a parent that is a popup or belongs to another window system.

On Wayland a dialog is an xdg-shell toplevel given its parent, marked as a dialog, modal or not, through xdg-dialog where the compositor offers it. The compositor decides where it goes and how it looks, and learns of the parent only once the parent has been drawn. On Windows a dialog is a window owned by its parent's window, with no button on the taskbar, opened over the middle of its parent. A modal one disables its parent's window while it is open, as Windows' dialog boxes do: Windows then shows the dialog when the user clicks the parent.

Input methods

An input method composes text from what is typed, as for Chinese and Japanese, and the user chooses the result from its candidates. A text field takes text from one while it has the focus:

(win:start-text-input window)                        ; the field gains the focus
(win:set-text-input-caret window x y 2 line-height)  ; its caret, in window coordinates
(win:set-text-input-surrounding window line cursor)  ; the text around the caret
…
(win:stop-text-input window)                         ; the field loses the focus

While it does, the input method's work arrives as events, in the order to apply them:

Type Meaning
:delete-surrounding delete event-delete-before characters before the caret and event-delete-after after it
:text the text the user chose, to insert at the caret, as typing's
:preedit the text being composed, event-text, to show at the caret, underlined and apart from the field's text, until the next :preedit replaces it; "" when nothing is composed. event-caret-start and event-caret-end are the input method's caret in it, or nil

Give the caret and the surrounding text again whenever they change, as after handling these events; unchanged ones cost nothing, so a text field may give them every frame. On Wayland input methods speak text-input-v3, where the compositor offers it. On Windows they speak IMM32: a window's input method is off while it takes no text input, so keys reach it as they are typed, and Windows' input methods are not told the surrounding text.

Cursors

Each window has a cursor, the pointer's look while it is over the window, named as CSS names cursors:

(setf (win:window-cursor window) :text)        ; an I-beam, over text
(setf (win:window-cursor window) :ew-resize)   ; over a divider
(setf (win:window-cursor window) :hidden)      ; no cursor at all

It is :default, the arrow, until set, and takes effect at once if the pointer is over the window. Setting the cursor a window has already costs nothing, so it may be set on every pointer motion. The type cursor lists them all: :pointer for links and buttons, :wait and :progress for busy, the resize cursors for edges and corners (:n-resize, :nwse-resize and so on) and for columns and rows, the drag and drop cursors, and more.

On Wayland the compositor draws the cursor from its name where it offers cursor-shape-v1. Elsewhere cl-windowing draws it from the user's cursor theme, named by $XCURSOR_THEME and $XCURSOR_SIZE as other toolkits read them, through libwayland-cursor, at the window's scale, and animates animated cursors. A cursor the theme lacks is shown as :default. On Windows the cursors are the user's system cursors; where Windows has none for a cursor, the nearest it has is shown, such as the arrow for :zoom-in.

Holding the pointer

A window holds the pointer as its pointer mode says:

(setf (win:window-pointer-mode window) :captured)   ; a camera follows the mouse
(setf (win:window-pointer-mode window) :confined)   ; a strategy game's edge scrolling
(setf (win:window-pointer-mode window) :free)       ; as usual, the default

:confined keeps the pointer within the window. :captured hides it and keeps it where it is, and its motion comes as :pointer-motion events whose event-x and event-y stay put and whose event-dx and event-dy are the device's own motion, without the platform's acceleration: about a unit for each count of the mouse, so a camera turns by them times a sensitivity. A window holds the pointer only while it has the focus and the pointer has come over it, and gives it back while another window or program has the focus, so the mode lasts until it is set again; a game sets it once, as play starts, and again on pausing.

window-system-capabilities includes :pointer-capture where the window system can hold the pointer; setting another mode than :free signals unsupported-error where it cannot. On Wayland the compositor holds the pointer, through pointer-constraints-v1, and the captured pointer's motion comes through relative-pointer-v1; the compositor must offer both. On Windows a window keeps the pointer within its client area, or at one point of it, with ClipCursor, and the captured pointer's motion is the mouse's raw input.

The clipboard

clipboard-text returns the text on the clipboard, or nil, and setf puts text there, for this and other programs to paste:

(setf (win:clipboard-text window-system) "Copied")   ; on Control+C
(win:clipboard-text window-system)                   ; on Control+V

Lines end with #\Newline whatever the platform uses. The text may be another program's, which is asked for it: clipboard-text waits up to :timeout seconds, 1 by default, and returns nil if that program does not answer. Do both in answer to the user: on Wayland a program sees the clipboard only while one of its windows has the keyboard focus, and setting it then signals window-system-error otherwise. window-system-capabilities includes :clipboard where there is one.

On Wayland, the text stays on the clipboard until something else is put there or the window system is closed. On Windows it stays until something else is put there; while the session is locked, Windows gives no program the clipboard, and both signal window-system-error.

Fullscreen

(setf (win:window-fullscreen-p window) t)     ; on F11, say
(setf (win:window-fullscreen-p window) nil)   ; and back

A fullscreen window covers the display it is on, without decorations, and the window system sizes it: its size comes as a :resize event, as any other, and set-window-size asks nothing while it lasts. The window system decides: window-fullscreen-p says what it did, once it has. Back from fullscreen, the window is as it was. A popup is never fullscreen.

On Wayland the compositor makes the window fullscreen on the output it chooses, and says so in a configure, so window-fullscreen-p changes as events are processed. On Windows a fullscreen window is a window without a frame covering its display, at once; leaving fullscreen puts it back where it was, maximised if it was.

Resizing

A resize is a handshake between the window system and the code that renders. The window system proposes a size with a :resize event, and window-pending-size returns it until it is taken. The renderer recreates its swapchain at that size, then calls adopt-window-size just before presenting the first frame at it. The window system is told only then that the next frame follows its proposal, so it never sees a frame that disagrees. window-size is the size last adopted.

The program asks for a size with set-window-size, in units, and the size comes the same way, with a :resize event:

(win:set-window-size window 1280 720)
;; => T

The window system may give the window another size, as one that fits the screen. While it sizes the window itself, nothing is asked and set-window-size returns nil: on Windows while the window is maximised, minimised or fullscreen, and on Wayland while it is maximised, fullscreen or tiled, or the user is resizing it. On Wayland a window sizes itself by drawing at the size, which xdg-shell allows, so a configure that comes before the compositor has seen a frame at it, and proposes the old size, does not undo it. A popup keeps its placement, as reposition-popup with only a size places it.

Scaling

A window's size is in its own coordinates, which pointer positions use too. Its scale, window-scale, is how many pixels its frames have to a unit of them on the display it is on: an exact rational such as 1, 2 or 3/2 for a display scaled to 150%. window-pixel-size is the size of its frames in pixels, size times scale. Draw at that size, and draw things at their size in the window's coordinates times the scale, so they look the same size on every display.

A new scale comes as a resize does: a :resize event with event-scale, window-pending-pixel-size until it is taken, and adopt-window-size just before the first frame drawn at it.

On Wayland, fractional scales come from fractional-scale-v1, and frames are shown at the window's size through viewporter, where the compositor offers both; window-system-capabilities then includes :fractional-scale. Otherwise the scale is a whole number: the compositor's preferred buffer scale for the window, or, from a compositor whose wl_surface is older than version 6, such as Weston 16, the largest scale of the outputs the window is on, displays plugged in later included.

On Windows, a window's scale is the DPI of the display it is on over 96: 3/2 at 150%. Windows sizes windows in pixels, so a window's size in its coordinates is its pixel size over its scale, rounded; at a fractional scale, window-pixel-size can then differ from the window's real pixel size by a pixel, which the swapchain takes from Windows. A window moved to a display of another scale keeps its size in its coordinates.

Threads

process-events may be called from one thread at a time, the event thread; everything else may be called from any thread. adopt-window-size may run on the render thread while process-events waits on the event thread: a window system serialises access to the platform with a lock, which process-events does not hold while it waits.

On Windows, a window gets its messages only on the thread that created it. So each window system runs a thread of its own, which creates the windows and handles their messages, and create-window and destroy-window are handed to it from whatever thread calls them. Its events wait for process-events as on Wayland. While the user drags a window's border, Windows runs a loop of its own in that thread, and events keep coming.

Vulkan

(vk:with-instance (instance :extensions (cl-windowing/vulkan:vulkan-instance-extensions
                                         window-system))
  (let ((surface (cl-windowing/vulkan:create-vulkan-surface window instance)))
    ...))

vulkan-instance-extensions names the instance extensions the window system's surfaces need. create-vulkan-surface returns a cl-vk:surface; size its swapchain by window-pixel-size and window-pending-pixel-size, call adopt-window-size before presenting at a new size, and destroy the surface before the window.

Errors and lifetimes

Errors are windowing-error conditions:

  • window-system-error: the window system cannot be reached, or the connection failed.
  • no-backend-error: the backend asked for, or the platform's, is not loaded.
  • popup-error: a popup breaks the window system's rules, as a popup that grabs made where another grabs already.
  • dialog-error: a dialog cannot be made, as a modal window without a parent.
  • unsupported-error: the window system cannot do what was asked, as capturing the pointer where window-system-capabilities lacks :pointer-capture.
  • closed-error: a closed window system or a destroyed window was used.

destroy-window and close-window-system release what create-window and open-window-system made, and with-window and with-window-system do it on leaving. Releasing succeeds even when the connection has failed, so the error that broke it is the one that leaves the with- forms.

Backends

A backend subclasses window-system, window and popup, registers itself with define-backend, and implements the backend- generic functions. It reports what the platform sends with window-configured, window-close-requested, window-decorations-changed, window-fullscreen-changed, popup-configured and popup-dismissed, and input with queue-event, whose modifier masks modifier-bit makes, and which drops the input a modal dialog keeps from its parent. A backend whose platform does not place popups itself places them with place-popup, which follows xdg_positioner's rules, within the part of the screen windows may cover. evdev-key and evdev-button name the Linux kernel's key and button codes, which the Windows backend maps scancodes to as well. size-in-pixels is how a size in units becomes pixels.

The core calls backend-create-window, backend-create-popup, backend-reposition-popup, backend-destroy-window, backend-set-title, backend-set-window-size, backend-set-fullscreen, backend-pointer-mode-changed, backend-show-cursor, backend-text-input-changed, backend-clipboard-text, backend-set-clipboard-text and backend-close without the window system's lock, so that a backend can hand them to a thread of its own and wait; they take the lock themselves around what they share, with with-window-system-lock, as code that reports events does. The other backend- functions are called holding it. backend-create-window gets a dialog's :parent and :modal, which the window is made with as :parent and :modal-p. A backend whose platform leaves a window to size itself, as Wayland does, reports the size backend-set-window-size asks for with window-configured and a serial of nil: there is nothing to acknowledge. The Wayland backend in wayland/ is the example to follow; the Win32 backend in win32/ shows a backend with a thread.

Development

Dependencies are pinned in the stiftfile with stift, and tests use parachute.

stift test

The suite tests the backend of the platform it runs on.

On Windows, the tests open real windows, briefly, so they need a desktop session, and a Vulkan driver for the Vulkan tests. They play the user by sending the windows the messages Windows would send.

Elsewhere, the protocol tests run against cl-wl's in-process compositor, so they need only libwayland (Debian/Ubuntu: libwayland-client0 libwayland-server0). The Vulkan tests start headless Weston (weston) and present to it, so they need a Vulkan driver that presents to Wayland, such as Mesa's lavapipe (libvulkan1 mesa-vulkan-drivers). When the Khronos validation layer is installed (vulkan-validationlayers), they run under it and fail on any error it reports.

License

MIT. See LICENSE.