EUI applications without a browser

04 — Layout

Status: normative for what it defines; §9 lists what version 1 does not. Implemented by crates/eui-layout.

Layout is the one expensive thing the client still does, because it depends on facts the server cannot know: the viewport, the viewer's font scale and density, and the shaped width of text in the client's fonts. It is therefore specified as one algorithm rather than a family of behaviours, so that every conforming client places every node at the same pixel.

There are no floats, no CSS positioning, no calc(), no percentage margins, no auto margins, no writing modes, no z-index across containers. Each of those is a way for two implementations to disagree, and none of them is needed to build an application.

1. Terms

  • Container: a node whose display is row, column, stack or grid.
  • Main axis: horizontal for row, vertical for column. Cross axis: the other.
  • Available space: for each axis, either a definite length in px or indefinite (unbounded).
  • Box model: width and height size the border box. Padding and border lie inside it; margin lies outside and is never collapsed. The content box is the border box minus border minus padding.
  • Content size: the size a node takes with indefinite available space on the axis in question.

All lengths are f32 device-independent pixels. Results are not snapped to whole pixels by layout; the renderer snaps at paint.

2. Resolving a Dim

Against an axis whose parent content-box length is P:

DimResolves to
Autounresolved — the algorithm decides
Px(n)n
Percent(n)P × n / 10000 if P is definite, else unresolved
Space(i)the resolved space scale entry i (05-theme.md §5)
Fr(n)unresolved outside a grid track

After width/height resolve, min_*/max_* clamp the result, with min winning over max when they conflict. Padding, margin and gap are space indices and always resolve.

A node with no width of its own and a resolved max_width is sized against the narrower of the two: its content — so a text run's wrapping, and therefore its height — is measured at min(available, max_width), not measured wide and clamped afterwards. Clamped afterwards, a paragraph was measured on one line and drawn on three, over what followed it (the reference client until 0.7.1).

3. Sizing a node

size(node, avail_w, avail_h) → (w, h, baseline) is defined per kind.

text — shaped by the client's font engine at the resolved text scale entry, in the font_family and font_weight roles. With definite avail_w the run wraps at that width; line_clamp > 0 truncates to that many lines with an ellipsis. w is the widest line, h is lines × line height, and baseline is the first line's ascent. A run of only whitespace has zero width and one line height. icon and image size to their intrinsic size unless width/height say otherwise; divider is 1 px on its parent's cross axis; spacer has zero content size.

A container — §4–§6, with w/h from width/height when resolved, otherwise from content. A container's baseline is that of its first in-flow child if it has one, else its border-box height.

sizer, slot, overlay behave as a column box. scroll and list are §7.

display: none removes the node and its subtree from layout entirely; they have no size and take no space. A position: absolute child is skipped by the flow of any container other than stack.

4. Flow: row and column

This is CSS flexbox restricted to what applications use. Item order is child order; there is no order property.

4.1 Hypothetical main size

For each in-flow child: basis if resolved; else width/height on the main axis if resolved; else the child's content size on the main axis, sized with the container's cross-axis available space. Clamp by min/max.

The cross-axis space a child is sized with is definite when it will be stretched to a definite size: in a column of definite inner width that does not wrap, a child whose width is Auto and whose alignment is stretch is sized at that width (less its margins, within its own min/max) — as CSS does (flexbox §9.4, §9.8). Only the width axis: a row's children are not given a definite height this way. It matters to a child that is itself a container: a row measured with its width only bounded distributes no free space (§4.3), so a grow child with basis: 0 in it was measured at 0 px — a word a line — and the row took that height, though it was later placed at its real share (the reference client until 0.7.1).

4.2 Lines

With wrap: nowrap, one line holds every child. Otherwise children are placed in order onto the current line until adding the next child's hypothetical main size plus the gap would exceed the container's inner main size, at which point a new line begins. A child larger than the line stands alone on it. wrap-reverse lays lines in reverse cross-axis order.

4.3 Distributing free space

Per line, free = inner main − Σ hypothetical − gap × (n − 1).

If free > 0, each child with grow > 0 receives free × grow / Σ grow. If free < 0, each child with shrink > 0 loses −free × (shrink × hypothetical) / Σ (shrink × hypothetical).

Then clamp each child by its min/max. A child whose main-axis min is auto has an automatic minimum: along a row, its min-content width — its size measured as if the available width were zero, so a paragraph's longest word; down a column, its content height at the width on offer — each capped by the child's specified size, unless it is a scroll or list node or its overflow is scroll: such children shrink to zero. This is the CSS min-width: auto rule — a column that overflows its box overflows, it does not squash its children's text onto each other. A child whose clamp changed its size is frozen at the clamped size, the free space is recomputed over the remaining children, and the step repeats — at most eight times, after which remaining children keep their last computed size. This is the CSS resolution loop with a bound, so a pathological set of constraints costs eight passes rather than a hang.

When the container's main size is indefinite, free is zero: children take their hypothetical size and the container's main size is their sum plus gaps.

4.4 Justify

With any free space left after §4.3 (which can only happen when no child grows), children are packed on the main axis per justify: start, center, end, or spread with between (no space at the ends), around (half a space at each end), evenly (equal spaces everywhere). With a single child, between behaves as start and around/evenly as center.

4.5 Cross axis

A line's cross size is the largest cross size among its children, where a child's cross size is height/width on that axis if resolved, else its content cross size given its now-final main size. With one line and a definite container cross size, the line's cross size is the container's inner cross size.

Each child is then aligned within its line by align_self, or the container's align_items when align_self is auto: start, center, end; stretch sets the child's cross size to the line's if the child's cross Dim was Auto; baseline aligns children's baselines to the largest baseline on the line.

With multiple lines, lines are stacked on the cross axis with gap between them; a container with indefinite cross size takes their sum.

5. stack

Every child, in-flow or absolute, is laid out against the container's content box independently of its siblings. A child's size comes from its width/ height when resolved; otherwise an in-flow child under stretch (the default) fills the container's definite inner size on that axis, and any other child takes its content size given the container's inner size as available space. It is then positioned by its own align_self on the vertical axis and by the stack's justify on the horizontal — one value for every child, since a child's justify is about its own children — with its margins as offsets from the chosen edge. An absolute child whose position is absolute_start, absolute_center or absolute_end (version 7) names its horizontal edge itself instead, placed as justify start, center or end would place it, whatever the stack's justify says: a badge at a card's top right beside children that start at the left. It is absolute in every other respect. A popover and a panel at the pointer are placed as below, whichever of the absolute values they carry. The same holds for an absolute child of a row or a column, which is placed as a stack places one. Children paint in ascending z, ties in child order.

A stack with indefinite size takes the largest in-flow child border box plus that child's margins, per axis: an absolute child is placed on the stack, never counted into it, and never stretched to it.

Popovers. An overlay child of a stack with position: absolute hangs off the stack's first in-flow child — its anchor — instead of the stack's own corner. It is measured against the viewport rather than against the stack, on both axes, less its own margins: it floats, so what limits it is the room the viewer has, and a scroll in it therefore stops at the window edge and scrolls the rest. Its left edge starts on the anchor's; its top is the anchor's bottom plus its own top margin. When that would put its bottom outside the viewport and the anchor has more room above it than below, it goes over the anchor instead: its bottom the anchor's top, less the same margin. Either way the box is then clamped into the viewport on both axes, so a panel taller or wider than the window still starts inside it. Nothing is measured again: the panel and its subtree are moved.

Following the pointer. position: pointer is absolute in every respect above — out of flow, sized to its content, measured against the viewport, never counted into its stack — but the anchor gives it only its stack, not its place. A client MUST put it above the pointer and centred on it: its bottom the pointer's y less its own top margin, its centre the pointer's x. When there is no room above — the top of the box would fall outside the viewport — it goes under the pointer instead, its top the pointer's y plus the same margin. It is then clamped into the viewport on both axes as any other panel is.

Two panels want two rules, and the client tells them apart by what the hand is doing. A panel that follows the pointer while nothing is being dragged describes what is under the cursor, so it sits above it and centred on it — a chip under the fingertip is a chip the fingertip covers. While a drag is live (06-events.md §6) the panel is what is under the cursor, so it hangs from the hand instead: below it and to the right, by the same margin, going above only when there is no room below. Either way it is clamped into the viewport, which is why a wide one ends against an edge rather than off it.

A tooltip is the case this exists for, and it is a client's job for the same reason a scrollbar is: a local chunk has no access to the pointer (see 07-bytecode.md §1), and asking the server for a position is a round trip per mouse sample. The client MUST keep such a panel under the pointer as it moves, and SHOULD do so without laying out again — nothing about the panel changes but its origin. A client that does not know where the pointer is — it has left the window, or the input is not a pointer at all — leaves the panel where it was.

A panel positioned at the pointer is never hit-tested. It is on the top layer and it is under the hand by construction, so asking it first would put it between the pointer and everything it describes: a tooltip would answer the hover that shows it, and flicker. Nothing is ever dropped on one (06-events.md §6.2) and nothing is ever clicked on one. A panel meant to be pointed at is anchored, not tracking.

6. grid

Version 1 supports one shape: N equal columns. N is the container's columns prop, an integer ≥ 1, defaulting to 1. Children are placed in order, row-major. Each column is (inner width − gap × (N − 1)) / N wide; each row is as tall as its tallest child; rows are separated by gap. A child is sized as in §4.5 with stretch on both axes when its Dims are Auto.

7. Scrolling and virtualisation

A scroll node lays out its content as a column with indefinite available height (and indefinite width when overflow is scroll on that axis), then clips to its own border box. Its scroll offset is clamped to [0, content − viewport] per axis after every layout.

An offset is not a size, so a scroll changes no measurement and no placement relative to the scroller: every box under a scroll that scrolled lands exactly where it was, less the change in offset. A client MAY therefore serve a frame whose only change is an offset by translating those boxes rather than laying the tree out again, and the result MUST equal a full layout box for box. Three things are not translations and are laid out as ever: a virtualised list (the rows it holds depend on the offset, below), a popover or a panel at the pointer under the scroller (§5 places those against the window), and a frame in which anything else changed as well. The reference client does this in Layout::scroll; 09 §4 compares it with a full layout.

A scroll in flight — a wheel notch or a key press easing to its offset (03 §5's motion) — is laid out once, at the offset it lands on; the frames between draw that layout with the content slid, in the vertex stage, from where it was to where it was put, along the motion's curve from the list's own clock, so a frame owed to a glide alone is the previous draw list drawn again. The reference client tells its layout how far the content stands from where it was put meanwhile, so a hit mid-glide finds what is drawn under the point rather than what will be. A scroll event is emitted when it lands, as ever. A virtualised list holds the rows at both ends of the travel for the glide's one layout (§7.1), which is why a glide over more than two viewports of such a list moves its offset frame by frame instead.

A list node is a scroll whose children are laid out at their content size in a column, with one addition: when the list carries an item_height prop (integer px), a child outside the visible range plus one viewport of margin on either side is not measured — it is assigned item_height and skipped. A child carrying its own item_height prop takes that height instead, so a feed of cards of a few known heights virtualises like a table: the client sums the heights once, one addition per row, and measures only the rows in view. Once, not once per frame: the sum is a function of the rows' heights and the list's gap, and a scroll changes neither — a client is expected to keep it until something under the list changes, since rebuilding it is the whole cost of a scrolled frame otherwise (the reference client marks a scrolled node dirty::SCROLL rather than dirty::SELF for this). This is the only place the algorithm is allowed to use an estimate, and it is what makes a ten-thousand-row table cost what a fifty-row one costs.

7.1 Windowed lists

A list carrying a count prop (integer, the number of rows) has rows the tree does not hold. Row i is heights[i] px tall when the list carries a heights prop (a list of integers, one per row; a missing entry is item_height), else item_height. A child of such a list carries a row prop naming the row it is; a child without one is not laid out. Rows with no child are laid out as empty boxes of their height — the scroll extent, the scrollbar and the row tops are exactly those of the full list — and those in view are painted as placeholders: a block in surface.sunken, inset by space.2 and rounded md, so a scroll that outruns the server shows where the rows are rather than nothing.

Mid-glide the window covers both ends of the travel — the rows in view where the glide began and where it lands, each with its margins — so the one layout a glide takes has rows to slide past, and the placeholders are painted for every row the view passes over.

When the range of rows that intersects the viewport plus two viewports of margin on each side changes, and the scroll has been still for a moment (the reference client waits 120 ms — a request a frame would be a server render a frame) — after a scroll lands, a mount, a resize, a change of count — the list emits window (spec 06) with [first, last], inclusive row indices, if it holds a handler of that kind. While the view is still moving, it also asks the moment the rows within half a viewport of what it shows are not all among those it last asked for — a drag that has outrun its rows would otherwise show placeholders until it stopped — at most once every 50 ms in the reference client, and the settle that follows then finds nothing new to ask. A server that answers with those rows as children, and lets the others go, holds one window of a feed in memory, not the feed: forty thousand posts cost the client forty thousand integers and the server a few dozen cards.

A server SHOULD keep the row a drag started on inside the window it answers, until the drop. It knows which one, because it received drag_start (06-events.md §6.1); without it, a drag that autoscrolls three hundred rows drops the thing it is carrying out of the tree. A client survives a server that does not — it holds what is in the hand by key, and ends the drag when the key goes — but what it survives to is a cancelled gesture.

8. Invalidation

A conforming client MAY relayout from the root on every change; it SHOULD relayout only subtrees whose dirty bits are set and whose size could affect an ancestor. Either way the result MUST equal a full relayout. Text measurements SHOULD be cached by (text, font, available width), as shaping is the single most expensive operation in the pipeline.

9. Not in version 1

Stated so no one goes looking:

  • Grid tracks other than N equal columns; spanning; named areas.
  • order, auto margins, percentage margins or padding, aspect-ratio.
  • Vertical writing modes and right-to-left layout. (Text shaping is bidirectional; layout mirroring is not.)
  • Sticky positioning.
  • Cross-container stacking: z orders siblings within one stack only.
  • Sub-pixel snapping rules for the renderer, which belong in 03-widgets.md once written.

Rendered from spec/04-layout.md in the repository. The normative text is spec/; where it and a prose page disagree the spec wins, and that is a bug worth reporting. Edit this page