Terminal UI layout is cell-based (integer coordinates) and uses a simple two-pass protocol:
SizeHints under LayoutConstraintsRectangle and position children inside itThis page explains how the layout protocol works and how to use it when building custom controls. The full specification is also available for deeper details:
In a terminal UI:
A two-pass protocol gives predictable results:
This separation is what enables:
LayoutConstraintsLayoutConstraints describes the allowed range for a measure pass:
MinWidth, MaxWidthMinHeight, MaxHeightIsWidthBounded, IsHeightBoundedUnbounded maxima are represented by int.MaxValue and surfaced as IsWidthBounded == false / IsHeightBounded == false.
You’ll often create constraints using:
var constraints = LayoutConstraints.FromMaxSize(new Size(80, 25));
// or:
var constraints = LayoutConstraints.Unbounded;
SizeHintsMeasureCore(...) returns SizeHints:
Min: minimum size the visual needs to functionNatural: preferred size (must be finite)Max: maximum size (can be int.MaxValue per axis)FlexGrowX/Y, FlexShrinkX/Y: how a container may distribute extra/deficit spaceMost controls use the helpers:
SizeHints.Fixed(new Size(w, h));
SizeHints.Flex(min, natural, max, growX: 1, growY: 0, shrinkX: 1, shrinkY: 0);
SizeHints.FlexX(min, natural);
SizeHints.FlexY(min, natural);
Natural must be finite. If you want “unbounded growth”, use Max = int.MaxValue (and flex factors) rather than an
infinite natural size.
RectangleArrange(Rectangle) provides a finite rectangle in UI coordinates. The framework clamps negative sizes to 0.
VisualMargin is a Visual property. You usually do not need to handle it inside a custom control:
Margin, then inflates the returned SizeHints back by MarginMargin before calling ArrangeCore(...)This means containers can generally use child.DesiredSize / child.MeasureHints directly and margins “just work”.
Alignment (Align.Start/Center/End/Stretch) is applied by the child when it is arranged into a slot.
As a container author you typically:
child.Arrange(slot)This design is why many containers are simpler: they don’t need to implement per-child alignment logic.
By default:
HorizontalAlignment is Align.StartVerticalAlignment is Align.StartContainers and content controls may choose defaults more appropriate for their role (e.g. ScrollViewer stretches).
Containers often default to Align.Stretch so they fill the viewport (e.g. ScrollViewer, DataGridControl),
while leaf/content controls often default to Align.Start and size to content (e.g. Button, TextBlock).
MeasureCore (custom controls)Math.Max(0, max - padding) to avoid negative widths/heights.SizeHints that preserve the child’s flex factors when you are just “wrapping” a child.Layout runs under a binding tracking context. If you read bindables during measure/arrange, those reads become dependencies for invalidation. Read state through properties (bindables), not private backing fields.
This is the classic pattern used by Padder, Border, and many content controls:
public sealed class PaddedContent : ContentVisual
{
[Bindable] public Thickness Padding { get; set; } = new(1);
protected override SizeHints MeasureCore(in LayoutConstraints constraints)
{
var pad = Padding;
var padH = pad.Horizontal;
var padV = pad.Vertical;
var maxW = constraints.IsWidthBounded ? Math.Max(0, constraints.MaxWidth - padH) : int.MaxValue;
var maxH = constraints.IsHeightBounded ? Math.Max(0, constraints.MaxHeight - padV) : int.MaxValue;
var inner = new LayoutConstraints(0, maxW, 0, maxH);
var child = Content;
var childHints = child is null ? SizeHints.Fixed(Size.Zero) : child.Measure(inner);
var min = new Size(childHints.Min.Width + padH, childHints.Min.Height + padV);
var nat = new Size(childHints.Natural.Width + padH, childHints.Natural.Height + padV);
var max = new Size(
childHints.Max.Width == int.MaxValue ? int.MaxValue : childHints.Max.Width + padH,
childHints.Max.Height == int.MaxValue ? int.MaxValue : childHints.Max.Height + padV);
return SizeHints.Flex(min, nat, max,
childHints.FlexGrowX, childHints.FlexGrowY,
childHints.FlexShrinkX, childHints.FlexShrinkY).Normalize();
}
protected override void ArrangeCore(in Rectangle finalRect)
{
var child = Content;
if (child is null)
{
return;
}
var pad = Padding;
var inner = new Rectangle(
finalRect.X + pad.Left,
finalRect.Y + pad.Top,
Math.Max(0, finalRect.Width - pad.Horizontal),
Math.Max(0, finalRect.Height - pad.Vertical));
child.Arrange(inner);
}
}
Notes:
int.MaxValue for “unbounded” because the sentinel is int.MaxValue in this library.ArrangeCore (custom controls)When arranging children, think in slots:
child.Arrange(slot)The child will:
MarginAlign.Start/Center/End/StretchMinWidth/MaxWidth/MinHeight/MaxHeightSome controls need to re-measure children when the final main-axis size becomes known.
For example, a wrapping text visual might measure differently under width ∞ vs width 40.
This is a valid pattern (used by WrapStackBase), but it should be used carefully:
Containers like HStack, VStack, and WrapStackBase use flex factors to distribute space:
FlexGrowX/Y > 0 get a share of it (up to Max)FlexShrinkX/Y > 0 give up space (down to Min)You don’t need to use flex to write custom controls, but it’s the mechanism that makes “fill remaining space” work predictably.
Natural is finite (never int.MaxValue).Max = int.MaxValue when you want unbounded growth.Align.Stretch (and flex grow) when you want the control to fill its container.MeasureCore or ArrangeCore.Layout participates in binding tracking. If you compute and also “fix up” a bindable during Measure/Arrange,
you may hit the binding loop guard.
See Binding & State for the recommended “split read/write across phases” pattern.