diff options
| author | user <user@clank> | 2026-08-14 13:42:00 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-14 13:42:00 +0200 |
| commit | 4333a40dce43866490396d4418f268c5d1037ff6 (patch) | |
| tree | 5941187fa14279cfe344ac2b55d6ea58ed66025f /src | |
| parent | sl0ppty: the pane title is a name — double-click it to rename (diff) | |
| download | slosh-4333a40dce43866490396d4418f268c5d1037ff6.tar.gz slosh-4333a40dce43866490396d4418f268c5d1037ff6.tar.xz slosh-4333a40dce43866490396d4418f268c5d1037ff6.zip | |
sl0ppty: terminal shaders — a colour pass over pane contents
A shader is a pure function from (cell, position, parameters) to that cell's
colours, applied to a pane's contents. Tinting, dimming and greying are the
first three; the point is the mechanism, because it is going to carry a lot of
UX later — drag a pane and grey the others, dim what is not focused, mark a
pane that wants attention — and all of those want to be a colour pass rather
than another special case in the drawing code.
Where it runs is the whole design. draw_cb already paints the frame, then the
contents, then the split guide, so the pass goes between the last two and
"contents, not chrome" stops being a rule anyone has to remember: the frame
was painted before and lies outside the content rect, and an affordance
painted after stays legible on top of a pane that has been dimmed underneath
it. node_t already carries the content rect, so the pass has its bounds.
Cells only, and colours and attributes only. Rewriting a cell's text would
desync selection and copy, which read the terminal rather than the screen, and
could break the wide-cell invariant; the tail half of a wide cell is skipped
for the same reason, since shading it computes a colour nothing displays.
Each cell is shaded independently — no neighbours — so anything positional
works and convolutions do not. That is deliberate: blur would need a second
buffer, and everything actually wanted here is a function of position and
colour. The context carries the content size and rect-relative coordinates so
gradients and vignettes are expressible, plus a timestamp for whatever ends up
animated.
The one real problem is that "terminal default" has no RGB. We never learn
what the client's default foreground is, and most terminal text is
default-coloured, so a shader that skipped those cells would leave nearly
everything at full brightness and read as broken. The pass materialises them
to a configured default_fg/default_bg (white on black) before running, which
means a shaded pane stops tracking the terminal's own theme for those cells —
so the config exists to make that right, and asking the terminal directly with
OSC 10/11 is the eventual fix. A pane with no shaders skips the pass entirely
and still defers to the terminal exactly as before.
Attachment is internal policy: app_shade_add/clear, callable from inside the
app and from nowhere else. Not over the control API and not in-band, so a
program in a pane cannot restyle itself. Nothing attaches one yet.
Kitty graphics are re-emitted as images rather than cells, so a shader cannot
touch them: a greyed pane keeps a full-colour image in it. Known and accepted.
Tested as a C unit test, since the pass is pure: the built-ins and their
strength scale, luma weighting, chaining order mattering, defaults being
materialised, wide-cell tails and text surviving, degenerate input, what the
context tells a shader, and — the one that protects the chrome — that the pass
touches nothing outside its rect.
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.c | 46 | ||||
| -rw-r--r-- | src/app.h | 10 | ||||
| -rw-r--r-- | src/config.c | 4 | ||||
| -rw-r--r-- | src/config.h | 7 | ||||
| -rw-r--r-- | src/shader.c | 104 | ||||
| -rw-r--r-- | src/shader.h | 70 |
6 files changed, 241 insertions, 0 deletions
@@ -83,6 +83,12 @@ struct node { pane_t *pane; uint32_t id; rect_t content; /* where the pane's cells go */ + + /* Colour passes over this pane's contents, applied in order. Attached by + * policy inside this file; nothing in-band or over the control API can set + * them yet, so a program cannot restyle itself by accident. */ + shader_t shaders[SHADE_MAX]; + size_t nshaders; char purpose[64]; bool purpose_locked; /* declared by a layout: in-band cannot override */ @@ -1420,6 +1426,42 @@ static void draw_collapsed(app_t *a, screen_t *s, node_t *n) { hit_add(&s->hits, r.x, r.y, r.w, 1, action); } +/* ---- shaders ------------------------------------------------------------ */ + +bool app_shade_add(app_t *a, uint32_t pane_id, const char *kind, color_t color, + uint8_t amount) { + node_t *n = pane_by_id(a, pane_id); + if (!n || n->nshaders >= SHADE_MAX) return false; + shader_t sh; + if (!shader_make(&sh, kind, color, amount)) return false; + n->shaders[n->nshaders++] = sh; + return true; +} + +void app_shade_clear(app_t *a, uint32_t pane_id) { + node_t *n = pane_by_id(a, pane_id); + if (n) n->nshaders = 0; +} + +size_t app_shade_count(app_t *a, uint32_t pane_id) { + node_t *n = pane_by_id(a, pane_id); + return n ? n->nshaders : 0; +} + +/* A pane with no shaders costs nothing: no context is built and no cell is + * visited, so an unshaded session emits the same bytes it did before. */ +static void shade_leaf(app_t *a, screen_t *s, node_t *n) { + if (!n->nshaders) return; + shade_ctx_t base = { + .now_ms = now_ms_(), + .focused = n == cur(a)->focus, + .default_fg = CFG.default_fg, + .default_bg = CFG.default_bg, + }; + shade_apply(s, n->shaders, n->nshaders, n->content.x, n->content.y, + n->content.w, n->content.h, &base); +} + static void draw_node(app_t *a, screen_t *s, node_t *n); static void draw_cb(node_t *n, void *ud) { @@ -1462,6 +1504,10 @@ static void draw_cb(node_t *n, void *ud) { return; } pane_compose(n->pane, d->s, n->content.x, n->content.y, n == cur(d->a)->focus); + /* Between the contents and the chrome that goes over them: the frame was + * painted before this and lies outside the content rect, and the split guide + * is painted after, so it stays legible on top of a shaded pane. */ + shade_leaf(d->a, d->s, n); draw_split_guide(d->a, d->s, n); } @@ -5,6 +5,7 @@ #ifndef SL0PPTY_APP_H #define SL0PPTY_APP_H +#include "shader.h" #include "sl0ppty.h" typedef struct { @@ -89,6 +90,15 @@ bool app_close_pane(app_t *a, uint32_t id); uint32_t app_focused_pane_id(app_t *a); uint32_t app_current_tab_id(app_t *a); +/* Colour passes over a pane's contents (shader.h). Policy-only for now: these + * are called from inside the app, not reachable over the control API or from + * a program in a pane. False if the pane or the shader kind is unknown, or if + * the pane already has SHADE_MAX of them. */ +bool app_shade_add(app_t *a, uint32_t pane_id, const char *kind, color_t color, + uint8_t amount); +void app_shade_clear(app_t *a, uint32_t pane_id); +size_t app_shade_count(app_t *a, uint32_t pane_id); + /* State as JSON, for the control API and the harness. Caller frees. */ char *app_panes_json(app_t *a); char *app_tabs_json(app_t *a); diff --git a/src/config.c b/src/config.c index a55969a..c41a6f7 100644 --- a/src/config.c +++ b/src/config.c @@ -132,6 +132,9 @@ void config_defaults(config_t *c) { c->status_bar = true; c->focus_follows_mouse = true; + c->default_fg = rgb(0xff, 0xff, 0xff); + c->default_bg = rgb(0x00, 0x00, 0x00); + c->frame_focus = rgb(0xff, 0x5f, 0xd7); c->frame_idle = rgb(0x45, 0x45, 0x4a); c->title_focus = rgb(0xff, 0xff, 0xff); @@ -264,6 +267,7 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) { const char *name; color_t *slot; } colors[] = { + {"default_fg", &c->default_fg}, {"default_bg", &c->default_bg}, {"frame_focus", &c->frame_focus}, {"frame_idle", &c->frame_idle}, {"title", &c->title_focus}, {"button_fg", &c->button_fg}, {"button_bg", &c->button_bg}, {"button_bg_idle", &c->button_bg_idle}, diff --git a/src/config.h b/src/config.h index 8482116..c006ca0 100644 --- a/src/config.h +++ b/src/config.h @@ -64,6 +64,13 @@ typedef struct { bool status_bar; bool focus_follows_mouse; + /* What a cell's "terminal default" colour resolves to when a shader has to + * compute on it. We cannot know the client's real default, and most text is + * default-coloured, so a shader would otherwise leave nearly everything + * alone. Only consulted while shading: an unshaded pane still defers to the + * terminal exactly as before. */ + color_t default_fg, default_bg; + /* theme */ color_t frame_focus, frame_idle, title_focus; color_t button_fg, button_bg, button_bg_idle; diff --git a/src/shader.c b/src/shader.c new file mode 100644 index 0000000..ae47929 --- /dev/null +++ b/src/shader.c @@ -0,0 +1,104 @@ +/* The shader pass and the built-in shaders. See shader.h for the model. */ +#include "shader.h" + +#include <string.h> + +/* Integer maths throughout: this runs per cell per pass per frame, and a float + * here would buy nothing a byte channel can tell the difference between. */ +static uint8_t mix8(uint8_t from, uint8_t to, uint8_t amount) { + return (uint8_t)(((int)from * (255 - amount) + (int)to * amount) / 255); +} + +static color_t mix(color_t from, color_t to, uint8_t amount) { + return (color_t){true, mix8(from.r, to.r, amount), + mix8(from.g, to.g, amount), mix8(from.b, to.b, amount)}; +} + +/* Rec. 601 luma. The green weight is not a rounding error: the eye is far more + * sensitive to green, and averaging the channels instead produces a grey that + * reads as the wrong brightness. */ +static uint8_t luma(color_t c) { + return (uint8_t)(((int)c.r * 77 + (int)c.g * 150 + (int)c.b * 29) >> 8); +} + +/* ---- the built-ins ------------------------------------------------------ */ + +static void sh_dim(const shader_t *sh, const shade_ctx_t *ctx, cell_t *c) { + (void)ctx; + const color_t black = {true, 0, 0, 0}; + c->fg = mix(c->fg, black, sh->amount); + c->bg = mix(c->bg, black, sh->amount); +} + +static void sh_grayscale(const shader_t *sh, const shade_ctx_t *ctx, + cell_t *c) { + (void)ctx; + uint8_t f = luma(c->fg), b = luma(c->bg); + c->fg = mix(c->fg, (color_t){true, f, f, f}, sh->amount); + c->bg = mix(c->bg, (color_t){true, b, b, b}, sh->amount); +} + +static void sh_tint(const shader_t *sh, const shade_ctx_t *ctx, cell_t *c) { + (void)ctx; + c->fg = mix(c->fg, sh->color, sh->amount); + c->bg = mix(c->bg, sh->color, sh->amount); +} + +static const struct { + const char *name; + shade_fn fn; +} REGISTRY[] = { + {"dim", sh_dim}, + {"grayscale", sh_grayscale}, + {"tint", sh_tint}, +}; + +bool shader_make(shader_t *out, const char *kind, color_t color, + uint8_t amount) { + if (!out || !kind) return false; + for (size_t i = 0; i < sizeof REGISTRY / sizeof *REGISTRY; i++) { + if (strcmp(REGISTRY[i].name, kind) != 0) continue; + *out = (shader_t){REGISTRY[i].name, REGISTRY[i].fn, color, amount}; + return true; + } + return false; +} + +const char *shader_kind(size_t i) { + return i < sizeof REGISTRY / sizeof *REGISTRY ? REGISTRY[i].name : NULL; +} + +/* ---- the pass ----------------------------------------------------------- */ + +void shade_apply(screen_t *s, const shader_t *shaders, size_t n, uint16_t x0, + uint16_t y0, uint16_t w, uint16_t h, const shade_ctx_t *base) { + if (!s || !shaders || !n || !w || !h) return; + + shade_ctx_t ctx = base ? *base : (shade_ctx_t){0}; + ctx.cols = w; + ctx.rows = h; + + for (size_t i = 0; i < n && i < SHADE_MAX; i++) { + const shader_t *sh = &shaders[i]; + if (!sh->kind || !sh->fn) continue; + + for (uint16_t y = 0; y < h; y++) { + for (uint16_t x = 0; x < w; x++) { + cell_t *c = screen_at(s, (uint16_t)(x0 + x), (uint16_t)(y0 + y)); + /* width 0 is the tail half of a wide cell: never painted, so shading + * it would be computing a colour nothing can display. */ + if (!c || !c->width) continue; + + /* Materialised per shader rather than once for the chain, so a shader + * added later cannot observe a half-resolved cell. Idempotent: once + * set, this does nothing. */ + if (!c->fg.set) c->fg = ctx.default_fg; + if (!c->bg.set) c->bg = ctx.default_bg; + + ctx.x = x; + ctx.y = y; + sh->fn(sh, &ctx, c); + } + } + } +} diff --git a/src/shader.h b/src/shader.h new file mode 100644 index 0000000..74fa2d7 --- /dev/null +++ b/src/shader.h @@ -0,0 +1,70 @@ +/* Terminal shaders: per-cell colour computation over a pane's CONTENTS. + * + * A shader is a pure function from (cell, position, parameters) to that cell's + * colours. It runs after pane_compose() has written the pane's viewport into + * the screen and before the split guide is drawn over it, which is what makes + * "contents, not chrome" fall out of the existing paint order rather than + * needing a rule: the frame is painted before and lives outside the content + * rect, and an affordance drawn after stays legible on top of a dimmed pane. + * + * Cells only. A shader may write fg, bg and attrs; it may not touch text, len + * or width. Rewriting text would desync selection and copy (which read the + * terminal, not the screen) and could break the wide-cell invariant. + * + * Each cell is shaded independently — no neighbours — so effects are limited + * to what position and colour can express. That is most of them: tint, dim, + * grayscale, gradients, vignettes, anything positional. Convolutions (blur, + * bloom) would need a second buffer and are deliberately not possible here. + * + * Shaders chain: each is a complete pass, so `grayscale` then `tint` differs + * from `tint` then `grayscale`, and both are expressible. + */ +#ifndef SL0PPTY_SHADER_H +#define SL0PPTY_SHADER_H + +#include <stdbool.h> +#include <stddef.h> +#include <stdint.h> + +#include "sl0ppty.h" + +/* Per pane. Small and fixed: a stack this deep is already a lot of passes. */ +#define SHADE_MAX 4 + +typedef struct { + uint16_t x, y; /* cell position within the content rect, 0-based */ + uint16_t cols, rows; /* content size, so an effect can be positional */ + int64_t now_ms; /* for anything animated */ + bool focused; + + /* What "terminal default" means while shading. A cell whose colour is unset + * is drawn in whatever the client's terminal calls default, and we never + * learn that RGB — but most terminal text is default-coloured, so a shader + * that skipped those cells would visibly do nothing. The pass materialises + * them to these before running the chain. Configured, because guessing is + * the one thing that would be worse. */ + color_t default_fg, default_bg; +} shade_ctx_t; + +typedef struct shader shader_t; +typedef void (*shade_fn)(const shader_t *sh, const shade_ctx_t *ctx, cell_t *c); + +struct shader { + const char *kind; /* registry name, NULL for an empty slot */ + shade_fn fn; + color_t color; /* the target colour, for shaders that have one */ + uint8_t amount; /* strength, 0..255; 0 is identity, 255 is fully applied */ +}; + +/* Build a shader by registry name. False if the name is not a built-in. */ +bool shader_make(shader_t *out, const char *kind, color_t color, + uint8_t amount); +/* Iterate the registry: the i'th name, or NULL past the end. */ +const char *shader_kind(size_t i); + +/* Run `n` shaders in order over the w×h rect at (x0,y0). `base` supplies + * everything in the context except the per-cell position and the size. */ +void shade_apply(screen_t *s, const shader_t *shaders, size_t n, uint16_t x0, + uint16_t y0, uint16_t w, uint16_t h, const shade_ctx_t *base); + +#endif /* SL0PPTY_SHADER_H */ |
