diff options
| author | user <user@clank> | 2026-08-14 22:39:18 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-14 22:39:18 +0200 |
| commit | c0b88416fc9543fa348b63be07905f7a33fdbcc3 (patch) | |
| tree | f669fdf1066b2ffeed7d61baae572f4834b7ce62 /tests/test_shader_expr.py | |
| parent | sl0ppty: a tab is something you can name and move (diff) | |
| download | slosh-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.py | 132 |
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()) |
