aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-14 22:06:57 +0200
committeruser <user@clank>2026-08-14 22:06:57 +0200
commit2ae507a8a331799875e07c327730a1a4ca0363f4 (patch)
tree685b167c2a87f5e9f877cca31d31f43fd4e71734
parentsl0ppty: expand ~ in a path that a config file wrote (diff)
downloadslosh-2ae507a8a331799875e07c327730a1a4ca0363f4.tar.gz
slosh-2ae507a8a331799875e07c327730a1a4ca0363f4.tar.xz
slosh-2ae507a8a331799875e07c327730a1a4ca0363f4.zip
sl0ppty: shaders can be loaded from shared libraries
Adding an effect meant editing shader.c and rebuilding, which is fine for us and useless to anyone else. Now any `*.so` in the shader directory that exports a plugin table is loaded at startup, and what it declares is named in the config exactly like a built-in -- same syntax, same three parameters, same pass. Default location is a `shaders` directory beside the config file, so dropping the .so next to config.kdl is the whole installation. The interesting part is what a plugin can see, which is one header. shader_abi.h holds cell_t, color_t, shade_ctx_t, shader_t and nothing else: no libghostty-vt, no pty, no layout, no session. A shader is therefore unable to reach anything but the cell it was handed, not by rule but because nothing else is declared where it can find it. cell_t and color_t moved there from sl0ppty.h for the same reason -- that is the one place they are a contract with code compiled outside this tree rather than an internal type. Loading is deliberately narrow: - refused unless the library reports our ABI version *and* matching struct sizes. The version says which contract; the sizes catch someone editing their copy of the header, which a version number alone calls fine and a running session calls garbled colours; - refused if it redefines an existing name, because a config naming `dim` means the `dim` it was written against; - all-or-nothing per file, and per file: a bad library is a line on stderr and everything else still loads (D9 fail-open); - nothing is ever unloaded. A shade_fn may be sitting in a live config or on a pane, so dlclose() is a jump into an unmapped page. A reload picks up newly *added* plugins; replacing one needs a new session. This is the single exception to D9's "a plugin is a subprocess", and it is argued in D15 rather than drifted into: a shader is called per cell, per pass, per frame, which no subprocess can be in the path of, and the measured alternative -- interpreting bytecode per cell -- is 56ns against 8ns for the compiled function. On trust, `shell` and a layout's `command=` already let a file in the config directory run anything as you, so a .so beside them is not a new boundary. The docs say so plainly rather than implying a sandbox. contrib/shader-plugin is a working skeleton: Makefile, two commented effects (one positional, one animated), and the three rules a shader has to keep. It is built and loaded by the test suite, so a broken example is a failing test rather than someone else's afternoon.
-rw-r--r--.gitignore3
-rw-r--r--DESIGN.md6
-rw-r--r--HANDOVER.md30
-rw-r--r--Makefile17
-rw-r--r--README.md19
-rw-r--r--config/config.kdl16
-rw-r--r--contrib/README.md10
-rw-r--r--contrib/shader-plugin/Makefile28
-rw-r--r--contrib/shader-plugin/README.md93
-rw-r--r--contrib/shader-plugin/example.c82
-rw-r--r--src/config.c30
-rw-r--r--src/config.h6
-rw-r--r--src/shader.c176
-rw-r--r--src/shader.h64
-rw-r--r--src/shader_abi.h138
-rw-r--r--src/sl0ppty.h30
-rw-r--r--tests/shader_plugin_test.c35
-rw-r--r--tests/test_shader_plugin.py163
18 files changed, 855 insertions, 91 deletions
diff --git a/.gitignore b/.gitignore
index 5921f47..80fa1fd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,6 @@ vendor/libghostty-vt/zig-out/
vendor/libghostty-vt/.zig-cache/
vendor/libghostty-vt/zig-pkg/
__pycache__/
+
+# built plugins
+*.so
diff --git a/DESIGN.md b/DESIGN.md
index 1725424..cc245cd 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -151,12 +151,13 @@ unreadable.
| **D6** | **Responsive layout is a pure function** — see below. If *any* node cannot give its children their floor, the **whole tab** flattens — asked of the tab, answered once, because half a screen of panes beside half a screen of headers explains neither what happened nor what to do about it. The check is the real layout run with resizing switched off, not a second implementation of the same arithmetic. Flattening means: every pane under it becomes one row in a flat list with the focused one opened below them, rather than a nest of headers. Nesting is exactly what there is no room to express, and in that state a reader wants *which pane*, not *which arrangement*. It also makes every pane reachable — collapsing a subtree to a single header gave every leaf inside it the same rect and one hit for the first of them, so the rest could not be clicked at all. |
| **D7** | **Server renders, client is dumb.** The client forwards raw bytes and paints what it is sent — it does not even decode, so the decoder can change without redeploying clients, and a bug there costs a frame rather than a session. The same socket is the scripting API. Detach/reattach is table stakes because agents keep running. |
| **D8** | **Panes and tabs carry `purpose=`**, sl0ppi's semantics kept verbatim, including the trust model: *declared purposes outrank in-band ones and cannot be overridden*, so `cat hostile.txt` cannot relabel a project tab. Declared means "from a layout or an operator", i.e. the control path; the in-band path (OSC, M4) always passes `declared:false` and is refused against a locked slot. Sanitised on ingest to `[A-Za-z0-9_.:/-]`. |
-| **D9** | **No wasm plugins.** Status bar and pane finder are built in. A "plugin" is a subprocess speaking the control protocol. |
+| **D9** | **No wasm plugins.** Status bar and pane finder are built in. A "plugin" is a subprocess speaking the control protocol — with exactly one exception, shaders, for the reason in D15. |
| **D10** | **One attached client** in the MVP. The protocol allows N (read-only observers are what sl0p.foo actually wants); the multi-user *rendering* path is not built, because it is where the fork's phantom-client and `MY FOCUS AND:` bugs live. |
| **D11** | **No terminfo/ncurses.** We are opinionated about the outer terminal: emit a known-good modern subset, probe with DA/XTGETTCAP where we must. |
| **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. |
### D6 — responsive layout as a pure function
@@ -338,5 +339,6 @@ Two protocol details worth keeping in mind, both found by testing:
- Not a zellij/tmux replacement for anyone but us.
- No multi-user session rendering (D10).
- No content resurrection. Layout restore yes, replaying scrollback no.
-- No plugin sandbox (D9).
+- No plugin sandbox (D9, D15): a shader plugin is native code with the
+ session's privileges.
- Nothing but `x86_64-linux` until it works.
diff --git a/HANDOVER.md b/HANDOVER.md
index 03d8a8d..422b04d 100644
--- a/HANDOVER.md
+++ b/HANDOVER.md
@@ -118,6 +118,31 @@ before changing anything near it.
blocking `waitpid` would hang the entire session on one program that closes
its terminal and keeps running. Losing the race costs the words "status 0".
+## Shaders: what is measured, and what is not worth doing
+
+Before optimising anything here, read the numbers rather than reasoning about
+them — `.scratch/shbench.c` and `.scratch/shprof.c` produce them in a second
+(they are gitignored; rebuild with the command in their headers).
+
+What they say, per cell per pass on this box:
+
+- the **arithmetic is free**. `vignette` — squared distances, clamp, divide —
+ is *cheaper* than `dim`, which computes nothing. So precomputing an amount
+ per cell, the obvious optimisation, buys approximately zero.
+- the cost is per-cell overhead and the colour mixing. Removing `screen_at()`
+ from the inner loop took the pass from 8.0ns to 6.1ns for ten lines, and
+ that is done.
+- the next real win is **per-row dispatch** (an indirect call per row rather
+ 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.
+
+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.
+
## Things left on the table
- **A `reload` keybinding.** The config watcher made it less pressing.
@@ -133,6 +158,11 @@ before changing anything near it.
something you are looking at; less fine once you have six of them put away.
- **Nothing re-runs a whole tab**, which is the obvious next want once one
pane can be re-run.
+- **Shader plugins cannot be replaced without a new session** (D15): loading
+ is additive on purpose, because a `shade_fn` may be live in a config or on a
+ 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.
- **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 45b6841..907d525 100644
--- a/Makefile
+++ b/Makefile
@@ -71,6 +71,23 @@ 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 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
+# here so that a broken example is a failing test rather than a bug report.
+PLUGIN_CFLAGS := -std=c23 -O1 -fPIC -shared -Isrc
+
+build/testshader.so: tests/shader_plugin_test.c src/shader_abi.h | build
+ $(CC) $(PLUGIN_CFLAGS) $< -o $@
+
+build/badshader.so: tests/shader_plugin_test.c src/shader_abi.h | build
+ $(CC) $(PLUGIN_CFLAGS) -DBAD_ABI $< -o $@
+
+build/exampleshader.so: contrib/shader-plugin/example.c src/shader_abi.h | build
+ $(CC) $(PLUGIN_CFLAGS) $< -o $@
+
+build/.pass-test_shader_plugin: build/testshader.so build/badshader.so \
+ build/exampleshader.so
+
build/.pass-%: tests/%.py tests/harness.py $(BIN) | build
@cd tests && timeout 300 python3 $(notdir $<) > ../build/.log-$* 2>&1 \
|| { echo "FAIL $(notdir $<)"; tail -25 ../build/.log-$*; exit 1; }
diff --git a/README.md b/README.md
index 5b5dcb9..45c89b8 100644
--- a/README.md
+++ b/README.md
@@ -3,16 +3,6 @@
A terminal multiplexer — panes, tabs, sessions you can detach from — written
in C on top of [libghostty-vt](https://github.com/ghostty-org/ghostty).
-```
- 1 2:build +tab 5 panes
- pi · ready────────────────────────────────────────────────────
- pi · ready────────────────────────────────────────────────────
- ╭──────────────────────────── pi ──────────────────────────+─╮
- │ │
- │ │
- ╰ alt+N · ready · #7 ────────────────────[continue]─[explain]─╯
-```
-
## Why another one
The multiplexers we have are crusty, and the things that make them awkward are
@@ -129,6 +119,15 @@ 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.
+**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
+in the config exactly like a built-in. Skeleton, Makefile and the rules a
+shader has to keep: [`contrib/shader-plugin`](contrib/shader-plugin). It is
+native code in the session's process, so install ones you trust — the same
+standing as `shell` and a layout's `command=`, which can already run anything
+as you.
+
## Scripting
Three things make sl0ppty easy to drive from other programs.
diff --git a/config/config.kdl b/config/config.kdl
index ef3c4f2..ac99e19 100644
--- a/config/config.kdl
+++ b/config/config.kdl
@@ -156,6 +156,22 @@ hover_delay_ms 250
// program calls itself.
double_click_ms 400
+// Extra shaders can be loaded from shared libraries: any `*.so` in the
+// directory below that exports a sl0ppty shader plugin. Once loaded they are
+// named here exactly like a built-in. Default: a `shaders` directory beside
+// this file, so dropping the .so next to your config is the whole install.
+//
+// A plugin is native code running in the session's own process, with your
+// privileges and no sandbox -- which is the same thing `shell` and a layout's
+// `command=` already are, and the reason the search path is a directory you
+// own rather than a value that can point anywhere. Install ones you trust.
+//
+// Adding a plugin and saving this file loads it; nothing is ever unloaded, so
+// *replacing* one needs a new session. Skeleton and Makefile:
+// contrib/shader-plugin/
+//
+// shader_dir "~/.config/sl0ppty/shaders"
+
// ── shaders ─────────────────────────────────────────────────────────────────
//
// Colour passes over a pane's contents (never the frame, title or tab strip).
diff --git a/contrib/README.md b/contrib/README.md
index d187a95..ece31c1 100644
--- a/contrib/README.md
+++ b/contrib/README.md
@@ -33,3 +33,13 @@ SESSION=work contrib/theme-tour slate
It works by writing the theme over the file the session was started with and
letting the config watcher notice, which is also a fair demonstration of the
watcher.
+
+## shader-plugin/
+
+A skeleton for adding your own shaders as a shared library, with a Makefile
+and two worked examples (`checker`, `pulse`). Build it, drop the `.so` in
+`~/.config/sl0ppty/shaders/`, and name it in your config like a built-in.
+
+The only header a plugin needs is `src/shader_abi.h`. What a shader may and
+may not do — and why a plugin is native code rather than a sandboxed one —
+is in that directory's README.
diff --git a/contrib/shader-plugin/Makefile b/contrib/shader-plugin/Makefile
new file mode 100644
index 0000000..94734e5
--- /dev/null
+++ b/contrib/shader-plugin/Makefile
@@ -0,0 +1,28 @@
+# Build a sl0ppty shader plugin.
+#
+# The only thing a plugin needs is src/shader_abi.h. Any C compiler will do —
+# zig is used here because the project already depends on it, not because it
+# has to be. Out of tree, copy shader_abi.h next to your source and drop the
+# -I below.
+
+SL0PPTY ?= ../..
+ZIG ?= $(HOME)/zig-0.16.0/zig
+CC ?= $(ZIG) cc
+
+CFLAGS := -std=c23 -O2 -Wall -Wextra -fPIC -I$(SL0PPTY)/src
+PREFIX ?= $(HOME)/.config/sl0ppty/shaders
+
+.PHONY: all install clean
+
+all: example.so ## build the plugin
+
+example.so: example.c $(SL0PPTY)/src/shader_abi.h
+ $(CC) $(CFLAGS) -shared $< -o $@
+
+install: example.so ## copy it where sl0ppty looks
+ @mkdir -p $(PREFIX)
+ cp example.so $(PREFIX)/
+ @echo "installed to $(PREFIX)/example.so — start a new session to load it"
+
+clean:
+ rm -f example.so
diff --git a/contrib/shader-plugin/README.md b/contrib/shader-plugin/README.md
new file mode 100644
index 0000000..9ed874b
--- /dev/null
+++ b/contrib/shader-plugin/README.md
@@ -0,0 +1,93 @@
+# Shader plugins
+
+A shader is a C function from one cell to that cell's colours. The built-in
+ones live in the binary; you can add your own as a shared library, without
+rebuilding sl0ppty.
+
+```bash
+make # builds example.so
+make install # copies it to ~/.config/sl0ppty/shaders/
+```
+
+Then name it in your config exactly as you would a built-in:
+
+```kdl
+shaders {
+ checker amount=40 band=4
+ pulse amount=60 color="#ff5fd7"
+}
+
+states {
+ unfocused { checker amount=25 band=6 }
+}
+```
+
+Start a new session to pick it up. A running session will load plugins that
+have been **added** when you save your config, but never reloads or unloads
+one that is already in — a shader's function pointer may be sitting in a live
+config or on a pane, and unmapping it underneath would be a crash. To replace
+a plugin, restart the session.
+
+## Writing one
+
+Everything you need is in [`src/shader_abi.h`](../../src/shader_abi.h) — one
+header, no libghostty-vt, no other sl0ppty types. `example.c` is a working
+plugin with two effects and comments about what each one demonstrates.
+
+The shape is:
+
+```c
+#include "shader_abi.h"
+
+static void sh_mine(const shader_t *sh, const shade_ctx_t *ctx, cell_t *c) {
+ /* sh->color, sh->amount, sh->param — what the config set
+ ctx->x, ctx->y, ctx->cols, ctx->rows, ctx->now_ms, ctx->focused,
+ ctx->has_cursor, ctx->cursor_x, ctx->cursor_y, ctx->default_fg/bg
+ c->fg, c->bg, c->attrs — yours to write */
+}
+
+static const shader_def_t SHADERS[] = {{"mine", sh_mine}};
+SL0PPTY_SHADER_PLUGIN("my-shaders", SHADERS)
+```
+
+Three rules, which are also why shaders can be this simple:
+
+- **Colours only.** Write `c->fg`, `c->bg`, `c->attrs`. Never `c->text`,
+ `c->len` or `c->width`: selection and copy read the terminal rather than the
+ screen, so rewriting text there desyncs them, and changing a cell's width
+ breaks an invariant the compositor relies on.
+- **One cell.** No neighbours, no history. That is what makes a shader a pure
+ function of position and colour — and what makes blurs impossible, which is
+ the price of needing no second buffer.
+- **Fast and total.** This runs per cell, per pass, per frame — tens of
+ thousands of calls at up to 120Hz. Do not allocate, block, do I/O, or loop
+ for a length you do not control. Integer maths is plenty: the output is
+ three bytes.
+
+## Config parameters
+
+Each shader node takes the same three, and what they mean is yours to decide:
+
+| in the config | in the struct | |
+|---|---|---|
+| `color="#rrggbb"` | `sh->color` | defaults to the theme's `frame_focus` |
+| `amount=0..255` | `sh->amount` | defaults to 128; 0 should be identity |
+| `at=` `radius=` `band=` `direction=` | `sh->param` | one number, four spellings |
+
+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.
+
+## Compatibility
+
+The plugin declares which ABI it was built against, and the sizes of the three
+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.
+
+## Trust
+
+A shader plugin is native code loaded into the process that holds all your
+terminals. It runs with your privileges and there is no sandbox. That is why
+the search path is a directory you own and not a config value pointing
+anywhere: installing a plugin is a decision, and it should look like one.
diff --git a/contrib/shader-plugin/example.c b/contrib/shader-plugin/example.c
new file mode 100644
index 0000000..9bb257e
--- /dev/null
+++ b/contrib/shader-plugin/example.c
@@ -0,0 +1,82 @@
+/* An example sl0ppty shader plugin.
+ *
+ * Two effects, chosen to show the whole of what a shader is given: where the
+ * cell is, how big the pane is, where the cursor is, what time it is, and the
+ * three parameters the config can set (`color`, `amount`, and one number).
+ *
+ * Build it with `make`, drop the .so in ~/.config/sl0ppty/shaders/, and name
+ * it in your config exactly like a built-in:
+ *
+ * shaders {
+ * checker amount=40 band=4
+ * pulse amount=60 color="#ff5fd7"
+ * }
+ *
+ * The rules a shader must keep (they are the reason shaders can be this
+ * simple):
+ *
+ * - write c->fg, c->bg and c->attrs; never c->text, c->len or c->width
+ * - look at one cell only: no neighbours, no history, no other panes
+ * - return quickly and always. This runs per cell, per pass, per frame:
+ * tens of thousands of calls at 120Hz. It must not allocate, block, do
+ * I/O, or loop for a length it does not control.
+ *
+ * A shader is native code in the multiplexer's own process. It can do
+ * anything the session can. Only install ones you trust.
+ */
+#include "shader_abi.h"
+
+/* The mix every colour effect needs: `amount` of `to`, 0..255. */
+static uint8_t mix8(uint8_t from, uint8_t to, uint8_t amount) {
+ return (uint8_t)(((int)from * (255 - amount) + (int)to * amount) / 255);
+}
+static color_t mix(color_t from, color_t to, uint8_t amount) {
+ return (color_t){true, mix8(from.r, to.r, amount), mix8(from.g, to.g, amount),
+ mix8(from.b, to.b, amount)};
+}
+
+/* A checkerboard, `param` cells to a square (`band=` in the config, which is
+ * one of the names that number answers to). Positional only: it looks at
+ * ctx->x and ctx->y and nothing else, which is the cheapest kind of effect
+ * and the kind most worth writing. */
+static void sh_checker(const shader_t *sh, const shade_ctx_t *ctx, cell_t *c) {
+ uint16_t n = sh->param ? sh->param : 4;
+ /* Cells are about twice as tall as they are wide, so a square in cells is
+ * half as many rows as columns. Ignore this and your checkerboard is a
+ * pattern of letterboxes. */
+ bool dark = (((ctx->x / n) + (ctx->y / (n / 2 ? n / 2 : 1))) & 1) != 0;
+ if (!dark) return;
+ const color_t black = {true, 0, 0, 0};
+ c->fg = mix(c->fg, black, sh->amount);
+ c->bg = mix(c->bg, black, sh->amount);
+}
+
+/* A slow pulse towards `color`, brightest at the cursor and fading with
+ * distance from it. Uses ctx->now_ms, so it repaints continuously while it is
+ * on screen — animation is allowed, but it is the expensive kind of effect
+ * and it is worth knowing that is what you asked for. */
+static void sh_pulse(const shader_t *sh, const shade_ctx_t *ctx, cell_t *c) {
+ if (!ctx->has_cursor) return;
+
+ /* A triangle wave over four seconds: 0 -> 255 -> 0, no floating point and
+ * no trig, because neither buys anything a byte channel can show. */
+ int phase = (int)(ctx->now_ms % 4000);
+ int wave = phase < 2000 ? phase * 255 / 2000 : (4000 - phase) * 255 / 2000;
+
+ int dx = (int)ctx->x - (int)ctx->cursor_x;
+ int dy = ((int)ctx->y - (int)ctx->cursor_y) * 2; /* cells, again */
+ int d2 = dx * dx + dy * dy;
+ int reach = 20 * 20;
+ if (d2 >= reach) return;
+
+ int near = (reach - d2) * 255 / reach;
+ uint8_t a = (uint8_t)((int)sh->amount * wave / 255 * near / 255);
+ c->fg = mix(c->fg, sh->color, a);
+}
+
+static const shader_def_t SHADERS[] = {
+ {"checker", sh_checker},
+ {"pulse", sh_pulse},
+};
+
+SL0PPTY_SHADER_PLUGIN("example", SHADERS)
diff --git a/src/config.c b/src/config.c
index 6b10d36..2b80a18 100644
--- a/src/config.c
+++ b/src/config.c
@@ -322,6 +322,7 @@ void config_defaults(config_t *c) {
void config_free(config_t *c) {
free(c->binds);
free(c->shell);
+ free(c->shader_dir);
memset(c, 0, sizeof *c);
}
@@ -433,6 +434,35 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) {
c->shell = strdup(sh);
}
+ /* Shader plugins, before any shader is named below: a `shaders` block may
+ * use what one of these adds, and a name is looked up as it is parsed. The
+ * default is a directory beside this file, so dropping a `.so` next to the
+ * config is the whole installation procedure. */
+ const char *sdir = kdl_arg(kdl_child(root, "shader_dir"), 0, NULL);
+ if (sdir) {
+ free(c->shader_dir);
+ c->shader_dir = strdup(sdir);
+ }
+ {
+ char dir[1024];
+ if (c->shader_dir) {
+ char buf[1024];
+ snprintf(dir, sizeof dir, "%s",
+ path_expand(c->shader_dir, buf, sizeof buf));
+ } else {
+ snprintf(dir, sizeof dir, "%s", path);
+ char *slash = strrchr(dir, '/');
+ if (slash) slash[1] = 0;
+ else dir[0] = 0;
+ snprintf(dir + strlen(dir), sizeof dir - strlen(dir), "shaders");
+ }
+ char lerr[256] = {0};
+ shader_load_dir(dir, lerr, sizeof lerr);
+ /* A plugin that will not load is worth a line, and worth nothing more:
+ * the config it came with still works, minus that effect (D9). */
+ if (lerr[0] && err && !err[0]) snprintf(err, errcap, "%s", lerr);
+ }
+
/* One node per pass, in the order written, because a chain is a sequence.
* Shared by `shaders { }` and by every state, so a state's chain is exactly
* as expressive as the global one and there is one parser to be wrong. */
diff --git a/src/config.h b/src/config.h
index a854fe8..f8b799c 100644
--- a/src/config.h
+++ b/src/config.h
@@ -228,6 +228,12 @@ typedef struct {
/* what a new pane runs; NULL means $SHELL */
char *shell;
+
+ /* Where to look for shader plugins (`*.so`). NULL means the default: a
+ * `shaders` directory beside the config file. Read before the `shaders` and
+ * `states` blocks are parsed, because what they are allowed to name depends
+ * on what has been loaded. */
+ char *shader_dir;
} config_t;
/* Defaults, then <config dir>/config.kdl on top. Never fails: on a bad file
diff --git a/src/shader.c b/src/shader.c
index 6a02dbc..1d36891 100644
--- a/src/shader.c
+++ b/src/shader.c
@@ -1,6 +1,12 @@
-/* The shader pass and the built-in shaders. See shader.h for the model. */
+/* The shader pass, the built-in shaders, and loading more of them from disk.
+ * See shader.h for the model and shader_abi.h for what a plugin sees. */
+#define _GNU_SOURCE
#include "shader.h"
+#include <dlfcn.h>
+#include <glob.h>
+#include <stdio.h>
+#include <stdlib.h>
#include <string.h>
/* Integer maths throughout: this runs per cell per pass per frame, and a float
@@ -125,10 +131,9 @@ static void sh_spotlight(const shader_t *sh, const shade_ctx_t *ctx,
dim_by(c, (uint8_t)((int)sh->amount * k / 255));
}
-static const struct {
- const char *name;
- shade_fn fn;
-} REGISTRY[] = {
+/* shader_def_t rather than a struct of its own, so a built-in and a loaded
+ * shader are the same kind of thing and lookup does not have two shapes. */
+static const shader_def_t REGISTRY[] = {
{"dim", sh_dim},
{"grayscale", sh_grayscale},
{"tint", sh_tint},
@@ -140,6 +145,33 @@ static const struct {
{"spotlight", sh_spotlight},
};
+#define NBUILTIN (sizeof REGISTRY / sizeof *REGISTRY)
+
+/* ---- loaded shaders -----------------------------------------------------
+ *
+ * Kept beside the built-ins rather than merged into them: a built-in is a
+ * fixed table the compiler can put in .rodata, and this is a list that grows
+ * at runtime. Lookup asks the built-ins first, so a plugin cannot shadow
+ * `dim` and change what every existing config means.
+ *
+ * Both the name and the function belong to the loaded library, which is why
+ * nothing here is ever freed or unloaded: a shader_t copied into a config or
+ * onto a pane holds both pointers. */
+static struct {
+ shader_def_t *defs;
+ size_t n, cap;
+ char **paths; /* what has been loaded, so a reload does not load it twice */
+ size_t npaths, pathcap;
+} LOADED;
+
+static const shader_def_t *find_kind(const char *kind) {
+ for (size_t i = 0; i < NBUILTIN; i++)
+ if (strcmp(REGISTRY[i].name, kind) == 0) return &REGISTRY[i];
+ for (size_t i = 0; i < LOADED.n; i++)
+ if (strcmp(LOADED.defs[i].name, kind) == 0) return &LOADED.defs[i];
+ return NULL;
+}
+
bool shader_make(shader_t *out, const char *kind, color_t color,
uint8_t amount) {
return shader_make_p(out, kind, color, amount, 0);
@@ -148,16 +180,136 @@ bool shader_make(shader_t *out, const char *kind, color_t color,
bool shader_make_p(shader_t *out, const char *kind, color_t color,
uint8_t amount, uint16_t param) {
if (!out || !kind) return false;
- for (size_t i = 0; i < sizeof REGISTRY / sizeof *REGISTRY; i++) {
- if (strcmp(REGISTRY[i].name, kind) != 0) continue;
- *out = (shader_t){REGISTRY[i].name, REGISTRY[i].fn, color, amount, param};
- return true;
- }
- return false;
+ const shader_def_t *d = find_kind(kind);
+ if (!d) return false;
+ *out = (shader_t){d->name, d->fn, color, amount, param};
+ return true;
}
const char *shader_kind(size_t i) {
- return i < sizeof REGISTRY / sizeof *REGISTRY ? REGISTRY[i].name : NULL;
+ if (i < NBUILTIN) return REGISTRY[i].name;
+ i -= NBUILTIN;
+ return i < LOADED.n ? LOADED.defs[i].name : NULL;
+}
+
+/* ---- loading ------------------------------------------------------------ */
+
+static bool already_loaded(const char *path) {
+ for (size_t i = 0; i < LOADED.npaths; i++)
+ if (strcmp(LOADED.paths[i], path) == 0) return true;
+ return false;
+}
+
+static void remember_path(const char *path) {
+ if (LOADED.npaths == LOADED.pathcap) {
+ LOADED.pathcap = LOADED.pathcap ? LOADED.pathcap * 2 : 8;
+ LOADED.paths = realloc(LOADED.paths, LOADED.pathcap * sizeof *LOADED.paths);
+ }
+ LOADED.paths[LOADED.npaths++] = strdup(path);
+}
+
+/* One library. Everything that can be wrong with it is checked before a
+ * single one of its shaders is registered, so a plugin is all-or-nothing:
+ * half a bundle is harder to explain than none of it. */
+static size_t load_one(const char *path, char *err, size_t errcap) {
+ void *lib = dlopen(path, RTLD_NOW | RTLD_LOCAL);
+ if (!lib) {
+ if (err && !err[0])
+ snprintf(err, errcap, "%s: %s", path, dlerror() ? dlerror() : "dlopen");
+ return 0;
+ }
+
+ /* An object pointer is not a function pointer in ISO C, and dlsym returns
+ * the former. POSIX requires the conversion to work; the cast through a
+ * union is how it is written without the compiler being right to warn. */
+ union {
+ void *obj;
+ const shader_plugin_t *(*fn)(void);
+ } entry = {.obj = dlsym(lib, SL0PPTY_SHADER_PLUGIN_SYM)};
+
+ if (!entry.obj) {
+ if (err && !err[0])
+ snprintf(err, errcap, "%s: no " SL0PPTY_SHADER_PLUGIN_SYM, path);
+ dlclose(lib);
+ return 0;
+ }
+
+ const shader_plugin_t *t = entry.fn();
+ if (!t || t->abi != SL0PPTY_SHADER_ABI) {
+ if (err && !err[0])
+ snprintf(err, errcap, "%s: shader ABI %u, we speak %u", path,
+ t ? t->abi : 0, SL0PPTY_SHADER_ABI);
+ dlclose(lib);
+ return 0;
+ }
+ /* The version says which contract; the sizes say whether the plugin really
+ * has that contract's structs. They differ when someone edits their copy of
+ * shader_abi.h, which a version number alone reports as fine and a running
+ * session reports as garbled colours or a crash. */
+ if (t->cell_size != sizeof(cell_t) || t->ctx_size != sizeof(shade_ctx_t) ||
+ t->shader_size != sizeof(shader_t)) {
+ if (err && !err[0])
+ snprintf(err, errcap, "%s: built against different structs", path);
+ dlclose(lib);
+ return 0;
+ }
+ if (t->count && !t->shaders) {
+ if (err && !err[0]) snprintf(err, errcap, "%s: empty shader table", path);
+ dlclose(lib);
+ return 0;
+ }
+
+ for (size_t i = 0; i < t->count; i++) {
+ const shader_def_t *d = &t->shaders[i];
+ if (!d->name || !*d->name || !d->fn) {
+ if (err && !err[0]) snprintf(err, errcap, "%s: nameless shader", path);
+ continue;
+ }
+ if (find_kind(d->name)) {
+ /* Refused rather than replacing: a config naming `dim` means the `dim`
+ * it was written against, and a plugin that could redefine it would
+ * change effects nobody asked it to touch. */
+ if (err && !err[0])
+ snprintf(err, errcap, "%s: shader %s already exists", path, d->name);
+ continue;
+ }
+ if (LOADED.n == LOADED.cap) {
+ LOADED.cap = LOADED.cap ? LOADED.cap * 2 : 8;
+ LOADED.defs = realloc(LOADED.defs, LOADED.cap * sizeof *LOADED.defs);
+ }
+ LOADED.defs[LOADED.n++] = *d;
+ }
+
+ /* The library stays mapped for the life of the session, on purpose: see
+ * shader_load_dir()'s comment in the header. */
+ return t->count;
+}
+
+size_t shader_load_dir(const char *dir, char *err, size_t errcap) {
+ if (err && errcap) err[0] = 0;
+ if (!dir || !*dir) return 0;
+
+ char pattern[1024];
+ snprintf(pattern, sizeof pattern, "%s/*.so", dir);
+
+ glob_t g = {0};
+ int rc = glob(pattern, 0, NULL, &g);
+ if (rc != 0) {
+ globfree(&g);
+ return 0; /* no directory, or nothing in it: the normal case */
+ }
+
+ size_t added = 0;
+ for (size_t i = 0; i < g.gl_pathc; i++) {
+ if (already_loaded(g.gl_pathv[i])) continue;
+ /* Remembered whether or not it loads. A library that fails today fails
+ * every reload, and saying so once is a warning while saying so on every
+ * save is a broken editor. */
+ remember_path(g.gl_pathv[i]);
+ added += load_one(g.gl_pathv[i], err, errcap);
+ }
+ globfree(&g);
+ return added;
}
/* ---- the pass ----------------------------------------------------------- */
diff --git a/src/shader.h b/src/shader.h
index 74518fc..5e25b37 100644
--- a/src/shader.h
+++ b/src/shader.h
@@ -26,6 +26,7 @@
#include <stddef.h>
#include <stdint.h>
+#include "shader_abi.h"
#include "sl0ppty.h"
/* Per pane, and separately per config. Small and fixed: a stack this deep is
@@ -36,54 +37,37 @@
* this frame. */
#define SHADE_CHAIN_MAX (SHADE_MAX * 2 + 2)
-typedef struct {
- uint16_t x, y; /* cell position within the content rect, 0-based */
- uint16_t cols, rows; /* content size, so an effect can be positional */
- int64_t now_ms; /* for anything animated */
- bool focused;
+/* shade_ctx_t, shader_t and shade_fn are the ABI (shader_abi.h). */
- /* Where the cursor is, in the same rect-relative space as x/y. Only ever
- * set for the pane that owns it, so an effect that follows the cursor does
- * not chase another pane's. */
- bool has_cursor;
- uint16_t cursor_x, cursor_y;
-
- /* What "terminal default" means while shading. A cell whose colour is unset
- * is drawn in whatever the client's terminal calls default, and we never
- * learn that RGB — but most terminal text is default-coloured, so a shader
- * that skipped those cells would visibly do nothing. The pass materialises
- * them to these before running the chain. Configured, because guessing is
- * the one thing that would be worse. */
- color_t default_fg, default_bg;
-} shade_ctx_t;
-
-typedef struct shader shader_t;
-typedef void (*shade_fn)(const shader_t *sh, const shade_ctx_t *ctx, cell_t *c);
-
-struct shader {
- const char *kind; /* registry name, NULL for an empty slot */
- shade_fn fn;
- 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
- * that is a column for one effect and a radius for another is not really
- * two things:
- * ruler the column to mark margin first column to dim
- * zebra rows per band spotlight radius in columns
- * gradient 0 down, 1 up, 2 right, 3 left
- */
- uint16_t param;
-};
-
-/* Build a shader by registry name. False if the name is not a built-in. */
+/* Build a shader by registry name. False if the name is not known — a
+ * built-in, or one a loaded plugin added. */
bool shader_make(shader_t *out, const char *kind, color_t color,
uint8_t amount);
/* The same, for the shaders that take a number of their own. */
bool shader_make_p(shader_t *out, const char *kind, color_t color,
uint8_t amount, uint16_t param);
-/* Iterate the registry: the i'th name, or NULL past the end. */
+/* Iterate the registry: the i'th name, or NULL past the end. Built-ins first,
+ * then anything plugins added, in load order. */
const char *shader_kind(size_t i);
+/* Load every `*.so` in `dir` that exports a shader plugin, adding what they
+ * declare to the registry. Returns how many shaders were added; a directory
+ * that does not exist is not an error (it is the normal case). Failures are
+ * per file: a library that cannot be opened, does not export the symbol, or
+ * was built against another ABI is reported in `err` and skipped, and the
+ * rest still load.
+ *
+ * Idempotent by path: a file already loaded is not loaded again, so calling
+ * this on a config reload picks up newly added plugins and disturbs nothing.
+ * Nothing is ever unloaded — a shader's function pointer may be sitting in a
+ * live config or on a pane, and dlclose() would turn it into a jump into an
+ * unmapped page. Replacing a plugin therefore needs a new session.
+ *
+ * A plugin is native code with the privileges of the session that loads it.
+ * This is deliberately a directory you own, not a config value pointing
+ * anywhere: the trust decision is "put a file here". */
+size_t shader_load_dir(const char *dir, char *err, size_t errcap);
+
/* Run `n` shaders in order over the w×h rect at (x0,y0). `base` supplies
* everything in the context except the per-cell position and the size. */
void shade_apply(screen_t *s, const shader_t *shaders, size_t n, uint16_t x0,
diff --git a/src/shader_abi.h b/src/shader_abi.h
new file mode 100644
index 0000000..98decfd
--- /dev/null
+++ b/src/shader_abi.h
@@ -0,0 +1,138 @@
+/* The shader ABI: everything a shader needs to see, and nothing else.
+ *
+ * This header exists so a shader can be compiled *outside* this tree — a
+ * plugin includes only this file, with no libghostty-vt, no pty, no layout.
+ * That is also the contract: a shader gets a cell and where it sits, and it
+ * cannot reach anything else, because nothing else is declared here.
+ *
+ * Everything in here is ABI. Changing the layout of these structs breaks
+ * every plugin built against the old one, so SL0PPTY_SHADER_ABI is bumped
+ * when they change, and a plugin that reports the wrong version (or the wrong
+ * sizes, which catches a plugin built against an edited copy of this file) is
+ * refused at load rather than trusted into a crash.
+ */
+#ifndef SL0PPTY_SHADER_ABI_H
+#define SL0PPTY_SHADER_ABI_H
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+#define SL0PPTY_SHADER_ABI 1
+
+enum {
+ ATTR_BOLD = 1 << 0,
+ ATTR_DIM = 1 << 1,
+ ATTR_ITALIC = 1 << 2,
+ ATTR_UNDERLINE = 1 << 3,
+ ATTR_BLINK = 1 << 4,
+ ATTR_INVERSE = 1 << 5,
+ ATTR_INVISIBLE = 1 << 6,
+ ATTR_STRIKE = 1 << 7,
+};
+
+typedef struct {
+ bool set; /* false = terminal default */
+ uint8_t r, g, b;
+} color_t;
+
+/* A composited cell. `len` bytes of UTF-8; a grapheme cluster may be several
+ * codepoints, hence the buffer rather than a codepoint. width 0 means this is
+ * the tail half of a wide cell and must not be painted.
+ *
+ * A shader may write fg, bg and attrs. It may not write text, len or width:
+ * selection and copy read the terminal rather than the screen, so rewriting
+ * text here would desync them, and changing width would break the invariant
+ * the compositor relies on. */
+typedef struct {
+ char text[16];
+ uint8_t len;
+ uint8_t width;
+ uint16_t attrs;
+ color_t fg, bg;
+} cell_t;
+
+/* Where a cell sits, and what else the effect is allowed to know. */
+typedef struct {
+ uint16_t x, y; /* cell position within the content rect, 0-based */
+ uint16_t cols, rows; /* content size, so an effect can be positional */
+ int64_t now_ms; /* for anything animated */
+ bool focused;
+
+ /* Where the cursor is, in the same rect-relative space as x/y. Only ever
+ * set for the pane that owns it, so an effect that follows the cursor does
+ * not chase another pane's. */
+ bool has_cursor;
+ uint16_t cursor_x, cursor_y;
+
+ /* What "terminal default" means while shading. A cell whose colour is unset
+ * is drawn in whatever the client's terminal calls default, and we never
+ * learn that RGB — but most terminal text is default-coloured, so a shader
+ * that skipped those cells would visibly do nothing. The pass materialises
+ * them to these before running the chain. Configured, because guessing is
+ * the one thing that would be worse. */
+ color_t default_fg, default_bg;
+} shade_ctx_t;
+
+typedef struct shader shader_t;
+typedef void (*shade_fn)(const shader_t *sh, const shade_ctx_t *ctx, cell_t *c);
+
+struct shader {
+ const char *kind; /* registry name, NULL for an empty slot */
+ shade_fn fn;
+ 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
+ * that is a column for one effect and a radius for another is not really
+ * two things:
+ * ruler the column to mark margin first column to dim
+ * zebra rows per band spotlight radius in columns
+ * gradient 0 down, 1 up, 2 right, 3 left
+ */
+ uint16_t param;
+};
+
+/* ---- plugins ------------------------------------------------------------
+ *
+ * A shared library dropped in the shader directory, exporting one symbol:
+ *
+ * const shader_plugin_t *sl0ppty_shader_plugin(void);
+ *
+ * SL0PPTY_SHADER_PLUGIN() writes that for you. See
+ * contrib/shader-plugin/ for a complete one.
+ */
+
+typedef struct {
+ const char *name; /* what to call it in the config */
+ shade_fn fn;
+} shader_def_t;
+
+typedef struct {
+ uint32_t abi; /* SL0PPTY_SHADER_ABI it was built against */
+ uint32_t cell_size; /* the three sizes catch a plugin built against a */
+ uint32_t ctx_size; /* copy of this header that has drifted, which a */
+ uint32_t shader_size; /* version number alone would not */
+ const char *name; /* the bundle's name, for the log line */
+ size_t count;
+ const shader_def_t *shaders;
+} shader_plugin_t;
+
+#define SL0PPTY_SHADER_PLUGIN_SYM "sl0ppty_shader_plugin"
+
+/* Define the entry point. `defs` is a static array of shader_def_t. */
+#define SL0PPTY_SHADER_PLUGIN(bundle_name, defs) \
+ const shader_plugin_t *sl0ppty_shader_plugin(void); \
+ const shader_plugin_t *sl0ppty_shader_plugin(void) { \
+ static const shader_plugin_t table = { \
+ .abi = SL0PPTY_SHADER_ABI, \
+ .cell_size = sizeof(cell_t), \
+ .ctx_size = sizeof(shade_ctx_t), \
+ .shader_size = sizeof(shader_t), \
+ .name = (bundle_name), \
+ .count = sizeof(defs) / sizeof((defs)[0]), \
+ .shaders = (defs), \
+ }; \
+ return &table; \
+ }
+
+#endif /* SL0PPTY_SHADER_ABI_H */
diff --git a/src/sl0ppty.h b/src/sl0ppty.h
index 96f8542..f7b7ee6 100644
--- a/src/sl0ppty.h
+++ b/src/sl0ppty.h
@@ -8,36 +8,12 @@
#include <sys/types.h>
#include "input.h"
+/* cell_t, color_t and the ATTR_* flags live in the shader ABI, because that is
+ * the one place they are a *contract* with code compiled outside this tree. */
+#include "shader_abi.h"
/* ---- screen: our composited cell buffer + diff emitter ---------------- */
-enum {
- ATTR_BOLD = 1 << 0,
- ATTR_DIM = 1 << 1,
- ATTR_ITALIC = 1 << 2,
- ATTR_UNDERLINE = 1 << 3,
- ATTR_BLINK = 1 << 4,
- ATTR_INVERSE = 1 << 5,
- ATTR_INVISIBLE = 1 << 6,
- ATTR_STRIKE = 1 << 7,
-};
-
-typedef struct {
- bool set; /* false = terminal default */
- uint8_t r, g, b;
-} color_t;
-
-/* A composited cell. `len` bytes of UTF-8; a grapheme cluster may be several
- * codepoints, hence the buffer rather than a codepoint. width 0 means this is
- * the tail half of a wide cell and must not be painted. */
-typedef struct {
- char text[16];
- uint8_t len;
- uint8_t width;
- uint16_t attrs;
- color_t fg, bg;
-} cell_t;
-
/* One geometry (DESIGN.md): every painted interactive element records its rect
* here as it is painted, and a click is a lookup. Drawing and hit-testing
* cannot disagree because there is only one of them. */
diff --git a/tests/shader_plugin_test.c b/tests/shader_plugin_test.c
new file mode 100644
index 0000000..15639d6
--- /dev/null
+++ b/tests/shader_plugin_test.c
@@ -0,0 +1,35 @@
+/* Fixture for test_shader_plugin.py: a shader plugin with nothing to it.
+ *
+ * Built twice — once as itself, once with -DBAD_ABI to produce a library that
+ * announces a version we do not speak, which is the case that has to fail
+ * safely rather than crash.
+ */
+#include "shader_abi.h"
+
+#ifdef BAD_ABI
+#undef SL0PPTY_SHADER_ABI
+#define SL0PPTY_SHADER_ABI 9999
+#endif
+
+/* Paints every cell's background the configured colour, at full strength when
+ * amount is 255. Deterministic and position-independent, so the test can
+ * assert one cell and mean it. */
+static void sh_fill(const shader_t *sh, const shade_ctx_t *ctx, cell_t *c) {
+ (void)ctx;
+ if (!sh->amount) return;
+ c->bg = sh->color;
+}
+
+/* Uses param, so the test can prove a plugin sees the config's number. */
+static void sh_stripe(const shader_t *sh, const shade_ctx_t *ctx, cell_t *c) {
+ uint16_t n = sh->param ? sh->param : 2;
+ if (ctx->x % n) return;
+ c->bg = sh->color;
+}
+
+static const shader_def_t SHADERS[] = {
+ {"testfill", sh_fill},
+ {"teststripe", sh_stripe},
+};
+
+SL0PPTY_SHADER_PLUGIN("test-fixture", SHADERS)
diff --git a/tests/test_shader_plugin.py b/tests/test_shader_plugin.py
new file mode 100644
index 0000000..9648a0d
--- /dev/null
+++ b/tests/test_shader_plugin.py
@@ -0,0 +1,163 @@
+#!/usr/bin/env python3
+"""Shaders loaded from shared libraries at runtime.
+
+The built-ins are compiled in; these are not. What matters is that a plugin's
+shader is indistinguishable from a built-in once loaded (same config syntax,
+same parameters, same pass), and that everything which can be wrong with a
+library fails safely: a wrong ABI, a name that already exists, a directory
+that is not there.
+
+The example in contrib/ is built and loaded here too, so it cannot rot.
+"""
+import os
+import shutil
+import sys
+import tempfile
+
+from harness import Session, check, report
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+BUILD = os.path.join(HERE, "..", "build")
+GOOD = os.path.join(BUILD, "testshader.so")
+BAD = os.path.join(BUILD, "badshader.so")
+EXAMPLE = os.path.join(BUILD, "exampleshader.so")
+
+# A pane that paints one known cell and then sits still.
+SH = ["/bin/sh", "-c", 'printf "\\033]2;p\\007"; printf "BLOCK\\n"; read x']
+
+
+def session_dir(plugins, config, shaders_subdir=False):
+ """A config file, and the plugins it should find beside it."""
+ d = tempfile.mkdtemp(prefix="sl0ppty-plug-")
+ dest = os.path.join(d, "shaders") if shaders_subdir else d
+ os.makedirs(dest, exist_ok=True)
+ for p in plugins:
+ shutil.copy(p, dest)
+ path = os.path.join(d, "config.kdl")
+ with open(path, "w") as f:
+ f.write(config)
+ return path, dest
+
+
+def bg_at(snap, pane, row=0, col=0):
+ run = snap.style_at(pane["content_x"] + col, pane["content_y"] + row)
+ return (run or {}).get("bg")
+
+
+def fg_at(snap, pane, row=0, col=0):
+ run = snap.style_at(pane["content_x"] + col, pane["content_y"] + row)
+ return (run or {}).get("fg")
+
+
+def test_a_plugin_shader_is_used_like_a_builtin():
+ cfg, d = session_dir([GOOD], 'shader_dir "%s"\nshaders {\n'
+ ' testfill amount=255 color="#00ff00"\n}\n')
+ # the directory is known only after it exists, so write the config now
+ with open(cfg, "w") as f:
+ f.write('shader_dir "%s"\nshaders {\n'
+ ' testfill amount=255 color="#00ff00"\n}\n' % d)
+
+ with Session(SH, cols=40, rows=10, config=cfg) as s:
+ snap = s.until_text("BLOCK")
+ pane = s.pane()
+ check("the loaded shader ran", bg_at(snap, pane) == "#00ff00",
+ str(snap.style_at(pane["content_x"], pane["content_y"])))
+
+
+def test_it_is_found_beside_the_config_by_default():
+ cfg, d = session_dir([GOOD], "", shaders_subdir=True)
+ with open(cfg, "w") as f:
+ f.write('shaders {\n testfill amount=255 color="#0000ff"\n}\n')
+
+ with Session(SH, cols=40, rows=10, config=cfg) as s:
+ snap = s.until_text("BLOCK")
+ check("no shader_dir needed", bg_at(snap, s.pane()) == "#0000ff",
+ snap.screen())
+
+
+def test_the_config_number_reaches_the_plugin():
+ cfg, d = session_dir([GOOD], "")
+ with open(cfg, "w") as f:
+ f.write('shader_dir "%s"\nshaders {\n'
+ ' teststripe amount=255 band=3 color="#ff0000"\n}\n' % d)
+
+ with Session(SH, cols=40, rows=10, config=cfg) as s:
+ snap = s.until_text("BLOCK")
+ pane = s.pane()
+ got = [bg_at(snap, pane, 0, x) for x in range(6)]
+ check("every third column, as asked",
+ got[0] == "#ff0000" and got[3] == "#ff0000" and
+ got[1] != "#ff0000" and got[2] != "#ff0000", str(got))
+
+
+def test_a_stale_plugin_is_refused_and_the_rest_still_load():
+ cfg, d = session_dir([GOOD, BAD], "")
+ with open(cfg, "w") as f:
+ f.write('shader_dir "%s"\nshaders {\n'
+ ' testfill amount=255 color="#00ff00"\n}\n' % d)
+
+ with Session(SH, cols=40, rows=10, config=cfg) as s:
+ snap = s.until_text("BLOCK")
+ check("the session is alive", s.alive())
+ check("the good plugin still loaded",
+ bg_at(snap, s.pane()) == "#00ff00", snap.screen())
+
+
+def test_an_unknown_shader_is_skipped_not_fatal():
+ cfg, d = session_dir([], "")
+ with open(cfg, "w") as f:
+ f.write('shaders {\n nosuchshader amount=255\n dim amount=0\n}\n')
+
+ with Session(SH, cols=40, rows=10, config=cfg) as s:
+ snap = s.until_text("BLOCK")
+ check("the session survives a name it does not know", s.alive())
+ check("and still draws", "BLOCK" in snap.screen(), snap.screen())
+
+
+def test_no_shader_directory_at_all_is_normal():
+ cfg, d = session_dir([], "")
+ with open(cfg, "w") as f:
+ f.write('shader_dir "/nonexistent/sl0ppty/shaders"\n'
+ 'shaders {\n dim amount=100\n}\n')
+
+ with Session(SH, cols=40, rows=10, config=cfg) as s:
+ snap = s.until_text("BLOCK")
+ check("a missing directory is not an error", s.alive())
+ check("and the built-ins are unaffected", "BLOCK" in snap.screen())
+
+
+def test_the_directory_may_be_written_with_a_tilde():
+ cfg, d = session_dir([GOOD], "", shaders_subdir=True)
+ home = os.path.dirname(d)
+ with open(cfg, "w") as f:
+ f.write('shader_dir "~/shaders"\nshaders {\n'
+ ' testfill amount=255 color="#00ff00"\n}\n')
+
+ with Session(SH, cols=40, rows=10, config=cfg, env={"HOME": home}) as s:
+ snap = s.until_text("BLOCK")
+ check("~ in shader_dir is the home directory",
+ bg_at(snap, s.pane()) == "#00ff00", snap.screen())
+
+
+def test_the_contrib_example_builds_and_loads():
+ cfg, d = session_dir([EXAMPLE], "")
+ with open(cfg, "w") as f:
+ f.write('shader_dir "%s"\nshaders {\n'
+ ' checker amount=255 band=2\n}\n' % d)
+
+ with Session(SH, cols=40, rows=10, config=cfg) as s:
+ snap = s.until_text("BLOCK")
+ pane = s.pane()
+ # Asserted on the foreground: the checkerboard darkens towards black
+ # and the default background already *is* black, so two squares that
+ # differ would still read the same there. Reading the wrong channel
+ # would make a working effect look like a broken one.
+ a = fg_at(snap, pane, 0, 0)
+ b = fg_at(snap, pane, 0, 2)
+ check("the shipped example draws something", a != b, f"{a} {b}")
+
+
+for name, fn in sorted(list(globals().items())):
+ if name.startswith("test_"):
+ fn()
+sys.exit(report())