diff options
| author | user <user@clank> | 2026-08-16 01:06:42 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-16 01:06:42 +0200 |
| commit | 3b9c1d987db7dae1db68a8ea304e8227344a1cdb (patch) | |
| tree | b2aa66a61e2111720115442611ca092d261c161c | |
| parent | sl0ppty: a reply is never a request (diff) | |
| download | slosh-3b9c1d987db7dae1db68a8ea304e8227344a1cdb.tar.gz slosh-3b9c1d987db7dae1db68a8ea304e8227344a1cdb.tar.xz slosh-3b9c1d987db7dae1db68a8ea304e8227344a1cdb.zip | |
sl0ppty: a pane can set its own shaders
printf '\033]5577;1;shader;chrome;tint color="#ff5fd7" amount="abs(t / 8 % 510 - 255)"\033\\'
The field after `shader` is which rect -- `content` or `chrome` -- and the
rest of the payload is a chain in exactly the syntax a `shaders { }` block
uses, taken verbatim so that `;` can go on separating entries. Empty text
clears that chain. The session answers on the program's stdin, `shader-reply;ok`
or `shader-reply;error;bad amount for tint: ...`, because a prototyping loop
that gets no answer cannot tell a typo from a build without the feature.
`contrib/shader-repl` is the prompt this exists for: type a chain, watch the
pane, and `:paste` prints what you have as a config block. Both ends are the
same parser, so what you arrive at is what you can keep -- verified by
pasting `:paste`'s output into a file, linting it, and checking the frame
comes out the same colour.
**D13 said this was impossible on purpose, and the reasoning stands**: a
program that can restyle the session it happens to be running in is a hazard,
and `cat hostile.txt` should not dim your panes. But *impossible* was the
wrong shape for it -- editing a file, saving, looking and guessing again is
slow enough that shaders go unwritten. So it takes `in_band_shaders true`,
one line of consent, and it is scoped to the pane that asked: its own two
chains, never its neighbour's, never what the config said about anyone else.
There is a test for the neighbour.
Each chain owns the programs its expressions compiled to, and is swapped in
whole, so a chain that turns out not to read leaves the pane looking like it
did rather than half-restyled. Measured: 1,800 replacements of an animated
chain, resident size flat after the first pass.
Also here, because it is the same paint order: the chrome chain now sits
between the config's and policy's, exactly where the content chain does.
Dead code out -- `app_shade_add`/`app_shade_clear`/`app_shade_count` had no
callers and their comment claimed the opposite of what this commit does.
| -rw-r--r-- | DESIGN.md | 23 | ||||
| -rw-r--r-- | config/config.kdl | 18 | ||||
| -rwxr-xr-x | contrib/shader-repl | 150 | ||||
| -rw-r--r-- | docs/config.md | 2 | ||||
| -rw-r--r-- | docs/scripting.md | 11 | ||||
| -rw-r--r-- | docs/shaders.md | 28 | ||||
| -rw-r--r-- | src/app.c | 135 | ||||
| -rw-r--r-- | src/app.h | 18 | ||||
| -rw-r--r-- | src/config.c | 4 | ||||
| -rw-r--r-- | src/config.h | 10 | ||||
| -rwxr-xr-x | tests/test_inband_shaders.py | 286 |
11 files changed, 653 insertions, 32 deletions
@@ -366,7 +366,7 @@ M4 landed: the pi extensions' exact byte patterns are an acceptance test no payload — answer-picker's way of dropping buttons while keeping the status text — and a click report that satisfies the extension's own `CLICK_RE`. -Two protocol details worth keeping in mind, both found by testing: +Three protocol details worth keeping in mind, all found by testing: - **An over-long button id must be rejected, not truncated.** Unescaping a 40-character id into a 33-byte buffer produced a *valid* 32-character id, so @@ -376,6 +376,27 @@ Two protocol details worth keeping in mind, both found by testing: - **BEL terminates an OSC just as ST does**, which matters more than it looks: a trailing `ESC \` inside a double-quoted shell string escapes the quote, so anything scripting this from a shell will reach for BEL. +- **A reply must never re-parse as a request.** Every message the session sends + a program ends its verb in `-reply`, and nothing dispatches one. `hello` used + to answer with the verb `hello`, so a pane that echoed what it was sent — a + shell with echo on, `cat`, a REPL waiting for a line — answered the answer: + measured at 4.3 MB and 165,532 hellos in a second and a half. Fixed as the + rule rather than the instance, because the loop is a property of "a reply that + looks like a request" and the next verb to get an answer would have found it + again. + +**D13, revisited: a program may set its own pane's shader chains**, behind +`in_band_shaders` (off by default). The original reasoning stands — a program +that can restyle the session it is running in is a hazard, and `cat +hostile.txt` must not be able to dim your panes — but *impossible* was the wrong +shape for it: arriving at a colour pass by editing a file, saving, looking and +guessing again is slow enough that shaders go unwritten. So it is possible, it +costs a line of consent, and it is scoped to the pane that asked: its own two +chains, never its neighbour's and never what the config said about everybody +else. The payload is the config's own chain syntax parsed by the same function +(`config_parse_chain`), because a chain that meant one thing typed and another +thing pasted would be worse than not having it. `contrib/shader-repl` is the +prompt this exists for, and `:paste` prints the block to keep. ## Non-goals diff --git a/config/config.kdl b/config/config.kdl index b0ff1f9..95611c6 100644 --- a/config/config.kdl +++ b/config/config.kdl @@ -251,6 +251,24 @@ status_pad 4 // expands a collapsed pane just because the pointer crossed its header. focus_follows_mouse true +// Whether the program running in a pane may set that pane's shader chains, by +// printing an escape sequence: +// +// printf '\033]5577;1;shader;chrome;tint color="#ff5fd7" amount=255\033\\' +// +// The payload after `chrome` (or `content`) is a chain in exactly the syntax the +// `shaders` block below uses, so a chain you arrive at this way is a line you can +// paste. `contrib/shader-repl` is a prompt that does it for you, and prints the +// block to paste when you like what you see. +// +// Off, because a program restyling the session it happens to be running in is a +// hazard and not a feature -- `cat` some hostile file and it can dim your panes. +// On, it is scoped to the pane that asked: it cannot touch another pane, and it +// cannot change anything this file said about everybody else. The pane is told +// `off` rather than ignored, so a program can tell "you said no" from "this +// build has never heard of it". +in_band_shaders false + // The floor below which a pane is not small but useless. A split that cannot // give every child this much collapses into a stack instead, with one pane // expanded and its siblings as one-row headers. Recomputed every frame, so diff --git a/contrib/shader-repl b/contrib/shader-repl new file mode 100755 index 0000000..0fd9c39 --- /dev/null +++ b/contrib/shader-repl @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""A prompt for prototyping shader chains, run inside the pane it repaints. + + $ contrib/shader-repl + chrome> tint color="#ff5fd7" amount="abs(t / 8 % 510 - 255)" + ok + chrome> :paste + +The line you type is a chain in exactly the syntax `config.kdl` uses, sent to the +session with OSC 5577 and applied to *this* pane. Nothing is written anywhere, so +a chain you like has to be copied out -- which `:paste` does, in the form a config +file wants. + +Needs `in_band_shaders true` in your config. Without it the session answers "off" +rather than doing it, which is the answer this asks for: a program restyling the +session it happens to be running in is a hazard, and prototyping is worth one line +of consent (D13). + +Commands: + :chrome aim the next chain at the frame (the default) + :content aim it at the pane's contents + :clear take this pane's chain away + :both clear both chains + :paste print what you have as a `shaders { }` block + :quit leave (the chain stays until the pane closes) + +Python 3, standard library only. +""" +import os +import select +import sys +import termios +import time +import tty + +OSC = "\033]5577;1;" +ST = "\033\\" +TIMEOUT = 2.0 + + +def send(payload): + sys.stdout.write(OSC + payload + ST) + sys.stdout.flush() + + +def reply(fd, timeout=TIMEOUT): + """One answer, read raw off the terminal. + + Replies carry no newline -- they are escape sequences, not lines -- so this + reads bytes until the terminator rather than calling readline() and hanging. + """ + out = "" + deadline = time.monotonic() + timeout + while True: + left = deadline - time.monotonic() + if left <= 0 or not select.select([fd], [], [], left)[0]: + break + out += os.read(fd, 256).decode("utf-8", "replace") + if out.endswith(ST) or out.endswith("\a"): + break + if OSC + "shader-reply;" not in out: + return None + body = out.split(OSC + "shader-reply;", 1)[1] + return body.rstrip(ST).rstrip("\a") + + +def ask(fd, where, chain): + send("shader;%s;%s" % (where, chain)) + answer = reply(fd) + if answer is None: + return ("no answer -- is this pane inside sl0ppty?", False) + if answer == "ok": + return ("ok", True) + return (answer.replace("error;", "", 1), False) + + +def paste(chains): + """What a config file would say. Two `where=` entries rather than two blocks, + because one `shaders { }` holds both and that is the shape to copy.""" + if not any(chains.values()): + return "shaders { } // nothing set" + lines = ["shaders {"] + for where, chain in chains.items(): + for entry in [e.strip() for e in chain.split(";") if e.strip()]: + if "where=" in entry: + lines.append(" %s" % entry) + else: + head, _, rest = entry.partition(" ") + lines.append(' %s where="%s"%s' + % (head, where, (" " + rest) if rest else "")) + lines.append("}") + return "\n".join(lines) + + +def main(): + if not sys.stdin.isatty(): + sys.exit("shader-repl: needs a terminal (run it in a pane)") + fd = sys.stdin.fileno() + saved = termios.tcgetattr(fd) + where = "chrome" + chains = {"chrome": "", "content": ""} + + print("shader-repl: a chain in config syntax, applied to this pane.") + print(" :chrome :content :clear :both :paste :quit") + try: + while True: + try: + line = input("%s> " % where) + except EOFError: + break + line = line.strip() + if not line: + continue + + if line in (":quit", ":q"): + break + if line in (":chrome", ":content"): + where = line[1:] + continue + if line == ":paste": + print(paste(chains)) + continue + if line in (":clear", ":both"): + targets = ["chrome", "content"] if line == ":both" else [where] + for target in targets: + tty.setraw(fd) + try: + msg, ok = ask(fd, target, "") + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, saved) + if ok: + chains[target] = "" + print("%s: %s" % (target, msg)) + continue + + # raw only around the exchange, so the prompt keeps its line editing + tty.setraw(fd) + try: + msg, ok = ask(fd, where, line) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, saved) + if ok: + chains[where] = line + print(msg) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, saved) + + +if __name__ == "__main__": + main() diff --git a/docs/config.md b/docs/config.md index b504111..57b816c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -104,7 +104,7 @@ theme { frame_focus "#00ff88" } // ...but that one colour is mine |---|---| | geometry | `gap` `gap_aspect` `padding` `rounded` `title_align` `title_inset` `min_pane` `min_split` | | chrome | `status_bar` `status_line` `status_pad` `hints` `version_banner` `pane_buttons` and the marks (`zoom_mark` `zoom_on_mark` `close_mark` `min_mark` `newtab_mark` `bell_mark`) | -| behaviour | `focus_follows_mouse` `scroll_lines` `toast_ms` `hover_delay_ms` `double_click_ms` `anim_ms` `modal_scrim` `dim_unfocused` `keep_dead` `shell` `editor` `shader_dir` | +| behaviour | `focus_follows_mouse` `scroll_lines` `toast_ms` `hover_delay_ms` `double_click_ms` `anim_ms` `modal_scrim` `dim_unfocused` `keep_dead` `in_band_shaders` `shell` `editor` `shader_dir` | | colour | `theme { }` | | effects | `shaders { }`, `states { }` — see [shaders](shaders.md) and [chrome](chrome.md) | | keys | `keys { }` — see [keys](keys.md) | diff --git a/docs/scripting.md b/docs/scripting.md index 0c27565..298ae17 100644 --- a/docs/scripting.md +++ b/docs/scripting.md @@ -58,6 +58,17 @@ printing a prompt and hoping. `purpose` is the other verb: `printf '\033]5577;1;purpose;logs\033\\'`. A purpose declared by a layout or the control API wins and cannot be overwritten this way. +`shader` is the third, and the only one the session can refuse: it sets the +shader chain for the pane that asked, in the config's own syntax, and needs +`in_band_shaders true` because a program restyling your session is a hazard +before it is a convenience. See [shaders](shaders.md#prototyping-in-a-pane). + +Anything the session sends *back* to a program ends its verb in `-reply` -- +`hello-reply`, `shader-reply` -- and no request verb may. A pane that echoes what +it is sent (`cat`, a shell with echo on, a REPL waiting for a line) would +otherwise be answered into a loop, which is exactly what `hello` used to do: +4 MB of hellos in a second and a half. + ## Headless `sl0ppty --script` is the whole program without a terminal: commands on stdin, diff --git a/docs/shaders.md b/docs/shaders.md index 7e32d09..95550f0 100644 --- a/docs/shaders.md +++ b/docs/shaders.md @@ -88,6 +88,34 @@ your cursor. Each is a file you can paste into your config or `include`. `contrib/shadertoy.html` previews the language in a browser (a test cross-checks that preview against the real compiler, so it cannot lie to you). +## Prototyping in a pane + +Edit, save, look, guess again is a slow way to arrive at a colour. A program can +set the chains for the pane it is running in, in the same syntax the config uses: + +```bash +printf '\033]5577;1;shader;chrome;tint color="#ff5fd7" amount="abs(t / 8 %% 510 - 255)"\033\\' +printf '\033]5577;1;shader;content;dim amount=90\033\\' +printf '\033]5577;1;shader;chrome;\033\\' # nothing: back to normal +``` + +The field after `shader` is which rect (`content` or `chrome`), and the rest of +the payload is the chain, verbatim -- `;` separates entries, so several passes fit +on one line. The session answers on the program's stdin, `\033]5577;1;shader-reply;ok\033\\` +or `shader-reply;error;bad amount for tint: ...`, so a typo says so instead of +looking like a shader that does nothing. + +`contrib/shader-repl` is that loop with a prompt on it: type a chain, see the +pane change, and `:paste` prints what you have as a `shaders { }` block for your +config. What you prototype and what you paste are parsed by the same code, which +is the point of using the config's syntax for a thing typed at a terminal. + +**Off by default.** It needs `in_band_shaders true`, because a program that can +restyle the session it happens to be running in is a hazard first and a +convenience second: `cat` the wrong file and your panes go dark. With it on, a +pane can only paint *itself* -- not its neighbour, and not anything the config +said about anybody else. + ## Your own, compiled A shader is a C function from one cell to that cell's colours. Any `*.so` in @@ -141,6 +141,17 @@ static const color_t NO_COLOR = {0}; /* ---- tree --------------------------------------------------------------- */ +/* A chain attached to one pane, with the programs its expressions compiled to + * kept beside it. Together because they are one lifetime: freeing the chain + * without the programs leaks, and freeing the programs without the chain leaves + * shaders pointing at freed code. */ +typedef struct { + shader_t sh[SHADE_MAX]; + size_t n; + expr_prog_t *exprs[SHADE_MAX]; + size_t nexprs; +} inband_chain_t; + struct node { enum { NODE_LEAF, NODE_SPLIT } kind; node_t *parent; @@ -161,11 +172,17 @@ struct node { 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; + /* Colour passes over this pane, applied in order: one chain for its contents + * and one for its frame, because those are two rects and not one list with a + * sort key. Sent in-band by the program inside the pane, which `shaders.md` + * calls prototyping and D13 called a hazard -- both are true, so it takes + * `in_band_shaders true` to be possible at all. + * + * Each chain owns the programs its expressions compiled to. The config owns + * the ones a file produced and frees them on reload; these live exactly as + * long as the chain that was sent, which is until the next one replaces it or + * the pane goes away, and that is nobody else's schedule. */ + inband_chain_t content_chain, chrome_chain; char purpose[64]; bool purpose_locked; /* declared by a layout: in-band cannot override */ @@ -398,9 +415,52 @@ static void bypane_cb(node_t *n, void *ud) { /* OSC 5577 verbs pane.c does not own. `purpose` arrives from a pane's own * output, so it is in-band by definition and can never be declared: a pane * that prints one is asking, not telling (D8). */ +/* `shader;<where>;<chain>` from the program in the pane: `where` is `content` or + * `chrome`, and the chain is the rest of the payload *verbatim*, `;` and all, + * because a chain is several entries separated by `;` and escaping the one + * character the syntax uses would make a pasteable line unpasteable. + * + * Answered either way, on the pane's own stdin: `shader;ok` or + * `shader;error;<why>`. A prototyping loop that gets no answer cannot tell "you + * typed it wrong" from "this build does not have it", and a program asking for + * something the session refuses deserves to hear so rather than watch nothing + * happen. */ +static void on_pane_shader(app_t *a, pane_t *p, const char *payload) { + const char *semi = strchr(payload, ';'); + size_t wlen = semi ? (size_t)(semi - payload) : strlen(payload); + bool chrome = wlen == 6 && memcmp(payload, "chrome", 6) == 0; + bool content = wlen == 7 && memcmp(payload, "content", 7) == 0; + + char reply[256]; + if (!chrome && !content) { + snprintf(reply, sizeof reply, + "\033]5577;1;shader-reply;error;where must be content or chrome\033\\"); + pane_write(p, reply, strlen(reply)); + return; + } + + struct bypane b = {p, NULL}; + walk_all(a, bypane_cb, &b); + if (!b.found) return; + + char err[192] = {0}; + const char *text = semi ? semi + 1 : ""; + bool ok = app_set_pane_shaders(a, b.found->id, chrome, text, err, sizeof err); + if (ok) + snprintf(reply, sizeof reply, "\033]5577;1;shader-reply;ok\033\\"); + else + snprintf(reply, sizeof reply, "\033]5577;1;shader-reply;error;%s\033\\", + err[0] ? err : "refused"); + pane_write(p, reply, strlen(reply)); +} + static void on_pane_osc(pane_t *p, const char *verb, const char *payload, void *ud) { app_t *a = ud; + if (strcmp(verb, "shader") == 0) { + on_pane_shader(a, p, payload); + return; + } if (strcmp(verb, "purpose") != 0) return; struct bypane b = {p, NULL}; walk_all(a, bypane_cb, &b); @@ -824,9 +884,18 @@ static node_t *leaf_new(app_t *a) { return n; } +/* Empty a chain, freeing the programs it owned. */ +static void chain_clear(inband_chain_t *c) { + for (size_t i = 0; i < c->nexprs; i++) expr_free(c->exprs[i]); + c->nexprs = 0; + c->n = 0; +} + static void node_free(node_t *n) { if (!n) return; if (n->kind == NODE_LEAF) { + chain_clear(&n->content_chain); + chain_clear(&n->chrome_chain); pane_free(n->pane); } else { for (size_t i = 0; i < n->nkids; i++) node_free(n->kids[i]); @@ -2809,24 +2878,41 @@ static void draw_collapsed(app_t *a, screen_t *s, node_t *n) { /* ---- shaders ------------------------------------------------------------ */ -bool app_shade_add(app_t *a, uint32_t pane_id, const char *kind, color_t color, - uint8_t amount) { +/* A chain the program in the pane asked for, in the config's own syntax so that + * what you prototype is what you can paste (D13, revisited). Replaces that + * chain: empty text clears it, which is how a program puts a pane back. + * + * Refused, with a reason, when `in_band_shaders` is off -- a program restyling + * itself by accident is exactly what that decision was about, and the answer is + * a line of config rather than a rule nobody can lift. */ +bool app_set_pane_shaders(app_t *a, uint32_t pane_id, bool chrome, + const char *text, char *err, size_t errcap) { + if (err && errcap) err[0] = 0; + if (!CFG.in_band_shaders) { + if (err && errcap) snprintf(err, errcap, "in_band_shaders is off"); + return false; + } 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; -} + if (!n) { + if (err && errcap) snprintf(err, errcap, "no such pane"); + return false; + } -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; -} + /* Parsed into a fresh chain and swapped in, so a chain that turns out to be + * unreadable leaves the pane looking like it did rather than half-restyled. */ + inband_chain_t next = {0}; + next.n = config_parse_chain(text, CFG.frame_focus, chrome, next.sh, SHADE_MAX, + next.exprs, &next.nexprs, err, errcap); + if (err && errcap && err[0]) { + for (size_t i = 0; i < next.nexprs; i++) expr_free(next.exprs[i]); + return false; + } -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; + inband_chain_t *slot = chrome ? &n->chrome_chain : &n->content_chain; + chain_clear(slot); + *slot = next; + pane_touch(n->pane); + return true; } /* The session's opinion about this pane at this moment, as a shader. @@ -2925,8 +3011,8 @@ static void shade_leaf(app_t *a, screen_t *s, node_t *n) { * whatever the other two produced. */ for (size_t i = 0; i < CFG.nshaders && nc < SHADE_CHAIN_MAX; i++) chain[nc++] = CFG.shaders[i]; - for (size_t i = 0; i < n->nshaders && nc < SHADE_CHAIN_MAX; i++) - chain[nc++] = n->shaders[i]; + for (size_t i = 0; i < n->content_chain.n && nc < SHADE_CHAIN_MAX; i++) + chain[nc++] = n->content_chain.sh[i]; nc += policy_shaders(a, n, &chain[nc], SHADE_CHAIN_MAX - nc); if (!nc) return; @@ -2974,8 +3060,13 @@ static void shade_chrome(app_t *a, screen_t *s, node_t *n, rect_t r, shader_t chain[SHADE_CHAIN_MAX]; size_t nc = 0; + /* Configured, then this pane's own, then policy -- the same order the + * contents use, and for the same reason: the session's opinion about this + * moment has to be able to grey out whatever the other two produced. */ for (size_t i = 0; i < CFG.nchrome_shaders && nc < SHADE_CHAIN_MAX; i++) chain[nc++] = CFG.chrome_shaders[i]; + for (size_t i = 0; i < n->chrome_chain.n && nc < SHADE_CHAIN_MAX; i++) + chain[nc++] = n->chrome_chain.sh[i]; nc += chrome_policy_shaders(a, n, &chain[nc], SHADE_CHAIN_MAX - nc); if (!nc) return; @@ -137,14 +137,16 @@ bool app_edit_config(app_t *a); 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); +/* Colour passes over one pane (shader.h), set by the program running in it over + * OSC 5577 -- `chrome` picks the chain that runs over its frame rather than its + * contents, and empty text clears that chain. + * + * `text` is what a config's `shaders { }` block holds, one or more entries + * separated by `;` or newlines, so a chain you arrive at by prototyping is a + * chain you can paste. Refused with a reason in `err`: `in_band_shaders` off, + * no such pane, or a chain that does not read. */ +bool app_set_pane_shaders(app_t *a, uint32_t pane_id, bool chrome, + const char *text, char *err, size_t errcap); /* The session written back out as a layout file: tabs, splits, proportions, * directories and commands. The inverse of app_apply_layout_text(), and what diff --git a/src/config.c b/src/config.c index 681f540..4d75658 100644 --- a/src/config.c +++ b/src/config.c @@ -590,6 +590,7 @@ void config_defaults(config_t *c) { * than as another row of it. */ c->status_pad = 4; c->focus_follows_mouse = true; + c->in_band_shaders = false; c->default_fg = rgb(0xff, 0xff, 0xff); c->default_bg = rgb(0x00, 0x00, 0x00); @@ -1103,6 +1104,7 @@ char *config_render(const config_t *c) { cb_add(&b, "\n// ---- behaviour ----\n"); cb_add(&b, "focus_follows_mouse %s\n", yesno(c->focus_follows_mouse)); + cb_add(&b, "in_band_shaders %s\n", yesno(c->in_band_shaders)); cb_add(&b, "scroll_lines %u\n", c->scroll_lines); cb_add(&b, "toast_ms %u\n", c->toast_ms); cb_add(&b, "hover_delay_ms %u\n", c->hover_delay_ms); @@ -1413,6 +1415,8 @@ static bool load_into(config_t *c, const char *path, int depth, char *err, (uint16_t)kdl_arg_int(kdl_child(root, "status_pad"), 0, c->status_pad); c->focus_follows_mouse = kdl_arg_bool(kdl_child(root, "focus_follows_mouse"), 0, c->focus_follows_mouse); + c->in_band_shaders = kdl_arg_bool(kdl_child(root, "in_band_shaders"), 0, + c->in_band_shaders); const char *align = kdl_arg(kdl_child(root, "title_align"), 0, NULL); if (align) { diff --git a/src/config.h b/src/config.h index 9229e20..815b58a 100644 --- a/src/config.h +++ b/src/config.h @@ -233,6 +233,16 @@ typedef struct { uint16_t status_pad; bool focus_follows_mouse; + /* Whether the program running in a pane may set that pane's shader chains + * over OSC 5577. Off, because a program restyling the session it happens to + * be running in is a hazard and not a feature (D13) -- and on for anyone + * prototyping a chain, because the alternative is edit, save, look, and the + * whole point of a colour pass is that you can see it. + * + * Scoped to the pane that asked: it cannot touch another pane, the chrome + * outside itself, or anything the config said about everybody else. */ + bool in_band_shaders; + /* 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 diff --git a/tests/test_inband_shaders.py b/tests/test_inband_shaders.py new file mode 100755 index 0000000..5fdf23d --- /dev/null +++ b/tests/test_inband_shaders.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +r"""Shaders set by the program running in the pane, over OSC 5577. + +D13 said a program must not be able to restyle the session it happens to be +running in. That is still the failure mode, so this is off unless the config says +`in_band_shaders true` -- and with it on, the difference between prototyping a +colour pass and editing a file to guess at one. + +The chain is the config's own syntax, and that is the property worth testing: +what you arrive at by typing has to be a line you can paste. +""" +import os +import sys +import tempfile + +from harness import Session, check, report + +ST = "\\033\\\\" + +# A pane's command as a layout file writes it. KDL strings understand \e and have +# no \033, and every quote inside one is escaped -- twice over for the ones that +# have to survive as far as the chain parser. +LAYOUT = r'''layout { + tab name="t" { + pane command="/bin/sh -c \"stty raw -echo; printf '\\e]5577;1;shader;chrome;tint color=\\\"#ff0033\\\" amount=255\\x07'; exec cat\"" + pane command="/bin/sh -c \"stty raw -echo; exec cat\"" + } +} +''' + + +def cfg(text): + f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False) + f.write(text) + f.close() + return f.name + + +def osc(payload): + return "\\033]5577;" + payload + ST + + +def pane(*seqs, body="hello"): + """A pane that prints `body`, emits sequences, then echoes what it is sent, + so the session's replies are readable on screen. + + Each sequence is single-quoted with its `%` doubled: a chain is + `tint amount="y + 1"`, and inside a double-quoted shell word those quotes + vanish and the spaces split the argument -- which cost two confusing failures + before I looked at what the pane was actually running. + """ + emit = "".join("printf '%s';" % s.replace("%", "%%") for s in seqs) + return ['/bin/sh', '-c', + "printf '%s\\n'; stty raw -echo; %s exec cat -v" % (body, emit)] + + +def staged(*stages, body="hello"): + """A pane that walks (sequence, marker) stages, waiting for a cue between + them, so a test can look at the screen *between* two chains. + + A marker proves the session has already processed the sequence in front of + it, because both arrived on one pty stream in that order -- no sleeping. The + cue is a bare newline handed over with `raw()`: `send()` turns a newline into + the CR that Enter really is, and a raw-mode `read` waits for the LF. + + Typing the chains instead was the first attempt and does not work. A reply + carries no newline, so it sits unread on the pane's stdin and the next line a + read loop gets is that reply with the typed line glued onto the end of it. + """ + parts = [] + for i, (seq, mark) in enumerate(stages): + if i: + parts.append("read -r _;") + parts.append("printf '%s'; printf '%s\\n';" + % (seq.replace("%", "%%"), mark)) + return ['/bin/sh', '-c', + "printf '%s\\n'; stty raw -echo; %s exec cat -v" + % (body, " ".join(parts))] + + +def frame(snap, p): + return snap.style_at(p["x"], p["y"]) + + +def replies(snap, p): + return snap.pane_text(p) + + +def test_a_program_can_paint_its_own_frame(): + path = cfg("in_band_shaders true\n") + with Session(pane(osc('1;shader;chrome;tint color="#ff0033" amount=255')), + cols=60, rows=9, config=path) as s: + s.until_text("shader-reply") + snap, p = s.snapshot(), s.pane() + st = frame(snap, p) + check("the frame takes the colour the pane asked for", + st["fg"] == "#ff0033", str(st)) + check("and the pane is told it was done", + "shader-reply;ok" in replies(snap, p), + repr(replies(snap, p)[:120])) + os.unlink(path) + + +def test_the_contents_are_a_separate_chain(): + """Two rects, two chains: what runs over the frame is not what runs over the + text, and setting one leaves the other alone.""" + path = cfg("in_band_shaders true\n") + with Session(pane(osc('1;shader;content;tint color="#00ff00" amount=255'), + osc('1;shader;chrome;tint color="#ff0000" amount=255')), + cols=60, rows=9, config=path) as s: + s.until_text("shader-reply") + snap, p = s.snapshot(), s.pane() + body = snap.style_at(p["x"] + 1, p["y"] + 1) + check("the contents take the content chain", + body["fg"] == "#00ff00", str(body)) + check("and the frame takes the chrome one", + frame(snap, p)["fg"] == "#ff0000", str(frame(snap, p))) + os.unlink(path) + + +def test_a_line_from_a_config_means_what_it_meant_there(): + """The rect the sender names is the *default*, so an entry lifted out of a + config file works as written -- and one naming the other rect is refused + rather than quietly moved to the one that was asked about.""" + path = cfg("in_band_shaders true\n") + with Session(pane(osc('1;shader;chrome;tint where="chrome" ' + 'color="#0000ff" amount=255')), + cols=64, rows=9, config=path) as s: + s.until_text("shader-reply") + snap, p = s.snapshot(), s.pane() + check("a pasted `where=chrome` entry lands on the chrome", + frame(snap, p)["fg"] == "#0000ff", str(frame(snap, p))) + + with Session(pane(osc('1;shader;chrome;tint where="content" ' + 'color="#0000ff" amount=255')), + cols=64, rows=9, config=path) as s: + s.until_text("shader-reply") + snap, p = s.snapshot(), s.pane() + check("and one naming the other rect is refused", + "error" in replies(snap, p), repr(replies(snap, p)[:160])) + check("so the frame is left alone", + frame(snap, p)["fg"] != "#0000ff", str(frame(snap, p))) + os.unlink(path) + + +def test_several_entries_in_one_chain(): + """`;` separates entries, which is why the chain is the last field of the + payload and is taken verbatim rather than split on.""" + path = cfg("in_band_shaders true\n") + with Session(pane(osc('1;shader;chrome;tint color="#ff0033" amount=255; ' + 'dim amount=120')), + cols=60, rows=9, config=path) as s: + s.until_text("shader-reply") + snap, p = s.snapshot(), s.pane() + check("a chain of two entries is accepted whole", + "shader-reply;ok" in replies(snap, p), + repr(replies(snap, p)[:120])) + st = frame(snap, p) + check("and both ran: tinted, then dimmed off the tint", + st["fg"] != "#ff0033" and st["fg"] != "#ffffff", str(st)) + os.unlink(path) + + +def test_an_empty_chain_puts_the_pane_back(): + path = cfg("in_band_shaders true\n") + with Session(staged((osc('1;shader;chrome;tint color="#ff0033" amount=255'), + "on"), + (osc("1;shader;chrome;"), "off")), + cols=60, rows=9, config=path) as s: + s.until_text("on") + painted = frame(s.snapshot(), s.pane())["fg"] + s.raw("\\n") # the cue for the next stage + s.until_text("off") + after = frame(s.snapshot(), s.pane())["fg"] + check("the pane was painted", painted == "#ff0033", painted) + check("and an empty chain gives the frame back", after != painted, + "%s -> %s" % (painted, after)) + os.unlink(path) + + +def test_a_chain_that_does_not_read_is_refused_with_a_reason(): + """The rule the config follows: the entry says what it wants, we cannot do + it, so it does not run -- and the pane is told which part was the problem + rather than watching nothing happen.""" + path = cfg("in_band_shaders true\n") + cases = [ + ('1;shader;chrome;bloom amount=200', "unknown shader"), + ('1;shader;chrome;tint amount="y +"', "bad amount"), + ('1;shader;chrome;tint channel="blue"', "bad channel"), + ('1;shader;sideways;tint amount=200', "content or chrome"), + ] + for payload, want in cases: + with Session(pane(osc(payload)), cols=76, rows=9, config=path) as s: + s.until_text("shader-reply") + out = replies(s.snapshot(), s.pane()) + check("refused, and says why: %s" % want, want in out, + repr(out[:200])) + os.unlink(path) + + +def test_a_refused_chain_leaves_the_last_good_one_standing(): + path = cfg("in_band_shaders true\n") + with Session(staged((osc('1;shader;chrome;tint color="#ff0033" amount=255'), + "good"), + (osc("1;shader;chrome;nonesuch amount=10"), "bad")), + cols=60, rows=9, config=path) as s: + s.until_text("good") + before = frame(s.snapshot(), s.pane())["fg"] + s.raw("\\n") + s.until_text("bad") + after = frame(s.snapshot(), s.pane())["fg"] + check("the good chain ran", before == "#ff0033", before) + check("and a bad one does not undo it", before == after, + "%s -> %s" % (before, after)) + os.unlink(path) + + +def test_it_is_off_unless_the_config_says_otherwise(): + """The D13 default. A program that tries gets told no, which is the part + that matters: silence would look like a build without the feature.""" + with Session(pane(osc('1;shader;chrome;tint color="#ff0033" amount=255')), + cols=70, rows=9) as s: + s.until_text("shader-reply") + snap, p = s.snapshot(), s.pane() + check("the frame is untouched", frame(snap, p)["fg"] != "#ff0033", + str(frame(snap, p))) + check("and the pane is told the session will not do it", + "in_band_shaders is off" in replies(snap, p), + repr(replies(snap, p)[:200])) + + +def test_a_pane_can_only_paint_itself(): + """The scope, and the whole of what makes this safe enough to have at all: + one pane's chain lives on one pane's node, so the loud one cannot reach the + quiet one beside it. + + A layout rather than a split, because splitting in this harness re-runs the + session's own command -- two panes both shouting proves nothing about either. + """ + path = cfg("in_band_shaders true\n") + lay = cfg(LAYOUT) + with Session(["/bin/sh", "-c", "exec cat"], cols=90, rows=12, + config=path, layout=lay) as s: + s.until(lambda snap: any( + snap.style_at(q["x"], q["y"])["fg"] == "#ff0033" for q in s.panes())) + snap = s.snapshot() + panes = sorted(s.panes(), key=lambda q: q["x"]) + check("the layout gave two panes", len(panes) == 2, str(len(panes))) + shouty, quiet = panes[0], panes[1] + check("the one that asked is painted", + frame(snap, shouty)["fg"] == "#ff0033", str(frame(snap, shouty))) + check("and the one beside it is untouched", + frame(snap, quiet)["fg"] != "#ff0033", str(frame(snap, quiet))) + os.unlink(path) + os.unlink(lay) + + +def test_an_expression_animates(): + """The reason to prototype in-band at all: `t` moves, and a chain that reads + the clock keeps the session painting without anything else happening.""" + path = cfg("in_band_shaders true\n") + chain = 'tint color="#ff0033" amount="abs(t / 4 % 510 - 255)"' + with Session(pane(osc("1;shader;chrome;" + chain)), + cols=60, rows=9, config=path) as s: + s.until_text("shader-reply") + seen = set() + for _ in range(14): + seen.add(frame(s.snapshot(), s.pane())["fg"]) + s.settle(60) + check("a time expression keeps the frame moving", len(seen) > 2, + str(sorted(seen)[:6])) + os.unlink(path) + + +if __name__ == "__main__": + test_a_program_can_paint_its_own_frame() + test_the_contents_are_a_separate_chain() + test_a_line_from_a_config_means_what_it_meant_there() + test_several_entries_in_one_chain() + test_an_empty_chain_puts_the_pane_back() + test_a_chain_that_does_not_read_is_refused_with_a_reason() + test_a_refused_chain_leaves_the_last_good_one_standing() + test_it_is_off_unless_the_config_says_otherwise() + test_a_pane_can_only_paint_itself() + test_an_expression_animates() + sys.exit(report()) |
