// 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 pts

Implement 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..0x7e and 0xa0..0xff โ€” become Key::Text with ch equal to the keysym value (X11 deliberately made these keysyms equal their character codes; the server has already applied Shift via the keymap, so A arrives as keysym 0x41).
  • The navigation/editing keysyms in the table in the starter map to their named Key values, with ch = 0.
  • Anything else becomes Key::Unknown with ch = 0 (real editors ignore these).
  • shift, ctrl, alt come from the state bitmask (ShiftMask, ControlMask, Mod1Mask). Other bits โ€” Caps Lock, NumLock โ€” must be ignored, not rejected: state 0x12 (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 setting shift/ctrl/alt.

Log in to submit a solution and earn points.

โ€บ Atoms and the Close Button

12 pts

Two 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 type is not the WM_PROTOCOLS atom, it's not ours: return WmAction::None.
  • If data0 is the WM_DELETE_WINDOW atom: return WmAction::Close. (The caller decides whether to prompt about unsaved changes.)
  • If data0 is the _NET_WM_PING atom: return WmAction::PingReply with reply equal to the incoming message except window replaced by root_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.