<feed xmlns='http://www.w3.org/2005/Atom'>
<title>slosh.git/tests/test_shader_expr.py, branch docs-new</title>
<subtitle>an opinionated terminal multiplexer in C with programmable pane chrome</subtitle>
<id>https://git.sl0p.foo/slosh.git/atom/tests/test_shader_expr.py?h=docs-new</id>
<link rel='self' href='https://git.sl0p.foo/slosh.git/atom/tests/test_shader_expr.py?h=docs-new'/>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/'/>
<updated>2026-08-17T12:36:59Z</updated>
<entry>
<title>sl0ppty: ruff format the Python</title>
<updated>2026-08-17T12:36:59Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-17T12:36:59Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=c0022776d0912042819e4ea330cf7491a8196b45'/>
<id>urn:sha1:c0022776d0912042819e4ea330cf7491a8196b45</id>
<content type='text'>
The bumpy one, again. Every `.py` outside `vendor/` plus the three `contrib/`
scripts that are Python with no extension: 60 files, 6,714 lines rewritten, no
behaviour changed. Nothing here is worth reading -- the next commit adds it to
`.git-blame-ignore-revs` so `git blame` walks past it.

It is a bigger reformat than the C was, 60% of the lines against 17%, and for a
reason worth writing down rather than tuning away: ruff and black break a call that
does not fit onto one argument per line, where this code aligned the continuation
under the opening paren. The test suite is thousands of `check("some prose", cond,
detail)` calls, so nearly every one of them changed shape and the suite grew 3,308
lines. Line length was the one lever that moved it -- 120 columns would have been a
40% reformat and no growth at all -- and it was left at 88, because picking an
unusual width to dodge a one-time diff is paying forever to avoid paying once.

Import sorting came with it. Two files had `import os, pty, uuid, select, struct,
sys, termios, time, fcntl, signal` on one line and now have ten.

Verified after, not before, because a formatter that alters behaviour would be
worse than none:

- `make retest`, 1801 checks green -- and this reformats the suite that does the
  checking, including `harness.py`, which every one of those files imports.
- `make test-live`, the tty tests the fast suite skips. They matter here more than
  usual: `live_input.py` and `live_m0.py` are two of the files whose comma-imports
  were split, and nothing in the fast suite would have noticed if that broke them.
- All 60 files still compile, `make fmt` twice in a row changes nothing the second
  time, and `ruff check --select I` has nothing left to say.
</content>
</entry>
<entry>
<title>sl0ppty: zero strength is not a pass</title>
<updated>2026-08-16T13:37:16Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-16T13:37:16Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=a5114a9e923814878cd9c331adfe7a4a3ae5113a'/>
<id>urn:sha1:a5114a9e923814878cd9c331adfe7a4a3ae5113a</id>
<content type='text'>
`shader_abi.h` has said `0 is identity` from the start, and the pass did not
honour it: it materialised a cell's default colours -- resolving "whatever the
terminal draws" into our `default_fg`/`default_bg` -- and only then called a
shader that was contractually going to change nothing.

So a chain that is currently *off* still repainted. `focused * 200` on an
unfocused pane, `(since &lt; 500) * 255` half a second later, `(x &lt; 10) * 200`
from column ten on: every one of those wrote our idea of the default over the
terminal's own. Visible whenever the two differ, which for a real terminal
theme is always, and invisible in the test suite because the tests compared
against the materialised value.

Found by measuring, not by reading: I was checking that each example I was
about to hand over actually changed the pane, and one that should have changed
nothing changed eight style runs.

The fix is an ordering: work out the strength, and if it is zero leave the cell
before touching it. A constant-zero shader now skips its whole rect before the
loops. Cheaper too -- a pass that is off costs no materialisation and no call --
though that is a side effect and not the reason.

Three test changes, all of them the same correction. `tests/shader_test.c`'s
traversal probe was built `{.kind, .fn}` with `amount` defaulting to 0, so it
was asking to be called for every cell at zero strength; it now asks for 255,
which is what those checks are about. A new block pins the rule: a zero-strength
pass visits nothing and leaves an unset cell unset. And four checks in
`tests/test_shader_expr.py` asserted `== "#ffffff"` for cells outside an
effect's band -- the materialised default. They now assert the cell has no
colour of its own at all, which is the stronger claim and the one that would
have caught this.
</content>
</entry>
<entry>
<title>sl0ppty: a shader's strength can be an expression</title>
<updated>2026-08-14T20:39:18Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-14T20:39:18Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=c0b88416fc9543fa348b63be07905f7a33fdbcc3'/>
<id>urn:sha1:c0b88416fc9543fa348b63be07905f7a33fdbcc3</id>
<content type='text'>
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 &gt; 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.
</content>
</entry>
</feed>
