aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-14 20:58:48 +0200
committeruser <user@clank>2026-08-14 20:58:48 +0200
commit76d2032ff807d536114f46e1a183eeb62eacdc61 (patch)
treed88197d2d50ff48c470128e61fee6c44b72735f2
parentsl0ppty: find the crossing whichever way the tree is nested (diff)
downloadslosh-76d2032ff807d536114f46e1a183eeb62eacdc61.tar.gz
slosh-76d2032ff807d536114f46e1a183eeb62eacdc61.tar.xz
slosh-76d2032ff807d536114f46e1a183eeb62eacdc61.zip
sl0ppty: contrib themes, a way to look at them, and a handover
Six themes, each setting every colour the config knows about. A theme that has fallen behind silently keeps the compiled-in default for whatever it forgot, which looks like a bug in the theme rather than a missing line in it, so a test compares each file against the names config.c actually parses. contrib/theme-tour applies one to a running session, or cycles the lot. It does that by writing over the file the session was started with and letting the config watcher notice, which makes the showcase a demonstration of the watcher as well. HANDOVER.md is for the next context. It deliberately does not restate DESIGN.md or the README; it is the part that was only learnable by getting it wrong — which state is derived and which is intent and what happens when you confuse them, the dwell repaint list, that glyph width and glyph weight are different problems, that settle() in the harness is a sleep in disguise, and that a test pinned to a coordinate it does not care about will eventually look like a regression it is not.
-rw-r--r--HANDOVER.md105
-rw-r--r--README.md3
-rw-r--r--contrib/README.md35
-rwxr-xr-xcontrib/theme-tour40
-rw-r--r--contrib/themes/amber.kdl66
-rw-r--r--contrib/themes/mono.kdl66
-rw-r--r--contrib/themes/paper.kdl66
-rw-r--r--contrib/themes/phosphor.kdl66
-rw-r--r--contrib/themes/sl0p.kdl66
-rw-r--r--contrib/themes/slate.kdl66
-rw-r--r--tests/test_themes.py59
11 files changed, 638 insertions, 0 deletions
diff --git a/HANDOVER.md b/HANDOVER.md
new file mode 100644
index 0000000..c715781
--- /dev/null
+++ b/HANDOVER.md
@@ -0,0 +1,105 @@
+# Handover
+
+Written at the end of a long session, for whoever picks this up next — most
+likely me, without the memory of having written any of it.
+
+`DESIGN.md` says what this is and why. `README.md` says what it does.
+`.fastfeedback/SPEED.md` says how to run things quickly. This file is the part
+that is only learnable by having got it wrong.
+
+## Where things stand
+
+Everything is committed and pushed; `git log` is the honest changelog and the
+messages are long on purpose. The working tree should be clean — if it is not,
+that is unfinished work and not a stash worth trusting.
+
+Built and tested with `make test` (~6s, parallel, per-file stamps) and
+`make test-live` (~7s, needs real ptys). Both green. Clean build, no warnings.
+
+## The rule this codebase keeps, and how I kept breaking it
+
+**Anything derived from the layout is recomputed every frame and must not be
+remembered between frames.** `rect`, `content`, `collapsed` and `hidden` all
+say so in their comments. The corollary is the part that bites: *intent* is
+different and does have to be stored — `weight`, `minimized`, a tab's `zoom`,
+which pane has focus.
+
+Three bugs in one afternoon came from confusing the two:
+
+- A drag held an **index into a corner list rebuilt every frame**, by a drag
+ whose whole job was changing the layout. It silently started moving other
+ panes' boundaries. Fixed by storing the boundaries themselves.
+- `layout_node` clears `collapsed`/`hidden` at entry. Putting `minimized` next
+ to them would have un-minimised everything every frame; it lives beside
+ `weight` instead, with a comment saying why.
+- A test counted visible panes across *all* tabs. Only the current tab is laid
+ out, so every other tab's rects are whatever they were last time it was on
+ screen.
+
+If something works and then stops working while you drag or resize, this is the
+first thing to suspect.
+
+## Traps that cost real time
+
+**The dwell repaint list.** `app_next_deadline_ms` lists the hit-action
+prefixes that arm after the pointer rests. Anything that arms on dwell and is
+*not* listed there appears only when some unrelated event happens to repaint —
+so it looks intermittent, which is the worst way to be wrong. This caught me
+twice, for `edge:` and then `corner:`.
+
+**Glyph width and glyph weight are different problems.** `screen_text` books
+every chrome cell as one column. An emoji drawn two columns wide shifts the
+whole row. Separately, a glyph that does not *fill* its cell donates that space
+to the gap beside it and looks badly spaced next to one that does — the cells
+can be provably even and still look wrong. Three attempts at the frame buttons
+established this; they are plain ASCII now. If someone reports spacing, dump
+the actual cells before touching any arithmetic.
+
+**`settle N` in the test harness is a sleep in disguise.** `send` and
+`snapshot` are already synchronous with the app, so a settle before asserting
+on *our own chrome* waits for nothing. It is only needed to pump a pane's pty.
+Use `until_text()` where the answer is observable. Getting this wrong is what
+made the suite 80 seconds; it is 6 now.
+
+**Tests pinned to incidental coordinates.** Several asserted a style at a
+hardcoded column, or a shape that happened to be true. They only break when
+something unrelated moves, and then they look like real regressions. Assert the
+thing you mean.
+
+## Architecture in five sentences
+
+`app.c` owns the layout tree, drawing, and input routing; `pane.c` wraps one
+pty plus one libghostty-vt terminal; `screen.c` is a cell buffer that diffs
+itself and emits the minimal byte stream. Everything painted registers its
+`(rect, action)` into a hit list *as it is painted*, and a click is a lookup in
+that list — this is D1 and it is why drawing and hit-testing cannot disagree.
+Layout runs twice a frame: once as a probe to ask whether the tab fits, once
+for real, and the probe must not resize panes. Shaders are a colour pass over
+pane *contents* between `pane_compose` and the chrome drawn over them, which is
+what makes "contents, not chrome" the paint order rather than a rule. The
+control API in `cmd.c` is the same vocabulary the headless test driver speaks,
+so a script written against one works against the other.
+
+## Things left on the table
+
+- **A `reload` keybinding.** The config watcher made it less pressing.
+- **Tab-level shaders**, and the bigger one: **OSC 5577 → shader**, letting a
+ pane colour itself by reporting its own state. That is the idea most likely
+ to make this feel unlike other multiplexers.
+- **Dead panes are not observable.** `app_reap` closes a pane before the next
+ paint, so there is no `dead` pane state to colour. Making one means keeping
+ dead panes until dismissed — a feature about lifetimes, not colour.
+- **Collapsed headers do not reach the shader pass**, so a `suspended` or
+ `scrolled` colour is invisible on one.
+- **Corner crossings**: found for both nestings now, but if more turn up
+ missing, ask *how the layout was built* — the split order decides the tree
+ shape, and that is where any remaining blind spot will be.
+
+## How to work on it
+
+Read the comment above the thing before changing it; they explain the decision,
+not the mechanics, and several are load-bearing. Run `make test` constantly —
+it is six seconds. Drive the thing in a zellij pane and look at it; every bug
+in this file was found by looking, not by reasoning. Write the test that would
+have caught it, and check the test fails against the old build before believing
+it.
diff --git a/README.md b/README.md
index da78d84..43aa862 100644
--- a/README.md
+++ b/README.md
@@ -158,6 +158,9 @@ collapsed tab, each state of a tab, the status line, the finder, toasts, the
rename editor. Several share a default, which says they look right together
rather than that they are the same thing.
+Six complete themes live in [`contrib/themes`](contrib/themes), and
+`contrib/theme-tour` cycles a running session through them.
+
Configuration is [`config/config.kdl`](config/config.kdl), which documents
every setting by being the defaults. Copy it to `~/.config/sl0ppty/config.kdl`.
**Saving it re-reads it into every running session** — a file that does not
diff --git a/contrib/README.md b/contrib/README.md
new file mode 100644
index 0000000..d187a95
--- /dev/null
+++ b/contrib/README.md
@@ -0,0 +1,35 @@
+# contrib
+
+Things that are useful but are not the multiplexer.
+
+## themes/
+
+Six complete themes. Each sets **every** colour the config knows about, so
+nothing silently falls back to a compiled-in default that belongs to a
+different palette — there is a test that keeps them that way.
+
+| | |
+|---|---|
+| `sl0p` | the default: hot pink on near-black |
+| `phosphor` | a green CRT that never quite went away |
+| `amber` | the other CRT, for people who found green loud |
+| `slate` | muted blues, for looking at all day |
+| `paper` | a light theme, for a light terminal |
+| `mono` | no colour at all, only weight and brightness |
+
+To use one, put it in `~/.config/sl0ppty/config.kdl` (or point
+`$SL0PPTY_CONFIG` at it). A running session re-reads it the moment you save,
+so you can edit and watch.
+
+## theme-tour
+
+```sh
+contrib/theme-tour # start a session and cycle every theme
+contrib/theme-tour slate # apply one to a running session
+DWELL=8 contrib/theme-tour # linger longer on each
+SESSION=work contrib/theme-tour slate
+```
+
+It works by writing the theme over the file the session was started with and
+letting the config watcher notice, which is also a fair demonstration of the
+watcher.
diff --git a/contrib/theme-tour b/contrib/theme-tour
new file mode 100755
index 0000000..f8775b6
--- /dev/null
+++ b/contrib/theme-tour
@@ -0,0 +1,40 @@
+#!/bin/sh
+# Show the themes in contrib/themes on a running session.
+#
+# contrib/theme-tour spawn a session and cycle every theme
+# contrib/theme-tour slate apply one to the session named below
+# SESSION=work contrib/theme-tour ...to a different session
+#
+# It works by writing the theme to the file the session was started with and
+# letting the config watcher do the rest, which is also a fair demonstration of
+# the watcher.
+set -e
+here=$(cd "$(dirname "$0")" && pwd)
+bin="$here/../build/sl0ppty"
+SESSION=${SESSION:-themes}
+CONF=${SL0PPTY_CONFIG:-/tmp/sl0ppty-theme-tour.kdl}
+DWELL=${DWELL:-4}
+
+apply() {
+ cp "$here/themes/$1.kdl" "$CONF"
+ printf ' %-10s %s\n' "$1" "$(sed -n '1s|// ||p' "$here/themes/$1.kdl")"
+}
+
+if [ -n "$1" ]; then
+ [ -f "$here/themes/$1.kdl" ] || { echo "no such theme: $1"; exit 1; }
+ apply "$1"
+ exit 0
+fi
+
+if ! "$bin" ls 2>/dev/null | grep -q "^$SESSION "; then
+ cp "$here/themes/sl0p.kdl" "$CONF"
+ echo "starting a session called $SESSION..."
+ SL0PPTY_CONFIG="$CONF" "$bin" -s "$SESSION" -- "${SHELL:-/bin/sh}" &
+ sleep 1
+fi
+
+echo "cycling every theme, ${DWELL}s each. ^C to stop wherever you like."
+for t in "$here"/themes/*.kdl; do
+ apply "$(basename "$t" .kdl)"
+ sleep "$DWELL"
+done
diff --git a/contrib/themes/amber.kdl b/contrib/themes/amber.kdl
new file mode 100644
index 0000000..e2dabf1
--- /dev/null
+++ b/contrib/themes/amber.kdl
@@ -0,0 +1,66 @@
+// amber -- the other CRT, for people who found green loud
+//
+// Drop into ~/.config/sl0ppty/config.kdl, or point $SL0PPTY_CONFIG at a file
+// that contains it. A running session re-reads it the moment you save.
+theme {
+ // what a shader takes "terminal default" to mean
+ default_fg "#ffc860"
+ default_bg "#0f0900"
+
+ // panes
+ frame_focus "#ffb000"
+ frame_idle "#6b4a00"
+ title "#ffe0a0"
+ title_idle "#6b4a00"
+
+ // a pane's own OSC 5577 buttons
+ button_fg "#1a1000"
+ button_bg "#ffb000"
+ button_bg_idle "#6b4a00"
+
+ // affordances under the pointer
+ guide "#ffb000"
+ resize "#ffb000"
+ drop_target "#ffb000"
+
+ // scrollback indicator
+ scroll_fg "#1a1000"
+ scroll_bg "#ffb000"
+
+ // rows of a tab collapsed into a list
+ header "#6b4a00"
+ header_hover "#ffb000"
+ header_hover_title "#ffe0a0"
+
+ // the strip along the top
+ tab_active_fg "#1a1000"
+ tab_active_bg "#ffb000"
+ tab_active_hover_fg "#ffe0a0"
+ tab_idle "#6b4a00"
+ tab_hover "#ffb000"
+ prefix_fg "#1a1000"
+ prefix_bg "#ffb000"
+ tab_count "#6b4a00"
+
+ // the line along the bottom
+ status "#6b4a00"
+ status_state "#ffe0a0"
+ hint "#ffe0a0"
+
+ // the pane finder
+ finder_fg "#ffe0a0"
+ finder_bg "#6b4a00"
+ finder_sel_fg "#1a1000"
+ finder_sel_bg "#ffb000"
+
+ // rename editor, bell, frame buttons, the minimised bar
+ rename_fg "#1a1000"
+ rename_bg "#ffb000"
+ toast_fg "#1a1000"
+ toast_bg "#ffb000"
+ bell "#ffb000"
+ pane_button "#6b4a00"
+ pane_button_hover "#ffb000"
+ minbar "#6b4a00"
+ minbar_hover "#ffb000"
+}
diff --git a/contrib/themes/mono.kdl b/contrib/themes/mono.kdl
new file mode 100644
index 0000000..bedf6d0
--- /dev/null
+++ b/contrib/themes/mono.kdl
@@ -0,0 +1,66 @@
+// mono -- no colour at all, only weight and brightness
+//
+// Drop into ~/.config/sl0ppty/config.kdl, or point $SL0PPTY_CONFIG at a file
+// that contains it. A running session re-reads it the moment you save.
+theme {
+ // what a shader takes "terminal default" to mean
+ default_fg "#e4e4e4"
+ default_bg "#000000"
+
+ // panes
+ frame_focus "#e4e4e4"
+ frame_idle "#585858"
+ title "#ffffff"
+ title_idle "#585858"
+
+ // a pane's own OSC 5577 buttons
+ button_fg "#101010"
+ button_bg "#e4e4e4"
+ button_bg_idle "#585858"
+
+ // affordances under the pointer
+ guide "#e4e4e4"
+ resize "#e4e4e4"
+ drop_target "#e4e4e4"
+
+ // scrollback indicator
+ scroll_fg "#101010"
+ scroll_bg "#e4e4e4"
+
+ // rows of a tab collapsed into a list
+ header "#585858"
+ header_hover "#e4e4e4"
+ header_hover_title "#ffffff"
+
+ // the strip along the top
+ tab_active_fg "#101010"
+ tab_active_bg "#e4e4e4"
+ tab_active_hover_fg "#ffffff"
+ tab_idle "#585858"
+ tab_hover "#e4e4e4"
+ prefix_fg "#101010"
+ prefix_bg "#e4e4e4"
+ tab_count "#585858"
+
+ // the line along the bottom
+ status "#585858"
+ status_state "#ffffff"
+ hint "#ffffff"
+
+ // the pane finder
+ finder_fg "#ffffff"
+ finder_bg "#585858"
+ finder_sel_fg "#101010"
+ finder_sel_bg "#e4e4e4"
+
+ // rename editor, bell, frame buttons, the minimised bar
+ rename_fg "#101010"
+ rename_bg "#e4e4e4"
+ toast_fg "#101010"
+ toast_bg "#e4e4e4"
+ bell "#e4e4e4"
+ pane_button "#585858"
+ pane_button_hover "#e4e4e4"
+ minbar "#585858"
+ minbar_hover "#e4e4e4"
+}
diff --git a/contrib/themes/paper.kdl b/contrib/themes/paper.kdl
new file mode 100644
index 0000000..902f343
--- /dev/null
+++ b/contrib/themes/paper.kdl
@@ -0,0 +1,66 @@
+// paper -- a light theme, for a light terminal
+//
+// Drop into ~/.config/sl0ppty/config.kdl, or point $SL0PPTY_CONFIG at a file
+// that contains it. A running session re-reads it the moment you save.
+theme {
+ // what a shader takes "terminal default" to mean
+ default_fg "#1a1a1a"
+ default_bg "#fdfdfa"
+
+ // panes
+ frame_focus "#0057b8"
+ frame_idle "#9aa0a6"
+ title "#1a1a1a"
+ title_idle "#9aa0a6"
+
+ // a pane's own OSC 5577 buttons
+ button_fg "#ffffff"
+ button_bg "#0057b8"
+ button_bg_idle "#9aa0a6"
+
+ // affordances under the pointer
+ guide "#0057b8"
+ resize "#0057b8"
+ drop_target "#0057b8"
+
+ // scrollback indicator
+ scroll_fg "#ffffff"
+ scroll_bg "#0057b8"
+
+ // rows of a tab collapsed into a list
+ header "#9aa0a6"
+ header_hover "#0057b8"
+ header_hover_title "#1a1a1a"
+
+ // the strip along the top
+ tab_active_fg "#ffffff"
+ tab_active_bg "#0057b8"
+ tab_active_hover_fg "#1a1a1a"
+ tab_idle "#9aa0a6"
+ tab_hover "#0057b8"
+ prefix_fg "#ffffff"
+ prefix_bg "#0057b8"
+ tab_count "#9aa0a6"
+
+ // the line along the bottom
+ status "#9aa0a6"
+ status_state "#1a1a1a"
+ hint "#1a1a1a"
+
+ // the pane finder
+ finder_fg "#1a1a1a"
+ finder_bg "#9aa0a6"
+ finder_sel_fg "#ffffff"
+ finder_sel_bg "#0057b8"
+
+ // rename editor, bell, frame buttons, the minimised bar
+ rename_fg "#ffffff"
+ rename_bg "#0057b8"
+ toast_fg "#ffffff"
+ toast_bg "#0057b8"
+ bell "#0057b8"
+ pane_button "#9aa0a6"
+ pane_button_hover "#0057b8"
+ minbar "#9aa0a6"
+ minbar_hover "#0057b8"
+}
diff --git a/contrib/themes/phosphor.kdl b/contrib/themes/phosphor.kdl
new file mode 100644
index 0000000..a0727a9
--- /dev/null
+++ b/contrib/themes/phosphor.kdl
@@ -0,0 +1,66 @@
+// phosphor -- a green CRT that never quite went away
+//
+// Drop into ~/.config/sl0ppty/config.kdl, or point $SL0PPTY_CONFIG at a file
+// that contains it. A running session re-reads it the moment you save.
+theme {
+ // what a shader takes "terminal default" to mean
+ default_fg "#8cff9e"
+ default_bg "#020a05"
+
+ // panes
+ frame_focus "#33ff66"
+ frame_idle "#1f5b32"
+ title "#b9ffcc"
+ title_idle "#1f5b32"
+
+ // a pane's own OSC 5577 buttons
+ button_fg "#04140a"
+ button_bg "#33ff66"
+ button_bg_idle "#1f5b32"
+
+ // affordances under the pointer
+ guide "#33ff66"
+ resize "#33ff66"
+ drop_target "#33ff66"
+
+ // scrollback indicator
+ scroll_fg "#04140a"
+ scroll_bg "#33ff66"
+
+ // rows of a tab collapsed into a list
+ header "#1f5b32"
+ header_hover "#33ff66"
+ header_hover_title "#b9ffcc"
+
+ // the strip along the top
+ tab_active_fg "#04140a"
+ tab_active_bg "#33ff66"
+ tab_active_hover_fg "#b9ffcc"
+ tab_idle "#1f5b32"
+ tab_hover "#33ff66"
+ prefix_fg "#04140a"
+ prefix_bg "#33ff66"
+ tab_count "#1f5b32"
+
+ // the line along the bottom
+ status "#1f5b32"
+ status_state "#b9ffcc"
+ hint "#b9ffcc"
+
+ // the pane finder
+ finder_fg "#b9ffcc"
+ finder_bg "#1f5b32"
+ finder_sel_fg "#04140a"
+ finder_sel_bg "#33ff66"
+
+ // rename editor, bell, frame buttons, the minimised bar
+ rename_fg "#04140a"
+ rename_bg "#33ff66"
+ toast_fg "#04140a"
+ toast_bg "#33ff66"
+ bell "#33ff66"
+ pane_button "#1f5b32"
+ pane_button_hover "#33ff66"
+ minbar "#1f5b32"
+ minbar_hover "#33ff66"
+}
diff --git a/contrib/themes/sl0p.kdl b/contrib/themes/sl0p.kdl
new file mode 100644
index 0000000..693754b
--- /dev/null
+++ b/contrib/themes/sl0p.kdl
@@ -0,0 +1,66 @@
+// sl0p -- the default: hot pink on near-black
+//
+// Drop into ~/.config/sl0ppty/config.kdl, or point $SL0PPTY_CONFIG at a file
+// that contains it. A running session re-reads it the moment you save.
+theme {
+ // what a shader takes "terminal default" to mean
+ default_fg "#ffffff"
+ default_bg "#000000"
+
+ // panes
+ frame_focus "#ff5fd7"
+ frame_idle "#45454a"
+ title "#ffffff"
+ title_idle "#45454a"
+
+ // a pane's own OSC 5577 buttons
+ button_fg "#141418"
+ button_bg "#ff5fd7"
+ button_bg_idle "#45454a"
+
+ // affordances under the pointer
+ guide "#ff5fd7"
+ resize "#ff5fd7"
+ drop_target "#ff5fd7"
+
+ // scrollback indicator
+ scroll_fg "#141418"
+ scroll_bg "#ff5fd7"
+
+ // rows of a tab collapsed into a list
+ header "#45454a"
+ header_hover "#ff5fd7"
+ header_hover_title "#ffffff"
+
+ // the strip along the top
+ tab_active_fg "#141418"
+ tab_active_bg "#ff5fd7"
+ tab_active_hover_fg "#ffffff"
+ tab_idle "#45454a"
+ tab_hover "#ff5fd7"
+ prefix_fg "#141418"
+ prefix_bg "#ff5fd7"
+ tab_count "#45454a"
+
+ // the line along the bottom
+ status "#45454a"
+ status_state "#ffffff"
+ hint "#ffffff"
+
+ // the pane finder
+ finder_fg "#ffffff"
+ finder_bg "#45454a"
+ finder_sel_fg "#141418"
+ finder_sel_bg "#ff5fd7"
+
+ // rename editor, bell, frame buttons, the minimised bar
+ rename_fg "#141418"
+ rename_bg "#ff5fd7"
+ toast_fg "#141418"
+ toast_bg "#ff5fd7"
+ bell "#ff5fd7"
+ pane_button "#45454a"
+ pane_button_hover "#ff5fd7"
+ minbar "#45454a"
+ minbar_hover "#ff5fd7"
+}
diff --git a/contrib/themes/slate.kdl b/contrib/themes/slate.kdl
new file mode 100644
index 0000000..57d837b
--- /dev/null
+++ b/contrib/themes/slate.kdl
@@ -0,0 +1,66 @@
+// slate -- muted blues, for looking at all day
+//
+// Drop into ~/.config/sl0ppty/config.kdl, or point $SL0PPTY_CONFIG at a file
+// that contains it. A running session re-reads it the moment you save.
+theme {
+ // what a shader takes "terminal default" to mean
+ default_fg "#c0caf5"
+ default_bg "#1a1b26"
+
+ // panes
+ frame_focus "#7aa2f7"
+ frame_idle "#3b4261"
+ title "#c0caf5"
+ title_idle "#3b4261"
+
+ // a pane's own OSC 5577 buttons
+ button_fg "#11131c"
+ button_bg "#7aa2f7"
+ button_bg_idle "#3b4261"
+
+ // affordances under the pointer
+ guide "#7aa2f7"
+ resize "#7aa2f7"
+ drop_target "#7aa2f7"
+
+ // scrollback indicator
+ scroll_fg "#11131c"
+ scroll_bg "#7aa2f7"
+
+ // rows of a tab collapsed into a list
+ header "#3b4261"
+ header_hover "#7aa2f7"
+ header_hover_title "#c0caf5"
+
+ // the strip along the top
+ tab_active_fg "#11131c"
+ tab_active_bg "#7aa2f7"
+ tab_active_hover_fg "#c0caf5"
+ tab_idle "#3b4261"
+ tab_hover "#7aa2f7"
+ prefix_fg "#11131c"
+ prefix_bg "#7aa2f7"
+ tab_count "#3b4261"
+
+ // the line along the bottom
+ status "#3b4261"
+ status_state "#c0caf5"
+ hint "#c0caf5"
+
+ // the pane finder
+ finder_fg "#c0caf5"
+ finder_bg "#3b4261"
+ finder_sel_fg "#11131c"
+ finder_sel_bg "#7aa2f7"
+
+ // rename editor, bell, frame buttons, the minimised bar
+ rename_fg "#11131c"
+ rename_bg "#7aa2f7"
+ toast_fg "#11131c"
+ toast_bg "#7aa2f7"
+ bell "#7aa2f7"
+ pane_button "#3b4261"
+ pane_button_hover "#7aa2f7"
+ minbar "#3b4261"
+ minbar_hover "#7aa2f7"
+}
diff --git a/tests/test_themes.py b/tests/test_themes.py
new file mode 100644
index 0000000..5e21bc7
--- /dev/null
+++ b/tests/test_themes.py
@@ -0,0 +1,59 @@
+#!/usr/bin/env python3
+"""The themes in contrib/.
+
+Mostly a guard: a theme file that has fallen behind the config silently keeps
+the compiled-in default for whatever it forgot, which looks like a bug in the
+theme rather than a missing line in it.
+"""
+import glob
+import os
+import re
+import sys
+
+from harness import Session, check, report
+
+SH = ["/bin/sh", "-c", 'printf "\\033]2;p\\007"; stty raw -echo; cat']
+HERE = os.path.dirname(os.path.abspath(__file__))
+THEMES = sorted(glob.glob(os.path.join(HERE, "..", "contrib", "themes", "*.kdl")))
+
+
+def declared_in_config():
+ src = open(os.path.join(HERE, "..", "src", "config.c")).read()
+ return set(re.findall(r'\{"([a-z_]+)", &c->', src))
+
+
+def set_by(path):
+ return set(re.findall(r'^ ([a-z_]+) "', open(path).read(), re.M))
+
+
+def test_there_are_themes():
+ check("contrib ships some", len(THEMES) >= 3, str(THEMES))
+
+
+def test_each_sets_every_colour_the_config_knows():
+ want = declared_in_config()
+ for t in THEMES:
+ missing = want - set_by(t)
+ check(f"{os.path.basename(t)} sets all {len(want)} of them",
+ not missing, "missing: " + ", ".join(sorted(missing)))
+
+
+def test_each_one_parses_and_reaches_the_screen():
+ for t in THEMES:
+ declared = dict(re.findall(r'^ ([a-z_]+) "(#[0-9a-fA-F]{6})"',
+ open(t).read(), re.M))
+ with Session(SH, cols=50, rows=10, config=os.path.abspath(t)) as s:
+ s.settle(30)
+ snap, p = s.snapshot(), s.pane()
+ drawn = (snap.style_at(p["x"], p["y"] + 1) or {}).get("fg")
+ check(f"{os.path.basename(t)} draws its own frame colour",
+ drawn == declared["frame_focus"],
+ f"{drawn} != {declared['frame_focus']}")
+ check(f"{os.path.basename(t)} leaves the session alive", s.alive(), "")
+
+
+if __name__ == "__main__":
+ test_there_are_themes()
+ test_each_sets_every_colour_the_config_knows()
+ test_each_one_parses_and_reaches_the_screen()
+ sys.exit(report())