How It Works¶
The pieces of libwinui as they are built today, and how a frame moves through them. Read this before you change anything below the public API.
The one idea the whole design hangs on: controls never talk to Windows. A control measures
itself against a TextMeasurer, draws through a Renderer, reads colors from a Theme and
receives PointerEvent/KeyEvent values. Each of those is an interface or a plain value, and
each has a Windows implementation and a test implementation. That is why the canonical test
needs no window, no GPU and no Windows session:
ui::ButtonControl button{"Hello"};
ui::testing::TestRenderer renderer;
button.render(renderer);
REQUIRE(renderer.count() == 3); // background + elevation-border stroke + text
The Pieces¶
| Page | What it covers | Main files |
|---|---|---|
| The Tree | Element, single ownership, observers, DirtyFlags, VisualId, VisualTree |
include/winui/ui/Element.hpp, core/DirtyFlags.hpp, ui/VisualTree.hpp |
| Specs and build() | The declarative ui layer, Spec/SpecList, Property<T> and bindings |
ui/spec/*.hpp, each control's header, core/Property.hpp |
| Layout | Measure and arrange, the measurer seam, sizing rules, containers, parallel measuring | ui/Element.hpp, render/Text.hpp, ui/panels/Stack.hpp, ui/ParallelLayout.hpp |
| Rendering | The render-command stream, TestRenderer, D2DRenderer, describe(), offscreen capture, composition and animation |
render/Renderer.hpp, src/render/Compositor.hpp, ui/Animation.hpp |
| Input and Focus | Pointer and key routing, popups and modals, the tab ring, shortcuts, automation | ui/Window.hpp, src/ui/Window.cpp, ui/AutomationTree.hpp |
| Theming | Theme, palettes per era, ThemeRequest, the OS theme source, high contrast |
ui/themes/Theme.hpp, ui/themes/*Palette.hpp, ui/themes/ThemeSource.hpp |
| Era Packs | What each of the nine theme variants reproduces, approximates and cannot, per era | src/ui/themes/Theme*.cpp, ui/themes/*Palette.hpp |
| Platform Seams | Every OS service behind an interface with a test double | ui/Clipboard.hpp, render/NativeHost.hpp, src/ui/WindowHost.hpp, ... |
A few layout rules hold everywhere and are worth knowing before you open any of them:
include/winui/<subdir>/mirrorssrc/<subdir>/one to one. A header and its.cppsit in matching folders, and includes always use the fullwinui/<subdir>/<name>.hppform.- Everything Windows-specific lives under
src/platform/windows/. Its headers,D2DRendererandDWriteMeasureramong them, are private and never installed. include/winui/testing/HeadlessUI.hppis the other host. It builds the same logical tree with a deterministic text measurer and never touches a Windows object. It is part of the testing tier, a library of its own (winui::testing, sources insrc/testing/) that only tests link.
One Request Through the System¶
A left click on a Button in a running window, from the mouse message to the pixels. Every
step names the function that does it.
sequenceDiagram
participant OS as USER32
participant Host as Win32WindowHost.cpp
participant W as Window
participant B as Button
participant R as D2DRenderer
OS->>Host: WM_LBUTTONDOWN
Host->>Host: SetCapture, DIP conversion, click count
Host->>W: handle_pointer(PointerEvent Down)
W->>W: focus the stop under the pointer
W->>B: pointer_override, after the hit test
B->>B: pointer_down_ = true
Host->>OS: InvalidateRect
OS->>Host: WM_LBUTTONUP
Host->>W: handle_pointer(PointerEvent Up)
W->>B: pointer_override (it holds the capture)
B->>B: activate(), on_click handler runs
W->>W: sync_composition, sync_native
Host->>OS: InvalidateRect
OS->>Host: WM_PAINT
Host->>W: layout(client, DWriteMeasurer)
Host->>W: render(D2DRenderer)
W->>B: render(renderer, context)
B->>R: fill_rounded_rect, stroke, draw_text
In words:
- The host translates the message.
WM_LBUTTONDOWNinsrc/platform/windows/Win32WindowHost.cppcallsSetCapture(so the matching button-up arrives even outside the window), converts the position from physical pixels to DIPs, counts the click streak for double and triple clicks, and builds aPointerEvent{position, PointerButton::Left, PointerAction::Down}. Window::handle_pointer()routes it. It remembers the cursor under the press, then checks in order: a drag in progress, an open modal, open popups (a press outside every popup and its owner light-dismisses them and is swallowed). For an ordinary press it walks the tab ring and focuses the first stop whosetakes_focus_at()accepts the point, then hands the event to the content root.- The content root routes it.
Element::handle_pointer()hit-tests once, finds theButtonas the topmost element under the point, and hands it the event inpointer_override()before its parents. TheButtonsetspointer_down_andhovered_and returns true, so a list row or card around it does not act on the same press. It does not redraw anything itself. The press captures the pointer for the button and its parents until the release. - The host asks for a repaint. Every mouse message ends with
InvalidateRect. - The release clicks. On
WM_LBUTTONUPthe button seesUpinside its bounds after its ownDown, callsactivate(), and theon_clickhandler runs. If that handler writes aProperty<T>, every bound control updates synchronously through its setter, and each setter callsmark_dirty(). After dispatch, the window callssync_composition()(the hover overlay fades through the compositor) andsync_native(). - Deferred work runs. A
DispatchGuardwraps every input entry point. When the outermost one unwinds, a modal closed from its own button is destroyed and queued URI launches are handed to theLauncher, so no stack frame is still inside the objects being torn down. - Paint lays out, then draws.
WM_PAINTcallsWindow::layout()first. It measures and arranges only what is markedMeasureorArrange, or everything when the client size changed, clears the dirty flags, rebuilds the tab ring and re-places popups. Thenpaint()clears the target toWindow::background()(or to transparent, under a system backdrop) and callsWindow::render()with aD2DRendererwrapped around theID2D1HwndRenderTarget. The button emits its three commands and Direct2D draws them.
The message loop also calls Window::tick() after every message it dispatches (and at least
every 100 ms while idle), with the real elapsed time. tick() runs posted work, the app's
on_tick() hook, every element's animations and tick_override(), and the tooltip countdown, and
returns whether anything is dirty so the loop knows to invalidate again.
The headless host runs the same steps minus the first and last: a test calls
Window::handle_pointer() directly, then layout() with a HeadlessTextMeasurer, then
render() into a TestRenderer.
Threading¶
Everything in the tree is UI-thread only. There are no locks in Element, Window or
Property<T>. Two things cross threads:
Window::post()may be called from any thread. The work runs on the UI thread at the start of the nexttick(), and the host is woken so it does not wait for the idle timer. It needs theWindowto exist;WindowRef::post()(fromWindow::ref()) is the same and drops the work once theWindowis destroyed (see Coroutines).Scheduler(include/winui/core/Scheduler.hpp) is a small worker pool for coarse chunks of work that never touch the tree.Window::layout()uses it to measure large batches of independent siblings whose subtrees answerElement::parallel_measure_safe()on worker threads; measuring there only reads, see Layout.
The UI thread of a Window is the thread that constructed it. A tree belongs to the window it
hangs under: set_content() and show_modal() stamp the window's thread on the root, and an
element finds it by walking up its parents. An element that is not under a window yet belongs
to nobody, so a page can be built on a worker and handed over with post().
The thread checks¶
include/winui/core/ThreadCheck.hpp turns the "UI thread only" rule into a report in debug
builds. LIBWINUI_THREAD_CHECKS switches it: unset, it follows NDEBUG; the CMake option
LIBWINUI_THREAD_CHECKS (AUTO, ON, OFF) forces it as a public compile definition, because
PropertyBase, Element and Window carry an extra std::thread::id with the checks on.
With them off nothing is stored and nothing is called; tests/thread_checks_off.cpp is built
without the library to prove it, as a leftover call would not link.
Only mutations are checked, never reads, so measure() on a worker passes:
| What | Checked against | Hook |
|---|---|---|
| element setters | the root's window thread; detached elements are not checked | Element::mark_dirty() |
Window members other than post() and ref() |
the constructing thread | Window::check_thread() |
Property::set()/update()/=, ObservableVector edits, Signal::raise() |
the thread of the first write that reached an observer | PropertyBase::check_write() |
observe() |
the same, once claimed | PropertyBase::attach() |
A property with no observers is not checked, and does not claim a thread until a write first reaches one. Claiming on the first subscriber would be wrong for a page built on a worker: its bindings subscribe there, and the UI thread's writes afterwards would be reported.
Every check takes a defaulted std::source_location. A default argument is evaluated at the
call site, so the one on mark_dirty() names the setter that called it, and the ones on
Property::set(), update(), Window::set_content() and show_modal() name the app's file and
line. Property::operator= cannot take one itself, so it takes detail::AssignedValue<T>, whose
converting constructor has the defaulted location, and the conversion runs at the caller. While a property write
dispatches, detail::WriteScope remembers where it was made, and an element report it leads to
ends with "reached from a ui::Property write at main.cpp:42".
ui::set_thread_check_handler() replaces what a failed check does; the tests install a
recording one. The default writes the message to stderr and the debugger output, then breaks
into an attached debugger, or aborts: the call is a data race, and what it corrupts surfaces far
away from it.
Coroutines¶
include/winui/ui/Coroutine.hpp lets a handler hop threads in straight-line code.
ui::FireAndForget is the return type: it starts at once and frees its frame at the end, and an
exception escaping it calls std::terminate(), since nobody is waiting to catch it.
co_await ui::on_background() resumes on ui::background_scheduler() (a Scheduler made on
first use and never destroyed, so exit never joins its workers), or on a given Scheduler.
co_await ui::on_ui_thread(home) resumes through post() on the window a ui::WindowRef names
(taken with Window::ref() on the UI thread), so always inside a later tick(), even from the
UI thread.
The resumption post() carries owns the suspended frame. Run, it resumes it; dropped, because
the window closed first, it destroys it, so the coroutine never resumes, its locals' destructors
run, and nothing leaks. Which thread drops it depends on when: the awaiting thread when the
window was closed or destroyed already, the UI thread as the window closes, the destroying thread when the
Window dies with the work queued. post() and the close path destroy dropped work outside the
window's lock, so those destructors may post again. A Scheduler runs every job it accepts, so
on_background() needs no such care.
The default pool is never joined, so a coroutine can still hop back after main() destroyed its
Window. on_ui_thread() therefore never calls Window::post(); it posts through a
ui::WindowRef, which src/ui/WindowRef.cpp resolves against a process-wide registry of live
windows: a mutex and a map from address to a serial number, leaked so it outlives static
destruction. Window's constructor registers as its last step, its destructor unregisters as its
first, and WindowRef::post() looks the window up and queues the work while it holds the
registry's lock, so a destructor running at the same time waits until the work is queued (and
then drops it with the rest of the queue) or finds it refused. Lock order is registry, then the
window's queue lock; nothing takes them the other way round. A refused resumption destroys the
frame on the posting thread. A WindowRef carries the serial as well as the address, so a new
Window at a reused address does not get work meant for the old one; there is no form that goes
by address alone, since that is made on the worker, after the window may be gone.
Where to Go Next¶
- To add something, the hooks pages walk the files for each kind of change.
- To write tests for it, see Write Tests.
- For where the visual targets come from, see the background pages.