Architecture
Compust is an X11 client that owns the compositor selection for one screen. The X server retains responsibility for input, application connections, and display hardware. The window manager retains responsibility for placement, decorations, focus, and workspaces.
From a window to a frame
Application draws into a redirected window
|
Damage / window events
|
Scene: stacking and lifetime
|
XRender: wallpaper -> blur -> windows
|
Present copy or XRender copy
|
Composite overlay (empty input region)
session.rs negotiates capabilities and claims _NET_WM_CM_Sn. A short server grab makes checking the existing owner and acquiring redirection atomic with respect to other clients. The grab is released on the error path too. A real server timestamp is obtained before claiming the selection and announcing MANAGER.
surface.rs captures each viewable top-level window using a named pixmap. Damage is attached to the pixmap, which remains valid while the closing animation completes even if the original window disappears. picture.rs owns XRender pictures and owned pixmaps; closing the final connection also releases server resources and redirection after startup failures.
scene.rs stores surfaces in server stacking order. It mirrors the root's children from create, destroy, reparent, configure, and circulate events, so restacking costs no QueryTree round trip. A query's reply already reflects every event the server generated before handling it, so events with an earlier sequence number leave the mirror alone; an event that names a window the mirror lacks triggers a new query. Destroyed windows' surfaces remain below their former upper neighbor until the fade finishes. Remapping a window replaces its content only after successful capture, preserving the current opacity for reopening. events.rs updates their geometry, lifetime, shapes, and opacity. The server gives a window a new pixmap on every resize and reports every resize, so a surface is recaptured when the size in a ConfigureNotify differs from the captured one. That includes a window resized and restored before the events are handled, whose first event carries the intermediate size; comparing only the current size would miss it. A move only updates the position from the event, without a round trip. surface/client.rs discovers the application using WM_STATE through at most eight descendant levels. It subscribes to property and child-window events on each visited window before inspecting its state. Late creation or removal of WM_STATE, child creation, reparenting, and destruction refresh the affected frame's client association and opacity. The frame's opacity property takes precedence when present.
A descendant that disappears during discovery is skipped only for BadWindow. If the selected client disappears before its opacity can be read, the surviving frame uses its own opacity or the opaque default; subsequent lifecycle events refresh the association. Losing a client must not close the frame's surface. This is deliberately limited support for window-manager conventions, not complete EWMH/ICCCM implementation.
rules.rs holds the per-window rules. A capture reads the client's identity in one round trip: the resource class from WM_CLASS, the first type in _NET_WM_WINDOW_TYPE that Compust knows, whether WM_TRANSIENT_FOR is set, the title from _NET_WM_NAME or else WM_NAME, whether _GTK_FRAME_EXTENTS declares margins, in which a client draws its own shadow and corners, and whether _NET_WM_STATE lists _NET_WM_STATE_FULLSCREEN. Rules can also choose by focus, which comes from the root instead: _NET_ACTIVE_WINDOW names the active client, a change to it costs one read and gives the surfaces that lost and gained focus their settings again, and without the property every window counts as focused. Every reply is read before an error is acted on, because the error of a reply left unread would arrive later as an event and stop the compositor. A window without a known type is dialog when it is transient and not override-redirect, and normal otherwise, as EWMH specifies. The surface keeps the settings the rules give it, which painting reads each frame. A resize recaptures the surface but keeps its identity without reading it again. A change to one of those properties on the window the identity came from reads it again, and so does a client found later, as when it gains WM_STATE. A frame whose client is gone keeps that client's identity, and so does a window already gone when its change is read, so a closing window fades out with its own rules. A reload resolves every surface's rules again.
Property reads validate the wire representation before using application data. WM_STATE must have type WM_STATE, format 32, and exactly two values. _NET_WM_WINDOW_OPACITY must contain one 32-bit CARDINAL, with no extra data left in the property. WM_CLASS must be an 8-bit STRING, _NET_WM_NAME an 8-bit UTF8_STRING, and WM_NAME an 8-bit STRING, read as Latin-1, or UTF8_STRING; _NET_WM_WINDOW_TYPE and _NET_WM_STATE must each be a list of at most 32 atoms. Invalid values are treated as absent, including fallback from malformed frame opacity to valid client opacity, text that is not valid in its encoding, and text longer than the 1 KiB read for a class or the 4 KiB read for a title. Ordinary X request errors still follow the existing error paths.
Rendering and scheduling
The renderer paints wallpaper into a reusable root-depth buffer, composites windows from bottom to top, and uses an A8 mask for effective opacity. Per-pixel alpha remains part of the source picture. Shape rectangles clip both window painting and blur. They are moved to root coordinates in 32-bit arithmetic and limited to the window and the repaint area before they reach the server, so extreme off-screen shapes cannot overflow 16-bit coordinates. The blur module builds a pyramid around each translucent window that no rule keeps sharp: each level halves the previous one with bilinear sampling, then the coarsest level is scaled back up, level by level, into a backdrop buffer shown within the window's shape. The backdrop shows as strongly as the window covers each pixel, its alpha times its opacity: the transparent margin and shadow that ARGB windows such as browser menus draw around themselves get little blur, and blur fades in and out with a window. The radius selects the depth, rounded to 2, 4, 8, or 16 pixels. The pyramid area adds a margin of twice the coarsest step and aligns to that step, so pixels left from earlier frames cannot reach the window and the result stays anchored to the screen as windows move. Level buffers exist only when blur is enabled. Glamor-based drivers accelerate these bilinear transforms but render convolution filters, used by an earlier box blur, on the CPU; the desktop qualification guide compares both.
animation.rs uses monotonic Instant values and integer smoothstep interpolation. Closing and reopening retarget from the sampled opacity so interrupted animations remain continuous. The event loop schedules a final frame at the endpoint, including when the duration is zero.
Each frame repaints only an area of the root. A window's Damage reports the bounding box of its changes since the previous report, which locates them without fetching the region. renderer/damage.rs keeps what the last frame showed of each surface: its bounds, shape, captured picture, effective opacity, whether blur shows through it, and stacking position. Each change also records the lowest layer of the stack it alters; the background is layer 0. A surface's content changes from its own layer up, and so do the old and new bounds of a surface that moved, was resized or recaptured, changed shape, opacity, or blur, appeared, or left. A restack changes the scene beneath each surface that swapped places, across both surfaces' bounds, but the output only where they overlap. An exposure of the overlay changes the output alone; a new renderer or a wallpaper change starts from the background, and a Present rejection repaints everything. region.rs keeps the repaint area as at most 16 rectangles, merging the pair whose hull adds the least, so it can cover more than changed but never less. Painting is clipped to the area, and a frame whose area is empty is not painted at all.
Each blurred surface keeps its blurred backdrop in a buffer that covers its bounds on the screen. Blur reads around every pixel it writes, so a change beneath a surface anywhere in its footprint blurs it again, which repaints the whole footprint and changes the surface for those above it. A surface repainted where its kept backdrop no longer matches its bounds also blurs again. Any other repaint, such as the surface's own content changing, its opacity fading, or a window above it changing, copies the kept backdrop and costs no blur. Buffers round up to 64 pixels and are replaced only when too small or over four times too large; a buffer goes when its surface stops being blurred.
renderer/cover.rs skips what opaque surfaces hide. A surface without an alpha channel, shown at full opacity, hides its shape from every surface beneath it and from the background, which are painted only where the area remains in sight. A surface due to blur again reads the scene beneath it across its footprint, so nothing there is hidden from the surfaces beneath it. A blurred surface hidden whole by those above it does not blur at all; it drops its kept backdrop instead, so that it blurs again once something uncovers it. The covered area is kept as at most 64 rectangles, past which it hides less than it could, never more.
renderer/shadow.rs gives a surface a shadow while shadow_radius is above zero and the surface casts one: its rule says so, or else its client's type does and the client keeps no margins for a shadow of its own, and its shape is its whole rectangle. The shadow is that rectangle, moved by the offset and blurred. A rounded surface's shadow is the blur of its rounded shape instead: the product of the strips described below, less the blur of each corner the rounding cuts off, which a correction computed once per pair of radii gives. Near each corner a patch holds those values, and it also fills the surface's corner square as far as the surface leaves each pixel uncovered, so no background shows between the arc and the shadow; patches that would overlap on small surfaces merge. Blurring a rectangle separates into one profile along each axis, so each painter keeps two strips per surface, as long as the shadow is wide and high, and multiplies them with the shadow's strength at every pixel: XRender composites one strip through the other, and the GPU painter samples both in one draw. The XRender strips round their length up, so a resize uploads new profiles into the same pictures. Three box filters computed with integers give the profiles, so both painters draw the same values. A shadow belongs to its surface's layer. It is painted after the surface's backdrop has blurred, which keeps it out of that blur, and before the surface, around the surface's bounds and never beneath them, where the repaint area reaches and no opaque surface above hides it. What a frame showed of a surface includes its shadow, so a move, a fade, or a reload repaints the old and new extent of both, while the surface's own content leaves the shadow alone. A surface hidden whole still paints the part of its shadow that shows.
renderer/corner.rs gives each surface the radius of its rounded corners: its rule's corner_radius, or else the global one when the surface would cast a shadow by its type and margins; none while its client is fullscreen or its shape is not its whole rectangle; and at most half its shorter side, border included. What a frame showed of a surface includes that radius, so a change repaints the surface. The module computes the disk both painters draw corners from, counting 16 × 16 points per pixel with integers. When a surface has an X border, it also computes inner-arc and border-ring coverage for the radius and border width. The XRender painter uploads one A8 disk per radius, and one inner and ring pair per bordered radius and width. It copies the surface's top-left pixel into a repeating ARGB picture, then composes corner content through the inner mask and that border color through the ring into a cleared ARGB tile. The tile is drawn through the opacity mask. Surfaces without borders use the disk directly. A blurred backdrop beneath a surface shows through weights that the outer disk multiplies at the corners, so blur stops at the outside arc. An opaque rounded surface hides only its interior from the surfaces beneath it, and a rounded one never counts as covering the screen. The GPU painter keeps corresponding disk and band textures; one shader draw combines the source content inside the inner arc with the sampled border color in the ring while preserving source alpha and opacity. A rounded shadow's patches use the shadow draw, with an opaque pixel standing for the second profile.
compositor.rs drains bounded event batches so an event storm cannot postpone painting forever. It repaints only after damage, relevant state changes, or an active animation, with max_fps as a ceiling. An event that changes neither a shown surface nor their order does not repaint: with one Present buffer, such a frame would hold the next real one back by a vblank. Idle operation polls the X socket with a one-second maximum wait to observe shutdown and reload flags.
With unredirect_fullscreen, the compositor suspends compositing while one surface hides everything else: the topmost surface shown, when it is still mapped, opaque at full opacity, wholly rectangular, and covers the screen. It stops redirecting the root's children, so windows draw to the screen themselves, and unmaps the overlay. Surfaces keep their pixmaps meanwhile, which no longer receive drawing, and the scene keeps following events. An event whose handling would capture a window or replace the renderer resumes compositing first: a viewable window that maps, a window that arrives on the root, a mapped one that changes size, or a change of the screen. After every other batch of events, compositing resumes if the scene no longer has such a cover. Resuming redirects the windows again, each into a new pixmap that starts with what the window shows, maps the overlay, and captures every mapped surface again, which repaints the whole screen because the cover either changed or left. A surface that closed meanwhile is dropped without fading, because its pixmap shows an image from before the suspension.
config.rs reads the --config file, or else the first compust/compust.toml in the XDG configuration directories. SIGUSR1 sets a flag that the event loop checks after each event batch. watch.rs also reloads it when a file changes: an inotify descriptor, polled beside the X connection, watches the directory of each file the search could read, and of the file that a symbolic link among them points to, because editors often save by replacing a file, which a watch on the file itself would lose. A change to one of those names, or the loss of a watched directory or of events, makes the reload due 100 ms later, and each further change postpones it: a save in several steps, such as Vim's, which moves the old file aside before writing the new one, reloads once and never between steps. Each reload watches the files again. Where inotify is unavailable, SIGUSR1 remains. The reload repeats that search, so it reads what a restart would; a file that cannot be read or is invalid leaves the running configuration untouched. Opacity, including the opacity and blur that rules give, and the frame ceiling are read on every frame. Each fade transition takes the duration configured when it starts, so a reloaded fade_ms, global or in a rule, also governs windows opened earlier. The renderer records the blur radius and vsync setting it was built for, and a reload that changes either replaces it at the next paint, after Present has released the buffer as for any frame. The signal handlers are installed before the compositor selection is claimed: a client can signal the process as soon as it sees the selection, and SIGUSR1 would otherwise terminate it. SIGUSR1 follows picom; SIGHUP is avoided because a closing terminal sends it.
With Present, a single output buffer is submitted in COPY mode with the repaint area as its update region, so the server copies only that area; the buffer keeps the rest of the previous frame, which still matches the screen. It is reused only after both completion and idle notifications. A RandR screen change is the exception: the renderer is replaced without waiting, because a CRTC reconfiguration on modesetting/amdgpu hardware discarded both events for a pending submission. A one-second timeout covers losses from any other cause: the renderer is replaced and the frame repainted once. If the next submission also times out, the buffers are replaced again but painting waits for new damage, so a server that stops reporting does not cause a repaint every second. A root ConfigureNotify can request that replacement but not cancel it. Old subscriptions are released, but the server reports a replaced renderer's pending submission to the new subscription too, and nothing orders it against the new renderer's first frame: Xvfb showed it second about one time in three after a root resize, leaving the old buffer's pixels in its update area. Serials therefore continue across a replacement, and a completion that is not the renderer's own makes it show the whole buffer again. This is a simple baseline, not a low-latency multi-buffer scheduler. Without Present, XRender copies the area directly to the overlay.
A BadMatch on the exact outstanding PresentPixmap request can fall back to XRender after checked geometry queries confirm that the original back pixmap and output still exist on the same screen with matching depth and expected buffer dimensions. The rejected request does not own the buffer, so the idle/completion wait is cleared. The session then marks Present unavailable, so the fallback survives a root resize or a configuration reload that recreates the renderer. A warning records the rejected request. Invalid resources and unrelated errors are not covered by this recovery.
GPU renderer
The renderer plans each frame once: the repaint area, which backdrops blur again, what opaque surfaces hide, and the clip of each surface. renderer/paint.rs hands that plan to one of two painters. The XRender painter carries it out as described above. The GPU painter in renderer/gpu.rs, chosen with backend = "gl", carries it out with OpenGL ES on the X server's own GPU and keeps its backdrops and blur pyramid as GPU textures. A clip is a list of rectangles that may overlap, which XRender unions; the GPU painter draws one quad per rectangle, so it first splits a clip into disjoint pieces, and a translucent draw blends once at every pixel.
DRI3 Open names the server's DRM device, and the GPU painter opens the EGL device behind the same node, so a machine with several GPUs uses the one the server renders with. The back buffer stays the pixmap the server allocated: the painter imports it through DRI3 BuffersFromPixmap as its render target, so Present shows its frames exactly as it shows the XRender painter's, and no buffer format or modifier needs negotiating. Window and wallpaper pixmaps are imported the same way, once per capture, and sampled without copying. The kernel orders the GPU's reads and writes of shared buffers only after each side has sent its work. The painter flushes its draws before the frame is presented. glamor holds back its own commands until the server idles or a fence triggers, so before each frame the painter triggers a SYNC fence and waits for its answer: a busy server could otherwise let a newly painted window be read before its contents arrive.
The painter needs DRI3 1.2. Without it, or when the GPU painter cannot open, Compust warns and keeps to XRender. A frame that fails, including a GL error reported at flush, also switches the session to XRender, which repaints everything; later renderers do not try the GPU again. Xvfb lacks DRI3, and Xwayland refuses to share pixmaps, so on both the GL backend falls back.
All of the compositor's unsafe code lives in crates/gl, the compust-gl crate: libEGL loaded at run time, device selection, a surfaceless OpenGL ES context, dma-buf imports, and one draw that samples a source at (target pixel + offset) × scale, which covers composition and the pyramid's passes. Every unsafe block states why it is sound. EGL gives every user of a device the same display, so the crate never terminates it, and every GL call first makes its own context current, so a renderer and the one replacing it keep their objects apart.
Source map
| Area | Files |
|---|---|
| Startup and configuration | main.rs, config.rs, rules.rs, watch.rs |
| X11 ownership and capabilities | session.rs, atoms.rs, capabilities.rs |
| Captured resources and lifetime | picture.rs, surface.rs |
| Client/frame association | surface/client.rs |
| Scene and events | scene.rs, events.rs, compositor.rs |
| Effects and presentation | animation.rs, region.rs, renderer.rs, renderer/{paint,damage,cover,blur,shadow,corner,present,wallpaper}.rs |
| GPU renderer | renderer/gpu.rs, and crates/gl/src/ for EGL, OpenGL ES, and dma-buf textures |
| Real-server verification | tests/x11.rs, tests/cases/, tests/support/, and tests/gpu.rs against a server with DRI3 |
All source paths above are relative to src/ except the test paths and crates/gl. The compositor crate uses unsafe_code = "forbid"; that restriction covers this crate, not compust-gl or the internals of other dependencies. Rust prevents several memory errors, but protocol races, rendering mistakes, and resource leaks still require tests.
Capture verification
Capture-race tests route only the compositor connection through tests/support/proxy.rs. The proxy frames native-endian X11 requests with x11rb, forwards replies and events unchanged, and joins its worker on shutdown. tests/support/capture.rs discovers extension opcodes from Xvfb and completes a checked window destruction immediately before forwarding the selected request. The test client retains a direct server connection for pixel checks and resource probes. This transport belongs only to the test binary; normal compositor execution has no interception hooks.
The same transport supports presentation tests by substituting a request field before forwarding it; Xvfb generates the actual error. It also records the update region of each PresentPixmap request, so region tests can check how much of the screen each frame replaced. Substituting a never-triggered SYNC wait fence withholds a submission's completion and idle events instead. tests/support/monitors.rs uses checked RandR requests on that test's isolated server. tests/support/resources.rs queries XRes using the compositor selection owner's window as the client identifier. It compares resource counts and the full reported bytes of owned pixmap XIDs at matching rendered states after warmup. XRes 1.2 QueryResourceBytes provides these sizes without dividing by reference counts; QueryClientPixmapBytes attribution can change while Present holds a reference. The fixture requires size entries for every owned pixmap and excludes cross references to avoid duplicate accounting. XRes is a development-only feature; the production compositor does not require the extension. Test screenshots use the current overlay dimensions.
Where to extend it
A painter carries out a frame's plan; a new one, such as Vulkan, should keep the plan and the resource-lifetime contracts and say how it imports, synchronizes, and presents. Explicit synchronization through DRI3 1.4 and buffers the GPU painter allocates itself remain open.
The next optimization should be driven by profiles. Every saved repaint adds state that a missed change leaves stale, so each needs a test that makes that change and compares the frame with one from a fresh renderer. Measure the X server as well as Compust because XRender delegates rendering work to it. The roadmap defines acceptance criteria for these changes.
Improve this page on GitHub