Constraint-Based Layout for Terminal UIs
Rezi now supports constraint-based layout: declare relationships between panels and let the layout engine handle resizing.
Terminal UIs have always had a sizing problem.
You can set a widget to a fixed width. You can use flex to distribute remaining space. But flex alone can't express things like "match the widest sibling," "hide below a viewport width," or "fill remaining space after specific panels." The moment you need any of that, you're writing imperative resize logic in your view function.
I just shipped constraint-based layout in Rezi to fix that.
What it looks like
Here's a three-panel layout that adapts to terminal size. The navigation panel hides below 70 columns, the detail rail hides below 110, and the main content area fills whatever space remains:
+---------------------------+---------------------------+---------------------------+
| Navigation | Main Content | Inspector |
| | | |
| nav.w = clamp(18, | main.w = fillRemaining( | rail.w = clamp(26, |
| parent.w*0.22, 32) | nav, rail, gaps) | parent.w*0.28, 44) |
| | | |
+---------------------------+---------------------------+---------------------------+
nav hidden when viewport.w < 70 rail hidden when viewport.w < 110

ui.row({ gap: 1, width: "full", height: "full" }, [
ui.box({
id: "nav",
width: widthConstraints.clampedPercentOfParent({ ratio: 0.22, min: 18, max: 32 }),
display: visibilityConstraints.viewportWidthAtLeast(70),
border: "single",
p: 1,
}, [ui.text("Navigation")]),
ui.box({
id: "main",
width: spaceConstraints.remainingWidth({
subtract: [{ id: "nav" }, { id: "rail" }],
minus: 2,
}),
border: "single",
p: 1,
}, [ui.text("Main content")]),
ui.box({
id: "rail",
width: widthConstraints.clampedPercentOfParent({ ratio: 0.28, min: 26, max: 44 }),
display: visibilityConstraints.viewportAtLeast({ width: 110, height: 28 }),
border: "single",
p: 1,
}, [ui.text("Detail rail")]),
])
No resize event handlers. No manual Math.max calls. The layout engine figures out the rest.
How it works
Constraints are expressions that describe relationships. Under the hood, they compile down to a small DSL that gets parsed into an AST, analyzed for dependencies, topologically sorted, and evaluated each frame.
The available references inside expressions:
parent.w,parent.h— parent content areaviewport.w,viewport.h— terminal sizeintrinsic.w,intrinsic.h— widget's natural measured size#id.w,#id.h— another widget's resolved size
And a small set of functions: clamp, min, max, floor, ceil, round, abs, if, steps, max_sibling, sum_sibling.
So an expression like clamp(20, viewport.w * 0.25, 50) means "25% of the terminal width, but at least 20 and at most 50 columns." That's it. No magic.
Helpers over raw strings
For most cases, you don't need to write raw expr("...") strings. There's a helper layer that covers common patterns:
// Width: 22% of parent, clamped between 18 and 32
widthConstraints.clampedPercentOfParent({ ratio: 0.22, min: 18, max: 32 })
// Show only when viewport is at least 110 columns wide
visibilityConstraints.viewportWidthAtLeast(110)
// Fill remaining width after subtracting siblings
spaceConstraints.remainingWidth({ subtract: [{ id: "sidebar" }], minus: 1 })
// Equalize label widths across repeated rows
groupConstraints.maxSiblingMinWidth("kv-key")
The helpers validate their arguments, give clear error messages, and compile down to expr() internally. Raw expr("...") is there as an escape hatch when you need something the helpers don't cover.
Sibling aggregation
One thing that's genuinely hard to do with traditional TUI layout: equalizing widths across siblings.
Say you have a key-value list and you want every label column to match the width of the widest label. With constraints:
entries.map((e) =>
ui.row({ key: e.key, gap: 2 }, [
ui.text(e.key, {
id: "kv-key",
width: groupConstraints.maxSiblingMinWidth("kv-key"),
dim: true,
}),
ui.text(e.value, { flex: 1 }),
])
)
max_sibling(#kv-key.min_w) computes the maximum natural width across all widgets with id: "kv-key" and uses that as the width. The layout engine resolves this automatically in dependency order.
Dependency graph, not evaluation order
The constraint resolver builds a dependency graph from all active constraint expressions, detects cycles (and tells you the exact cycle path if it finds one), and topologically sorts nodes for evaluation.
This means you can reference #sidebar.w from your main content panel without worrying about which widget is declared first in the tree. As long as there are no cycles, it works.
If you do create a cycle — say widget A references widget B's width and B references A's — you'll get a ZRUI_CIRCULAR_CONSTRAINT error with the cycle path, not silent broken layout. There are no "best-effort" fallbacks. Errors are deterministic.
Where constraints make sense
Constraints are for layout relationships. Use them when sizing depends on the viewport, parent, siblings, or intrinsic content measurements.
For everything else — fixed sizes, flex distribution, smooth interpolation — the simpler primitives (width: 24, flex: 1, fluid(min, max)) are the right tool.
Docs
If you want to dig deeper:
- Constraints Guide — overview of the system, supported references, syntax, and where to use constraints
- Constraints API Reference — full helper namespace reference
- Constraint Expressions Reference — raw
expr()DSL syntax and function allowlist - Constraint Recipes — cookbook with common layout patterns
- Debugging Constraints — error codes and diagnosis flow
- Performance with Constraints — cost model and best practices