
A couple years ago, especially during the COVID pandemic, I was very interested in customizing my Linux desktop - a process many known as "ricing", originating in automotive slang for customized cheap Asian cars. Through the process I realized that while the pleathora of desktop environments and window managers offered everything I could possibly want, it'd be cooler to build my own.
While the Wayland protocol is the standard for modern desktops, with X11 being effectively obsolete, I wrote worm during the period when the *nix community was in the process of transitioning, and X11 was still the default display server on most desktop-oriented distributions. X11 does a lot of the heavy lifting for you, acting as the display server which actually renders pixels onto your screen. It seperates the compositor (think picom or compiz) - which renders windows off-screen to prevent tearing - from the window manager. As such, your window manager's logic focuses solely on actually managing windows, and not low-level complex graphics APIs.
Initially, I wrote worm in Rust, but I ran into a couple limitations of the x11rb library I chose to use and the borrow checker kinda got on my nerves with X11 types, so I migrated to another language I really liked - Nim. In this article, rather than the rewrite (as the Rust version was a very simple, non-reparenting WM) I'll focus on the technical architecture of the current version.
Reparenting vs non-reparenting WMs
There are two major types of window managers: reparenting and non-reparenting.
X11's hierarchy
In X11, the window manager is a client of the server just like every other application on your screen - it just happens to be the one that launches first and therefore controls the others. These clients follow a hierarchy where windows can have both parent and child windows. When your X11 server initializes, it will create a root window, similar to how a root directory / works in a file tree. Every client launched after is thus a child of the root window (analogous to /usr, /lib, ...).
Why do we care?
Reparenting window managers take advantage of this hierarchy. Whenever an application is launched, they "re-parent" it. Instead of the application being a direct child of the root window, it is now the child of a window created by the window manager. This new parent window is managed by us, and serves to handle drawing the titlebar, as well as moves and resizes. This technique is how almost (I say almost because projects like berry don't) every window manager that draws titlebars does so - whether it's a free-standing WM like openbox, or the WMs that come with desktop environments (Gnome's mutter, KDE's kwin, ...)
Non-reparenting windows are a lot simpler to build, and they can manage windows just fine. Many window managers such as dwm and bspwm are non-reparenting. But the standard way to do titlebars - a feature I really wanted - is to have a larger parent window and blit the pixels for the buttons, titles, etc. directly upon that window.
However, this approach comes with a major caveat: the window manager now has to act as a mini X11 system itself. You aren't just deciding where a window goes anymore, but you have to handle all the events of that child window.
X11's Event Loop
A window manager is really just a glorified event loop. It spends a lot of it's time idling and waiting for the next event.
proc eventLoop*(self: var Wm) =
while true:
discard self.dpy.XNextEvent(unsafeAddr self.currEv)
self.dispatchEvent self.currEv
Once it does recieve an event to handle, it dispatches it to the appropriate handler.
proc dispatchEvent*(self: var Wm; ev: XEvent) =
case ev.theType:
of ButtonPress: self.handleButtonPress ev.xbutton
of ButtonRelease: self.handleButtonRelease ev.xbutton
of MotionNotify: self.handleMotionNotify ev.xmotion
# ... so on and so forth ...
Some events of note:
MapRequest
When a window wants to be displayed on the screen, it sends a MapRequest. Worm listens for these and intercepts them with perhaps it's lengthiest event handler. This is wwhere the actual reparenting happens. First, a new parent window is created with the appropriate dimensions, which it fetches from the user's settings:
var frameAttr = XSetWindowAttributes(backgroundPixel: culong self.config.frameActivePixel,
borderPixel: self.config.borderActivePixel, colormap: attr.colormap)
let frame = self.dpy.XCreateWindow(self.root, attr.x +
self.config.struts.left.cint, attr.y + self.config.struts.top.cint, cuint attr.width, cuint attr.height +
cint frameHeight,
cuint self.config.borderWidth, attr.depth,
InputOutput,
attr.visual, CWBackPixel or CWBorderPixel or CWColormap, addr frameAttr)
and then this window is set as the parent of the window the event was called for.
discard self.dpy.XReparentWindow(ev.window, frame, 0, cint frameHeight)
This new parent window, in addition to the child window it just gained, will spawn a couple other children for it's window controls and title. That looks like this:
let minimize = self.dpy.XCreateWindow(top, cint attr.width -
self.config.buttonSize.cint, 0, self.config.buttonSize.cuint, cuint frameHeight,
0, attr.depth,
InputOutput,
attr.visual, CWBackPixel or CWBorderPixel or CWColormap, addr frameAttr)
worm maintains this hodgepodge collection of windows grouped under one parent window as one Client, with the actual application window in one field and all the other frame subwindows in another:
self.clients.add Client(window: ev.window, frame: Frame(window: frame,
top: top, close: close, maximize: maximize, minimize: minimize,
title: titleWin), draw: draw, color: color,
title: $title, tags: self.tags, floating: self.layout == lyFloating,
frameHeight: frameHeight, csd: csd, class: $chr.resClass, maximized: max)
There's a lot more to it - ensuring the newly opened window has the right focus on the actual application, checking de-facto standardized NetWM atoms (X11's way of storing window properties) to handle window titles, and more... but that'd be out of scope for a blog post.