aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-09-22 12:42:15 +0200
committeruser <user@clank>2026-09-22 12:42:15 +0200
commitfaa3fb6ee575a900abc574b0f9ed5ff291761837 (patch)
treece644998c144687e205d1f4e97e6f999de98723b
parenttabs: idle tabs you can actually read across the room (diff)
downloadslosh-faa3fb6ee575a900abc574b0f9ed5ff291761837.tar.gz
slosh-faa3fb6ee575a900abc574b0f9ed5ff291761837.tar.xz
slosh-faa3fb6ee575a900abc574b0f9ed5ff291761837.zip
theme: a name and a directory, not sixty colours in your config
Wearing a theme meant pasting its `theme { }` block into config.kdl, or including a path to a file you had copied there yourself. Both make the palette a literal in your config, and a literal is the one thing you cannot change cheaply: not from a script, not from a running session, not by a list of what is available, and never again from upstream -- the colours you pasted are the colours that theme had the day you pasted them. So a theme is a file in a directory now, and a config names it: theme_name "phosphor" theme { frame_focus "#00ff88" } // ...but that one colour is mine theme_name is applied after the includes and before everything the file says itself, which is the rule includes already follow and for the same reason: the loader reads a document by asking it for names rather than walking it in order, so "here" is not a position it could honour, and "what you asked for is the base, what you wrote is yours" is the rule worth having. Two directories, yours first: `themes/` beside the root config -- beside the file that was loaded, so a config somewhere else (SLOSH_CONFIG, or one handed to --check) takes its themes with it -- and then <prefix>/share/slosh/themes, compiled in from PREFIX. install(1) has been putting themes there all along and nothing could read them; now a shipped theme is nameable without being copied, and a file of yours with the same name shadows it, which is how a root-owned theme gets edited. A name, not a path. The directory is what makes a theme switchable, listable and shadowable, and a path silently does none of those, so `theme_name "../x.kdl"` is refused with a line pointing at `include`, which still works and is still the answer for a file somewhere a directory of names cannot reach. A theme that is not there is one complaint and no more (D9): the rest of the config applies, because losing your keybindings over a mistyped colour scheme would be the wrong trade. A theme file is read by the same loader, so it is watched like any other: saving one reloads the session wearing it, and the derivations (tab_status_fg, tab_idle_bg) run over the theme's own colours exactly as before. Switching without editing anything, over the socket a session already has: slosh -s main cmd theme # amber default* ... slosh -s main cmd '{"cmd":"theme","name":"amber"}' # now slosh -s main cmd '{"cmd":"theme","name":"amber","save":true}' The session holds the name over later reloads, so saving your config for an unrelated reason does not put the old colours back; `save` writes the theme_name line into the file, replacing the one already there and leaving every other line and comment alone -- dumping the whole config back over itself would reformat a hand-written file and drop its comments, which is not a fair price for changing a colour scheme. A name is tried on a throwaway config first, so a typo costs nothing. An empty name gives the answer back to the file. --dump-config stops writing colours: it writes the theme's name and the colours a config overrode by hand, so a dumped config keeps following its theme instead of freezing it. --dump-theme is the other half and is new: the resolved palette as a theme file, which is how you start one from the session in front of you rather than from an empty file. It is also what keeps contrib/themes/default.kdl honest -- that file is now the annotated reference (the commented palette moved there out of config.kdl, where it was 119 lines of colour in the middle of the prose), and a test compares it against the compiled-in one. While in there: tests/test_themes.py's "every theme sets every colour" guard had been grepping for `&c->`, the spelling before the colour table moved to offsetof, so it had been finding zero colours and passing for months. Fixed, with the derived and opt-in colours exempted for the reasons they are derived and opt-in, and with a check that the guard found a table at all.
-rw-r--r--Makefile19
-rw-r--r--config/config.kdl154
-rwxr-xr-xcontrib/theme-tour26
-rw-r--r--contrib/themes/default.kdl150
-rw-r--r--docs/config.md59
-rw-r--r--docs/slosh.121
-rw-r--r--docs/slosh.561
-rw-r--r--include/app.h17
-rw-r--r--include/config.h63
-rw-r--r--src/app.c66
-rw-r--r--src/cmd.c69
-rw-r--r--src/config.c335
-rw-r--r--src/main.c11
-rw-r--r--tests/test_dump_config.py37
-rw-r--r--tests/test_theme_names.py321
-rw-r--r--tests/test_themes.py86
-rw-r--r--web/demo/overlay/root/.config/slosh/config.kdl2
17 files changed, 1276 insertions, 221 deletions
diff --git a/Makefile b/Makefile
index a149202..dd94364 100644
--- a/Makefile
+++ b/Makefile
@@ -36,8 +36,14 @@ VT_INC := $(VT_OUT)/include
# all -> ./VERSION verbatim, which is how a cgit snapshot / Homebrew build sees it).
VERSION := $(shell contrib/version)
+# Where `install` will put the shipped themes, compiled in so that a named
+# theme (`theme_name "phosphor"`) can be found without being copied into your
+# config directory first. PREFIX only, never DESTDIR: DESTDIR is a staging root
+# a package is built in, not a path the program will ever see.
+PREFIX ?= /usr/local
+
CFLAGS := -std=c23 -O1 -g -Wall -Wextra -Wno-unused-parameter -MMD -MP \
- -I$(VT_INC) -Iinclude -Ibuild
+ -I$(VT_INC) -Iinclude -Ibuild -DSLOSH_DATADIR='"$(PREFIX)/share/slosh"'
LDFLAGS :=
SRC := $(wildcard src/*.c)
@@ -454,11 +460,12 @@ release: release-check release-linux release-windows ## full release: linux tarb
# ── installation ────────────────────────────────────────────────────────────
# For packagers (Homebrew's formula mirrors this layout) and for anyone doing
# `sudo make install`. DESTDIR is the staging root a package manager wants;
-# PREFIX is where the files will finally live. The binary is the only thing
-# slosh needs to run -- the rest is example configuration you may copy into
-# ~/.config/slosh, so it goes to share/ and nothing reads it from there. The
-# manpage goes where man(1) already looks, which is the whole point of one.
-PREFIX ?= /usr/local
+# PREFIX (set near CFLAGS, because the binary is compiled knowing it) is where
+# the files will finally live. The binary is the only thing slosh needs to run;
+# of the rest, share/slosh/themes is the one directory it reads by itself --
+# `theme_name` looks there after your own themes directory -- and the example
+# config and layout are still yours to copy. The manpage goes where man(1)
+# already looks, which is the whole point of one.
DESTDIR ?=
BINDIR := $(DESTDIR)$(PREFIX)/bin
SHAREDIR := $(DESTDIR)$(PREFIX)/share/slosh
diff --git a/config/config.kdl b/config/config.kdl
index fc6fb3b..147b709 100644
--- a/config/config.kdl
+++ b/config/config.kdl
@@ -799,130 +799,40 @@ splash_ms 900
// ── theme ───────────────────────────────────────────────────────────────────
-// Every colour the compositor draws has its own name here. They started as six
-// names doing thirty jobs, which meant the split guide could not be recoloured
-// without also recolouring the focused frame, because they were the same
-// entry. Several still share a default: that says they look right together,
-// not that they are the same thing.
-theme {
- // What a cell's "terminal default" colour is taken to be when a shader has
- // to compute on it. See the shaders section above.
- default_fg "#ffffff"
- default_bg "#000000"
-
- // ── panes ───────────────────────────────────────────────────────────────
- frame_focus "#7aa2f7" // border of the focused pane
- frame_idle "#45454a" // border of every other pane
- title "#ffffff" // its title, and its OSC 5577 status text
- title_idle "#45454a" // the same, on a pane that is not focused
-
- // A pane's own OSC 5577 buttons.
- button_fg "#141418"
- button_bg "#7aa2f7"
- button_bg_idle "#55555c" // buttons on a pane that is not focused
-
- // ── things that appear under the pointer ────────────────────────────────
- guide "#7aa2f7" // split guide: armed edge, handle, its arrow, boundary
- resize "#7aa2f7" // the handle in the gap between two panes
- drop_target "#7aa2f7" // frame of a pane a drag could land on
-
- // ── a pane's scrollback indicator ───────────────────────────────────────
- scroll_fg "#141418"
- scroll_bg "#7aa2f7"
-
- // ── rows of a tab that has collapsed into a list ────────────────────────
- header "#45454a"
- header_hover "#7aa2f7" // the rule, under the pointer
- header_hover_title "#ffffff" // the name, under the pointer
-
- // ── the strip along the top ─────────────────────────────────────────────
- // The tab you are in is filled rather than merely bold: bold alone is easy
- // to miss on a strip of short labels. It keeps that fill while the pointer
- // is on it -- it is still the tab you are in -- so its hover lands on the
- // text instead.
- tab_active_fg "#141418"
- tab_active_bg "#7aa2f7"
- tab_active_hover_fg "#ffffff"
- // Brighter than the grey on idle frames and titles: a tab you are not in
- // is a place to go, and has to read from across the screen.
- tab_idle "#8a8a95"
- // Every other tab is a plate too, 43% of the way from the background to
- // the grey above: a tab with edges rather than a word floating on the
- // background, and something for the active fill to be brighter *than*.
- // That fraction is a contrast ratio in disguise -- about 1.9:1 against
- // the background whether the theme is dark or light. Unset, it is mixed
- // from those two, so a theme gets one in its own family without naming
- // it. Name your own background here for bare tabs.
- tab_idle_bg "#3b3b40"
- tab_hover "#7aa2f7"
- prefix_fg "#141418" // the "C-a" badge, while the prefix is pending
- prefix_bg "#7aa2f7"
- tab_count "#45454a" // panes in the whole session
-
- // ── the line along the bottom ───────────────────────────────────────────
- status "#45454a" // session, tab, pane, and the pane index
- status_state "#ffffff" // scrolled / alt screen / not started / exited
- hint "#ffffff" // what the pointer is on, in the middle
-
- // ── marks on a pane's frame ─────────────────────────────────────────────
- bell "#e0af68" // a pane rang and has not been looked at:
- // amber, not the accent -- attention, not focus
- dead "#ff875f" // its program exited: the line in its backlog,
- // and the words in its frame and the status line
- pane_button "#45454a" // the frame's own close/zoom/minimise marks
- pane_button_hover "#7aa2f7"
-
- // ── the row of minimised panes ──────────────────────────────────────────
- minbar "#45454a"
- minbar_hover "#7aa2f7"
-
- // ── modals: a surface that floats over the layout ───────────────────────
- // Named apart from the pane colours they resemble because a modal is a
- // different surface: opaque, on top of everything, and legible against a
- // background that has been dimmed underneath it. (The first version drew
- // the close button in the pane button colour on the finder background,
- // which are the same value — an invisible button until you hovered it.)
- modal_bg "#1c1c22" // a shade lighter than the dimmed screen
- modal_fg "#e8e8ea"
- modal_border "#7aa2f7"
- modal_title "#ffffff"
- modal_button "#8a8a95" // visible *without* the pointer on it
- modal_button_hover "#7aa2f7"
-
- // ── the pane finder ─────────────────────────────────────────────────────
- // The finder is a modal, so its box is the modal colours above; these are
- // the parts only it has. `finder_bg` is the query field and the row under
- // the pointer — a band that is not the box, so typing and hovering both
- // have somewhere to show.
- finder_fg "#ffffff"
- finder_bg "#45454a"
- finder_sel_fg "#141418"
- finder_sel_bg "#7aa2f7" // the row enter would take
-
- // ── scrollback search (the `/` bar) ────────────────────────────────
- // Every match on screen is tinted `search_*`; the one the bar is on gets
- // `search_cur_*` instead, so "this one" reads apart from "all of them".
- // Warm gold by default, because a match is a different thing from a
- // selection (finder) or focus (accent) and should not be mistaken for one.
- search_fg "#ffffff"
- search_bg "#4d4124" // the other matches
- search_cur_fg "#141418"
- search_cur_bg "#f2c97a" // the one ↑↓ and enter act on
-
- // ── the in-place editor a double-click opens on a pane title ────────────
- rename_fg "#141418"
- rename_bg "#7aa2f7"
+// A theme is a file of colours in a directory, named here rather than pasted
+// in. Named, because a name is a thing you can change: `theme_name` is one
+// line to edit, one `{"cmd":"theme","name":"amber"}` to switch while the
+// session runs, and one entry in a list a picker can show you. Sixty colours
+// pasted into this file are none of those things -- and they freeze the
+// palette at the moment you pasted them, so every later improvement to the
+// theme you copied from is one you never see.
+//
+// theme_name "phosphor"
- // ── announcements ───────────────────────────────────────────────────────
- toast_fg "#141418"
- toast_bg "#7aa2f7"
+// Where those files are looked for. Unset, it is `themes/` beside this file
+// (so ~/.config/slosh/themes for the usual config), and then the ones slosh
+// installed in <prefix>/share/slosh/themes -- a shipped theme is nameable
+// without being copied first, and a theme of yours with the same name shadows
+// it. Yours is searched first for exactly that reason.
+//
+// theme_dir "~/.config/slosh/themes"
- // The "2 clients" tag when several terminals share the session. Muted by
- // default -- standing information, not an announcement. attach_bg unset
- // means the terminal's own background.
- attach_fg "#45454a"
- // attach_bg "#141418"
-}
+// A theme file *is* a config file -- one `theme { }` block in it and nothing
+// else -- which is why `slosh --check` lints one, why saving one reloads the
+// session wearing it, and why `include` still works for a theme that lives
+// somewhere a directory of names cannot reach.
+//
+// The shipped `default` theme is the annotated reference: every colour with
+// what it is for, and it matches what no config at all gets you. To start one
+// from the session in front of you:
+//
+// slosh --dump-theme > ~/.config/slosh/themes/mine.kdl
+//
+// Anything written in a `theme { }` block here beats the named theme, colour
+// by colour, so "that one, but with my green frames" is two lines:
+//
+// theme_name "phosphor"
+// theme { frame_focus "#00ff88" }
// ── keys ────────────────────────────────────────────────────────────────────
//
diff --git a/contrib/theme-tour b/contrib/theme-tour
index d816cfe..3b4a4ce 100755
--- a/contrib/theme-tour
+++ b/contrib/theme-tour
@@ -5,9 +5,13 @@
# 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.
+# One control-socket call per theme (`cmd theme`), which is the same thing a
+# person does with one line of their config -- the tour used to copy a theme
+# file over the session's config and wait for the watcher to notice, which
+# demonstrated the watcher rather than the themes.
+#
+# THEME_DIR points the session at this repo's themes, so the tour shows what is
+# in the checkout rather than what happens to be installed.
set -e
here=$(cd "$(dirname "$0")" && pwd)
bin="$here/../build/slosh"
@@ -16,23 +20,23 @@ CONF=${SLOSH_CONFIG:-/tmp/slosh-theme-tour.kdl}
DWELL=${DWELL:-4}
apply() {
- cp "$here/themes/$1.kdl" "$CONF"
+ "$bin" -s "$SESSION" cmd "theme $1" >/dev/null
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"
+ printf 'theme_dir "%s"\ntheme_name "sl0p"\n' "$here/themes" >"$CONF"
echo "starting a session called $SESSION..."
SLOSH_CONFIG="$CONF" "$bin" -s "$SESSION" -- "${SHELL:-/bin/sh}" &
sleep 1
fi
+if [ -n "$1" ]; then
+ [ -f "$here/themes/$1.kdl" ] || { echo "no such theme: $1"; exit 1; }
+ apply "$1"
+ exit 0
+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)"
diff --git a/contrib/themes/default.kdl b/contrib/themes/default.kdl
index 5924e82..fdd4479 100644
--- a/contrib/themes/default.kdl
+++ b/contrib/themes/default.kdl
@@ -1,91 +1,139 @@
// default -- what no config gets you: muted blue on neutral dark
//
-// This file exists so the tour can show the starting point and so another
-// theme is one include-swap away from coming back. It matches the compiled-in
-// palette exactly (`slosh --dump-config` is the authority if they ever drift).
+// The reference theme, and the annotated one: every colour the compositor
+// draws is named here, with what it is for. Copy this file to
+// ~/.config/slosh/themes/mine.kdl, change what you like, and put
+// `theme_name "mine"` in your config -- or name one of the others beside it.
+//
+// It matches the compiled-in palette exactly, so `theme_name "default"` and
+// naming no theme at all are the same session (`slosh --dump-config` is the
+// authority if they ever drift).
+//
+// The names started as six doing thirty jobs, which meant the split guide
+// could not be recoloured without also recolouring the focused frame, because
+// they were the same entry. Several still share a value: that says they look
+// right together, not that they are the same thing.
//
// Two colours mean something rather than match something: `bell` is amber
// (attention, not focus) and `dead` is warm red-orange. Everything else that
// says "this one" -- the focused frame, the guide, the active tab -- is one
// muted blue, calm enough to frame text without shouting over it.
theme {
- // what a shader takes "terminal default" to mean
+ // What a cell's "terminal default" colour is taken to be when a shader has
+ // to compute on it. See the shaders chapter (docs/shaders.md).
default_fg "#ffffff"
default_bg "#000000"
- // panes
- frame_focus "#7aa2f7"
- frame_idle "#45454a"
- title "#ffffff"
- title_idle "#45454a"
+ // ── panes ───────────────────────────────────────────────────────────────
+ frame_focus "#7aa2f7" // border of the focused pane
+ frame_idle "#45454a" // border of every other pane
+ title "#ffffff" // its title, and its OSC 5577 status text
+ title_idle "#45454a" // the same, on a pane that is not focused
- // a pane's own OSC 5577 buttons
+ // A pane's own OSC 5577 buttons.
button_fg "#141418"
button_bg "#7aa2f7"
- button_bg_idle "#55555c"
+ button_bg_idle "#55555c" // buttons on a pane that is not focused
- // affordances under the pointer
- guide "#7aa2f7"
- resize "#7aa2f7"
- drop_target "#7aa2f7"
+ // ── things that appear under the pointer ────────────────────────────────
+ guide "#7aa2f7" // split guide: armed edge, handle, its arrow, boundary
+ resize "#7aa2f7" // the handle in the gap between two panes
+ drop_target "#7aa2f7" // frame of a pane a drag could land on
- // scrollback indicator
+ // ── a pane's scrollback indicator ───────────────────────────────────────
scroll_fg "#141418"
scroll_bg "#7aa2f7"
- // rows of a tab collapsed into a list
+ // ── rows of a tab that has collapsed into a list ────────────────────────
header "#45454a"
- header_hover "#7aa2f7"
- header_hover_title "#ffffff"
+ header_hover "#7aa2f7" // the rule, under the pointer
+ header_hover_title "#ffffff" // the name, under the pointer
- // the strip along the top, or the sidebar down the side
+ // ── the strip along the top ─────────────────────────────────────────────
+ // The tab you are in is filled rather than merely bold: bold alone is easy
+ // to miss on a strip of short labels. It keeps that fill while the pointer
+ // is on it -- it is still the tab you are in -- so its hover lands on the
+ // text instead.
tab_active_fg "#141418"
tab_active_bg "#7aa2f7"
tab_active_hover_fg "#ffffff"
+ // Brighter than the grey on idle frames and titles: a tab you are not in
+ // is a place to go, and has to read from across the screen.
tab_idle "#8a8a95"
+ // Every other tab is a plate too, 43% of the way from the background to
+ // the grey above: a tab with edges rather than a word floating on the
+ // background, and something for the active fill to be brighter *than*.
+ // That fraction is a contrast ratio in disguise -- about 1.9:1 against
+ // the background whether the theme is dark or light. Unset, it is mixed
+ // from those two, so a theme gets one in its own family without naming
+ // it. Name your own background here for bare tabs.
+ tab_idle_bg "#3b3b40"
tab_hover "#7aa2f7"
- prefix_fg "#141418"
+ prefix_fg "#141418" // the "C-a" badge, while the prefix is pending
prefix_bg "#7aa2f7"
- tab_count "#45454a"
- // a sidebar's status rows, part way from tab_count to tab_hover: the live
- // thing in the sidebar, so not the flat grey of the chrome round it, and
- // not the full accent either. Unset, it is mixed from those two, so a
- // theme gets a status colour in its own family without naming one.
- // tab_status_bg is unset -- a band is opt-in, and none leaves a
- // translucent terminal's background alone.
- tab_status_fg "#5a6a8f"
+ tab_count "#45454a" // panes in the whole session
+
+ // ── the line along the bottom ───────────────────────────────────────────
+ status "#45454a" // session, tab, pane, and the pane index
+ status_state "#ffffff" // scrolled / alt screen / not started / exited
+ hint "#ffffff" // what the pointer is on, in the middle
- // the line along the bottom
- status "#45454a"
- status_state "#ffffff"
- hint "#ffffff"
+ // ── marks on a pane's frame ─────────────────────────────────────────────
+ bell "#e0af68" // a pane rang and has not been looked at:
+ // amber, not the accent -- attention, not focus
+ dead "#ff875f" // its program exited: the line in its backlog,
+ // and the words in its frame and the status line
+ pane_button "#45454a" // the frame's own close/zoom/minimise marks
+ pane_button_hover "#7aa2f7"
+
+ // ── the row of minimised panes ──────────────────────────────────────────
+ minbar "#45454a"
+ minbar_hover "#7aa2f7"
+
+ // ── modals: a surface that floats over the layout ───────────────────────
+ // Named apart from the pane colours they resemble because a modal is a
+ // different surface: opaque, on top of everything, and legible against a
+ // background that has been dimmed underneath it. (The first version drew
+ // the close button in the pane button colour on the finder background,
+ // which are the same value — an invisible button until you hovered it.)
+ modal_bg "#1c1c22" // a shade lighter than the dimmed screen
+ modal_fg "#e8e8ea"
+ modal_border "#7aa2f7"
+ modal_title "#ffffff"
+ modal_button "#8a8a95" // visible *without* the pointer on it
+ modal_button_hover "#7aa2f7"
- // the pane finder
+ // ── the pane finder ─────────────────────────────────────────────────────
+ // The finder is a modal, so its box is the modal colours above; these are
+ // the parts only it has. `finder_bg` is the query field and the row under
+ // the pointer — a band that is not the box, so typing and hovering both
+ // have somewhere to show.
finder_fg "#ffffff"
finder_bg "#45454a"
finder_sel_fg "#141418"
- finder_sel_bg "#7aa2f7"
+ finder_sel_bg "#7aa2f7" // the row enter would take
+
+ // ── scrollback search (the `/` bar) ────────────────────────────────
+ // Every match on screen is tinted `search_*`; the one the bar is on gets
+ // `search_cur_*` instead, so "this one" reads apart from "all of them".
+ // Warm gold by default, because a match is a different thing from a
+ // selection (finder) or focus (accent) and should not be mistaken for one.
+ search_fg "#ffffff"
+ search_bg "#4d4124" // the other matches
+ search_cur_fg "#141418"
+ search_cur_bg "#f2c97a" // the one ↑↓ and enter act on
- // rename editor, bell, dead panes, frame buttons, the minimised bar
+ // ── the in-place editor a double-click opens on a pane title ────────────
rename_fg "#141418"
rename_bg "#7aa2f7"
+
+ // ── announcements ───────────────────────────────────────────────────────
toast_fg "#141418"
toast_bg "#7aa2f7"
- // the "N clients" tag: the theme's muted chrome tone, quiet on purpose
+ // The "2 clients" tag when several terminals share the session. Muted by
+ // default -- standing information, not an announcement. attach_bg unset
+ // means the terminal's own background.
attach_fg "#45454a"
- bell "#e0af68"
- dead "#ff875f"
- pane_button "#45454a"
- pane_button_hover "#7aa2f7"
- minbar "#45454a"
- minbar_hover "#7aa2f7"
-
- // a modal floating over the layout (the cheatsheet, C-a ?)
- modal_bg "#1c1c22"
- modal_fg "#e8e8ea"
- modal_border "#7aa2f7"
- modal_title "#ffffff"
- modal_button "#8a8a95"
- modal_button_hover "#7aa2f7"
+ // attach_bg "#141418"
}
diff --git a/docs/config.md b/docs/config.md
index fa287f1..6cee3d2 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -65,19 +65,62 @@ include "keys/vim.kdl" "shaders/crt.kdl"
## Themes
-Every colour the compositor draws has its own name under `theme { }`.
-
-Six ready-made themes are in
-`contrib/themes`:
-`amber`, `mono`, `paper`, `phosphor`, `sl0p`, `slate`, plus `default`, the
-compiled-in palette written out, so another theme is one include-swap away from
-coming back. Include one and put your own two lines on top:
+A theme is a file of colours in a directory, and a config names it:
```kdl
-include "~/.config/slosh/themes/phosphor.kdl"
+theme_name "phosphor" // themes/phosphor.kdl
theme { frame_focus "#00ff88" } // ...but that one colour is mine
```
+**Named rather than pasted, because a name is a thing you can change.** It is
+one line to edit, one `{"cmd":"theme","name":"amber"}` to switch while the
+session is running, and one row in a list something can show you. Sixty colours
+pasted into a config are none of those — and they freeze the palette at the
+moment they were pasted, so every later improvement to the theme they came from
+is one you never see.
+
+**Two directories, yours first.** `themes/` beside your config (so
+`~/.config/slosh/themes` for the usual one), then the themes slosh installed in
+`<prefix>/share/slosh/themes`. A shipped theme is therefore nameable without
+being copied anywhere, and a file of yours with the same name shadows it —
+which is how you edit a shipped theme without touching a root-owned file. Point
+`theme_dir` somewhere else and that replaces the first of the two, never the
+shipped set.
+
+`theme_name` takes a **name, not a path**: the directory is what makes a theme
+switchable, listable and shadowable, and a path quietly does none of that. For
+a file somewhere a directory of names cannot reach, `include` is unchanged and
+still works.
+
+Seven themes ship in `contrib/themes`: `amber`, `mono`, `paper`, `phosphor`,
+`sl0p`, `slate`, and `default` — the annotated reference, every colour with
+what it is for, matching exactly what no config at all gets you. A theme file
+*is* a config file with one `theme { }` block in it, so `slosh --check` lints
+one and saving one reloads the session wearing it.
+
+Start your own from the session in front of you rather than from an empty file:
+
+```bash
+slosh --dump-theme > ~/.config/slosh/themes/mine.kdl # the palette in force
+```
+
+`--dump-config` writes `theme_name` and the colours you overrode, not the
+resolved palette, so a dumped config keeps following its theme.
+
+**Switching without editing anything:**
+
+```bash
+slosh -s main cmd theme # amber default* mono ...
+slosh -s main cmd '{"cmd":"theme","name":"amber"}' # now
+slosh -s main cmd '{"cmd":"theme","name":"amber","save":true}' # ...and from now on
+```
+
+The session holds the name over later reloads, so editing your config does not
+put the old colours back; `save` writes the `theme_name` line into the config,
+replacing the one already there, and leaves every other line and comment alone.
+`{"cmd":"theme","name":""}` drops the session's own answer and goes back to
+whatever the file says.
+
## The knobs, briefly
| group | what is in it |
diff --git a/docs/slosh.1 b/docs/slosh.1
index 6436f6a..aba19ac 100644
--- a/docs/slosh.1
+++ b/docs/slosh.1
@@ -27,6 +27,8 @@ slosh \- a terminal multiplexer
.br
.B slosh \-\-dump\-config
.br
+.B slosh \-\-dump\-theme
+.br
.B slosh \-\-headless
|
.B \-\-script
@@ -120,6 +122,25 @@ The values are the defaults, not the ones currently in effect:
this is the file to begin from, and
.B \-\-check
is the one that reads yours back.
+Colour is the exception: it prints the
+.B theme_name
+in force and the colours a configuration overrides by hand, not the
+resolved palette, so a dumped configuration keeps following its theme.
+.RE
+.TP
+.B \-\-dump\-theme
+Print the palette in force as a theme file: every colour the
+compositor draws, derivations resolved.
+.RS
+.PP
+.EX
+slosh \-\-dump\-theme > ~/.config/slosh/themes/mine.kdl
+.EE
+.PP
+starts a theme from the session in front of you rather than from an
+empty file; name it with
+.B theme_name
+to wear it.
.RE
.TP
.B \-\-headless
diff --git a/docs/slosh.5 b/docs/slosh.5
index 70cfa08..21315e7 100644
--- a/docs/slosh.5
+++ b/docs/slosh.5
@@ -302,20 +302,52 @@ Unset, the workspaces feature is dormant.
A layout applied to projects that do not carry their own
.IR slosh.layout .
.SH THEME
-.B theme { }
-names every colour the compositor draws (frames, titles, tabs,
-buttons, modals, toasts), each its own entry, 48 of them, so one can
-change without dragging thirty others along.
+A theme is a file of colours in a directory, named rather than pasted:
.PP
.EX
-include "~/.config/slosh/themes/phosphor.kdl"
+theme_name "phosphor"
theme { frame_focus "#00ff88" } // ...but that one colour is mine
.EE
+.TP
+.BI theme_name\ name
+The theme to wear:
+.IR name .kdl
+from the theme directory, applied before the rest of the file, so a
+.B theme { }
+block beside it wins.
+A name, not a path \(em use
+.B include
+for a file somewhere else.
+Unset, the compiled\-in palette stands.
+.TP
+.BI theme_dir\ path
+Where those files live.
+Unset, it is
+.I themes/
+beside the configuration file, and then
+.IR PREFIX/share/slosh/themes/ ,
+so a shipped theme is nameable without being copied and one of yours
+with the same name shadows it.
.PP
-.B slosh \-\-dump\-config
-prints the full list with the compiled-in values; ready-made themes
-ship under
-.IR PREFIX/share/slosh/themes/ .
+.B theme { }
+names every colour the compositor draws (frames, titles, tabs,
+buttons, modals, toasts), each its own entry, so one can change without
+dragging thirty others along.
+A theme file is a configuration containing one of those blocks, which
+is why
+.B slosh \-\-check
+lints one and why saving it reloads the session.
+.PP
+The theme can also be changed while the session runs, over the control
+socket:
+.B \(aq{"cmd":"theme"}\(aq
+lists what is installed and what is worn,
+.B \(aq{"cmd":"theme","name":"amber"}\(aq
+switches now, and
+.B "save":true
+with it writes the
+.B theme_name
+line into the configuration so the switch outlives the session.
.SH SHADERS
A shader is a colour pass over a pane's cells: foreground, background,
attributes, never the text, because rewriting text would desync
@@ -488,9 +520,16 @@ for the full search order).
.TP
.I PREFIX/share/slosh/
The annotated default configuration and an example layout, plus the
-shipped themes, chrome presets and shader presets, each a file to
-copy or
+chrome and shader presets, each a file to copy or
.BR include .
+.TP
+.I PREFIX/share/slosh/themes/
+The shipped themes, read by name: see
+.BR theme_name .
+.TP
+.I ~/.config/slosh/themes/
+Yours, searched first, so a file here replaces a shipped theme of the
+same name.
.SH SEE ALSO
.BR slosh (1)
.PP
diff --git a/include/app.h b/include/app.h
index 106d54a..74b841d 100644
--- a/include/app.h
+++ b/include/app.h
@@ -59,6 +59,23 @@ char *app_take_clipboard(app_t *a);
/* Re-read the config file. Keeps the working one if the new file is broken. */
bool app_reload_config(char *err, size_t errcap);
+
+/* Switch the session to a named theme, now.
+ *
+ * The name is held by the session and re-applied over every later reload, so
+ * editing your config does not quietly put the old colours back. `save` also
+ * writes the `theme_name` line into the config file, which is the difference
+ * between "for now" and "from now on" -- a live switch that edited your config
+ * behind you would be rude, and one that vanished on the next reload would be
+ * useless, so it is a choice the caller makes. An empty name drops the
+ * session's own answer and goes back to whatever the file says. */
+bool app_set_theme(const char *name, bool save, char *err, size_t errcap);
+/* The theme in force, or "" when nobody has named one. */
+const char *app_theme(void);
+/* What there is to pick from, and where it was looked for. Through the app
+ * rather than the config, because the front ends do not own a config. */
+size_t app_themes(char (*out)[64], size_t max);
+size_t app_theme_dirs(char (*out)[512], size_t max);
/* The files the config in force was read from: the one that was loaded and
* everything it included, existing or not. What the watcher watches. */
size_t app_config_files(const char **out, size_t max);
diff --git a/include/config.h b/include/config.h
index e24817a..8d1917b 100644
--- a/include/config.h
+++ b/include/config.h
@@ -142,6 +142,16 @@ enum {
* problems in it has one problem, and the list is long enough to say so. */
#define CONFIG_MSGS_MAX 32
+/* Room for the colour table in config.c, checked against it there: the flags
+ * beside it live in the config, so the header needs a ceiling rather than the
+ * table. Generous, because overshooting costs one byte per unused slot. */
+#define THEME_COLORS_MAX 128
+
+/* How many named themes a picker will list, and how long a name may be. A
+ * theme is a file in a directory; both numbers are "more than anyone has". */
+#define THEMES_MAX 64
+#define THEME_NAME_MAX 64
+
typedef struct {
/* geometry */
uint16_t gap, gap_aspect;
@@ -571,6 +581,31 @@ typedef struct {
size_t nproject_roots;
int project_depth;
+ /* Named themes. `theme_name "phosphor"` reads `phosphor.kdl` out of the
+ * theme directory and applies it *before* the rest of the file, exactly as
+ * an include does: the theme is the base, anything you write beside it
+ * wins. NULL is "nobody asked", and the compiled-in palette stands.
+ *
+ * `theme_dir` is where to look, resolved against the file that said it when
+ * it is relative; NULL means the pair of directories config_theme_dirs
+ * derives -- `themes/` beside your config, then the ones `make install`
+ * put in `<prefix>/share/slosh/themes`. Two rather than one so a theme that
+ * ships with slosh is reachable by name without being copied first, and a
+ * theme of yours with the same name shadows it. */
+ char *theme_dir;
+ char *theme_name;
+
+ /* Which colours were named by hand rather than inherited from the theme the
+ * config asked for, indexed like the table in config.c. Only the dump reads
+ * it, and only so that `--dump-config` can write `theme_name` plus your two
+ * overrides instead of freezing sixty resolved colours into a file -- which
+ * would pin the theme's palette at the moment you dumped it and quietly
+ * make every later change to that theme invisible. */
+ bool theme_set[THEME_COLORS_MAX];
+ /* Loader scratch: true while a named theme file is being read, which is how
+ * the flags above tell "the theme said it" from "you said it". */
+ bool in_theme;
+
/* The layout a project with no file of its own opens as. Yours rather than a
* guess about your stack: relative paths in it bind to whichever project is
* being opened, so one file is the shape you start every project in. NULL is
@@ -603,6 +638,29 @@ typedef struct {
* it keeps the defaults and writes the reason to `err`. */
void config_defaults(config_t *c);
bool config_load(config_t *c, const char *path, char *err, size_t errcap);
+
+/* Apply a named theme over a config that is already loaded, as if the config
+ * had asked for it: the same lookup, the same file, the same derivations. How
+ * a live theme switch is made, and why switching is not a special path through
+ * the loader -- a theme applied at runtime and one named in a config have to
+ * end up at the same palette, or the session and its file disagree. */
+bool config_apply_theme(config_t *c, const char *name, char *err,
+ size_t errcap);
+
+/* Where named themes are looked for, in order. Absolute and expanded. */
+size_t config_theme_dirs(const config_t *c, char (*out)[512], size_t max);
+
+/* Every theme that can be named, sorted, each one listed once -- a name in an
+ * earlier directory shadows the same name in a later one, which is what makes
+ * a theme of yours able to replace a shipped one. */
+size_t config_themes(const config_t *c, char (*out)[THEME_NAME_MAX],
+ size_t max);
+
+/* Write `theme_name "x"` into a config file, replacing the line already there
+ * or adding one. For a switch somebody asked to keep: the session can hold a
+ * theme for itself, but only the file can hold it past a restart. */
+bool config_write_theme_name(const char *path, const char *name, char *err,
+ size_t errcap);
/* $SLOSH_CONFIG, else $XDG_CONFIG_HOME/slosh/config.kdl, else ~/.config/… */
const char *config_default_path(void);
/* Every complaint from the last load, oldest first: "config.kdl:12: ...".
@@ -676,6 +734,11 @@ char *config_render(const config_t *c);
/* The same, for a config nobody has edited: what a fresh install would do.
* Caller frees. */
char *config_dump_defaults(void);
+/* The palette in force as a theme file, which is how a theme is started from
+ * the one you are looking at. The config dump writes `theme_name` rather than
+ * sixty colours, so this is the only place they are all spelled out. */
+char *config_render_theme(const config_t *c);
+char *config_dump_theme(void);
const char *config_action_group(action_t a);
/* "ctrl+a", "alt+left", "\\", "f" -> key + mods. False if unparseable. */
bool config_parse_chord(const char *text, int *out_key, uint16_t *out_mods);
diff --git a/src/app.c b/src/app.c
index 74d49ff..c17831f 100644
--- a/src/app.c
+++ b/src/app.c
@@ -63,6 +63,12 @@ const char *app_config_complaint(void) {
return CFG_COMPLAINT;
}
+/* A theme this session was told to wear, which outranks the config file's own
+ * answer until it is cleared. Held here rather than in the config because a
+ * reload builds a whole new config from disk: anything that has to survive
+ * that cannot live inside the thing being replaced. */
+static char THEME_OVERRIDE[THEME_NAME_MAX];
+
bool app_reload_config(char *err, size_t errcap) {
config_t fresh;
config_defaults(&fresh);
@@ -71,6 +77,17 @@ bool app_reload_config(char *err, size_t errcap) {
config_free(&fresh);
return false; /* keep what works */
}
+ /* After the file, over whatever it asked for: a session that was switched
+ * to a theme stays switched when the config is saved. A name that has gone
+ * missing since (the file was deleted, the directory moved) complains like
+ * any other bad line and leaves the file's own colours standing -- it must
+ * not cost you the reload. */
+ if (THEME_OVERRIDE[0]) {
+ char terr[192] = {0};
+ if (!config_apply_theme(&fresh, THEME_OVERRIDE, terr, sizeof terr) && err &&
+ errcap && !err[0])
+ snprintf(err, errcap, "%s", terr[0] ? terr : "cannot apply that theme");
+ }
config_free(&CFG);
CFG = fresh;
CFG_LOADED = true;
@@ -79,6 +96,55 @@ bool app_reload_config(char *err, size_t errcap) {
return true;
}
+const char *app_theme(void) {
+ ensure_config();
+ return CFG.theme_name ? CFG.theme_name : "";
+}
+
+size_t app_themes(char (*out)[THEME_NAME_MAX], size_t max) {
+ ensure_config();
+ return config_themes(&CFG, out, max);
+}
+
+size_t app_theme_dirs(char (*out)[512], size_t max) {
+ ensure_config();
+ return config_theme_dirs(&CFG, out, max);
+}
+
+bool app_set_theme(const char *name, bool save, char *err, size_t errcap) {
+ ensure_config();
+ if (err && errcap) err[0] = 0;
+
+ /* Nothing named: forget the session's answer and take the file's, which is
+ * the only way back to a config that names no theme at all. */
+ if (!name || !*name) {
+ THEME_OVERRIDE[0] = 0;
+ return app_reload_config(err, errcap);
+ }
+
+ /* Tried on a throwaway config first: a name that is not there must not cost
+ * the session the colours it is wearing. */
+ config_t probe;
+ config_defaults(&probe);
+ bool ok = config_apply_theme(&probe, name, err, errcap);
+ config_free(&probe);
+ if (!ok) return false;
+
+ if (save &&
+ !config_write_theme_name(config_default_path(), name, err, errcap))
+ return false;
+
+ snprintf(THEME_OVERRIDE, sizeof THEME_OVERRIDE, "%s", name);
+ char rerr[192] = {0};
+ if (!app_reload_config(rerr, sizeof rerr)) {
+ /* The config file is broken, so a reload cannot be how the theme lands --
+ * but the theme itself was fine, and refusing the switch over somebody
+ * else's syntax error would be the wrong half to give up. */
+ if (!config_apply_theme(&CFG, name, err, errcap)) return false;
+ }
+ return true;
+}
+
/* Which files the config in force was read from. The server watches every one
* of them, so a theme that can be included is a theme that reloads when you
* save it — asked of the app rather than read from a config the front ends do
diff --git a/src/cmd.c b/src/cmd.c
index 4be505d..14835af 100644
--- a/src/cmd.c
+++ b/src/cmd.c
@@ -501,6 +501,48 @@ static char *cmd_json(app_t *a, screen_t *s, input_parser_t *in,
}
return jok_int(NULL, 0);
}
+ /* Colour, by name. Without one it answers what there is to pick from, which
+ * is the question a picker (or a person) has first; with one it switches the
+ * session now. `save` writes the name into the config as well, so the same
+ * verb covers "let me see it" and "keep it". */
+ if (strcmp(cmd, "theme") == 0) {
+ const char *name = jv_gets(req, "name", NULL);
+ bool save = jv_getb(req, "save", false);
+ char err[192] = {0};
+ if (name && *name && !app_set_theme(name, save, err, sizeof err))
+ return jerr(err[0] ? err : "cannot use that theme");
+ if (name && !*name && !app_set_theme("", false, err, sizeof err))
+ return jerr(err[0] ? err : "cannot reload");
+ if (name) {
+ app_resize(a, s->cols, s->rows); /* a theme may move the geometry */
+ s->force_full = true;
+ char msg[128];
+ snprintf(msg, sizeof msg, "theme: %s%s",
+ *app_theme() ? app_theme() : "the config's own",
+ save ? " (saved)" : "");
+ app_toast(a, msg);
+ }
+ char names[64][64];
+ size_t n = app_themes(names, 64);
+ char dirs[4][512];
+ size_t nd = app_theme_dirs(dirs, 4);
+ json_t j;
+ json_init(&j);
+ json_obj_open(&j, NULL);
+ json_bool(&j, "ok", true);
+ json_str(&j, "theme", app_theme(), strlen(app_theme()));
+ if (name) json_bool(&j, "saved", save);
+ json_arr_open(&j, "themes");
+ for (size_t i = 0; i < n; i++)
+ json_str(&j, NULL, names[i], strlen(names[i]));
+ json_arr_close(&j);
+ json_arr_open(&j, "dirs");
+ for (size_t i = 0; i < nd; i++)
+ json_str(&j, NULL, dirs[i], strlen(dirs[i]));
+ json_arr_close(&j);
+ json_obj_close(&j);
+ return j.buf;
+ }
if (strcmp(cmd, "alive") == 0) {
json_t j;
json_init(&j);
@@ -644,6 +686,33 @@ char *cmd_exec(app_t *a, screen_t *s, input_parser_t *in, const char *line,
s->force_full = true;
return strdup(ok ? "ok" : err);
}
+ /* `theme` lists, `theme phosphor` switches. The bare form has no room for
+ * `save`, which is the right way round: the driver is for trying things. */
+ if (strcmp(verb, "theme") == 0) {
+ char err[192] = {0};
+ if (*arg && !app_set_theme(arg, false, err, sizeof err))
+ return strdup(err[0] ? err : "cannot use that theme");
+ if (*arg) {
+ app_resize(a, s->cols, s->rows);
+ s->force_full = true;
+ return strdup(app_theme());
+ }
+ char names[64][64];
+ size_t n = app_themes(names, 64);
+ /* One line, because a bare verb's answer is one line -- the driver reads
+ * replies by line and a list down the page would put it out of step. The
+ * one in force is marked, so "which am I wearing" needs no second call. */
+ char out[64 * 68];
+ size_t used = 0;
+ for (size_t i = 0; i < n && used + 68 < sizeof out; i++) {
+ int wrote =
+ snprintf(out + used, sizeof out - used, "%s%s%s", used ? " " : "",
+ names[i], strcmp(names[i], app_theme()) == 0 ? "*" : "");
+ if (wrote > 0) used += (size_t)wrote;
+ }
+ out[used] = 0;
+ return strdup(out);
+ }
if (strcmp(verb, "alive") == 0) {
return strdup(app_should_quit(a) ? "false" : "true");
}
diff --git a/src/config.c b/src/config.c
index d2b89b7..0943935 100644
--- a/src/config.c
+++ b/src/config.c
@@ -2,14 +2,17 @@
#include "config.h"
#include <ghostty/vt.h>
+#include <dirent.h>
#include <stdio.h>
#include <stdlib.h>
#include <stdarg.h>
#include <stddef.h>
#include <string.h>
+#include <unistd.h>
#include "input.h"
#include "kdl.h"
+#include "slosh.h"
static const struct {
const char *name;
@@ -1266,6 +1269,8 @@ void config_free(config_t *c) {
free(c->binds);
free(c->shell);
free(c->editor);
+ free(c->theme_dir);
+ free(c->theme_name);
free(c->project_layout);
for (size_t i = 0; i < c->nproject_roots; i++) free(c->project_roots[i]);
for (size_t i = 0; i < c->nfiles; i++) free(c->files[i]);
@@ -1814,10 +1819,39 @@ char *config_render(const config_t *c) {
else
cb_add(&b, "// project_layout \"~/.config/slosh/project.layout\"\n");
- cb_add(&b, "\n// ---- colour ----\ntheme {\n");
+ /* Colour is a name and a short list of exceptions, never sixty resolved
+ * values. Writing the whole palette out would pin the theme as it looked at
+ * the moment of the dump: the file would keep working and every later
+ * change to that theme -- including one you make yourself -- would silently
+ * stop arriving. So the dump says which theme, and then only what this
+ * config said for itself. */
+ cb_add(&b, "\n// ---- colour ----\n");
+ if (c->theme_dir)
+ cb_qstr(&b, "theme_dir", c->theme_dir, "where named themes live");
+ else
+ cb_add(&b,
+ "// theme_dir \"~/.config/slosh/themes\" // unset: themes/ beside "
+ "this file,\n// then the "
+ "installed ones\n");
+ if (c->theme_name)
+ cb_qstr(&b, "theme_name", c->theme_name, "a file in there, without .kdl");
+ else
+ cb_add(&b,
+ "// theme_name \"phosphor\" // unset: the compiled-in palette\n");
+ size_t nset = 0;
for (size_t i = 0; i < sizeof THEME_COLORS / sizeof *THEME_COLORS; i++)
- cb_color(&b, THEME_COLORS[i].name, *THEME_COLOR((config_t *)c, i));
- cb_add(&b, "}\n");
+ if (c->theme_set[i]) nset++;
+ if (nset) {
+ cb_add(&b, "// ...and the colours this config names itself, which beat "
+ "the theme's:\ntheme {\n");
+ for (size_t i = 0; i < sizeof THEME_COLORS / sizeof *THEME_COLORS; i++)
+ if (c->theme_set[i])
+ cb_color(&b, THEME_COLORS[i].name, *THEME_COLOR((config_t *)c, i));
+ cb_add(&b, "}\n");
+ } else {
+ cb_add(&b, "// theme { frame_focus \"#00ff88\" } // ...and the colours "
+ "you want anyway\n");
+ }
/* Written even when empty: an empty block says "this exists and you have
* none", where nothing at all says "we forgot to tell you". Chrome passes
@@ -1912,6 +1946,41 @@ char *config_dump_defaults(void) {
return text;
}
+/* The palette itself, as a theme file.
+ *
+ * The config dump stopped writing sixty colours when themes got names -- it
+ * writes the name -- so this is where "what exactly am I wearing" is answered:
+ * the resolved colours, derivations included, in the one format a theme is
+ * written in. `slosh --dump-theme > ~/.config/slosh/themes/mine.kdl` is how a
+ * theme is started from the one in front of you rather than from nothing. */
+char *config_render_theme(const config_t *c) {
+ cfgbuf_t b = {0};
+ cb_add(&b, "// A slosh theme: every colour the compositor draws, as it\n");
+ cb_add(&b, "// currently stands. Written by `slosh --dump-theme`.\n");
+ cb_add(&b, "//\n");
+ cb_add(&b, "// Drop it in your theme directory and name it with\n");
+ cb_add(&b, "// `theme_name`. What each colour is for is written out in\n");
+ cb_add(&b, "// the shipped `default` theme.\n");
+ if (c->theme_name) cb_add(&b, "// Started from: %s\n", c->theme_name);
+ cb_add(&b, "theme {\n");
+ for (size_t i = 0; i < sizeof THEME_COLORS / sizeof *THEME_COLORS; i++)
+ cb_color(&b, THEME_COLORS[i].name, *THEME_COLOR((config_t *)c, i));
+ cb_add(&b, "}\n");
+ return b.buf ? b.buf : strdup("");
+}
+
+char *config_dump_theme(void) {
+ config_t fresh;
+ config_defaults(&fresh);
+ /* The config's own answer, not the compiled one: dumping a theme is asking
+ * what this session looks like, and a session reads a config. A broken file
+ * leaves the defaults standing, which is what the session would wear too. */
+ config_load(&fresh, config_default_path(), NULL, 0);
+ char *text = config_render_theme(&fresh);
+ config_free(&fresh);
+ return text;
+}
+
action_t config_lookup(const config_t *c, int key, uint16_t mods) {
/* caps/num lock must not make a binding stop working */
mods &= (uint16_t)(MOD_SHIFT | MOD_CTRL | MOD_ALT | MOD_SUPER);
@@ -1950,6 +2019,95 @@ static action_t action_by_name(const char *name) {
return ACT_NONE;
}
+/* Defined with the rest of the theme lookup, below: what a theme may be
+ * called is one rule, and the writer has to refuse exactly what the loader
+ * would refuse. */
+static bool theme_name_ok(const char *name);
+
+/* Keep a theme switch past a restart, by editing the one line that says which
+ * theme it is.
+ *
+ * A line rather than a rewrite: dumping the whole config back over itself
+ * would reformat a file somebody wrote by hand and drop every comment in it,
+ * which is not a fair price for changing a colour scheme. So the existing
+ * `theme_name` line is replaced in place where there is one, and appended
+ * where there is not -- and a config that does not exist yet becomes a
+ * one-line one, since that is exactly what was asked for. */
+bool config_write_theme_name(const char *path, const char *name, char *err,
+ size_t errcap) {
+ if (err && errcap) err[0] = 0;
+ if (!theme_name_ok(name)) {
+ if (err && errcap) snprintf(err, errcap, "not a theme name: %s", name);
+ return false;
+ }
+
+ char *text = NULL;
+ size_t len = 0;
+ FILE *f = fopen(path, "rb");
+ if (f) {
+ fseek(f, 0, SEEK_END);
+ long size = ftell(f);
+ if (size < 0) size = 0;
+ fseek(f, 0, SEEK_SET);
+ text = malloc((size_t)size + 1);
+ if (!text) {
+ fclose(f);
+ if (err && errcap) snprintf(err, errcap, "out of memory");
+ return false;
+ }
+ len = fread(text, 1, (size_t)size, f);
+ text[len] = 0;
+ fclose(f);
+ }
+
+ char dir[1024];
+ path_mkdirs(path_dir(path, dir, sizeof dir));
+ /* Written beside the original and renamed over it: a config half-written by
+ * a crash is a session that will not start. */
+ char tmp[1100];
+ snprintf(tmp, sizeof tmp, "%s.new", path);
+ FILE *out = fopen(tmp, "wb");
+ if (!out) {
+ free(text);
+ if (err && errcap) snprintf(err, errcap, "cannot write %s", path);
+ return false;
+ }
+
+ bool replaced = false;
+ for (char *line = text; line && *line;) {
+ char *nl = strchr(line, '\n');
+ size_t n = nl ? (size_t)(nl - line) : strlen(line);
+ const char *p = line;
+ while (p < line + n && (*p == ' ' || *p == '\t')) p++;
+ /* The line this owns, and only at the top level -- a `theme_name` inside
+ * a block is not this setting, and indentation is the only thing that
+ * tells them apart without parsing the file again. */
+ bool mine = !replaced && p == line && strncmp(p, "theme_name", 10) == 0 &&
+ (p[10] == ' ' || p[10] == '\t' || p[10] == '"');
+ if (mine) {
+ fprintf(out, "theme_name \"%s\"\n", name);
+ replaced = true;
+ } else {
+ fwrite(line, 1, n, out);
+ fputc('\n', out);
+ }
+ if (!nl) break;
+ line = nl + 1;
+ }
+ if (!replaced) {
+ if (len && text[len - 1] != '\n') fputc('\n', out);
+ fprintf(out, "theme_name \"%s\"\n", name);
+ }
+ free(text);
+ bool ok = fclose(out) == 0;
+ if (ok) ok = rename(tmp, path) == 0;
+ if (!ok) {
+ remove(tmp);
+ if (err && errcap) snprintf(err, errcap, "cannot write %s", path);
+ }
+ return ok;
+}
+
const char *config_default_path(void) {
static char path[512];
const char *explicit_ = getenv("SLOSH_CONFIG");
@@ -2037,6 +2195,8 @@ static const char *const KNOWN_TOP[] = {
"tab_bar_width",
"tab_gap",
"theme",
+ "theme_dir",
+ "theme_name",
"title_align",
"title_inset",
"toast_ms",
@@ -2091,6 +2251,144 @@ static void include_path(const char *base_file, const char *ref, char *out,
snprintf(out, cap, "%s/%s", path_dir(base_file, dir, sizeof dir), r);
}
+/* The directories a named theme is looked for in, in order.
+ *
+ * `themes/` beside the config first -- beside the *root* config, not beside
+ * whichever file named the theme, because it is where a person keeps their
+ * themes and a config elsewhere (SLOSH_CONFIG, or a file handed to --check)
+ * takes its themes with it rather than borrowing yours. Then the
+ * ones `make install` wrote to `<prefix>/share/slosh/themes`, if this build
+ * knows where that is: a theme slosh ships with should be nameable without
+ * being copied anywhere first, and a theme of yours with the same name wins
+ * by being looked at first. `theme_dir` replaces the first of the two, not
+ * both, so naming your own directory never hides the shipped set. */
+size_t config_theme_dirs(const config_t *c, char (*out)[512], size_t max) {
+ size_t n = 0;
+ if (n < max) {
+ if (c && c->theme_dir && *c->theme_dir) {
+ snprintf(out[n++], 512, "%s", c->theme_dir);
+ } else {
+ /* files[0] is the file this config was loaded from -- remembered before
+ * it was parsed, so it is right even for a file that turned out not to
+ * exist. Only a config nobody has loaded falls back to where one would
+ * have been. */
+ const char *root = c && c->nfiles ? c->files[0] : config_default_path();
+ char dir[512];
+ snprintf(out[n++], 512, "%s/themes", path_dir(root, dir, sizeof dir));
+ }
+ }
+#ifdef SLOSH_DATADIR
+ if (n < max) snprintf(out[n++], 512, "%s/themes", SLOSH_DATADIR);
+#endif
+ return n;
+}
+
+/* A theme's file, or false with the places that were looked in. */
+static bool theme_path(const config_t *c, const char *name, char *out,
+ size_t cap, char *tried, size_t triedcap) {
+ char dirs[4][512];
+ size_t nd = config_theme_dirs(c, dirs, 4);
+ if (tried && triedcap) tried[0] = 0;
+ for (size_t i = 0; i < nd; i++) {
+ char path[1024];
+ snprintf(path, sizeof path, "%s/%s.kdl", dirs[i], name);
+ if (access(path, R_OK) == 0) {
+ snprintf(out, cap, "%s", path);
+ return true;
+ }
+ if (tried && triedcap) {
+ size_t used = strlen(tried);
+ snprintf(tried + used, triedcap - used, "%s%s", used ? ", " : "",
+ dirs[i]);
+ }
+ }
+ return false;
+}
+
+/* A theme is named, never spelled as a path: `theme_name "phosphor"`, not
+ * `theme_name "../../etc/x.kdl"`. The directory is the whole point -- it is
+ * what makes a theme switchable at runtime, listable in a picker, and
+ * shadowable by one of yours -- and a path silently does none of that. A file
+ * somewhere else is what `include` has always been for, and the complaint
+ * says so rather than half-honouring it. */
+static bool theme_name_ok(const char *name) {
+ if (!name || !*name || strlen(name) >= THEME_NAME_MAX) return false;
+ for (const char *p = name; *p; p++)
+ if (*p == '/' || *p == '\\' || *p == ':' || *p < 0x20) return false;
+ return strcmp(name, ".") != 0 && strcmp(name, "..") != 0;
+}
+
+static int by_theme_name(const void *a, const void *b) {
+ return strcmp((const char *)a, (const char *)b);
+}
+
+size_t config_themes(const config_t *c, char (*out)[THEME_NAME_MAX],
+ size_t max) {
+ char dirs[4][512];
+ size_t nd = config_theme_dirs(c, dirs, 4), n = 0;
+ for (size_t i = 0; i < nd && n < max; i++) {
+ DIR *d = opendir(dirs[i]);
+ if (!d) continue; /* a directory nobody made is the normal case */
+ struct dirent *e;
+ while ((e = readdir(d)) && n < max) {
+ size_t len = strlen(e->d_name);
+ if (len < 5 || strcmp(e->d_name + len - 4, ".kdl") != 0) continue;
+ if (e->d_name[0] == '.' || len - 4 >= THEME_NAME_MAX) continue;
+ char name[THEME_NAME_MAX];
+ snprintf(name, sizeof name, "%.*s", (int)(len - 4), e->d_name);
+ bool seen = false; /* the first directory to offer a name owns it */
+ for (size_t j = 0; j < n && !seen; j++) seen = strcmp(out[j], name) == 0;
+ if (!seen) snprintf(out[n++], THEME_NAME_MAX, "%s", name);
+ }
+ closedir(d);
+ }
+ qsort(out, n, THEME_NAME_MAX, by_theme_name);
+ return n;
+}
+
+/* Read the theme the file asked for, before the file's own settings.
+ *
+ * `in_theme` is saved and restored rather than simply cleared, because a theme
+ * may name a theme: the flags have to keep meaning "somebody outside the theme
+ * chain said this" however deep the chain goes. */
+static void apply_theme_name(config_t *c, const char *name, int line, int depth,
+ char *err, size_t errcap) {
+ if (!theme_name_ok(name)) {
+ complain(c, err, errcap, line,
+ "theme_name is a name, not a path: %s (use include for a file "
+ "elsewhere)",
+ name);
+ return;
+ }
+ char path[1024], tried[512];
+ if (!theme_path(c, name, path, sizeof path, tried, sizeof tried)) {
+ complain(c, err, errcap, line, "no theme named %s in %s", name, tried);
+ return;
+ }
+ free(c->theme_name);
+ c->theme_name = strdup(name);
+ bool was = c->in_theme;
+ c->in_theme = true;
+ char terr[256] = {0};
+ if (!load_into(c, path, depth + 1, terr, sizeof terr) && !terr[0])
+ snprintf(terr, sizeof terr, "cannot read it");
+ c->in_theme = was;
+ if (terr[0] && err && errcap && !err[0]) snprintf(err, errcap, "%s", terr);
+}
+
+bool config_apply_theme(config_t *c, const char *name, char *err,
+ size_t errcap) {
+ if (err && errcap) err[0] = 0;
+ const char *was = c->loading;
+ c->loading = NULL;
+ apply_theme_name(c, name, 0, 0, err, errcap);
+ c->loading = was;
+ /* Named it and got it: the name is on the config, which is the only signal
+ * that cannot lie about whether the file was there. */
+ return c->theme_name && strcmp(c->theme_name, name) == 0 &&
+ (!err || !errcap || !err[0]);
+}
+
/* Every `include` at the top level of this file, in order, applied *before* the
* file's own settings — wherever the line happens to sit. The loader reads a
* document by asking it for the keys it knows rather than walking it in order,
@@ -2190,6 +2488,31 @@ static bool load_into(config_t *c, const char *path, int depth, char *err,
/* After the includes, because each of those set it to its own file while it
* was being read. From here the complaints belong to this file. */
c->loading = path;
+ /* The named theme, after the includes and before everything this file says
+ * itself -- the same rule includes follow, for the same reason: the loader
+ * reads a document by asking it for names rather than walking it in order,
+ * so "here" is not a position it could honour, and "what you asked for is
+ * the base, what you wrote is yours" is the rule worth having. A theme
+ * beats an include because it is the more specific request. */
+ {
+ const kdl_node_t *dn = kdl_child(root, "theme_dir");
+ const char *dir = kdl_arg(dn, 0, NULL);
+ if (dir) {
+ /* Resolved against the file that said it, like an include's path: a
+ * config is a thing on disk that refers to its neighbours. */
+ char resolved[512];
+ include_path(path, dir, resolved, sizeof resolved);
+ free(c->theme_dir);
+ c->theme_dir = strdup(resolved);
+ }
+ const kdl_node_t *tn = kdl_child(root, "theme_name");
+ const char *name = kdl_arg(tn, 0, NULL);
+ if (tn && !name)
+ complain(c, err, errcap, tn->line, "theme_name needs a name");
+ else if (name)
+ apply_theme_name(c, name, tn->line, depth, err, errcap);
+ c->loading = path; /* the theme file borrowed it while it was read */
+ }
/* ...which includes "I have no idea what this document is", so it has to be
* said after the file is known and not before. */
complain_unknown_top(c, root, err, errcap);
@@ -2490,6 +2813,12 @@ static bool load_into(config_t *c, const char *path, int depth, char *err,
if (!parse_color(v, THEME_COLOR(c, i)))
complain(c, err, errcap, kdl_child(theme, THEME_COLORS[i].name)->line,
"bad colour for %s: %s", THEME_COLORS[i].name, v);
+ /* Not while reading the theme the config asked for: those colours are
+ * the theme's answer, and the dump wants to write the theme's *name*
+ * rather than its palette. A colour named anywhere else -- your config,
+ * a file it includes -- is yours, and survives the dump. */
+ else if (!c->in_theme)
+ c->theme_set[i] = true;
}
/* A theme that restyles the chrome but has never heard of the sidebar's
* status rows gets coherent ones anyway, mixed from two colours it does
diff --git a/src/main.c b/src/main.c
index 838d6f7..61bf198 100644
--- a/src/main.c
+++ b/src/main.c
@@ -147,7 +147,8 @@ static int check_config(const char *path) {
static void usage(void) {
fputs("usage: slosh [-s NAME] [--layout FILE] [--no-reload]\n"
- " [--version] [--dump-config] [--check [FILE]]\n"
+ " [--version] [--dump-config] [--dump-theme]\n"
+ " [--check [FILE]]\n"
" [--install-terminfo] [ls | cmd LINE | -- CMD...]\n",
stderr);
}
@@ -203,6 +204,14 @@ int main(int argc, char **argv) {
fputs(text, stdout);
free(text);
return 0;
+ } else if (strcmp(a, "--dump-theme") == 0) {
+ /* The other half of --dump-config, which writes a theme's *name* rather
+ * than its colours: this writes the colours, so a theme can be started
+ * from the one in front of you instead of from a blank file. */
+ char *text = config_dump_theme();
+ fputs(text, stdout);
+ free(text);
+ return 0;
} else if (strcmp(a, "--install-terminfo") == 0) {
#ifdef _WIN32
fputs("slosh: terminfo is not a Windows concept; nothing to install\n",
diff --git a/tests/test_dump_config.py b/tests/test_dump_config.py
index 67679a1..611578c 100644
--- a/tests/test_dump_config.py
+++ b/tests/test_dump_config.py
@@ -32,10 +32,18 @@ def dump(config=None):
).stdout
+def theme_dump(config=None):
+ env = dict(os.environ)
+ env["SLOSH_CONFIG"] = config or "/nonexistent/slosh.kdl"
+ return subprocess.run(
+ [BIN, "--dump-theme"], capture_output=True, text=True, env=env
+ ).stdout
+
+
def test_it_dumps_something_that_looks_like_a_config():
text = dump()
check("it has content", len(text.splitlines()) > 100, str(len(text)))
- for expect in ("gap ", "theme {", "states {", "keys {", "prefix "):
+ for expect in ("gap ", "states {", "keys {", "prefix "):
check(f"it has {expect!r}", expect in text, text[:200])
@@ -102,10 +110,33 @@ def test_every_key_the_parser_knows_is_in_it():
)
-def test_every_theme_colour_is_in_it():
+def test_the_config_dump_names_a_theme_rather_than_a_palette():
+ """Colour left this file when themes got names. A seed config full of
+ resolved colours pins the palette at the moment it was written, and every
+ later improvement to the theme it came from stops arriving -- so the dump
+ offers the two settings that *can* follow a theme, and no block."""
+ text = dump()
+ for expect in ("// theme_name", "// theme_dir"):
+ check(f"it offers {expect!r}", expect in text, text[:400])
+ check(
+ "it pins no palette",
+ not re.search(r"^theme \{", text, re.M),
+ text[:400],
+ )
+ check(
+ "and names no theme of its own: a default is not a theme",
+ not re.search(r"^theme_name", text, re.M),
+ text[:400],
+ )
+
+
+def test_every_theme_colour_is_in_the_theme_dump():
+ """The same guard, moved to where the colours went: --dump-theme is the
+ file you start a theme from, so a colour missing from it is a colour
+ nobody can change by copying what they are wearing."""
src = open(SRC).read()
colours = set(re.findall(r'\{"([a-z_]+)", offsetof\(config_t', src))
- text = dump()
+ text = theme_dump()
# A colour that is *unset* is the terminal's own, and a theme block has no
# spelling for that -- parse_color takes #rrggbb and nothing else. Those
# are written commented out, so the dump still names the key while loading
diff --git a/tests/test_theme_names.py b/tests/test_theme_names.py
new file mode 100644
index 0000000..63bb0cc
--- /dev/null
+++ b/tests/test_theme_names.py
@@ -0,0 +1,321 @@
+#!/usr/bin/env python3
+"""Named themes: theme_dir, theme_name, and switching one while it runs.
+
+A theme is a file in a directory and a config names it. The rules being
+checked here are the ones that make a *name* worth more than a pasted palette:
+the named theme is the base and anything beside it wins, the directory is
+searched before the installed one so a theme of yours shadows a shipped one,
+a name is a name and not a path, and a switch at runtime survives the reload
+that follows it.
+"""
+
+import json
+import os
+import subprocess
+import sys
+import tempfile
+
+from harness import BIN, Session, check, report
+
+SH = ["/bin/sh", "-c", "stty raw -echo; cat"]
+
+GREEN, PINK, BLUE = "#00ff00", "#ff0088", "#3355ff"
+
+
+def home(**themes):
+ """A config directory: config.kdl plus themes/, and the config's path."""
+ d = tempfile.mkdtemp()
+ os.makedirs(os.path.join(d, "themes"))
+ for name, body in themes.items():
+ with open(os.path.join(d, "themes", name + ".kdl"), "w") as f:
+ f.write(body)
+ return d
+
+
+def write(path, text):
+ with open(path, "w") as f:
+ f.write(text)
+ return path
+
+
+def frame(s):
+ """The focused pane's frame colour: one cell that every theme names."""
+ p = s.pane()
+ return (s.snapshot().style_at(p["x"], p["y"] + 1) or {}).get("fg")
+
+
+def test_a_named_theme_is_read_from_the_theme_dir():
+ d = home(lime='theme { frame_focus "%s" }\n' % GREEN)
+ cfg = write(os.path.join(d, "config.kdl"), 'theme_name "lime"\n')
+ with Session(SH, cols=50, rows=10, config=cfg) as s:
+ s.settle(30)
+ check("the theme's colour is the one drawn", frame(s) == GREEN, frame(s))
+
+
+def test_the_config_beats_the_theme_it_named():
+ """The same bargain include makes: what you asked for is the base, what you
+ wrote beside it is yours -- wherever the line happens to sit."""
+ d = home(lime='theme { frame_focus "%s" title "#111111" }\n' % GREEN)
+ cfg = write(
+ os.path.join(d, "config.kdl"),
+ 'theme { frame_focus "%s" }\ntheme_name "lime"\n' % PINK,
+ )
+ with Session(SH, cols=50, rows=10, config=cfg) as s:
+ s.settle(30)
+ check("the config's own colour wins", frame(s) == PINK, frame(s))
+
+
+def test_a_theme_dir_of_your_own():
+ d = home()
+ other = tempfile.mkdtemp()
+ write(os.path.join(other, "lime.kdl"), 'theme { frame_focus "%s" }\n' % GREEN)
+ cfg = write(
+ os.path.join(d, "config.kdl"),
+ 'theme_dir "%s"\ntheme_name "lime"\n' % other,
+ )
+ with Session(SH, cols=50, rows=10, config=cfg) as s:
+ s.settle(30)
+ check("the named directory is where it looked", frame(s) == GREEN, frame(s))
+
+
+def test_a_relative_theme_dir_is_relative_to_the_file():
+ """Like an include's path: a config is a thing on disk that refers to its
+ neighbours, not to wherever you were standing when you started."""
+ d = home()
+ os.makedirs(os.path.join(d, "palettes"))
+ write(
+ os.path.join(d, "palettes", "lime.kdl"), 'theme { frame_focus "%s" }\n' % GREEN
+ )
+ cfg = write(
+ os.path.join(d, "config.kdl"), 'theme_dir "palettes"\ntheme_name "lime"\n'
+ )
+ with Session(SH, cols=50, rows=10, config=cfg) as s:
+ s.settle(30)
+ check("resolved against the config, not the cwd", frame(s) == GREEN, frame(s))
+
+
+def test_a_missing_theme_is_a_line_and_no_more():
+ """D9: losing your keybindings over a mistyped theme name would be a worse
+ answer than a session that says so."""
+ d = home()
+ cfg = write(os.path.join(d, "config.kdl"), 'gap 3\ntheme_name "nope"\n')
+ r = subprocess.run([BIN, "--check", cfg], capture_output=True, text=True)
+ check("--check says so", "no theme named nope" in r.stderr, r.stderr.strip())
+ check("...and names where it looked", "themes" in r.stderr, r.stderr.strip())
+ with Session(SH, cols=50, rows=10, config=cfg) as s:
+ s.settle(30)
+ check("the session still runs", s.alive(), "")
+ check(
+ "and the rest of the config applied",
+ s.pane()["x"] >= 3,
+ str(s.pane()["x"]),
+ )
+
+
+def test_a_theme_name_is_a_name_not_a_path():
+ d = home()
+ cfg = write(os.path.join(d, "config.kdl"), 'theme_name "../../etc/passwd"\n')
+ r = subprocess.run([BIN, "--check", cfg], capture_output=True, text=True)
+ check(
+ "a path is refused rather than half-honoured",
+ "not a path" in r.stderr,
+ r.stderr.strip(),
+ )
+ check(
+ "...and it points at include instead", "include" in r.stderr, r.stderr.strip()
+ )
+
+
+def test_the_control_socket_lists_and_switches():
+ d = home(
+ lime='theme { frame_focus "%s" }\n' % GREEN,
+ rose='theme { frame_focus "%s" }\n' % PINK,
+ )
+ cfg = write(os.path.join(d, "config.kdl"), 'theme_name "lime"\n')
+ with Session(SH, cols=50, rows=10, config=cfg) as s:
+ s.settle(30)
+ r = s.api("theme")
+ check("it lists what is installed", r["themes"] == ["lime", "rose"], str(r))
+ check("...and says which is worn", r["theme"] == "lime", str(r))
+ check(
+ "...and where it looked, in order",
+ r["dirs"] and r["dirs"][0] == os.path.join(d, "themes"),
+ str(r.get("dirs")),
+ )
+
+ s.api("theme", name="rose")
+ s.settle(30)
+ check("switching repaints in the new colours", frame(s) == PINK, frame(s))
+
+ bad = s.api("theme", name="nope")
+ check("an unknown name is refused", not bad["ok"], str(bad))
+ s.settle(30)
+ check(
+ "...and costs nothing: the session keeps what it wore",
+ frame(s) == PINK,
+ frame(s),
+ )
+
+
+def test_a_switch_survives_the_next_reload():
+ """The session holds the name, not the file -- otherwise saving your config
+ for an unrelated reason would silently put the old colours back."""
+ d = home(
+ lime='theme { frame_focus "%s" }\n' % GREEN,
+ rose='theme { frame_focus "%s" }\n' % PINK,
+ )
+ cfg = write(os.path.join(d, "config.kdl"), 'theme_name "lime"\n')
+ with Session(SH, cols=50, rows=10, config=cfg) as s:
+ s.settle(30)
+ s.api("theme", name="rose")
+ write(cfg, 'theme_name "lime"\ngap 2\n') # an edit about something else
+ s.api("reload")
+ s.settle(30)
+ check("the switch is still in force", frame(s) == PINK, frame(s))
+
+ s.api("theme", name="") # ...until it is given back
+ s.settle(30)
+ check("cleared, the file's own theme returns", frame(s) == GREEN, frame(s))
+
+
+def test_saving_writes_one_line_and_leaves_the_rest_alone():
+ d = home(
+ lime='theme { frame_focus "%s" }\n' % GREEN,
+ rose='theme { frame_focus "%s" }\n' % PINK,
+ )
+ cfg = write(
+ os.path.join(d, "config.kdl"),
+ '// my config\ngap 2\ntheme_name "lime"\nsplash_ms 10\n',
+ )
+ with Session(SH, cols=50, rows=10, config=cfg) as s:
+ s.settle(30)
+ r = s.api("theme", name="rose", save=True)
+ check("it says it saved", r.get("saved") is True, str(r))
+ text = open(cfg).read()
+ check("the line is replaced", 'theme_name "rose"' in text, text)
+ check("...exactly once", text.count("theme_name") == 1, text)
+ check(
+ "...and every other line is untouched",
+ "// my config" in text and "gap 2" in text and "splash_ms 10" in text,
+ text,
+ )
+
+
+def test_saving_into_a_config_that_never_said_anything():
+ d = home(rose='theme { frame_focus "%s" }\n' % PINK)
+ cfg = write(os.path.join(d, "config.kdl"), "gap 1\n")
+ with Session(SH, cols=50, rows=10, config=cfg) as s:
+ s.settle(30)
+ s.api("theme", name="rose", save=True)
+ text = open(cfg).read()
+ check("the line is added", 'theme_name "rose"' in text, text)
+ check("...and the file it was added to is still there", "gap 1" in text, text)
+
+
+def test_a_theme_may_be_shadowed_by_one_of_yours():
+ """Both directories are searched, yours first, which is how a shipped theme
+ is edited without touching a file root owns."""
+ d = home(default='theme { frame_focus "%s" }\n' % BLUE)
+ cfg = write(os.path.join(d, "config.kdl"), 'theme_name "default"\n')
+ with Session(SH, cols=50, rows=10, config=cfg) as s:
+ s.settle(30)
+ check("the local file is the one read", frame(s) == BLUE, frame(s))
+
+
+def test_the_seed_config_offers_a_name_rather_than_a_palette():
+ """`--dump-config` writes the file to begin from, and the file to begin
+ from must not open with sixty colours: the whole point of a named theme is
+ that the colours are somewhere a name can follow when they change."""
+ d = home(lime='theme { frame_focus "%s" title "#010203" }\n' % GREEN)
+ cfg = write(
+ os.path.join(d, "config.kdl"),
+ 'theme_name "lime"\ntheme { frame_idle "%s" }\n' % PINK,
+ )
+ env = dict(os.environ, SLOSH_CONFIG=cfg)
+ r = subprocess.run([BIN, "--check", cfg], capture_output=True, text=True, env=env)
+ check("the config is clean", r.returncode == 0, r.stderr.strip())
+
+ dump = subprocess.run(
+ [BIN, "--dump-config"], capture_output=True, text=True, env=env
+ ).stdout
+ check(
+ "it names no theme of its own",
+ not any(l.startswith("theme_name") for l in dump.splitlines()),
+ dump[:200],
+ )
+ check(
+ "...but says the setting exists",
+ "// theme_name" in dump and "// theme_dir" in dump,
+ dump[:400],
+ )
+ check(
+ "...and pins no palette: one example line, not a block",
+ "\ntheme {" not in dump,
+ dump[:400],
+ )
+ seeded = write(os.path.join(d, "seeded.kdl"), dump)
+ r2 = subprocess.run([BIN, "--check", seeded], capture_output=True, text=True)
+ check("the file it writes loads clean", r2.returncode == 0, r2.stderr.strip())
+
+ theme = subprocess.run(
+ [BIN, "--dump-theme"], capture_output=True, text=True, env=env
+ ).stdout
+ check("--dump-theme resolves the palette", GREEN in theme, theme[:200])
+ check("...including what the config overrode", PINK in theme, theme[:200])
+ check("...and says what it started from", "lime" in theme, theme[:300])
+
+
+def test_a_dumped_theme_is_a_theme():
+ """--dump-theme has to produce a file that can be named and worn, which is
+ the only reason to write sixty colours out anywhere."""
+ d = home(lime='theme { frame_focus "%s" }\n' % GREEN)
+ cfg = write(os.path.join(d, "config.kdl"), 'theme_name "lime"\n')
+ env = dict(os.environ, SLOSH_CONFIG=cfg)
+ theme = subprocess.run(
+ [BIN, "--dump-theme"], capture_output=True, text=True, env=env
+ ).stdout
+ write(os.path.join(d, "themes", "mine.kdl"), theme)
+ write(os.path.join(d, "config2.kdl"), 'theme_name "mine"\n')
+ r = subprocess.run(
+ [BIN, "--check", os.path.join(d, "config2.kdl")],
+ capture_output=True,
+ text=True,
+ )
+ check("it lints as a theme", r.returncode == 0, r.stderr.strip())
+ with Session(SH, cols=50, rows=10, config=os.path.join(d, "config2.kdl")) as s:
+ s.settle(30)
+ check("and wearing it paints the same", frame(s) == GREEN, frame(s))
+
+
+def test_editing_the_theme_repaints_on_reload():
+ """A theme read by the loader is a file the session was built from, so it
+ is watched like every other one -- reload here, since the scripted driver
+ has no watcher of its own."""
+ d = home(lime='theme { frame_focus "%s" }\n' % GREEN)
+ cfg = write(os.path.join(d, "config.kdl"), 'theme_name "lime"\n')
+ with Session(SH, cols=50, rows=10, config=cfg) as s:
+ s.settle(30)
+ write(
+ os.path.join(d, "themes", "lime.kdl"), 'theme { frame_focus "%s" }\n' % PINK
+ )
+ s.api("reload")
+ s.settle(30)
+ check("the edited theme is what is drawn", frame(s) == PINK, frame(s))
+
+
+if __name__ == "__main__":
+ test_a_named_theme_is_read_from_the_theme_dir()
+ test_the_config_beats_the_theme_it_named()
+ test_a_theme_dir_of_your_own()
+ test_a_relative_theme_dir_is_relative_to_the_file()
+ test_a_missing_theme_is_a_line_and_no_more()
+ test_a_theme_name_is_a_name_not_a_path()
+ test_the_control_socket_lists_and_switches()
+ test_a_switch_survives_the_next_reload()
+ test_saving_writes_one_line_and_leaves_the_rest_alone()
+ test_saving_into_a_config_that_never_said_anything()
+ test_a_theme_may_be_shadowed_by_one_of_yours()
+ test_the_seed_config_offers_a_name_rather_than_a_palette()
+ test_a_dumped_theme_is_a_theme()
+ test_editing_the_theme_repaints_on_reload()
+ sys.exit(report())
diff --git a/tests/test_themes.py b/tests/test_themes.py
index 9843d89..6710477 100644
--- a/tests/test_themes.py
+++ b/tests/test_themes.py
@@ -9,18 +9,41 @@ theme rather than a missing line in it.
import glob
import os
import re
+import subprocess
import sys
+import tempfile
-from harness import Session, check, report
+from harness import BIN, 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")))
+THEMEDIR = os.path.join(HERE, "..", "contrib", "themes")
+THEMES = sorted(glob.glob(os.path.join(THEMEDIR, "*.kdl")))
+
+
+# Colours a theme is *not* expected to name. Two kinds, and both are the
+# point of the feature rather than an oversight: the derived ones are mixed
+# from colours the theme does define (so a theme gets them in its own family
+# without a line), and the opt-in ones are unset on purpose, because a band
+# behind a row takes a translucent terminal's own background away.
+DERIVED = {"tab_status_fg", "tab_status_stripe", "tab_idle_bg"}
+OPT_IN = {"tab_status_bg", "attach_bg"}
+# The scrollback search tints: one theme in contrib names them and the rest
+# take the compiled-in pair, which reads the same on every palette here.
+SHARED = {"search_fg", "search_bg", "search_cur_fg", "search_cur_bg"}
def declared_in_config():
+ """Every colour the loader knows, from the table it walks.
+
+ Matched against `offsetof`, which is how the table is written. It used to
+ grep for `&c->` -- the spelling before the table moved to offsets -- and
+ matched nothing at all for however many months, so this guard was passing
+ by finding no colours to check. A guard that cannot fail is worse than no
+ guard, so the count is asserted too.
+ """
src = open(os.path.join(HERE, "..", "src", "config.c")).read()
- return set(re.findall(r'\{"([a-z_]+)", &c->', src))
+ return set(re.findall(r'\{"([a-z_]+)", offsetof\(config_t', src))
def set_by(path):
@@ -31,8 +54,13 @@ def test_there_are_themes():
check("contrib ships some", len(THEMES) >= 3, str(THEMES))
-def test_each_sets_every_colour_the_config_knows():
+def test_the_guard_has_something_to_guard():
want = declared_in_config()
+ check("the colour table was found", len(want) > 40, str(len(want)))
+
+
+def test_each_sets_every_colour_the_config_knows():
+ want = declared_in_config() - DERIVED - OPT_IN - SHARED
for t in THEMES:
missing = want - set_by(t)
check(
@@ -42,6 +70,28 @@ def test_each_sets_every_colour_the_config_knows():
)
+def test_default_is_the_compiled_palette_written_out():
+ """`theme_name "default"` and naming no theme at all have to be the same
+ session -- that claim is the whole reason the file exists, and since the
+ config dump stopped writing colours, --dump-theme is what can check it."""
+ path = os.path.join(HERE, "..", "contrib", "themes", "default.kdl")
+ theirs = dict(
+ re.findall(r'^ ([a-z_]+) "(#[0-9a-fA-F]{6})"', open(path).read(), re.M)
+ )
+ env = dict(os.environ, SLOSH_CONFIG="/nonexistent/slosh.kdl")
+ dumped = subprocess.run(
+ [BIN, "--dump-theme"], capture_output=True, text=True, env=env
+ ).stdout
+ ours = dict(re.findall(r'^ ([a-z_]+)\s+"(#[0-9a-fA-F]{6})"', dumped, re.M))
+ check("the dump has a palette in it", len(ours) > 40, str(len(ours)))
+ wrong = {k: (v, ours.get(k)) for k, v in theirs.items() if ours.get(k) != v}
+ check(
+ "default.kdl matches the compiled-in palette",
+ not wrong,
+ "; ".join(f"{k}: file {a}, code {b}" for k, (a, b) in sorted(wrong.items())),
+ )
+
+
def test_each_one_parses_and_reaches_the_screen():
for t in THEMES:
declared = dict(
@@ -59,8 +109,36 @@ def test_each_one_parses_and_reaches_the_screen():
check(f"{os.path.basename(t)} leaves the session alive", s.alive(), "")
+def test_each_one_can_be_worn_by_name():
+ """What a person actually types: `theme_name "amber"`, not a path to a file
+ in a checkout. Every shipped theme has to answer to its own filename."""
+ d = tempfile.mkdtemp()
+ cfg = os.path.join(d, "config.kdl")
+ for t in THEMES:
+ name = os.path.basename(t)[:-4]
+ declared = dict(
+ re.findall(r'^ ([a-z_]+) "(#[0-9a-fA-F]{6})"', open(t).read(), re.M)
+ )
+ with open(cfg, "w") as f:
+ f.write(
+ 'theme_dir "%s"\ntheme_name "%s"\n' % (os.path.abspath(THEMEDIR), name)
+ )
+ with Session(SH, cols=50, rows=10, config=cfg) 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"theme_name {name!r} is the theme that gets drawn",
+ drawn == declared["frame_focus"],
+ f"{drawn} != {declared['frame_focus']}",
+ )
+
+
if __name__ == "__main__":
test_there_are_themes()
+ test_the_guard_has_something_to_guard()
test_each_sets_every_colour_the_config_knows()
+ test_default_is_the_compiled_palette_written_out()
test_each_one_parses_and_reaches_the_screen()
+ test_each_one_can_be_worn_by_name()
sys.exit(report())
diff --git a/web/demo/overlay/root/.config/slosh/config.kdl b/web/demo/overlay/root/.config/slosh/config.kdl
index 7b4634d..513f9c6 100644
--- a/web/demo/overlay/root/.config/slosh/config.kdl
+++ b/web/demo/overlay/root/.config/slosh/config.kdl
@@ -15,7 +15,7 @@
// paper dark-on-light, for the well-lit
// mono no colour at all
// default white on black, undecorated
-include "themes/sl0p.kdl"
+theme_name "sl0p"
// Busybox ash is the only shell on this machine and $SHELL is not set on it.
shell "/bin/sh"