ADR-0029: the terminal cell model, and the rules that follow from it#523
Conversation
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.
|
i love it reading this on park bench now |
|
ok finally finished reading:
#521: the table says |
|
All four land. Fixed in #524 (stacked on #521), plus one correction to #521 itself. 1. 2. 3. It also exposed a missing concept, so the theme gains a 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 purposeThose 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. 4. #521's table is stale — fixed. Sweep 2609/2609. |
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:
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.
:black\e[40mbox(bg: :blue)drew a blue outline around a hollow middle\nWhat's in it
docs/adr/0029-the-terminal-cell-model.md— five invariants and their consequences:background_clip(:border_boxdefault) is a real choice, not a detail.\r\nrow 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 (gapisn't read fromstyle; an unknown border variant renders as a space; only:boxis addressable; cells don't composite).docs/core/LAYOUT.md— gains the layout counterpart: only:boxproduces a positioned element;:flex/:row/:columndissolve. That's whyViewportcould neither bound nor clip its own content, and why the LiveViewoverflow-anchorCSS shipped inert.The pattern underneath all of it
Every bug was one name doing two jobs:
:black= unpainted and blacknil= transparent and erase what's beneathbg= fill and border paintgap= read fromattrsand top-level — but notstyle, where people put itvariant= a known style and silently invisibleEach 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.