Skip to content

Window-System Concepts: Shared Vocabulary

The shared-vocabulary page for the window-system-integration survey. Every per-subject deep-dive (Winit, SDL3, GLFW, Qt 6, GTK 4, Smithay/libdecor, …) links back here for the concepts it exhibits, so each idea is defined once, grounded in a concrete real example, and cross-linked to the implementations that show it off. This page is the windowing counterpart of the async-I/O survey's primitives vocabulary; where a concept is really an event-loop concept wearing a windowing hat, it cross-links there.

For the raw OS windowing APIs beneath these concepts — and a minimal, runnable D program per platform that opens or bootstraps a window by calling them directly — see the OS Windowing APIs sub-tree: its overview and cross-platform summary, and the per-platform surveys for Wayland, X11, Win32, AppKit, UIKit, and Android.

NOTE

Scope. These are the cross-cutting concepts the deep-dives share — the recurring hazards and design forks of the windowing layer (decorations, coordinates, input translation, the loop). Library-specific mechanics stay in each deep-dive; this page only fixes the shared terms and shows the smallest faithful illustration of each. Several concepts are demonstrated by a CI-verified runnable D snippet (pure std, no display, byte-deterministic output) placed in the most relevant section.

Last reviewed: June 9, 2026


Client-side vs server-side decorations

A window's decorations are its titlebar, borders, drop shadow, and the minimize/maximize/close buttons. The fork is who draws them:

  • Server-side decorations (SSD) — the display server / compositor / window manager draws the frame around the client's content. This is the historical X11 model (the WM reparents the client and draws a frame) and the Wayland model when the compositor honours zxdg_decoration_manager_v1 with mode_server_side (KDE/KWin, wlroots).
  • Client-side decorations (CSD) — the application draws its own titlebar and borders into its own surface. This is mandatory on Wayland compositors that refuse SSD (GNOME/Mutter advertises no zxdg_decoration_manager_v1 at all), and on Win32 for custom-chrome apps.

On Wayland the choice is negotiated, not assumed — see the negotiation handshake below. The recurring lesson across the survey is that SSD is only a hint: per the xdg-decoration protocol there is no reliable way to force or forbid it, so every serious toolkit must carry a CSD path. Who draws CSD differs sharply:

ImplementationCSD strategy
Winitown-drawn Adwaita frame via sctk-adwaita (no libdecor)
SDL3delegates to libdecor; draws no titlebar of its own
GLFWlibdecor → SSD → own-drawn subsurface-edge fallback
Qt 6own-drawn CSD via decoration plugins (adwaita/bradient), predating libdecor
GTK 4own-drawn CSD in gtk/; binds only KDE org_kde_kwin_server_decoration for SSD
Chromium OzoneSSD-first via zxdg_toplevel_decoration_v1, own Views-drawn CSD fallback
Smithay SCTKthree-tier: compositor SSD → spartan FallbackFramelibdecor

GTK 4 is the outlier worth remembering: GDK binds neither libdecor norzxdg-decoration-v1, so on wlroots compositors it always falls back to its own CSD.

The Wayland decoration handshake

On Wayland the SSD/CSD decision is a double-buffered, asynchronous negotiation, not a boolean the client sets. The canonical sequence (as wrapped by Smithay SCTK and spoken raw by every other Wayland client):

  1. The client binds zxdg_decoration_manager_v1 if the compositor advertises it. If it does not (GNOME), the client is on its own — CSD is the only option.
  2. The client creates a zxdg_toplevel_decoration_v1 for its toplevel and requests a mode with set_mode(server_side) / set_mode(client_side) (or unset_mode to defer to the compositor).
  3. The compositor replies with a zxdg_toplevel_decoration_v1.configure(mode) event carrying the actual mode. The client must obey it: if it comes back client_side, the client draws the frame; if server_side, the client draws nothing.

SCTK exposes this verbatim — its WindowConfigure.decoration_mode "will always be DecorationMode::Client if server side decorations are not enabled or supported", and libdecor's own source admits that, per xdg-decoration v1, the toggle "is just a hint and there is no reliable way of disabling all decorations." Because the handshake is per-configure and may flip, a robust client treats decoration mode like any other negotiated window state (no-buffer-no-window, scale): request, then react to the configure.

X11 selections / INCR are not a decoration concept, but they are the same shape of ICCCM negotiation; deep-dives that mention X11 clipboard chunking point at the ICCCM spec.


Scancode vs keysym vs virtual-key

A keypress carries (at least) two independent identities, and conflating them is the classic input bug:

  • Scancode (a.k.a. physical key, PhysicalKey/KeyCode, evdev code, USB-HID usage) — the layout-independent physical location of the key. The key labelled Q on a US keyboard and A on an AZERTY keyboard report the same scancode. This is what you want for WASD movement or "the key in the top-left", and it is stable across layouts.
  • Keysym (a.k.a. logical key, Keycode, virtual-key/VK_* on Win32) — the layout-dependent symbol the key produces: q, a, Escape, F1. This is what you want for "is this the Z of Ctrl+Z" or for displaying a shortcut.
  • Text / commit string — the actual characters produced, including composition. A dead-key sequence (´ then eé) produces no text on the first press and the composed text on the second.

Where the scancode→keysym translation happens is the platform fork. On Linux, the state machine is xkbcommon: Winit owns its own xkb state in winit-common, SDL3 feeds the compositor's keymap fd to xkbcommon, and Smithay SCTK holds xkb::Context/State/compose::State behind mutexes. Two recurring gotchas: Wayland's wl_keyboard delivers raw evdev codes that need a +8 offset before every xkb call (the X11 keycode base), and toolkits that ship their own table instead of xkbcommon (Avalonia's X11KeyTransform, JUCE's XLookupString-only path) inherit layout bugs. On Win32 the symbol is a VK_* virtual-key; on macOS there is "no direct analogue to either keysyms or virtual-key codes", so Winit reports the raw scancode there.

The runnable snippet below shows the direction of the lookup — a fixed evdev-code table standing in for what xkbcommon resolves against the active layout. Real toolkits delegate to xkbcommon; this is the shape of what it returns.

d
#!/usr/bin/env dub
/+ dub.sdl:
    name "scancode_keysym"
+/
import std.stdio : writefln;

// A physical (evdev) scancode is layout-independent; a keysym is the
// layout-dependent symbol xkbcommon resolves it to. This is a tiny fixed
// US-layout slice of that mapping (real toolkits delegate to libxkbcommon).
struct Entry { int scancode; string keysym; }

immutable Entry[] table = [
    Entry(1,  "Escape"),
    Entry(30, "a"),
    Entry(57, "space"),
];

string lookup(int scancode) pure @safe nothrow
{
    foreach (e; table)
        if (e.scancode == scancode)
            return e.keysym;
    return "(unmapped)";
}

void main()
{
    foreach (sc; [1, 30, 57, 99])
        writefln("scancode %2d -> keysym %s", sc, lookup(sc));
}
scancode  1 -> keysym Escape
scancode 30 -> keysym a
scancode 57 -> keysym space
scancode 99 -> keysym (unmapped)

Logical vs physical coordinates

Two coordinate systems coexist in every windowing API:

  • Physical (device) pixels — the actual pixels the GPU rasterizes into. A 4K monitor is 3840×2160 physical pixels.
  • Logical (device-independent) pixels / points / DIPs — a resolution-independent unit the app lays out in. At 200% scale, one logical pixel covers a 2×2 block of physical pixels, so a "1920×1080 logical" window fills a 3840×2160 physical surface.

The two are bridged by the scale factor. The deep design choice is which one the API speaks natively:

Native unitToolkits
Physical (app converts via scale factor)Winit (PhysicalSize/PhysicalPosition are the default)
Logical (physical only at the OS seam)Qt 6, GTK 4, Avalonia, JUCE, Slint, MAUI
Mixed / per-platform native unitSDL3, GLFW (separate window-size vs framebuffer-size), Flutter

SDL3 is the instructive case: it deliberately uses each platform's own native unit — physical on Win32/X11/Android, logical on macOS/iOS/Wayland — so the same default window reports 1920×1080 from SDL_GetWindowSize on macOS but 3840×2160 on Windows for one physical 4K/200% display. GLFW formalizes the split into two distinct queries: window size (in screen coordinates) versus framebuffer size (in pixels), with a content-scale ratio between them. The practical rule the survey keeps re-learning: never size your render target off the logical window size — size it off the physical/framebuffer size (or the pixel-size-changed event), or you render blurry.


The scale factor & fractional scaling

The scale factor is the multiplier from logical to physical pixels: physical = logical × scale. Integer scales (1×, 2×, 3×) are easy; fractional scales (1.25×, 1.5×, 1.75×) are where the platforms diverge, and where two distinct hazards live.

Where the scale comes from, per platform:

  • Waylandwp_fractional_scale_v1 (paired with wp_viewporter). The compositor sends a preferred scale as an integer in 1/120ths: 180 means 1.5×, 90 means 0.75×, 120 means 1.0×. The client renders at that fractional scale and uses a wp_viewport to size the buffer exactly. GTK 4's GdkFractionalScale stores the raw 120ths value and offers to_double; GLFW reports numerator / 120.f as the content scale; Qt 6 creates a QWaylandFractionalScale only when the rounding policy is PassThrough. Without the protocol, clients fall back to the integerwl_surface.set_buffer_scale. (Smithay SCTK never implements fractional scale at all — integer only — and is the cautionary tale here.)
  • Win32 — per-monitor DPI. The process opts into Per-Monitor-V2 awareness (SetProcessDpiAwarenessContext(PER_MONITOR_AWARE_V2), falling back to V1 then system-DPI), and the OS delivers WM_DPICHANGED when a window crosses a DPI boundary. The message packs the new X DPI in the low word of wParam and the new Y DPI in the high word; scale = dpi / 96. The handler must reposition the window to the OS-suggested rect from lParam. Qt 6 additionally handles the prefatory WM_GETDPISCALEDSIZE and carefully separates the spontaneous-drag case from the setGeometry-induced case to avoid double-scaling.
  • macOSNSWindow.backingScaleFactor, which is integer-only (1× or 2×); the OS composites everything else.
  • X11 — there is no per-surface scale protocol, so toolkits scrape a single global Xft.dpi (GLFW, JUCE — which will literally shell out to dconf for Ubuntu's per-display scale) or guess (Avalonia). Mixed-DPI multi-monitor on X11 is therefore the universal weak spot: a window dragged to a differently-scaled monitor does not re-scale.

Hazard 1 — created-at-wrong-scale. On Wayland a surface has no scale until it enters an output (pre-v6) or receives its first preferred_scale, so it is created at 1× and rescaled on the first event. Toolkits handle this by treating the first scale as a configure-driven event and re-laying-out; Winit's ScaleFactorChanged even ships a surface_size_writer so the callback can rewrite the post-rescale physical size, and Slint dispatches ScaleFactorChanged on window creation before first paint to dodge the transient. Hazard 2 — mixed-DPI migration. Dragging a window between monitors of different scale must re-fire the scale event (WM_DPICHANGED on Win32, a fresh preferred_scale on Wayland) — the X11 single-global-DPI model cannot.

The two snippets below show the arithmetic of each side: the Wayland 120ths conversion and the Win32 WM_DPICHANGED word extraction.

d
#!/usr/bin/env dub
/+ dub.sdl:
    name "fracscale_v120"
+/
import std.stdio : writefln;

// Wayland wp_fractional_scale_v1 sends the preferred scale as an integer in 1/120ths.
// to_double = numerator / 120.0  (e.g. 180 -> 1.5, 90 -> 0.75, 120 -> 1.0).
double scaleFrom120(int numerator) pure @safe nothrow @nogc
{
    return numerator / 120.0;
}

void main()
{
    foreach (n; [120, 180, 90, 144, 240])
        writefln("preferred_scale=%3d -> scale=%.4f", n, scaleFrom120(n));
}
preferred_scale=120 -> scale=1.0000
preferred_scale=180 -> scale=1.5000
preferred_scale= 90 -> scale=0.7500
preferred_scale=144 -> scale=1.2000
preferred_scale=240 -> scale=2.0000

The Win32 side decodes the DPI from a WM_DPICHANGED wParam. 0x00C000C0 is 192 DPI in both words → 2.0× scale.

d
#!/usr/bin/env dub
/+ dub.sdl:
    name "wm_dpichanged"
+/
import std.stdio : writefln;

// On Win32, WM_DPICHANGED packs the new X DPI in the low word of wParam and the
// new Y DPI in the high word; both are normally equal. scale = dpi / 96.
enum uint USER_DEFAULT_SCREEN_DPI = 96;

uint loword(uint v) pure @safe nothrow @nogc { return v & 0xFFFF; }
uint hiword(uint v) pure @safe nothrow @nogc { return (v >> 16) & 0xFFFF; }

void main()
{
    foreach (wparam; [0x0060_0060u, 0x0078_0078u, 0x00C0_00C0u, 0x0090_0090u])
    {
        const dpiX = loword(wparam);
        const dpiY = hiword(wparam);
        const scale = cast(double) dpiX / USER_DEFAULT_SCREEN_DPI;
        writefln("wParam=0x%08X -> dpiX=%d dpiY=%d scale=%.3f", wparam, dpiX, dpiY, scale);
    }
}
wParam=0x00600060 -> dpiX=96 dpiY=96 scale=1.000
wParam=0x00780078 -> dpiX=120 dpiY=120 scale=1.250
wParam=0x00C000C0 -> dpiX=192 dpiY=192 scale=2.000
wParam=0x00900090 -> dpiX=144 dpiY=144 scale=1.500

IME pre-edit / composition

An input method editor (IME) turns a sequence of keystrokes into text that has no 1:1 key mapping — CJK characters, accented Latin via dead keys, emoji pickers. The key concept is the pre-edit (composition) string: the provisional, not-yet-committed text shown (usually underlined) while the user is still composing, distinct from the commit string that is finally inserted. A robust app must (a) render the pre-edit inline, (b) position the candidate window near the text caret, and (c) keep raw key events separate from composed text.

Every toolkit models this as a separate event stream from key events. Winit emits Ime::{Enabled, Preedit, Commit, Disabled}; SDL3 separates SDL_EVENT_TEXT_EDITING (pre-edit) from SDL_EVENT_TEXT_INPUT (commit); Slint and Flutter route through the platform stack. The per-platform protocol is the fragmentation point:

PlatformModern protocolWhat the survey actually found
Waylandzwp_text_input_v3Used by SDL3, Winit, Qt 6; not bound by GDK (GTK 4 does IME above GDK)
Win32Text Services Framework (TSF)Nobody uses TSF — SDL3, Winit, Qt 6, GTK 4, JUCE, Avalonia all use legacy IMM32
macOSNSTextInputClientImplemented by SDL3, Winit, JUCE; GLFW mispositions the candidate window
X11XIMWinit/SDL3 use XIM; GLFW, JUCE omit pre-edit entirely

Two findings dominate. First, the universal IMM32-over-TSF choice on Windows: not one surveyed toolkit uses the modern TSF, every one falls back to legacy IMM32 (WM_IME_COMPOSITION with GCS_COMPSTR/GCS_RESULTSTR). Second, IME is frequently absent: GLFW omits pre-edit by design (issue #41 open since 2013), JUCE's X11 path has no IME at all, Smithay SCTK ships no client-side text_input_v3 consumer, and MAUI delegates the whole concern to native controls. The deep design lesson recorded across the tree: IME is table-stakes but uneven, and a windowing layer that wants to be reusable standalone should own the pre-edit consumer rather than punt it upward (as GTK 4 does, leaving GDK alone without text input).


X11 override-redirect vs Wayland xdg_popup grab

Menus, tooltips, dropdowns, and combo-box popups are transient surfaces that the window manager should not decorate, tile, or focus normally, and that must dismiss on click-outside. The two display servers solve this with fundamentally different mechanisms:

  • X11 override-redirect. The client sets the override_redirect attribute on the window, which tells the WM "do not manage this window" — no frame, no reparenting, the client positions and stacks it absolutely. The client is then responsible for the pointer/keyboard grab and for dismissing on click-outside. SDL3 sets xattr.override_redirect for TOOLTIP/POPUP_MENU windows; Avalonia positions override-redirect popups with its own ManagedPopupPositioner flip/slide math; Qt 6 creates BypassWindowManagerHint windows override-redirect.
  • Wayland xdg_popup grab. There is no absolute positioning. A popup is an xdg_popupparented to another surface, placed via an xdg_positioner (anchor + gravity + constraint-adjustment/slide), and given an explicit grab(seat, serial). The compositor owns the grab and dismisses the popup on click-outside. The client cannot place a popup at an absolute screen coordinate; it can only describe it relative to its parent. SDL3 maps popups to xdg_popup + xdg_positioner; Smithay SCTK wraps it as Popup/XdgPositioner; libdecor forwards window-menu grabs via libdecor_frame_popup_grab(frame, seat_name).

The cross-platform consequence the survey keeps hitting: a toolkit's popup abstraction cannot be a simple "place this window at (x, y)" call, because that maps onto neither model cleanly. Winit punts entirely — it has no cross-platform popup/grab API, only X11 _NET_WM_WINDOW_TYPE hints — and apps draw menus inside the parent surface instead. Framework toolkits that render popups in-canvas (Uno, Slint's winit backend) sidestep the whole fork by never creating a real OS sub-surface, at the cost of not getting compositor-enforced dismiss.


The Win32 modal resize/move loop

When the user grabs a Win32 window's titlebar or a resize edge, Windows enters its own nested modal message loop inside DefWindowProc and stops returning to the application's pump until the drag ends. The symptoms: timers stop firing, GetMessage never returns, and redraws freeze for the entire drag — a live-resize that shows a frozen window. The loop is bracketed by WM_ENTERSIZEMOVE and WM_EXITSIZEMOVE.

The survey-wide workaround is identical in spirit everywhere: on WM_ENTERSIZEMOVE, install a SetTimer, and drive a frame from each WM_TIMER so the app keeps drawing while DefWindowProc blocks. SDL3 arms SetTimer(SDL_IterateMainCallbacks, ...) and runs live-resize iterations from WM_TIMER; GTK 4 pumps g_main_context_iteration from the timer; sokol arms SetTimer(USER_TIMER_MINIMUM). A second, finer trick appears in Winit and sokol: a ~500 ms pause on the first titlebar click is cancelled by posting a dummy WM_MOUSEMOVE (with lParam = 0) on WM_NCLBUTTONDOWN. Qt 6, JUCE, and wxWidgets track an "in size-move" flag and accept that the loop is mitigated, not eliminated.

This is a Win32-only concept. Wayland resize is cooperative and non-blocking (a CSD border click calls xdg_toplevel.resize and the compositor streams configure events — Smithay SCTK notes the client "never enters a blocking modal loop"); X11 resize is likewise driven by ordinary ConfigureNotify events. Toolkits with no Win32 backend (Smithay/libdecor) mark this dimension N/A.


Raw/relative vs accelerated/absolute pointer motion

Two kinds of pointer motion serve two kinds of application:

  • Absolute / accelerated motion — the cursor position in window-local coordinates, after the OS applies pointer acceleration. This is what GUIs want: "the click was at (412, 88)". It is the default everywhere (WindowEvent::PointerMoved, wl_pointer.motion, WM_MOUSEMOVE).
  • Raw / relative motion — un-accelerated deltas (dx, dy) with no absolute position, used by first-person cameras and 3D viewports that lock and hide the cursor. This is a separate event source everywhere: a DeviceEvent::MouseMotion (Winit) backed by zwp_relative_pointer_v1 on Wayland and WM_INPUT on Win32, paired with pointer locking/confinement (zwp_pointer_constraints_v1, or SDL_WINDOW_MOUSE_RELATIVE_MODE). Notably GTK 4 binds none of the pointer-constraints/relative-pointer protocols — a deliberate gap, since GTK is not a game toolkit, so it cannot do FPS-style raw input at all.

A closely-related sub-concept is high-resolution scroll. Both Wayland's wl_pointer.axis_value120 and Win32's WM_MOUSEWHEEL report wheel motion in 1/120 of a logical detent (Win32's WHEEL_DELTA is 120) — a convention shared across the two platforms (Chromium Ozone notes the "120-per-detent convention shared with Windows"). A high-resolution mouse or trackpad sends sub-120 deltas; the client accumulates them and emits one wheel "notch" per 120 units, carrying the remainder for sub-notch precision. SDL3 accumulates axis_value120 per wl_pointer.frame, Smithay SCTK merges value120 across frames, GLFW/Qt 6/GTK 4 all read axis_value120 when the seat is new enough.

The snippet below is that accumulator. Feeding [40, 40, 40, 30] (sub-notch deltas) emits exactly one notch and carries a remainder of 30/120.

d
#!/usr/bin/env dub
/+ dub.sdl:
    name "axis_v120"
+/
import std.stdio : writefln;

// wl_pointer.axis_value120 (and Win32 WM_MOUSEWHEEL) report wheel motion in
// 1/120 of a logical "detent". Accumulate deltas; emit one wheel notch per 120
// units accumulated, carrying the remainder for sub-notch precision.
void main()
{
    int acc;
    int notches;
    foreach (delta; [40, 40, 40, 30])
    {
        acc += delta;
        while (acc >= 120)
        {
            acc -= 120;
            ++notches;
            writefln("wheel notch #%d", notches);
        }
    }
    writefln("emitted %d notch(es), remainder %d/120", notches, acc);
}
wheel notch #1
emitted 1 notch(es), remainder 30/120

The Wayland no-buffer-no-window rule

On X11 and Win32, creating a window maps it immediately: CreateWindowEx + ShowWindow, or XMapWindow, and the window is on screen. Wayland is the opposite: a wl_surface is invisible until a buffer is committed, and a toplevel surface cannot use its role until the compositor has sent the initial xdg_surface.configure. The startup dance is mandatory and ordered:

  1. Create wl_surface → give it the xdg_toplevel role via xdg_surface.
  2. Commit the surface with no buffer and round-trip.
  3. Wait for the compositor's xdg_surface.configure, ack it.
  4. Only now attach the first buffer and commit — the window appears.

The consequence is that "create window" is not synchronous on Wayland. Winit's constructor blocks in roundtrip + blocking_dispatch until is_configured() before returning; SDL3's Wayland_ShowWindow spins on libdecor_dispatch/dispatch_pending "until the surface gets a configure event"; Smithay SCTK draws the first frame inside WindowHandler::configure, never on creation; Chromium Ozone notes some state (set_window_geometry) is "silently dropped if committed without a buffer"; Slint goes so far as to destroy the window on hide because winit "won't create an invisible Wayland window". A second consequence, recorded in SDL3's long bug tail (referencing KDE bug 448856), is that retrofitting a synchronous "create then show" API onto this asynchronous handshake is fragile — model the lifecycle as explicit state from the start.

This rule is also why clients cannot set their own toplevel position on Wayland (the compositor owns placement, so SetWindowPosition is a silent no-op — see SDL3/GLFW/Chromium Ozone) and why window size/state are negotiated via configure rather than set.


Frame callbacks & per-platform vsync sources

Smooth animation needs the application to draw once per display refresh, synchronized to vsync, and ideally to stop drawing when the window is hidden so it does not burn the CPU/GPU. There is no single cross-platform vsync primitive; each platform has its own source, and a toolkit must funnel them into one redraw scheduler:

PlatformVsync source
Waylandwl_surface.frame callbacks — the compositor tells you when to draw next
macOSCVDisplayLink (deprecated since macOS 15) / CADisplayLink
Win32DXGI WaitForVBlank / DWM composition timing
X11the app's own redraw cadence (no standard vsync event)

The Wayland frame callback is the model worth internalizing: before committing a frame, the client requests wl_surface.frame; the compositor fires the callback when the surface will next be visible; the client draws only then. This both paces to vsync and naturally throttles to zero when occluded. GTK 4 freezes its per-surface GdkFrameClock while a frame callback is outstanding so the client "never out-runs the compositor"; Qt 6 runs a dedicated second event thread for frame callbacks so they are not starved by the main queue; GLFW gates its EGL swap on a frame callback specifically so a swap on a hidden surface does not block forever; Winit exposes pre_present_notify solely to schedule one.

The other platforms decouple pacing from the message loop onto a separate high-priority source: JUCE runs a per-screen CVDisplayLink on macOS and a dedicated WaitForVBlank thread at highest priority on Windows; GTK 4 and Qt 6 use CVDisplayLink on macOS (both noting its macOS-15 deprecation). The recurring weakness: toolkits with no dedicated pacing source (GLFW, which relies purely on the GL/EGL swap interval; X11 paths generally) offer no frame-pacing telemetry and tear on high-refresh displays. A new framework should pick the per-platform source explicitly and fold all of them into one frame-clock abstraction, and should have a CADisplayLink migration plan for the deprecated CVDisplayLink.


Readiness vs completion in the windowing loop

The windowing event loop is, underneath, an I/O event loop, and it inherits the same readiness vs completion distinction analyzed for async I/O in the primitives vocabulary — here it governs who owns the loop and how the display connection is multiplexed.

On Linux, the display connection is a socket fd (the Wayland display socket, or the X11/XCB connection), and a windowing loop is a readiness loop over that fd: poll the fd, and when it is readable, drain and dispatch the queued events. This is exactly the reactor model. Winit wraps the Wayland socket as a calloop WaylandSource and the XCB fd as a calloop Generic source — "the windowing analogue of the readiness-driven reactors"; Smithay SCTK integrates the Wayland fd into calloop via calloop-wayland-source; SDL3's Wayland pump hand-rolls the prepare_read/flush/SDL_IOReady/read_events/dispatch_pending readiness dance; Chromium Ozone plugs each backend's display fd into Chromium's base::MessagePump (and runs a dedicated fd-watch thread to dodge the libwayland prepare_read single-reader deadlock).

The deeper point is the loop-ownership fork, which mirrors readiness-vs-completion's "who calls whom":

  • App owns the loop (poll model) — the app calls glfwPollEvents / SDL_PollEvent / dispatches calloop. GLFW and default SDL3 are here. Like a readiness reactor, the app is in control and can add its own fd sources (on Linux; Winit's EventLoop even implements AsFd so it can be polled inside a larger reactor).
  • Library/OS owns the loop (callback model) — control is inverted: the OS drives, and the app is called back. This is mandatory where the OS owns the loop — Web's animation-frame, iOS UIApplicationMain, macOS CFRunLoop — which is precisely why Winit abandoned its old poll_events() iterator for ApplicationHandler callbacks, and why SDL3 added the opt-in SDL_MAIN_USE_CALLBACKS model. On macOS, every toolkit cedes the loop to AppKit ([NSApp run] + CFRunLoop observers).

The cross-cutting lesson: an external async runtime (async-io) and a windowing loop both want to own the process's blocking wait, and reconciling them means either making the windowing loop a pollable fd source (the Winit/calloop story) or running the windowing loop on its own thread — there is no portable external-fd injection in GLFW or SDL3.


Sources

The concepts above are abstractions over behaviours documented and quoted in the per-subject deep-dives; each deep-dive's own Sources block carries its primary-source citations. The cross-cutting external references behind the definitions here: