Layout
Tuimark lays out every frame itself, on a grid of whole terminal cells, with one measuring pass and one allocating pass. There are no pixels and nothing is fractional, so a layout is exact and the same on every machine: what tuimark dump reports is what the terminal shows.
Every frame on this page is real tuimark dump output.
Units
| Unit | Example | Means |
|---|---|---|
N | width: 12 | N cells: columns for widths, rows for heights |
N% | width: 30% | a percent of the parent's content box on that axis, rounded down |
Nfr | width: 1fr | a share of the space left after everything else |
auto | height: auto | the content's own size |
flex: N is accepted as another way to write a grow weight on the parent's main axis; it shares the leftover space with fr. px, em, ch, vw, vh, and min-content are V003.
<tui>
<style>
box { border: single; }
#a { width: 12; }
#b { width: 30%; }
#c { width: 1fr; }
#d { width: 2fr; }
</style>
<screen id="main">
<row>
<box id="a"><text>12</text></box>
<box id="b"><text>30%</text></box>
<box id="c"><text>1fr</text></box>
<box id="d"><text>2fr</text></box>
</row>
</screen>
</tui>┌──────────┐┌────────────────┐┌────────┐┌──────────────────┐
│12 ││30% ││1fr ││2fr │
└──────────┘└────────────────┘└────────┘└──────────────────┘At 60 columns, #a takes 12 and #b takes 30% of 60, 18. The 30 cells left are shared one part to #c and two parts to #d. The last fr item gets any remainder, so no cell is lost to rounding.
The box model
width and height are the outer size, border included. Inside it:
outer the allocated rect: x, y, w, h
border one cell on each side, when there is one
padding inside the border
content what is left for children or textpadding and margin take one to four cell values, in the CSS order (top, right, bottom, left). gap (0–4) puts empty cells between a container's children. A size that would leave a negative content box is clamped to zero, and anything that does not fit is clipped, never wrapped onto the next line.
In version="3", row-gap and column-gap (0–4) split gap into its two axes: column-gap between siblings side by side (a layout: row container, grid and table columns, a tab strip, a hints laid out along a row), row-gap between siblings stacked (a layout: column container, grid rows, a hints with layout: column). gap stays their shorthand in the cascade — it sets both, with its own specificity and order — so a document that writes only gap lays out exactly as before.
Direction and alignment
col stacks its children vertically, row horizontally, and box is a column unless you set layout: row. Any container takes layout: column | row (and, in version 2, grid).
| Property | Axis | Values |
|---|---|---|
justify | main | start, center, end, space-between |
align | cross | stretch (the default), start, center, end |
content-align | text inside its box | start, center, end |
With the default align: stretch, children fill the cross axis. That is usually what you want, but it is also why a row of buttons in a tall modal grows tall: give such a row height: 1.
Default sizes
| Tag | Width | Height |
|---|---|---|
screen | 100% | 100% |
col, row | 1fr | 1fr |
box in a row | auto | 1fr |
box in a col | 1fr | auto |
text, input, button, progress, rule | auto | auto |
list, scroll, table in a col | 1fr | auto, fit to the space left |
sparkline | one column per value | 1 |
modal | 80% of the screen | 80% of the screen |
If any sibling on an axis has an fr size, siblings with no size on that axis become 1fr too.
How space is shared
Along the main axis, a container hands out its content size in this order:
- fixed sizes (
N); - percentages, of the parent's full content size (not of what is left);
autochildren, at their content size;- viewports sized
auto, at their content size but capped by what is left (auto-fit); - what remains, split between the
frchildren by weight.
min-width, max-width, min-height, and max-height clamp a child after its percentage and before the fr split. There is no flex-wrap: a row that does not fit is clipped.
Docks
dock: top | bottom | left | right pulls a child out of the flow and pins it to an edge of its parent. Top and bottom strips are cut first, then left and right, and the other children share what is left.
<tui>
<style>
#top { dock: top; height: 1; background: $panel; }
#bottom { dock: bottom; height: 1; background: $panel; }
#side { dock: left; width: 14; border: single; }
#main { height: 1fr; border: single; }
</style>
<screen id="app">
<row id="top"><text>header · dock: top</text></row>
<row id="bottom"><text>status · dock: bottom</text></row>
<box id="side"><text>dock: left</text></box>
<box id="main"><text>everything else</text></box>
</screen>
</tui>header · dock: top
┌────────────┐┌────────────────────────────────┐
│dock: left ││everything else │
│ ││ │
│ ││ │
│ ││ │
└────────────┘└────────────────────────────────┘
status · dock: bottomGive a docked child a size across its edge: a height for top and bottom, a width for left and right. An fr size on the same axis as a dock is V010, and more than one bottom-docked status line is warned about (L005).
Scrolling
A viewport clips its content and scrolls it: a scroll, a list, a table, or any container with overflow: scroll.
<scroll axis="y">(the default) scrolls vertically,axis="x"horizontally,axis="both"both ways.list,table, andoverflow: scrollscroll ony.- A
scrolltakes the arrow and paging keys when it has focus; give itfocusable="true"and anid. A list or table follows its cursor instead. - In version 2,
scrollbar: autopaints a thumb over the right border of a bordered viewport at least 3 rows tall whose content does not fit. It never takes layout space. - In
version="3",<scroll id="log" stick="bottom">keeps a growing viewport showing its end, like a log or a chat: while the offset is already at the bottom it keeps sticking as content arrives, but scrolling up to read back stops it from jumping, until you scroll (ormove-last) back to the end. It needs anidand ayaxis, and cannot go inside a list<item>or a containereachtemplate.
The dump reports each viewport's scroll state (offset and extent), and a list reports every item, including the ones scrolled out of view.
Auto-fit
A viewport sized auto on its scroll axis takes its content size, capped by the space its siblings leave. A short list stays short; a long one fills what is left and scrolls, and whatever comes after it stays on screen.
<tui>
<style>
#log { border: single; }
#status { background: $panel; }
</style>
<screen id="main" focus="#log">
<text>deploy log</text>
<list id="log" each="lines as l" key="l.n" bind="cursor">
<item><text>{l.n} {l.text}</text></item>
</list>
<text id="status">{count} lines</text>
</screen>
</tui>deploy log
┌──────────────────────────────┐
│9 rotate secrets │
│10 warm caches │
│11 notify #deploys │
│12 done │
└──────────────────────────────┘
12 linesTwelve lines of log do not fit in 8 rows, so the list takes the 6 rows the title and the status line leave, and scrolls to keep its cursor (the last line) in view. Viewports are served in document order: the first may leave nothing for the next. To share space between two viewports, give both an fr size.
L006 warns when a viewport can never show any of its content: zero rows while it has some, or clipped away by an ancestor that does not scroll. The fix is a size (N, %, or fr) or more room in the parent.
Grids
In version 2, layout: grid places a container's children in equal columns, row by row, which is what a dashboard of panels needs:
<tui>
<style>
#cards { layout: grid; grid-columns: 3; grid-min-width: 16; gap: 1; }
.card { border: rounded; }
</style>
<screen id="main">
<box id="cards">
<box class="card" title="cpu"><text>23%</text></box>
<box class="card" title="memory"><text>61%</text></box>
<box class="card" title="disk"><text>73%</text></box>
<box class="card" title="load"><text>1.20</text></box>
<box class="card" title="net"><text>2.1 MB/s</text></box>
<box class="card" title="temp"><text>52 C</text></box>
</box>
</screen>
</tui>grid-columns: K is the most columns (1 to 12). With grid-min-width: M, the count adapts to the width: as many columns of at least M cells as fit, up to K, so the grid is responsive without any @media. The same document at 64 and 36 columns:
╭─ cpu ────────────╮ ╭─ memory ─────────╮ ╭─ disk ─────────────╮
│23% │ │61% │ │73% │
╰──────────────────╯ ╰──────────────────╯ ╰────────────────────╯
╭─ load ───────────╮ ╭─ net ────────────╮ ╭─ temp ─────────────╮
│1.20 │ │2.1 MB/s │ │52 C │
╰──────────────────╯ ╰──────────────────╯ ╰────────────────────╯╭─ cpu ─────────╮ ╭─ memory ───────╮
│23% │ │61% │
╰───────────────╯ ╰────────────────╯
╭─ disk ────────╮ ╭─ load ─────────╮
│73% │ │1.20 │
╰───────────────╯ ╰────────────────╯
╭─ net ─────────╮ ╭─ temp ─────────╮
│2.1 MB/s │ │52 C │
╰───────────────╯ ╰────────────────╯gap separates both columns and rows. A row is as tall as its tallest child, and align places shorter ones. A child's width, min-width, max-width, flex, and any fr or % height are ignored in a grid (L007 warns). A grid never shrinks to fit: put it in a <scroll> (or make it overflow: scroll) to scroll its rows when they do not fit.
Responsive layouts
@media switches rules by terminal size. This is the tour document from the home page: at 56 columns its panes stack.
#body { gap: 1; }
#tickets { width: 45%; border: rounded; border-color: $border; }
#tickets:focus { border-color: $focus; }
#detail { width: 1fr; border: rounded; border-color: $border; }
#keys { height: 1; }
.hint-key { color: $accent; bold: true; }
@media (max-cols: 60) {
#body { layout: column; gap: 0; }
#tickets { width: 100%; height: 5; }
#detail { width: 100%; height: 1fr; }
}╭─ tickets ────────────────────────────────────────────╮
│t-1 login loop on staging │
│t-2 cannot deploy europe-west │
│t-3 stale feature flag cache │
╰──────────────────────────────────────────────────────╯
╭─ t-2 ────────────────────────────────────────────────╮
│Deploys to europe-west fail at the health check: the │
│new probe path returns 404. │
│ │
│ │
│ │
│ │
│ │
│ │
╰──────────────────────────────────────────────────────╯
q quit enter opendisplay: none removes a node from the layout entirely (the monitor example hides table columns that way on narrow terminals); visibility: hidden keeps its space and paints nothing.
Checking a layout
Dump at the widths you care about and read the nodes:
tuimark dump app.tui --data sample.json --cols 80 --rows 24
tuimark dump app.tui --data sample.json --cols 120 --rows 24 --format jsonLayout problems are diagnostics with an L code. The ones you will meet:
| Code | Meaning |
|---|---|
L001 | an fr size or flex on a child along its viewport's scroll axis, where there is no leftover to share |
L002 | a % size inside a parent whose size on that axis is auto |
L003 | fixed and minimum sizes that add up to more than the parent (a warning; the rest is clipped) |
L004 | a modal that is not the last child of its screen |
L006 | a viewport that can never show its content |
L007 | a size on a grid cell, which the grid ignores |
The full list is in the diagnostics reference.