aboutsummaryrefslogtreecommitdiffstats
path: root/src/config.c
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-19 13:46:57 +0200
committeruser <user@clank>2026-08-19 13:46:57 +0200
commit42b3c10995eb67778d9dd3aa2fd80624896acd5f (patch)
tree751bcb8604967dc8687cf23fbeb1033aa26ca427 /src/config.c
parentslosh: a website that is a page, not a mock session (diff)
downloadslosh-42b3c10995eb67778d9dd3aa2fd80624896acd5f.tar.gz
slosh-42b3c10995eb67778d9dd3aa2fd80624896acd5f.tar.xz
slosh-42b3c10995eb67778d9dd3aa2fd80624896acd5f.zip
slosh: defaults most people can live in, said by shaders
The out-of-the-box look, rethought as one piece: - the compiled-in palette calms down: muted blue #7aa2f7 on the same neutral darks, with the colours that mean something kept semantic -- amber bell, red-orange dead. sl0p stays one include away as the house style, and contrib/themes/default.kdl is the starting point written down (theme-tour shows it). - derived state defaults now actually follow the theme: the scrolled wash is rebuilt from scroll_bg after the theme block (it used to bake the compiled accent), and declaring a state -- in any file of an include chain -- still wins. The dump writes derived states as comments so C-a e cannot pin what is meant to keep following. - a bell you can see: the frame blinks three times in theme's bell colour then breathes until answered, and a soft diagonal shimmer -- the bell colour halved towards default_fg, width breathing on a sine -- crosses the body for the same 900ms. bell_indicator false silences all of it; states { bell { } } replaces it like any state. since carries the clock dependency, so an idle session pays nothing. - scrollback says where it continues: while scrolled, the viewport's top and bottom rows melt towards an edge with content past it and turn solid at the buffer's end. New expression variables above/below (lines hidden past each edge, own dependency bit, cached per scroll position), carried in shade_ctx_t -- SLOSH_SHADER_ABI 3 -> 4. - expressions survive the dump: a program keeps its source and cb_chain writes it back, so amount="(y % 2) * 40" no longer dumps as a number. - headers move to include/, -Iinclude everywhere; the plugin Makefile's SLOSH var becomes SLOSH_DIR because slosh exports SLOSH=1 into every pane it runs, and building from inside a session read that as a path. - comment drift culled where features outran the prose, and the derivation boilerplate folded into derived_pass()/ctx_scroll().
Diffstat (limited to 'src/config.c')
-rw-r--r--src/config.c238
1 files changed, 210 insertions, 28 deletions
diff --git a/src/config.c b/src/config.c
index 48622b2..2d65998 100644
--- a/src/config.c
+++ b/src/config.c
@@ -687,6 +687,120 @@ static void apply_dim_unfocused(config_t *c, bool declared) {
c->state_n[PSTATE_UNFOCUSED] = 1;
}
+/* One derived pass: a shader whose amount is a compiled-in expression,
+ * written into `sh` with its program registered on the config, which owns
+ * every program. The sources are string literals in this file, so a compile
+ * failure is a can't-happen guard rather than a path with a story -- the
+ * caller just stops deriving and ships what it has. */
+static bool derived_pass(config_t *c, shader_t *sh, const char *kind,
+ color_t color, uint8_t channels, const char *src) {
+ shader_make(sh, kind, color, 128);
+ sh->channels = channels;
+ char eerr[128] = {0};
+ expr_prog_t *p = expr_compile(src, eerr, sizeof eerr);
+ if (!p) return false;
+ sh->amount_expr = p;
+ c->exprs = realloc(c->exprs, (c->nexprs + 1) * sizeof *c->exprs);
+ c->exprs[c->nexprs++] = p;
+ return true;
+}
+
+/* The scrolled default, derived *after* the theme block is read rather than
+ * baked from the compiled palette a theme was about to replace. `declared` is
+ * whether the config named `scrolled` itself, in which case its chain --
+ * including an empty one -- stands.
+ *
+ * The wash: the scroll indicator's own colour (theme's scroll_bg), weak
+ * (about 9%) because scrollback is something you are reading through it.
+ *
+ * The edge fades: the viewport's top and bottom rows melt towards an edge
+ * while there is content past it -- `above`/`below` are the lines hidden past
+ * each edge, so `(above > 0)` snaps the top fade off at the top of the
+ * buffer: an edge you cannot scroll past renders solid, which is the whole
+ * message. (Reaching the bottom ends the state itself, same statement.) Up to
+ * three rows a side and never more than a quarter of the viewport each,
+ * because two three-row fades on a six-row viewport is every row faded and
+ * nothing said. They ride after the wash so they dim washed cells rather
+ * than racing it. */
+#define SCROLL_FADE_TOP "(above > 0) * max(0, (min(3, rows / 4) - y) * 45)"
+#define SCROLL_FADE_BOTTOM \
+ "(below > 0) * max(0, (y - rows + min(3, rows / 4) + 1) * 45)"
+
+static void apply_scrolled(config_t *c, bool declared) {
+ if (declared) return;
+ shader_t *chain = c->state_shaders[PSTATE_SCROLLED];
+ c->state_n[PSTATE_SCROLLED] = 0;
+ shader_make(&chain[0], "tint", c->scroll_bg, 22);
+ c->state_n[PSTATE_SCROLLED] = 1;
+ if (!derived_pass(c, &chain[1], "dim", (color_t){0}, 0, SCROLL_FADE_TOP))
+ return;
+ c->state_n[PSTATE_SCROLLED] = 2;
+ if (!derived_pass(c, &chain[2], "dim", (color_t){0}, 0, SCROLL_FADE_BOTTOM))
+ return;
+ c->state_n[PSTATE_SCROLLED] = 3;
+}
+
+/* What an unanswered bell does to the frame of the pane that rang: three
+ * full-strength blinks over the first 900ms -- the part that catches the eye
+ * the moment it rings -- then a slow breathe, so a bell nobody answered stays
+ * findable without nagging. The breathe's phase constant starts it at its
+ * dimmest (sin(since/8 + 158) is about -1 at since=900), so the hand-off from
+ * the last dark blink is a fade-in rather than a pop. */
+#define BELL_FLASH_AMOUNT \
+ "since < 900 ? (since / 150 % 2 == 0) * 255 : 120 + sin(since / 8 + 158) / " \
+ "3"
+
+/* ...and to its body, for those same 900ms and not a millisecond longer: a
+ * shimmer -- one soft diagonal sheen gliding across, the way light crosses
+ * something glossy. Deliberately quiet next to the frame: peak 95 where the
+ * blinks are 255, with a fade slow enough (since / 64) that the band keeps
+ * its strength across the whole sweep and the 900ms cut is what ends it. `x + 2 * y` keeps the band at a true diagonal on
+ * a terminal's 1:2 cells, and motion is the whole trick: the eye is pulled by
+ * something *moving* long before it is pulled by something bright. The band's
+ * width breathes on a sine while it travels -- the falloff slope swings 6±2
+ * over roughly one swell per lifetime (sin is -255..255, so /96 is ±2), about
+ * ±14 to ±27 columns -- which is what makes it read as a living gleam rather
+ * than a ruled stripe. The shimmer is the announcement, the frame the
+ * reminder. */
+#define BELL_SHIMMER_AMOUNT \
+ "(since < 900) * " \
+ "max(0, 95 - abs(x + 2 * y - since / 4) * (6 + sin(since / 2) / 96) - " \
+ "since / 64)"
+
+/* The shimmer's colour: the bell colour softened halfway towards the theme's
+ * default_fg -- light crossing the pane rather than paint laid on it. Derived
+ * from both ends of the theme, so it follows either being changed. */
+static color_t bell_sheen(const config_t *c) {
+ return (color_t){.set = true,
+ .r = (uint8_t)((c->bell.r + c->default_fg.r) / 2),
+ .g = (uint8_t)((c->bell.g + c->default_fg.g) / 2),
+ .b = (uint8_t)((c->bell.b + c->default_fg.b) / 2)};
+}
+
+/* Derived like the scrolled wash, and for the same reason: both passes are in
+ * theme's `bell` colour, so they are built after the theme is read and a
+ * theme moves the mark, the flash and the shimmer together. The frame pass is
+ * `channel="fg"`, because a frame's background is the terminal's own default
+ * and colouring glyphs is the whole job; the body pass keeps both channels,
+ * because most of a body is blank cells whose background is the only thing a
+ * shimmer can travel across. It follows `bell_indicator` off -- that knob's
+ * promise is "silent *and* invisible" -- and a `states { bell { } }` chain
+ * somebody wrote replaces it, like every state. The expressions read `since`,
+ * so the frame clock this costs runs only while a rung pane is on screen. */
+static void apply_bell(config_t *c, bool declared) {
+ if (declared) return;
+ c->state_n[PSTATE_BELL] = 0;
+ c->chrome_state_n[PSTATE_BELL] = 0;
+ if (!c->bell_indicator) return;
+
+ if (derived_pass(c, &c->chrome_state_shaders[PSTATE_BELL][0], "tint", c->bell,
+ SHADE_FG, BELL_FLASH_AMOUNT))
+ c->chrome_state_n[PSTATE_BELL] = 1;
+ if (derived_pass(c, &c->state_shaders[PSTATE_BELL][0], "tint", bell_sheen(c),
+ 0, BELL_SHIMMER_AMOUNT))
+ c->state_n[PSTATE_BELL] = 1;
+}
+
void config_defaults(config_t *c) {
memset(c, 0, sizeof *c);
c->gap = 1;
@@ -777,9 +891,8 @@ void config_defaults(config_t *c) {
c->default_fg = rgb(0xff, 0xff, 0xff);
c->default_bg = rgb(0x00, 0x00, 0x00);
- /* The one state with an opinion out of the box: while a pane is being
- * dragged, everything it could be dropped onto is pushed back so the pane in
- * your hand stands out. Everything else is empty until asked for. */
+ /* While a pane is being dragged, everything it could be dropped onto is
+ * pushed back so the pane in your hand stands out. */
shader_make(&c->state_shaders[PSTATE_DROP_TARGET][0], "grayscale",
(color_t){0}, 200);
shader_make(&c->state_shaders[PSTATE_DROP_TARGET][1], "dim", (color_t){0},
@@ -790,21 +903,12 @@ void config_defaults(config_t *c) {
shader_make(&c->state_shaders[PSTATE_DROP_HOVER][1], "dim", (color_t){0},
140);
c->state_n[PSTATE_DROP_HOVER] = 2;
- /* The states with an opinion, and the line between those and the ones
- * without it:
- *
- * a pane that is *not live* gets one — dead, suspended, scrolled. In all
- * three the cells are showing something other than a running program's
- * present: output from something that has exited, a pane that never
- * started, or the past. You cannot discover any of that by looking unless
- * something says so, and that is the whole argument for the feature;
- *
- * a pane that is merely *not the one you are in* does not — unfocused,
- * dragging. That is ambient contrast, which is a taste, and shipping a
- * taste as a default is how a tool gets a reputation for fighting you.
- *
- * All three are gentle on purpose. A dead pane, a suspended one and
- * scrollback are all things you still want to read. */
+ /* Which states ship a default, and why the line falls where it does, is
+ * argued in full in config/config.kdl (the states section) -- one copy of
+ * that essay is one that cannot drift. The short form: not-live states
+ * (dead, suspended, scrolled) and news (bell) get one; unfocused gets one
+ * through the dim_unfocused knob; dragging gets none. All gentle, because
+ * every one of them is a pane you still want to read. */
shader_make(&c->state_shaders[PSTATE_DEAD][0], "grayscale", (color_t){0},
200);
shader_make(&c->state_shaders[PSTATE_DEAD][1], "dim", (color_t){0}, 90);
@@ -819,7 +923,13 @@ void config_defaults(config_t *c) {
apply_dim_unfocused(c, false);
- const color_t accent = rgb(0xff, 0x5f, 0xd7);
+ /* Muted blue on neutral dark: the accent has to say "this one" on the
+ * focused frame, the guide and the active tab without shouting over the
+ * text it frames, and blue is the one hue that reads as focus/selection
+ * everywhere else, sits apart from the red-orange reserved for `dead`, and
+ * survives the common colour-vision deficiencies doing it. The house pink
+ * lives on as contrib/themes/sl0p.kdl, one include away. */
+ const color_t accent = rgb(0x7a, 0xa2, 0xf7);
const color_t ink = rgb(0x14, 0x14, 0x18);
const color_t dim = rgb(0x45, 0x45, 0x4a);
const color_t bright = rgb(0xff, 0xff, 0xff);
@@ -840,13 +950,9 @@ void config_defaults(config_t *c) {
c->scroll_fg = ink;
c->scroll_bg = accent;
- /* Looking at the past, said in colour as well as in the ▲ count: a wash of
- * the same accent the scroll indicator uses, so a theme moves both together
- * rather than leaving a hardcoded hue nobody can reach. Weak (about 9%) —
- * it has to survive being read through. Attached here rather than beside
- * the other states because it is the one that needs the palette. */
- shader_make(&c->state_shaders[PSTATE_SCROLLED][0], "tint", c->scroll_bg, 22);
- c->state_n[PSTATE_SCROLLED] = 1;
+ /* After scroll_bg above, and re-derived after a config's theme block: the
+ * rationale lives at apply_scrolled. */
+ apply_scrolled(c, false);
c->header = dim;
c->header_hover = accent;
@@ -871,9 +977,17 @@ void config_defaults(config_t *c) {
c->finder_sel_fg = ink;
c->finder_sel_bg = accent;
- c->bell = accent;
+ /* The two colours that mean something rather than match something: a bell
+ * is attention, so amber, not the accent -- painted in the focus colour it
+ * said nothing -- and a dead pane is warm red-orange. Both sit apart from
+ * the blue accent and from each other. */
+ c->bell = rgb(0xe0, 0xaf, 0x68);
c->dead = rgb(0xff, 0x87, 0x5f);
+ /* After c->bell above, and re-derived after a config's theme block: the
+ * rationale lives at apply_bell. */
+ apply_bell(c, false);
+
/* A surface, not a hole: lighter than the dimmed screen behind it, with a
* border in the accent so the edge is never in doubt. The button is a grey
* that is *visible* on that surface — the first version used the pane
@@ -1307,7 +1421,15 @@ static void cb_chain(cfgbuf_t *b, const char *indent, const shader_t *sh,
size_t n, bool chrome) {
for (size_t i = 0; i < n; i++) {
if (!sh[i].kind) continue;
- cb_add(b, "%s%s amount=%u", indent, sh[i].kind, sh[i].amount);
+ /* An expression is written back as the line somebody wrote; only a
+ * numeric amount is a number. Pretending otherwise is how a dumped
+ * config used to lose every expression it contained. */
+ if (sh[i].amount_expr && expr_source(sh[i].amount_expr)) {
+ cb_add(b, "%s%s amount=", indent, sh[i].kind);
+ cb_qval(b, expr_source(sh[i].amount_expr));
+ } else {
+ cb_add(b, "%s%s amount=%u", indent, sh[i].kind, sh[i].amount);
+ }
if (sh[i].color.set)
cb_add(b, " color=\"#%02x%02x%02x\"", sh[i].color.r, sh[i].color.g,
sh[i].color.b);
@@ -1321,6 +1443,33 @@ static void cb_chain(cfgbuf_t *b, const char *indent, const shader_t *sh,
}
}
+/* Whether a state's chain is still the one the loader derives rather than one
+ * somebody wrote: `unfocused` from the dim_unfocused knob, `scrolled` from
+ * theme's scroll_bg. Field-wise rather than memcmp, because a struct copy
+ * carries padding nobody initialised. */
+static bool state_is_derived(const config_t *c, int st) {
+ switch (st) {
+ /* bell and scrolled carry compiled expressions, which cannot be compared
+ * structurally -- but they also cannot be written by a config, so the
+ * declared flags (persisted across an include chain) are the whole test. */
+ case PSTATE_BELL: return !c->bell_declared;
+ case PSTATE_SCROLLED: return !c->scrolled_declared;
+ /* unfocused has no flag -- the knob is re-applied per file -- so it is
+ * compared against what the knob would build: exactly one plain dim at the
+ * knob's strength, or nothing when the knob is 0. */
+ case PSTATE_UNFOCUSED: {
+ if (c->chrome_state_n[st]) return false;
+ if (!c->dim_unfocused) return c->state_n[st] == 0;
+ if (c->state_n[st] != 1) return false;
+ const shader_t *got = &c->state_shaders[st][0];
+ return got->kind && strcmp(got->kind, "dim") == 0 && !got->amount_expr &&
+ got->amount == c->dim_unfocused && !got->color.set && !got->param &&
+ !got->channels;
+ }
+ default: return false;
+ }
+}
+
char *config_render(const config_t *c) {
cfgbuf_t b = {0};
@@ -1462,6 +1611,32 @@ char *config_render(const config_t *c) {
cb_add(&b,
"\n// ---- what a pane looks like in a given state ----\nstates {\n");
for (int st = 0; st < PSTATE_COUNT; st++) {
+ /* The derived states are written as comments while they still match
+ * their derivation: declaring a state replaces its default outright, so a
+ * dump that declared these would pin what is meant to keep following the
+ * dim_unfocused knob and the theme -- and `C-a e` seeds a fresh config
+ * with exactly this text, which is the moment nobody has declared
+ * anything yet. The round trip is unharmed, because loading the comment
+ * derives the identical chain. A chain somebody replaced no longer
+ * matches, and is written out declared, as it must be. */
+ if (state_is_derived(c, st)) {
+ const char *why =
+ st == PSTATE_BELL ? "a body shimmer, and a frame "
+ "blink-then-breathe, in theme's bell "
+ "colour"
+ : st == PSTATE_SCROLLED ? "a wash of theme's scroll_bg, and edge "
+ "fades where more content is"
+ : "the dim_unfocused knob above";
+ cb_add(&b, " // %s { // derived: %s\n",
+ pane_state_name((pane_state_t)st), why);
+ cb_chain(&b, " // ", c->state_shaders[st], c->state_n[st], false);
+ cb_chain(&b, " // ", c->chrome_state_shaders[st],
+ c->chrome_state_n[st], true);
+ if (st == PSTATE_BELL && !c->state_n[st] && !c->chrome_state_n[st])
+ cb_add(&b, " // // nothing: bell_indicator is false\n");
+ cb_add(&b, " // }\n");
+ continue;
+ }
cb_add(&b, " %s {\n", pane_state_name((pane_state_t)st));
cb_chain(&b, " ", c->state_shaders[st], c->state_n[st], false);
cb_chain(&b, " ", c->chrome_state_shaders[st], c->chrome_state_n[st],
@@ -2036,6 +2211,8 @@ static bool load_into(config_t *c, const char *path, int depth, char *err,
continue;
}
if (st == PSTATE_UNFOCUSED) unfocused_declared = true;
+ if (st == PSTATE_SCROLLED) c->scrolled_declared = true;
+ if (st == PSTATE_BELL) c->bell_declared = true;
parse_shader_list(c, k, c->state_shaders[st], &c->state_n[st],
c->chrome_state_shaders[st], &c->chrome_state_n[st],
err, errcap);
@@ -2055,6 +2232,11 @@ static bool load_into(config_t *c, const char *path, int depth, char *err,
"bad colour for %s: %s", THEME_COLORS[i].name, v);
}
}
+ /* After the theme, so the wash follows whatever scroll_bg the theme just
+ * chose -- a config that wrote its own `scrolled` chain keeps it, whether
+ * the chain was written here or in a file this one included. */
+ apply_scrolled(c, c->scrolled_declared);
+ apply_bell(c, c->bell_declared);
const kdl_node_t *keys = kdl_child(root, "keys");
if (keys) {