-
Notifications
You must be signed in to change notification settings - Fork 0
UI Layout and Panels
Cursorial.UI lays elements out with the same two-pass Measure/Arrange model as WPF and Avalonia,
sized in terminal cells rather than device pixels. Reach for this page when you need to control
how elements are sized and positioned — fixed sizes, margins, alignment, and the panels that arrange
their children (StackPanel, DockPanel, Grid, Canvas, WrapPanel, and the virtualizing
variant).
If you've authored WPF or Avalonia layouts, almost everything here will be familiar. The headline
differences: lengths are integers (cells, not floating-point pixels), Width/Height are nullable
(int? — null means "unconstrained, size to content"), and margins are a Margins value type
(left/top/right/bottom in cells, signed).
Layout runs in two passes, owned by the framework's layout manager — you don't call them directly in normal use; you author overrides and set properties, and the manager schedules the passes.
-
Measure — top-down. Each element is asked "given this much room, how big do you want to be?"
An element measures its children, then reports a
DesiredSize(which includes itsMargin). - Arrange — top-down. Each element is handed a final rectangle and places its children within it, honoring alignment and the element's own size constraints.
After layout, an element exposes DesiredSize (a Size of Columns × Rows) and Bounds
(its parent-relative arranged rectangle). Both are read-only results of the passes.
A custom element or panel overrides the two halves:
protected override Size MeasureOverride(Size availableSize)
{
// measure children against availableSize, return your desired content size
}
protected override Size ArrangeOverride(Size finalSize)
{
// call child.Arrange(rect) for each child, return the size you actually used
}availableSize in MeasureOverride is already deflated by your Margin and coerced by your
Min/Max/explicit size — you size pure content. An axis may arrive as LayoutMath.Unbounded
(e.g. a StackPanel measures children unbounded along its stacking axis); return a finite desired
size, never Unbounded.
To request a fresh pass after changing something layout-affecting yourself, call
InvalidateMeasure() or InvalidateArrange(). Most built-in layout properties do this for you —
they are marked to invalidate measure or arrange when they change.
Every UIElement carries the standard sizing knobs:
-
Width/Height(int?) — an explicit cell size, ornull(the default) to size to content. An explicit value folds into both the min and max constraint, exactly as WPF'sMinMaxrule. -
MinWidth/MinHeight/MaxWidth/MaxHeight(int) — bounds applied during measure and arrange. On aMin > Maxconflict, Min wins. -
Margin(Margins) — exterior space, in cells. The constructors arenew Margins(uniform),new Margins(horizontal, vertical), andnew Margins(left, top, right, bottom). Margins are signed: a negative component pulls the element toward (or past) its neighbor for overlap effects — measure enlarges by the negative amount and the arranged origin can go negative; anything outside the visible area clips at composite time. -
HorizontalAlignment/VerticalAlignment— placement within the arranged slot when the element is smaller than the slot:Stretch(the default — fill the slot), plusLeft/Center/RightandTop/Center/Bottom. When an explicit or max size prevents stretching, the element centers. -
Visibility—Visible(lays out, paints, hit-tests),Hidden(keeps its layout slot but paints nothing and isn't hit-testable), orCollapsed(occupies no layout space — desired size 0×0). Toggling to/fromCollapsedre-runs layout;Visible ↔ Hiddenis render-only.
var label = new TextBlock
{
Text = "Centered, with a margin",
Width = 30,
Margin = new Margins(left: 2, top: 1, right: 2, bottom: 1),
HorizontalAlignment = HorizontalAlignment.Center,
};Panels live in Cursorial.UI.Controls and derive from Panel. Each owns a Children
(UIElementCollection) and implements its own arrangement rule.
Stacks children one after another along its Orientation (default Vertical). Spacing inserts
cells between consecutive non-collapsed children (negative values coerce to 0).
var stack = new StackPanel { Orientation = Orientation.Vertical, Spacing = 1 };
stack.Children.Add(new TextBlock { Text = "Line one" });
stack.Children.Add(new TextBlock { Text = "Line two" });
stack.Children.Add(new Button { Content = "OK" });Children are measured unbounded along the stacking axis, so a StackPanel is the natural choice for
content that should size to its own length. Cross-axis overflow isn't clipped by the panel — that's
the render zone's job.
Docks each child against an edge via the attached DockPanel.Dock property (Left/Top/Right/Bottom,
default Left), accumulating from the edges in child order. When LastChildFill is true (the
default), the final child fills whatever rectangle remains.
var dock = new DockPanel(); // LastChildFill = true
var header = new TextBlock { Text = "Title" };
DockPanel.SetDock(header, Dock.Top);
dock.Children.Add(header);
var status = new TextBlock { Text = "Ready" };
DockPanel.SetDock(status, Dock.Bottom);
dock.Children.Add(status);
dock.Children.Add(new TextBlock { Text = "Body fills the rest" }); // last child fillsA track-based grid: define RowDefinitions and ColumnDefinitions, then place children with the
attached Grid.Row / Grid.Column (and Grid.RowSpan / Grid.ColumnSpan) properties. Track sizes
are GridLengths:
-
Fixed — a cell count. An
intconverts implicitly (12⇒ a 12-cell track), or useGridLength.FromCells(12). -
Auto —
GridLength.Auto, sized to the content of the track's children. -
Star —
GridLength.Star(weight)(default weight 1), a weighted share of the space left after fixed and auto tracks. Cursorial distributes star space as whole cells (largest-remainder rounding), since you can't render a fractional column.
Definitions also take optional MinWidth/MaxWidth (columns) or MinHeight/MaxHeight (rows), and
expose their resolved size after arrange via ActualWidth / ActualHeight.
var grid = new Grid();
grid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto)); // label column
grid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star())); // field fills the rest
grid.RowDefinitions.Add(new RowDefinition(1)); // 1-cell-tall row
grid.RowDefinitions.Add(new RowDefinition(GridLength.Star()));
var label = new TextBlock { Text = "Name:" };
Grid.SetRow(label, 0);
Grid.SetColumn(label, 0);
grid.Children.Add(label);
var field = new TextBox();
Grid.SetRow(field, 0);
Grid.SetColumn(field, 1);
grid.Children.Add(field);An axis with no definitions behaves as a single implicit 1* track. Placement indices are clamped
into the defined range, and spans clamp to the remaining tracks.
The same grid in XAML:
<Grid xmlns="https://cursorial.dev/ui">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="1" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Name:" />
<TextBox Grid.Row="0" Grid.Column="1" />
</Grid>Positions children at explicit coordinates via the attached Canvas.Left / Canvas.Top /
Canvas.Right / Canvas.Bottom properties (all int?). Children measure unbounded; the canvas
itself desires 0×0. Left beats Right and Top beats Bottom when both are set, and computed
offsets clamp ≥ 0 — Canvas coordinates never go negative (use a signed Margin for static overlap
placement).
var canvas = new Canvas();
var badge = new TextBlock { Text = "●" };
Canvas.SetLeft(badge, 10);
Canvas.SetTop(badge, 2);
canvas.Children.Add(badge);Flows children along its Orientation (default Horizontal) and wraps to a new line when the next
item would exceed the line. Optional ItemWidth / ItemHeight (int?) impose uniform item slots
and replace the per-axis child measure constraint.
var wrap = new WrapPanel { Orientation = Orientation.Horizontal, ItemWidth = 12 };
foreach (var tag in tags)
wrap.Children.Add(new Button { Content = tag });VirtualizingStackPanel is the stacking panel for long, scrolled lists: instead of realizing a
container per item, it materializes only the items whose rows fall inside the visible band (plus a
little slack) and reports an estimated total extent to the scroll viewer. Scrolling within the band
is a pure composite slide — no re-measure, no re-realization.
You rarely instantiate it directly. It's the default items host for the virtualization-aware
controls (e.g. ListBox), driven by attached properties on the owning ItemsControl:
var list = new ListBox { ItemsSource = thousandsOfRows };
VirtualizingPanel.SetIsVirtualizing(list, true);Reach for it when a list is long enough that realizing every row would cost real time or memory; for
a handful of items, a plain StackPanel (or letting the control use its default host) is simpler and
just as fast. It transparently handles variable-height items by caching each measured row count, so a
ragged list scrolls smoothly.
- Controls — the control library that lives inside these panels.
- Styling & themes — set size/margin/alignment from styles and themes.
- Windowing — top-level surfaces, windows, and popups that host a layout root.
- UI overview — the element tree, property system, and frame loop these panels sit in.
Cursorial.Core
Cursorial.Rendering
Drawing & Animation
Cursorial.UI
- Overview
- Layout & panels
- Controls
- Styling & themes
- Data binding
- Input & focus
- Windowing
- Animation & transitions
Declarative