Sizing to content — auto, content, min-content, max-content, square
Rows and columns fill by default: a section's height is split across its rows,
a row's width across its columns. That is usually what you want, but it means a
label like Shields: gets the same share as the value beside it, and you end up
hand-tuning percentages that then break at another window size.
Add a keyword and the row or column sizes itself to what is actually in it.
gui_row("row-height: content;")
gui_text("$text:`Shields:`;", "col-width: content;") # hugs its own text
gui_text("$text:`{shield_pct}%`;") # takes the rest
The label is now exactly as wide as the word Shields: — at every window size,
with no percentages to maintain.
1fr — and why you rarely have to type it
1fr is the odd one out, and the one you are probably already getting:
it is the default. A row or column that says nothing is 1fr.
Naming. This mode is an equal share of the leftover space, with a minimum — which CSS spells
1fr(grid) orflex: 1(flexbox). It used to be calledauto, andautostill works, but the name mispredicted the behaviour: CSS's ownautomeans size to your content and shrink under pressure, which is nearly the opposite. Prefer1frin new work. Ifautois ever given its CSS meaning, only scripts that wroteautoexplicitly will change.
auto keeps a column in the flex pool — it still shares the leftover space —
but puts a floor under it, so it is never squeezed below its min-content.
A column with a long word grows, and its roomier neighbours give way. The other
keywords take a column out of the pool and give it a size of its own.
That is the difference in one line:
| in the flex pool? | floor | |
|---|---|---|
1fr (default, was auto) |
yes — shares leftover space | never below min-content |
content / min-content / max-content |
no — sized from its content | n/a |
square (columns only) |
no — sized from the ROW HEIGHT | n/a |
Because col-width cascades column → row → section, putting 1fr on a section
makes every column in it minimum-aware without annotating any of them.
The keywords
| keyword | on a column | on a row |
|---|---|---|
1fr (default; auto is an alias) |
flex, but never below min-content |
flex, but never below its content height |
content (fit-content is an alias) |
natural width, clamped to what is available | as tall as the tallest cell at its final width, wrapping included |
min-content |
the widest unbreakable word | alias of content |
max-content |
the whole line, unbroken | tallest cell measured as one unwrapped line |
square |
as wide as it is tall — sized from the row height | not valid (it would be circular; raises) |
square — as wide as it is tall
The other keywords derive a width from the column's own content. square derives
it from the other axis: the column becomes as wide as the row is tall. It is
what a portrait, an icon, a ship render or a badge normally wants.
gui_row("row-height: 6em;")
gui_face(face, style="col-width: square") # a 6em square
gui_text("$text:`Harkin`;justify:left") # flex: takes the rest
square and an explicit width are mutually exclusive — setting either clears
the other. They are two answers to one question, and holding both is an illegal
state rather than a combination: a square column carrying a width is counted twice
when the row is divided up, so the row reserves its space twice over and, because
the engine does not clip, draws the surplus over and outside its neighbours.
gui_face and gui_icon are square by default. gui_ship and the image widgets
are not — left alone they flex, so a ship in a two-column strip takes half of
it. Say col-width: square and they behave like the others. An image keeps its
aspect ratio inside the square box, so a non-square source letterboxes rather
than distorting.
min-content on a row is an intentional alias. A true CSS row min-content
(how tall it gets when wrapped as narrow as possible) is expensive to compute and
not useful for a console.
# same string, three widths
gui_text("$text:`AA EXTRAORDINARILY BB`;", "col-width: min-content;") # ~ "EXTRAORDINARILY"
gui_text("$text:`AA EXTRAORDINARILY BB`;", "col-width: content;") # natural, clamped
gui_text("$text:`AA EXTRAORDINARILY BB`;", "col-width: max-content;") # the whole line
Requests, not reservations
A content size says "this is what I'd like", not "reserve this for me". When a row cannot hold everything, space is given up in a fixed order:
- flex columns shrink to 0 — they draw nothing, so this costs nothing visually
- content columns shrink proportionally, down to
min-content - below that it clamps and accepts the overflow
That order exists because the engine does not clip text. A zero-width flex
column is invisible; a content column squeezed past min-content draws its
letters across whatever is beside it.
Rows behave the same way: over-tall content rows scale down proportionally so the flex rows are not left with a negative share. Fixed rows are never scaled — an over-large fixed row is your instruction, and it is honoured.
What can and cannot be measured
| measurable | sizes to |
|---|---|
gui_text, gui_button, gui_checkbox, gui_input, radio buttons |
their text |
gui_image |
the image's real pixel size |
a sub-section (gui_sub_section) |
its widest row / the sum of its row heights |
gui_blank |
zero — a spacer asked to size to content collapses |
| declines | why |
|---|---|
gui_drop_down, gui_slider |
width includes engine-drawn chrome (arrow, border) that cannot be measured |
gui_text_area |
already scrolls to handle its own overflow |
gui_ship, engine console widgets |
drawn by the engine, no reportable size |
Anything that declines falls back to a normal flex share — never to zero. That is what makes this safe to put on a whole section:
gui_section("area: 10,10,90,90; col-width: content;")
Every column in that section sizes to its content, and the ones that cannot are laid out exactly as they would have been before.
Gotchas
col-width is in the same units as area:. Both are screen percent, not a
fraction of the panel. In a section spanning 51..99, col-width: 26 is about
half of it — col-width: 55 runs off the right-hand edge.
Content cannot invent space. Fill a section with fixed em rows and the
content rows will correctly be squeezed to nothing:
gui_section("area: 1,1,49,20;")
gui_row("row-height: 2.4em;") # x6 -- already taller than the section
...
gui_row("row-height: content;") # -> zero height, and rightly so
If a content row renders flat, the section is oversubscribed. Give it room.
Squares are special. An icon or face is square, so its size comes from the
row height — it therefore ignores col-width: content, and it never drives a
content row's height (that would be circular). A row containing nothing but
squares has no natural height and falls back to flex.
em is one line of the ROW's font — not of the text inside it. A row that
declares no font gets the default font, gui-2 (24px). If the text inside
declares something bigger, the row is too short, and because the engine does not
clip, the text draws over its neighbour:
gui_row("row-height: 1em;") # 24px -- the DEFAULT font
gui_text("$text:`{name}`;font:gui-3;") # draws at 28px -> overdraws
Two fixes. Say the font on the row, so em means what you meant:
gui_row("row-height: 1em;font:gui-3;") # now 28px
…or use row-height: content, which measures the real text and stays right if
the font changes later. One line of each font, for when you need the number:
| font | one line |
|---|---|
smallest |
18px |
gui-1 |
22px |
gui-2 (default) |
24px |
gui-3 |
28px |
gui-4 |
32px |
gui-5 |
36px |
gui-6 |
52px |
Padding is left, top, right, bottom, and top/bottom come out of the row
height. A single value is horizontal only and costs no height:
| padding | row | text box |
|---|---|---|
| (none) | 48px | 48px |
13px |
48px | 48px |
10px,10px,10px,0 |
48px | 38px |
0,10px,0,10px |
48px | 28px |
So a row that must hold one line of gui-3 and 10px of top padding needs
row-height: 1em+10px, not 1em.
Arithmetic works in row-height and col-width — 1em+10px, 62-25px,
2*3em, min(10,20). (Before v1.4.0 a + or - term was silently dropped and
you got just the first value, so old layouts may have been running with sizes
they did not ask for.)
Inside a gui_list_box item template, size the ROWS — do not return a
height. The listbox only resizes an item's section to its content when the
template returns None, and each section starts at zero height. Return a size
and the section stays degenerate: the row becomes unclickable, with no selection
highlight. Content keywords inside a listbox template also still fall back to
flex, so set the row heights explicitly there.
Check narrow wrapping in a real session. Row heights over text that wraps
inside a narrow column are the least certain case: the headless mock agrees with
the engine at column widths ≥600px, and 94% of the time at ≥300px, but diverges
below that. Since the engine does not clip, a row that is short by one line spills
into whatever sits under it. The content_demo mission exists to make that
visible.
What a row is guaranteed
Two guarantees worth knowing, both of which used to be violated:
A row is never sized below its own content just to pay for another row. Space is shared by min-constrained water-filling — the same shape CSS uses for flex items with a minimum: share evenly, freeze whatever cannot fit its share at its floor, re-share the rest. A row whose content fits inside the even share is completely unaffected.
A nested section asks for the height its content really needs. When the width is known, a sub-section measures its rows wrapped, so it requests the several lines it will actually occupy rather than one unwrapped line.
Both matter because the engine does not clip: a row squeezed below its content does not truncate, it draws over its neighbour.
If the floors genuinely do not fit, they are all scaled together — nothing is
starved to pay for something else — and --audit-layout reports it as
TEXT_TALL. That is the section being too small, which is an authoring fix.
Cost
With the default (1fr), sizing is not free — this is the important
correction to make. 1fr is a content mode: an unannotated column is measured
to find its min-content floor, and an unannotated row is measured for its
content height. So "using no keywords" no longer means "no measuring"; it means
every column and row takes the measure path.
Measured on a full calc() of a text-heavy screen (mock, warm cache):
| screen | 1fr default |
pure FILL (AUTO_DEFAULT=False) |
|---|---|---|
| all rows & columns unannotated | ~0.58 ms | ~0.26 ms |
fixed-height rows, columns 1fr |
~0.35 ms | ~0.26 ms |
So the default is 1.4–2.2× a full layout calc on a screen that is mostly measurable text, and the cost falls as more of the layout is given fixed sizes. (Before the per-widget result cache described below these were ~1.2 ms and ~0.6 ms — 2–5× — so most of that gap is now closed.)
Two things keep this from mattering in practice:
- The engine boundary is free. Text measurements are memoised in pixel
space, so a repainted screen makes zero
sbs.get_text_*calls. The whole cost above is Python arithmetic in the layout pass, not the Pybind boundary. - Full
calc()is rare. The dirty system re-lays-out only on a page present or a genuine layout change; a value cycling through same-width text stays visual-only and never re-measures. The millisecond above is paid when a screen is (re)built, not every frame.
If a screen is genuinely hot and mostly fixed anyway, AUTO_DEFAULT = False
restores pure FILL and the ~0.26 ms column — content keywords still work when
named explicitly.
Each widget's measured size is cached on its full inputs (text, mode, available width, font, aspect ratio), so a repaint of an unchanged tree resolves every measurement from a dict lookup rather than re-parsing props and re-measuring. The cache is self-invalidating — change any input and the key changes — and is cleared with the pixel memos. This is what closes most of the gap above; a screen whose text genuinely changes every frame pays the uncached cost.