Building A Window Manager (Part 1)

Date of Release

A few years ago I switched full time from a traditional desktop environment to a tiling window manager (specifically bspwm). With how "close to the metal" such a WM puts the user (in comparison to a traditional DE), I've been interested in learning more about what makes them tick. That is what eventually led me to wanting to create my own tiling window manager. This is definitely outside my comfort zone, but as I think you'll see in this post, the individual parts are relatively simple and digestible. This won't be a comprehensive guide for crafting beautiful, elegant window managers but it should be a fun learning experience, and at the end we'll have something approximating usable*.

* Depends on how you define usable.


IMPORTANT! 6/14/24

A previous version of this post prefixed all of the Xlib proc calls with a captial "X". I have since updated my Odin version and the bindings have changed. I have updated the post content to reflect this change. I'm using the current (as of 6/14/24) master branch of Odin. If you're using an older version you may have to prefix the proc names (ie OpenDisplay -> XOpenDisplay).


For this project (which I've named valkyrie because I'm so original) I'll be using Odin and its vendor package for xlib. This package doesn't appear in the package docs because it is currently undocumented. However it's a pretty straightforward bindings package for the libX11 C library with a few bits and bobs to make using it in Odin more comfortable, so we should manage just fine. I've never worked with X11 before, so we'll be learning as we go. The code probably won't be professional quality, but that's okay because I'm no professional. However, I will make a concerted effort to keep the code organized and comprehensible.

The foundations of this project were borrowed from this post on writing a window manager in Rust. Using it as a starting skeleton, I intend to take this project farther than the linked two-part blog post does. No guarantees.

Why not XCB?

Good question, two answers:

A) I would have to write the Odin bindings for XCB myself and I'm lazy.

2) My understanding is that Xlib, while it has some legacy warts that might bite us later, has a much simpler API which may aid in the learning goal of this project. If necessary, we can port to XCB later.

The Big Picture

At its most abstract level, an X11 window manager does 4 things:

Simple, right? For this first post we will be implementing the most basic form of these four actions, after which our window manager will be able open windows for us. This won't be terribly useful as it'll stack multiple windows over top of each other, but it'll let us confirm that our setup is working.

The Code

Let's start with laying out a struct to hold our WM's state. For now we just need to hold a pointer to a Display struct from the Xlib package. I'm aliasing the import as x for no particular reason. Feel free to ignore that and use ^xlib.Display if you prefer.

1package valkyrie
2
3import x "vendor:x11/xlib"
4
5Valkyrie :: struct {
6    display: ^x.Display,
7}

Next we'll want to be able to create and initialize our WM struct. We'll compartmentalize this startup routine in a vlk_create() proc. This is where we'll see our first calls out to Xlib functions. Let's take a look and then we'll go through what's happening here in detail.

 1import "core:log"
 2import "core:os"
 3import "core:strings"
 4
 5// ...
 6
 7vlk_create :: proc(display_name: string, allocator := context.allocator) -> (vlk: ^Valkyrie) {
 8    vlk = new(Valkyrie)
 9    display_name := strings.clone_to_cstring(display_name, allocator)
10
11    vlk.display = x.OpenDisplay(display_name)
12    if vlk.Display == nil {
13        log.fatalf("Failed to open X display %q", display_name)
14        os.exit(1)
15    }
16
17    x.SelectInput(vlk.display, x.DefaultRootWindow(vlk.display), {.SubstructureRedirect})
18    return
19}

Our initialization proc takes a string for an X server display name, such as :0 you may have seen before in scripts and such. The allocator parameter is an Odin convention that defaults to the context's allocator. I'm choosing to use a named return that is a pointer to a Valkyrie struct.

If you're interested in the nitty gritty of X display names, check this section of the manpage.

Inside the proc we start by allocating our return struct, and then shadowing the display_name parameter while cloning it into a cstring. This is necessary as the OpenDisplay proc expects a null-terminated string but strings in Odin are a pointer + the length.

We then call OpenDisplay to open a connection between our WM and the X server. If successful, we get back a pointer to a Display. If not, we will get a nil pointer and so we check for that. Since an X11 window manager isn't much use if it can't connect to the X server, I'm choosing to log a fatal error and quit with a non-zero status if the returned pointer is nil.

By default the X server does not send any events, so we must explicitly subscribe to events that we're interested in receiving. We do this with a call to SelectInput. We pass our display pointer, a window (in this case we get the default root window), and a bit set which defines the events we want to receive. Here we're specifying the SubstructureRedirect event mask, which according to the Xlib docs relates to "redirect structure requests on children". For our purposes we don't really need to get more specific than that. Just know that it lets us receive an event when a change to the existence or layout of child windows is requested.

If you're unfamiliar with bit sets, you can see them in action in the Odin overview here.

If you're interested in more detail on exactly what the events included in the SubstructureRedirect mask do, see this section of the docs.

Doing The Thing

Now that we've gotten the initialization out of the way, we can write a proc to actually run our window manager. The basic flow here will be as follows:

  1. Ask the X server for an event
  2. Switch on the event's type
  3. Call out to a proc to handle the event
  4. GOTO 1
 1vlk_run :: proc(vlk: ^Valkyrie) {
 2    evt: x.Event
 3    log.info("Listening for events...")
 4    for {
 5        x.NextEvent(vlk.display, &evt)
 6
 7        #partial switch evt.type {
 8        case .MapRequest:
 9            vlk_create_window(vlk, &evt)
10        }
11    }
12}

Nothing too complicated here. We declare an event variable, call NextEvent to wait for one of the events we subscribed to, and check if it's a request to map (create) a window. For now this is the only event we handle, but this switch statement will grow as we add more features.

To actually create the window, we need to call MapWindow with the window ID. The Event type is a union, so before we can pull out the window ID we need to cast it into the appropriate struct type. Since we know our event type, we can safely cast the event into an MapRequestEvent.

1vlk_create_window :: proc(vlk: ^Valkyrie, evt: ^x.Event) {
2    req_evt := cast(^x.XMapRequestEvent)evt
3    x.MapWindow(vlk.display, req_evt.window)
4}

Now we can write our main proc like so:

 1import "core:log"
 2
 3main :: proc() {
 4    logger := log.create_console_logger()
 5    context.logger = logger
 6    defer log.destroy_console_logger(logger)
 7
 8    vlk := vlk_create(":69")
 9    vlk_run(vlk)
10}

For now we hard code the display name. Choose whatever number you like that isn't :0 or :1 (for some reason on my system $DISPLAY is :1).

See It In Action

In order to test and fiddle with our window manager without messing with our currently running X session, I'm using a tool called Xephyr. Xephyr opens a window and acts as an X server, allowing you to run a nested X server.

Install Xephyr with your package manager or what have you and start it like this:

1Xephyr -br -ac -noreset -screen WxH :69

Replace W and H with the desired width and height for the nested X server. Replace :69 with whatever number you used in the call to vlk_create.

Now in another terminal, start the window manager.

1odin run .
2[INFO ] --- [2024-06-14 02:56:55] [valkyrie.odin:28:vlk_run()] Listening for events...

In yet another terminal, try opening an application on that display. I like to test with urxvt.

1DISPLAY=:69 urxvt

You should see the app pop up in your Xephyr window. If not, double check your code with the project repo here.

I'm going to end this post here and pick back up with a part two where we'll add a way to track our child windows and do some basic tiling layout. After I learn how to do that, that is...