Layout¶
Two passes, one measurer interface, and the sizing rules applied once in Element instead of in
every control.
Measure and Arrange¶
Layout is the classic two-pass model WinUI and WPF use.
Measure asks each element how big it wants to be. Element::measure(available, measurer,
theme) returns the desired size including margin, and caches the core result for arrange. A
container's measure_override() measures each child with whatever constraint its own layout rule
gives that child, and usually caches the answers.
Arrange hands each element its final rect. Element::arrange(final_rect) settles the
element's own bounds() from the rect, its margin and its alignment, then calls arrange_override()
so a container can place its children, typically from the sizes it cached during measure.
A few conventions hold across the whole tree:
- Infinity means unbounded. An available extent of
ui::Unbounded(+infinity) on an axis means "as much as you like", as in WinUI; 0 is no room at all. AStackControlmeasures its children withUnboundedalong the stack direction, and a verticalScrollmeasures its child withUnboundedfor the height.Element::measure()keeps an unbounded axis unbounded through margins, and a bounded axis that margins use up becomes 0. A desired size is always finite. TheLayoutcontexts used byForEachandLayoutPanelshare the same convention. - Bounds are window-absolute. Every
arrange()passes a rect in window-client DIPs, not relative to the parent.bounds()is always in the same space as aPointerEvent::position, andactual_offset()subtracts the parent's position when a relative one is needed. - Measure before arrange.
arrange()relies on the size the lastmeasure()cached. A container whose cache does not match its children (arrange without measure) does nothing rather than reading stale data. - DIPs everywhere. Layout never sees physical pixels.
StackControlandWindowround positions to whole DIPs for crisp edges; snapping to device pixels is a rasterization policy the renderer applies (see Rendering). measure_override()isconst. A control caches measure results inmutablemembers. That is also what makes parallel measuring of independent elements possible.- A measure depends on its inputs and its flags only.
measure()returns its last answer without callingmeasure_override()when it is offered the same size, measurer and theme and nothing at or below the element was markedDirtyFlags::Measuresince.arrange()does nothing for the rect it was last given unless something at or below was measured or markedDirtyFlags::Arrange. So a setter whose value changes the size callsinvalidate_measure()(mark_dirty(DirtyFlags::Layout)), one that only moves things within the space they have callsinvalidate_arrange(), and one that only changes the look marksDirtyFlags::Visual.
Invalidation¶
mark_dirty() sets flags on the element and bubbles them to every ancestor. On the element itself
Measure and Arrange also mean "my last result is no longer good"; on an ancestor they only
mean "something below me needs it". That split is what lets a change stay local.
Window::layout(client, measurer) drives the whole thing:
- Skip entirely while the window is minimized, and do nothing at all when no flag is set anywhere, the size is the same and no root, popup or modal came or went.
- If something is
DirtyFlags::Layout(or the size changed, or this is the first pass), lay out:- From the dirty elements up, when the size, theme and measurer are the ones of the last
pass. Each element whose own measure or arrange is out of date is measured again with the
space it was last offered. If its size came out the same, it is arranged again in its old
rect and the change stops there. If not, its parent is measured again the same way, and so
on. A change that reaches the root, or an element its parent measured with two different
sizes in the last pass (a
Gridauto track), falls back to the full pass. - The full pass otherwise: measure the content with the client size and arrange it at
the full client size, where its own alignment, margin and max size place it.
set_stretch_content(false)arranges it at its desired size instead, centered in the client area (rounded to whole pixels). The per-element caches make the clean parts of the tree cost one comparison each.
- From the dirty elements up, when the size, theme and measurer are the ones of the last
pass. Each element whose own measure or arrange is out of date is measured again with the
space it was last offered. If its size came out the same, it is arranged again in its old
rect and the change stops there. If not, its parent is measured again the same way, and so
on. A change that reaches the root, or an element its parent measured with two different
sizes in the last pass (a
- Clear the dirty flags (the walk stops at a clean element, whose subtree is clean too), rebuild the tab ring.
- Measure and place open popups and the modal.
- Let connected animations snapshot their destinations, then
sync_composition()andsync_native().
The VisualTree is built when Window::visual_tree() is asked for, not by every pass. The
[perf-count] tests in tests/test_perf_count.cpp hold the counts: a label changed inside a
fixed-size cell measures the label and the cell and nothing else.
The TextMeasurer Seam¶
Text is the only thing in layout that needs a font engine, so measuring text goes through an
interface (include/winui/render/Text.hpp):
class TextMeasurer
{
public:
virtual ~TextMeasurer() = default;
[[nodiscard]] virtual TextMetrics measure(std::string_view text, const TextStyle& style) const = 0;
[[nodiscard]] virtual TextMetrics measure_wrapped(std::string_view text, const TextStyle& style, float max_width) const;
// ...
};
TextMetrics is the extent (width, height), the first line's baseline and the
line_count, and converts to a Size. Element::measure() takes a measurer and passes it down
the tree. There are two implementations:
| Measurer | Where | Behavior |
|---|---|---|
HeadlessTextMeasurer |
include/winui/testing/HeadlessUI.hpp |
Every code point advances font_size * AdvanceRatio (nudged by weight and character spacing); a line is font_size * LineRatio tall unless the style sets a line height. Deterministic on every OS. |
DWriteMeasurer |
src/platform/windows/DWriteMeasurer.hpp (private) |
Real DirectWrite layout. Text formats come from a process-wide cache (DWriteCache.hpp) that D2DRenderer::draw_text() draws with too, so what is measured and what is drawn agree. Measurements are cached by string, style and width. |
The same layout code runs both ways. The semantic goldens and most layout and focus tests use
the headless measurer, so they are stable in CI and on any machine. The pixel goldens lay out
with DirectWrite (OffscreenCapture::measurer()) because they compare against what DirectWrite
actually paints.
Keep in mind that the headless measurer is not an approximation of DirectWrite. Do not write a test that expects a headless width to match a real one.
DWriteMeasurer has two rules worth knowing when a label moves by a pixel:
- Wrapping never moves a label that fits.
measure_wrapped()first measures the text on one line; if it fits and holds no\n, that result is returned unchanged. Only text that really breaks goes through a wrappingIDWriteTextLayout. A broken line's trailing space hangs past the layout width, so a wrapped block is as wide as its widest line without it, while no-wrap text measures including trailing whitespace. - An explicit
line_height(Metro puts every run on one) usesDWRITE_LINE_SPACING_METHOD_UNIFORMwith the baseline at 0.8 of the line box, roughly where a Latin baseline falls.D2DRenderermakes the identical call when it draws. If you change one, change the other.
Text Layout¶
A plain label is one measure() call. Anything with several runs (TextControl inlines,
RichText, a multi-line TextBox) goes through TextLayout
(include/winui/render/TextLayout.hpp), which breaks paragraphs of styled runs into lines.
It is built only on TextMeasurer::measure(), so it runs headless exactly as it does on
DirectWrite. Its line breaking follows DirectWrite's word wrapping:
- Lines break greedily after a run of spaces, and a trailing space hangs off the line end.
- Under
TextWrapping::Wrapa word longer than a line is split between characters; underWrapWholeWordsit overflows. - A
\ninside a run is a hard break (XAML'sLineBreak), and a word spanning two runs stays one word. - Each piece between two break opportunities is measured once and cached, so a relayout at another width measures nothing.
Offsets address text(): every run's text in order, paragraphs joined by one \n. One known
limitation: mixed font sizes on one line share a bottom edge, not a baseline, because the
measurer interface reports no ascent.
Wrapping needs a width to wrap at. A TextControl only wraps under a bounded width, so a wrapping
TextControl in a horizontal StackControl (which offers its children an unbounded width) stays on
one line, as in XAML. On a bounded axis a TextControl never asks for more than it was offered: an
unwrapped run is clipped, and an over-long word under WrapWholeWords overflows the same way.
Alignment, Margin and Minimum Size¶
The sizing properties every control has (margin, width, height, min_*, max_*,
horizontal_alignment, vertical_alignment, plus visible) are applied once, in
Element::measure() and Element::arrange(). A control's measure_override() never sees them:
measure(available, context):
Collapsed? -> desired = 0, return 0
reserve the margin on both sides
constrain the offer to [min, max] per axis (explicit width/height narrows the range)
core = measure_override(offer, context) (under its own theme, if it asks for one)
clamp core to [min, max]; cache it as desired_
return core + margin
arrange(final_rect):
Collapsed? -> bounds = empty rect at final_rect's origin
inner = final_rect minus margin
Stretch: width = min(inner, max(desired, max_width)), else min(desired, inner)
position by alignment (a Stretch element narrower than its slot is centered)
bounds_ = that rect; arrange_override(bounds_, the last measure's measurer and theme)
The min/max folding follows WinUI's CFrameworkElement::GetMinMax(): an explicit size wins over
the content, min_width wins over the explicit size and over max_width, and an unbounded offer
with a finite max is offered the max.
Visibility::Collapsed takes no room, draws nothing, takes no input and leaves the tab ring,
along with everything under it.
No Room for the Focus Ring¶
Layout never depends on focus. The window draws the focus ring over everything once a root is drawn, so the Fluent rings drawn outside a control lie over its neighbors rather than under them, and the default spacing between controls leaves them room. An item inside a viewport rings inside itself instead (see Input and focus).
Requested Theme¶
An element with set_requested_theme(ElementTheme::Dark) (or Light) measures and draws its
subtree with Theme::with_dark_mode(dark) instead of the theme it was handed. The derived theme
is cached on the element and rebuilt when the handed theme's generation changes. Both
measure() and render() apply it, so a subtree lays out with the same metrics it draws with.
See Theming.
Containers¶
A container is an Element that owns children and overrides measure_override()/arrange_override()
to call their measure()/arrange(), and render_override()/pointer_override() to call their
render()/handle_pointer(). It lists its children in for_each_child(), which the tree-wide
walks (collect_focusables(), clear_dirty(), tick(), sync_composition(), sync_native(),
the window's walk for popups, automation_children(), build_visual_tree()) recurse through by
default; see The Tree. The main ones:
| Container | Header | Measure rule | Arrange rule |
|---|---|---|---|
StackControl |
ui/panels/Stack.hpp |
Each child gets the panel's cross-axis extent and Unbounded along the stack; results are cached; spacing added between children. |
Children placed one after another at their cached main-axis size; the cross axis follows the panel's child_alignment. |
Grid |
ui/panels/Grid.hpp |
Dip tracks first, then Auto tracks from their children's natural size, then Fraction tracks share what is left, each track kept within its minmax() limits; only Fraction children are re-measured. On an unbounded axis a Fraction track behaves like Auto. The border and padding come off first. |
Tracks are re-resolved against the final rect, so a Fraction track is a share of the final extent. |
Scroll |
ui/scrolling/Scroll.hpp |
The child gets the viewport width (vertical orientation) and 0 for the height; its desired size is the extent. | The child is arranged at its extent, offset by the scroll position; it is drawn through a clip and, when zoomed, a push_transform(). |
VirtualList |
ui/collections/VirtualListControl.hpp |
Fixed row height; only rows inside the viewport exist. | Rows are created by an item factory when they scroll in and destroyed when they scroll out. |
Panel subclasses |
ui/panels/Panel.hpp |
Canvas, WrapPanel, TilePanel, RelativePanel and LayoutPanel share child storage, border and padding. |
Children draw in render_order() (insertion order unless the panel says otherwise). |
LayoutPanel, ForEach |
ui/panels/LayoutPanel.hpp, ui/collections/ForEach.hpp |
Delegate to an attachable Layout (StackLayoutEngine, TileLayoutEngine, WrapLayoutEngine, LinedFlowLayoutEngine) through a layout context. |
ForEach with .virtualize realizes only what falls inside the viewport plus a cache buffer. |
ContentHost |
ui/hosting/ContentHost.hpp |
Pure forwarding to its one child. | The child can be swapped at runtime (ui::Match). |
Known simplifications, stated in their headers: Grid only sizes Auto tracks from children with
a span of 1, and VirtualList recreates rows rather than recycling them, so a row gets a fresh
VisualId every time it scrolls back in.
Grid¶
Grid measures each child against its own tracks, never against the whole Grid's available.
Measuring against the Grid made a TextControl in a 60 DIP Dip column report the width of a
several-hundred-DIP single line, and every number downstream of it (cell alignment, a
neighboring Auto track, the Grid's desired size) was impossible.
Columns are resolved before rows, the order CGrid::MeasureOverride uses for its cell groups.
Once the Fraction columns have their widths, a child in a Fraction column is measured again at its
column's real width, and only that height may size an Auto row. A wrapping TextControl in a Fraction
column and an Auto row therefore sizes the row by its wrapped height, not by its one-line
unbounded height. A Grid inside an unconstrained container (a horizontal StackControl, an
unbounded Scroll) sizes to its content and splits that width across its Fraction tracks at
arrange time.
Popups and Modals¶
A control that floats part of itself above the tree (a ComboBox's list, a MenuBar's menus,
a Flyout, a ToolTip) is not laid out inline. It opens a popup with open_popup(), and
Window::layout() measures and arranges the request's content separately: against the
request's anchor (the owner by default, or anchor_rect inside it), on the side placement
asks for, flipped to the opposite side when there is no room and clamped to the client area. An
owner whose anchor moves sends the request again from arrange_override(). Popup and
TeachingTip place their own content through the request's place callback. A modal (show_modal(), usually a DialogControl) is measured
at the client size and centered. Input and Focus covers
how input reaches them.
Popups nest. A cascading menu is the main user: MenuPresenter (src/ui/menus/MenuPresenter.hpp,
private) is one level of a menu, WinUI's MenuFlyoutPresenter with its items and
CascadingMenuHelper folded in, shared by MenuFlyout, MenuBar and CommandBar's overflow. It
draws and hit-tests straight from the owner's std::vector<MenuItem>, and each presenter
is the popup owner of the submenu open from it, so a cascade is a chain of presenters each
floating the next. The root presenter takes the keys for the whole chain (route_key()) and
hands them to the deepest open level. Submenu presenters are pooled per item index and live as long as their parent level,
because the window's popup list is only rebuilt on layout and may still point at one that has
just closed.
Virtualizing Layouts¶
LayoutPanel and ForEach do not lay out their children themselves. They hand the job to
an attachable Layout (include/winui/ui/layouts/Layout.hpp), ported from WinUI's
controls/dev/Repeater/.
Layouts and Their Contexts¶
A layout never measures an element directly. It goes through a layout context
(NonVirtualizingLayoutContext or VirtualizingLayoutContext), because an element needs the
host's measurer and theme to be measured at all, and the context also remembers each element's
desired size. Contexts work in coordinates relative to the host's content box, and an unbounded
extent is infinity there, as it is everywhere else in the tree.
A Layout is not copyable and belongs to exactly one host, which takes it by std::unique_ptr
(set_layout()). Any state it needs between passes lives in its own members, so there is no
per-context layout state object. A layout whose own properties change calls invalidate_measure(), which
re-lays out the host.
In a virtualizing context, an element the layout does not ask for during a measure pass is
cleared by the host afterwards. A layout that wants to manage an element itself realizes it with
ElementRealizationOptions::SuppressAutoRecycle and calls recycle_element() on its own.
LayoutPanel hands a VirtualizingLayout its children through an adapter context: every child counts as realized, the realization rect is unbounded, there
is no anchor and nothing gets recycled. The layout origin is always 0,0, which
LayoutContextAdapter requires too.
The Flow Algorithm¶
StackLayoutEngine, TileLayoutEngine and WrapLayoutEngine share one private flow algorithm
(src/ui/layouts/FlowLayoutAlgorithm.hpp), a restatement of WinUI's FlowLayoutAlgorithm and
ElementManager. Items go along a minor axis in lines, and the lines stack along the major axis,
the one a scroller moves.
- Anchor. Measure picks the first realized item while the realized range still touches the
realization rect, otherwise the layout's own estimate (
anchor_for_realization_rect()). - Generate. From the anchor it walks forward and then backward, realizing and measuring one
item at a time until the item before went past the rect. One item past each edge is always
realized. An item joins the current line unless the line is full or the layout's
should_break_line()says it does not fit. - Recycle. Elements outside what was generated go back to the host.
- Extent. The size of the whole collection, realized or estimated, comes from the layout's
extent(). - Arrange. The realized lines are walked, every item gets its line's major size, the line is placed by its line alignment and, for a stack, stretched across the minor extent.
A layout supplies its sizes and estimates through FlowAlgorithmDelegates.
| Layout | Ported from | Lines | Estimate for unrealized items |
|---|---|---|---|
StackLayoutEngine |
StackLayout.cpp, StackLayoutState.cpp |
One item per line. Each item is measured with the room across the stack and unbounded along it, and arranged across the full cross extent. The desired cross size is the widest item arranged in the last pass. | The average size of the last 100 measured items (a ring buffer, as StackLayoutState keeps), rounded once two measured sizes differ. It sizes the scroll extent and decides where a jump lands. |
TileLayoutEngine |
UniformGridLayout.cpp |
Equal cells, as many per line as fit. The cell is min_item_width() by min_item_height() where set, else item 0's desired size, exactly that size. |
Every line has the same size, so the extent covers every line without estimating. |
WrapLayoutEngine |
FlowLayout.cpp, FlowLayoutState.cpp |
Each item at its desired size, min_item_spacing apart, a new line when the next would not fit. Lines are line_spacing apart, as tall as their tallest item, placed by line_alignment. Horizontal (the default) fills rows and scrolls vertically. |
The average of the last 100 measured lines, the collection's last line left out since it is usually short. Before any line was measured, item 0 stands in for a line of as many like it as fit. |
StackLayoutEngine still realizes one element for a zero-size viewport, for a repeater nested out of
view.
LinedFlowLayout¶
LinedFlowLayoutEngine (a photo wall) does not use the flow algorithm. It ports WinUI's greedy "fast
path" in LinedFlowLayout.cpp, the one WinUI runs when every item's aspect ratio is known. Here
each item's aspect ratio comes from measuring it at the line height with an unbounded width.
- The line height is
line_height, or item 0's desired height when it is unset. - Items never measured are estimated at the average aspect ratio of those that were, 1 before any were.
- Lines fill greedily. When an item does not fit, the layout either closes the line (to be
stretched) or takes the item in (and shrinks the line), whichever scale factor is nearer 1.
One factor scales every item on a line. A line wider than the room is always shrunk; a
narrower one is widened only with
LinedFlowLayoutItemsStretch::Fill, and never the last line. - With
Filla line runs edge to edge and the gaps absorb the rounding error. WithNone,items_justificationplaces the line (Start, Center, End, SpaceAround, SpaceBetween, SpaceEvenly).
Not ported: the ItemsInfoRequested event (aspect ratios supplied by the app), LockItemToLine,
the regular path's line balancing, and the item transitions.
ForEach¶
A virtualized ForEach (ForEachControl, after WinUI's ItemsRepeater.cpp, ViewManager.cpp and
ViewportManager.cpp) has no chrome and does no scrolling of its own. Inside a Scroll it realizes only the items its
layout places in the visible part, plus a cache buffer of horizontal_cache_length() /
vertical_cache_length() viewports (2 by default, one on each side).
- Collection changes arrive through
notify_inserted(),notify_removed(),notify_replaced(),notify_moved()andnotify_reset()(the actions ofINotifyCollectionChanged), so realized elements keep their items and the layout keeps its place. - Clearing. An element the layout did not ask for in a measure pass is cleared, unless it
holds keyboard focus (the pinned element, kept and parked off-screen at -10000) or the
layout keeps it itself (
SuppressAutoRecycle). - Events:
on_element_prepared()(before measure),on_element_clearing(),on_element_index_changed(). get_or_create_element()realizes an item where the layout would put it, so it can be brought into view.- Scrolling. A scroll arranges without measuring, so the repeater re-runs its layout from
arrange_override()when the viewport moved, and asks for a full pass next tick if that changed its size.
Elements are created and destroyed, not pooled.
Shapes¶
The shape family (include/winui/ui/media/Shape.hpp) follows WinUI's two layout behaviors, from
dxaml/xcp/core/core/elements/shape.cpp and one file per shape.
| Shapes | Default Stretch |
Measure | Geometry |
|---|---|---|---|
Rectangle, Ellipse |
Fill |
Nothing of their own, only an explicit width or height (CRectangle::MeasureOverride), so without a size they stretch to their slot. Under UniformToFill they ask for a square of the larger bounded side. |
Built from the layout size (CShape::GetOutlineRect): the element rect inset by half the stroke so the stroke stays inside, squared by Uniform/UniformToFill, collapsed by None. |
Line, Polyline, Polygon, Path |
None |
With None, the geometry's bottom-right corner plus half the stroke. |
Their own geometry in local coordinates. With None it is drawn where it is (CShape::ApplyContentStretch); otherwise its bounds are moved to the origin, scaled into the layout rect less the stroke, and centered on the free axis of an aspect-preserving stretch (CGeometry::ComputeStretchMatrix). |
stroke_thickness counts only while there is a stroke (CShape::GetStrokeThickness), so an
unstroked shape is never deflated. A laid-out shape of zero area draws nothing
(CShape::ShouldRender).
Layout Traps¶
A few rules that follow from the model above and keep catching people:
- A
Scrolldirectly in aStackControlcannot scroll. The panel measures its children unbounded along the stack axis (asCStackPanel::MeasureOverridedoes), so theScrollreports its whole content as its desired size. Put it in a fixed-heightGridcell instead. - A
Scrolldirectly inside another one collapses. The outer one measures its child unbounded and re-arranges it at its full content height on every scroll, which erases the inner viewport. Put a fixed-size cell (a Dip track in aGrid) between them; a cell never re-stretches its child. TwoPaneneeds a bounded parent. It picks its mode from the extent it is offered during measure, so under an unbounded offer it can never passmin_wide_mode_widthormin_tall_mode_height(641 by default). A Dip-trackGridcell works.- A leaf never reports the full
availablesize as its desired size. During an Auto-row measureavailableis the whole remaining budget, so a leaf that "wants to fill" balloons the row. A toolbar separator reports a control-sized height (a text line plus the theme's padding) for this reason. Stretching is the parent's job at arrange time. - No callbacks fire from inside layout. Measure is
const, so a state derived there has no change event.TwoPanehas no mode change event, so callers readmode()after layout (from atick_override(), say). - Containers never insert hidden wrappers.
TwoPanedoes not wrap its panes in aScroll.Scrollfills whatever it is offered and has no Auto scroll-bar visibility, so an unconditional wrapper would make every pane as tall as the pane instead of its content and always reserve scroll-bar room.
Layout Snapshots¶
src/ui/LayoutSnapshot.hpp is the one place layout runs on more than one thread. It is a private header: an app only sees ParallelLayout (include/winui/ui/ParallelLayout.hpp), the switch on Window.
A LayoutSnapshot is a read-only batch of independent elements plus everything needed to
measure them, captured once so every worker sees the same inputs:
struct LayoutSnapshot
{
std::span<Element* const> elements; // non-owning; results come back in this order
Size available;
const TextMeasurer* measurer; // must be safe to use from several threads
const Theme* theme;
};
Measuring in Parallel¶
measure_parallel(snapshot, scheduler) splits the batch into at most
scheduler.worker_count() contiguous chunks, submits one job per chunk, waits for all of them
and returns the desired sizes in order. Each element is measured exactly as a sequential
measure() would, and each job writes only its own slice of the result, so no locking is
needed. With Scheduler{Scheduler::Inline{}} the chunks run on the calling thread, in order,
which is what the deterministic tests use (tests/test_parallel_layout.cpp).
The rules that make it safe:
- The elements must be independent: none may be measured elsewhere or mutated while the call
runs.
measure()writes each element's ownmutablecaches, which is fine only because no two jobs share an element. - Workers never touch the tree beyond that measure call. Committing the sizes back (arranging with them) is the caller's job, on the UI thread.
- Chunks are coarse on purpose: one job per worker, never one per element.
Scheduler (include/winui/core/Scheduler.hpp) is the pool underneath: one worker per hardware
thread by default, detail::submit() returns a move-only detail::Job to wait() on, and the destructor runs
every job still queued before joining. It also runs ui::on_background()'s coroutines and the
picture decodes of ImageSource::load() (a pool of two of its own).
Parallel Measuring in Window::layout()¶
Window::layout() measures its content inside a parallel scope (src/ui/ParallelMeasure.hpp),
set by Window::set_parallel_layout(ParallelLayout). It is on by default:
struct ParallelLayout
{
bool enabled = true;
std::size_t min_elements = 256; // the smallest batch worth sending to workers
Scheduler* scheduler = nullptr; // borrowed; nullptr is the process-wide layout pool
};
The split happens in the containers whose children are measured independently of each other:
StackControl, GridControl (each of its three measure passes is one batch: every offer in a
pass depends only on track sizes fixed before it), WrapPanelControl, CanvasControl,
TilePanelControl (every tile after the first, which sets the cell size) and a
LayoutPanelControl without a layout. Their
measure_override() checks parallel_measure_active(), a thread-local that is only set on the
thread running the window's measure, and if it is set hands its children to
measure_batch(std::span<MeasureRequest>, context) instead of looping. With the scope
closed (parallel layout off, arrange, popups, a modal, a worker thread) the loop is the old one,
so the sequential path costs one thread-local read per container.
measure_batch() then decides, per batch:
- Which children may go. A child goes to a worker only when its whole subtree may: every
element in it answers
Element::parallel_measure_safe()(default false), walkingfor_each_child(). A collapsed element counts as safe, since itsmeasure()returns beforemeasure_override(). The answer and the subtree's size are cached per pass, for containers only. - Whether it pays. The safe children's subtree sizes are summed; under
min_elements, or with fewer than two safe children, the batch is measured in order on the calling thread. - Whether to split here or lower. With fewer safe children than twice the threads and one
child at least
min_elementslarge by itself, splitting here would leave threads idle, so the batch is measured in order and that child splits its own, wider, set of children. - The split. The unsafe children are measured first, in order, on the calling thread. The
safe ones are cut into contiguous chunks of about equal subtree size, one per worker plus one
for the calling thread, which measures its share itself rather than waiting. Neither a worker
nor the calling thread's share splits again: the scope is not set on workers and is
suspended for the calling thread's share, so a batch never waits on a pool its own jobs hold.
Each job carries the caller's
animations_enabled()(a thread-local), and an exception thrown on a worker is rethrown bymeasure_batch()once every chunk is done.
The sizes are the same, bit for bit, as sequential measuring: each element is measured by the same code with the same inputs, nothing a safe element measures depends on a sibling, and the container sums the results in child order afterwards. The one order that changes is between an unsafe child and its safe siblings; an unsafe child whose measure changes a safe sibling (a binding it fires) has that sibling measured with the change, which is what the next pass its invalidation asks for would do anyway.
layout_scheduler() is the process-wide pool: one worker per hardware thread but one, at most
7. It is created the first time a batch qualifies and never destroyed (joining from a static
destructor runs after main(), under the loader lock in a DLL build), so a small app never
starts a thread. A test passes its own Scheduler: Scheduler{4} for real threads,
Scheduler{Scheduler::Inline{}} to run the same batching code on the calling thread.
What answers true. TextControl, ButtonControl without content (a button's content()
is measured but, being one tab stop with the button, not listed in for_each_child(), so a
button with content answers false), ToggleButtonControl through it, CheckBoxControl,
RadioButtonControl, ToggleSwitchControl, SliderControl, TextBoxControl,
ProgressBarControl, ProgressRingControl, InfoBadgeControl, IconControl,
SpacerControl, BorderControl, ImageControl, AppBarButtonControl, DropdownButtonControl (as its button answers), the
splitting panels, RelativePanelControl (it measures its children in dependency order on one
thread), ScrollControl, and the hosts ui::Match, ui::Lazy and ui::State build, which
swap their child when a property changes, never while measuring. A LayoutPanelControl with a
layout answers what Layout::parallel_measure_safe() says; the built-in layouts virtualize and say
false. Image and icon sources are safe to measure: bitmaps and SVG documents are immutable once
decoded, and IconSource keeps a glyph's measured box in one atomic word. Everything else is
false and is measured on the UI thread; a container above it can still split its own siblings, so
one unsafe control on a page only keeps its own subtree sequential.
What was found unsafe, and stays on the UI thread. The audit behind the list above:
- Controls that build children while measuring:
ForEachControlrealizes items from its template inmeasure_override(), building specs and subscribing to properties; the views andLayoutPanelControl's virtualizing layouts realize and recycle elements on demand too, and read the viewport of the scroller around them. The thread checks (core/ThreadCheck.hpp) report such a subscription from a worker. - Thread-locals that assume the UI thread:
animations_enabled()(carried into each job, see above) anddetail::RetireScope's parked list. - Function-local statics: the regional number format
NumberBoxreads.
The shared inputs are safe. Theme is read-only during layout (a requested theme's scoped copy is
per element), and the HeadlessTextMeasurer is pure arithmetic. DWriteMeasurer keeps no
state of its own. Its formats, layouts and metrics come from the process-wide cache in
DWriteCache.hpp, which is locked, and from the window's DWRITE_FACTORY_TYPE_SHARED factory,
which is free-threaded (a test lays a form out
on four threads through it and compares every bound with the sequential pass). A measurer
that keeps a cache has to lock it or keep it per thread, as DWriteCache.hpp does.
Rules for a custom Element. Leave parallel_measure_safe() false unless
measure_override() touches nothing but the element's own state, the children it lists in
for_each_child() (which answer for themselves), the measurer and the theme. It must not build
or remove children, read or write a Property, subscribe, invalidate, start an animation, or
use a cache or static shared with other elements. Writing its own mutable measure caches is
fine: no two threads ever measure the same element. A part measured without being listed in
for_each_child() makes the answer false, and the answer is inherited, so a subclass whose
measure_override() does more overrides it back. measure_batch() is private to the library, so a
custom container measures its children in order; its children can still split their own.
What it buys. tests/test_parallel_layout.cpp's hidden [benchmark] case lays out a
settings-like form (sections of label/field grids, wrapped paragraphs, wrap panels of buttons)
at a new width each run, on a 12-thread machine (7 workers plus the UI thread), mean of 50 runs
(20 in Debug):
| Form | Measurer | Build | Sequential | Parallel |
|---|---|---|---|---|
| 2,500 elements | DirectWrite | Release | 24.3 ms | 7.8 ms |
| 2,500 elements | DirectWrite | Debug | 40.6 ms | 19.1 ms |
| 2,500 elements | headless | Release | 0.52 ms | 0.39 ms |
| 500 elements | DirectWrite | Release | 4.90 ms | 1.65 ms |
| 500 elements | headless | Release | 0.098 ms | 0.114 ms |
| 250 elements (under the threshold) | DirectWrite | Release | 2.46 ms | 2.45 ms |
| 250 elements (under the threshold) | headless | Release | 0.084 ms | 0.085 ms |
DirectWrite text measurement is what the workers take off the UI thread; the headless measurer is so cheap that a 500-element batch barely pays for the hand-off, which is why the threshold sits at 256 and parallel layout only matters in the shipping, DirectWrite path. Under the threshold the scope's checks cost nothing measurable.