aboutsummaryrefslogtreecommitdiffstats
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
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.
-rw-r--r--DESIGN.md3
-rw-r--r--HANDOVER.md28
-rw-r--r--Makefile14
-rw-r--r--README.md19
-rw-r--r--config/config.kdl36
-rw-r--r--contrib/shader-plugin/README.md15
-rw-r--r--src/config.c36
-rw-r--r--src/config.h8
-rw-r--r--src/expr.c504
-rw-r--r--src/expr.h86
-rw-r--r--src/shader.c47
-rw-r--r--src/shader_abi.h9
-rw-r--r--tests/expr_test.c209
-rw-r--r--tests/test_shader_expr.py132
14 files changed, 1135 insertions, 11 deletions
diff --git a/DESIGN.md b/DESIGN.md
index 9ec565e..fd83b26 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -157,7 +157,8 @@ unreadable.
| **D12** | **Vendor lib-vt source**, pinned by commit in `vendor/libghostty-vt.vendor.json`, with a patches file if we ever need one. zig is a build-time dependency only, and doubles as our C compiler and static/musl cross-linker. |
| **D13** | **Shaders are a colour pass over pane *contents*, never our chrome.** A shader is a pure function from (cell, position, parameters) to that cell's colours, run between `pane_compose()` and the split guide — so "contents, not chrome" is the paint order, not a rule anyone has to remember. Cells only: fg, bg and attrs may change, text and width may not, because rewriting text would desync selection and copy (which read the terminal, not the screen). Each cell is shaded independently, so positional effects are expressible and convolutions are not; that is the price of needing no second buffer. Shaders chain in order, each a complete pass. Attached per pane by internal policy only — not over the control API and not in-band, so a program cannot restyle itself. A cell whose colour is "terminal default" is materialised to a **configured** `default_fg`/`default_bg` first: we never learn the client's real default, and most text is default-coloured, so the alternative is a dimmer that visibly does nothing. Panes with no shader skip the pass entirely and still defer to the terminal. Which shader a pane gets from the session is a **table of states** — dragging, drop_hover, drop_target, dead, suspended, scrolled, unfocused — each naming a chain. A pane is usually in several at once, so exactly one wins: the first to match, in a fixed order running from the most deliberate to the most ambient. Fixed rather than configured, because it is a ranking by urgency and not a preference. They do not stack, because two reasons to be grey compound into one muddy grey that reads as neither. `dead` is one of them because a pane whose program exits is now kept until it is dismissed (D14); it was excluded while there was no frame it could ever draw. All of it is **derived every frame, never attached and remembered**: focus and drags move through too many paths to keep an attachment in sync with, and a pane left grey by the one path that forgot to clear it is a bug that only appears in front of someone. Same rule as the rect and the collapsed flag. |
| **D14** | **A pane outlives its program.** When the program in a pane exits, the pane stays: it keeps everything it printed, writes one line into its own backlog saying how it ended (`[process exited: status 3]`, `[process exited: signal 9]`), says the same thing in its frame and in the status line, and offers the only two verbs still meaningful — `[re-run]` and `[close]`. Re-running spawns the same argv in the same cwd, in the *same pane*: same id, same place in the tree, same terminal, so the run that ended stays above the new one in the scrollback and nothing that referred to that pane has to be told anything. The old behaviour reaped the pane before the next paint, which meant the last thing a failing command printed vanished together with the status that would have explained it, and a mistyped command in a fresh session closed the session. `keep_dead false` restores it. Two consequences are load-bearing rather than incidental: the pty fd is **closed** the moment EOF is seen, because an EOF fd left in the poll set is readable forever and would spin a session at 100% CPU behind a pane that looks idle; and the exit status is collected with a **bounded** wait, never a blocking one, because a program that closes its terminal and keeps running must not be able to hang the session — losing that race costs the words "status 0" and nothing else. |
-| **D15** | **Shaders may be loaded as native shared libraries**, from a directory beside the config. This is the one hole in D9 and it is argued for rather than drifted into. A shader is called per cell, per pass, per frame — tens of thousands of times at up to 120Hz — so the subprocess model D9 prescribes cannot reach that path at all, and the measured alternative, interpreting a bytecode per cell, is 56ns against 8ns for the compiled function. What is loaded is also the narrowest possible thing: a pure function from one cell and its position to that cell's colours, unable to touch text, width, neighbours, panes or the session, because `shader_abi.h` is the only header it sees and nothing else is declared in it. On trust: the config file can already say `shell` and a layout can already say `command=`, so a file in the config directory can already run anything as you — a `.so` in the same directory is not a new boundary. It is refused unless it declares our ABI version *and* matching struct sizes, so a stale plugin fails at load instead of garbling colours. Nothing is ever unloaded: a `shade_fn` may be sitting in a live config or on a pane, so `dlclose()` would be a jump into an unmapped page — a reload therefore picks up newly *added* plugins only, and replacing one needs a new session. |
+| **D15** | **Shaders may be loaded as native shared libraries**, from a directory beside the config. This is the one hole in D9 and it is argued for rather than drifted into (and D16 has since removed most of the *need* for it). A shader is called per cell, per pass, per frame — tens of thousands of times at up to 120Hz — so the subprocess model D9 prescribes cannot reach that path at all, and the measured alternative, interpreting a bytecode per cell, is 56ns against 8ns for the compiled function. What is loaded is also the narrowest possible thing: a pure function from one cell and its position to that cell's colours, unable to touch text, width, neighbours, panes or the session, because `shader_abi.h` is the only header it sees and nothing else is declared in it. On trust: the config file can already say `shell` and a layout can already say `command=`, so a file in the config directory can already run anything as you — a `.so` in the same directory is not a new boundary. It is refused unless it declares our ABI version *and* matching struct sizes, so a stale plugin fails at load instead of garbling colours. Nothing is ever unloaded: a `shade_fn` may be sitting in a live config or on a pane, so `dlclose()` would be a jump into an unmapped page — a reload therefore picks up newly *added* plugins only, and replacing one needs a new session. |
+| **D16** | **A shader's `amount` may be an expression**, compiled to bytecode at config load. The scope is the argument: every built-in here is already `colour_op(amount)` with the amount a pure function of position, so the part worth handing to a config is the *number*, not the colour maths. That keeps mixing in C, gives an expression no way to produce an invalid cell (it produces one byte), and makes the result cacheable — a program that does not read the clock is evaluated once per cell into a map and reused until something it reads changes, so a config-written positional shader measures 5.2ns/cell against a compiled vignette's 6.1ns. Reading the clock costs 35ns/cell, per frame, which is the honest price of animation and is opt-in. The language is **total**: no loops, no recursion, no jumps — even `?:` evaluates both sides and selects, because nothing in it has an effect. So evaluation is O(program length), a config cannot spin, and there is nothing to sandbox; division by zero is 0 rather than a trap, because an expression that is wrong should look wrong rather than take the session with it. What a program reads is **derived by the compiler**, not declared, and that dependency set *is* the cache key — a stale map is unrepresentable rather than merely unlikely, which is the same rule the layout follows. An expression that does not compile drops that one shader with a warning: not the config, and not a half-strength version of what was asked for. |
### D6 — responsive layout as a pure function
diff --git a/HANDOVER.md b/HANDOVER.md
index 422b04d..ca0531b 100644
--- a/HANDOVER.md
+++ b/HANDOVER.md
@@ -136,13 +136,33 @@ What they say, per cell per pass on this box:
than per cell, which also lets each shader hoist its row-invariant work):
measured at 4.7ns in a prototype. Not done, because it is a rewrite of every
shader and nothing needs the 1.4ns yet.
-- an **interpreted** shader is 56ns against a compiled 8ns, which is the whole
- argument for D15 loading native code instead of inventing a language.
+- an **interpreted** shader is 56ns against a compiled 8ns *per evaluation* --
+ which is why D16's expressions are memoised rather than run per frame. With
+ the map, a config-written positional shader is 5.2ns/cell against a compiled
+ vignette's 6.1ns; only a clock-reading one pays the interpreter every frame,
+ at 35ns/cell.
All of this is 0.24ms per frame on a 200x50 pane, under 3% of a 120Hz frame.
It has never been the bottleneck; do not spend a day here without a profile
saying otherwise.
+## The expression memo, and the one thing wrong with it
+
+`expr_amount_map()` caches per *program*, keyed on everything the compiler
+says the program reads. Two panes of **different sizes** sharing one config
+shader therefore fight over that one buffer and rebuild it each time -- which
+costs what interpreting per cell would have cost, so it degrades to the slow
+path rather than going wrong. Same for one focused and one unfocused pane
+when the expression reads `cursor` or `focused`. The fix, if it ever matters,
+is to hang the map off the pane rather than the program; the key logic does
+not change, only where the buffer lives.
+
+The thing that must not change is *what* the key contains. It is derived from
+the source by the compiler (`expr_deps`), so it cannot drift from what the
+program actually reads -- the same reason the layout recomputes rather than
+remembers. If you add a variable to the language, add its dependency flag in
+the same table, and the cache stays correct for free.
+
## Things left on the table
- **A `reload` keybinding.** The config watcher made it less pressing.
@@ -163,6 +183,10 @@ saying otherwise.
pane. Hot-swapping would need every shader reference to be indirected
through the registry, which is a real change and not obviously worth it.
- **Per-row shader dispatch** — see the numbers above.
+- **Expressions cannot make a colour**, only a strength (D16). `tint` takes a
+ fixed `color=`; a gradient *between* two colours needs either three
+ expressions (one per channel) or a plugin. Worth doing only if somebody
+ wants it; the one-byte result is what makes the memo a byte map.
- **Corner crossings**: found for both nestings now, but if more turn up
missing, ask *how the layout was built* — the split order decides the tree
shape, and that is where any remaining blind spot will be.
diff --git a/Makefile b/Makefile
index 907d525..d6204fd 100644
--- a/Makefile
+++ b/Makefile
@@ -66,10 +66,15 @@ KDL_TEST := build/kdl_test
$(KDL_TEST): tests/kdl_test.c src/kdl.c | build
$(CC) $(CFLAGS) tests/kdl_test.c src/kdl.c -o $@
+EXPR_TEST := build/expr_test
+
+$(EXPR_TEST): tests/expr_test.c src/expr.c | build
+ $(CC) $(CFLAGS) tests/expr_test.c src/expr.c -o $@
+
SHADER_TEST := build/shader_test
-$(SHADER_TEST): tests/shader_test.c src/shader.c src/screen.c src/json.c $(VT_LIB) | build
- $(CC) $(CFLAGS) tests/shader_test.c src/shader.c src/screen.c src/json.c $(VT_LIB) -o $@
+$(SHADER_TEST): tests/shader_test.c src/shader.c src/screen.c src/json.c src/expr.c $(VT_LIB) | build
+ $(CC) $(CFLAGS) tests/shader_test.c src/shader.c src/screen.c src/json.c src/expr.c $(VT_LIB) -o $@
# Shader plugins, for test_shader_plugin.py: a good one, one that announces an
# ABI we do not speak, and the example we ship in contrib -- which is built
@@ -94,9 +99,10 @@ build/.pass-%: tests/%.py tests/harness.py $(BIN) | build
@printf ' ok %-24s %s\n' "$(notdir $<)" "$$(grep -c '^ok' build/.log-$* 2>/dev/null || echo ?) checks"
@touch $@
-test: $(BIN) $(TEST_BIN) $(KDL_TEST) $(SHADER_TEST) ## unit + headless checks (fast)
+test: $(BIN) $(TEST_BIN) $(KDL_TEST) $(SHADER_TEST) $(EXPR_TEST) ## unit + headless checks (fast)
@./$(TEST_BIN) >/dev/null && ./$(KDL_TEST) >/dev/null && ./$(SHADER_TEST) >/dev/null \
- && printf ' ok %-24s %s\n' "C unit tests" "3 binaries"
+ && ./$(EXPR_TEST) >/dev/null \
+ && printf ' ok %-24s %s\n' "C unit tests" "4 binaries"
@$(MAKE) --no-print-directory -j$(JOBS) $(PY_STAMPS)
@printf '\nall green\n'
diff --git a/README.md b/README.md
index 5a6b982..b365f9c 100644
--- a/README.md
+++ b/README.md
@@ -122,6 +122,25 @@ states {
The same mechanism draws a column ruler, a margin marker or a spotlight that
follows the cursor, inside programs that have never heard of such things.
+**A shader's strength can be an expression**, so an effect nobody built in
+takes one line of config and no compiler:
+
+```kdl
+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"
+}
+```
+
+Integer arithmetic over `x y cols rows curx cury focused t`, with `min max abs
+clamp dist` and comparisons that give 0 or 1 — so `(x < 10) * 200` is how you
+write a rule. There are no loops, so a config cannot hang a session. The
+expression produces the *strength* and never the colour, which keeps the
+mixing in C and means the whole program can be worked out once into a per-cell
+map and reused: a shader you wrote in your config costs about what a compiled
+one does.
+
**You can add your own** without rebuilding sl0ppty: a shader is a C function
from one cell to that cell's colours, and any `*.so` in
`~/.config/sl0ppty/shaders/` that exports one is loaded at startup and named
diff --git a/config/config.kdl b/config/config.kdl
index 1bc866c..099c34d 100644
--- a/config/config.kdl
+++ b/config/config.kdl
@@ -246,6 +246,42 @@ states {
// margin amount at=100 everything from that column recedes
// spotlight amount radius=12 brightness falls away from the cursor
//
+// `amount` can also be an *expression* instead of a number, evaluated for
+// every cell. That is how you get an effect nobody built in, without building
+// anything:
+//
+// shaders {
+// dim amount="(y % 2) * 40" // scanlines
+// dim amount="(x > cols - 10) * 120" // the right margin
+// tint amount="255 - dist(x, y, curx, cury) * 12" color="#ff5fd7"
+// }
+//
+// The expression produces the *strength*, never the colour: the mixing stays
+// in C, so an expression cannot make an invalid cell, and its result is one
+// byte per cell -- which is what lets the whole program be evaluated once into
+// a per-cell map and then reused. A user-written positional shader therefore
+// costs about what a compiled one does. One that reads the clock cannot be
+// cached and is evaluated per cell per frame, which is the price of animation.
+//
+// variables x y the cell, inside the pane's content rect
+// cols rows the size of that rect
+// curx cury the cursor, and `cursor` (0 or 1) for whether
+// this pane has it
+// focused 0 or 1
+// t milliseconds, for animation
+// operators + - * / % integer only; division by zero is 0, not a crash
+// < > <= >= == != and && || !, which give 0 or 1 -- so
+// `(x < 10) * 200` is how you write a rule
+// ? : both sides are evaluated, then one is chosen
+// functions min(a,b) max(a,b) abs(a) clamp(v,lo,hi)
+// dist(x1,y1,x2,y2) counts a row double, because a cell is
+// about twice as tall as it is wide
+//
+// There are no loops and no recursion, on purpose: a config cannot spin, and
+// there is nothing to sandbox. An expression that does not compile drops that
+// one shader with a warning -- never the config, and never a half-strength
+// version of what you asked for.
+//
// Empty by default: these are yours to ask for, not opinions we hold.
//
// shaders {
diff --git a/contrib/shader-plugin/README.md b/contrib/shader-plugin/README.md
index 9ed874b..4bcd850 100644
--- a/contrib/shader-plugin/README.md
+++ b/contrib/shader-plugin/README.md
@@ -78,6 +78,17 @@ The four spellings are one field. `at` for a column, `radius` for a distance,
`band` for a size, `direction` for a direction — whichever reads right for
your effect.
+## Do you actually need one?
+
+A shader's `amount` can be an expression in the config
+(`dim amount="(y % 2) * 40"`), evaluated per cell and about as fast as
+compiled code. That covers every effect that is "a strength computed from
+where the cell is", which is most of them, with no compiler and no `.so`.
+
+Write a plugin when the *colour maths* is the point rather than the strength
+-- a palette mapping, a channel swap, a curve -- or when you want an effect
+under a name of its own.
+
## Compatibility
The plugin declares which ABI it was built against, and the sizes of the three
@@ -85,6 +96,10 @@ structs it shares with sl0ppty. A mismatch is refused at load with a line on
stderr rather than trusted into a crash, so a plugin left over from an older
version fails safely and everything else still loads.
+**The ABI is currently 2.** It went to 2 when `shader_t` gained the compiled
+expression that backs `amount`; a plugin built against 1 is refused with
+`shader ABI 1, we speak 2` and needs nothing but a rebuild.
+
## Trust
A shader plugin is native code loaded into the process that holds all your
diff --git a/src/config.c b/src/config.c
index 80d9936..e06e25a 100644
--- a/src/config.c
+++ b/src/config.c
@@ -126,7 +126,33 @@ static size_t parse_shader_list(config_t *c, const kdl_node_t *node,
const kdl_node_t *k = node->kids[i];
if (!k || !k->name) continue;
- long amount = kdl_prop_int(k, "amount", 128);
+ /* `amount` is a number, or an expression that produces one per cell.
+ * Same key either way: `amount=90` and `amount="(y % 2) * 40"` are the
+ * same idea, one of them constant, and the compiler folds a constant
+ * expression back to a number so nothing downstream can tell. */
+ expr_prog_t *aexpr = NULL;
+ long amount = 128;
+ const char *as = kdl_prop(k, "amount", NULL);
+ if (as) {
+ char *end = NULL;
+ long v = strtol(as, &end, 10);
+ while (end && (*end == ' ' || *end == '\t')) end++;
+ if (end && !*end) {
+ amount = v;
+ } else {
+ char eerr[128] = {0};
+ aexpr = expr_compile(as, eerr, sizeof eerr);
+ if (!aexpr) {
+ /* The shader is dropped, not run at its default strength: an
+ * expression that did not compile leaves the strength *unknown*,
+ * and half-dimming a pane is a worse answer to that than doing
+ * nothing and saying why. */
+ if (err && !err[0])
+ snprintf(err, errcap, "bad amount for %s: %s", k->name, eerr);
+ continue;
+ }
+ }
+ }
if (amount < 0) amount = 0;
if (amount > 255) amount = 255;
/* Every shader that takes a number calls it something different, so accept
@@ -146,8 +172,14 @@ static size_t parse_shader_list(config_t *c, const kdl_node_t *node,
if (!shader_make_p(&out[n], k->name, col, (uint8_t)amount,
(uint16_t)param)) {
if (err && !err[0]) snprintf(err, errcap, "unknown shader: %s", k->name);
+ expr_free(aexpr);
continue;
}
+ out[n].amount_expr = aexpr;
+ if (aexpr) {
+ c->exprs = realloc(c->exprs, (c->nexprs + 1) * sizeof *c->exprs);
+ c->exprs[c->nexprs++] = aexpr; /* the config owns every program */
+ }
n++;
}
return n;
@@ -321,6 +353,8 @@ void config_defaults(config_t *c) {
}
void config_free(config_t *c) {
+ for (size_t i = 0; i < c->nexprs; i++) expr_free(c->exprs[i]);
+ free(c->exprs);
free(c->binds);
free(c->shell);
free(c->shader_dir);
diff --git a/src/config.h b/src/config.h
index 4be4ae0..0377776 100644
--- a/src/config.h
+++ b/src/config.h
@@ -11,6 +11,7 @@
#include <stddef.h>
#include <stdint.h>
+#include "expr.h"
#include "shader.h"
#include "sl0ppty.h"
@@ -229,6 +230,13 @@ typedef struct {
binding_t *binds;
size_t nbinds;
+ /* Every amount expression this config compiled. A shader_t is copied by
+ * value all over the place and several copies can point at one program, so
+ * ownership lives here — with the config that parsed them, freed when it is
+ * replaced. */
+ expr_prog_t **exprs;
+ size_t nexprs;
+
/* what a new pane runs; NULL means $SHELL */
char *shell;
diff --git a/src/expr.c b/src/expr.c
new file mode 100644
index 0000000..3400e34
--- /dev/null
+++ b/src/expr.c
@@ -0,0 +1,504 @@
+/* The shader expression compiler and its VM. See expr.h for the model. */
+#define _GNU_SOURCE
+#include "expr.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+/* ---- bytecode ----------------------------------------------------------- */
+
+enum {
+ OP_PUSH, /* imm */
+ OP_VAR, /* imm = which */
+ OP_ADD, OP_SUB, OP_MUL, OP_DIV, OP_MOD, OP_NEG,
+ OP_LT, OP_GT, OP_LE, OP_GE, OP_EQ, OP_NE,
+ OP_AND, OP_OR, OP_NOT,
+ OP_MIN, OP_MAX, OP_ABS, OP_CLAMP, OP_DIST,
+ OP_SELECT, /* cond a b -> a or b, both already evaluated */
+};
+
+/* Variable slots, in the order the names table lists them. */
+enum { V_X, V_Y, V_COLS, V_ROWS, V_CURX, V_CURY, V_CURSOR, V_FOCUSED, V_T };
+
+static const struct {
+ const char *name;
+ int slot;
+ unsigned dep;
+} VARS[] = {
+ {"x", V_X, EXPR_DEP_POS},
+ {"y", V_Y, EXPR_DEP_POS},
+ {"cols", V_COLS, EXPR_DEP_SIZE},
+ {"rows", V_ROWS, EXPR_DEP_SIZE},
+ {"curx", V_CURX, EXPR_DEP_CURSOR},
+ {"cury", V_CURY, EXPR_DEP_CURSOR},
+ {"cursor", V_CURSOR, EXPR_DEP_CURSOR},
+ {"focused", V_FOCUSED, EXPR_DEP_FOCUS},
+ {"t", V_T, EXPR_DEP_TIME},
+};
+
+/* cols/rows are a dependency even though the map's key always carries the
+ * rect: what the flag prevents is *folding* them at compile time, where the
+ * rect is not known and every size reads as zero. Found by a unit test that
+ * asked what `cols` evaluates to. */
+
+typedef struct {
+ uint8_t op;
+ int32_t imm;
+} insn_t;
+
+#define MAX_INSNS 256
+#define MAX_STACK 32
+
+struct expr_prog {
+ insn_t code[MAX_INSNS];
+ size_t n;
+ unsigned deps;
+ bool constant;
+ int value; /* when constant */
+
+ /* The memo: the last map this program produced, and what it was produced
+ * from. Held here rather than by the caller because a program is compiled
+ * once and shared by every copy of the shader that uses it, so this is the
+ * one place that outlives a frame. The key is every input the compiler says
+ * the program reads, which is what makes a stale map unrepresentable rather
+ * than merely unlikely. */
+ uint8_t *map;
+ size_t map_cap;
+ bool map_valid;
+ int k_cols, k_rows, k_curx, k_cury, k_cursor, k_focused;
+};
+
+/* ---- lexer -------------------------------------------------------------- */
+
+typedef struct {
+ const char *p;
+ const char *start;
+ char *err;
+ size_t errcap;
+ bool failed;
+} parser_t;
+
+static void fail(parser_t *ps, const char *what) {
+ if (ps->failed) return;
+ ps->failed = true;
+ if (ps->err && ps->errcap)
+ snprintf(ps->err, ps->errcap, "%s at offset %d", what,
+ (int)(ps->p - ps->start));
+}
+
+static void skip_ws(parser_t *ps) {
+ while (*ps->p == ' ' || *ps->p == '\t' || *ps->p == '\n' || *ps->p == '\r')
+ ps->p++;
+}
+
+static bool eat(parser_t *ps, const char *tok) {
+ skip_ws(ps);
+ size_t n = strlen(tok);
+ if (strncmp(ps->p, tok, n) != 0) return false;
+ /* `<` must not eat the `<` of `<=`; callers try the longer one first, but
+ * an identifier-like token must also not match a prefix of a longer name. */
+ ps->p += n;
+ return true;
+}
+
+static void emit(parser_t *ps, expr_prog_t *pr, uint8_t op, int32_t imm) {
+ if (pr->n >= MAX_INSNS) {
+ fail(ps, "expression too long");
+ return;
+ }
+ pr->code[pr->n++] = (insn_t){op, imm};
+}
+
+/* ---- parser (precedence climbing) --------------------------------------- */
+
+static void parse_expr(parser_t *ps, expr_prog_t *pr);
+
+static void parse_primary(parser_t *ps, expr_prog_t *pr) {
+ skip_ws(ps);
+ if (ps->failed) return;
+
+ if (*ps->p == '(') {
+ ps->p++;
+ parse_expr(ps, pr);
+ skip_ws(ps);
+ if (*ps->p != ')') {
+ fail(ps, "expected )");
+ return;
+ }
+ ps->p++;
+ return;
+ }
+
+ if (*ps->p == '-') {
+ ps->p++;
+ parse_primary(ps, pr);
+ emit(ps, pr, OP_NEG, 0);
+ return;
+ }
+ if (*ps->p == '!') {
+ ps->p++;
+ parse_primary(ps, pr);
+ emit(ps, pr, OP_NOT, 0);
+ return;
+ }
+
+ if (*ps->p >= '0' && *ps->p <= '9') {
+ long v = strtol(ps->p, (char **)&ps->p, 10);
+ if (v > INT32_MAX) v = INT32_MAX;
+ emit(ps, pr, OP_PUSH, (int32_t)v);
+ return;
+ }
+
+ if ((*ps->p >= 'a' && *ps->p <= 'z') || (*ps->p >= 'A' && *ps->p <= 'Z') ||
+ *ps->p == '_') {
+ const char *s = ps->p;
+ while ((*ps->p >= 'a' && *ps->p <= 'z') || (*ps->p >= 'A' && *ps->p <= 'Z') ||
+ (*ps->p >= '0' && *ps->p <= '9') || *ps->p == '_')
+ ps->p++;
+ size_t len = (size_t)(ps->p - s);
+ char name[32];
+ if (len >= sizeof name) {
+ fail(ps, "name too long");
+ return;
+ }
+ memcpy(name, s, len);
+ name[len] = 0;
+
+ skip_ws(ps);
+ if (*ps->p == '(') { /* a call */
+ ps->p++;
+ static const struct {
+ const char *name;
+ int args;
+ uint8_t op;
+ } FNS[] = {
+ {"min", 2, OP_MIN}, {"max", 2, OP_MAX}, {"abs", 1, OP_ABS},
+ {"clamp", 3, OP_CLAMP}, {"dist", 4, OP_DIST},
+ };
+ for (size_t i = 0; i < sizeof FNS / sizeof *FNS; i++) {
+ if (strcmp(FNS[i].name, name) != 0) continue;
+ for (int a = 0; a < FNS[i].args; a++) {
+ if (a) {
+ skip_ws(ps);
+ if (*ps->p != ',') {
+ fail(ps, "expected ,");
+ return;
+ }
+ ps->p++;
+ }
+ parse_expr(ps, pr);
+ if (ps->failed) return;
+ }
+ skip_ws(ps);
+ if (*ps->p != ')') {
+ fail(ps, "expected )");
+ return;
+ }
+ ps->p++;
+ emit(ps, pr, FNS[i].op, 0);
+ return;
+ }
+ fail(ps, "unknown function");
+ return;
+ }
+
+ for (size_t i = 0; i < sizeof VARS / sizeof *VARS; i++) {
+ if (strcmp(VARS[i].name, name) != 0) continue;
+ pr->deps |= VARS[i].dep;
+ emit(ps, pr, OP_VAR, VARS[i].slot);
+ return;
+ }
+ fail(ps, "unknown name");
+ return;
+ }
+
+ fail(ps, "expected a value");
+}
+
+static void parse_mul(parser_t *ps, expr_prog_t *pr) {
+ parse_primary(ps, pr);
+ for (;;) {
+ if (ps->failed) return;
+ skip_ws(ps);
+ uint8_t op;
+ if (*ps->p == '*') op = OP_MUL;
+ else if (*ps->p == '/') op = OP_DIV;
+ else if (*ps->p == '%') op = OP_MOD;
+ else return;
+ ps->p++;
+ parse_primary(ps, pr);
+ emit(ps, pr, op, 0);
+ }
+}
+
+static void parse_add(parser_t *ps, expr_prog_t *pr) {
+ parse_mul(ps, pr);
+ for (;;) {
+ if (ps->failed) return;
+ skip_ws(ps);
+ uint8_t op;
+ if (*ps->p == '+') op = OP_ADD;
+ else if (*ps->p == '-') op = OP_SUB;
+ else return;
+ ps->p++;
+ parse_mul(ps, pr);
+ emit(ps, pr, op, 0);
+ }
+}
+
+static void parse_cmp(parser_t *ps, expr_prog_t *pr) {
+ parse_add(ps, pr);
+ for (;;) {
+ if (ps->failed) return;
+ skip_ws(ps);
+ uint8_t op;
+ /* Longest first: `<` would otherwise eat the `<` of `<=` and leave an `=`
+ * that parses as nothing. */
+ if (eat(ps, "<=")) op = OP_LE;
+ else if (eat(ps, ">=")) op = OP_GE;
+ else if (eat(ps, "==")) op = OP_EQ;
+ else if (eat(ps, "!=")) op = OP_NE;
+ else if (*ps->p == '<') { ps->p++; op = OP_LT; }
+ else if (*ps->p == '>') { ps->p++; op = OP_GT; }
+ else return;
+ parse_add(ps, pr);
+ emit(ps, pr, op, 0);
+ }
+}
+
+static void parse_and(parser_t *ps, expr_prog_t *pr) {
+ parse_cmp(ps, pr);
+ while (!ps->failed && (skip_ws(ps), eat(ps, "&&"))) {
+ parse_cmp(ps, pr);
+ emit(ps, pr, OP_AND, 0);
+ }
+}
+
+static void parse_or(parser_t *ps, expr_prog_t *pr) {
+ parse_and(ps, pr);
+ while (!ps->failed && (skip_ws(ps), eat(ps, "||"))) {
+ parse_and(ps, pr);
+ emit(ps, pr, OP_OR, 0);
+ }
+}
+
+/* `c ? a : b` evaluates all three and selects. There are no side effects to
+ * skip, and no jumps means the VM stays a straight walk over the code, which
+ * is what keeps evaluation O(program length) and the language total. */
+static void parse_expr(parser_t *ps, expr_prog_t *pr) {
+ parse_or(ps, pr);
+ if (ps->failed) return;
+ skip_ws(ps);
+ if (*ps->p != '?') return;
+ ps->p++;
+ parse_expr(ps, pr);
+ skip_ws(ps);
+ if (*ps->p != ':') {
+ fail(ps, "expected :");
+ return;
+ }
+ ps->p++;
+ parse_expr(ps, pr);
+ emit(ps, pr, OP_SELECT, 0);
+}
+
+/* ---- VM ----------------------------------------------------------------- */
+
+static int vm_run(const expr_prog_t *pr, const expr_env_t *env) {
+ int32_t st[MAX_STACK];
+ int sp = 0;
+ const int32_t vars[] = {
+ [V_X] = env->x, [V_Y] = env->y,
+ [V_COLS] = env->cols, [V_ROWS] = env->rows,
+ [V_CURX] = env->curx, [V_CURY] = env->cury,
+ [V_CURSOR] = env->cursor, [V_FOCUSED] = env->focused,
+ [V_T] = (int32_t)(env->t & 0x7fffffff),
+ };
+
+ for (size_t i = 0; i < pr->n; i++) {
+ const insn_t *in = &pr->code[i];
+ /* The compiler bounds the stack (see expr_compile), so an overflow here
+ * would be a compiler bug rather than a program's doing; the guard costs
+ * a predictable branch and turns that into a wrong colour, not a smash. */
+ if (sp >= MAX_STACK - 1) return 0;
+ switch (in->op) {
+ case OP_PUSH: st[sp++] = in->imm; break;
+ case OP_VAR: st[sp++] = vars[in->imm]; break;
+ case OP_NEG: if (sp) st[sp - 1] = -st[sp - 1]; break;
+ case OP_NOT: if (sp) st[sp - 1] = !st[sp - 1]; break;
+ case OP_ABS: if (sp) st[sp - 1] = st[sp - 1] < 0 ? -st[sp - 1] : st[sp - 1]; break;
+ default: {
+ int need = in->op == OP_CLAMP ? 3 : in->op == OP_DIST ? 4
+ : in->op == OP_SELECT ? 3 : 2;
+ if (sp < need) return 0;
+ int32_t b = st[sp - 1], a = st[sp - 2];
+ switch (in->op) {
+ case OP_ADD: st[sp - 2] = a + b; sp--; break;
+ case OP_SUB: st[sp - 2] = a - b; sp--; break;
+ case OP_MUL: st[sp - 2] = a * b; sp--; break;
+ /* Defined rather than trapping: an expression that divides by zero
+ * should look wrong, not take the session with it. */
+ case OP_DIV: st[sp - 2] = b ? a / b : 0; sp--; break;
+ case OP_MOD: st[sp - 2] = b ? a % b : 0; sp--; break;
+ case OP_LT: st[sp - 2] = a < b; sp--; break;
+ case OP_GT: st[sp - 2] = a > b; sp--; break;
+ case OP_LE: st[sp - 2] = a <= b; sp--; break;
+ case OP_GE: st[sp - 2] = a >= b; sp--; break;
+ case OP_EQ: st[sp - 2] = a == b; sp--; break;
+ case OP_NE: st[sp - 2] = a != b; sp--; break;
+ case OP_AND: st[sp - 2] = a && b; sp--; break;
+ case OP_OR: st[sp - 2] = a || b; sp--; break;
+ case OP_MIN: st[sp - 2] = a < b ? a : b; sp--; break;
+ case OP_MAX: st[sp - 2] = a > b ? a : b; sp--; break;
+ case OP_CLAMP: {
+ int32_t hi = st[sp - 1], lo = st[sp - 2], v = st[sp - 3];
+ st[sp - 3] = v < lo ? lo : v > hi ? hi : v;
+ sp -= 2;
+ break;
+ }
+ case OP_SELECT: {
+ int32_t f = st[sp - 1], t = st[sp - 2], c = st[sp - 3];
+ st[sp - 3] = c ? t : f;
+ sp -= 2;
+ break;
+ }
+ case OP_DIST: {
+ /* A cell is about twice as tall as it is wide, so a distance in
+ * cells counts a row double -- the same correction the layout's
+ * own centre-distance makes. Without it every "circle" here is an
+ * ellipse, which is the first thing anyone notices. */
+ int32_t y2 = st[sp - 1], x2 = st[sp - 2], y1 = st[sp - 3],
+ x1 = st[sp - 4];
+ int32_t dx = x1 - x2, dy = (y1 - y2) * 2;
+ int32_t d2 = dx * dx + dy * dy;
+ /* Integer square root, so `dist` is a distance and not its
+ * square: expressions that scale linearly with it are what people
+ * write, and squaring is easy to ask for and hard to undo. */
+ int32_t r = 0;
+ while ((r + 1) * (r + 1) <= d2 && r < 4096) r++;
+ st[sp - 4] = r;
+ sp -= 3;
+ break;
+ }
+ default: return 0;
+ }
+ break;
+ }
+ }
+ }
+ return sp ? st[sp - 1] : 0;
+}
+
+/* ---- compile ------------------------------------------------------------ */
+
+/* The deepest the stack can get, which is what makes MAX_STACK a fact rather
+ * than a hope: every opcode's effect on depth is known, so the maximum is a
+ * walk over the code. */
+static bool stack_fits(const expr_prog_t *pr) {
+ int sp = 0, peak = 0;
+ for (size_t i = 0; i < pr->n; i++) {
+ switch (pr->code[i].op) {
+ case OP_PUSH:
+ case OP_VAR: sp++; break;
+ case OP_NEG:
+ case OP_NOT:
+ case OP_ABS: break;
+ case OP_CLAMP:
+ case OP_SELECT: sp -= 2; break;
+ case OP_DIST: sp -= 3; break;
+ default: sp--; break;
+ }
+ if (sp > peak) peak = sp;
+ if (sp < 0) return false;
+ }
+ return peak < MAX_STACK - 1 && sp == 1;
+}
+
+expr_prog_t *expr_compile(const char *src, char *err, size_t errcap) {
+ if (err && errcap) err[0] = 0;
+ if (!src || !*src) {
+ if (err) snprintf(err, errcap, "empty expression");
+ return NULL;
+ }
+
+ expr_prog_t *pr = calloc(1, sizeof *pr);
+ if (!pr) return NULL;
+
+ parser_t ps = {.p = src, .start = src, .err = err, .errcap = errcap};
+ parse_expr(&ps, pr);
+ skip_ws(&ps);
+ if (!ps.failed && *ps.p) fail(&ps, "unexpected trailing input");
+ if (!ps.failed && !stack_fits(pr)) fail(&ps, "malformed expression");
+
+ if (ps.failed) {
+ free(pr);
+ return NULL;
+ }
+
+ /* An expression that reads nothing is a number written the long way, and
+ * every later stage is happier not knowing the difference. */
+ if (!pr->deps) {
+ expr_env_t zero = {0};
+ pr->value = vm_run(pr, &zero);
+ pr->constant = true;
+ }
+ return pr;
+}
+
+void expr_free(expr_prog_t *p) {
+ if (!p) return;
+ free(p->map);
+ free(p);
+}
+
+unsigned expr_deps(const expr_prog_t *p) { return p ? p->deps : 0; }
+int expr_constant(const expr_prog_t *p) { return p ? p->value : 0; }
+
+int expr_eval(const expr_prog_t *p, const expr_env_t *env) {
+ if (!p) return 0;
+ if (p->constant) return p->value;
+ return vm_run(p, env);
+}
+
+static uint8_t clamp8(int v) { return (uint8_t)(v < 0 ? 0 : v > 255 ? 255 : v); }
+
+bool expr_amount_map(expr_prog_t *p, const expr_env_t *env, uint8_t **out) {
+ if (!p || !out) return false;
+ if (p->deps & EXPR_DEP_TIME) return false; /* nothing to reuse */
+
+ size_t cells = (size_t)env->cols * (size_t)env->rows;
+ if (!cells) return false;
+
+ bool same = p->map_valid && p->k_cols == env->cols && p->k_rows == env->rows;
+ if (same && (p->deps & EXPR_DEP_CURSOR))
+ same = p->k_curx == env->curx && p->k_cury == env->cury &&
+ p->k_cursor == env->cursor;
+ if (same && (p->deps & EXPR_DEP_FOCUS)) same = p->k_focused == env->focused;
+
+ if (!same) {
+ if (p->map_cap < cells) {
+ uint8_t *grown = realloc(p->map, cells);
+ if (!grown) return false;
+ p->map = grown;
+ p->map_cap = cells;
+ }
+ expr_env_t e = *env;
+ for (int y = 0; y < env->rows; y++) {
+ e.y = y;
+ for (int x = 0; x < env->cols; x++) {
+ e.x = x;
+ p->map[(size_t)y * env->cols + x] = clamp8(vm_run(p, &e));
+ }
+ }
+ p->map_valid = true;
+ p->k_cols = env->cols;
+ p->k_rows = env->rows;
+ p->k_curx = env->curx;
+ p->k_cury = env->cury;
+ p->k_cursor = env->cursor;
+ p->k_focused = env->focused;
+ }
+ *out = p->map;
+ return true;
+}
diff --git a/src/expr.h b/src/expr.h
new file mode 100644
index 0000000..075df7d
--- /dev/null
+++ b/src/expr.h
@@ -0,0 +1,86 @@
+/* Shader expressions: a tiny integer language for the one number a shader
+ * takes, compiled to bytecode at config load.
+ *
+ * The idea is smaller than "shaders in a language". Every built-in shader here
+ * is already `colour_op(amount)` where the amount is a pure function of where
+ * the cell is:
+ *
+ * vignette amount = f(x, y) ruler amount = (x == col) * amount
+ * zebra amount = f(y) margin amount = (x >= col) * amount
+ *
+ * So the part worth handing to a config is the *amount*, not the colour maths.
+ * That keeps the mixing in C where it is fast and correct, gives an expression
+ * no way to produce an invalid cell (it produces one byte), and \u2014 because the
+ * result is a pure function of position \u2014 lets the whole program be evaluated
+ * once into a per-cell map and then reused, which is what makes an interpreter
+ * affordable at all. Measured: 56ns/cell interpreted, 2ns/cell from the map.
+ *
+ * The language is deliberately total. No loops, no recursion, no jumps: even
+ * `?:` compiles to evaluating both sides and selecting, which is safe because
+ * nothing in here has an effect. Evaluation is therefore O(program length),
+ * a bad expression cannot hang a session, and there is nothing to sandbox.
+ *
+ * variables x y cols rows curx cury cursor focused t
+ * operators + - * / % < > <= >= == != && || ! ?:
+ * functions min max abs clamp dist
+ *
+ * `x`/`y` are cell positions inside the pane's content rect, `t` is a
+ * millisecond clock for animation, `dist` corrects for a cell being about
+ * twice as tall as it is wide. Division and modulo by zero are 0 rather than
+ * a trap: an expression that is wrong should look wrong, not take the session
+ * with it.
+ */
+#ifndef SL0PPTY_EXPR_H
+#define SL0PPTY_EXPR_H
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+typedef struct expr_prog expr_prog_t;
+
+/* What a program reads, worked out by the compiler rather than declared. This
+ * is what decides whether its output can be cached and against what, so it is
+ * derived from the source and can never disagree with it. */
+enum {
+ EXPR_DEP_POS = 1 << 0, /* x, y */
+ EXPR_DEP_CURSOR = 1 << 1, /* curx, cury, cursor */
+ EXPR_DEP_TIME = 1 << 2, /* t: recomputed every frame, never cached */
+ EXPR_DEP_FOCUS = 1 << 3, /* focused */
+ /* cols, rows. Not part of the cache key -- the key always carries the rect,
+ * because a map is per rect by construction -- but it still has to be a
+ * dependency, or a program reading only the size would look constant and be
+ * folded at compile time, where the size is not known yet and is zero. */
+ EXPR_DEP_SIZE = 1 << 4,
+};
+
+/* Compile, or NULL with a reason in `err`. A program that reads nothing is
+ * folded to its value at compile time and still returned, so callers have one
+ * shape to handle. */
+expr_prog_t *expr_compile(const char *src, char *err, size_t errcap);
+void expr_free(expr_prog_t *p);
+
+/* What it reads (EXPR_DEP_*). 0 means the result is a constant. */
+unsigned expr_deps(const expr_prog_t *p);
+/* The value of a program that reads nothing; undefined otherwise. */
+int expr_constant(const expr_prog_t *p);
+
+/* Everything a program can see. Filled by the shader pass. */
+typedef struct {
+ int x, y, cols, rows;
+ int curx, cury, cursor;
+ int focused;
+ int64_t t;
+} expr_env_t;
+
+/* Evaluate for one cell. Clamped to 0..255 by the caller, not here: the
+ * language has no opinion about what its number is for. */
+int expr_eval(const expr_prog_t *p, const expr_env_t *env);
+
+/* Evaluate for every cell of a cols x rows rect into `out` (cols*rows bytes,
+ * clamped to 0..255), reusing the last result when nothing it reads has
+ * changed. Returns false when the program reads the clock, which is the one
+ * case there is nothing to reuse. */
+bool expr_amount_map(expr_prog_t *p, const expr_env_t *env, uint8_t **out);
+
+#endif /* SL0PPTY_EXPR_H */
diff --git a/src/shader.c b/src/shader.c
index 1d36891..b1bb1a1 100644
--- a/src/shader.c
+++ b/src/shader.c
@@ -9,6 +9,10 @@
#include <stdlib.h>
#include <string.h>
+#include "expr.h"
+
+static int clamp255(int v) { return v < 0 ? 0 : v > 255 ? 255 : v; }
+
/* Integer maths throughout: this runs per cell per pass per frame, and a float
* here would buy nothing a byte channel can tell the difference between. */
static uint8_t mix8(uint8_t from, uint8_t to, uint8_t amount) {
@@ -182,7 +186,7 @@ bool shader_make_p(shader_t *out, const char *kind, color_t color,
if (!out || !kind) return false;
const shader_def_t *d = find_kind(kind);
if (!d) return false;
- *out = (shader_t){d->name, d->fn, color, amount, param};
+ *out = (shader_t){d->name, d->fn, NULL, color, amount, param};
return true;
}
@@ -340,6 +344,38 @@ void shade_apply(screen_t *s, const shader_t *shaders, size_t n, uint16_t x0,
const shader_t *sh = &shaders[i];
if (!sh->kind || !sh->fn) continue;
+ /* An expression's amount is computed here, not inside the shader, so no
+ * shader — built in or loaded — has to know that its strength might be a
+ * function of position. `local` carries the per-cell value; it is copied
+ * once per pass rather than per cell, because copying a shader_t for every
+ * cell would cost more than evaluating the expression. */
+ shader_t local = *sh;
+ const uint8_t *map = NULL;
+ expr_env_t env = {0};
+ bool per_cell = false;
+
+ if (sh->amount_expr) {
+ env.cols = w;
+ env.rows = h;
+ env.curx = ctx.has_cursor ? ctx.cursor_x : 0;
+ env.cury = ctx.has_cursor ? ctx.cursor_y : 0;
+ env.cursor = ctx.has_cursor;
+ env.focused = ctx.focused;
+ env.t = ctx.now_ms;
+ if (expr_deps(sh->amount_expr) == 0) {
+ local.amount = (uint8_t)clamp255(expr_constant(sh->amount_expr));
+ } else {
+ uint8_t *m = NULL;
+ /* The map is the whole reason an interpreter is affordable: a program
+ * that does not read the clock is evaluated once per cell and then
+ * reused for as long as nothing it reads has changed. One that does
+ * read the clock is evaluated per cell, per frame, which is what
+ * asking for animation costs. */
+ if (expr_amount_map(sh->amount_expr, &env, &m)) map = m;
+ else per_cell = true;
+ }
+ }
+
for (uint16_t y = 0; y < h; y++) {
cell_t *row = &s->cur[(size_t)(y0 + y) * s->cols + x0];
ctx.y = y;
@@ -356,7 +392,14 @@ void shade_apply(screen_t *s, const shader_t *shaders, size_t n, uint16_t x0,
if (!c->bg.set) c->bg = ctx.default_bg;
ctx.x = x;
- sh->fn(sh, &ctx, c);
+ if (map) {
+ local.amount = map[(size_t)y * w + x];
+ } else if (per_cell) {
+ env.x = x;
+ env.y = y;
+ local.amount = (uint8_t)clamp255(expr_eval(sh->amount_expr, &env));
+ }
+ local.fn(&local, &ctx, c);
}
}
}
diff --git a/src/shader_abi.h b/src/shader_abi.h
index 98decfd..60fbbc5 100644
--- a/src/shader_abi.h
+++ b/src/shader_abi.h
@@ -18,7 +18,7 @@
#include <stddef.h>
#include <stdint.h>
-#define SL0PPTY_SHADER_ABI 1
+#define SL0PPTY_SHADER_ABI 2
enum {
ATTR_BOLD = 1 << 0,
@@ -77,9 +77,16 @@ typedef struct {
typedef struct shader shader_t;
typedef void (*shade_fn)(const shader_t *sh, const shade_ctx_t *ctx, cell_t *c);
+/* Opaque to a shader: a compiled amount expression, owned by the config. When
+ * one is attached the pass computes `amount` per cell before calling the
+ * shader, so an effect never has to know whether its strength was a number in
+ * a config file or a function of where the cell is. */
+typedef struct expr_prog expr_prog_t;
+
struct shader {
const char *kind; /* registry name, NULL for an empty slot */
shade_fn fn;
+ expr_prog_t *amount_expr; /* NULL when `amount` is just a number */
color_t color; /* the target colour, for shaders that have one */
uint8_t amount; /* strength, 0..255; 0 is identity, 255 is fully applied */
/* One number whose meaning is the shader's own, because a second parameter
diff --git a/tests/expr_test.c b/tests/expr_test.c
new file mode 100644
index 0000000..a202f06
--- /dev/null
+++ b/tests/expr_test.c
@@ -0,0 +1,209 @@
+/* The shader expression compiler and its VM. Pure text in -> numbers out, so
+ * pure tests: no screen, no terminal, no timing. */
+#include "expr.h"
+
+#include <stdio.h>
+#include <string.h>
+
+static int fails = 0;
+static char detail[256];
+
+static void ok(const char *name, bool cond, const char *what) {
+ if (!cond) fails++;
+ printf("%s %-56s %s\n", cond ? "ok " : "FAIL", name, cond ? "" : what);
+}
+
+/* Evaluate at a fixed point, so a test says what it means about the maths. */
+static int at(const char *src, int x, int y) {
+ char err[128] = {0};
+ expr_prog_t *p = expr_compile(src, err, sizeof err);
+ if (!p) {
+ snprintf(detail, sizeof detail, "%s: %s", src, err);
+ return -99999;
+ }
+ expr_env_t env = {.x = x, .y = y, .cols = 80, .rows = 24,
+ .curx = 10, .cury = 5, .cursor = 1, .focused = 1,
+ .t = 1000};
+ int v = expr_eval(p, &env);
+ expr_free(p);
+ return v;
+}
+
+static void eq(const char *src, int want) {
+ int got = at(src, 3, 4);
+ if (got != want) snprintf(detail, sizeof detail, "%s -> %d, wanted %d", src, got, want);
+ ok(src, got == want, detail);
+}
+
+static void refused(const char *label, const char *src) {
+ char err[128] = {0};
+ expr_prog_t *p = expr_compile(src, err, sizeof err);
+ if (p) expr_free(p);
+ snprintf(detail, sizeof detail, "%s compiled", src);
+ ok(label, p == NULL && err[0] != 0, detail);
+}
+
+int main(void) {
+ printf("-- arithmetic and precedence\n");
+ eq("1 + 2 * 3", 7);
+ eq("(1 + 2) * 3", 9);
+ eq("10 - 3 - 2", 5); /* left-associative, not 9 */
+ eq("7 % 3", 1);
+ eq("-5 + 1", -4);
+ eq("100 / 7", 14); /* integer division, truncating */
+
+ printf("\n-- division by zero is defined, not a trap\n");
+ eq("5 / 0", 0);
+ eq("5 % 0", 0);
+ eq("1 + 5 / 0", 1);
+
+ printf("\n-- comparisons and logic produce 0 or 1\n");
+ eq("3 < 4", 1);
+ eq("4 < 3", 0);
+ eq("3 <= 3", 1);
+ eq("3 >= 4", 0);
+ eq("3 == 3", 1);
+ eq("3 != 3", 0);
+ eq("1 && 0", 0);
+ eq("1 || 0", 1);
+ eq("!0", 1);
+ eq("!5", 0);
+ /* The reason comparisons return numbers: masking is how you write a rule. */
+ eq("(3 < 4) * 200", 200);
+
+ printf("\n-- variables\n");
+ eq("x", 3);
+ eq("y", 4);
+ eq("cols", 80);
+ eq("rows", 24);
+ eq("curx", 10);
+ eq("cury", 5);
+ eq("cursor", 1);
+ eq("focused", 1);
+ eq("t", 1000);
+
+ printf("\n-- functions\n");
+ eq("min(3, 9)", 3);
+ eq("max(3, 9)", 9);
+ eq("abs(0 - 7)", 7);
+ eq("clamp(300, 0, 255)", 255);
+ eq("clamp(-5, 0, 255)", 0);
+ eq("clamp(100, 0, 255)", 100);
+ /* dist counts a row double, because a cell is about twice as tall as it is
+ * wide. Four columns apart is 4; two rows apart is also 4. */
+ eq("dist(0, 0, 4, 0)", 4);
+ eq("dist(0, 0, 0, 2)", 4);
+
+ printf("\n-- the ternary evaluates both sides and selects\n");
+ eq("1 ? 10 : 20", 10);
+ eq("0 ? 10 : 20", 20);
+ eq("0 ? 1 / 0 : 7", 7); /* the untaken side is safe because nothing traps */
+
+ printf("\n-- an expression that reads nothing is a constant\n");
+ {
+ char err[128] = {0};
+ expr_prog_t *p = expr_compile("2 * 3 + 1", err, sizeof err);
+ ok("it compiles", p != NULL, err);
+ ok("with no dependencies", p && expr_deps(p) == 0, "deps");
+ ok("folded at compile time", p && expr_constant(p) == 7, "value");
+ expr_free(p);
+ }
+
+ printf("\n-- dependencies are derived from the source, not declared\n");
+ {
+ struct { const char *src; unsigned want; } cases[] = {
+ {"128", 0},
+ {"x * 2", EXPR_DEP_POS},
+ {"y", EXPR_DEP_POS},
+ {"cols / 2", EXPR_DEP_SIZE}, /* not constant: the rect is not known yet */
+ {"dist(x, y, curx, cury)", EXPR_DEP_POS | EXPR_DEP_CURSOR},
+ {"focused * 90", EXPR_DEP_FOCUS},
+ {"t % 1000", EXPR_DEP_TIME},
+ };
+ for (size_t i = 0; i < sizeof cases / sizeof *cases; i++) {
+ char err[128] = {0};
+ expr_prog_t *p = expr_compile(cases[i].src, err, sizeof err);
+ unsigned got = p ? expr_deps(p) : 0xffff;
+ snprintf(detail, sizeof detail, "%s -> %u, wanted %u", cases[i].src, got,
+ cases[i].want);
+ ok(cases[i].src, p && got == cases[i].want, detail);
+ expr_free(p);
+ }
+ }
+
+ printf("\n-- the map is the whole point: computed once, reused\n");
+ {
+ char err[128] = {0};
+ expr_prog_t *p = expr_compile("x + y", err, sizeof err);
+ expr_env_t env = {.cols = 8, .rows = 4};
+ uint8_t *m1 = NULL, *m2 = NULL;
+ ok("a positional program has a map", expr_amount_map(p, &env, &m1), err);
+ ok("with the right values", m1 && m1[0] == 0 && m1[8 * 1 + 2] == 3,
+ "values");
+ ok("asking again returns the same buffer",
+ expr_amount_map(p, &env, &m2) && m2 == m1, "buffer");
+
+ /* Resizing must rebuild it: the size is part of what it was computed from,
+ * and a map kept across a resize is the stale-state bug this design is
+ * supposed to make unrepresentable. */
+ env.cols = 4;
+ env.rows = 2;
+ ok("a different rect rebuilds it",
+ expr_amount_map(p, &env, &m2) && m2[4 * 1 + 1] == 2, "rebuild");
+ expr_free(p);
+ }
+
+ printf("\n-- a clock-reading program has nothing to reuse\n");
+ {
+ char err[128] = {0};
+ expr_prog_t *p = expr_compile("t % 255", err, sizeof err);
+ expr_env_t env = {.cols = 4, .rows = 2};
+ uint8_t *m = NULL;
+ ok("no map is offered", !expr_amount_map(p, &env, &m), "map");
+ expr_free(p);
+ }
+
+ printf("\n-- the cursor is part of the key\n");
+ {
+ char err[128] = {0};
+ expr_prog_t *p = expr_compile("dist(x, y, curx, cury)", err, sizeof err);
+ expr_env_t env = {.cols = 4, .rows = 2, .cursor = 1, .curx = 0, .cury = 0};
+ uint8_t *m = NULL;
+ expr_amount_map(p, &env, &m);
+ int before = m[3];
+ env.curx = 3;
+ expr_amount_map(p, &env, &m);
+ snprintf(detail, sizeof detail, "%d then %d", before, m[3]);
+ ok("moving the cursor rebuilds the map", m[3] != before, detail);
+ expr_free(p);
+ }
+
+ printf("\n-- what is refused, and says why\n");
+ refused("an unknown name", "nosuchvar + 1");
+ refused("an unknown function", "wobble(1, 2)");
+ refused("an unbalanced paren", "(1 + 2");
+ refused("a missing operand", "1 +");
+ refused("trailing junk", "1 + 2 )");
+ refused("an empty expression", "");
+ refused("a ternary without its colon", "1 ? 2");
+ refused("something that is not an expression at all", "@@@");
+
+ printf("\n-- totality: there is no loop to write\n");
+ {
+ /* The language has no way to express unbounded work, so this is a
+ * statement about the grammar rather than a timing test: a deeply nested
+ * expression is bounded by the program length limit and is refused rather
+ * than accepted and run forever. */
+ char big[4096];
+ size_t n = 0;
+ for (int i = 0; i < 200; i++) n += (size_t)snprintf(big + n, sizeof big - n, "1+");
+ snprintf(big + n, sizeof big - n, "1");
+ char err[128] = {0};
+ expr_prog_t *p = expr_compile(big, err, sizeof err);
+ if (p) expr_free(p);
+ ok("an over-long program is refused, not truncated", p == NULL, err);
+ }
+
+ printf("\n%s (%d failures)\n", fails ? "FAILED" : "all green", fails);
+ return fails ? 1 : 0;
+}
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())