Layout in terminal cells
Noir follows Flutter’s declarative layout model, and every size and gap is a whole terminal cell. Describe the relationships between widgets first. Add an explicit size only where the interface has a real boundary.
Constraints go down, sizes come up
- Constraints go down.
A parent tells each child the smallest and largest size it may use.
- Sizes come up.
The child chooses an integer-cell size inside those constraints.
- Parents set positions.
The parent places each child and establishes its hit-test geometry.
A widget never reads its own position. Flexible lets a child use less than
its flex share. Expanded requires it to fill that share. SizedBox sets an
exact extent, and ConstrainedBox adds a minimum or a maximum without
promising an exact result.
Compose a region
StatusRegion is a small StatelessWidget: a natural-width Badge, a message
that owns the rest of the row, a trailing percentage, and a fixed-width
progress bar.
class StatusRegion extends StatelessWidget {
const StatusRegion({required this.progress, super.key});
final double progress;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 1,
children: [
Row(
spacing: 1,
children: [
const Badge(label: 'BUILD', variant: BadgeVariant.info),
const Expanded(
child: Text(
'Compiling package graph',
maxLines: 1,
softWrap: false,
overflow: TextOverflow.ellipsis,
),
),
Text('${(progress * 100).round()}%'),
],
),
ProgressBar(value: progress, width: 32),
],
);
}
}spacing: 1 inserts one cell only between adjacent children. Expanded takes
the row left after the Badge, the gap cells, and the percentage. The
ProgressBar is deliberately 32 cells wide, so its extent stays predictable as
the viewport changes.
Think in cells, not pixels
Widths, heights, padding, offsets, and flex spacing are integers. A row is roughly twice as tall as a column is wide, so one blank row already creates substantial vertical separation. CJK characters and many emoji occupy two columns even though each is one grapheme.
- Use
spacing: 1for a normal gap. Two rows is already generous at 80×24. - Keep controls one row tall unless the control is a viewport or an editor.
- Let unused width trail at the end of a region. Do not stretch every child to make an 80-column screen look full.
- Clamp one-line table and status text with
maxLines: 1,softWrap: false, and an explicit overflow behavior.
| When the relationship is… | Start with |
|---|---|
| A horizontal or vertical sequence | Row or Column |
| One child owns leftover space | Expanded or Flexible |
| Children continue onto another run | Wrap |
| Children overlap at cell offsets | Stack and Positioned |
| One child is larger than its viewport | ScrollBox |
| Many rows, built as they scroll | ListView |
| A child must read the space it gets | LayoutBuilder |
The Widget catalog covers the rest of the composition surface, including overlays, modals, and menus.
Give scrolling a bounded viewport
A scrollable region needs a bounded main axis. Put ScrollBox or ListView
inside an Expanded, a SizedBox, or a Panel that already has a size. In an
unbounded column the viewport has no extent to scroll inside.
Column(
children: [
const StatusRegion(progress: 0.4),
Expanded(
child: ScrollBox(child: log),
),
],
)ScrollBox wraps one oversized child. ListView builds only the visible rows
and suits long or unbounded lists. Give the scrollable region focus when its
arrow, PageUp, PageDown, Home, and End keys should not move another region.
Put borders around regions, not every widget
A border occupies the outside cells of its box. Panel provides the normal
themed region with an optional title and focused state, so a second Text
heading inside the same box creates duplicate chrome.
Panel(
title: 'Build log',
width: 24,
child: log,
)Omit width and height when the region should shrink-wrap its child. When a
viewport or a label needs a fixed extent, remember that a Panel’s dimensions
include its border and horizontal inset. Its colors come from Theme; pass
color or borderColor only for a deliberate override. Reach for Container
and BoxDecoration when a region needs custom geometry instead of Panel’s
standard shape.
Read the offered space with LayoutBuilder
Every other widget declares a size and lets layout resolve it. LayoutBuilder
is the one place an application reads the BoxConstraints its parent offers,
so a region can show more rows in a tall pane and fewer in a short one.
LayoutBuilder(
builder: (context, constraints) => ListView(
itemCount: items.length,
height: constraints.maxHeight ?? 8,
itemBuilder: buildRow,
),
)The builder runs during layout and its result is laid out in the same frame, so the constraints it reads are the constraints its child receives. It runs on the first layout, on a constraint change, on a widget update, on an inherited dependency change, and on reassembly. Laying the same subtree out again at unchanged constraints does not run it.
An unbounded axis reports a null maximum, so supply a fallback as the example
does. Put LayoutBuilder inside a region whose size is already bounded, rather
than at the root of an unbounded column.
LayoutBuilder is available in Noir 0.0.1. See
Installation for package setup.
When the screen is wrong
| Symptom | Cause and fix |
|---|---|
| A child fills the whole row unexpectedly | An Expanded requires its full flex share. Use Flexible when less is allowed. |
| Long text pushes other children off screen | Wrap it in Expanded and clamp it with maxLines: 1 and an overflow behavior. |
| A scrollable region shows nothing | Its main axis is unbounded. Put it inside Expanded or a sized box. |
| A border is clipped at a viewport edge | Keep bordered regions inside the viewport. See Platform support. |
| Emoji or CJK text overlaps the next column | Those graphemes are two cells wide. Measure in cells, not in string length. |
Widgets declare configuration. Paint recordings and FFI stay in the layers described in Architecture. The repository’s layout basics example is a runnable comparison of main-axis alignment and flex distribution.