How to open a window in X11 with Zig

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.

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:

  1. Open a display with XOpenDisplay
  2. You can defer XCloseDisplay. This will clear all the resources that X11 creates and you don't need to clear or close anything else.
  3. Then you'll need a screen, I've just used XDefaultScreen
  4. 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.
  5. 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.