Skip to content

ADR-0029: the terminal cell model, and the rules that follow from it#523

Merged
DROOdotFOO merged 1 commit into
DROOdotFOO:masterfrom
merklebonsai:pr/adr-terminal-cell-model
Jul 13, 2026
Merged

ADR-0029: the terminal cell model, and the rules that follow from it#523
DROOdotFOO merged 1 commit into
DROOdotFOO:masterfrom
merklebonsai:pr/adr-terminal-cell-model

Conversation

@merklebonsai

Copy link
Copy Markdown
Contributor

Drew asked for an ADR explaining how and why the TUI paint path works the way it does. This aggregates what the last few days of rendering bugs taught us.

The observation the ADR is built on

A terminal is not a canvas. It is a grid of cells; a cell holds one grapheme, one foreground, one background. No alpha, no sub-cell positioning, no compositing.

Nearly every rendering bug we shipped came from reasoning as if it were a canvas — and every one shared a signature:

They were invisible on the default configuration.

An opaque black terminal running the default theme hides a remarkable number of mistakes. Painting an opaque black background where you meant "leave it alone" looks exactly right — until someone turns on transparency.

What we did Why it looked fine What it actually was
Defaulted an unpainted background to :black black on black every "unpainted" cell painted \e[40m
Painted the theme's background (= the terminal's own) identical output destroyed transparency, overrode the user's terminal
A box's background painted the border ring, never the interior dark outline on a dark theme box(bg: :blue) drew a blue outline around a hollow middle
An unpainted cell overwrote the cell beneath it usually nothing beneath a button on a modal erased the modal and showed the desktop through itself
Joined frame rows with a bare \n the shell used to cook it to CRLF in raw output every row drifted a column right

What's in it

docs/adr/0029-the-terminal-cell-model.md — five invariants and their consequences:

  1. The cell is the atom of paint. You cannot half-paint. A border glyph fills a whole cell, so background_clip (:border_box default) is a real choice, not a detail.
  2. Transparency is an absence, not a value. A cell is transparent precisely because we emitted no background for it. Therefore: never paint a background equal to the terminal's own.
  3. "Unpainted" means show what is beneath. Cells don't composite, so an unpainted background must inherit, not erase.
  4. The terminal owns the canvas; the app colours the content. Themes colour fg/accents/borders. This is already CellDim's premise (OSC 11 + H-K solving against the real ground) — the theme layer just contradicted it.
  5. The frame owns its geometry. \r\n row joins, DECAWM off, \e[0m-terminated runs.

docs/core/RENDERING.md — the working reference: the rules, where to put things, and the traps that have actually bitten us (gap isn't read from style; an unknown border variant renders as a space; only :box is addressable; cells don't composite).

docs/core/LAYOUT.md — gains the layout counterpart: only :box produces a positioned element; :flex/:row/:column dissolve. That's why Viewport could neither bound nor clip its own content, and why the LiveView overflow-anchor CSS shipped inert.

The pattern underneath all of it

Every bug was one name doing two jobs:

  • :black = unpainted and black
  • nil = transparent and erase what's beneath
  • a box's bg = fill and border paint
  • gap = read from attrs and top-level — but not style, where people put it
  • a border variant = a known style and silently invisible

Each time the two meanings were indistinguishable by the time they reached the renderer, and the wrong one was silently wrong. The fix was always the same move: split the meanings apart and make the wrong one unrepresentable.

So the review question isn't "does this look right?" — on the default configuration it will. It's: what is this value's one job, and what happens when the user's terminal is not mine?

Docs only; no code.

A terminal is not a canvas. It is a grid of cells, and a cell holds one grapheme,
one foreground, one background. No alpha, no sub-cell positioning, no
compositing. Nearly every rendering bug we have shipped came from reasoning as if
it were a canvas, and they all shared a signature: they were invisible on the
default configuration. An opaque black terminal running the default theme hides a
remarkable number of mistakes.

The ADR records five invariants -- the cell is the atom of paint; transparency is
an absence, not a value; unpainted means "show what is beneath"; the terminal
owns the canvas and the app colours the content; the frame owns its own geometry
-- and the failure mode they exist to prevent.

That failure mode is one pattern, not five: one name doing two jobs. `:black`
meant unpainted and black. `nil` meant transparent and erase. A box's background
meant fill and border paint. `gap` was read from attrs and the top level but not
from style, where people put it. An unknown border variant meant a known style
and an invisible one. Each time the two meanings were indistinguishable by the
time they reached the renderer, and the wrong one was silently wrong.

RENDERING.md is the working reference: the rules, where to put things, and the
traps that have actually bitten us. LAYOUT.md gains the layout counterpart --
only `:box` is addressable; `:flex`/`:row`/`:column` dissolve -- which is why
Viewport could not bound or clip itself and why the LiveView overflow-anchor CSS
shipped inert.
@DROOdotFOO DROOdotFOO merged commit f3b400c into DROOdotFOO:master Jul 13, 2026
1 check passed
@DROOdotFOO

Copy link
Copy Markdown
Owner

i love it reading this on park bench now

@DROOdotFOO

Copy link
Copy Markdown
Owner

ok finally finished reading:

dialog_surface/4 is a pre-#521 workaround. The outer fill box only exists because borders used to paint just their ring. With background_clip: :border_box as the default (#521), one bordered box with bg: fill_bg fills the whole footprint. Collapse it: drop the outer box, the doubled width/height, the last-write-wins reliance.

rendering.ex:86: the docstring still cites last-write-wins composition; invariant 3 replaced it with inherit-what's-beneath. Output's fine, rationale's stale.

rendering.ex:95: fill_bg defaults to :black, the token the ADR flags. Invariant 4 says the terminal owns the canvas, so that's a black slab on a light terminal. Opaque is right, the literal isn't. Use a theme surface color.

#521: the table says box(border, bg: :blue) leaves the border unpainted, but the code defaults :border_box, so border_ring_bg returns fill_bg and the ring's backed. Table predates the background_clip add; fix before merge. @merklebonsai

@merklebonsai

Copy link
Copy Markdown
Contributor Author

All four land. Fixed in #524 (stacked on #521), plus one correction to #521 itself.

1. dialog_surface/4 is pre-#521 scaffolding — collapsed.
Dead on. The outer fill box existed only because borders painted just their ring; its docstring even said so, and leaned on last-write-wins to let the inner box win. Both premises are now false (:border_box default + inherit-what's-beneath). Collapsed to one bordered box with a background — outer box gone, doubled width/height gone, composition-order reliance gone. Verified: children: 0, and every cell (ring and interior) carries the surface.

2. rendering.ex:86 docstring cites last-write-wins — rewritten.
Correct, the rationale was stale even though the output was fine. It's now: a dialog is opaque by design; it declares a background, and a box's background fills its whole footprint, border included, so a single box is the whole surface.

3. fill_bg defaults to :black — the token the ADR flags.
This was the best catch. You put it exactly right: opaque is right, the literal isn't. A dialog is painted on purpose — the dimmed content behind must not read through — but black is a black slab on a light terminal, and the terminal owns the canvas.

It also exposed a missing concept, so the theme gains a :surface colour:

background: "#000000"   # the theme's ASSUMPTION about the terminal's canvas -- never painted
surface:    "#1E1E1E"   # a raised opaque surface (a dialog, a panel) -- painted on purpose

Those are genuinely different things, and conflating them is what produced the original bug. Which is the ADR's own thesis eating its own dog food: one name doing two jobs. :background was doing both. Worth the extra key.

4. #521's table is stale — fixed.
Right again: it was written before the background_clip commit, so it still claimed box(border, bg: :blue) leaves the ring unpainted, when :border_box is the default and border_ring_bg returns fill_bg. Corrected in place, along with the :padding_box row and why :border_box is the default.

Sweep 2609/2609.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants