aboutsummaryrefslogtreecommitdiffstats
path: root/src
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 /src
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.
Diffstat (limited to 'src')
-rw-r--r--src/app.c66
-rw-r--r--src/cmd.c69
-rw-r--r--src/config.c335
-rw-r--r--src/main.c11
4 files changed, 477 insertions, 4 deletions
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",