aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_theme_names.py
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 /tests/test_theme_names.py
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 'tests/test_theme_names.py')
-rw-r--r--tests/test_theme_names.py321
1 files changed, 321 insertions, 0 deletions
diff --git a/tests/test_theme_names.py b/tests/test_theme_names.py
new file mode 100644
index 0000000..63bb0cc
--- /dev/null
+++ b/tests/test_theme_names.py
@@ -0,0 +1,321 @@
+#!/usr/bin/env python3
+"""Named themes: theme_dir, theme_name, and switching one while it runs.
+
+A theme is a file in a directory and a config names it. The rules being
+checked here are the ones that make a *name* worth more than a pasted palette:
+the named theme is the base and anything beside it wins, the directory is
+searched before the installed one so a theme of yours shadows a shipped one,
+a name is a name and not a path, and a switch at runtime survives the reload
+that follows it.
+"""
+
+import json
+import os
+import subprocess
+import sys
+import tempfile
+
+from harness import BIN, Session, check, report
+
+SH = ["/bin/sh", "-c", "stty raw -echo; cat"]
+
+GREEN, PINK, BLUE = "#00ff00", "#ff0088", "#3355ff"
+
+
+def home(**themes):
+ """A config directory: config.kdl plus themes/, and the config's path."""
+ d = tempfile.mkdtemp()
+ os.makedirs(os.path.join(d, "themes"))
+ for name, body in themes.items():
+ with open(os.path.join(d, "themes", name + ".kdl"), "w") as f:
+ f.write(body)
+ return d
+
+
+def write(path, text):
+ with open(path, "w") as f:
+ f.write(text)
+ return path
+
+
+def frame(s):
+ """The focused pane's frame colour: one cell that every theme names."""
+ p = s.pane()
+ return (s.snapshot().style_at(p["x"], p["y"] + 1) or {}).get("fg")
+
+
+def test_a_named_theme_is_read_from_the_theme_dir():
+ d = home(lime='theme { frame_focus "%s" }\n' % GREEN)
+ cfg = write(os.path.join(d, "config.kdl"), 'theme_name "lime"\n')
+ with Session(SH, cols=50, rows=10, config=cfg) as s:
+ s.settle(30)
+ check("the theme's colour is the one drawn", frame(s) == GREEN, frame(s))
+
+
+def test_the_config_beats_the_theme_it_named():
+ """The same bargain include makes: what you asked for is the base, what you
+ wrote beside it is yours -- wherever the line happens to sit."""
+ d = home(lime='theme { frame_focus "%s" title "#111111" }\n' % GREEN)
+ cfg = write(
+ os.path.join(d, "config.kdl"),
+ 'theme { frame_focus "%s" }\ntheme_name "lime"\n' % PINK,
+ )
+ with Session(SH, cols=50, rows=10, config=cfg) as s:
+ s.settle(30)
+ check("the config's own colour wins", frame(s) == PINK, frame(s))
+
+
+def test_a_theme_dir_of_your_own():
+ d = home()
+ other = tempfile.mkdtemp()
+ write(os.path.join(other, "lime.kdl"), 'theme { frame_focus "%s" }\n' % GREEN)
+ cfg = write(
+ os.path.join(d, "config.kdl"),
+ 'theme_dir "%s"\ntheme_name "lime"\n' % other,
+ )
+ with Session(SH, cols=50, rows=10, config=cfg) as s:
+ s.settle(30)
+ check("the named directory is where it looked", frame(s) == GREEN, frame(s))
+
+
+def test_a_relative_theme_dir_is_relative_to_the_file():
+ """Like an include's path: a config is a thing on disk that refers to its
+ neighbours, not to wherever you were standing when you started."""
+ d = home()
+ os.makedirs(os.path.join(d, "palettes"))
+ write(
+ os.path.join(d, "palettes", "lime.kdl"), 'theme { frame_focus "%s" }\n' % GREEN
+ )
+ cfg = write(
+ os.path.join(d, "config.kdl"), 'theme_dir "palettes"\ntheme_name "lime"\n'
+ )
+ with Session(SH, cols=50, rows=10, config=cfg) as s:
+ s.settle(30)
+ check("resolved against the config, not the cwd", frame(s) == GREEN, frame(s))
+
+
+def test_a_missing_theme_is_a_line_and_no_more():
+ """D9: losing your keybindings over a mistyped theme name would be a worse
+ answer than a session that says so."""
+ d = home()
+ cfg = write(os.path.join(d, "config.kdl"), 'gap 3\ntheme_name "nope"\n')
+ r = subprocess.run([BIN, "--check", cfg], capture_output=True, text=True)
+ check("--check says so", "no theme named nope" in r.stderr, r.stderr.strip())
+ check("...and names where it looked", "themes" in r.stderr, r.stderr.strip())
+ with Session(SH, cols=50, rows=10, config=cfg) as s:
+ s.settle(30)
+ check("the session still runs", s.alive(), "")
+ check(
+ "and the rest of the config applied",
+ s.pane()["x"] >= 3,
+ str(s.pane()["x"]),
+ )
+
+
+def test_a_theme_name_is_a_name_not_a_path():
+ d = home()
+ cfg = write(os.path.join(d, "config.kdl"), 'theme_name "../../etc/passwd"\n')
+ r = subprocess.run([BIN, "--check", cfg], capture_output=True, text=True)
+ check(
+ "a path is refused rather than half-honoured",
+ "not a path" in r.stderr,
+ r.stderr.strip(),
+ )
+ check(
+ "...and it points at include instead", "include" in r.stderr, r.stderr.strip()
+ )
+
+
+def test_the_control_socket_lists_and_switches():
+ 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')
+ 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("...and says which is worn", r["theme"] == "lime", str(r))
+ check(
+ "...and where it looked, in order",
+ r["dirs"] and r["dirs"][0] == os.path.join(d, "themes"),
+ str(r.get("dirs")),
+ )
+
+ s.api("theme", name="rose")
+ s.settle(30)
+ check("switching repaints in the new colours", frame(s) == PINK, frame(s))
+
+ bad = s.api("theme", name="nope")
+ check("an unknown name is refused", not bad["ok"], str(bad))
+ s.settle(30)
+ check(
+ "...and costs nothing: the session keeps what it wore",
+ frame(s) == PINK,
+ frame(s),
+ )
+
+
+def test_a_switch_survives_the_next_reload():
+ """The session holds the name, not the file -- otherwise saving your config
+ for an unrelated reason would silently put the old colours back."""
+ 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')
+ with Session(SH, cols=50, rows=10, config=cfg) as s:
+ s.settle(30)
+ s.api("theme", name="rose")
+ write(cfg, 'theme_name "lime"\ngap 2\n') # an edit about something else
+ s.api("reload")
+ s.settle(30)
+ check("the switch is still in force", frame(s) == PINK, frame(s))
+
+ s.api("theme", name="") # ...until it is given back
+ s.settle(30)
+ check("cleared, the file's own theme returns", frame(s) == GREEN, frame(s))
+
+
+def test_saving_writes_one_line_and_leaves_the_rest_alone():
+ d = home(
+ lime='theme { frame_focus "%s" }\n' % GREEN,
+ rose='theme { frame_focus "%s" }\n' % PINK,
+ )
+ cfg = write(
+ os.path.join(d, "config.kdl"),
+ '// my config\ngap 2\ntheme_name "lime"\nsplash_ms 10\n',
+ )
+ with Session(SH, cols=50, rows=10, config=cfg) as s:
+ s.settle(30)
+ r = s.api("theme", name="rose", save=True)
+ check("it says it saved", r.get("saved") is True, str(r))
+ text = open(cfg).read()
+ check("the line is replaced", 'theme_name "rose"' in text, text)
+ check("...exactly once", text.count("theme_name") == 1, text)
+ check(
+ "...and every other line is untouched",
+ "// my config" in text and "gap 2" in text and "splash_ms 10" in text,
+ text,
+ )
+
+
+def test_saving_into_a_config_that_never_said_anything():
+ d = home(rose='theme { frame_focus "%s" }\n' % PINK)
+ cfg = write(os.path.join(d, "config.kdl"), "gap 1\n")
+ with Session(SH, cols=50, rows=10, config=cfg) as s:
+ s.settle(30)
+ s.api("theme", name="rose", save=True)
+ text = open(cfg).read()
+ check("the line is added", 'theme_name "rose"' in text, text)
+ check("...and the file it was added to is still there", "gap 1" in text, text)
+
+
+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."""
+ d = home(default='theme { frame_focus "%s" }\n' % BLUE)
+ cfg = write(os.path.join(d, "config.kdl"), 'theme_name "default"\n')
+ with Session(SH, cols=50, rows=10, config=cfg) as s:
+ s.settle(30)
+ check("the local file is the one read", frame(s) == BLUE, frame(s))
+
+
+def test_the_seed_config_offers_a_name_rather_than_a_palette():
+ """`--dump-config` writes the file to begin from, and the file to begin
+ from must not open with sixty colours: the whole point of a named theme is
+ that the colours are somewhere a name can follow when they change."""
+ d = home(lime='theme { frame_focus "%s" title "#010203" }\n' % GREEN)
+ cfg = write(
+ os.path.join(d, "config.kdl"),
+ 'theme_name "lime"\ntheme { frame_idle "%s" }\n' % PINK,
+ )
+ env = dict(os.environ, SLOSH_CONFIG=cfg)
+ r = subprocess.run([BIN, "--check", cfg], capture_output=True, text=True, env=env)
+ check("the config is clean", r.returncode == 0, r.stderr.strip())
+
+ dump = subprocess.run(
+ [BIN, "--dump-config"], capture_output=True, text=True, env=env
+ ).stdout
+ check(
+ "it names no theme of its own",
+ not any(l.startswith("theme_name") for l in dump.splitlines()),
+ dump[:200],
+ )
+ check(
+ "...but says the setting exists",
+ "// theme_name" in dump and "// theme_dir" in dump,
+ dump[:400],
+ )
+ check(
+ "...and pins no palette: one example line, not a block",
+ "\ntheme {" not in dump,
+ dump[:400],
+ )
+ seeded = write(os.path.join(d, "seeded.kdl"), dump)
+ r2 = subprocess.run([BIN, "--check", seeded], capture_output=True, text=True)
+ check("the file it writes loads clean", r2.returncode == 0, r2.stderr.strip())
+
+ theme = subprocess.run(
+ [BIN, "--dump-theme"], capture_output=True, text=True, env=env
+ ).stdout
+ check("--dump-theme resolves the palette", GREEN in theme, theme[:200])
+ check("...including what the config overrode", PINK in theme, theme[:200])
+ check("...and says what it started from", "lime" in theme, theme[:300])
+
+
+def test_a_dumped_theme_is_a_theme():
+ """--dump-theme has to produce a file that can be named and worn, which is
+ the only reason to write sixty colours out anywhere."""
+ d = home(lime='theme { frame_focus "%s" }\n' % GREEN)
+ cfg = write(os.path.join(d, "config.kdl"), 'theme_name "lime"\n')
+ env = dict(os.environ, SLOSH_CONFIG=cfg)
+ theme = subprocess.run(
+ [BIN, "--dump-theme"], capture_output=True, text=True, env=env
+ ).stdout
+ write(os.path.join(d, "themes", "mine.kdl"), theme)
+ write(os.path.join(d, "config2.kdl"), 'theme_name "mine"\n')
+ r = subprocess.run(
+ [BIN, "--check", os.path.join(d, "config2.kdl")],
+ capture_output=True,
+ text=True,
+ )
+ check("it lints as a theme", r.returncode == 0, r.stderr.strip())
+ with Session(SH, cols=50, rows=10, config=os.path.join(d, "config2.kdl")) as s:
+ s.settle(30)
+ check("and wearing it paints the same", frame(s) == GREEN, frame(s))
+
+
+def test_editing_the_theme_repaints_on_reload():
+ """A theme read by the loader is a file the session was built from, so it
+ is watched like every other one -- reload here, since the scripted driver
+ has no watcher of its own."""
+ d = home(lime='theme { frame_focus "%s" }\n' % GREEN)
+ cfg = write(os.path.join(d, "config.kdl"), 'theme_name "lime"\n')
+ with Session(SH, cols=50, rows=10, config=cfg) as s:
+ s.settle(30)
+ write(
+ os.path.join(d, "themes", "lime.kdl"), 'theme { frame_focus "%s" }\n' % PINK
+ )
+ s.api("reload")
+ s.settle(30)
+ check("the edited theme is what is drawn", frame(s) == PINK, frame(s))
+
+
+if __name__ == "__main__":
+ test_a_named_theme_is_read_from_the_theme_dir()
+ test_the_config_beats_the_theme_it_named()
+ test_a_theme_dir_of_your_own()
+ test_a_relative_theme_dir_is_relative_to_the_file()
+ test_a_missing_theme_is_a_line_and_no_more()
+ test_a_theme_name_is_a_name_not_a_path()
+ test_the_control_socket_lists_and_switches()
+ 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_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()
+ test_editing_the_theme_repaints_on_reload()
+ sys.exit(report())