aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_shader_expr.py
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-14 22:39:18 +0200
committeruser <user@clank>2026-08-14 22:39:18 +0200
commitc0b88416fc9543fa348b63be07905f7a33fdbcc3 (patch)
treef669fdf1066b2ffeed7d61baae572f4834b7ce62 /tests/test_shader_expr.py
parentsl0ppty: a tab is something you can name and move (diff)
downloadslosh-c0b88416fc9543fa348b63be07905f7a33fdbcc3.tar.gz
slosh-c0b88416fc9543fa348b63be07905f7a33fdbcc3.tar.xz
slosh-c0b88416fc9543fa348b63be07905f7a33fdbcc3.zip
sl0ppty: a shader's strength can be an expression
Adding an effect meant writing C -- in the binary, or since D15 in a shared library. Both are a lot of ceremony for "dim every other row". So: `amount` may be an expression, compiled to bytecode when the config is read and evaluated per cell. shaders { dim amount="(y % 2) * 40" // scanlines dim amount="(x > cols - 10) * 120" // a right margin tint amount="255 - dist(x, y, curx, cury) * 12" color="#ff5fd7" } The scope is the argument, and that is the whole design. Every built-in here is already colour_op(amount) with the amount a pure function of position -- vignette, zebra, ruler, margin and spotlight differ only in that function -- so the part worth handing to a config is the *number*, not the colour maths. Three things follow at once: the mixing stays in C where it is fast and correct; an expression cannot produce an invalid cell, because it produces one byte; and that byte is cacheable. The cache is what makes an interpreter affordable, and the numbers are why this exists at all. Measured on a 200x50 pane, one pass: native vignette 6.1 ns/cell constant expression, folded 5.6 ns/cell positional expression, mapped 5.2 ns/cell animated (reads the clock) 35.0 ns/cell A shader you wrote in your config costs what a compiled one does. A program that does not read the clock is evaluated once per cell into a byte map and reused until something it reads changes; one that does read the clock is interpreted per cell, per frame, which is the honest price of animation and is opt-in. (The earlier measurement of 56ns for interpretation was per evaluation -- correct, and the reason the map exists rather than an argument against the language.) What a program reads is derived by the compiler rather than declared, and that dependency set *is* the cache key. A stale map is therefore unrepresentable rather than merely unlikely -- the same rule the layout follows, arrived at from the other direction. A unit test caught the first violation of it before anything else did: `cols` and `rows` carried no dependency flag, on the theory that the key always contains the rect, and so a program reading only the size looked constant and was folded at compile time, where every size is zero. The language is deliberately total. No loops, no recursion, no jumps: even `?:` compiles to evaluating both sides and selecting, because nothing in it has an effect. Evaluation is O(program length), so a config cannot spin, and there is nothing to sandbox. Division by zero is 0 rather than a trap: an expression that is wrong should look wrong, not take the session with it. One that does not compile drops that one shader with a warning -- not the config, and not the half-strength version of what you asked for, which is what the number path did with garbage and is now also fixed. shader_t gained the compiled program, so the plugin ABI goes to 2. A plugin built against 1 is refused with "shader ABI 1, we speak 2" and needs nothing but a rebuild -- the check earning its keep the first time it was asked to.
Diffstat (limited to 'tests/test_shader_expr.py')
-rw-r--r--tests/test_shader_expr.py132
1 files changed, 132 insertions, 0 deletions
diff --git a/tests/test_shader_expr.py b/tests/test_shader_expr.py
new file mode 100644
index 0000000..136026d
--- /dev/null
+++ b/tests/test_shader_expr.py
@@ -0,0 +1,132 @@
+#!/usr/bin/env python3
+"""Shaders whose strength is an expression, not a number.
+
+`amount=90` and `amount="(x < 10) * 200"` are the same key and the same idea,
+one of them constant. What is being checked here is that the expression really
+reaches the cells, that a broken one costs its own shader and nothing else,
+and that the states table gets the same treatment as the global chain.
+"""
+import sys
+import tempfile
+
+from harness import Session, check, report
+
+# Prints a full row of known-coloured text, then waits.
+SH = ["/bin/sh", "-c",
+ 'printf "\\033]2;p\\007"; printf "%s\\n" ABCDEFGHIJKLMNOPQRSTUVWXYZ; read x']
+
+
+def cfg(text):
+ f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
+ # A known default_fg, so "what colour is this cell" has one answer.
+ f.write('theme { default_fg "#ffffff" default_bg "#000000" }\n' + text)
+ f.close()
+ return f.name
+
+
+def fg_row(snap, pane, row=0):
+ y = pane["content_y"] + row
+ return [(snap.style_at(pane["content_x"] + x, y) or {}).get("fg")
+ for x in range(pane["content_w"])]
+
+
+def test_an_expression_decides_the_amount_per_cell():
+ c = cfg('shaders {\n dim amount="(x < 10) * 200"\n}\n')
+ with Session(SH, cols=44, rows=8, config=c) as s:
+ snap = s.until_text("ABC")
+ row = fg_row(snap, s.pane())
+ check("the first ten columns are dimmed",
+ all(c == row[0] and c != "#ffffff" for c in row[:10]), str(row[:12]))
+ check("and the rest are not",
+ all(c == "#ffffff" for c in row[10:20]), str(row[10:20]))
+
+
+def test_a_constant_expression_is_just_a_number():
+ """Folded at compile time, so it has to behave identically to writing it."""
+ a = cfg('shaders {\n dim amount="100 + 28"\n}\n')
+ b = cfg('shaders {\n dim amount=128\n}\n')
+ with Session(SH, cols=44, rows=8, config=a) as s1, \
+ Session(SH, cols=44, rows=8, config=b) as s2:
+ r1 = fg_row(s1.until_text("ABC"), s1.pane())
+ r2 = fg_row(s2.until_text("ABC"), s2.pane())
+ check("an expression that reads nothing matches the number", r1 == r2,
+ f"{r1[:4]} vs {r2[:4]}")
+
+
+def test_the_rect_is_known_to_the_expression():
+ """`cols` is a dependency even though it never varies per cell: it is not
+ known at compile time, and folding it there made every size read as 0."""
+ c = cfg('shaders {\n dim amount="(x > cols - 5) * 200"\n}\n')
+ with Session(SH, cols=44, rows=8, config=c) as s:
+ snap = s.until_text("ABC")
+ pane = s.pane()
+ row = fg_row(snap, pane)
+ check("the last columns are dimmed, not the first",
+ row[0] == "#ffffff" and row[-1] != "#ffffff",
+ f"{row[0]} .. {row[-1]}")
+
+
+def test_it_follows_a_resize():
+ c = cfg('shaders {\n dim amount="(x > cols - 5) * 200"\n}\n')
+ with Session(SH, cols=60, rows=8, config=c) as s:
+ s.until_text("ABC")
+ s.resize(40, 8)
+ s.settle()
+ snap = s.snapshot()
+ pane = s.pane()
+ row = fg_row(snap, pane)
+ # The cached map has to be rebuilt for the new rect, or the dimmed
+ # band would still be sitting where the old right-hand edge was.
+ check("the band moved with the edge",
+ row[-1] != "#ffffff" and row[len(row) - 8] == "#ffffff",
+ str(row[-10:]))
+
+
+def test_a_broken_expression_costs_only_its_own_shader():
+ c = cfg('shaders {\n'
+ ' dim amount="nosuchthing * 2"\n'
+ ' tint color="#00ff00" amount="255"\n'
+ '}\n')
+ with Session(SH, cols=44, rows=8, config=c) as s:
+ snap = s.until_text("ABC")
+ check("the session starts anyway", s.alive())
+ row = fg_row(snap, s.pane())
+ check("and the shader after it still ran", row[0] == "#00ff00",
+ str(row[:3]))
+
+
+def test_states_take_expressions_too():
+ c = cfg('states {\n unfocused { dim amount="(y % 2) * 200" }\n}\n')
+ with Session(SH, cols=80, rows=16, config=c) as s:
+ s.until_text("ABC")
+ s.key("\\\\") # split; the left pane loses focus
+ s.settle()
+ panes = s.panes()
+ unfocused = [p for p in panes if not p["focused"]][0]
+ snap = s.snapshot()
+ r0 = fg_row(snap, unfocused, 0)
+ r1 = fg_row(snap, unfocused, 1)
+ check("even rows are left alone", r0[0] == "#ffffff", str(r0[:3]))
+ check("odd rows are dimmed", r1[0] != "#ffffff" and r1[0] is not None,
+ str(r1[:3]))
+
+
+def test_an_expression_cannot_hang_the_session():
+ """There is no loop to write, so the worst case is a long expression --
+ and one longer than the program limit is refused rather than run."""
+ long_expr = "+".join(["x"] * 300)
+ c = cfg('shaders {\n dim amount="%s"\n}\n' % long_expr)
+ with Session(SH, cols=44, rows=8, config=c) as s:
+ snap = s.until_text("ABC")
+ check("the session is alive and drawing", s.alive())
+ # Unstyled, not merely undimmed: with its only shader refused there
+ # is no pass at all, so the cells still defer to the terminal.
+ check("and the refused shader did nothing",
+ fg_row(snap, s.pane())[0] in (None, "#ffffff"),
+ str(fg_row(snap, s.pane())[:3]))
+
+
+for name, fn in sorted(list(globals().items())):
+ if name.startswith("test_"):
+ fn()
+sys.exit(report())