diff options
| author | user <user@clank> | 2026-09-22 12:57:26 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-09-22 12:57:26 +0200 |
| commit | 68ff62e9492accd4c1c9a83edbcbd225afb1a11a (patch) | |
| tree | b744ad33cdbaa25fca333f28f13b5e7df28838dd | |
| parent | themes: a picker that wears them as you browse (C-a t) (diff) | |
| download | slosh-68ff62e9492accd4c1c9a83edbcbd225afb1a11a.tar.gz slosh-68ff62e9492accd4c1c9a83edbcbd225afb1a11a.tar.xz slosh-68ff62e9492accd4c1c9a83edbcbd225afb1a11a.zip | |
themes: ask the binary where it is, not the prefix it was built for
`theme_name "sl0p"` with all seven themes sitting in /usr/share/slosh/themes:
"no theme named sl0p". The lookup knew two places -- your own themes directory
and the PREFIX this build was compiled with -- and on that machine the first
did not exist and the second was /usr/local, because that is the Makefile's
default and the package was built with PREFIX=/usr. Neither was where the
themes actually were, and nothing in the search could ever have found them.
A compiled-in prefix is a guess about where the binary will end up, and the
binary is the one thing that knows where it really is. So the search now asks
it: path_self() (/proc/self/exe, _NSGetExecutablePath, GetModuleFileName --
lifted out of server.c, which was already doing this for SLOSH_BIN) gives
<bin>/../share/slosh/themes for an installed slosh wherever it was installed
to, and <repo>/contrib/themes for one being run out of a build tree, which
makes a checkout able to name a theme without installing anything first. The
compiled-in prefix stays as the last candidate, for a binary sitting somewhere
that says nothing about its data, and duplicates collapse so the usual install
does not report the same directory twice.
Your own directory is still first and still shadows everything, which is the
property that matters; the rest is about the shipped set being findable at all.
The tests that assumed a closed world of two temp themes now narrow to their
own names by typing, since the shipped ones are (correctly) always in the list
-- and the "no themes anywhere" toast lost its test, because with the shipped
set reachable that state takes a binary with nothing beside it. The case that
started this is a test of its own.
| -rw-r--r-- | docs/config.md | 22 | ||||
| -rw-r--r-- | docs/slosh.5 | 15 | ||||
| -rw-r--r-- | include/app.h | 3 | ||||
| -rw-r--r-- | include/config.h | 4 | ||||
| -rw-r--r-- | include/slosh.h | 3 | ||||
| -rw-r--r-- | src/app.c | 4 | ||||
| -rw-r--r-- | src/cmd.c | 4 | ||||
| -rw-r--r-- | src/config.c | 67 | ||||
| -rw-r--r-- | src/path.c | 40 | ||||
| -rw-r--r-- | tests/test_theme_names.py | 123 |
10 files changed, 214 insertions, 71 deletions
diff --git a/docs/config.md b/docs/config.md index 6cee3d2..506cbff 100644 --- a/docs/config.md +++ b/docs/config.md @@ -79,13 +79,21 @@ 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. +**Yours first, then the ones that ship with slosh.** `themes/` beside your +config (so `~/.config/slosh/themes` for the usual one), and then the shipped +set, found **relative to the binary that is running**: `<bin>/../share/slosh/themes` +for an installed slosh, `<repo>/contrib/themes` when you are running one out of +a build tree, and the prefix the build was compiled with as a last resort. The +binary is asked where it is rather than trusting the compiled-in prefix alone, +because the two disagree all the time — a distro builds with `PREFIX=/usr` +while the default is `/usr/local`, and a binary can be moved after the fact. + +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. `theme_dir` replaces the first of those +directories, never the shipped set. `cmd theme` reports the whole list in +`dirs`, in the order it searches, which is what to read when a name is not +found. `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 diff --git a/docs/slosh.5 b/docs/slosh.5 index 21315e7..ed7c0ad 100644 --- a/docs/slosh.5 +++ b/docs/slosh.5 @@ -324,10 +324,17 @@ Unset, the compiled\-in palette stands. 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. +beside the configuration file, and then the shipped themes, found +relative to the running binary +.RI ( <bin>/../share/slosh/themes , +or +.I <repo>/contrib/themes +out of a build tree) and finally under the prefix this build was +compiled with. +A shipped theme is therefore nameable without being copied, and one of +yours with the same name shadows it. +.B \(aq{"cmd":"theme"}\(aq +reports the directories in the order they are searched. .PP .B theme { } names every colour the compositor draws (frames, titles, tabs, diff --git a/include/app.h b/include/app.h index 74b841d..3a660b2 100644 --- a/include/app.h +++ b/include/app.h @@ -5,6 +5,7 @@ #ifndef SLOSH_APP_H #define SLOSH_APP_H +#include "config.h" #include "graphics.h" #include "kdl.h" #include "project.h" @@ -74,7 +75,7 @@ bool app_set_theme(const char *name, bool save, char *err, size_t errcap); 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_themes(char (*out)[THEME_NAME_MAX], 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. */ diff --git a/include/config.h b/include/config.h index d3c7e55..25fcc41 100644 --- a/include/config.h +++ b/include/config.h @@ -153,6 +153,10 @@ enum { #define THEMES_MAX 64 #define THEME_NAME_MAX 64 +/* How many directories a theme is looked for in: yours, two derived from + * where the binary is, and the prefix this build was compiled with. */ +#define THEME_DIRS_MAX 4 + typedef struct { /* geometry */ uint16_t gap, gap_aspect; diff --git a/include/slosh.h b/include/slosh.h index 8f96543..beec697 100644 --- a/include/slosh.h +++ b/include/slosh.h @@ -137,6 +137,9 @@ const char *path_base(const char *path); const char *path_relative(const char *path, const char *base); /* mkdir -p, for the directory a config or a session socket wants to live in. */ bool path_mkdirs(const char *dir); +/* This binary's own path, from the kernel rather than from argv[0]. What lets + * slosh find the files installed beside it without being told where they are. */ +bool path_self(char *out, size_t cap); /* ---- the shell a pane gets when nothing else is said -------------------- */ @@ -1677,8 +1677,8 @@ bool run_action(app_t *a, action_t act) { * session started is exactly the one being looked for. */ a->nthemes = app_themes(a->themes, THEMES_MAX); if (!a->nthemes) { - char dirs[4][512]; - size_t nd = app_theme_dirs(dirs, 4); + char dirs[THEME_DIRS_MAX][512]; + size_t nd = app_theme_dirs(dirs, THEME_DIRS_MAX); char msg[600]; snprintf(msg, sizeof msg, "no themes in %s", nd ? dirs[0] @@ -524,8 +524,8 @@ static char *cmd_json(app_t *a, screen_t *s, input_parser_t *in, } char names[64][64]; size_t n = app_themes(names, 64); - char dirs[4][512]; - size_t nd = app_theme_dirs(dirs, 4); + char dirs[THEME_DIRS_MAX][512]; + size_t nd = app_theme_dirs(dirs, THEME_DIRS_MAX); json_t j; json_init(&j); json_obj_open(&j, NULL); diff --git a/src/config.c b/src/config.c index c3b62be..4e60652 100644 --- a/src/config.c +++ b/src/config.c @@ -2268,23 +2268,56 @@ static void include_path(const char *base_file, const char *ref, char *out, * 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. */ +/* Append a candidate, skipping one that is already in the list: the lookup + * below names the same directory two ways on an ordinary install, and a + * "looked in" message that says it twice reads like a bug. */ +static void dir_add(char (*out)[512], size_t *n, size_t max, const char *fmt, + ...) { + if (*n >= max) return; + char path[512]; + va_list ap; + va_start(ap, fmt); + vsnprintf(path, sizeof path, fmt, ap); + va_end(ap); + for (size_t i = 0; i < *n; i++) + if (strcmp(out[i], path) == 0) return; + snprintf(out[(*n)++], 512, "%s", path); +} + 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)); - } + if (c && c->theme_dir && *c->theme_dir) { + dir_add(out, &n, max, "%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]; + dir_add(out, &n, max, "%s/themes", path_dir(root, dir, sizeof dir)); + } + + /* Then the shipped ones, found relative to *this binary* before anything + * compiled in. A build compiles in one PREFIX; the binary that ends up + * running may have been packaged under another (a distro building with + * PREFIX=/usr while the default is /usr/local is the common case), moved, + * or never installed at all. `<bin>/../share/slosh/themes` is true in all of + * those, and the checkout's own `contrib/themes` covers the last one, so a + * build tree can name a theme without installing anything first. */ + char self[512]; + if (path_self(self, sizeof self)) { + char bindir[512], updir[512]; + const char *up = + path_dir(path_dir(self, bindir, sizeof bindir), updir, sizeof updir); + dir_add(out, &n, max, "%s/share/slosh/themes", up); + dir_add(out, &n, max, "%s/contrib/themes", up); } #ifdef SLOSH_DATADIR - if (n < max) snprintf(out[n++], 512, "%s/themes", SLOSH_DATADIR); + /* Last, and usually a repeat of the first of those: the prefix this build + * was told it would be installed under, which is the only answer left when + * the binary sits somewhere that says nothing about its data. */ + dir_add(out, &n, max, "%s/themes", SLOSH_DATADIR); #endif return n; } @@ -2292,8 +2325,8 @@ size_t config_theme_dirs(const config_t *c, char (*out)[512], size_t max) { /* 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); + char dirs[THEME_DIRS_MAX][512]; + size_t nd = config_theme_dirs(c, dirs, THEME_DIRS_MAX); if (tried && triedcap) tried[0] = 0; for (size_t i = 0; i < nd; i++) { char path[1024]; @@ -2330,8 +2363,8 @@ static int by_theme_name(const void *a, const void *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; + char dirs[THEME_DIRS_MAX][512]; + size_t nd = config_theme_dirs(c, dirs, THEME_DIRS_MAX), 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 */ @@ -6,6 +6,7 @@ * session happened to be in. Silently \u2014 which is the worst way for a path to * be wrong, because the pane still starts and still works. */ +#define _GNU_SOURCE #include "slosh.h" #include <sys/stat.h> @@ -14,6 +15,14 @@ #include <stdio.h> #include <stdlib.h> #include <string.h> +#include <unistd.h> + +#ifdef __APPLE__ +#include <mach-o/dyld.h> /* Darwin has no /proc/self/exe */ +#endif +#ifdef _WIN32 +#include "compat_win.h" +#endif /* Whether a path names a place outright, rather than one to be read against * somewhere else. @@ -149,6 +158,37 @@ const char *path_relative(const char *path, const char *base) { return *rest ? rest : "."; } +/* This binary, as a path: what the program can find *relative to itself*. + * + * Read from the kernel rather than from argv[0], which is whatever the caller + * felt like passing. It is how the shipped themes are found without being told + * where they are: a build compiles in a PREFIX, but the binary that ends up + * running may have been installed under a different one, packaged by somebody + * else, or not installed at all -- and `<the binary>/../share/slosh` is true in + * every one of those cases while a compiled-in guess is true in one. + */ +bool path_self(char *out, size_t cap) { + if (!out || cap < 2) return false; +#ifdef _WIN32 + return sl_self_exe(out, cap); +#elif defined(__APPLE__) + char raw[1024]; + uint32_t sz = sizeof raw; + /* What Darwin hands back can still hold symlinks and `..`; realpath + * finishes the job, and falls back to the raw answer rather than to + * nothing. */ + if (_NSGetExecutablePath(raw, &sz) != 0) return false; + char resolved[1024]; + snprintf(out, cap, "%s", realpath(raw, resolved) ? resolved : raw); + return true; +#else + ssize_t n = readlink("/proc/self/exe", out, cap - 1); + if (n <= 0) return false; + out[n] = 0; + return true; +#endif +} + /* Create a directory and everything above it. `mkdir` of one level is enough * on a machine where ~/.config already exists, which is most of them and not * all of them -- a fresh container has neither, and "could not write your diff --git a/tests/test_theme_names.py b/tests/test_theme_names.py index 66f4459..82b8281 100644 --- a/tests/test_theme_names.py +++ b/tests/test_theme_names.py @@ -135,7 +135,16 @@ def test_the_control_socket_lists_and_switches(): 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( + "it lists what is installed", + {"lime", "rose"} <= set(r["themes"]), + str(r), + ) + check( + "...sorted, and each name once", + r["themes"] == sorted(set(r["themes"])), + str(r["themes"]), + ) check("...and says which is worn", r["theme"] == "lime", str(r)) check( "...and where it looked, in order", @@ -212,6 +221,44 @@ def test_saving_into_a_config_that_never_said_anything(): check("...and the file it was added to is still there", "gap 1" in text, text) +def test_the_shipped_themes_are_found_beside_the_binary(): + """The compiled-in prefix is a guess about where this build will be + installed, and the binary that ends up running may have been packaged under + another one, moved, or never installed at all -- so the search also asks + the binary where *it* is. This is the case that sent a real config to + "no theme named sl0p": themes in /usr/share, a build compiled for + /usr/local, and nothing in either of the places it knew to look.""" + d = home() + cfg = write(os.path.join(d, "config.kdl"), 'theme_name "phosphor"\n') + r = subprocess.run([BIN, "--check", cfg], capture_output=True, text=True) + check( + "a theme nobody copied anywhere is still nameable", + r.returncode == 0, + r.stderr.strip(), + ) + with Session(SH, cols=50, rows=10, config=cfg) as s: + s.settle(30) + listed = s.api("theme") + check( + "and it is in the list", + "phosphor" in listed["themes"], + str(listed["themes"]), + ) + check( + "...found somewhere relative to the binary", + any( + "contrib/themes" in x or "share/slosh/themes" in x + for x in listed["dirs"][1:] + ), + str(listed["dirs"]), + ) + check( + "...with your own directory still searched first", + listed["dirs"][0] == os.path.join(d, "themes"), + str(listed["dirs"]), + ) + + 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.""" @@ -310,25 +357,29 @@ def test_editing_the_theme_repaints_on_reload(): # honest preview of a palette meant to dress a whole session. -def picker(s): - """The picker's rows, as text, without the frame either side.""" - return [l.strip() for l in s.snapshot().screen().splitlines()] +# The picker lists every theme that can be named, which includes the ones +# slosh ships. So these write two nobody else has and narrow to them by typing +# the prefix -- which is what the query is for, and what a person with a +# directory of themes does anyway. +PAIR = {"zzlime": GREEN, "zzrose": PINK} + + +def pair_home(): + return home(**{n: 'theme { frame_focus "%s" }\n' % c for n, c in PAIR.items()}) def test_the_picker_lists_what_can_be_named(): - 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') + d = pair_home() + cfg = write(os.path.join(d, "config.kdl"), 'theme_name "zzlime"\n') with Session(SH, cols=64, rows=16, config=cfg) as s: s.settle(30) s.key("t") s.settle(30) screen = s.snapshot().screen() check("it is a picker called themes", "themes" in screen, screen) - for name in ("lime", "rose"): + for name in ("zzlime", "zzrose"): check(f"{name} is listed", name in screen, screen) + check("...and so are the shipped ones", "phosphor" in screen, screen) check( "the config's own theme says so on its row", "in your config" in screen, @@ -342,11 +393,8 @@ def test_the_picker_lists_what_can_be_named(): def test_moving_the_selection_wears_the_theme(): - 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') + d = pair_home() + cfg = write(os.path.join(d, "config.kdl"), 'theme_name "zzlime"\n') with Session(SH, cols=64, rows=16, config=cfg) as s: s.settle(30) before = frame(s) @@ -354,69 +402,65 @@ def test_moving_the_selection_wears_the_theme(): s.settle(30) check( "opening it changes nothing by itself", - s.api("theme")["theme"] == "lime", + s.api("theme")["theme"] == "zzlime", str(s.api("theme")), ) - s.send(r"\e[B") # the selection starts on the one being worn + s.send("zz") # narrowed to the two this test wrote + s.settle(30) + s.send(r"\e[B") s.settle(30) check( "moving to the next one puts it on", - s.api("theme")["theme"] == "rose", + s.api("theme")["theme"] == "zzrose", str(s.api("theme")), ) s.send(r"\x1b") # escape s.settle(30) check( "escaping puts back what was worn", - s.api("theme")["theme"] == "lime", + s.api("theme")["theme"] == "zzlime", str(s.api("theme")), ) check("...to the cell, not just in the reply", frame(s) == before, frame(s)) def test_enter_keeps_it_for_the_session_only(): - 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') + d = pair_home() + cfg = write(os.path.join(d, "config.kdl"), 'theme_name "zzlime"\n') with Session(SH, cols=64, rows=16, config=cfg) as s: s.settle(30) s.key("t") s.settle(30) - s.send(r"\e[B") + s.send("zzrose") s.settle(30) s.send(r"\r") s.settle(30) screen = s.snapshot().screen() - check("the picker is gone", "1 of 2" not in screen, screen) + check("the picker is gone", "in your config" not in screen, screen) check("...saying it was not written down", "session only" in screen, screen) check("the theme stayed", frame(s) == PINK, frame(s)) check( "and your config was not edited by looking at a list", - 'theme_name "lime"' in open(cfg).read(), + 'theme_name "zzlime"' in open(cfg).read(), open(cfg).read(), ) def test_ctrl_s_writes_it_down(): - d = home( - lime='theme { frame_focus "%s" }\n' % GREEN, - rose='theme { frame_focus "%s" }\n' % PINK, - ) - cfg = write(os.path.join(d, "config.kdl"), '// mine\ntheme_name "lime"\ngap 1\n') + d = pair_home() + cfg = write(os.path.join(d, "config.kdl"), '// mine\ntheme_name "zzlime"\ngap 1\n') with Session(SH, cols=64, rows=16, config=cfg) as s: s.settle(30) s.key("t") s.settle(30) - s.send(r"\e[B") + s.send("zzrose") s.settle(30) s.send(r"\x13") # C-s # A reply to wait on: `settle` is fire-and-forget, so reading the file # straight after it races the session still writing it. s.until_text("written to your config") text = open(cfg).read() - check("the name is in the config now", 'theme_name "rose"' in text, text) + check("the name is in the config now", 'theme_name "zzrose"' in text, text) check("...and the rest of the file survived", "// mine" in text, text) check( "it says so out loud", @@ -425,7 +469,9 @@ def test_ctrl_s_writes_it_down(): ) -def test_with_no_themes_it_says_where_it_looked(): +def test_a_config_with_no_themes_of_its_own_still_has_a_picker(): + """Nothing in your theme directory is the state everybody starts in, and + the answer is the shipped set rather than an empty box.""" d = home() cfg = write(os.path.join(d, "config.kdl"), "gap 1\n") with Session(SH, cols=64, rows=16, config=cfg) as s: @@ -433,8 +479,8 @@ def test_with_no_themes_it_says_where_it_looked(): s.key("t") s.settle(30) screen = s.snapshot().screen() - check("no empty box", "themes" not in screen.split("\n")[4], screen) - check("a toast naming the directory instead", "no themes in" in screen, screen) + check("the picker opened", "themes" in screen, screen) + check("with the shipped themes in it", "phosphor" in screen, screen) if __name__ == "__main__": @@ -448,6 +494,7 @@ if __name__ == "__main__": 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_the_shipped_themes_are_found_beside_the_binary() 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() @@ -456,5 +503,5 @@ if __name__ == "__main__": test_moving_the_selection_wears_the_theme() test_enter_keeps_it_for_the_session_only() test_ctrl_s_writes_it_down() - test_with_no_themes_it_says_where_it_looked() + test_a_config_with_no_themes_of_its_own_still_has_a_picker() sys.exit(report()) |
