SDL3 (C)
Simple DirectMedia Layer 3: a cross-platform C library that abstracts windowing, input, audio, and GPU presentation behind one portable API, taking the stance that an app should "simply [create] a window and [listen] for events" while the library hides every per-platform windowing protocol underneath.
| Field | Value |
|---|---|
| Version/commit | SDL 3.5.0 dev tree, commit b53f1b06 (2026-06-07) |
| Language | C (C99/C11), with .m Objective-C for Cocoa/UIKit and .cpp for GameInput |
| License | Zlib |
| Repository | libsdl-org/SDL |
| Documentation | SDL3 wiki / README-migration.md / README-highdpi.md |
| Category | Windowing + media library (windowing, input, audio, GPU/render) |
| Platforms covered | Wayland, X11, Win32, Cocoa (macOS), UIKit (iOS), Android, plus KMSDRM, Emscripten, Haiku, consoles |
| Loop ownership | Hybrid — app owns the loop by default (SDL_PollEvent); SDL_MAIN_USE_CALLBACKS inverts control |
| Repo paths | src/video/{wayland,x11,windows,cocoa}/, src/video/SDL_video.c, src/events/, src/main/ |
Overview
What it solves
SDL is the lingua franca of cross-platform game and media development: it gives a single C entry point to window creation, the OS event stream, keyboard/mouse/pen/touch/gamepad input, the clipboard, and GPU/software presentation, so an application never touches wl_surface, XCreateWindow, CreateWindowEx, or NSWindow directly. The header for the video subsystem states the design goal plainly:
Of course, it can simply get out of your way and give you the window handles you need to use Vulkan, Direct3D, Metal, or whatever else you like directly, too.
The video subsystem covers a lot of functionality, out of necessity, so it is worth perusing the list of functions just to see what's available, but most apps can get by with simply creating a window and listening for events, so start with
SDL_CreateWindow()andSDL_PollEvent().—
include/SDL3/SDL_video.h(header doc comment)
The windowing layer is one piece of a larger media library, but it carries the same contract everywhere: a unified SDL_Event union, a SDL_Window opaque handle, and a properties bag for the rare native-handle escape. Layout and widget toolkits are explicitly not SDL's job — it is the substrate that toolkits like Dear ImGui, Clay, or Nuklear render on top of (see ui-layout for that layer).
Design philosophy
- One portable API, per-platform backends behind a vtable. Every backend implements the
SDL_VideoDevicefunction-pointer table insrc/video/SDL_sysvideo.h;SDL_CreateWindowinSDL_video.cdispatches_this->CreateWindow(...)toWayland_CreateWindow/X11_CreateWindow/WIN_CreateWindow/Cocoa_CreateWindow. - The app owns its loop — unless the platform refuses. The default model is a synchronous
SDL_PollEventpump the app drives. SDL3 adds an optional inverted main-callbacks model (SDL_AppInit/SDL_AppIterate/SDL_AppEvent/SDL_AppQuit) for platforms that "would rather be in charge of thatwhileloop" (iOS, Emscripten, Wayland frame-driven animation). - Properties replace typed getters. SDL3 deleted
SDL_GetWindowWMInfoand the typedSDL_GetWindowData; native handles now live in a string-keyedSDL_PropertiesIDbag (SDL_GetWindowProperties), a deliberate extensibility move so addingSDL_PROP_WINDOW_WAYLAND_SURFACE_POINTERnever breaks ABI. - Native units, not a synthetic coordinate space. SDL3 reversed SDL2's behavior: "Interfaces provided by SDL [use] the platform's native coordinates unless otherwise specified" (
README-highdpi.md). The library exposes the scale rather than hiding it. - Escape hatches are first-class.
SDL_SetWindowsMessageHook,SDL_SetX11EventHook, and the native-handle properties are documented API, not back doors — SDL assumes its abstraction will leak and gives you the raw layer when it does.
How it works
Core types and the dispatch table
The central objects are SDL_Window (opaque, defined in SDL_sysvideo.h), SDL_VideoDevice (the per-backend vtable), and the SDL_Event tagged union (SDL_events.h). One SDL_VideoDevice is selected at init (SDL_VideoInit) from a static list of backend bootstraps; on Linux SDL probes Wayland first, then X11. Window operations are virtual calls:
// src/video/SDL_sysvideo.h — the per-backend window vtable (excerpt)
struct SDL_VideoDevice
{
bool (*CreateWindow)(SDL_VideoDevice *_this, SDL_Window *window, SDL_PropertiesID create_props);
void (*ShowWindow)(SDL_VideoDevice *_this, SDL_Window *window);
void (*SetWindowSize)(SDL_VideoDevice *_this, SDL_Window *window);
void (*PumpEvents)(SDL_VideoDevice *_this);
int (*WaitEventTimeout)(SDL_VideoDevice *_this, Sint64 timeoutNS);
void (*DestroyWindow)(SDL_VideoDevice *_this, SDL_Window *window);
// ...dozens more...
};The default loop: SDL_PollEvent → SDL_PumpEvents → backend
In the classic model the app drives the loop. SDL_PollEvent calls SDL_PumpEvents, which calls the backend's PumpEvents to drain the native queue and translate native events into SDL_Events pushed onto SDL's internal queue, then dequeues one. Each backend's PumpEvents is the integration seam with the native loop (see Event loop).
The optional inverted loop: main callbacks
SDL_MAIN_USE_CALLBACKS flips ownership: the app provides four functions and SDL owns main. The generic implementation in src/main/generic/SDL_sysmain_callbacks.c runs the loop itself:
// src/main/generic/SDL_sysmain_callbacks.c — the faked loop on platforms that don't need real callbacks
while ((rc = GenericIterateMainCallbacks()) == SDL_APP_CONTINUE) {
// ...rate-limit to SDL_HINT_MAIN_CALLBACK_RATE, else run at the pace the video subsystem allows...
}The shared core (src/main/SDL_main_callbacks.c) installs an event watcher and dispatches events to SDL_AppEvent before each SDL_AppIterate; SDL_IterateMainCallbacks pumps, dispatches, then calls the iterate callback. The whole result is an atomic SDL_AppResult so a quit can land from any thread:
// src/main/SDL_main_callbacks.c
SDL_AppResult SDL_IterateMainCallbacks(bool pump_events)
{
if (pump_events) {
SDL_PumpEvents();
}
SDL_DispatchMainCallbackEvents();
SDL_AppResult rc = (SDL_AppResult)SDL_GetAtomicInt(&apprc);
if (rc == SDL_APP_CONTINUE) {
rc = SDL_main_iteration_callback(SDL_main_appstate);
// CAS so a quit set by another thread isn't clobbered...
}
return rc;
}NOTE
This is the headline windowing-layer change from SDL2. On iOS the loop is driven by a CADisplayLink, on Emscripten by emscripten_set_main_loop; the same four callbacks work unchanged everywhere because non-callback platforms "fake them with a simple loop in an internal implementation of the usual SDL_main" (README-main-functions.md).
1. Window creation & lifecycle
SDL_CreateWindow(title, w, h, flags) and SDL_CreateWindowWithProperties(props) both funnel into SDL_CreateWindow in SDL_video.c, which allocates the SDL_Window, then calls the backend CreateWindow. The native call per platform:
| Platform | Function | Native call |
|---|---|---|
| Win32 | WIN_CreateWindow (SDL_windowswindow.c:692) | CreateWindowEx(styleEx, SDL_Appname, ...) |
| X11 | X11_CreateWindow (SDL_x11window.c:556) | X11_XCreateWindow(display, RootWindow(...), ...) then XMapWindow on show |
| Cocoa | Cocoa_CreateWindow (SDL_cocoawindow.m:2421) | [[SDL3Window alloc] initWithContentRect:rect styleMask:style backing:NSBackingStoreBuffered defer:NO screen:screen] |
| Wayland | Wayland_CreateWindow (SDL_waylandwindow.c) | wl_compositor_create_surface; the xdg_surface/xdg_toplevel role is assigned later in Wayland_ShowWindow |
Attribute model. Window flags are a 64-bit bitfield in SDL_video.h:197: SDL_WINDOW_FULLSCREEN, SDL_WINDOW_BORDERLESS, SDL_WINDOW_RESIZABLE, SDL_WINDOW_ALWAYS_ON_TOP, SDL_WINDOW_TRANSPARENT, SDL_WINDOW_HIGH_PIXEL_DENSITY, SDL_WINDOW_UTILITY, SDL_WINDOW_TOOLTIP, SDL_WINDOW_POPUP_MENU, SDL_WINDOW_NOT_FOCUSABLE, and more. Per-platform silent gaps are real:
- Always-on-top maps to
xdg/libdecor on Wayland only weakly; Wayland deliberately offers no protocol to position a toplevel, soSDL_SetWindowPositionon a Wayland toplevel is silently a no-op (the compositor owns placement). - Transparency requires
SDL_WINDOW_TRANSPARENTplus an alpha-capable buffer; on Wayland SDL drops the opaque region (SetSurfaceOpaqueRegion,SDL_waylandwindow.c:294) so the compositor blends, while on X11 it depends on a compositing WM being present. - Fullscreen is real fullscreen on all four; Wayland uses
xdg_toplevel_set_fullscreen, X11 sets_NET_WM_STATE_FULLSCREEN, Win32 manipulates style +SetWindowPos, Cocoa toggles native fullscreen spaces.
Initial-frame handling diverges sharply. Win32 and X11 map immediately (CreateWindowEx + ShowWindow; XMapWindow). Wayland enforces the no-buffer-no-window rule: a surface is invisible until a buffer is committed, and the role can only be used after the compositor's configure. Wayland_ShowWindow round-trips and blocks for the configure before the surface is usable:
// src/video/wayland/SDL_waylandwindow.c (Wayland_ShowWindow) — must wait for the compositor's configure
/* We have to wait until the surface gets a "configure" event, or use of
* this surface will fail. This is a new rule for xdg_shell.
*/
while (data->shell_surface_status == WAYLAND_SHELL_SURFACE_STATUS_WAITING_FOR_CONFIGURE) {
if (libdecor_dispatch(c->shell.libdecor, -1) < 0) { /* ...handle disconnect... */ }
if (WAYLAND_wl_display_dispatch_pending(c->display) < 0) { /* ... */ }
}Surface/handle exposure for rendering. Native handles are exposed through the properties system (SDL_GetWindowProperties): SDL_PROP_WINDOW_WIN32_HWND_POINTER, SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, SDL_PROP_WINDOW_X11_DISPLAY_POINTER + SDL_PROP_WINDOW_X11_WINDOW_NUMBER, SDL_PROP_WINDOW_WAYLAND_DISPLAY_POINTER + SDL_PROP_WINDOW_WAYLAND_SURFACE_POINTER (all listed in SDL_video.h). This is SDL3's SDL_GetWindowProperties equivalent of Qt's QPA native interface or Rust's raw-window-handle.
Destruction ordering. SDL_DestroyWindow tears down children first; on Wayland the shell-surface objects (xdg_popup, xdg_toplevel, xdg_surface, then wl_surface) must be destroyed in protocol order, and a parent toplevel may not be destroyed while a popup grab is live without a protocol error — child windows are shown only after the parent reaches WAYLAND_SHELL_SURFACE_STATUS_SHOWN (the surface_frame_done callback walks first_child to release pending children, SDL_waylandwindow.c:840).
2. Event loop
Who owns the loop. By default the app owns it (SDL_PollEvent/SDL_WaitEvent pump). The SDL_MAIN_USE_CALLBACKS model hands ownership to SDL, which on real callback platforms hands it to the OS. The motivation is explicitly platform-driven:
There are platforms that would rather be in charge of that
whileloop: iOS would rather you return frommain()immediately and then it will let you know that it's time to update and draw the next frame of video. Emscripten (programs that run on a web page) absolutely requires this to function at all. Video targets like Wayland can notify the app when to draw a new frame, to save battery life and cooperate with the compositor more closely.
This is the central redesign relative to SDL2, which only ever offered the synchronous pump (the SDL_iPhoneSetAnimationCallback hack on iOS aside). See readiness-vs-completion-windowing for how this "who calls whom" question generalizes.
Native loop integration, per backend:
Win32 —
WIN_PumpEvents(SDL_windowsevents.c:2590) drains the thread message queue withPeekMessage(..., PM_REMOVE)+TranslateMessage+DispatchMessage, capping at ~1 ms of work so it can't busy-loop forever. The modal resize/move loop is handled specially:WM_ENTERSIZEMOVEinstalls a timer keyed on the callback function pointer, andWM_TIMERruns a live-resize iteration so rendering continues while the user drags the frame:c// src/video/windows/SDL_windowsevents.c — keep iterating during the Win32 modal resize loop case WM_ENTERSIZEMOVE: case WM_ENTERMENULOOP: ++data->in_modal_loop; if (data->in_modal_loop == 1) { SetTimer(hwnd, (UINT_PTR)SDL_IterateMainCallbacks, USER_TIMER_MINIMUM, NULL); } break; case WM_TIMER: if (wParam == (UINT_PTR)SDL_IterateMainCallbacks) { SDL_OnWindowLiveResizeUpdate(data->window); // app keeps drawing while DefWindowProc blocks return 0; } break;Cocoa —
Cocoa_PumpEvents(SDL_cocoaevents.m:633) wrapsnextEventMatchingMask:untilDate:inMode:dequeue:inNSDefaultRunLoopMode, forwarding eachNSEventto[NSApp sendEvent:].Cocoa_WaitEventTimeoutblocks on[NSDate distantFuture]; user wakeups are injected with a syntheticNSEventTypeApplicationDefinedevent posted via[NSApp postEvent:atStart:YES](Cocoa_SendWakeupEvent).Wayland —
Wayland_PumpEvents(SDL_waylandevents.c:598) drives the fd-dispatch dance by hand:wl_display_prepare_read/wl_display_flush/SDL_IOReady(display_fd, ...)/wl_display_read_events/wl_display_dispatch_pending, pluslibdecor_dispatch. It is the readiness-on-an-fd model. Frame pacing uses frame callbacks:surface_frame_done(SDL_waylandwindow.c:810) re-armswl_surface_frameevery frame, damages the surface, and advances the show-state machine — vsync comes from the compositor telling you when to draw, not from a clock.X11 —
X11_PumpEvents(SDL_x11events.c:2322) loopsX11_PollEvent→X11_DispatchEventover the connection, and also services bookkeeping: fullscreen-mode-switch deadlines, screensaver tickle every 30 s, window-flash timers, and XInput2 hierarchy changes.
Timers/wakeups & external fds. SDL_WaitEventTimeout blocks with a timeout; cross-thread wakeups are SDL_SendWakeupEvent (Cocoa synthetic event, Wayland a pipe write, X11 a dummy client message). There is no built-in way to add an arbitrary external fd to the wait set portably — apps that need that drop to the native handle or run their own thread. Frame pacing sources by platform: Wayland frame callbacks; macOS the run loop / Metal CAMetalLayer present; Win32 DWM/DXGI present timing; X11 the redraw cadence of the app. The main-callback rate is tunable via SDL_HINT_MAIN_CALLBACK_RATE ("waitevent" to iterate only after an event).
3. Input
Scancode vs keycode model. SDL separates physical keys from virtual keys: SDL_Scancode is a layout-independent physical position (USB HID-derived), SDL_Keycode is the layout-dependent symbol (SDL_keycode.h). This is the scancode/keysym/virtual-key split made explicit in the API — WASD movement keys by scancode, text shortcuts by keycode. Backends translate native codes through static tables in src/events/SDL_scancode_tables_c.h (scancodes_linux.h, scancodes_windows.h, scancodes_darwin.h, scancodes_xfree86.h).
Layout / xkbcommon ownership. On X11, SDL owns the xkb state: X11_XkbGetMap(... XkbUseCoreKbd) and X11_XkbSetDetectableAutoRepeat(display, True, ...) (SDL_x11keyboard.c:140). On Wayland the compositor sends a keymap fd that SDL feeds to xkbcommon to build the state machine per seat.
Key repeat — the client must synthesize it on Wayland. Wayland sends only physical press/release plus a repeat rate/delay; the client generates the repeats. SDL does this in the pump loop after dispatching, checking each seat's repeat state and emitting synthetic key + text events:
// src/video/wayland/SDL_waylandevents.c (keyboard_repeat_handle) — client-side key repeat
while (elapsed >= repeat_info->next_repeat_ns) {
if (repeat_info->scancode != SDL_SCANCODE_UNKNOWN) {
SDL_SendKeyboardKeyIgnoreModifiers(/*ts*/, repeat_info->keyboard_id, repeat_info->key, repeat_info->scancode, true);
}
if (repeat_info->text[0] && !(SDL_GetModState() & (SDL_KMOD_CTRL | SDL_KMOD_ALT))) {
SDL_SendKeyboardText(repeat_info->text);
}
repeat_info->next_repeat_ns += SDL_NS_PER_SECOND / (Uint64)repeat_info->repeat_rate;
}X11 takes the opposite route, asking the server for detectable autorepeat so it does not have to synthesize it.
IME / text input. SDL separates key events from text via SDL_StartTextInput/SDL_EVENT_TEXT_INPUT (committed) and SDL_EVENT_TEXT_EDITING (pre-edit/composition). Protocol per platform:
Wayland uses
zwp_text_input_v3(text-input-unstable-v3.xml). The pre-edit handler converts the compositor's byte cursor offsets into UTF-8 and emits an editing event; the commit handler emits final text:c// src/video/wayland/SDL_waylandevents.c — zwp_text_input_v3 pre-edit vs commit static void text_input_preedit_string(void *data, struct zwp_text_input_v3 *ti, const char *text, int32_t cursor_begin, int32_t cursor_end) { seat->text_input.has_preedit = true; SDL_SendEditingText(text, cursor_begin_utf8, cursor_size_utf8); // EVENT_TEXT_EDITING } static void text_input_commit_string(void *data, struct zwp_text_input_v3 *ti, const char *text) { SDL_SendKeyboardText(text); // EVENT_TEXT_INPUT }Candidate-window positioning is fed by
SDL_SetTextInputArea→zwp_text_input_v3_set_cursor_rectangle.Windows uses legacy IMM32, not TSF:
ImmGetContext,ImmSetCompositionWindow, and theWM_IME_STARTCOMPOSITION/WM_IME_COMPOSITION(GCS_COMPSTRfor pre-edit,GCS_RESULTSTRfor the committed string) messages (SDL_windowskeyboard.c). Candidate positioning isImmSetCompositionWindow(himc, &cof).macOS implements
NSTextInputClienton an internalSDL3TranslatorResponderview (SDL_cocoakeyboard.m:39):setMarkedText:produces pre-edit,insertText:the committed text,firstRectForCharacterRange:positions the candidate window.X11 uses XIM (legacy), or routes through the
textpackage'sSDL_IME_*whenSDL_USE_IME/IBus is built in.
Pointer. Mouse coordinates are floating-point for sub-pixel motion (an SDL2→SDL3 change, README-migration.md:377). Relative/raw motion is SDL_WINDOW_MOUSE_RELATIVE_MODE, on Wayland via zwp_relative_pointer_v1 + zwp_locked_pointer_v1 (raw-vs-accelerated pointer). High-resolution scroll: Wayland accumulates wl_pointer axis_value120 per frame (pointer_handle_axis_common, SDL_waylandevents.c:1132), preferring v120 > discrete > continuous within a frame; Win32 accumulates WM_MOUSEWHEEL WHEEL_DELTA; macOS tracks momentum phases. Capture is SDL_CaptureMouse; confinement/locking via SDL_WINDOW_MOUSE_GRABBED + the Wayland pointer-constraints protocol.
Touch, gestures, pen. SDL_EVENT_FINGER_DOWN/UP/MOTION for touch; SDL3 added a first-class pen API (src/events/SDL_pen.c, SDL_EVENT_PEN_*). Gesture recognition was removed in SDL3 (the SDL2 SDL_GESTURE events are gone) — apps are expected to recognize gestures from raw touch.
Cursor. Wayland prefers the cursor-shape-v1 protocol (wp_cursor_shape_device_v1_set_shape, SDL_waylandmouse.c:1135) and falls back to a client-rendered wl_surface cursor when the compositor lacks it; wl_pointer_set_cursor(NULL) hides it.
4. Wayland specifics
Decorations: SSD-preferred, libdecor or own-surface CSD fallback. SDL prefers server-side decorations when zxdg_decoration_manager_v1 is advertised; it binds it at registry time (SDL_waylandvideo.c:1389) and on a plain xdg_toplevel requests the mode directly:
// src/video/wayland/SDL_waylandwindow.c — ask the compositor for server-side decorations when possible
if (c->decoration_manager) {
data->server_decoration = zxdg_decoration_manager_v1_get_toplevel_decoration(c->decoration_manager, ...);
const enum zxdg_toplevel_decoration_v1_mode mode =
!(window->flags & SDL_WINDOW_BORDERLESS) ? ZXDG_TOPLEVEL_DECORATION_V1_MODE_SERVER_SIDE
: ZXDG_TOPLEVEL_DECORATION_V1_MODE_CLIENT_SIDE;
zxdg_toplevel_decoration_v1_set_mode(data->server_decoration, mode);
}When the compositor offers no xdg-decoration (notably GNOME/Mutter, which has refused SSD), SDL routes the toplevel through libdecor (#include <libdecor.h>, libdecor_decorate + libdecor_frame_map, WAYLAND_SHELL_SURFACE_TYPE_LIBDECOR) so a CSD frame is drawn for it. SDL does not hand-draw its own titlebar — it punts to libdecor. (See the in-code KDE bug reference around SDL_waylandwindow.c:2125 for the kind of compositor-specific workaround that litters this file.)
Protocol coverage. The bundled protocol set in wayland-protocols/ and its usage covers far more than core + xdg-shell: fractional-scale-v1, viewporter, xdg-activation-v1 (focus stealing), idle-inhibit-unstable-v1, cursor-shape-v1, relative-pointer/pointer-constraints/pointer-gestures, tablet-v2, primary-selection, text-input-unstable-v3, xdg-foreign-v2 (window export), xdg-toplevel-icon-v1, xdg-dialog-v1 (modal hint), single-pixel-buffer-v1, alpha-modifier-v1, and color-management-v1. There is no layer-shell support — SDL is a client-app toolkit, not a desktop-shell toolkit.
NOTE
Protocol absence is handled by feature-test: SDL binds each global only if the compositor advertises it (wl_registry listener in SDL_waylandvideo.c) and degrades — e.g. no fractional-scale-v1 means integer wl_surface_set_buffer_scale; no cursor-shape-v1 means a client-rendered cursor surface; no xdg-decoration means libdecor.
5. DPI & scaling
SDL3's scale model is documented in full in README-highdpi.md. The native unit is the platform's unit — SDL does not impose one logical space:
SDL 3.0 has new support for high DPI displays. Interfaces provided by SDL [use] the platform's native coordinates unless otherwise specified.
Three distinct quantities: display content scale (SDL_GetDisplayContentScale, the "draw things bigger" hint), window pixel density (SDL_GetWindowPixelDensity, the size-vs-pixels ratio), and window display scale (SDL_GetWindowDisplayScale, their product). Crucially the model differs by platform, and SDL exposes rather than hides it (logical vs physical coords, scale factor):
The Windows and Android coordinate system always deals in physical device pixels... The macOS and iOS coordinate system always deals in window coordinates... On Linux, X11 uses a similar approach to Windows and Wayland uses a similar approach to macOS.
So on a 3840×2160 / 200% monitor a default window reports SDL_GetWindowSize = 1920×1080 on macOS but 3840×2160 on Windows. The "created-at-wrong-scale-then-rescaled" problem is handled with events: you are guaranteed a SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED on creation and on every resize, and SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED when the scale changes — the app sizes its graphics context off those events rather than off the create-time numbers.
- Windows per-monitor DPI. SDL loads
SetProcessDpiAwarenessContextand prefersDPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, falling back to the v1SetProcessDpiAwareness(PROCESS_PER_MONITOR_DPI_AWARE)on older Windows (SDL_windowsvideo.c:263). TheWM_DPICHANGEDhandler (SDL_windowsevents.c:2423) resizes via the OS-suggested rect, while ignoring the change when SDL itself triggered the move (data->expected_resize). - Wayland fractional scaling is
fractional-scale-v1+viewporter: SDL renders at a buffer scale and useswp_viewportto map to the fractional logical size. - macOS backing scale comes from the
NSScreenbackingScaleFactor;SDL_WINDOW_HIGH_PIXEL_DENSITYrequests the 2× backing store.
Mixed-DPI multi-monitor migration fires the scale-changed events as the window crosses monitors; on Win32 that is the WM_DPICHANGED dance above.
6. Multi-window & popups
SDL3 has a real parent/child window model: SDL_CreatePopupWindow(parent, x, y, w, h, flags) plus the SDL_WINDOW_TOOLTIP, SDL_WINDOW_POPUP_MENU, SDL_WINDOW_UTILITY, and SDL_WINDOW_MODAL flags.
- Wayland maps popups to
xdg_popupwith anxdg_positioner(SDL_waylandwindow.c:2189): anchor rect, gravity, and constraint-adjustment (slide) are set, and tooltips get an empty input region so they cannot be interacted with. Popup-menu windows take keyboard focus; this is the xdg_popup grab discipline (a popup is parented to its toplevel and the compositor manages the grab/dismiss). - X11 uses override-redirect for tooltips and popup menus:
xattr.override_redirect = (TOOLTIP || POPUP_MENU || force) ? True : False(SDL_x11window.c:653), bypassing the WM for placement. - Win32 creates utility windows with a hidden owner window and uses owner/parent
HWNDrelationships (SDL_windowswindow.c:709). - Modal dialogs use
SDL_SetWindowModal; on Cocoa it runs anNSModalSessioninside the pump loop (SDL_cocoaevents.m:591), on Wayland thexdg-dialog-v1modal hint.
Stacking and groups follow the parent chain; a child is shown only after its parent is fully shown (Wayland's SHOW_PENDING state, SDL_waylandwindow.c:2102).
7. Threading
The hard constraint is that video init and window/event calls must happen on the main thread. SDL_INIT_VIDEO is documented "should be initialized on the main thread" (SDL_init.h:81), and the per-platform reason is AppKit/UIKit: on Apple platforms the main thread is the thread that runs main(), and NSApplication/NSWindow are main-thread-only. SDL exposes SDL_IsMainThread and, for off-thread work that must touch the UI, a marshaling primitive:
// include/SDL3/SDL_init.h — marshal a callback onto the main thread during event processing
typedef void (SDLCALL *SDL_MainThreadCallback)(void *userdata);
extern SDL_DECLSPEC bool SDLCALL SDL_RunOnMainThread(SDL_MainThreadCallback callback, void *userdata, bool wait_complete);Events are delivered on the thread that pumps (normally main). Rendering: the SDL 2D renderer and SDL_GPU are likewise expected to be used from the thread that created the window on most backends; the constraint is strictest on macOS where AppKit forbids off-main UI mutation. SDL_PushEvent/SDL_SetAtomicInt make the event queue and the main-callbacks quit-result thread-safe, but window mutation off-main is unsupported.
WARNING
The macOS main-thread requirement is the usual culprit shaping the whole model: because NSApp must run on thread 0, SDL routes its loop and all window calls there, and offers SDL_RunOnMainThread rather than locking, so cross-platform code that creates windows from a worker thread will work on Win32/X11 but break on Cocoa.
8. Clipboard & DnD
SDL3 unified the clipboard around MIME types and a lazy provider callback (SDL_clipboard.h): SDL_SetClipboardData(callback, cleanup, userdata, mime_types, n) registers the offered MIME types but defers the bytes until a consumer asks, and SDL_GetClipboardData(mime_type, &size) retrieves them. Primary selection (SDL_SetPrimarySelectionText) is exposed separately.
Wayland maps directly:
wl_data_device/wl_data_source/wl_data_offerfor the regular selection andzwp_primary_selection_v1for primary (SDL_waylanddatamanager.c). The lazy callback matches Wayland's "source sends bytes on demand over a pipe" model perfectly.X11 implements the selection protocol with INCR (incremental transfer) for large data — the getter loops, deleting the property between chunks until the transfer completes (
SDL_x11clipboard.c:215).Win32 investigated delayed rendering and chose eager materialization, with a candid comment:
I investigated delayed clipboard rendering, and at least with text and image formats you have to use an output window, not
SDL_HelperWindow, and the system requests them being rendered immediately, so there isn't any benefit.—
src/video/windows/SDL_windowsclipboard.c(WIN_SetClipboardData)
Drag-and-drop is delivery-only as files/text: SDL_EVENT_DROP_BEGIN, SDL_EVENT_DROP_FILE, SDL_EVENT_DROP_TEXT, SDL_EVENT_DROP_POSITION, SDL_EVENT_DROP_COMPLETE (src/events/SDL_dropevents.c). SDL is a drop target; initiating a drag from an SDL window is not part of the portable API.
9. Escape hatches
SDL treats the leaky abstraction as inevitable and ships documented hatches:
Native handles via
SDL_GetWindowProperties(theHWND,NSWindow,Display+Window,wl_display+wl_surfacepointers listed in §1). This is the primary hatch for integrating Vulkan/Direct3D/Metal directly or embedding into another toolkit.Message-pump hooks.
SDL_SetWindowsMessageHook(callback, userdata)(SDL_system.h:96) hands every Win32MSGto the app before SDL dispatches it (returningfalseswallows it);SDL_SetX11EventHookdoes the same for rawXEvents. These replace SDL2'sSDL_GetWindowWMInfo:The Windows and X11 events are now available via callbacks which you can set with
SDL_SetWindowsMessageHook()andSDL_SetX11EventHook().External windows.
SDL_CreateWindowWithPropertiesacceptsSDL_PROP_WINDOW_CREATE_WIN32_HWND_POINTER/..._WAYLAND_WL_SURFACE_POINTER/..._X11_WINDOW_NUMBER/..._COCOA_WINDOW_POINTERto wrap an already-created native window (SDL_WINDOW_EXTERNAL), the replacement for SDL2's removedSDL_CreateWindowFrom.
That this many hatches exist, and that the Win32/X11 hooks are first-class API, is itself the signal of where the abstraction is known to leak: raw message handling and native-handle interop.
10. History, redesigns & known regrets
The richest source is docs/README-migration.md, a long catalog of every SDL2→SDL3 break. The windowing-layer changes worth noting:
Main-callbacks model (new in SDL3).
SDL_AppInit/SDL_AppIterate/SDL_AppEvent/SDL_AppQuitunderSDL_MAIN_USE_CALLBACKS(SDL_main.h, used since SDL 3.2.0). It exists because iOS/Emscripten/Wayland want to drive the frame loop; the FIXME in the generic loop (SDL_sysmain_callbacks.c) records the still-unfinished ambition to "hand off callback responsibility to the video subsystem... if Wayland has a protocol to drive an animation loop."Properties replace typed getters (new in SDL3).
SDL_GetWindowWMInfoandSDL_SysWMinfowere deleted;SDL_GetWindowProperties+SDL_GetWindowData/SDL_SetWindowDataremoved in favor ofSDL_GetPointerPropertyon the window's property bag (README-migration.md:2224).SDL_CreateWindowFromwas replaced bySDL_CreateWindowWithProperties(line 2226).High-DPI overhaul (new in SDL3). Native coordinates by default; the distinct pixel-size / display-scale / pixel-density model;
SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED; mouse coordinates became floating-point. SDL2's renderer auto-scaling of mouse coordinates was removed (SDL_HINT_MOUSE_RELATIVE_SCALING, line 834); apps now callSDL_ConvertEventToRenderCoordinatesexplicitly (line 1392).Unified flags & 64-bit window flags. Window flags grew to 64 bits to fit
SDL_WINDOW_NOT_FOCUSABLE,SDL_WINDOW_TRANSPARENT,SDL_WINDOW_HIGH_PIXEL_DENSITY, etc.Gesture recognition removed. The SDL2 multi-gesture/dollar-gesture API is gone with no replacement; apps recognize gestures from raw
SDL_EVENT_FINGER_*themselves.Wayland's no-buffer-no-window has been a long tail of bugs. The
Wayland_ShowWindowcode carries explicit references to upstream compositor bugs (the KDE bug 448856 comment aroundSDL_waylandwindow.c:2125) and a confession that the same buffer-detach call had to be duplicated in both Hide and Show paths to satisfy Unreal Engine's popups — a candid maintainer note that the Wayland lifecycle is fiddly.
NOTE
SDL3.0 (3.2.0) was the first stable SDL3, released January 2025; this study is against the 3.5.0 development tree at b53f1b06. The migration document and the issue tracker (libsdl-org/SDL/issues) are the primary record of why each windowing change was made.
Strengths
- True cross-platform parity for the windowing core: one API spans Wayland, X11, Win32, Cocoa, UIKit, Android, KMSDRM, and Emscripten, with backends selected at runtime.
- The hybrid loop model is pragmatic. Apps keep a simple
SDL_PollEventloop, but the main-callbacks option means the same code runs on iOS and the web where the OS must own the loop. - Honest about coordinates. Exposing pixel density / content scale / display scale separately, with guaranteed events, lets apps render crisply on mixed-DPI setups without guesswork.
- Modern Wayland citizen. Fractional scale, cursor-shape, text-input v3, pointer-constraints, xdg-activation, and libdecor fallback are all present; protocol absence degrades gracefully.
- Documented escape hatches (properties, message-pump hooks, external windows) make SDL embeddable and extensible without forking.
- Live-resize keeps drawing on Win32 via the
WM_TIMERtrick inside the modal loop — a notoriously hard case handled.
Weaknesses
- Win32 IME is legacy IMM32, not TSF, so it lags modern Windows text-input features and per-input-method behavior.
- No layer-shell, no drag source. SDL can't be a desktop-shell component on Wayland, and can't initiate drag-and-drop portably.
- Gesture API removed with no replacement — a regression for touch apps that relied on SDL2.
- Main-thread bound. Window/event calls must be on the main thread (AppKit), so worker-thread window creation that works on Win32/X11 silently breaks on macOS.
- No portable external-fd integration into the wait set; async-runtime cohabitation needs the native handle or a side thread (cf. async-io).
- Wayland lifecycle is fragile — the no-buffer-no-window/configure round-trip and popup ordering have produced a long bug tail visible in the source comments.
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
Hybrid loop: app-owned SDL_PollEvent + optional main-callbacks | Simple by default; lets the OS own the loop on iOS/Emscripten/Wayland without #ifdefs in the app | Two code paths to learn; callbacks dispatch events via an event-watcher indirection |
| Per-backend vtable behind one C API | Add/replace a platform by filling a function table; runtime backend selection | Lowest-common-denominator API; per-platform features hide behind hints/properties |
| Native coordinates, expose the scale | Matches each OS's real model; no lossy synthetic logical space | App must handle three scale quantities and react to size-changed events itself |
| Properties bag for native handles | ABI-stable extensibility; one mechanism for HWND/NSWindow/wl_surface/… | Stringly-typed, runtime-checked access instead of typed getters |
| SSD-first, libdecor CSD fallback | Respect compositor decoration preference; still work on GNOME (no xdg-decoration) | Depends on an external libdecor; SDL draws no titlebar of its own |
| Client-side Wayland key repeat, server-side X11 autorepeat | Each follows its platform's protocol contract | Wayland repeat logic lives in SDL's pump and must track per-seat timing |
| Win32 IMM32 over TSF | Simpler, ubiquitous, fewer COM dependencies | Misses modern TSF capabilities and richer composition control |
| Main-thread-only windowing | AppKit/UIKit mandate it; uniform rule across platforms | SDL_RunOnMainThread marshaling needed; no off-thread window creation |
Verdict: what a new framework should steal / avoid
Steal: the hybrid loop — a dead-simple default pump plus an opt-in inverted callback model so the same app runs where the OS owns the loop; the honest DPI model (separate pixel-density / content-scale / display-scale with guaranteed change events); the properties-bag escape hatch for native handles (ABI-stable, uniform); SSD-first-with-libdecor-fallback on Wayland; and first-class message-pump hooks so the abstraction can be bypassed without forking. Avoid: legacy IMM32 instead of TSF on Windows; dropping a whole capability (gestures) with no replacement; and a Wayland show-path that blocks on a synchronous round-trip for configure — model the no-buffer-no-window lifecycle as explicit state from the start rather than a blocking wait retrofitted with compositor-bug workarounds.
Open questions I could not resolve (with where the answer likely lives)
- Will the main-callback loop ever be handed to the Wayland frame-callback driver? The FIXME in
SDL_sysmain_callbacks.cflags this as intended-but-undone; the answer will appear in the issue tracker / future commits tosrc/main/. - What exactly is the threading contract for
SDL_GPUvs the 2D renderer off the event thread? Documented thread-safety annotations are per-function in the headers; the authoritative answer is ininclude/SDL3/SDL_gpu.handsrc/render/, not the video subsystem studied here. Marked [inference] that it follows the main-thread window rule. - Does SDL ever use TSF on Windows? Only IMM32 paths were found in
SDL_windowskeyboard.c; whether a TSF backend is planned would be in the issue tracker.
Sources
- libsdl-org/SDL — repository (all quoted file paths), at commit
b53f1b06 include/SDL3/SDL_video.h— video subsystem API and header positioning quoteinclude/SDL3/SDL_main.h— main-callbacks macro and entry pointsdocs/README-main-functions.md— rationale for the callback loopdocs/README-migration.md— SDL2→SDL3 windowing-layer changesdocs/README-highdpi.md— the high-DPI coordinate modelsrc/main/SDL_main_callbacks.c/src/main/generic/SDL_sysmain_callbacks.c— callback loop implementationsrc/video/SDL_sysvideo.h/SDL_video.c— the backend vtable and dispatch- Backends:
SDL_waylandwindow.c,SDL_waylandevents.c,SDL_x11window.c,SDL_x11events.c,SDL_windowswindow.c,SDL_windowsevents.c,SDL_cocoawindow.m,SDL_cocoaevents.m - Wayland protocols:
wayland-protocols/,text-input-unstable-v3.xml - libsdl-org/SDL issues — design rationale and regressions
- Sibling docs: concepts, ui-layout, async-io