aboutsummaryrefslogtreecommitdiffstats
path: root/src/config.c
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/config.c
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/config.c')
-rw-r--r--src/config.c335
1 files changed, 332 insertions, 3 deletions
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