Skip to Content
Layout in terminal cells

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

  1. Constraints go down.

    A parent tells each child the smallest and largest size it may use.

  2. Sizes come up.

    The child chooses an integer-cell size inside those constraints.

  3. 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: 1 for 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 sequenceRow or Column
One child owns leftover spaceExpanded or Flexible
Children continue onto another runWrap
Children overlap at cell offsetsStack and Positioned
One child is larger than its viewportScrollBox
Many rows, built as they scrollListView
A child must read the space it getsLayoutBuilder

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

SymptomCause and fix
A child fills the whole row unexpectedlyAn Expanded requires its full flex share. Use Flexible when less is allowed.
Long text pushes other children off screenWrap it in Expanded and clamp it with maxLines: 1 and an overflow behavior.
A scrollable region shows nothingIts main axis is unbounded. Put it inside Expanded or a sized box.
A border is clipped at a viewport edgeKeep bordered regions inside the viewport. See Platform support.
Emoji or CJK text overlaps the next columnThose 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.

Last updated on