// lesson: a-window-on-x11
A Window on X11
Time to open a real window. We'll do Linux/X11 in full working code, then walk the same ground on Windows and macOS so you can see that the three APIs are one design wearing three costumes. (Wayland is Linux's successor protocol; its concepts map closely enough to X11's that everything here transfers, and X11 still runs everywhere via XWayland.)
What X11 actually is
X is not a library โ it is a network protocol, born in 1984 at MIT,
between your program (the client) and the X server, the process that
owns the screen, the mouse, and the keyboard. Everything you "do" to the
screen is a byte-serialized request written to a socket: CreateWindow is
request opcode 1, MapWindow opcode 8. Everything the user does arrives
back on the same socket as 32-byte event packets. That a window is "yours"
means only that you know its 32-bit ID and the server will route its
events to your socket. This design is why X could run applications on a
mainframe displaying on a terminal across the building โ and why every X
call has latency in mind: requests are buffered and flushed in batches,
and most don't wait for a reply.
Two client libraries speak this protocol: Xlib (1985, hides the asynchrony behind a synchronous-looking API) and XCB (2001, exposes the protocol nearly 1:1 โ you send a request, you later fetch the reply). We'll use XCB: it's honest about what's on the wire, and that honesty is the lesson.
A complete window, annotated
This is a full program โ about sixty lines โ that opens a window, paints
it, and closes cleanly. Build it with g++ main.cpp -lxcb on any Linux
box with X headers (apt install libxcb1-dev / pacman -S libxcb). Read
every comment; each one is a concept the challenges will test.
#include <xcb/xcb.h>
#include <cstdio>
#include <cstring>
int main() {
// 1. Connect: open the socket to the X server ($DISPLAY tells us where).
xcb_connection_t* conn = xcb_connect(nullptr, nullptr);
if (xcb_connection_has_error(conn)) return 1;
// The "screen" carries server facts: root window ID, depth, colors.
const xcb_setup_t* setup = xcb_get_setup(conn);
xcb_screen_t* screen = xcb_setup_roots_iterator(setup).data;
// 2. Create: WE pick the window ID (from a client-side ID range the
// server granted at connect time โ that's how creation needs no
// round trip). The window is a child of the root window.
xcb_window_t win = xcb_generate_id(conn);
uint32_t value_mask = XCB_CW_BACK_PIXEL | XCB_CW_EVENT_MASK;
uint32_t values[2] = {
screen->white_pixel,
// The event mask is a SUBSCRIPTION: the server sends only what
// you ask for. Forget EXPOSURE and you'll never be told to paint.
XCB_EVENT_MASK_EXPOSURE | XCB_EVENT_MASK_STRUCTURE_NOTIFY |
XCB_EVENT_MASK_KEY_PRESS | XCB_EVENT_MASK_BUTTON_PRESS |
XCB_EVENT_MASK_BUTTON_RELEASE | XCB_EVENT_MASK_POINTER_MOTION,
};
xcb_create_window(conn, XCB_COPY_FROM_PARENT, win, screen->root,
0, 0, 800, 600, 0, // x, y, w, h, border
XCB_WINDOW_CLASS_INPUT_OUTPUT,
screen->root_visual, value_mask, values);
// 3. The close-button dance (explained below): tell the window manager
// we understand the WM_DELETE_WINDOW protocol.
xcb_intern_atom_cookie_t c1 = xcb_intern_atom(conn, 0, 12, "WM_PROTOCOLS");
xcb_intern_atom_cookie_t c2 = xcb_intern_atom(conn, 0, 16, "WM_DELETE_WINDOW");
xcb_intern_atom_reply_t* r1 = xcb_intern_atom_reply(conn, c1, nullptr);
xcb_intern_atom_reply_t* r2 = xcb_intern_atom_reply(conn, c2, nullptr);
xcb_atom_t wm_protocols = r1->atom, wm_delete = r2->atom;
free(r1); free(r2);
xcb_change_property(conn, XCB_PROP_MODE_REPLACE, win, wm_protocols,
XCB_ATOM_ATOM, 32, 1, &wm_delete);
// 4. Map: creating a window doesn't show it. Mapping asks for it to
// become viewable; the window manager may intervene first.
xcb_map_window(conn, win);
xcb_flush(conn); // requests are buffered โ actually send them!
// 5. The event loop. xcb_wait_for_event blocks on the socket.
bool running = true;
while (running) {
xcb_generic_event_t* ev = xcb_wait_for_event(conn);
if (!ev) break; // connection died
switch (ev->response_type & ~0x80) { // high bit = "synthetic"
case XCB_EXPOSE: {
// A region became visible and its pixels are GONE. X does
// not remember your window's contents; repaint or stay blank.
auto* e = reinterpret_cast<xcb_expose_event_t*>(ev);
std::printf("expose %dx%d at (%d,%d), %d more coming\n",
e->width, e->height, e->x, e->y, e->count);
break;
}
case XCB_CONFIGURE_NOTIFY: {
auto* e = reinterpret_cast<xcb_configure_notify_event_t*>(ev);
std::printf("now %dx%d\n", e->width, e->height);
break;
}
case XCB_KEY_PRESS: {
auto* e = reinterpret_cast<xcb_key_press_event_t*>(ev);
std::printf("keycode %d, modifier state %#x\n",
e->detail, e->state);
break;
}
case XCB_CLIENT_MESSAGE: {
auto* e = reinterpret_cast<xcb_client_message_event_t*>(ev);
if (e->type == wm_protocols &&
e->data.data32[0] == wm_delete)
running = false; // close button clicked
break;
}
}
free(ev); // XCB events are malloc'd; in real code, wrap in RAII
}
xcb_disconnect(conn);
return 0;
}
Five ideas in that listing deserve a closer look.
Expose: the server remembers nothing
The deepest difference from "retained" UI frameworks: the X server does
not keep your window's pixels (historically, server memory was precious).
When your window is uncovered, resized, or first shown, you get an
Expose event naming the rectangle that needs painting, and if you don't
paint it, it stays garbage. Your editor is therefore, at bottom, a
function from document state to pixels, called every time the OS asks.
Win32 is identical in spirit: the WM_PAINT message with an "invalid
region". Cocoa too: drawRect: with a dirty rect. Every platform hands
you damage and expects you to repaint it โ which is why lesson 12
builds a damage-tracking system instead of repainting the world per
keystroke.
Note e->count: expose events can arrive as a batch of rectangles, and
count tells you how many more follow. The classic optimization โ union
the rects, repaint once when count == 0 โ is your pump coalescing
from lesson 1, in the wild.
Atoms: interned strings
X needed extensible message types without central registration, so it
interns strings: xcb_intern_atom("WM_DELETE_WINDOW") returns a small
integer โ the atom โ that every client asking for the same string
gets back. Properties (arbitrary data attached to windows) are keyed by
atoms; client messages are typed by atoms; clipboard formats are atoms.
It's a string-to-int hashmap that lives in the server, and half of X's
"protocols" are just conventions about which atom-named properties to
set. You'll implement the interning contract in this lesson's second
challenge, and atoms return with a vengeance in the clipboard lesson.
The window manager and WM_DELETE_WINDOW
Here's the part that surprises everyone: the close button is not part
of X. The title bar, the borders, the [x] โ they're drawn by another
ordinary client, the window manager, which X grants the special right
to intercept map requests and re-parent your window inside a frame. So
how does clicking the [x] reach you? Convention, standardized in the
ICCCM: you set the WM_PROTOCOLS property on your window listing the
atom WM_DELETE_WINDOW, which means "don't just destroy me โ send me a
message". The WM then delivers a ClientMessage of type WM_PROTOCOLS
whose first data word is WM_DELETE_WINDOW. If you don't opt in, the
WM calls XKillClient and your process dies mid-write. An editor with
unsaved changes cares deeply about the difference.
There's a second protocol worth knowing: _NET_WM_PING. Compositors use
it to detect hung apps โ the WM sends a ping ClientMessage and you must
echo it back (re-addressed to the root window) promptly, or the desktop
grays out your window and offers "Force Quit". Both protocols are pure
message-routing logic, which makes them perfect headless challenge
material.
Keycodes, keysyms, and state
XCB_KEY_PRESS gives you a keycode โ a number for a physical key
position (row/column of the switch, roughly). Keycode 38 is the key
labeled A on a US board and Q on a French one. Turning keycodes into
meaning is a two-step lookup: the server's keymap maps (keycode,
modifier level) to a keysym โ a stable symbolic constant like
XK_a (0x61), XK_Return (0xff0d), XK_Left (0xff51). Keysyms for
Latin-1 characters equal their character codes; function and navigation
keys live in a reserved 0xff00 page. The event's state field is a
bitmask of modifiers held at the moment of the press: ShiftMask
(bit 0), LockMask (bit 1, Caps), ControlMask (bit 2), Mod1Mask
(bit 3, almost always Alt).
Keep this distinction sharp โ it becomes a whole design principle in the final lesson: key events are for shortcuts; committed text is for typing. Ctrl+S is a key event you match against keysym+state; the letter "รฉ" typed via a compose sequence or an IME is text that may never correspond to any single key press.
The same machine, twice more
Win32. You register a window class naming a callback, create a window of that class, then pump messages:
// Win32: the same event loop, spelled differently.
LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
switch (msg) {
case WM_PAINT: /* BeginPaint gives the invalid rect; draw; EndPaint */
case WM_KEYDOWN: /* virtual-key code in wp โ the "keysym" step */
case WM_CHAR: /* translated TEXT arrives separately! */
case WM_CLOSE: /* the close button โ you may veto */
case WM_DESTROY: PostQuitMessage(0); return 0;
}
return DefWindowProc(hwnd, msg, wp, lp); // defaults for the rest
}
// ... RegisterClassEx{ .lpfnWndProc = WndProc, ... }; CreateWindowEx(...);
while (GetMessage(&msg, nullptr, 0, 0) > 0) {
TranslateMessage(&msg); // keydown -> WM_CHAR text events
DispatchMessage(&msg); // calls WndProc
}
The differences are instructive. Where X pushes events through one socket
and you switch on a type field, Win32 calls you back per-window โ but
notice GetMessage/DispatchMessage: the loop is still yours, the
callback is just where the switch lives. DefWindowProc is the genius
move: unhandled messages get OS-default behavior, which is why a minimal
Win32 window already moves, resizes, and closes. TranslateMessage is
the keysym step made explicit: raw WM_KEYDOWN goes in, cooked WM_CHAR
text comes out as a separate message โ the same shortcut/text split as
X. And WM_PAINT is Expose with one refinement: it's not queued
per-occurrence but synthesized when the queue is empty and the invalid
region is non-empty โ the OS coalesces paint events for you. For pixels,
the classic path is GDI (StretchDIBits to push a memory bitmap, as
we'll do in lesson 4); modern apps layer Direct2D/DirectWrite on top, but
the message machinery is unchanged since 1985.
Cocoa. macOS inverts control completely: [NSApplication run] is
the loop, and you never see it. You hand the framework objects and it
calls their methods:
// Cocoa: the loop belongs to AppKit; you supply delegates and views.
@interface EditorView : NSView @end
@implementation EditorView
- (void)drawRect:(NSRect)dirty { /* your Expose/WM_PAINT */ }
- (void)keyDown:(NSEvent*)ev {
// Route to the text system: this is how keystrokes become TEXT,
// IME included. insertText: arrives with the committed string.
[self interpretKeyEvents:@[ ev ]];
}
- (void)mouseDown:(NSEvent*)ev { /* hit test, place caret */ }
@end
// AppDelegate's applicationShouldTerminate: is WM_DELETE_WINDOW's cousin:
// a chance to say "wait, unsaved changes" before quitting.
Why the .mm files? Cocoa's API is Objective-C: message sends,
@interface, blocks. Objective-C++ lets one translation unit contain
both languages, so the convention is: your portable C++ core, plus a thin
platform_cocoa.mm whose Objective-C classes call into it. The seam from
lesson 2 is what makes this clean โ CocoaWindow : PlatformWindow wraps
an NSWindow*, and nothing else in the program knows Objective-C exists.
One more Cocoa distinction: the window does keep its contents (layers
are retained and composited), so drawRect: fires far less often โ but
the contract is the same: here's a dirty rect, fill it.
Same skeleton, three times: subscribe โ loop โ translate native events into your portable Event type โ repaint damage. The two challenges below implement the "translate" step for X11's two trickiest cases; your real backend calls exactly these functions from its event switch.
โบ Translate Keys
12 ptsImplement translate_key, the function your XCB_KEY_PRESS handler calls
after looking up the keysym: it turns an X11 keysym plus modifier state
into the portable KeyEvent the editor core understands. (The Win32
backend feeds virtual-key codes through a twin of this function โ the
portable side never knows.)
Rules:
- Keysyms in the printable Latin-1 ranges โ
0x20..0x7eand0xa0..0xffโ becomeKey::Textwithchequal to the keysym value (X11 deliberately made these keysyms equal their character codes; the server has already applied Shift via the keymap, soAarrives as keysym 0x41). - The navigation/editing keysyms in the table in the starter map to their
named
Keyvalues, withch = 0. - Anything else becomes
Key::Unknownwithch = 0(real editors ignore these). shift,ctrl,altcome from the state bitmask (ShiftMask,ControlMask,Mod1Mask). Other bits โ Caps Lock, NumLock โ must be ignored, not rejected: state0x12(Lock plus a NumLock-like bit, with none of the three masks above set) still decodes as an unmodified key โ those extra bits are noise, not grounds for rejecting the event or spuriously settingshift/ctrl/alt.
Log in to submit a solution and earn points.
โบ Atoms and the Close Button
12 ptsTwo functions, straight out of the annotated program above.
First, model the server's atom table. intern(table, name) returns the
atom for name, creating it if needed โ the same name must always yield
the same atom, and atom 0 is reserved to mean "None" (so your first
atom is 1). This is exactly the contract of xcb_intern_atom.
Second, handle_client_message โ the routing logic your
XCB_CLIENT_MESSAGE case delegates to:
- If the message's
typeis not theWM_PROTOCOLSatom, it's not ours: returnWmAction::None. - If
data0is theWM_DELETE_WINDOWatom: returnWmAction::Close. (The caller decides whether to prompt about unsaved changes.) - If
data0is the_NET_WM_PINGatom: returnWmAction::PingReplywithreplyequal to the incoming message exceptwindowreplaced byroot_windowโ that's the echo the window manager is waiting for, timestamp and all. - Any other protocol:
WmAction::None.
handle_client_message must intern the three protocol names itself
(interning is idempotent, so calling it repeatedly is safe and cheap โ
in the real backend you'd cache them, but the semantics are identical).
Log in to submit a solution and earn points.