Architecture
For anyone changing the code. fwm is a single-threaded wlroots compositor in C11, with three worker threads that never touch compositor state and one hard rule: nothing blocks the event loop, because fwm is the display server and a compositor that waits is a session that has stopped.
Contents: the mirror · the tick · threads · the file map · UI overlays · config · tests · conventions
The mirror
The compositor never talks to Box2D. PhysicsWorld (src/physics.h) holds a plain
array of PhysicsBody — position, size, velocity, flags — and that is what the
rest of fwm reads and writes. physics_step() is the only bridge:
push the mirror into Box2D → b2World_Step → pull Box2D back into the mirror
Two things make this work:
- Shadow fields. Each body slot remembers the values last written back. If the mirror differs from its shadow, something outside changed it (a drag, a throw, a teleport) and it is forced into Box2D; otherwise Box2D’s own evolution is left alone. This is what lets a drag write only a position and still produce correct contacts.
- Slot pairing.
world->bodies[i]always corresponds to engine sloti. A closed window releases its Box2D body immediately so the slot is safe to hand to the next window.
Everything the simulation needs about one window — mass, bounce, friction, gravity
scale — is resolved per step in material_for() from three layers: the world’s
[physics], the desktop’s profile, and the window’s [[rule]]. That is why a
window dragged across a desktop edge changes as it crosses.
See Physics for the model itself.
The tick
server_tick.c is the heartbeat. Its shape matters:
- A fixed 1/60 s simulation step with an accumulator: as many whole steps as the elapsed time paid for, usually one, capped at two after a stall so a hitch cannot make the world jump.
- Everything visual — impact squash, camera shake, the drag pendulum, the
render angle of a spinning window — advances at frame time in
server_animate()(src/server_output.c), immediately before the scene is committed. Two unsynchronised 60Hz clocks beat against each other, and a fade that is perfectly even in the log arrives visibly uneven on screen. - The loop idles at 200 ms when nothing is moving. Any UI animation therefore
has to clamp its
dtto one 60Hz frame, or the first tick after idle carries the whole 200 ms gap and finishes the animation in a single step. - Per-tick syncs (
server_cava_sync,server_sound_sync,server_mass_sync) are cheap and idempotent by design: each compares the config against what it last acted on, so a keybind,fwmctl set, the modes menu and a config reload all reach the same code without any of them knowing the others exist.
Threads
Three, all of them for things that block. None touches compositor state; each hands over data through a lock or an atomic.
| Thread | Why it exists |
|---|---|
Video wallpaper decode (src/video.c) |
libav decode of a clip, into a queue the compositor drains |
Audio capture (src/audio.c) |
pw_context_new() deadlocks with no sound server running — measured, still stuck after 90s. Everything PipeWire happens off the main thread, setup included |
Sound mixer (src/sound.c) |
pa_simple_write parks until the device has room |
Both audio threads are never joined. They are told to stop and ownership is
refcounted, so whichever of the two lets go last frees the handle. Joining cost
218–786 ms of frozen session every time [cava] was switched off, which is not a
price to pay for turning a feature off.
The file map
| Area | Files |
|---|---|
| Simulation | physics.c (the mirror + Box2D), wobble.c (drag lattice) |
| Compositor core | server.c, server_lifecycle.c, server_tick.c, server_output.c, server_shell.c, server_seat.c |
| Input | server_input.c (keyboard, binds, modes), server_pointer.c, server_drag.c, gestures.c + server_gestures.c |
| Windows | view.c, view_effects.c (squash/wobble/fade), snapshot.c, rotate.c (the one GLES2 path), group.c (tabs) |
| Layout | bsp.c (the tree), server_tiling.c (applying it), server_desktop.c |
| Desktops | expo*.c (the strip and its 3D view) |
| Config | config.c, config_binds.c, config_set.c, toml.c |
| State & IPC | session.c, ipc.c, ipc_events.c, server_config.c (paths, reload, remembered choices) |
| Wallpaper | wallpaper.c, video.c |
| Audio | audio.c (capture), cava.c (FFT + bars), sound.c (mixer), wav.c |
| Light | sun.c (where the sun is — arithmetic, no scene), shadow.c (what it draws: one shared nine-patch image, nine slices per window) |
| Extras | ram.c (memory footprints), theme.c (palette), lock.c, layer.c, foreign.c |
| UI | ui/*.c — see below |
server.c was split by area rather than by size; server_internal.h is the
contract between those pieces and is where a new one is declared.
UI overlays
Everything fwm draws itself is a cairo surface in the scene graph, built through
ui/cairo_overlay.c: it owns the buffer, the node animation (fade and slide) and
the redraw plumbing. An overlay that also animates inside its buffer — a switch
knob sliding, a highlight travelling — runs its own tick, because a node transform
cannot express that (ui/modes.c is the example).
| File | Draws |
|---|---|
ui/tray.c |
the status strip, including the modes pill |
ui/modes.c |
the modes menu: icons, rows, switches, segmented controls |
ui/launcher.c |
the application launcher |
ui/hints.c |
the key hints, read from the live config |
ui/errors.c |
the config-problem panel |
ui/welcome.c |
first-run panel |
ui/expo_menu.c, ui/expo_hints.c |
inside the desktop strip |
ui/logo.c |
the mark |
Icons are cairo paths, not glyphs: a compositor cannot assume a font has them.
Config
config.c parses TOML (vendored toml.c) into one FwmConfig, and carries one
promise: it always returns a working config. Every failure is recorded in
cfg->errors[] and defaults stand in; when [binds] yields nothing usable, the
built-in binds are installed so a typo cannot leave a compositor with no way to
open a terminal or exit.
Two hand-kept lists have to agree with the code:
action_is_known()(config.c) vs. the dispatch switch inserver_actions.c— when they drift, a perfectly good action is reported as unknown and dropped.tests/test_config.cbinds every documented action to catch exactly that.default_binds[](config_binds.c) vs.config.toml.example.
config_set.c is the runtime-settable table: {name, type, offset, min, max, help} into FwmConfig, which is how fwmctl get/set/config work without a
line of code per option. Writes never touch the file. config_option_check is
the same parse storing nothing, which is what lets a request carrying several
settings be refused whole rather than half-applied.
Choices the UI makes (the picked wallpaper, the modes menu) live in
~/.local/state/fwm/ and are applied over the config after every load — see
server_config.c. The user’s file is never rewritten.
~/.local/state/fwm/settings is the same idea opened up to the socket: what
fwmctl save writes, applied over the config on every load. Three things hang
together there and are easy to break separately:
- The baseline — every option’s value with the config file alone, captured
between parsing it and applying anything over it.
save --allandunsaveare both questions about the difference, and after the overlay is on, nothing else can tell the two apart. - The order: settings overlay first, modes file second, so the two switches the modes menu owns still answer to the menu.
server_settings_notifyis the ONE place thesettingevent comes from. It compares every option against what was last announced, at the end ofserver_apply_configand after every dispatched action — because an option changes through the socket, through a keybind and through the modes menu, and a per-site emit is two chances to add a fourth route and forget.
Tests
cmake -S . -B build-test -DFWM_TESTS=ON && ctest --test-dir build-test
Only modules that need no compositor, no display and no sound card. Each suite exists because its subject cannot be checked by hand in a running session.
| Suite | Covers |
|---|---|
test_bsp |
the tiling tree: splits, removal, leaf collection |
test_config |
parsing, every documented action, and the promise that a broken file still yields a usable config |
test_input |
bind matching, modes, mouse binds |
test_physics |
the ends of the world: throws, walls, the ring join, mass scaling |
test_physics_speed |
what has to keep working at speed — the drag that used to pass through windows, the world ceiling, walls under absurd throws |
test_ipc_events |
the event name/bit vocabulary |
test_wobble |
the drag lattice, which only runs while a button is held |
test_gestures |
what a swipe means, without a touchpad |
test_sun |
where the sun is at each hour, and what dusk does — half of it only happens at sunset |
test_ram |
/proc parsing and the process-tree sum |
test_wav |
the WAV reader, fed truncated and lying headers |
A compositor cannot be driven by a harness — there is no way to inject a keypress
or move the pointer — so anything pointer-driven is tested through a temporary
FWM_TEST_* hook and run under ASan instead. See
Troubleshooting.
Conventions
- Comments say why, not what. The interesting ones record a measurement or a
bug that was paid for; that is the house style, and
git logcontinues it. - px, y-down, top-left origin everywhere outside physics.c, which converts at its boundary (100 px per metre).
- Failures degrade. A missing sound server, an unreadable image, a broken config, a client that dies mid-drag: each has a defined fallback, and none of them may take the session down.
-Wall -Wextra -fstack-protector-strongin every build, release included: a compositor cannot report its own memory corruption.- Commits explain the reasoning at length. Read a few before writing one.