Collapse dialog_surface to one box; paint it with a theme surface colour#524
Collapse dialog_surface to one box; paint it with a theme surface colour#524merklebonsai wants to merge 9 commits into
Conversation
There is no alpha in the terminal protocol: a cell is transparent precisely when the application never set a background for it. Emitting any background -- including one that matches the terminal's -- makes the cell opaque. Unpainted backgrounds defaulted to the atom `:black`, which the renderer maps to `\e[40m`: an opaque black cell. On an opaque black terminal that is invisible, so it shipped. On a transparent terminal each of those cells punches an opaque rectangle through the window, while cells whose background is genuinely nil stay see-through -- hence the same UI rendering at two different opacities. Default an unpainted background to nil instead. The renderer already emits no SGR for a nil background, and every run is `\e[0m`-terminated, so nothing bleeds into it. This also un-overloads the sentinel. `:black` had to mean both "unpainted" and "actually black", indistinguishable by the time it reached the renderer; nil now means unpainted and `:black` is free to be a real, dimmable color. CellDim.dim_bg passes nil through: there is nothing to dim, and dimming it would paint it. The one test that asserted an unpainted background rendered as `:black` was pinning the bug.
Two more places turned an unpainted background into a painted one, so cells stayed opaque even after defaulting the background to nil: ThemeResolver.resolve_bg_color/3 fell back to the theme's global background (and then to :black), so every element was handed an explicit background. Renderer.build_ansi_prefix/2 fell back to the theme's default background when a cell's background was nil, painting the very cells that had asked for none. Both are the same mistake: the theme's "background" is an assumption about the terminal's own, and painting it is a no-op on an opaque terminal and an opaque rectangle on a transparent one. This is also what CellDim already assumes. It detects the real ground with OSC 11 and *solves* colours against it -- the terminal owns the background and we adapt to it. Painting our assumed ground over the real one contradicts that. So: themes colour content (foreground, accents, borders); the terminal owns the canvas. An app that genuinely wants a different canvas still sets a background explicitly, and that still paints -- foreground keeps its theme fallback, since text does need a colour. The theme tests asserted that an unpainted box inherits and paints the theme's background. That was the behaviour being removed, so they now assert nil; the test that overrides a background explicitly still expects it painted.
The FATE corpus hashes the cell grid, and a cell carries its background, so every fixture that never asked for one moves from `bg: :white` to `bg: nil`. The button snapshots move the same way at the ANSI level: the `\e[40m` they used to carry is simply gone. Both sets of references were recorded against the behaviour this branch removes -- they encode "an element with no background gets one anyway". The render diff is exactly that and nothing else (verified cell-by-cell against master: only the bg token changes; glyphs, positions, foregrounds and attrs are byte-identical).
`render_box_borders/6` iterated only the ring and handed those cells the box's
background, so the interior was never painted at all: `box(border: :single,
bg: :blue)` drew a blue *outline* around a hollow middle, which is close to the
opposite of what it reads like.
Fill the interior with the box's background and paint the ring only when asked
for explicitly, via `:border_bg`:
box(border: :single) border only, fully transparent
box(border: :single, bg: :blue) blue interior, unpainted border
box(border: :single, bg: :blue,
border_bg: :red) blue interior, red border
box(bg: :blue) no border, whole rect filled
Besides being the obvious reading, this is what a transparent terminal wants: a
border that floats over the desktop with only its content backed, instead of a
rectangle of paint tracing the frame.
A cell is the smallest unit of paint. The border glyph occupies a whole cell, so
a background cannot stop halfway through it -- the cell is either inside the
background or outside it. That is the only real choice, and it is exactly what
CSS calls background-clip:
background_clip: :padding_box (default) background stops at the border's
inner edge; the border cell is unpainted, so
the outline floats over a transparent
terminal with only the content backed.
background_clip: :border_box background extends under the border; the
glyph is drawn on the fill and the box reads
as a solid panel.
An explicit :border_bg still wins over both.
Leaving the border ring unpainted opens a one-cell gap around the fill, and whatever is behind the box -- a dimmed modal backdrop, the desktop -- shows through it. The panel reads as though a channel had been cut around its own frame, and no choice of colours fixes it; the more the box and the backdrop differ, the worse it looks. A frame is not a thing standing next to a panel, it is the panel's edge. So a box that declares a background paints its border with it. The transparent case is unaffected: a box with no background paints nothing at all, border included, so an unfilled box still lets the terminal through. :padding_box remains for the inset-fill look, and an explicit :border_bg still wins over both.
Cells do not composite: writing one replaces whatever was there. So a cell written with an unpainted background did not leave the background beneath it alone -- it cleared it. A button drawn on a filled modal punched a hole through the modal and showed the desktop through itself. That made "unpainted" mean two different things depending on where it landed: "the terminal shows through" at the top of the tree, and "erase the parent's fill" anywhere else. It has to mean one thing -- *show what is beneath* -- and the two cases then fall out of it: over a filled parent that is the parent's fill; at the top there is nothing beneath, so it stays unpainted and the terminal shows through. So at both composition points -- CellManager.merge_cells/2 and the cells-to- buffer write -- an unpainted background inherits the background already at that coordinate. A painted background still overrides, as before.
`dialog_surface/4` nested a bordered box inside an unbordered, fully-filled box of the same footprint. That outer box only existed because a border used to paint its ring and nothing else, so without it the dimmed content behind showed through the interior. Its docstring said as much, and leaned on last-write-wins cell composition to let the inner box win. Both premises are now false. A box's background fills its whole footprint, border included, and an unpainted cell inherits what is beneath rather than erasing it. So the outer box, the doubled width/height, and the composition-order reliance are all dead weight: one bordered box with a background is the entire surface. The fill colour also defaulted to the literal `:black` -- the exact token ADR-0029 flags. A dialog *is* opaque on purpose (the dimmed content behind must not read through it), so painting it is right; painting it black is not, because the terminal owns the canvas and that is a black slab on a light terminal. Take the colour from the theme instead, and give the theme a `:surface` colour to name the thing: a raised opaque surface, as distinct from `:background`, which is only the theme's assumption about the terminal's own canvas and is never painted.
The invariant "the terminal owns the canvas" left no colour for a thing that is opaque on purpose. A modal is exactly that: it sits over dimmed content which must not read through it. With no :surface to ask for, it reached for :background -- the one colour the ADR says is never painted. So the theme gains :surface (a raised opaque thing; painted) alongside :background (this theme's assumption about the terminal's own canvas; never painted), and the ADR names the split. It is the same failure the ADR was written about, one layer up: one name doing two jobs.
27c28bd to
473c03a
Compare
|
Rebased onto master (which now has ADR-0029) and folded the ADR amendment in, since ADR-0029 said "the terminal owns the canvas; the application colours the content" —
Which is the ADR's own thesis, one layer up from where it was looking: one name doing
|
Addresses @DROOdotFOO's review on #523. Stacked on #521 — it's what makes the collapse possible.
The outer box was scaffolding for a bug we deleted
dialog_surface/4nested a bordered box inside an unbordered, fully-filled box of the same footprint. Its own docstring explained why:Both premises are now false:
background_clip: :border_box)So the outer box, the doubled
width/height, and the composition-order reliance are all dead weight. One bordered box with a background is the entire surface.Verified:
children: 0(the nested box is gone), and every cell — ring and interior — carries the surface colour.The fill colour was the literal ADR-0029 flags
It defaulted to
:black. A dialog is opaque on purpose — the dimmed content behind it must not read through — so painting it is right. Painting it black is not: the terminal owns the canvas, and that's a black slab on a light terminal.The colour now comes from the theme, and the theme gains a
:surfacecolour to name the thing:This is a genuinely different concept from
:background, and worth the extra key::backgroundis only the theme's assumption about the terminal's own canvas, and is never painted. A:surfaceis painted, on purpose. Conflating them is what produced the original bug.Falls back to the default surface when no theme is available (rendering outside a started application).
Also fixed
The
background_cliptable in #521's description was stale — written before the:border_boxdefault landed, so it still claimedbox(border, bg: :blue)leaves the border unpainted. Corrected in place.Sweep: 2609/2609 (modal overlay + headless suites green).