diff options
| author | user <user@clank> | 2026-09-22 12:42:15 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-09-22 12:42:15 +0200 |
| commit | faa3fb6ee575a900abc574b0f9ed5ff291761837 (patch) | |
| tree | ce644998c144687e205d1f4e97e6f999de98723b | |
| parent | tabs: idle tabs you can actually read across the room (diff) | |
| download | slosh-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-- | Makefile | 19 | ||||
| -rw-r--r-- | config/config.kdl | 154 | ||||
| -rwxr-xr-x | contrib/theme-tour | 26 | ||||
| -rw-r--r-- | contrib/themes/default.kdl | 150 | ||||
| -rw-r--r-- | docs/config.md | 59 | ||||
| -rw-r--r-- | docs/slosh.1 | 21 | ||||
| -rw-r--r-- | docs/slosh.5 | 61 | ||||
| -rw-r--r-- | include/app.h | 17 | ||||
| -rw-r--r-- | include/config.h | 63 | ||||
| -rw-r--r-- | src/app.c | 66 | ||||
| -rw-r--r-- | src/cmd.c | 69 | ||||
| -rw-r--r-- | src/config.c | 335 | ||||
| -rw-r--r-- | src/main.c | 11 | ||||
| -rw-r--r-- | tests/test_dump_config.py | 37 | ||||
| -rw-r--r-- | tests/test_theme_names.py | 321 | ||||
| -rw-r--r-- | tests/test_themes.py | 86 | ||||
| -rw-r--r-- | web/demo/overlay/root/.config/slosh/config.kdl | 2 |
17 files changed, 1276 insertions, 221 deletions
@@ -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); @@ -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 @@ -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 @@ -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" |
