Skip to content

Collapse dialog_surface to one box; paint it with a theme surface colour#524

Open
merklebonsai wants to merge 9 commits into
DROOdotFOO:masterfrom
merklebonsai:pr/collapse-dialog-surface
Open

Collapse dialog_surface to one box; paint it with a theme surface colour#524
merklebonsai wants to merge 9 commits into
DROOdotFOO:masterfrom
merklebonsai:pr/collapse-dialog-surface

Conversation

@merklebonsai

Copy link
Copy Markdown
Contributor

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/4 nested a bordered box inside an unbordered, fully-filled box of the same footprint. Its own docstring explained why:

"a border only paints its ring, so without the outer fill the dimmed flow content behind it would show through the gaps. Outer is emitted first, inner (its child) after, so last-write-wins cell composition lets the border/content win…"

Both premises are now false:

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 :surface colour to name the thing:

surface: "#1E1E1E"   # a raised opaque surface -- a dialog, a panel

This is a genuinely different concept from :background, and worth the extra key: :background is only the theme's assumption about the terminal's own canvas, and is never painted. A :surface is 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_clip table in #521's description was stale — written before the :border_box default landed, so it still claimed box(border, bg: :blue) leaves the border unpainted. Corrected in place.

Sweep: 2609/2609 (modal overlay + headless suites green).

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.
@merklebonsai merklebonsai force-pushed the pr/collapse-dialog-surface branch from 27c28bd to 473c03a Compare July 13, 2026 20:19
@merklebonsai

Copy link
Copy Markdown
Contributor Author

Rebased onto master (which now has ADR-0029) and folded the ADR amendment in, since
this is the PR that introduces the colour it describes.

ADR-0029 said "the terminal owns the canvas; the application colours the content"
and then 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 now names both:

theme colour what it is painted?
:background this theme's assumption about the terminal's own canvas no
:surface a raised opaque thing — a dialog, a panel yes

Which is the ADR's own thesis, one layer up from where it was looking: one name doing
two jobs.
:background was doing both, and it was found by reading the invariants
against the code they were supposed to describe. That is the intended use, so it's now
the last entry in the ADR's own list, and RENDERING.md's "where to put things" table
gained a row.

modal_test.exs is updated in the same commit as the collapse — it was pinning the
two-box shape (assert surface.border == :none, assert [inner] = surface.children),
which is the scaffolding this removes.

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