summaryrefslogtreecommitdiff
path: root/docs/design.typ
diff options
context:
space:
mode:
Diffstat (limited to 'docs/design.typ')
-rw-r--r--docs/design.typ284
1 files changed, 203 insertions, 81 deletions
diff --git a/docs/design.typ b/docs/design.typ
index 2492329d..ff013f49 100644
--- a/docs/design.typ
+++ b/docs/design.typ
@@ -12,16 +12,17 @@
= What it is
Pardes is a text environment in the acme tradition: columns of panes, each pane a
-tag line plus a body; the body is a live terminal, a file, or an image. The mouse
-carries meaning — left selects, middle executes, right looks. Everything on screen
-is text and all text is equally alive, whether the shell printed it or the user
-typed it.
+tag line plus a body; the body is a live terminal, a file, an image, or a PDF. The
+mouse carries meaning — left selects, middle executes, right looks. Everything on
+screen is text and all text is equally alive, whether the shell printed it or the
+user typed it.
-It runs in three places: the terminal (libvaxis), a native SDL3 window (the
-steamdeck), and the browser (a freestanding wasm core driven by vanilla
-JavaScript and rendered as HTML/CSS). The rewrite exists because
+It runs in four places: the terminal (libvaxis), a native SDL3 window (the
+steamdeck), the browser (a freestanding wasm core driven by vanilla
+JavaScript and rendered as HTML/CSS), and a native macOS app (an AppKit and
+CoreText shell over a static `libpardes.a`). The rewrite exists because
the prototype grew three parallel implementations of one program. The rewrite has
-exactly one program and three thin shells.
+exactly one program and four thin shells.
= The shape
@@ -33,9 +34,18 @@ its own renderer; the core owns everything the user would recognize as pardes.
columns: (auto, 1fr),
stroke: 0.4pt,
[*core*], [layout, panes, modes, selection, click semantics, themes, text of the
- UI. Pure state machine: `update(event)` mutates, `surface()` describes. No
- syscalls, no rendering, no event loop.],
- [*shell*], [one file per platform. Owns the event loop, translates native input
+ UI. Pure state machine: `update(event)` mutates, `render(arena)` returns the
+ surface. No rendering, no event loop, and no IO that has an effect for it —
+ but "no syscalls" was never true and is less true now: `look` reads a file
+ a Look opened, walks a directory for Find and reads every candidate for
+ Grep, `fonts` walks the font directories, and MuPDF opens a `.pdf`. Those
+ are the ones that are cheaper done in place than round-tripped through an
+ effect and back; everything with a lifetime — a pty, a window, the
+ clipboard — is still asked for.],
+ [*shell*], [one MODULE per platform (tty is one file; gui adds the CRT and
+ gamepad files, a C font loader and twelve GLSL shaders; web and macOS each
+ add a host language).
+ Owns the event loop, translates native input
into core events, renders the core's surface, and performs the core's
requested effects (spawn a shell, write a pty, open a link).],
)
@@ -45,34 +55,48 @@ build today is a separate 800-line read-only replay viewer. In the rewrite,
loading a dump of another instance is a first-class feature of the one
application, the way acme handles dumps: `pardes -l state.zon` on every
platform reconstructs the panes (terminals by replaying their raw VT streams
-into fresh emulators, files and images from their bytes). The web shell embeds
+into fresh emulators, files, images and PDFs from their bytes — a PDF rides the
+dump's `image` kind, carrying its path, its bytes and the page it was on). The web shell embeds
a dump and leaves `spawn` unanswered. Same core, no viewer fork.
The boundary is two data types, both plain values:
-*Event* (in): `key`, `text`, `mouse` (press/release/motion; button left, middle,
-right; cell position), `wheel`, `pinch`, `resize`, and `output` (bytes a pty
-produced, tagged with the pane id). Touch policy belongs to the shell: web maps
+*Event* (in): `key` (which carries typed `text`), `mouse` (press/release/motion/
+drag; button left, middle, right and the four wheel directions; cell position;
+and the `ctrl` flag, because Ctrl-left-click is goto-definition), `pinch`,
+`touch_scroll`, `resize` (cols, rows, and — in a MuPDF build — the cell's pixel
+size), `paste`, `pdf_scroll`,
+`output` (bytes a pty produced, tagged with the pane id), `eof`, `command` (a
+builtin line arriving from another process over the nested socket), `tick`, and
+the answers to an ask: `lsp_resp`, `pipe_resp`, `file_changed`. Touch
+policy belongs to the shell: web maps
a one-finger tap to right-button LOOK and a drag past its tap slop to natural
scrolling; native SDL keeps its two-finger gestures. A joystick cursor is a
motion plus buttons. The shells do this translation and nothing else with
-input: one event vocabulary, three dialects translated at the door.
+input: one event vocabulary, four dialects translated at the door.
-*Surface* (out): a grid of cells — grapheme, fg, bg, attrs — plus a cursor and
-per-pane rectangles. This is the *canonical interface*: it is literally what the
+*Surface* (out): `cols`, `rows`, a grid of cells — grapheme, fg, bg, attrs — a
+cursor, and the pixel attachments. This is the *canonical interface*: it is literally what the
tty shell hands to vaxis, cell by cell. SDL rasterizes the same grid through a
glyph atlas; the browser reads a packed copy and patches native DOM cells. If it
cannot be expressed in the surface, it does not
-exist in pardes. (Pixel images ride along as an attachment per pane: the SDL
-shells blit RGBA, the tty shell falls back to the petscii matcher, kitty
-graphics when available.)
+exist in pardes. (Pixel images ride along as a list of PLACEMENTS rather than
+one per pane — a PDF pane contributes every page its viewport intersects, and
+pages can be arbitrarily short. The SDL shells blit RGBA, the tty shell falls
+back to the petscii matcher, kitty
+graphics when available. The per-pane rectangles live on `Pardes`, not on the
+surface: a shell that wants them asks the core.)
*Effect* (out, queued): `spawn{pane, cwd}`, `write{pane, bytes}`,
-`resize_pty{pane, cols, rows}`, `open_link{url}`, `read_file{path}`,
-`new_file{pane, serial}`, `set_clipboard`, `read_clipboard`, `bell`, `quit`.
-The core never performs IO; it asks — and `read_clipboard` is the one ask with
-an answer coming back, an ordinary `paste` event, or nothing at all when the
-shell cannot read the clipboard (most terminals refuse the OSC 52 read). This
+`resize_pty{pane, cols, rows}`, `open_link`, `new_file{pane, serial}`,
+`save_file{pane}`, `write_dump`, `set_clipboard`, `read_clipboard`, `lsp`,
+`pipe`, `watch`, `quit`.
+The core never performs IO for any of these; it asks — and four of the asks have an
+answer coming back: `read_clipboard` returns an ordinary `paste` event (or
+nothing at all when the shell cannot read the clipboard — most terminals refuse
+the OSC 52 read), `lsp` returns `lsp_resp`, `pipe` returns `pipe_resp`, and
+`watch` returns `file_changed` whenever the shell notices the file moved under
+it. This
is what makes the browser build honest instead of a
fork: a wasm shell simply
answers `spawn` differently (or not at all) — the core does not know.
@@ -81,7 +105,7 @@ Platform divergence inside the core is a comptime tag, used the way the stdlib
uses `os.tag`:
```zig
-pub const Platform = enum { tty, gui, web };
+pub const Platform = enum { tty, gui, web, macos };
// in look.zig:
if (platform == .web and target.kind == .url)
return .{ .open_link = target.text };
@@ -94,32 +118,43 @@ divergence is visible in one screenful.
= Data structures
-The whole state is one struct, sized at init, no hidden allocation after:
+The whole state is one struct, fixed-size where it can be. Panes themselves are
+heap-allocated on demand and their contents (file bytes, the yank register, PDF
+rasters, tree-sitter state) grow with what you open; everything else is sized
+at init.
```zig
Pardes
- cols: []Col // col: weight + pane ids, top to bottom
- panes: []Pane // slot array; id = index
- active: Pane.Id
- drag: Drag // none | select | move | border | scrollbar
- theme: usize // index into themes (228 of them)
+ ncol + col_weight[6], col_terms[6][16], col_n[6] // columns as flat arrays
+ panes: [16]?*Pane // slot array; id = index
+ active: usize
+ drag: Drag // none | select | move | border_v | border_h | tag
+ theme_idx: usize // index into themes (228 of them)
colors_on: bool
- surface: Surface // rebuilt by surface(), arena-backed
- effects: Fifo(Effect)
+ rects: [16]Rect // where each pane landed, this frame
+ effects: [4096]Effect + head/len // a fixed ring
Pane
- tag: TagLine // live prefix (mode, cwd/path) + editable tail
- kind: union { term: Term, file: File, image: Image }
+ tag: TagLine // live prefix (cwd/path) + editable tail
+ kind: enum { terminal, file, image, pdf } // + independent optional payloads
+ vt, stream: ghostty-vt Terminal and its stream — on EVERY pane, not just
+ terminals, which is what lets the same keys and the
+ same parity suite drive a file and a shell
+ file: ?File / image: ?Image / pdf: ?PdfView
vweight: f32
mode: enum { normal, insert, tty } // helix-modal; tty = raw to the pty
+ // `v` adds a fourth thing to DISPLAY, "select",
+ // which is a bool on top of normal, not a mode
cursor: absolute body position // rides the scrollback, not the screen
- sel: [3]Sel + line/char modal sels // per-button block sels, msel, vsel
- edits: Splice // typed runs anchored to absolute rows
- undo: edit snapshots // unified undo across term edits and file content
+ sel: [3]Sel + msel/vsel + sels[63] // per-button block sels, the line and
+ // char modal ones, and up to 64 cursors
+ ovl: ?Ovl // ONE typed run, anchored to an absolute row
+ undo: two stacks, not one — ed_undo[64] of overlay snapshots for a terminal,
+ // and File.undo of content snapshots for a file
-Term = ghostty-vt Terminal + its stream (bytes in via Event.output)
File = path + bytes + line index + Syn (tree-sitter highlight bytes)
Image = decoded RGBA + petscii grid cache
+PdfView = MuPDF document + per-page rasters + cached outline
```
Layout is arithmetic, not objects: columns are weights over the width, panes are
@@ -133,21 +168,43 @@ one pane may not move panes it does not touch.
TigerStyle, plus house rules proven in the prototype: imperative and flat; one
big `update` dispatch, not handler objects; no one-line helpers — inline the
four-line scan; assert invariants at entry (`assert(vsum > 0)`); static
-allocation at init, arenas per frame. The metric is lines of code; the number
-only goes down. As built, against the prototype's 12,072 lines of Zig:
+allocation at init, arenas per frame.
+
+The metric was lines of code, and for the rewrite itself it held: 7,626 lines
+replaced the prototype's 12,072 with three backends instead of one and a half.
+It has not held since, and the table below is the honest version rather than
+the flattering one. Everything after the rewrite — PDF, a language backend, a
+fourth shell, 228 themes, multiple cursors — was added, not traded for
+something removed:
#table(
- columns: (auto, auto, 1fr),
+ columns: (auto, auto, auto, 1fr),
stroke: 0.4pt,
- [*module*], [*lines*], [],
- [core (pardes.zig + look.zig + syntax + image + dump)], [4,289], [all semantics, all platforms],
- [modal.zig + petscii.zig (pure, unit-testable)], [1,410], [carried over, unchanged],
- [shells (tty.zig; gui.zig = SDL native; web.zig + web/ = browser)], [2,768], [translate + render only],
- [main.zig + build.zig], [484], [three targets, codegen for queries],
- [*application total*], [*7,626*], [vs 12,072 — three full backends instead of one and a half],
- [snapshot harness (snapshot.zig + e2e_harness.zig)], [831], [the parity oracle, kept],
+ [*module*], [*at the rewrite*], [*now*], [],
+ [core and the pieces that grew out of it — 25 files: pardes, look, syntax, image, dump, builtins, config, output/file/term panes, normal\_input, nested, fonts, themes, …], [4,289], [22,220], [all semantics, all platforms],
+ [modal.zig + petscii.zig (pure, unit-testable)], [1,410], [2,569], [carried over],
+ [pdf.zig], [—], [1,581], [MuPDF, `-Dmupdf`],
+ [lsp/ (seam + in-process ZLS backend)], [—], [1,663], [`.zig` only],
+ [shells: tty 1,396; gui 4,968; web 601; macos.zig 1,579], [2,768], [8,544], [translate + render only],
+ [main.zig + build.zig], [484], [1,471], [four targets, four codegen passes],
+ [*application total* — every `.zig` under `src/`, plus `build.zig`], [*7,626*], [*38,048*], [vs the prototype's 12,072],
)
+Those six rows are a partition: no file is in two of them and none is left out,
+which the old table could not say (its rows summed to 1,325 more than its own
+total, and twenty-six files were in no row at all). What the total deliberately
+does NOT count, because none of it is Zig under `src/`: 2,250 lines of Swift for
+the macOS app and its icon generator, 1,541 lines of C bridging MuPDF, 549 of
+JavaScript, HTML and CSS for the web shell, 216 of C for the SDL font loader,
+`mupdf.zig` (355) at the root, and `tools/` + `build/` (896). The test tree is
+another 5,234 lines of Zig across eight harnesses, plus 1,028 of Swift and 610
+of JavaScript — against 831 at the rewrite.
+
+Two things that number is not. It is not one program's worth of growth — the
+macOS and web shells and the PDF and language work are four products sharing a
+core. And it is not licence: the rule that survives is the local one, that a
+change should leave the file it touches no longer than it found it.
+
= Testing: the old program is the oracle
Before the rewrite compiled, the prototype got a harness (`snapshot.zig`, the
@@ -156,8 +213,9 @@ script* (one line per input: keys, SGR mouse, resizes, sync points), and
captures the rendered grid — text, cursor, and per-cell style runs — through
its own ghostty terminal. Goldens are generated from the old binary
(`zig build snap -- --update`); the new binary must reproduce them byte for
-byte (`pardes-snap pardes/zig-out/bin/pardes`). Eighteen scripts cover the
-checklist in Appendix A; all pass. Determinism pins: fixed workdir paths (they
+byte (`pardes-snap pardes/zig-out/bin/pardes`). Eighteen scripts covered the
+checklist in Appendix A at the rewrite; eighty-seven cover it and everything
+since. All pass. Determinism pins: fixed workdir paths (they
appear in tags), a controlled `$HOME` with `PS1='$ '`, `LC_ALL=C`, and a
grid-stability sync primitive instead of timing guesses.
@@ -169,36 +227,66 @@ text fields are the contract); and typed insert runs don't survive a dump
replay (they are an overlay, not pty bytes — the prototype's replay viewer had
the same semantics).
-Shell correctness (that SDL draws the grid it was given, that vaxis diffs
-correctly) is out of snapshot scope and covered by each shell's single smoke
-test.
+Shell correctness is no longer out of scope, and that is the biggest change to
+this section since the rewrite. `web-snap` / `web-e2e` drive headless Chromium
+over CDP with real DOM pointer and touch events and diff
+`test/web-snapshots/`; `image-harness` and `pdf-harness` snapshot native PIXEL
+output through kitty graphics and SDL; `macos-e2e` is an offscreen AppKit
+snapshot suite over its own seven scripts. What remains untested by a snapshot
+is the last hop — that vaxis diffs correctly onto a real terminal — plus each
+shell's inline unit tests (the FreeType atlas raster, the trackpad and rotation
+maths, the gamepad replay).
= Build
-`zig build` produces three statically linked artifacts: `pardes` (vaxis),
-`pardes-gui` (SDL3), `pardes.wasm` + a vanilla DOM shell (wasm32-freestanding).
-Native dependencies are ghostty, vaxis, uucode (shared config), zstbi, SDL
-(castholm, lazy), FreeType, and zig-tree-sitter + grammars — all pinned through
-`zig fetch` and wired in `build.zig`. Codegen during build, consumed by comptime:
-`highlights.scm` → options module; tutor text → embedded pane content. Debug
-builds are incremental for the seconds-loop; release builds are the product.
+Every `zig build` builds ONE platform: `platform` is an option defaulting to
+`.tty`, so a bare `zig build` gives `pardes` (vaxis) and the other three are
+separate invocations — `-Dplatform=gui` for `pardes-gui` (SDL3),
+`-Dplatform=web -Dtarget=wasm32-freestanding -Ddump=<dump.zon>` for
+`pardes.wasm` plus a vanilla DOM shell (any other spelling of that one fails on
+purpose rather than building something silently wrong), and `-Dplatform=macos`
+plus the `macos-app` step for `pardes.app` wrapped around a static
+`libpardes.a` on a Darwin host. Native dependencies are ghostty, vaxis, uucode (shared config),
+zstbi, SDL (a pinned fork, lazy), FreeType, MuPDF (`-Dmupdf`, on by default
+everywhere but the web, lazy), ZLS, mvzr (the regex engine behind `s`/`S`), and
+zig-tree-sitter + 26 grammars — all pinned
+through `zig fetch` and wired in `build.zig`. Generated during the build:
+`highlights.scm` → an options module; the vendored helix/zed theme
+sources → the generated half of the theme ring; the tracked `.zig` sources →
+the web shell's read-only archive; eight GLSL shaders → SPIR-V. The tutor and
+the embedded font are plain `@embedFile`s, not codegen. Debug builds are
+incremental for the seconds-loop; release builds are the product.
= What is deliberately absent
No render abstraction over the shells (the surface *is* the abstraction). No
-config files. No plugin system. No async runtime in the core — the shells may
+plugin system. No async runtime in the core — the shells may
thread, the core is single-threaded by construction. No 9P yet — but the
-library boundary is exactly where acme put the file server, and a fourth shell
-could serve `Surface` and `Event` over 9P without touching the core.
+library boundary is exactly where acme put the file server, and a fifth shell
+could serve `Surface` and `Event` over 9P without touching the core. Half of
+that is already built and unnamed: `src/nested.zig` listens on a unix socket at
+`$XDG_RUNTIME_DIR/pardes-<pid>.sock` (`~/.local/state/pardes` without one),
+accepting exactly one verb — `Look <path>`, and nothing else, which is the
+security property — and that is how
+a `pardes <file>` run inside a pardes hands the file to the outer session
+instead of stacking a second full-screen UI inside one of its panes.
+
+"No config files" held until the startup file (`docs/config.md`) arrived, and
+that is the narrowest thing the phrase could still cover: a list of builtin
+COMMANDS run before the first frame — no schema, no new vocabulary, and no key
+remapping. The keymap is `src/config.zig`, compiled in, where a wrong binding
+is a compile error rather than a silent no-op.
= Appendix A: feature parity checklist
From the prototype survey; every line is covered by at least one of the
-eighteen event scripts in `test/snapshots/` (boot, tty, edit, scroll, modal,
-look-file, look-dir, exec, tag, theme, tutor, windowops, dump, load, ttyonly,
-syntax, fileedit, images).
+event scripts in `test/snapshots/` — the original eighteen (boot, tty, edit,
+scroll, modal, look-file, look-dir, exec, tag, theme, tutor, windowops, dump,
+load, ttyonly, syntax, fileedit, images), and sixty-nine more added since for
+everything below that the prototype never had.
-*Layout.* Columns by weight (≤6), panes by vweight (≤16); global topbar;
+*Layout.* Columns by weight (≤6), panes by vweight (16 panes in total, not
+per column); global topbar;
per-pane gutter (move box + scrollbar) and tag row; `splitBelow` shrinks only
the source (cursor row kept visible); dying pane's weight absorbed by one
sibling; emptied column hands width to a neighbor; border-drag resize on a
@@ -223,13 +311,29 @@ first doc opens left column and may evict a lone pristine shell; later docs
split below the existing doc — an output pane, `+Search`/`+Help`, is not a
doc for either half of that rule: it never claims a column, it splits below
whatever pane asked for it, and nothing splits from it), image ext → image
-pane. Middle+left chord: kept
+pane, `.pdf` → PDF pane. Ctrl+left is goto-definition, the one chord borrowed
+from every editor with a language backend. Two spellings the word expansion
+has beyond a path: `` @`ls -la` `` is taken WHOLE and runs as a command rather
+than opening as a file, and `@p7:10:5` addresses a live pane by number for the
+things — terminals, output buffers — that have no path to name.
+Middle+left chord: kept
left selection appended as trailing CLI argument. Wheel: scroll hovered pane,
batched.
-*Modes.* normal: helix motions (`h j k l w b e W B E 0 $ ^ gg ge gh gl G`,
-`Ctrl-d/u/f/b`, `zt zz zb`), insert entries (`i a I A o O`), `v`/`x`
-selections, `d c y p u U`, Enter=look Tab=execute at cursor. insert:
+*Modes.* normal: helix motions (`h j k l w b e W B E 0 $ ^`, `f F t T` and
+`Alt-.`, counts, `gg ge gh gs gl g| G`, `Ctrl-d/u/f/b`, `zt zz zb zj zk`),
+insert entries (`i a I A o O`), selections (`v` extend — displayed as a fourth
+mode name, "select" — `x`/`X`/`Alt-x`, `%`, `;`/`Alt-;`, `_`), MULTIPLE CURSORS
+up to 64 (`C`/`Alt-C` copy, `s`/`S` select and split by regex with a live
+preview, `Alt-s` split on newline, `,`/`Alt-,` keep and remove the primary,
+`)`/`(` rotate, `Alt--`/`Alt-_` merge), operators (`d c y p P R u U`, `Alt-d`
+delete-noyank, `J`, `>`/`<`, `~`/`` ` ``/`` Alt-` ``, `Ctrl-a`/`Ctrl-x`,
+`Ctrl-c` comment-toggle), textobjects and surrounds under `m`
+(`mm mi ma ms mr md`), the `]`/`[` pairs (`]p ]d ]D ]<space>`), `/` with `n`/`N`,
+`|` to filter the selection through a command, `:` for the tag as a command
+line, and Enter=look Tab=execute at cursor. Since the helix motion model
+landed, a traversal motion SELECTS the range it crossed — which is why there is
+no verb+noun grammar and why `i` after `w` types at the selection's start. insert:
click-and-type; terminals get splice runs (shift right, never overwrite;
absolute-row anchored), files get real edits. tty: raw pty forwarding
(Ctrl-key toggle, default Ctrl-b, `--tty-toggle`), mouse still usable,
@@ -252,21 +356,39 @@ Colors and Crt left it for their leader paths.
OSC 7 cwd, DSR/DA/kitty-query replies (write_pty + device_attributes — the
nushell/helix regression), greeting `ls`, auto-follow output unless
navigating. File: line-number gutter (fixed width), tree-sitter highlights
-(c/cpp/zig minimal tier; 25 grammars full tier; re-highlight on edit,
+(c/cpp/zig minimal tier; 26 grammars full tier; re-highlight on edit,
visible-range first), Save, open-at-line, undo/redo. Image: zstbi decode,
kitty graphics when available, petscii matcher fallback (C64/terminal
-palettes, ascii glyph set toggle). Tutor: embedded text as file pane.
+palettes, ascii glyph set toggle). PDF (`-Dmupdf`, native default on): MuPDF
+rendering as one continuous page strip, real text search, mouse text
+selection, the document outline into `+PdfSections`, fit-width/fit-height and
+a themed duotone tint — the last three named in the pane's own live tag.
+Tutor: embedded text as file pane.
+
+*Language.* ZLS compiled in and called IN-PROCESS — no subprocess, no
+JSON-RPC, no daemon and nothing cached between queries — behind a
+one-function seam (`lsp.query`) so that swapping a backend touches nothing
+else. `.zig` only, and on demand only: helix's five `g` gotos, ten builtins
+under `SPC l`, `]d`/`[d`, `=`, and insert-mode Tab after a `.`. Answers become
+rows in `+Search`, `+Hover` or `+Lsp` — output buffers of the same kind `/`,
+Find and Help fill — or, for a rename, byte ranges the core applies in one undo. The
+web shell has no threads and therefore no backend at all.
*Chrome.* One theme per `.zig` file, folded into the ring at comptime: ours
first — helix (default; near-black page, chrome one grey-ramp step off it,
-colors lifted from the helix editor's own theme), dark, acme-light — then
+colors lifted from the helix editor's own theme), dark, and the acme-light one
+named simply `acme` — then
everything `tools/gen_themes.zig` exports at build time out of the vendored
helix `.toml` and zed `.json` sources in `vendor/themes`, which is all 214 helix
-ships plus zed's 11, sorted by name. helix and dark leave
-a child's ANSI palette native; acme-light and the zed exports resolve it onto
+ships plus zed's 11, sorted by name. Names are unique by construction: helix's
+own acme is vendored as `acme_helix.toml` so it cannot collide with ours, and
+every zed theme takes a `_zed` suffix for the same reason. helix and dark leave
+a child's ANSI palette native; acme and the zed exports resolve it onto
the page so shell output stays readable on a light one. A theme owns the page,
-the tag bar, the gutter and the move box; the selection colors stay fixed
-because they encode which button you pressed. `NextColor` browses the ring one
+the tag bar, the gutter, the move box and the SELECTION: `sel_bg`/`sel_fg` are
+one pair per theme, and the three per-button tints and the dimmed extra cursors
+are mixed off it, so what stays fixed is the distinction between buttons and not
+the colours. `NextColor` browses the ring one
step at a time — at 228 it is no longer how you REACH one —
`Theme <name>` jumps to one and `ThemeSel` (`SPC t t`) lists them all into an
output buffer whose rows are those very commands — execute a row (Tab, middle