Packages

Multi-surface application runtime for Elixir. One TEA module renders to terminal, browser (LiveView), SSH, and MCP (agents). 30+ widgets, flexbox + CSS grid, AI agent runtime, distributed swarm with CRDTs, time-travel debugging, session recording, sandboxed REPL, and agentic commerce.

Current section

Files

Jump to
raxol docs core LAYOUT.md
Raw

docs/core/LAYOUT.md

# Flex & Text Layout

<!--
Documents the `:flex` layout path (`Raxol.UI.Layout.FlexItem` +
`Flexbox.Solver` + `Flexbox.Positioner`) and the `Raxol.UI.TextLayout`
wrapping API. Literal `:row`/`:column` elements run on the same flex
engine through a compatibility translation (see section 6).
-->

This page is the supported-property reference for `:flex` containers and
`Raxol.UI.Components.Display.Text`. It documents what Raxol actually does
today (verified against source), not the CSS spec Raxol approximates.

## 1. Flex properties

A `:flex` container's own properties are read from its `style:` map when
present, falling back to legacy top-level/`attrs` forms. Item properties
(`flex`, `width`, `margin`, ...) are always read from the *child's*
`style:` map (`Raxol.UI.Layout.FlexItem.resolve/5`).

### Container properties

| Property           | Values                                                                 | Where                                                                                     | Notes |
|---------------------|-------------------------------------------------------------------------|---------------------------------------------------------------------------------------------|-------|
| `direction`         | `:row`, `:column` (`:row_reverse`/`:column_reverse` accepted, see divergences) | `attrs.flex_direction`; the `row`/`column` do-block macros set this for you | Default `:row`. NOT read from `style:`: `Engine.enrich_flex_attrs/1` only translates `style.justify_content`/`align_items`/`gap`/`padding`, not direction. |
| `justify_content`   | `:flex_start`, `:flex_end`, `:center`, `:space_between`, `:space_around`, `:space_evenly` | `style: %{justify_content: ...}` or `attrs.justify_content` | Default `:flex_start`. All variants distribute leftover space via exact integer splits (largest-remainder / Bresenham), spacing always sums exactly to the free space, no cells lost to `div/2` truncation. |
| `align_items`       | `:flex_start`, `:flex_end`, `:center`, `:stretch`                       | `style: %{align_items: ...}` or `attrs.align_items`                                        | Default `:stretch`. No `:baseline` (see divergences). |
| `align_self`        | same values as `align_items`, or `nil` (inherit)                        | child's `style: %{align_self: ...}`, or legacy `child.attrs.align_self`                     | Overrides the container's `align_items` for one child. |
| `align_content`     | `:flex_start`, `:flex_end`, `:center`, `:space_between`, `:space_around` | `attrs.align_content` only                                                                  | No `style:` translation exists for this key; set it directly in `attrs` if you're not going through the `row`/`column`/`flex` builders. Only matters when wrapping (`flex_wrap: :wrap`). |
| `flex_wrap`         | `:nowrap`, `:wrap` (anything non-`:nowrap` multi-lines)                 | `attrs.flex_wrap` only                                                                      | Default `:nowrap`. `:wrap_reverse` is accepted as a value but is not distinguished from `:wrap`: lines are not reversed. |
| `gap`                | integer, or `%{row: r, column: c}`                                      | `style: %{gap: ...}` or `attrs.gap`                                                         | Default `0`. Subtracted from the container's main-axis size before flexible-length resolution runs, and included in `MinContent` row aggregation (`sum(child min) + gap * (n - 1)`). |
| `padding`            | integer, `{v, h}`, `{t, r, b, l}`, or `%{top:, right:, bottom:, left:}` | `style: %{padding: ...}` or `attrs.padding`                                                 | Parsed by `Raxol.UI.Layout.LayoutUtils.parse_padding/1`. Applied to the container's space before children are measured or laid out; `:prepared_cache` and other extra space keys are preserved through the padding step, so flex children always measure with cache intact. |

### Item (child) properties

| Property                      | Values                                                                 | Where (child's `style:` map)          | Notes |
|--------------------------------|---------------------------------------------------------------------------|------------------------------------------|-------|
| `flex`                        | integer `n`; `{grow, shrink, basis}`; `%{grow:, shrink:, basis:}`         | `style: %{flex: ...}`                    | Integer shorthand `flex: n` expands to `grow: n, shrink: 1, basis: 0` **and** `min_main: 0` (terminal-pragmatic sugar: see divergences). Tuple/map forms set grow/shrink/basis only, no min override. Legacy `child.attrs.flex` (map form) is honored at lowest precedence for back-compat. |
| `flex_grow`                   | non-negative integer                                                     | `style: %{flex_grow: ...}`               | Default `0`. Ignored if `flex:` shorthand is also set. |
| `flex_shrink`                 | non-negative integer                                                     | `style: %{flex_shrink: ...}`             | Default `1`. |
| `flex_basis`                  | non-negative integer, `{:pct, n}`, or `:auto`                            | `style: %{flex_basis: ...}`              | Default `:auto`: resolves to the content main size (measured), or the explicit `width`/`height` when set. |
| `width` / `height`            | non-negative integer, `{:pct, n}`, or `:auto`/unset                      | `style: %{width: ...}` (or top-level `child.width`) | Explicit main-axis size wins over an `:auto` basis. On the cross axis it sets `cross_size` and disables the stretch guard (see below). |
| `min_width` / `min_height`    | non-negative integer, `{:pct, n}`, or `:auto`/unset                      | `style: %{min_width: ...}`               | Explicit value always wins over the `flex: n` sugar's `min_main: 0` override. Unset (`:auto`) falls back to the automatic minimum size (Section 3). |
| `max_width` / `max_height`    | non-negative integer, `{:pct, n}`, or `:auto`/unset                      | `style: %{max_width: ...}`               | `:auto`/unset means unbounded (`:infinity`). |
| `{:pct, n}`                   | any dimension/margin field above                                         | (tuple value, not a separate key)        | Resolves against the container's *definite* dimension only. Against an indefinite (`nil`) dimension it behaves as `:auto` per spec, EXCEPT margin percentages, which always resolve against the container **width** regardless of which side or axis. |
| `margin`                      | integer; `{h, v}`; `{t, r, b, l}`; `:auto` (whole value or per-side)      | `style: %{margin: ...}`                  | Sides may independently be `:auto`. `:auto` counts as `0` during sizing; main-axis auto margins absorb positive free space before `justify_content` runs; cross-axis auto margins center (both sides) or push (one side) and disable stretch. |
| `align_self`                  | see container `align_items`                                              | `style: %{align_self: ...}`              | See container table row above. |

## 2. Intentional CSS divergences

These are deliberate, not bugs: Raxol targets a monospace cell grid, not
a pixel box model, and some CSS corners aren't worth the complexity on a
terminal. Source: `Raxol.UI.Layout.FlexItem` moduledoc.

| CSS behavior | Raxol behavior | Why |
|---|---|---|
| `flex: 1` keeps each item's own `min-width`/`min-height: auto` (min-content) floor, so equal-`flex:1` columns don't always equalize if content differs. | `flex: n` sugar also sets `min_main: 0`, so equal-`flex` items *always* equalize: an explicit `min_width`/`min_height` in `style:` still overrides the sugar. | Terminal-pragmatic: equal columns that actually equalize is the common case terminal UIs want; the CSS min-content floor is opt-in via an explicit min. |
| `row-reverse` / `column-reverse` reverse visual order and flip the start/end edges for `justify-content`. | `:row_reverse`/`:column_reverse` are accepted as `flex_direction` values but map to the *same* axis pair as `:row`/`:column` (`get_axes/1`), no actual reversal of child order or edges happens. | Not implemented. Documented divergence: do not rely on reverse directions; they silently behave like the non-reversed direction. |
| `align-items: baseline` aligns items along their text baseline. | Not implemented. `Positioner.align_cross/4` has no `:baseline` clause; passing it falls through to a no-op catch-all (the child keeps whatever cross position it already had, effectively broken, not "close enough"). | No font metrics/baseline concept on a monospace cell grid worth the complexity. Use `:center` or `:flex_start` instead. |
| The fractional-flex-factor rule: if `sum(flex-grow) < 1`, only that fraction of free space is distributed, the rest stays unfilled. | Not implemented: grow/shrink factors are non-negative integers, so a fractional sum can't occur; all free space is always distributed among items with non-zero factors. | Documented as N/A rather than a gap: the precondition (fractional factors) can't arise given the integer-only factor type. |
| Percentages parse from CSS-like strings (`"50%"`) or numbers with a unit. | Only the `{:pct, n}` tuple is accepted; no string parsing. | One unambiguous representation, no parser/locale edge cases. |
| Margin percentages resolve against the *containing block's inline-axis size* per side semantics some engines special-case. | Margin percentages always resolve against the container's **width**, for all four sides, matching the CSS spec's actual (if surprising) rule. | Explicitly called out in `FlexItem` moduledoc because it trips people up even in browsers: Raxol matches spec here rather than "fixing" it. |
| Pixel box model: fractional/subpixel sizes, box-sizing modes. | Everything is whole cells; sizes are non-negative integers (or `{:pct, n}` rounded to a whole cell). No box-sizing switch, padding/border/margin math is always "content-box"-shaped in cells. | No subpixel concept on a terminal grid. |
| Invalid CSS values are ignored (the property falls back to its previous/initial value, per CSS's error-handling rule). | Invalid values (negative sizes, negative flex factors, malformed percentages/margins) are clamped to the nearest valid value (usually `0`) and reported via the `[:raxol, :layout, :invalid_style]` telemetry event. Layout never raises on style input. | Fail-soft with observability instead of silent CSS-style ignoring; a clamp is easier to spot in a telemetry dashboard than a silently-dropped property. |
| Overflow (`sum(min-size) > container`) is handled per `overflow` property (default `visible`, content spills out / overlaps). | Content clips at the container's main-end edge; siblings never overlap. Pairs with the text-overflow affordances in Section 4 so clipping degrades gracefully instead of silently truncating mid-glyph. | |

## 3. Automatic minimum size

CSS's `min-width: auto` / `min-height: auto` default (an item never
shrinks below its own minimum content size) is implemented via
`Raxol.UI.Layout.MinContent`, wired into `FlexItem.resolve/5` as the
`auto_min_fun` callback (`Flexbox.calculate_single_line_layout/5`):

- **Inline axis** (main axis is `:horizontal`, i.e. `flex_direction: :row`):
  the automatic minimum is `MinContent.width/1`: the width of the
  *longest unbreakable segment*, not the full content width. Text and
  labels break at spaces, after hyphens, and between CJK ideographs (each
  CJK grapheme is its own break opportunity), so a long sentence's
  min-content is just its longest word, not the whole sentence. A
  `:divider` has min-content `1` (not the available width: a full-width
  divider would otherwise inflate the measured container width and rob
  fixed-width siblings during shrink). A `:spacer` has min-content `0`.
- **Block axis** (main axis is `:vertical`, i.e. `flex_direction: :column`):
  the automatic minimum is the item's already-measured content size on
  that axis (no separate min-content pass: vertical wrapping isn't
  modeled, so "min content height" and "content height" coincide).
- Items **never shrink below** their automatic minimum; if the sum of
  minimums exceeds the container, the excess overflows per the clip
  rule (Section 2) rather than content vanishing or items overlapping.
- An **explicit `min_width`/`min_height`** in `style:` always overrides the
  automatic minimum (and overrides the `flex: n` sugar's `min_main: 0`,
  per `FlexItem.resolve/5`'s `{explicit_min, flex.min_main_override}`
  precedence).
- **`flex: n`** (the integer shorthand) opts out of the automatic minimum
  entirely by setting `min_main: 0` directly: use `flex: {n, s, b}`
  or explicit `flex_grow`/`flex_shrink`/`flex_basis` keys if you want the
  automatic-minimum floor to still apply.

## 4. Text layout

`Raxol.UI.TextLayout` is the canonical wrapping entry point, unifying
code that used to be scattered across `Input.TextWrapping` and ad-hoc
Component logic. `Raxol.UI.Components.Display.Text` exposes it via props.

### `white_space` (CSS Text Module Level 3)

| Value       | Newlines  | Space/tab collapsing | Wraps at width? |
|-------------|-----------|-----------------------|------------------|
| `:normal`   | collapse  | collapse              | yes |
| `:nowrap`   | collapse  | collapse              | no |
| `:pre`      | preserve  | preserve              | no |
| `:pre_wrap` | preserve  | preserve              | yes |
| `:pre_line` | preserve  | collapse              | yes |

`:normal` is the default and is deliberately bit-identical to the
pre-existing greedy word-wrap (including its non-CJK-safe character-count
line-fit check), so existing callers see unchanged output. The other four
modes are CJK-width-safe code paths
via `Raxol.UI.TextMeasure`. A single grapheme wider than `width` is never
split mid-grapheme; it's emitted alone on its own line even if it exceeds
`width`.

```elixir
alias Raxol.UI.Components.Display.Text

# Preserve blank lines and leading indentation (like <pre>), but still wrap
# long lines to the container width.
Text.init(content: "  def foo do\n\n  end", white_space: :pre_wrap, width: 20)
```

### `text_overflow: :ellipsis` (single-line truncation)

Only takes effect when `white_space` is `:nowrap` or `:pre` (the two
non-wrapping cases); at any other combination it's a no-op. Never splits
a double-width grapheme: the cut lands one column early instead.

```elixir
Text.init(
  content: "a very long single line that must not wrap",
  white_space: :nowrap,
  text_overflow: :ellipsis,
  width: 12
)
# => "a very lo…"
```

### `line_clamp` (CSS Overflow Module Level 4)

Caps wrapped output at `max_lines`, appending a block-ellipsis (`…`) to
the last kept line only if something was actually cut. Overrides the
legacy `wrap`/`truncate` props entirely when set; `white_space` still
selects the wrapping mode underneath it.

```elixir
Text.init(
  content: "Raxol is a multi-surface application runtime for Elixir built on OTP.",
  width: 20,
  line_clamp: 2
)
# => ["Raxol is a", "multi-surface Raxol…"]  (illustrative; exact break
#     points depend on TextMeasure/greedy wrap)
```

### `text_wrap: :pretty` (Knuth-Plass, `Raxol.UI.TextLayout.Pretty`)

`:auto` (default) is the existing greedy wrapper. `:pretty` runs a
Knuth-Plass-style dynamic program that minimizes total raggedness
(`sum(abs(width - line_width) ** 2)`) plus an orphan penalty for a
paragraph's last line containing a single word, avoiding the ugly
"one long word alone on the last line" greedy artifact. Break
opportunities: whitespace runs, after a hyphen, and between CJK
ideographs. Only applies when `white_space: :normal` (the only mode with
freely chosen break points); other modes ignore it. `:pretty` never
produces more lines than `:auto` for the same input.

```elixir
Text.init(
  content: "the quick brown fox jumps over the lazy dog",
  width: 15,
  text_wrap: :pretty
)
```

## 5. Distribution and caching guarantees

These guarantees hold across the current `:flex` layout path:

- **Free space distribution is exact.** `Flexbox.Solver` uses
  largest-remainder apportionment, so grow/shrink distributes every
  round's cells exactly, with no loss to integer truncation.
- **Explicit box `width`/`height` on a flex child is honored** as the
  main-axis size input (via `FlexItem.resolve/5`'s `explicit_main`),
  instead of being overridden by content measurement.
- **Padding tuples are honored end-to-end**, including through the
  `apply_padding` step, which preserves `:prepared_cache` (and any other
  extra space keys): flex children measure with cache intact instead of
  falling back to an uncached path.
- **`gap` is included in main-axis measurement**: the container's usable
  main size is `container_main - total_gaps` before flexible-length
  resolution runs, and `MinContent`'s row aggregation adds
  `gap * (n - 1)` to the summed child minimums: gap doesn't cause
  under- or over-fitting against the container.

## 6. The `:row`/`:column` compatibility dialect

Literal `%{type: :row}` / `%{type: :column}` element maps (as opposed to
the View DSL `row`/`column` macros, which build `:flex` maps) predate the
flex engine and carried their own conventions. They now translate onto
the flex engine (`Engine.containers_compat_to_flex/2`) preserving those
conventions:

- `gap` defaults to **1** in layout and **0** in measurement (the old
  dialect disagreed with itself; both behaviors are preserved so
  auto-sized boxes keep their dimensions)
- `justify`/`align` default to `:start` (mapped to `:flex_start`; the
  dialect never stretched children)
- children default to `flex_shrink: 0`: natural size, overflow instead of
  reflow. A child that declares any flex property opts out.
- `gap`/`justify`/`align`/`padding` are read from `attrs` first, then
  top-level keys, then the defaults above.

New code should use `:flex` (the View DSL macros) directly.

## 7. Overflow and scroll anchoring

- `style: %{overflow: :visible | :hidden | :clip | :auto | :scroll}` on
  boxes and flex containers. Anything but `:visible` (the default) stamps
  every descendant with the container's content rectangle as clip bounds;
  the renderer drops cells outside it. Nested clips intersect.
  `:auto`/`:scroll` clip identically, scrolling itself is the Viewport
  component's job; the property only guarantees containment.
- `Raxol.UI.Components.Display.Viewport` takes
  `overflow_anchor: :auto | :none`. `:auto` (default) is the terminal-log
  idiom: a viewport scrolled to the bottom stays pinned to the bottom as
  content grows; scrolling up releases the pin. `:none` never moves
  `scroll_top` on content changes.
- Chart wrapper boxes (`Raxol.UI.Charts.ViewBridge`) size themselves to
  the chart's render region and default to `overflow: :hidden`: chart
  cells can never paint outside the declared region.

## Future work

- **Span-aware wrapping**: a wrapped paragraph cannot yet carry per-span
  styles (`MarkdownRenderer` drops inline styling on lines that wrap;
  single-line content keeps it). Requires the wrapper to thread style
  runs through break points.
- **`:fill` as a dimension value**: accepted by the box path but not by
  `FlexItem` (where it clamps to `:auto` with telemetry). Candidate
  alias: `{:pct, 100}`.
- **`align-items: baseline`, reverse directions, `wrap-reverse`**:
  intentionally unsupported (see section 2); revisit only with a concrete
  consumer.

## Only `:box` is addressable

`Flexbox.process_flex/3` emits positioned elements for a container's *children*,
never for the container itself. A `:flex`/`:row`/`:column` **dissolves** into its
children's positions: it is pure layout, and it does not exist in the output.

Only `:box` produces a positioned element of its own. So a box is the only thing
that can:

- carry an `:id` (and therefore an accessibility role, a `data-raxol-id` CSS
  selector, an MCP projection, a change-stream identity)
- bound its own width/height
- stamp `:clip_bounds` on its descendants (i.e. clip its content)
- paint a background or a border

**A component that must be addressable has to render as a `:box`.** This is not
a gap to be filled in later; it is the split between "paints and is addressable"
and "positions its children and vanishes".

This has bitten us twice. `Viewport` rendered as a `:row`, so it could neither
bound nor clip its own content: its windowed rows painted straight through the
enclosing border and over whatever followed. The same `:row` had no id, so the
LiveView surface had nothing to key `overflow-anchor` CSS to, and the feature
shipped inert.

If you need flex layout *and* addressability, nest them: a `:box` that carries
the identity, bounds, clipping and background, wrapping a `:flex` that does the
layout.

## See also

- `docs/core/ARCHITECTURE.md`: where layout sits in the render pipeline.
- Module docs: `Raxol.UI.Layout.FlexItem` (resolved-item semantics),
  `Raxol.UI.Layout.Flexbox.Solver` (flexible-length algorithm),
  `Raxol.UI.Layout.MinContent` (automatic minimum measurement).