aboutsummaryrefslogtreecommitdiffstats
path: root/tests
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 /tests
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.
Diffstat (limited to 'tests')
-rwxr-xr-xtests/test_inband_shaders.py286
1 files changed, 286 insertions, 0 deletions
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())