aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-16 01:06:42 +0200
committeruser <user@clank>2026-08-16 01:06:42 +0200
commit3b9c1d987db7dae1db68a8ea304e8227344a1cdb (patch)
treeb2aa66a61e2111720115442611ca092d261c161c
parentsl0ppty: a reply is never a request (diff)
downloadslosh-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.md23
-rw-r--r--config/config.kdl18
-rwxr-xr-xcontrib/shader-repl150
-rw-r--r--docs/config.md2
-rw-r--r--docs/scripting.md11
-rw-r--r--docs/shaders.md28
-rw-r--r--src/app.c135
-rw-r--r--src/app.h18
-rw-r--r--src/config.c4
-rw-r--r--src/config.h10
-rwxr-xr-xtests/test_inband_shaders.py286
11 files changed, 653 insertions, 32 deletions
diff --git a/DESIGN.md b/DESIGN.md
index bf5c88e..251afa4 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -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
diff --git a/src/app.c b/src/app.c
index e339a33..3c500a3 100644
--- a/src/app.c
+++ b/src/app.c
@@ -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;
diff --git a/src/app.h b/src/app.h
index 31587a5..6a383c9 100644
--- a/src/app.h
+++ b/src/app.h
@@ -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())