diff options
| author | user <user@clank> | 2026-08-15 23:18:00 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-15 23:18:00 +0200 |
| commit | 930f8f04f0b3b487031514615ca7ace7ad7bbe9c (patch) | |
| tree | caa887f2c5630e1c531254a75e374a2efebddf33 | |
| parent | sl0ppty: a config can be made of files (diff) | |
| download | slosh-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.c | 9 | ||||
| -rw-r--r-- | src/app.h | 3 | ||||
| -rw-r--r-- | src/config.c | 19 | ||||
| -rw-r--r-- | src/config.h | 17 | ||||
| -rw-r--r-- | src/server.c | 103 | ||||
| -rw-r--r-- | tests/test_reload.py | 46 |
6 files changed, 166 insertions, 31 deletions
@@ -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 */ @@ -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() |
