aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-15 23:18:00 +0200
committeruser <user@clank>2026-08-15 23:18:00 +0200
commit930f8f04f0b3b487031514615ca7ace7ad7bbe9c (patch)
treecaa887f2c5630e1c531254a75e374a2efebddf33
parentsl0ppty: a config can be made of files (diff)
downloadslosh-930f8f04f0b3b487031514615ca7ace7ad7bbe9c.tar.gz
slosh-930f8f04f0b3b487031514615ca7ace7ad7bbe9c.tar.xz
slosh-930f8f04f0b3b487031514615ca7ace7ad7bbe9c.zip
sl0ppty: watch what the config was built from, not just what it was called
An included theme that does not reload when you save it is half a feature, and the half that is missing is the one the file headers in contrib/themes have been promising all along ("a running session re-reads it the moment you save"). That was true while a theme was something you pasted; the moment it became something you include, the watch was on the wrong file. So a config now remembers every file it was read from -- itself and everything it included -- and the watcher watches all of them. Recorded before each parse rather than after, because a file that is not there yet is exactly the file worth watching: writing it is the next thing that will happen, and that is the same reason the watch is on directories rather than on files. Deduplicated, so a cycle does not fill the list with two names twice. The set is rebuilt after every successful load, because it is a property of the config that just loaded: an include can be added, pointed somewhere else, or taken out, and the watch has to follow. One inotify watch per directory, deduplicated by the descriptor inotify hands back for a directory it is already watching; names are matched against the whole set rather than against the directory whose watch fired, which means two directories holding a file of the same name reload for each other's save. That costs a debounced re-read of a config that has not changed, and is not worth a second data structure to avoid. Only the main config's directory is created if missing. An include's is not: a mistyped path should not leave a directory behind. tests/test_reload.py drives a real session for this one -- the watcher is the thing under test and the headless driver has none. It writes a config that includes a theme, checks the theme applied at startup, then edits *only the included file* and waits for the session to change colour.
-rw-r--r--src/app.c9
-rw-r--r--src/app.h3
-rw-r--r--src/config.c19
-rw-r--r--src/config.h17
-rw-r--r--src/server.c103
-rw-r--r--tests/test_reload.py46
6 files changed, 166 insertions, 31 deletions
diff --git a/src/app.c b/src/app.c
index 48ec42d..9c33601 100644
--- a/src/app.c
+++ b/src/app.c
@@ -57,6 +57,15 @@ bool app_reload_config(char *err, size_t errcap) {
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
+ * not own. */
+size_t app_config_files(const char **out, size_t max) {
+ ensure_config();
+ return config_files(&CFG, out, max);
+}
+
#define WEIGHT_UNIT 1000
#define WEIGHT_MIN 150 /* a pane can be squeezed, not squeezed out */
#define WEIGHT_STEP 120 /* one keyboard nudge */
diff --git a/src/app.h b/src/app.h
index e9740ed..9e6c820 100644
--- a/src/app.h
+++ b/src/app.h
@@ -39,6 +39,9 @@ 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);
+/* 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);
void app_resize(app_t *a, uint16_t cols, uint16_t rows);
/* The attached client's cell size in pixels, passed on to every pane's pty and
* terminal. Images need it: a placement that does not say how many cells it
diff --git a/src/config.c b/src/config.c
index 257047c..a31496f 100644
--- a/src/config.c
+++ b/src/config.c
@@ -570,6 +570,7 @@ void config_free(config_t *c) {
free(c->shell);
free(c->editor);
free(c->shader_dir);
+ for (size_t i = 0; i < c->nfiles; i++) free(c->files[i]);
memset(c, 0, sizeof *c);
}
@@ -1046,6 +1047,23 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) {
return load_into(c, path, 0, err, errcap);
}
+/* Remembered before the parse, not after: a file that is not there yet is
+ * exactly the file worth watching, since writing it is the next thing that will
+ * happen. Deduplicated, so a cycle does not fill the list with two names. */
+static void remember_file(config_t *c, const char *path) {
+ for (size_t i = 0; i < c->nfiles; i++)
+ if (strcmp(c->files[i], path) == 0) return;
+ if (c->nfiles >= CONFIG_FILES_MAX) return;
+ char *dup = strdup(path);
+ if (dup) c->files[c->nfiles++] = dup;
+}
+
+size_t config_files(const config_t *c, const char **out, size_t max) {
+ size_t n = c->nfiles < max ? c->nfiles : max;
+ for (size_t i = 0; i < n; i++) out[i] = c->files[i];
+ return n;
+}
+
static bool load_into(config_t *c, const char *path, int depth, char *err,
size_t errcap) {
if (depth > INCLUDE_MAX_DEPTH) {
@@ -1053,6 +1071,7 @@ static bool load_into(config_t *c, const char *path, int depth, char *err,
snprintf(err, errcap, "%s: includes nested too deep (a cycle?)", path);
return false;
}
+ remember_file(c, path);
kdl_node_t *root = kdl_parse_file(path, err, errcap);
if (!root) return false; /* defaults stand; the caller reports why */
diff --git a/src/config.h b/src/config.h
index 7d9e50a..f7887e0 100644
--- a/src/config.h
+++ b/src/config.h
@@ -91,6 +91,11 @@ typedef struct {
typedef enum { ALIGN_LEFT, ALIGN_CENTER, ALIGN_RIGHT } align_t;
+/* How many files one config may be built from: itself plus its includes. Small
+ * and fixed, because a config assembled from more than this many pieces is a
+ * thing nobody can read. */
+#define CONFIG_FILES_MAX 16
+
typedef struct {
/* geometry */
uint16_t gap, gap_aspect, pad;
@@ -321,6 +326,15 @@ typedef struct {
* `states` blocks are parsed, because what they are allowed to name depends
* on what has been loaded. */
char *shader_dir;
+
+ /* Every file this config was built from, in the order they were read: the one
+ * that was loaded and everything it included, whether or not each existed.
+ * Kept because the watcher needs it — a theme you can include is a theme you
+ * expect to reload when you save it — and recorded even for a file that is
+ * not there, so a session started before you wrote one still notices it
+ * appearing. */
+ char *files[CONFIG_FILES_MAX];
+ size_t nfiles;
} config_t;
/* Defaults, then <config dir>/config.kdl on top. Never fails: on a bad file
@@ -330,6 +344,9 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap);
/* $SL0PPTY_CONFIG, else $XDG_CONFIG_HOME/sl0ppty/config.kdl, else ~/.config/… */
const char *config_default_path(void);
void config_free(config_t *c);
+/* The files this config was read from, oldest first: `files[0]` is the one that
+ * was loaded, the rest are what it included. Returns how many were written. */
+size_t config_files(const config_t *c, const char **out, size_t max);
/* What a chord does after the leader. Direct bindings answer here too, so
* pressing the leader first never makes a binding stop working. */
diff --git a/src/server.c b/src/server.c
index 242be26..0420ffe 100644
--- a/src/server.c
+++ b/src/server.c
@@ -174,6 +174,69 @@ static void drop_display(server_t *s, uint8_t reason) {
}
}
+/* What the config watcher is watching: a directory per config file, and the
+ * basenames to match events against. Rebuilt from the config every time one
+ * loads, because an `include` line can be added, changed or removed — a theme
+ * you can include is a theme that has to reload when you save it.
+ *
+ * Names are matched against every watched directory rather than against the one
+ * their own watch fired for. Two directories holding a file of the same name
+ * would reload for the other's save, which costs a debounced re-read of a config
+ * that has not changed and is not worth a second data structure to avoid. */
+#define WATCH_MAX CONFIG_FILES_MAX
+typedef struct {
+ int fd;
+ int wds[WATCH_MAX];
+ size_t nwds;
+ char names[WATCH_MAX][128];
+ size_t nnames;
+} watchset_t;
+
+static void watch_config(watchset_t *w) {
+ if (w->fd < 0) return;
+ for (size_t i = 0; i < w->nwds; i++) inotify_rm_watch(w->fd, w->wds[i]);
+ w->nwds = 0;
+ w->nnames = 0;
+
+ const char *files[WATCH_MAX];
+ size_t n = app_config_files(files, WATCH_MAX);
+ for (size_t i = 0; i < n; i++) {
+ char dir[512], base[128];
+ snprintf(dir, sizeof dir, "%s", files[i]);
+ char *slash = strrchr(dir, '/');
+ if (slash) {
+ *slash = 0;
+ snprintf(base, sizeof base, "%s", slash + 1);
+ } else {
+ snprintf(base, sizeof base, "%s", files[i]);
+ snprintf(dir, sizeof dir, ".");
+ }
+ /* The main config's directory is ours to create, so that a session started
+ * before the file exists still notices it appearing. An include's is not:
+ * a mistyped path should not leave a directory behind. */
+ if (i == 0) path_mkdirs(dir);
+
+ int wd = inotify_add_watch(w->fd, dir,
+ IN_CLOSE_WRITE | IN_MOVED_TO | IN_CREATE);
+ if (wd >= 0) {
+ bool have = false;
+ for (size_t k = 0; k < w->nwds; k++)
+ if (w->wds[k] == wd) have = true; /* same directory, same descriptor */
+ if (!have && w->nwds < WATCH_MAX) w->wds[w->nwds++] = wd;
+ }
+ if (w->nnames < WATCH_MAX) {
+ snprintf(w->names[w->nnames], sizeof w->names[0], "%s", base);
+ w->nnames++;
+ }
+ }
+}
+
+static bool watch_hit(const watchset_t *w, const char *name) {
+ for (size_t i = 0; i < w->nnames; i++)
+ if (strcmp(w->names[i], name) == 0) return true;
+ return false;
+}
+
int server_run(const char *name, const char *const argv[], uint16_t cols,
uint16_t rows, const char *layout, bool watch) {
char path[512];
@@ -225,37 +288,12 @@ int server_run(const char *name, const char *const argv[], uint16_t cols,
* writes in chunks is read once it has finished rather than halfway. */
const int64_t RELOAD_DEBOUNCE_MS = 80;
int64_t reload_due = -1;
- int inofd = -1, inowd = -1;
- char cfg_dir[512] = {0}, cfg_base[256] = {0};
+ int inofd = -1;
+ watchset_t watches = {.fd = -1};
if (watch) {
- const char *path = config_default_path();
- if (path && *path) {
- snprintf(cfg_dir, sizeof cfg_dir, "%s", path);
- char *slash = strrchr(cfg_dir, '/');
- if (slash) {
- *slash = 0;
- snprintf(cfg_base, sizeof cfg_base, "%s", slash + 1);
- } else {
- snprintf(cfg_dir, sizeof cfg_dir, ".");
- snprintf(cfg_base, sizeof cfg_base, "%s", path);
- }
- /* The watch is on the directory, not the file -- editors write a new
- * file and rename it over the old one, so watching the file itself
- * stops working the first time you save. But a directory that is not
- * there cannot be watched at all, and then nothing is ever noticed for
- * the life of the session: no config, no watch, and saving one later
- * does nothing until a restart. Ours to create, so create it. */
- path_mkdirs(cfg_dir);
- inofd = inotify_init1(IN_NONBLOCK | IN_CLOEXEC);
- if (inofd >= 0) {
- inowd = inotify_add_watch(inofd, cfg_dir,
- IN_CLOSE_WRITE | IN_MOVED_TO | IN_CREATE);
- if (inowd < 0) {
- close(inofd);
- inofd = -1;
- }
- }
- }
+ inofd = inotify_init1(IN_NONBLOCK | IN_CLOEXEC);
+ watches.fd = inofd;
+ watch_config(&watches);
}
bool pending_paint = true;
@@ -424,7 +462,7 @@ int server_run(const char *name, const char *const argv[], uint16_t cols,
if (got <= 0) break;
for (char *q = buf; q < buf + got;) {
struct inotify_event *ev = (struct inotify_event *)q;
- if (ev->len && strcmp(ev->name, cfg_base) == 0) touched = true;
+ if (ev->len && watch_hit(&watches, ev->name)) touched = true;
q += sizeof *ev + ev->len;
}
}
@@ -436,6 +474,9 @@ int server_run(const char *name, const char *const argv[], uint16_t cols,
char err[256] = {0};
if (app_reload_config(err, sizeof err)) {
app_resize(s.app, s.screen.cols, s.screen.rows);
+ /* The set of files can have changed with the config that named them:
+ * an include added, pointed somewhere else, or taken out. */
+ watch_config(&watches);
app_toast(s.app, "config reloaded");
} else {
app_toast(s.app, err[0] ? err : "config reload failed");
diff --git a/tests/test_reload.py b/tests/test_reload.py
index 20b3673..8e7b35a 100644
--- a/tests/test_reload.py
+++ b/tests/test_reload.py
@@ -122,6 +122,52 @@ def test_a_config_written_after_the_session_started_is_noticed():
capture_output=True, env=env)
+def test_an_included_file_is_watched_too():
+ """A theme you can include is a theme that has to reload when you save it,
+ so the watch covers every file the config was built from -- not only the one
+ the session was started with. Driven through a real session, because the
+ watcher is the thing under test and the headless driver has none."""
+ home = tempfile.mkdtemp(prefix="sl0ppty-inc-")
+ cfgdir = os.path.join(home, ".config", "sl0ppty")
+ os.makedirs(os.path.join(cfgdir, "themes"))
+ main = os.path.join(cfgdir, "config.kdl")
+ theme = os.path.join(cfgdir, "themes", "t.kdl")
+ with open(main, "w") as f:
+ f.write('include "themes/t.kdl"\n')
+ with open(theme, "w") as f:
+ f.write('theme { frame_focus "#00ff00" }\n')
+
+ env = dict(os.environ, SL0PPTY_CONFIG=main)
+ name = "inctest-%d" % os.getpid()
+ subprocess.run([BIN, "-s", name, "--", "/bin/sh", "-c", "read x"],
+ env=env, capture_output=True, timeout=5)
+
+ def screen():
+ return subprocess.run([BIN, "-s", name, "cmd",
+ '{"cmd":"snapshot","format":"json"}'],
+ capture_output=True, text=True, env=env).stdout
+
+ try:
+ check("the included theme applied at startup", '"#00ff00"' in screen(),
+ "green never appeared")
+
+ # The include line is untouched; only the file it points at changes.
+ deadline = time.time() + 3
+ got = None
+ while time.time() < deadline:
+ with open(theme, "w") as f:
+ f.write('theme { frame_focus "#ff00ff" }\n')
+ time.sleep(0.4)
+ if '"#ff00ff"' in screen():
+ got = True
+ break
+ check("saving the included file reloads the session", got,
+ "the session never saw the theme change")
+ finally:
+ subprocess.run([BIN, "-s", name, "cmd", '{"cmd":"quit"}'],
+ capture_output=True, env=env)
+
+
for name, fn in sorted(list(globals().items())):
if name.startswith("test_"):
fn()