Mojo bindings for the Wayland client protocol, generated from the official protocol XML.
MIT — see LICENSE.
The generated files under wayland/gen/ are derived from the Wayland core
protocol and xdg-shell protocol XMLs, which carry their own permissive
copyright notices, reproduced in wayland/c/generated/. The generated
bindings inherit those notices.
This Project follows the compiler version its built against, not its own versioning. Any releases not tied to a compiler revision will be mark with v1, etc...
Ai tools are used to maintain documentation and notes, and will continue to be used for that purpose. However, EVERY single line of (non-script-generated) actual code is human reviewed and tested. That is the expectation for any contribution.
| Path | Purpose |
|---|---|
scripts/wayland_bindgen.py |
Generator: protocol XML → Mojo modules (see below) |
wayland/core.mojo |
Hand-written runtime (external_call stubs, WLArgument, shim glue) — NOT regenerated |
wayland/gen/wayland.mojo |
Generated: requests, event opcodes, listen/next accessors |
wayland/gen/xdg_shell.mojo |
Generated: xdg-shell (wm_base, surface, toplevel, ...) |
wayland/c/shim.c |
Tiny C shim: event capture dispatcher + interface table |
wayland/c/generated/ |
wayland-scanner private-code + header for xdg-shell (xdg interfaces are NOT in libwayland-client) |
tests/test_pack.mojo |
Headless packaged-library smoke test (no compositor) |
tests/test_ffi_probe.mojo |
Executable FFI probe: zero-arg external_call status (live) |
tests/test_ffi_probe2.mojo |
Executable FFI probe: control + OwnedDLHandle (live) |
tests/test_connect.mojo |
Live test: connect → disconnect |
tests/test_globals.mojo |
Live test: connect → registry → print all globals |
tests/test_window.mojo |
Live test: full xdg-shell window with a wl_shm gradient buffer |
tests/recipe.yaml |
conda recipe for the modular-community channel |
dist/ |
Packed release artifacts (gitignored) |
- Requests are lowered through
wl_proxy_marshal_array/wl_proxy_marshal_array_constructor_versioned(libwayland exports no per-request symbols;wl_surface_commit& co. are header-inline only). Everything resolves viaexternal_callagainstlibwayland-client, linked at build time with-lwayland-client. - Interface records (
wl_registry_interfaceetc.) are data symbols — the C shim exposes a static name →wl_interface*table (wayland_shim_interface("wl_registry")). - Events use one generic C dispatcher (
wl_proxy_add_dispatcher) that captures(opcode, args)per proxy into a FIFO owned by the shim. Mojo polls with{iface}_next_{event}(queue, out_args)and frees copied string args with_shim_string_free. WLArgumentmirrorsunion wl_argumentexactly (8 bytes on x86_64).- Argument arrays are indexed by wire-signature position:
new_idslots stay zeroed (libwayland writes the new proxy id there); every other arg lands at its position in the XML arg list. Dense-packing non-new_idargs causes the compositor to read garbage (e.g.get_xdg_surfacesignatureno— the surface must be in slot 1, slot 0 is the new_id). - Child proxies inherit the parent's version: constructors resolve the
version via
wl_proxy_get_version(parent)— never hardcode the XML version (bindingxdg_wm_baseat v3 and creating anxdg_surfaceat v7 is a protocol error). wl_registry.bindis special: the wire signature isusun, so the generatedwl_registry_bindtakes BOTH the interface pointer (for the constructed proxy) and the interface NAME string (marshalled as theswire arg) plus name+version.- XDG interfaces come from
wayland-scanner private-codecompiled into the shim DSO; they are not exported by libwayland-client. - Minimum libwayland version: every C symbol the bindings call
(
wl_proxy_marshal_array_constructor_versioned,wl_proxy_add_dispatcher,wl_proxy_get_version, ...) has existed since wayland 1.10 (2016), so the practical floor is old. The conda recipe pinswayland >=1.23only because that is the oldest conda-forge version actually tested — see[SYNC:minlib]inwayland/core.mojobefore adding a new stub. - Targets: linux-64 and linux-aarch64 (both little-endian 64-bit;
sizeof(union wl_argument)is 8 on each). Other Wayland platforms (big-endian or 32-bit) are unsupported by theWLArgumentbyte-cell emulation — see[SYNC:abi]inwayland/core.mojo.
Add the dependency to pixi.toml (after the package is published, or with a
path dep for local development):
[dependencies]
mojo-wayland = { path = "path/to/mojo-wayland" } # or version once publishedBecause the bindings resolve shim symbols at load time, any binary using the
package must link the shim DSO alongside libwayland-client (path shown for
a conda/pixi env; use -L/usr/lib for the system libwayland):
mojo build app.mojo -I . \
-Xlinker -L.pixi/envs/default/lib -Xlinker -lwayland-client \
-Xlinker -L.pixi/lib -Xlinker -lwayland_shimfrom wayland.core import (
WLPtr, WLArgument, MAX_EVENT_ARGS, _shim_string_free,
wl_display_connect, wl_display_disconnect,
wl_display_dispatch, wl_display_roundtrip, stack_allocation,
)
from wayland.gen.wayland import (
wl_display_get_registry, wl_registry_listen, wl_registry_next_global,
)
def main() raises:
var display = wl_display_connect(0) # 0 = default socket ($WAYLAND_DISPLAY)
if Int(display) == 0:
raise Error("failed to connect to compositor")
var registry = wl_display_get_registry(display)
# install the capture dispatcher; the shim writes a queue handle to buf[0]
var queue_buf = stack_allocation[1, WLPtr]()
if wl_registry_listen(registry, queue_buf) != 0:
raise Error("registry_listen failed")
var queue = queue_buf[unsafe_offset=0]
var args = stack_allocation[MAX_EVENT_ARGS, WLArgument]() # MUST be 16 slots
while wl_display_dispatch(display) > 0:
while wl_registry_next_global(queue, args):
# args[0]=name (u), args[1]=interface (s, malloc'd copy), args[2]=version (i)
...Binding is generic (the wire signature is usun): pass the interface record
resolved via shim_interface, the interface name as a WLString, and the
global's name + negotiated version:
from wayland.core import shim_interface
from wayland.gen.wayland import wl_registry_bind, wl_compositor_create_surface
var compositor = wl_registry_bind(
registry, shim_interface("wl_compositor"),
str_to_wlstring("wl_compositor"), name, version,
)
var surface = wl_compositor_create_surface(compositor)
wl_surface_commit(surface)Every interface with events gets {iface}_listen(proxy, out_queue) plus one
{iface}_next_{event}(queue, out_args) accessor per event. Poll after each
wl_display_dispatch; the accessors return False when no matching event is
pending. String (s) args are malloc'd copies — free them with
_shim_string_free:
var evargs = stack_allocation[MAX_EVENT_ARGS, WLArgument]()
while xdg_toplevel_next_configure(top_queue, evargs):
pass # reconfigure: keep current size
if xdg_toplevel_next_close(top_queue):
running = False # no args -> takes no out_args bufferDecoding helpers for the raw WLArgument slots (byte-level, little-endian —
see tests/test_window.mojo for the full versions):
def arg_as_uint(a: WLArgument) -> UInt32:
var v = 0
for i in range(4):
v = v | (Int(a.raw[i]) << (8 * i))
return UInt32(v)tests/test_window.mojo is a complete, working example (~340 lines) covering
the whole WSI path: registry → bind wl_compositor/wl_shm/xdg_wm_base →
surface chain → xdg configure/ack handshake → memfd_create + mmap +
wl_shm pool → ARGB8888 gradient → attach/damage/commit → event loop that
answers pings and exits on close. Read it top-to-bottom before writing your
own client; it is kept compilable and compositor-verified by CI.
- Opaque objects are raw
WLPtrhandles; there is no automatic lifetime management. Destructors are explicit generated functions (wl_shm_pool_destroy(pool)etc. — marshal +wl_proxy_destroy). - Popped string args are owned by you:
_shim_string_free(ptr)when done. - Event arg buffers must be
MAX_EVENT_ARGS(16) entries — the shim zeroes the whole array; a shorter stack buffer overflows. memfd/mmap-backedwl_shmbuffers are plain libc calls viaexternal_call(see the_memfd_create/_mmapbindings intests/test_window.mojo); the library does not wrap them.
- Pass the extension XML to the generator:
pixi run gen(edit the task inpixi.tomlto append more XMLs) — it emitswayland/gen/{protocol}.mojo. - Re-export the module in
wayland/__init__.mojo(regenerated automatically by the generator's final write step). - Interfaces not exported by
libwayland-client(like all of xdg-shell) needwayland-scanner private-codeobjects compiled into the shim — see thescannerandshimtasks — plus theirwl_*_interfacerecords added toSHIM_IFACE_ENTRIESinwayland/c/shim.c. - Check the generated opcodes against the XML: opcode = position of the request/event in the interface element (0-based).
Dependencies are split into two pixi environments:
- dev (default) —
pixi run <task>— day-to-day work: gen, scanner, shim, build, and the live tests. Provides Mojo + Python. - packaging —
pixi run -e packaging <task>— the conda-package workflow:pack,test-pack,sync-recipe,test-build. Addsrattler-build(conda-forge) on top of everything dev has. Install it withpixi install -e packaging(the dev env is the default).
The host system additionally needs (per task):
| Dependency | Needed by | Tasks | Notes |
|---|---|---|---|
C compiler (gcc) |
dev + packaging | shim, pack |
compiles wayland/c/shim.c; the conda recipe uses conda's own compiler instead |
wayland-scanner |
dev | scanner |
ships with the wayland package |
protocol XMLs (wayland.xml, xdg-shell.xml) |
dev | gen |
Arch: wayland + wayland-protocols; Debian: libwayland-dev + wayland-protocols |
libwayland-client (+ headers) |
dev | all test builds, shim |
Debian: libwayland-dev; linked with -lwayland-client |
Wayland compositor on $WAYLAND_DISPLAY |
dev | live tests only | test-connect, test-globals, test-window, test_ffi_probe* |
sha256sum, awk |
packaging | pack |
coreutils + gawk (present on any Linux) |
rattler-build |
packaging | test-build |
conda-forge dep in this env, or system package |
Deployment (the published conda package) needs none of these on the
consumer host — pixi install mojo-wayland from a channel pulls
libwayland-client as a run-dependency automatically, and the recipe builds
in rattler-build's isolated env (conda gcc + conda wayland). The generated
outputs (wayland/gen/, wayland/c/generated/) are committed, so gen and
scanner are only required when protocols change.
Example install on Arch:
sudo pacman -S --needed wayland wayland-protocolsDebian/Ubuntu:
sudo apt install libwayland-dev wayland-protocolspixi run gen # regen Mojo bindings from protocol XML
pixi run shim # build the C shim DSO into .pixi/lib/
pixi run build # package the Mojo side → .pixi/envs/default/lib/wayland.mojoc
pixi run test-globals # build + run live registry test on $WAYLAND_DISPLAY
pixi run test-window # build + run live xdg-shell window (400x300 gradient)(Packaging tasks live in a separate packaging environment — see
System dependencies and
Packaging.)
The registry test prints every global your compositor advertises and exits 0. The window test opens a real 400x300 teal-purple gradient window (answers xdg pings, exits on close), proving: connect, generic bind, xdg-shell configure handshake, wl_shm via memfd, buffer attach/commit, and the event loop.
Note: on some pixi versions pixi run sandboxes the task environment in a way
that breaks wl_display_connect (connection refused at runtime). If the test
fails to connect under pixi run but builds fine, invoke the binary directly:
pixi run shim && pixi run build
pixi run mojo build tests/test_globals.mojo -I . -o .pixi/test_globals \
-Xlinker -L.pixi/lib -Xlinker -L.pixi/envs/default/lib \
-Xlinker -lwayland_shim -Xlinker -lwayland-client
LD_LIBRARY_PATH=.pixi/lib:.pixi/envs/default/lib .pixi/test_globalspixi run -e packaging pack # shim DSO + wayland.mojoc + sha256sum → dist/
pixi run -e packaging test-pack # headless smoke test of dist/ artifacts (no compositor)
pixi run -e packaging sync-recipe # copy recipe + test into ../modular-community/recipes/
pixi run -e packaging test-build # full rattler-build build + conda test of tests/recipe.yamlThe precompiled .mojoc we release against stable mojo-compiler updates for archival purposes.
This can also be used to pin against a specific version, just comment out the version check in pack.sh.
pack produces dist/wayland.mojoc, a precompiled artifact of the library.
- The mojoc is compiler-version-locked, exactly. The consuming compiler
must be the same build it was compiled with.
packwritesdist/mojo.versionrecording the producing compiler; check it before consuming. - The source tree is the universal fallback:
-I <path-to-mojo-wayland>works on any compiler that can parse it (the version error above disappears when importing from source) — that's how this repo's own tests consume the library. - The shim DSO is a normal C shared object: no version lock, just
-Xlinker/-L/-lwayland_shimat link time andlibwayland-clientat runtime (see Using the library).
prefer the conda package (pixi/modular) when available
- Events are poll-based (
dispatchthennext_*per event), not callback- based; a callback API would need C→Mojo trampolines. - String args in popped events must be freed with
_shim_string_free. - The core protocol + xdg-shell are generated; further extensions need their
XMLs passed to the generator (and any non-libwayland interfaces added to
the shim's
SHIM_IFACE_ENTRIES+ scanner private-code build). - Destructor requests marshal then
wl_proxy_destroy; there is no automatic object-lifetime management.
