glfmn.io hardmode-triangle-1(1)


Grokking the wayland protocol and creating a window.


In this part, we will be exploring wayland: what it is, what it does, how it works, and how know that its working before we have our window on screen.


  1. Part 0

    Introduction and why I decided to write an OpenGL hello-triangle program the hard way: no sdl, no glfw, only Linux platform libraries.

  2. Part 1*
  3. Part 2

    Initializing our OpenGL context and using EGL to connect it to our window.


Wayland is a protocol for communicating between a graphical application and the host system. The program which has a some sort of image to present, like our humble triangle, is called a wayland client, and the program that ultimately decides where on screen that image shows up is called the compositor or server.

When we say wayland there are technically two things we could be describing:

  1. wayland protocol — a specification which defines messages that can be sent between processes and how they are encoded and decoded.
  2. libwayland — a C library which implements functions to encode, decode, and send wayland protocol messages as well as extra utilities like an event loop.

While it is possible to write a wayland application without using libwayland, we will be using libwayland for this guide.

By the end of this guide, we will understand the basic functionality of wayland!

Getting Started

First thing you will need is to make sure you have the wayland libraries installed on your system.

As previously mentioned, we need to install libwayland. On my system I needed to install libwayland-dev (Ubuntu) to get the header files, etc.

sudo apt install libwayland-dev 
# libwayland-bin contains wayland-scanner and more

Once our dependencies are installed, we can begin our zig project. Create a new directory and then create a new project in that directory.

mkdir hardmode-triangle && cd hardmode-triangle
zig init-exe # Initialize our project
git init     # Our dependencies will be managed with git

We will be using the zig-wayland package as the zig bindings to the wayland protocol. As of writing this article, it does not support the package manager, so we will need to use a git submodule. We can create a folder called deps and add the submodule.

mkdir deps
git submodule add \
    https://github.com/ifreund/zig-wayland.git \
git submodule update --init

Wayland Protocol

The base wayland protocol defines a few basic types and how to encode/decode them. This is called the wire format. libwayland also has conventions on how to transport those messages between client and server, usually over a Unix socket. These messages are passed back-and-forth asynchronously.

The wayland protocol works by initializing objects essentially calling functions on those objects. However, because protocol messages are asynchronous, instead the client makes requests to the server and the server sends events to the client.

The valid requests and events for each object are defined by their interface. Some requests will create a new object of a particular interface and create an ID for that object. That ID then both identifies which interface and which object is being referred to. But that leaves us with a problem: if we need to send a message to create an object, but we also have to have an object in order to send a message, how do we get the first object?

That brings us to wl_display. This interface is special in that it is the entry point to wayland. It always implicitly exists and thus we can use it to begin creating interface objects which will let us send requests and events.

Building Wayland

Before we start using wayland it can be helpful to understand a bit more about the build process: If we take a closer look at the libwayland-dev package using dpkg -L we can see that it installs an XML file called wayland.xml.

dpkg -L libwayland-dev | grep "\.xml" # debian-based distros only

The wayland.xml file defines the protocol interfaces:

31<interface name="wl_display" version="1">
32 <request name="sync">
33 <arg name="callback" type="new_id" interface="wl_callback"
34 summary="callback object for the sync request"/>
35 </request>
36 <!-- truncated -->

There is an application called wayland-scanner which can read sections of the protocol XML and automatically generate the C code necessary to send or receive the various requests and events. This allows for protocol extensions to be described only in terms of the protocol itself, with the necessary C code being automatically generated.

Following this pattern, zig-wayland also reads the wayland protocol XML to generate zig code. Thanks to the zig build system, we can generate this code in our build.zig file by importing the Scanner defined by zig-wayland.

1const std = @import("std");
2const Scanner = @import("deps/zig-wayland/build.zig").Scanner;

We can then create a scanner and create a new module from the code that it generates, and also link to the system library for libwayland-client, as we will be depending on its functionality.

28// The scanner generates the code necessary to read and write different
29// wayland protocol messages to communicate with the compositor, but only
30// for the protocols and versions we need.
31const scanner = Scanner.create(b, .{});
32const wayland = b.createModule(.{ .source_file = scanner.result });
33exe.addModule("wayland", wayland);
38// TODO: remove when https://github.com/ziglang/zig/issues/131 is implemented

Note that we haven't actually used the scanner to explicitly generate any code for any protocol interfaces yet. There are a few that are automatically generated:

  1. wl_display — represents our connection between client and server and used for core protocol features.
  2. wl_registry — manages singleton interfaces, also a way to find out the capabilities of the server, and declare the desire to use them.
  3. wl_callback — a way for a client to be notified when a request completes.
  4. wl_buffer — rectangular buffer of pixels to be displayed.

So with that done, we can turn our attention to our main function and start our very first interactions with the wayland protocol: connecting to a display.


In this case, globals are singleton objects that are entry-points into different aspects of the protocol. The wl_shm global, for example, allows for creating buffers of memory on the CPU for sharing rendered images from our application to the window manager using file descriptors.

The first thing we need to do is connect to our display; zig-wayland defines a few types for the client to use functionality from libwayland-client. We will create a client.wl.Display, which will set up the transport (over a Unix socket) between our application and the server.

1const std = @import("std");
3const wayland = @import("wayland");
4const wl = wayland.client.wl;
6pub fn main() !void {
7 std.log.info("Hardmode triangle.", .{});
9 const display = try wl.Display.connect(null);
10 defer display.disconnect();

Let's look at a simplified snippet of the wayland.xml definition of the wl_display interface to see what we can do with it.

<interface name="wl_display" version="1">
  <request name="get_registry">
    <description summary="get global registry object">
      This request creates a registry object that allows the client
      to list and bind the global objects available from the
    <arg name="registry" type="new_id" interface="wl_registry"
         summary="global registry object"/>

In order to get access to more functionality, we need to use the registry to get access to the global IDs.

9const display = try wl.Display.connect(null);
10defer display.disconnect();
11const registry = try display.getRegistry();
12defer registry.destroy();

If we try running our app with zig build run nothing has really happened yet. Hopefully we haven't gotten any errors, but without any other feedback it can be frustrating or disappointing, or just hard to really understand what is going on. An immensely useful trick that really helped me figure out problems I was running into was using the WAYLAND_DEBUG=1 environment variable:

WAYLAND_DEBUG=1 zig build run

This will log every incoming and outgoing protocol message, allowing us to ensure things are happening and working, and we will use this throughout the guide. If we run with our trusty WAYLAND_DEBUG=1 variable set like above, we will now see the following output:

info: Hardmode triangle.
[ 811200.514]  -> wl_display@1.get_registry(new id wl_registry@2)

The second line there shows the out-going request associated with the call to Display.getRegistry. Its meaning is pretty straightforward:

  1. --> — Indicates this is a request made by the client
  2. wl_display@1 — The interface and ID of the object
  3. get_registry — The request being called
  4. new id wl_registry@2 — The arguments list of the request

Notice that from this request, however, that the client tells the server the ID (2) of the new wl_registry object.

Now let us use the registry!

From perusing the registry XML, or from looking at the zig code in your generated wayland_client.zig, you will find that the registry interface exposes the following requests and events:

  • bind. request

    Bind a client-created global to the server, announcing both the ID and interface.

  • global. event

    Notifies the client that a particular global is available.

  • global_remove. event

    Notifies the client when a particular global is no longer available and needs to be destroyed.

The server will use the global event to announce its capabilities, 1-by-1. The client then tells the server that it will use one of those capabilities by sending a bind request.

But how do we get events?

In zig-wayland and libwayland, this is done using callbacks. The wl.client.Registry struct has a function setListener which sets the callback function. When an event of the right type is received, the callback gets invoked with the event data, as well as some data that we provide to enable us affect the state of the program.

181pub inline fn setListener(
182 _registry: *Registry,
183 comptime T: type,
184 _listener: *const fn (registry: *Registry, event: Event, data: T) void,
185 _data: T,
186) void

Let's create a type which will store the globals we bind in the registry, and create a no-op callback function.

6const Globals = struct {};
8fn registryListener(
9 registry: *wl.Registry,
10 event: wl.Registry.Event,
11 globals: *Globals,
12) void {
13 _ = globals;
14 _ = event;
15 _ = registry;

Then let us bind the listener to the registry in our main function:

23const registry = try display.getRegistry();
24defer registry.destroy();
26var globals = Globals{};
27registry.setListener(*Globals, registryListener, &globals);

Now we should be all set to receive events! If we run with WAYLAND_DEBUG=1, we should, in theory, see the events popping up in our terminal.

info: Hardmode triangle.
[2339656.103]  -> wl_display@1.get_registry(new id wl_registry@2)

However, we don't receive any events! The wayland protocol is asynchronous. We haven't done anything to wait for or parse any events at this point. libwayland-client provides some useful event-loop functionality to help with waiting for events. Let's use the wl.Display.roundtrip() function:

26var globals = Globals{};
27registry.setListener(*Globals, registryListener, &globals);
29if (display.roundtrip() != .SUCCESS) return error.RoundtripFailure;

Now we should see the events for the registry, as well as something else (truncated for clarity):

info: Hardmode triangle.
[3953717.007]  -> wl_display@1.get_registry(new id wl_registry@2)
[3953717.043]  -> wl_display@1.sync(new id wl_callback@3)
[3953717.321] wl_display@1.delete_id(3)
[3953717.354] wl_registry@2.global(1, "wl_compositor", 5)
[3953717.370] wl_registry@2.global(2, "wl_drm", 2)
[3953717.383] wl_registry@2.global(3, "wl_shm", 1)
[3953717.394] wl_registry@2.global(4, "wl_output", 4)
[3953717.453] wl_registry@2.global(9, "xdg_wm_base", 4)
[3953717.521] wl_registry@2.global(15, "wl_seat", 8)
[3953717.600] wl_registry@2.global(22, "zwp_linux_dmabuf_v1", 4)
[3953717.656] wl_registry@2.global(27, "xdg_activation_v1", 1)
[3953717.667] wl_callback@3.done(185275)

There is now a sync request being called for the display, which creates a callback object; that callback object will get a done event back when the display has handled all the requests up to the sync request.

So what the roundtrip function is doing for us is making a sync request and listing to all the events until the callback done event comes back with the matching ID.

Note that the global events received on your device may differ, as each wayland compositor will support different functionality.


The first real global we need now is the wl_compositor. The compositor interface is responsible for combining all the things we want to show on screen into a single cohesive output.

Rather than windows, we display things to surfaces. From wayland.xml's description: A surface is a rectangular area that may be displayed on zero or more outputs, and shown any number of times at the compositor's discretion. They can present wl_buffers, receive user input, and define a local coordinate system. Surfaces can fill a number of roles from top-level application window, to popup or modal window, to even being the cursor.

We can use the compositor to create a surface:

190<interface name="wl_compositor" version="5">
191 <description summary="the compositor singleton">
192 A compositor. This object is a singleton global. The
193 compositor is in charge of combining the contents of multiple
194 surfaces into one displayable output.
195 </description>
197 <request name="create_surface">
198 <description summary="create new surface">
199 Ask the compositor to create a new surface.
200 </description>
201 <arg name="id" type="new_id" interface="wl_surface" summary="the new surface "/>
202 </request>
204 <!-- truncated -->

However, the wl_compositor interface is not included among the default interfaces that are generated by zig-wayland. Before we can use it, we need to generate that interface in our build.zig file:

31const scanner = Scanner.create(b, .{});
32const wayland = b.createModule(.{ .source_file = scanner.result });
34scanner.generate("wl_compositor", 1);
36exe.addModule("wayland", wayland);

This will read our wayland.xml file and generate the wl_compositor interface at the specified version (1).

Now, in main.zig, we can update our Globals struct to contain a compositor and bind it in our registry listener function:

6const Globals = struct {
7 compositor: ?*wl.Compositor = null,
10fn registryListener(
11 registry: *wl.Registry,
12 event: wl.Registry.Event,
13 globals: *Globals,
14) void {
15 switch (event) {
16 .global => |global| {
17 const compositor = wl.Compositor.getInterface().name;
18 if (std.mem.orderZ(u8, global.interface, compositor) == .eq) {
19 globals.compositor = registry.bind(
20 global.name,
21 wl.Compositor,
22 1,
23 ) catch return;
24 }
25 },
26 .global_remove => {},
27 }

Our listener will take a single event type for the interface as a union(enum); each variant will correspond to one of the event types, and its fields are the args of the event.

If we take a closer look at the wl.Registry.Event's definition in the generated wayland_client.zig file:

171pub const Event = union(enum) {
172 global: struct {
173 name: u32,
174 interface: [*:0]const u8,
175 version: u32,
176 },
177 global_remove: struct {
178 name: u32,
179 },

The global event identifies the interface with a null terminated string and the instance with a 32-bit number as its name. When our client application binds the instance, it will identify the global with its name. We receive one event for every global, so we must match the name of the global to see if it is one of the interfaces we are interested in.

If we check the output with WAYLAND_DEBUG one more time, among all the events we will see our outgoing bind request:

[1684599.075] wl_registry@2.global(1, "wl_compositor", 5)
[1684599.093]  -> wl_registry@2.bind(1, "wl_compositor", 1, new id [unknown]@4)

We can see the bind request in response to the global event. After this point, we are able to use the compositor interface to create a surface like so:

41if (display.roundtrip() != .SUCCESS) return error.RoundtripFailure;
43const compositor = globals.compositor orelse return error.NoWlCompositor;
44const surface = try compositor.createSurface();
45defer surface.destroy();

Make sure to insert this after the roundtrip has completed; otherwise, the compositor will still be null!


Now that we have a surface, the compositor won't necessarily know what to do with our surface right off the bat, so we need to passing our surface a role.

For this we need to use an extension called xdg shell. If you search your wayland.xml file, you will notice that xdg does not appear in any interface names.

xdg — Cross Desktop Group, an organization based at freedesktop.org dedicated to interoperability between open-source desktop environments. They maintain standards like wayland and its predecessor Xorg.

Since the protocol extensions for the xdg shell are not located in our base wayland.xml file, we need to load the extensions in our build.zig files. Wayland protocols can be installed system-wide at a file-path that should be something like /usr/share/wayland-protocols; from within there, you should find a directory for stable protocols, unstable protocols, and staging protocols. xdg shell is a stable protocol, so on my system xdg-shell.xml is located at:


On my Ubuntu system, these protocol files come from the wayland-protocols package, which may be different on your system.

The first interface inside should be xdg_wm_base which says: The xdg_wm_base interface is exposed as a global object enabling clients to turn their wl_surfaces into windows in a desktop environment. It defines the basic functionality needed for clients and the compositor to create windows that can be dragged, resized, maximized, etc., as well as creating transient windows such as popup menus.

So, in order to build the code necessary to interact with the xdg shell interface, we add the protocol XML to our scanner and then generate our global xdg_wm_base interface:

34scanner.generate("wl_compositor", 1);
36// Relative to wayland-protocols filepath
38scanner.generate("xdg_wm_base", 1);

This will generate code that lives in xdg_shell_client.zig in the zig-cache/o directory.

I should note it is also possible to create or use custom extensions, like river window manager does.

Since these interfaces are now coming from a new XML file with a new prefix, they will also be generated in a new namespace.

3const wayland = @import("wayland");
4const wl = wayland.client.wl;
5const xdg = wayland.client.xdg; // xdg_* interfaces
7const Globals = struct {
8 compositor: ?*wl.Compositor = null,
9 wm_base: ?*xdg.WmBase = null,

And then, inside the match for the .global event in our registryListener function, we can bind the xdg_wm_base global:

27const wm_base = xdg.WmBase.getInterface().name;
28if (std.mem.orderZ(u8, global.interface, wm_base) == .eq) {
29 globals.wm_base = registry.bind(
30 global.name,
31 xdg.WmBase,
32 1,
33 ) catch return;

If we run again with our WAYLAND_DEBUG=1 environment variable, we will see the bind request triggering when we receive the global event for the xdg_wm_base.

[1161268.904] wl_registry@2.global(9, "xdg_wm_base", 4)
[1161268.917]  -> wl_registry@2.bind(9, "xdg_wm_base", 1, new id [unknown]@5)

Now we can start assigning roles. If we look in our xdg-shell.xml file, we find that the xdg_wm_base has a request called get_xdg_surface:

<request name="get_xdg_surface">
  <arg name="id" type="new_id" interface="xdg_surface"/>
  <arg name="surface" type="object" interface="wl_surface"/>

It takes our wl_surface and provides us with an xdg_surface which allows us to assign a role. There are two roles currently; according to xdg-shell.xml:

  • xdg_toplevel

    This interface defines an xdg_surface role which allows a surface to, among other things, set window-like properties such as maximize, full-screen, and minimize, set application-specific metadata like title and ID, and well as trigger user interactive operations such as interactive resize and move.

  • xdg_popup

    A popup surface is a short-lived, temporary surface. It can be used to implement for example menus, popovers, tool-tips and other similar user interface concepts.

We will use the xdg_toplevel interface for our surface like so:

53const compositor = globals.compositor orelse return error.NoWlCompositor;
54const wm_base = globals.wm_base orelse return error.NoWmBase;
56const surface = try compositor.createSurface();
57defer surface.destroy();
58const xdg_surface = try wm_base.getXdgSurface(surface);
59defer xdg_surface.destroy();
60const xdg_toplevel = try xdg_surface.getToplevel();
61defer xdg_toplevel.destroy();
63xdg_toplevel.setTitle("Hardmode Triangle");

With this we have successfully:

  1. Extended our surface with the xdg_surface interface
  2. Assigned it the xdg_toplevel role
  3. Set the window title to "Hardmode triangle"

However, our application exits as quickly as it starts, we don't have a window yet, and there are events from the wm_base, xdg_surface, and xdg_toplevel interfaces that you should handle in a real application, but we can ignore some of them for now as they will not prevent us from presenting to the screen.

If we add a listener to our xdg_toplevel, we can listen for a close event:

fn xdgToplevelListener(
    _: *xdg.Toplevel,
    event: xdg.Toplevel.Event,
    running: *bool,
) void {
    switch (event) {
        .configure => {},
        // Signal to close this toplevel surface
        .close => running.* = false,

And in our fn main:

74// Set running to false when we get an event signaling our toplevel surface
75// was closed.
76var running = true;
77xdg_toplevel.setListener(*bool, xdgToplevelListener, &running);

Now, if we wanted, we could add a main loop. However, since we don't have anything to present to our surface just yet anyway, we can hold off on that for now.

And with that, we are done with part 1, feel free to move on to part 2. The rest of this document will cover some optional improvements that can be made to the zig code.


You may have noticed that the code for retrieving each of our globals has the same structure for each global:

12fn registryListener(
13 registry: *wl.Registry,
14 event: wl.Registry.Event,
15 globals: *Globals,
16) void {
17 switch (event) {
18 .global => |global| {
19 // Bind wl_compositor
20 const compositor = wl.Compositor.getInterface().name;
21 if (std.mem.orderZ(u8, global.interface, compositor) == .eq) {
22 globals.compositor = registry.bind(
23 global.name,
24 wl.Compositor,
25 1,
26 ) catch return;
27 }
29 // Bind xdg_wm_base
30 const wm_base = xdg.WmBase.getInterface().name;
31 if (std.mem.orderZ(u8, global.interface, wm_base) == .eq) {
32 globals.wm_base = registry.bind(
33 global.name,
34 xdg.WmBase,
35 1,
36 ) catch return;
37 }
38 },
39 .global_remove => {},
40 }
  1. get the string identifying the type of the global to compare against the global.interface by calling: GlobalType.getInterface().name
  2. compare the type of the interface from the event to the type of the global we are trying to bind using std.mem.orderZ
  3. populate the bind request using the identifying number in global.name and the type of our global so registry.bind can send the request and return an instance of our global object of the right type.

From this we can make an observation: all the information we need can be inferred from the type of each field. Or, in other words, we can use each field's @typeInfo to handle the global event.

We can use an inline for to iterate through the fields to start off with:

19inline for (@typeInfo(Globals).Struct.fields) |field | {
20 const global_type = field.type;
21 _ = global_type;

However, there is a catch: the fields on our Globals struct are not of the interface type, they are optional pointers. This means we cannot directly call global_type.getInterface().name. Instead, we've got to use @typeInfo a couple more times to unwrap the Optional and the Pointer and get the child type.

19inline for (@typeInfo(Globals).Struct.fields) |field | {
20 const pointer_type = @typeInfo(field.type).Optional.child;
21 const global_type = @typeInfo(pointer_type).Pointer.child;
22 const interface = global_type.getInterface().name;
24 _ = interface;

Now we can follow along with the implementation for comparing the interface and binding the global as before, using @field and the field.name from our field's metadata to access the field:

19inline for (@typeInfo(Globals).Struct.fields) |field | {
20 const pointer_type = @typeInfo(field.type).Optional.child;
21 const global_type = @typeInfo(pointer_type).Pointer.child;
22 const interface = global_type.getInterface().name;
24 // Compare the interface types to see if they match
25 if (std.mem.orderZ(u8, global.interface, interface) == .eq) {
27 // Bind the global to the appropriate field
28 @field(globals, field.name) = registry.bind(
29 global.name,
30 global_type,
31 1,
32 ) catch return;
33 }

With this in place, any time we add a new global, we only have to add a field for it to our Globals struct, enabling a more declarative style of programming. Running WAYLAND_DEBUG=1 zig build run should produce the same results as before.


This page is referenced by the following documents: