This one took me a week combing through docs watching others do it to figure this out. So maybe someone else would find a need for this. Although the API is a bit odd, it's wild to think that this API is from the 1980's and it still works on my computer in 2025.
If you rather just look at the code, you can find the repository here
Before I begin I'll link a few things that have proven to be quite useful.
- It's quite dense but reading the first few sections has proved quite useful. X Window System Protocol manual
- Index of functions for X11
- For a funny explanation of all things X11 you can watch the X11 App in C with Xlib by Tsoding Daily
- Asking Claude for explanations for some quirky X11 things has been found to be very useful too.
Setting up Zig
After you run zig init
into your project. In your build.zig
link the system library before you add the artifact.
exe.linkSystemLibrary("X11");
exe.linkLibC();
Then in your main.zig
you can use X11
by using cImport
. Like so:
const std = @import("std");
const c = @cImport({
@cInclude("X11/Xlib.h");
});
From then on there are a few steps before you can open a window.
Opening in window
Here are the steps:
- Open a display with XOpenDisplay
- You can defer XCloseDisplay. This will clear all the resources that X11 creates and you don't need to clear or close anything else.
- Then you'll need a screen, I've just used XDefaultScreen
- After this you can open a window, I've used the XCreateSimpleWindow there is also a complex one which I haven't used, simply because it needed too many things. You'll need a
parent
when you create a window. I've used the XRootWindow to get this. I don't really know what this does. If you do, you can let me know. - You are nearly there. Now all you need to do is map the window with XMapWindow and then call XSync to open the window. Just block after so that you can see the window by running a
while(true)
loop.
Listening to events
You'll need some events to know when the window has been closed, resized etc. You can't just listen to it. You have to let X11
know that you want to be notified when these things happen on your window. Something to do with bandwidth in the 80's. Not exactly sure why.
Before you can be notified of events you need to let X11
know you'd like to be notified by calling XSelectInput. You need to pass the event masks for the events you'd like to be notified for. You can find the events and their masks here.
As an example if you'd like to know when a key gets pressed on the window or if the window was resized you can do by:
_ = c.XSelectInput(display, window, c.KeyPressMask | c.StructureNotifyMask);
Then you can listen for the events by calling XPending and see if there any events that you have received. To get the actual event you can call XNextEvent. Although if I remember there are other ways to do this.
Closing windows
If you have stuck through so far, you'll notice that even if you close your window the program continues to run. It's because there is whole another way to listen for window closing events. As far as I understand it's got something to do with the fact that the window manager is responsible for this. Anyway you listen for close events like so:
var delete_atom: c.Atom = undefined;
delete_atom = c.XInternAtom(display, "WM_DELETE_WINDOW", 0);
const protocol_status = c.XSetWMProtocols(display, window, &delete_atom, 1);
if (protocol_status == 0) {
std.debug.print("failed to set wm_delete protocol", .{});
return 1;
}
// listening for the close event
while (c.XPending(display) > 0) {
_ = c.XNextEvent(display, &event);
switch (event.type) {
c.ClientMessage => {
if (event.xclient.data.l[0] == delete_atom) {
std.debug.print("Closing window.\n", .{});
quit = true;
break;
}
},
...
You can find more about the event data by looking into XEvent
type definition.
Bonus section: Drawing stuff to the window
I'll leave the entire method here as it's too much to explain. In a nutshell I've used the XPutImage to write an array of pixels onto the display. You can have a look at the arguments and work backwards to figure it out. The main thing to note is the char * data
. Which is the buffer that the window draws onto the screen. I've used a []u32
and then cast it to a char *
which is the equivalent of []u8
. As it was much easier to write the entire pixel at once.
Here is the draw
method:
fn redraw(arena: *std.heap.ArenaAllocator, display: ?*c.Display, window: c.Window, gc: c.GC) void {
var wa: c.XWindowAttributes = undefined;
_ = c.XGetWindowAttributes(display, window, &wa);
const bytes_per_pixel = @sizeOf(u32);
const size: usize = Height * Width * bytes_per_pixel;
// always true
_ = arena.reset(.free_all);
var pixels = arena.allocator().alloc(u32, size) catch unreachable;
var pixel_idx: usize = 0;
for (0..Height) |y| {
pixel_idx = y * Width;
for (0..Width) |x| {
pixels[pixel_idx + x] = @intCast(x * y);
}
}
var image = arena.allocator().create(c.XImage) catch unreachable;
image = c.XCreateImage(
display,
wa.visual,
@intCast(wa.depth),
c.ZPixmap,
0,
@ptrCast(pixels),
@intCast(Width),
@intCast(Height),
32,
0,
);
_ = c.XPutImage(display, window, gc, image, 0, 0, 0, 0, @intCast(image.width), @intCast(image.height));
}
Working with C in Zig
It is surprisingly quite easy to work with C in Zig. Sure there is a lot of casting which is expected as you need to translate Zig types to C types. But overall it was quite easy to get this working even though I'm quite new to Zig. The best part is that the rest of it can be done entirely done in Zig or so I thought. As it turns out I still got to get audio and keyboard/gamepad working. Which is a story for another day.