Onto the Screen
Writing a file is a one-way transaction: you produce bytes, you are done. A window is a conversation. The display server decides when you may paint, asks whether you are still alive, and reports input, and missing your side of any of that leaves the window blank, frozen, or killed.
Three things change from Writing an Image File.
Pixels get wider. Both X11 and Wayland want 32 bits per pixel, not 24. On a
little-endian machine both want a u32 holding 0x00RRGGBB, which lands in
memory as B, G, R, X. Writing whole u32s gets the byte order right without
thinking about it, which is why the framebuffer in this section has been 32-bit
from the first chapter.
The buffer may become shared. A file write is a copy. Putting pixels on
screen does not have to be: Wayland’s wl_shm hands the compositor a file
descriptor for the same pages you drew into, and nothing is copied at all. X11’s
XPutImage does copy, to the server, which is the whole reason the MIT-SHM
extension exists.
Drawing once is not enough. The server tells you when to paint, and it will ask again.
Xlib, the short version
Open a display, create a window, map it, then answer events. It is markedly less ceremony than Wayland, and it still runs everywhere, including under XWayland on a Wayland desktop.
const std = @import("std");
/// Generated by translate-c from the headers named above. `@cImport` was
/// removed from the language; C now arrives as a build-system step.
const c = @import("c");
const width: c_uint = 640;
const height: c_uint = 480;
fn drawFrame(pixels: []u32) void {
for (0..height) |y| {
for (0..width) |x| {
const r: u32 = @intCast((x * 255) / (width - 1));
const g: u32 = @intCast((y * 255) / (height - 1));
// 0x00RRGGBB. On a little-endian machine that lands in memory as
// B, G, R, X, which is what a 24-bit TrueColor visual expects.
pixels[y * width + x] = (r << 16) | (g << 8) | 80;
}
}
}
pub fn main(init: std.process.Init) !void {
_ = init;
const display = c.XOpenDisplay(null) orelse return error.NoDisplay;
defer _ = c.XCloseDisplay(display);
// Xlib's `DefaultScreen`, `RootWindow`, `DefaultVisual` and friends are
// function-like macros that poke at a private struct, and translate-c
// cannot give them meaning. Every one has a real function behind it.
const screen = c.XDefaultScreen(display);
const root = c.XRootWindow(display, screen);
const visual = c.XDefaultVisual(display, screen);
const depth = c.XDefaultDepth(display, screen);
const gc = c.XDefaultGC(display, screen);
if (depth != 24 and depth != 32) return error.UnsupportedVisual;
const window = c.XCreateSimpleWindow(
display,
root,
0,
0,
width,
height,
0,
c.XBlackPixel(display, screen),
c.XBlackPixel(display, screen),
);
defer _ = c.XDestroyWindow(display, window);
_ = c.XStoreName(display, window, "Zig pixels");
_ = c.XSelectInput(display, window, c.ExposureMask | c.KeyPressMask);
// Without this the close button tears down the connection instead of
// sending an event the program can act on.
var wm_delete = c.XInternAtom(display, "WM_DELETE_WINDOW", 0);
_ = c.XSetWMProtocols(display, window, &wm_delete, 1);
// XCreateImage takes ownership and frees this with `free()` inside
// XDestroyImage, so it has to come from the C allocator, not a Zig one.
const data: [*]u32 = @ptrCast(@alignCast(std.c.malloc(width * height * 4) orelse
return error.OutOfMemory));
drawFrame(data[0 .. width * height]);
const image = c.XCreateImage(
display,
visual,
@intCast(depth),
c.ZPixmap,
0,
@ptrCast(data),
width,
height,
32, // bitmap_pad: rows start on 32-bit boundaries
0, // bytes_per_line: 0 means "work it out from width"
) orelse {
std.c.free(data);
return error.ImageFailed;
};
// XDestroyImage is another macro; it calls through the image's own
// function table, which is a thing Zig can do directly.
defer _ = image.*.f.destroy_image.?(image);
_ = c.XMapWindow(display, window);
var event: c.XEvent = undefined;
while (true) {
_ = c.XNextEvent(display, &event);
switch (event.type) {
// X11 keeps no copy of your pixels. Every time the window is
// uncovered the server asks the client to paint it again, which is
// why the image is kept around instead of blitted once.
c.Expose => _ = c.XPutImage(display, window, gc, image, 0, 0, 0, 0, width, height),
c.KeyPress => {
if (c.XLookupKeysym(&event.xkey, 0) == c.XK_Escape) return;
},
c.ClientMessage => {
if (event.xclient.data.l[0] == @as(c_long, @intCast(wm_delete))) return;
},
else => {},
}
}
}
// Marked `//! norun`: this opens a window and then waits for a keypress, which
// is not something a CI step can sit through. It is still compiled and linked
// against the real libX11 on every run, so a change in Xlib's headers or in
// how Zig translates them fails the build.Four things in that code are worth knowing before you write your own.
@cImport is gone. C headers now arrive through a build-system step. In
this repo that is what //! link: X11 and //! cinclude: set up: build.zig
synthesizes a header, runs addTranslateC over it, and hands the snippet the
result as @import("c"). In your own project it is b.addTranslateC(...)
followed by translate.createModule(). See
Importing C for the general shape.
Xlib’s Default* macros do not survive translation. DefaultScreen(dpy),
RootWindow, DefaultVisual, and DefaultGC all expand to member accesses on a
private struct, and translate-c cannot give that meaning. Every one has a real
function behind it (XDefaultScreen, XRootWindow, and so on), so call those.
XDestroyImage is the same problem with a different answer: it expands to a call
through the image’s own function table, which Zig can do directly:
_ = image.*.f.destroy_image.?(image);
XCreateImage takes ownership of your pixels. It frees them with free()
inside XDestroyImage, so the buffer has to come from std.c.malloc and not
from a Zig allocator. Hand it a GeneralPurposeAllocator block and you get a
free of a pointer that allocator never owned.
X11 keeps no copy of your pixels. Every time the window is uncovered, the
server sends Expose and expects the client to paint again. That is why the
program keeps the image around instead of blitting once and forgetting it. The
close button also needs opting into: without WM_DELETE_WINDOW registered
through XSetWMProtocols, clicking it tears down the connection instead of
sending an event you can act on.
What Wayland changes
Wayland is the newer protocol and the default on most current Linux desktops. The drawing code is unchanged. Everything around it is different, and the differences are all in the same direction: the compositor does less on your behalf.
Discovery is a handshake. You connect, ask for the registry, and the
compositor advertises its globals. You bind the ones you need: wl_compositor,
wl_shm, xdg_wm_base, wl_seat. This takes two roundtrips, not one, because
the second is what runs the listeners you registered from inside the first.
A surface is not a window. A bare wl_surface is just a rectangle of
pixels. It becomes a window by being wrapped in an xdg_surface and then an
xdg_toplevel, which is what carries the title and the app id.
The first commit carries no buffer. It asks the compositor for the initial
configure. Attaching a buffer before that handshake completes is a protocol
error. The configure handler acks the serial, attaches, damages, and commits,
and that last commit is the moment the window appears.
You must answer xdg_wm_base.ping with pong. It is the compositor’s
liveness check. Ignore it and your client is killed as unresponsive. This is the
easiest thing in the protocol to forget, because nothing goes wrong until it
does.
The shell protocol is not in the library. xdg-shell ships as XML that
wayland-scanner turns into a header plus glue at build time. That is a normal
addSystemCommand step with addOutputFileArg, so the generated files can live
in the Zig cache with nothing checked in.
There are no window decorations. A compositor may offer to draw them via
zxdg_decoration_manager_v1, but it is not obliged to, and GNOME’s Mutter does
not advertise that global at all. A client that just posts a buffer gets exactly
that buffer: no titlebar, no close button, no way to move the window. Drawing
your own means allocating a taller buffer, painting a strip, tracking the
pointer, and turning a drag into xdg_toplevel_move, because window positions
belong to the compositor rather than to you. The cursor image is the client’s
job too: the pointer is undefined until you call wl_pointer.set_cursor, so
without a cursor theme loaded it simply vanishes over your window.
That list is why the X11 version above is one file and a Wayland one is not.
What neither version here does
Animation. One static frame. On Wayland, redraws are driven by a
wl_surface_frame callback so they land in step with the compositor. On X11 you
drive them from a timer or an idle loop.
Resizing. A real client allocates a new buffer on every ConfigureNotify or
configure. Reusing a fixed one leaves the extra area unpainted on X11 and
scaled on Wayland.
Double buffering. Writing into a buffer the compositor may be reading gets
you a torn frame. A real client keeps two and waits for wl_buffer.release.
Format negotiation. XRGB8888 is mandatory in the Wayland protocol so it is
safe to assume. The X11 code above checks only that the default visual is 24- or
32-bit, which is true on any desktop from the last twenty years and is not the
same as checking what is actually offered.