From cd4a985ef5bf33bb719bd688378dbb4ae6f4bfee Mon Sep 17 00:00:00 2001
From: EDToaster
Date: Thu, 22 Oct 2026 11:30:25 -0700
Subject: [PATCH 01/16] term: record BEL position and count; docs: M13
post-process shader spec
---
crates/toastty-term/src/term.rs | 52 +++++-
docs/milestones/m13-user-shaders.md | 239 +++++++++++++++++++++++++++-
2 files changed, 282 insertions(+), 9 deletions(-)
diff --git a/crates/toastty-term/src/term.rs b/crates/toastty-term/src/term.rs
index 5e912cf3..7bc03ff1 100644
--- a/crates/toastty-term/src/term.rs
+++ b/crates/toastty-term/src/term.rs
@@ -286,6 +286,15 @@ pub struct Term {
/// renderer keeps a cached linear-light palette and compares
/// revisions to decide when to rebuild.
palette_revision: u32,
+ /// Bump-counter incremented on every BEL (0x07) the program prints.
+ /// The renderer compares revisions to spot new bells (user shaders
+ /// animate on them). An OSC terminated by BEL never reaches
+ /// `execute`, so it doesn't count.
+ bell_revision: u32,
+ /// Cursor cell `(row, col)` in screen coordinates when the last BEL
+ /// arrived. Captured here because the cursor has usually moved on by
+ /// the time the next frame is drawn.
+ last_bell_cell: (u16, u16),
/// Intern table for OSC 8 hyperlink URLs. The cell stores a
/// `NonZeroU16` index into this `Vec`; closing the hyperlink
/// (`OSC 8 ; ; ST`) clears `current_hyperlink` without touching
@@ -592,6 +601,8 @@ impl Term {
pty_replies: Vec::new(),
palette_overrides: Box::new([None; 256]),
palette_revision: 0,
+ bell_revision: 0,
+ last_bell_cell: (0, 0),
hyperlinks: Vec::new(),
current_hyperlink: None,
extras: ExtraTable::default(),
@@ -966,6 +977,19 @@ impl Term {
self.palette_revision
}
+ /// Bump-counter incremented on every BEL. See [`Self::last_bell_cell`].
+ #[must_use]
+ pub fn bell_revision(&self) -> u32 {
+ self.bell_revision
+ }
+
+ /// Cursor cell `(row, col)` in screen coordinates at the most recent
+ /// BEL. `(0, 0)` before the first one; check [`Self::bell_revision`].
+ #[must_use]
+ pub fn last_bell_cell(&self) -> (u16, u16) {
+ self.last_bell_cell
+ }
+
/// Set palette override for `idx` and bump the revision. Marks every
/// row dirty so the new color shows immediately under partial
/// redraw (since the resolved color of any existing cell drawn at
@@ -4400,7 +4424,14 @@ impl Perform for Term {
// HT: advance to the next tab stop (HTS / TBC editable),
// clamped at the right margin. Same damage-gap fix as BS.
b'\t' => self.tab_forward(1),
- // BEL and everything else are no-ops for M3.
+ // BEL: record where it rang. Nothing on screen changes, so no
+ // damage; the renderer watches `bell_revision`.
+ 0x07 => {
+ let col = self.cursor.col.min(self.cols.saturating_sub(1));
+ self.last_bell_cell = (self.cursor.row, col);
+ self.bell_revision = self.bell_revision.wrapping_add(1);
+ }
+ // Everything else is a no-op.
_ => {}
}
}
@@ -9334,6 +9365,25 @@ mod tests {
assert_eq!(&t.drain_pty_replies()[..], b"\x1b[?2;5R");
}
+ #[test]
+ fn bel_records_cursor_cell_and_bumps_revision() {
+ let mut t = Term::new(5, 10, 0);
+ assert_eq!(t.bell_revision(), 0);
+ feed(&mut t, b"\x1b[3;7H\x07ab");
+ assert_eq!(t.bell_revision(), 1);
+ assert_eq!(t.last_bell_cell(), (2, 6));
+ feed(&mut t, b"\x07");
+ assert_eq!(t.bell_revision(), 2);
+ assert_eq!(t.last_bell_cell(), (2, 8));
+ }
+
+ #[test]
+ fn bel_terminated_osc_is_not_a_bell() {
+ let mut t = Term::new(2, 4, 0);
+ feed(&mut t, b"\x1b]0;title\x07");
+ assert_eq!(t.bell_revision(), 0);
+ }
+
#[test]
fn osc4_set_records_override_and_bumps_revision() {
let mut t = Term::new(2, 4, 0);
diff --git a/docs/milestones/m13-user-shaders.md b/docs/milestones/m13-user-shaders.md
index 4480bb43..5c66106c 100644
--- a/docs/milestones/m13-user-shaders.md
+++ b/docs/milestones/m13-user-shaders.md
@@ -1,17 +1,240 @@
# M13 — User shaders and post-process
-**Goal.** Power-user differentiator. ShaderToy port compatibility was measured at 100% on a 14-shader corpus in decision #8 — bring that to users.
+**Goal.** Let users run ShaderToy/Ghostty-style fragment shaders over the
+terminal image. Ghostty's shaders should paste in unchanged, and a shader
+that doesn't animate must cost nothing at idle.
-**Scope.** Post-process fragment shader stage after the cell pass, slotted into the existing render graph as pass 4. User supplies a shader file path in config (or via a CLI arg for live experimentation).
+**Status.** Spec agreed 2026-10-22. It supersedes parts of
+[decision #8](../decisions/shader-pipeline.md): GLSL is the main
+language in practice, the y axis points down, and there is no
+`iCursor`. The rest of #8 (naga, the shim, last-good on error) stands.
-**Dual-path shader pipeline.** WGSL primary — written by the user as `fn main(...) -> @location(0) vec4`. GLSL secondary via `naga`'s GLSL frontend — accepts ShaderToy-style `void mainImage(out vec4 fragColor, in vec2 fragCoord)`, runs through a ~50-LoC shim (`glsl_shim.rs` from decision #8's prototype is the reference) that synthesizes an entry point, translates via `naga` at runtime to whatever the backend wants (SPIR-V/MSL/HLSL).
+## Config
-**Uniforms.** Mirror ShaderToy conventions where sensible: `iTime` (seconds), `iFrame` (counter), `iResolution` (`vec3` with width/height/aspect), `iCursor` (cell coords). Plus a previous-framebuffer texture binding so effects can read the pre-shader output.
+```toml
+[shader]
+# Post-process shaders, run in order. Each one's output is the next one's
+# iChannel0. Empty (the default) turns post-processing off.
+# `~` expands; relative paths are relative to the config file's directory.
+# The extension picks the language: .glsl / .frag = GLSL, .wgsl = WGSL.
+paths = []
+# "auto" detect from the shader (below)
+# "always" redraw at ~60 Hz all the time, even unfocused
+# "never" redraw only on terminal updates and shader-input events
+animation = "auto"
+```
-**Hot reload.** `notify` crate watches the shader file. On change, recompile via `naga`. On compile failure, surface diagnostics in the status line (or stderr) and **keep the last-good pipeline live** so the renderer never crashes from a bad edit. The Ghostty/Kitty contract: a typo should never take the terminal down.
+Shader files are watched. On a change they are recompiled. If any shader
+in the chain fails, the error goes to the in-window error banner
+(`path:line:col: message`, line numbers in the user's file, not the
+prelude-wrapped source) and the previous working chain stays live. A
+config reload that changes `[shader]` behaves the same way.
-**Sandbox.** None needed — fragment shaders can't escape the GPU. Resource limits via wgpu's validation.
+## Languages
-**The shim's known gotcha.** From decision #8: `#define`-based ShaderToy uniform aliases break naga's swizzle parsing (`#define iMouse (vec4(...))` then `iMouse.zw` fails). The shim declares `iMouse` as a top-level `vec4` and copies from the UBO inside the synthesized `main()` — already worked out in the prototype.
+### GLSL (ShaderToy / Ghostty dialect)
-**Out of scope.** A UI for shader selection (just a file path in config). Multi-pass shaders. Compute-shader effects.
+The user file contains `void mainImage(out vec4 fragColor, in vec2
+fragCoord)` plus any helpers, and nothing else. We prepend a prelude and
+append a `main()`; naga's GLSL frontend parses the result. The prelude:
+
+- `#version 450 core`. A `#version` line in the user file is blanked
+ (kept as an empty line so line numbers stay right).
+- An unnamed std140 uniform block (`set = 0, binding = 0`) whose members
+ are the uniforms below, so the user writes `iTime` directly. naga turns
+ each access into `AccessIndex(GlobalVariable(block), i)`.
+- `texture2D iChannel0_tex` and `sampler iChannel0_smp` at bindings 1 and
+ 2, plus `#define iChannel0 sampler2D(iChannel0_tex, iChannel0_smp)`.
+- `#define texture2D texture` and the `CURSORSTYLE_*` macros (Ghostty
+ compatibility).
+- `main()` passes `gl_FragCoord.xy` straight through as `fragCoord`,
+ without flipping it (see Coordinates).
+
+`iChannel1..3` are not declared. Using one is a compile error that names
+the line.
+
+### WGSL
+
+The user file contains `fn mainImage(fragCoord: vec2f) -> vec4f`. The
+prelude declares `var toastty: ToasttyUniforms` (same member
+names as GLSL, so `toastty.iTime`), `iChannel0: texture_2d` and
+`iChannel0Sampler: sampler` at the same bindings, the
+`CURSORSTYLE_*` constants, and an entry point that calls `mainImage`.
+
+## Coordinates and colour
+
+- **The y axis points down** with the origin at the top left: wgpu's
+ native convention, and the one Ghostty uses on macOS. So `fragCoord` is
+ `gl_FragCoord.xy` (pixel centres at .5), and
+ `texture(iChannel0, fragCoord / iResolution.xy)` samples the same pixel.
+ Nothing is flipped anywhere. ShaderToy shaders that draw relative to
+ the bottom of the screen come out upside down, as they do in Ghostty on
+ macOS.
+- `iResolution` is the full surface, padding included.
+- **Colours are sRGB-encoded** (display values in 0–1), as on ShaderToy.
+ `iChannel0` is sampled through a non-sRGB view of the scratch texture,
+ and the last pass writes through a non-sRGB view of the swapchain
+ image. A pass-through shader therefore reproduces the frame exactly.
+ Colour uniforms (`iPalette`, `iBackgroundColor`, …) are sRGB-encoded
+ too, converted from the renderer's linear theme values.
+- **Alpha is premultiplied.** `iChannel0` holds what the cell pass wrote
+ (premultiplied; alpha 1 unless the theme bg is transparent). The output
+ is presented as is, so a shader that writes `alpha = 1` makes a
+ transparent window opaque. This is documented, not handled for v1.
+- The sampler is linear-filtered and clamps to the edge.
+
+## Uniforms
+
+Names and types match Ghostty's `shadertoy_prefix.glsl`
+(ghostty-org/ghostty `f54340ad`) so its shaders compile unchanged.
+
+| Uniform | Type | Value |
+|---|---|---|
+| `iResolution` | `vec3` | `(width, height, 1)` px |
+| `iTime` | `float` | Seconds since the first post-processed frame |
+| `iTimeDelta` | `float` | Seconds since the previous frame |
+| `iFrameRate` | `float` | `1 / iTimeDelta`, smoothed |
+| `iFrame` | `int` | Frames rendered with post-processing |
+| `iChannelTime[4]` | `float[4]` | 0 |
+| `iChannelResolution[4]` | `vec3[4]` | `[0]` = `iResolution`, the rest 0 |
+| `iMouse` | `vec4` | ShaderToy semantics, y-down px: `xy` = pointer position while button 1 is held (last held position otherwise); `zw` = position of the last press, `z` negative once released, `w` negative after the press frame |
+| `iDate` | `vec4` | `(year, month - 1, day, seconds since local midnight)` |
+| `iSampleRate` | `float` | 0 |
+| `iCurrentCursor` | `vec4` | Drawn cursor rect (a bar cursor is thin): `xy` = `(left, top + height)`, the left and **bottom** edge (Ghostty's y-down convention: the −x, +y corner), `zw` = width, height |
+| `iPreviousCursor` | `vec4` | Value before the last change |
+| `iCurrentCursorColor`, `iPreviousCursorColor` | `vec4` | sRGB RGBA |
+| `iCurrentCursorStyle`, `iPreviousCursorStyle` | `int` | `CURSORSTYLE_BLOCK` 0, `_BLOCK_HOLLOW` 1, `_BAR` 2, `_UNDERLINE` 3, `_LOCK` 4 |
+| `iCursorVisible` | `int` | 0 / 1 |
+| `iTimeCursorChange` | `float` | `iTime` when the cursor rect or colour last changed; `-1e6` until the first change |
+| `iTimeFocus` | `float` | `iTime` when the window last gained focus (0 at start) |
+| `iFocus` | `int` | 0 / 1 |
+| `iPalette[256]` | `vec3[256]` | Live palette (OSC 4 included) |
+| `iBackgroundColor`, `iForegroundColor` | `vec3` | Current theme (after reverse video) |
+| `iCursorColor`, `iCursorText` | `vec3` | |
+| `iSelectionBackgroundColor`, `iSelectionForegroundColor` | `vec3` | |
+
+Toastty extensions:
+
+| Uniform | Type | Value |
+|---|---|---|
+| `iTimeBell` | `float` | `iTime` at the last BEL; `-1e6` before the first |
+| `iBellCursor` | `vec4` | The cell the cursor was in when the BEL arrived, same layout as `iCurrentCursor` |
+| `iBellCount` | `int` | BELs since start |
+| `iBells[4]` | `vec4[4]` | The last four BELs, newest first: `xy` = centre of the bell cell (px), `z` = `iTime` at the bell, `w` = seconds since the bell, computed on the CPU in f64 (exact even after days of uptime). Unused slots: `(0, 0, -1e6, 1e6)` |
+| `iCellSize` | `vec2` | Cell width, height (px) |
+| `iGridRect` | `vec4` | The cell grid: `xy` = top-left (px), `zw` = size (px) |
+
+The bell position comes from `Term::last_bell_cell()` (screen row/col
+captured when BEL is parsed) and `Term::bell_revision()`, mapped to
+pixels the way `cursor_pixel_rect` maps the cursor (view offset
+included). A bell in an OSC terminator is not a bell.
+
+## Animation
+
+After compiling, walk the naga module: collect the uniform-block members
+read by `mainImage` and everything it calls (the `AccessIndex` on the
+block global, through the `Call` graph). The chain's needs are the union
+over its shaders.
+
+- **Time inputs:** `iTime`, `iTimeDelta`, `iFrame`, `iDate`, `iBells`
+ (its `w`). A shader that reads none of them can't change between events,
+ so it never needs time-driven frames.
+- **Event inputs:** `iMouse` (pointer move / press), `iFocus` and
+ `iTimeFocus` (focus change), the bell uniforms (BEL). When one of these
+ changes and the shader reads it, render a frame even if nothing is
+ damaged. Cursor and colour uniforms change only with damage or a full
+ clear, which already render.
+- **Event duration hint:** a shader may declare
+ `const float TOASTTY_EVENT_DURATION = 1.5;` (WGSL:
+ `const TOASTTY_EVENT_DURATION: f32 = 1.5;`), read from the module's
+ named constants. It means "my time-driven animation only lasts this long
+ after an event". It is valid but unused in Ghostty.
+
+Per `[shader] animation`:
+
+| Mode | Shader reads time, declares a duration | Reads time, no duration | No time inputs |
+|---|---|---|---|
+| `auto` | ~60 Hz for `duration` after any bell, cursor change or focus gain (focused or not), else idle | ~60 Hz while focused; idle while unfocused | Idle |
+| `always` | ~60 Hz | ~60 Hz | ~60 Hz |
+| `never` | Event frames only | Event frames only | Event frames only |
+
+"Idle" means the existing skip-submit gate applies. The chain's
+duration is the max over its shaders. Time-driven frames are scheduled
+through `Renderer::next_redraw_deadline`, like cursor blink.
+
+## Render path
+
+With no shaders configured, the frame path is **unchanged and
+pixel-identical** (the existing snapshot tests guard this).
+
+With a chain:
+
+1. **Content pass** into `scratch_texture`, never directly into the
+ swapchain (`render_direct` is off). It draws everything it draws today
+ except the UI overlays, and keeps the partial-redraw and `LoadOp` logic.
+ The IME preedit stays here, because it is part of the content.
+2. **Post passes**, one fullscreen triangle per shader. Pass 0 reads
+ scratch. Each later pass reads the previous pass's output (two
+ ping-pong textures in the non-sRGB surface format, created only when
+ the chain has more than one shader). The last pass writes the
+ swapchain image through its non-sRGB view. This replaces the
+ `copy_texture_to_texture` blit.
+3. **UI pass** on the swapchain image (sRGB view, `LoadOp::Load`), drawn
+ every post-processed frame and never shaded: debug overlay, error
+ banner, close dialog with its scrim, scroll-to-bottom chip,
+ "Copy Markdown" chip.
+
+Scratch needs `TEXTURE_BINDING` and a non-sRGB entry in `view_formats`.
+The surface config needs a non-sRGB `view_formats` entry. Switching the
+chain on or off forces a full clear.
+
+A frame with no damage but a due post animation (or an event) skips the
+content pass's draws and runs only steps 2 and 3.
+
+## Renderer API (`toastty-render`)
+
+```rust
+pub struct PostShaderSource { pub path: PathBuf, pub source: String }
+pub struct ShaderError { pub path: PathBuf, pub line: Option, pub column: Option, pub message: String } // Display: "path:line:col: message"
+pub enum PostAnimation { Auto, Always, Never }
+
+impl Renderer {
+ /// Compile and install a chain. All-or-nothing: if any shader fails,
+ /// returns every error and keeps the current chain. An empty slice
+ /// turns post-processing off.
+ pub fn set_post_shaders(&mut self, shaders: &[PostShaderSource]) -> Result<(), Vec>;
+ pub fn set_post_animation(&mut self, mode: PostAnimation);
+ pub fn set_focused(&mut self, focused: bool);
+ /// Pointer position in physical px (surface space) and button-1 state.
+ pub fn set_pointer(&mut self, pos: (f32, f32), button1_down: bool);
+}
+```
+
+The renderer reads the bell from `Term` itself in `render_term`, so a
+BEL with no other output must still produce a `render_term` call (the
+binary requests a redraw after any PTY input).
+
+## Testing
+
+- Unit: the prelude keeps user line numbers; `#version` is blanked; error
+ locations map back to the user file; analysis finds time inputs through
+ helper calls; a static shader is static; the duration constant is read
+ in both languages; `iChannel1` gives a clean error.
+- GPU (via `test_gpu`): a pass-through shader reproduces the no-shader
+ frame; an invert shader inverts; a two-shader chain applies both; with
+ no shader, existing snapshots are unchanged.
+- App, on Xvfb: screenshots of the demo shader, static and mid-ripple.
+
+## Demo
+
+`examples/shaders/crt-splitflap.glsl`: a static CRT look (barrel
+curvature, scanlines, aperture mask, vignette) plus, on each BEL, a
+split-flap wave. Starting at the bell's cell, cells flip like a
+departure-board tile in a ring that spreads outward. Overlapping bells
+come from `iBells`, and `TOASTTY_EVENT_DURATION` keeps it idle between
+bells.
+
+## Out of scope
+
+UI for choosing shaders, multi-buffer ShaderToys (`iChannel1+`, feedback),
+compute effects, HLSL/Slang/SPIR-V input, visual/audible bell features.
From 0234fbbd5f48615d8a9f6b28346e3f9d633f5bc1 Mon Sep 17 00:00:00 2001
From: EDToaster
Date: Thu, 22 Oct 2026 11:32:13 -0700
Subject: [PATCH 02/16] examples: CRT + split-flap bell demo shader
---
examples/shaders/crt-splitflap.glsl | 148 ++++++++++++++++++++++++++++
1 file changed, 148 insertions(+)
create mode 100644 examples/shaders/crt-splitflap.glsl
diff --git a/examples/shaders/crt-splitflap.glsl b/examples/shaders/crt-splitflap.glsl
new file mode 100644
index 00000000..cfd880aa
--- /dev/null
+++ b/examples/shaders/crt-splitflap.glsl
@@ -0,0 +1,148 @@
+// CRT look with a split-flap wave on every bell.
+//
+// The CRT part is static: barrel curvature, scanlines, an aperture-grille
+// mask, slight colour fringing and a vignette. It reads no time input, so
+// between bells toastty draws nothing at all.
+//
+// On each BEL (try `printf '\a'`), the cells flip like the tiles of a
+// split-flap departure board. The wave starts at the cell where the bell
+// rang and spreads outward until it has crossed the window. Up to four
+// overlapping bells (`iBells`) each run their own wave.
+//
+// Enable it with:
+//
+// [shader]
+// paths = ["~/src/toastty/examples/shaders/crt-splitflap.glsl"]
+
+// toastty: animate for this long after an event (bell, cursor move, focus),
+// then stop drawing. Must cover SWEEP_SECONDS + FLIP_SECONDS.
+const float TOASTTY_EVENT_DURATION = 1.6;
+
+// ---- CRT ----
+const float CURVATURE = 0.06; // barrel distortion, 0 = flat
+const float SCANLINES = 0.22; // scanline darkening, 0..1
+const float SCANLINE_PX = 3.0; // scanline period in pixels
+const float MASK = 0.10; // aperture-grille strength, 0..1
+const float FRINGE_PX = 0.6; // red/blue offset at the edges, px
+const float VIGNETTE = 0.30; // corner darkening, 0..1
+const float BRIGHTNESS = 1.12; // makes up for scanline/mask loss
+
+// ---- split-flap ----
+const float SWEEP_SECONDS = 1.0; // time for the wave to cross the window
+const float FLIP_SECONDS = 0.34; // time for one tile to flip
+const float FLAP_SHADE = 0.55; // darkest the flap gets edge-on
+const float SHADOW = 0.35; // shadow the flap casts on the half below
+
+const float PI = 3.14159265;
+
+// Barrel distortion in 0..1 uv space.
+vec2 curve(vec2 uv) {
+ vec2 c = uv * 2.0 - 1.0;
+ c *= 1.0 + CURVATURE * (c.yx * c.yx);
+ return c * 0.5 + 0.5;
+}
+
+// Flip phase of the cell centred at `center`: 0..1 while it is flipping,
+// -1 when it is at rest. The newest bell that is flipping this cell wins.
+float flip_phase(vec2 center) {
+ float diag = length(iResolution.xy);
+ for (int i = 0; i < 4; i++) {
+ vec4 bell = iBells[i];
+ float delay = distance(center, bell.xy) / diag * SWEEP_SECONDS;
+ float t = (bell.w - delay) / FLIP_SECONDS;
+ if (t > 0.0 && t < 1.0) {
+ return t;
+ }
+ }
+ return -1.0;
+}
+
+// Where to sample for pixel `p` (px), plus a brightness factor. Inside a
+// flipping cell this folds the top half down over the hinge at the
+// cell's middle, the way a split-flap tile turns.
+vec3 split_flap(vec2 p) {
+ vec2 grid = p - iGridRect.xy;
+ if (any(lessThan(grid, vec2(0.0))) || any(greaterThanEqual(grid, iGridRect.zw))) {
+ return vec3(p, 1.0);
+ }
+ vec2 origin = iGridRect.xy + floor(grid / iCellSize) * iCellSize;
+ float t = flip_phase(origin + 0.5 * iCellSize);
+ if (t < 0.0) {
+ return vec3(p, 1.0);
+ }
+
+ vec2 local = (p - origin) / iCellSize; // 0..1, y down
+ float fold = cos(PI * t); // 1 -> 0 -> -1
+ float half_h = 0.5 * abs(fold); // projected height of the flap
+ float light = mix(FLAP_SHADE, 1.0, abs(fold));
+ float y = local.y;
+ float shade = 1.0;
+
+ if (fold > 0.0) {
+ // First half: the top flap tips toward the viewer and shrinks
+ // onto the hinge, uncovering the (shadowed) card behind it.
+ if (y >= 0.5 - half_h && y < 0.5) {
+ y = 0.5 - (0.5 - y) / abs(fold);
+ shade = light;
+ } else if (y < 0.5) {
+ shade = 1.0 - SHADOW * abs(fold);
+ }
+ } else {
+ // Second half: the flap has passed the hinge and now covers the
+ // bottom half, unfolding downward. The half it hasn't reached yet
+ // sits in its shadow.
+ if (y >= 0.5 && y < 0.5 + half_h) {
+ y = 0.5 + (y - 0.5) / abs(fold);
+ shade = light;
+ } else if (y >= 0.5) {
+ shade = 1.0 - SHADOW * (1.0 - abs(fold));
+ }
+ }
+
+ // The hinge and the tile edges show as thin dark gaps mid-flip.
+ float px_y = abs(local.y - 0.5) * iCellSize.y;
+ vec2 edge = min(local, 1.0 - local) * iCellSize;
+ if (px_y < 0.75 || min(edge.x, edge.y) < 0.75) {
+ shade *= 0.35;
+ }
+
+ return vec3(origin + vec2(local.x, y) * iCellSize, shade);
+}
+
+void mainImage(out vec4 fragColor, in vec2 fragCoord) {
+ vec2 uv = curve(fragCoord / iResolution.xy);
+ if (any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0)))) {
+ fragColor = vec4(0.0, 0.0, 0.0, 1.0); // outside the tube
+ return;
+ }
+
+ vec3 flap = split_flap(uv * iResolution.xy);
+ vec2 src = flap.xy / iResolution.xy;
+
+ // Colour fringing grows toward the edges.
+ vec2 fringe = (uv - 0.5) * 2.0 * FRINGE_PX / iResolution.xy;
+ vec4 base = texture(iChannel0, src);
+ vec3 color = vec3(
+ texture(iChannel0, src + fringe).r,
+ base.g,
+ texture(iChannel0, src - fringe).b
+ );
+ color *= flap.z;
+
+ // Scanlines follow the curved surface, so they bend with it.
+ float scan = 0.5 + 0.5 * cos(2.0 * PI * uv.y * iResolution.y / SCANLINE_PX);
+ color *= 1.0 - SCANLINES * scan;
+
+ // Aperture grille: one bright channel per pixel column, in screen space.
+ int column = int(mod(floor(fragCoord.x), 3.0));
+ vec3 mask = vec3(1.0 - MASK);
+ mask[column] = 1.0;
+ color *= mask;
+
+ float v = 16.0 * uv.x * uv.y * (1.0 - uv.x) * (1.0 - uv.y);
+ color *= mix(1.0 - VIGNETTE, 1.0, pow(v, 0.3));
+
+ // `base.a` keeps a transparent background transparent; the colour is
+ // only ever darkened, so it stays a valid premultiplied value.
+ fragColor = vec4(min(color * BRIGHTNESS, vec3(base.a)), base.a);
+}
From 51e20b518c3837b2929b16e639d5f2ddd92c5ea9 Mon Sep 17 00:00:00 2001
From: EDToaster
Date: Thu, 22 Oct 2026 11:35:18 -0700
Subject: [PATCH 03/16] config: [shader] section; shader file loading and
watching (M13)
Adds [shader] paths / animation to toastty-config, and two
renderer-agnostic modules in the binary crate: shader_files (resolve
~ and config-relative paths, read sources, report per-file errors)
and shader_watcher (watch each parent dir, flag + Wake on change).
Not wired into main.rs yet.
---
Cargo.lock | 1 +
crates/toastty-config/src/lib.rs | 16 ++
crates/toastty-config/src/shader.rs | 103 ++++++++
.../toastty-config/tests/fixtures/full.toml | 4 +
crates/toastty/Cargo.toml | 1 +
crates/toastty/src/cli.rs | 1 +
crates/toastty/src/config_watcher.rs | 3 +-
crates/toastty/src/lib.rs | 2 +
crates/toastty/src/shader_files.rs | 227 ++++++++++++++++++
crates/toastty/src/shader_watcher.rs | 218 +++++++++++++++++
10 files changed, 575 insertions(+), 1 deletion(-)
create mode 100644 crates/toastty-config/src/shader.rs
create mode 100644 crates/toastty/src/shader_files.rs
create mode 100644 crates/toastty/src/shader_watcher.rs
diff --git a/Cargo.lock b/Cargo.lock
index 01f58e7e..29da5ce0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4942,6 +4942,7 @@ dependencies = [
"notify",
"pollster",
"pprof",
+ "tempfile",
"thiserror 2.0.18",
"toastty-config",
"toastty-graphics",
diff --git a/crates/toastty-config/src/lib.rs b/crates/toastty-config/src/lib.rs
index f01d4493..126121f3 100644
--- a/crates/toastty-config/src/lib.rs
+++ b/crates/toastty-config/src/lib.rs
@@ -29,6 +29,7 @@ mod scroll_button;
mod scrollback;
mod security;
mod selection;
+mod shader;
mod shell;
mod text;
mod theme;
@@ -45,6 +46,7 @@ pub use scroll_button::{ScrollButtonConfig, ScrollButtonPosition};
pub use scrollback::{ScrollbackConfig, SmoothingFunction};
pub use security::SecurityConfig;
pub use selection::{DEFAULT_WORD_CHARS, SelectionConfig};
+pub use shader::{ShaderAnimation, ShaderConfig};
pub use shell::ShellConfig;
pub use text::TextConfig;
pub use theme::ThemeConfig;
@@ -73,6 +75,7 @@ pub struct Config {
pub scroll_button: ScrollButtonConfig,
pub selection: SelectionConfig,
pub text: TextConfig,
+ pub shader: ShaderConfig,
}
impl Default for Config {
@@ -99,6 +102,7 @@ impl Config {
scroll_button: ScrollButtonConfig::defaults(),
selection: SelectionConfig::defaults(),
text: TextConfig::defaults(),
+ shader: ShaderConfig::defaults(),
}
}
@@ -246,6 +250,18 @@ mod tests {
assert!(Config::parse_str("[text]\nnope = 1\n").is_err());
}
+ #[test]
+ fn shader_parses_and_defaults() {
+ let d = Config::defaults();
+ assert!(d.shader.paths.is_empty());
+ assert_eq!(d.shader.animation, ShaderAnimation::Auto);
+ let cfg =
+ Config::parse_str("[shader]\npaths = [\"a.glsl\"]\nanimation = \"always\"\n").unwrap();
+ assert_eq!(cfg.shader.paths, vec!["a.glsl".to_string()]);
+ assert_eq!(cfg.shader.animation, ShaderAnimation::Always);
+ assert!(Config::parse_str("[shader]\nanimation = \"maybe\"\n").is_err());
+ }
+
#[test]
fn fully_populated_toml_parses() {
let toml_src = include_str!("../tests/fixtures/full.toml");
diff --git a/crates/toastty-config/src/shader.rs b/crates/toastty-config/src/shader.rs
new file mode 100644
index 00000000..9cf1fc10
--- /dev/null
+++ b/crates/toastty-config/src/shader.rs
@@ -0,0 +1,103 @@
+//! `[shader]` table — user post-process shaders (M13).
+//!
+//! ```toml
+//! [shader]
+//! # Post-process shaders, run in order. Empty turns post-processing off.
+//! # `~` expands; relative paths are relative to the config file's directory.
+//! # .glsl / .frag = GLSL (ShaderToy / Ghostty dialect), .wgsl = WGSL.
+//! paths = []
+//! # "auto" (detect from the shader), "always" (~60 Hz), "never" (events only).
+//! animation = "auto"
+//! ```
+//!
+//! This crate only stores the raw strings; path resolution and file
+//! loading live in the binary (`toastty::shader_files`). See
+//! `docs/milestones/m13-user-shaders.md`.
+
+use serde::{Deserialize, Serialize};
+
+/// When post-process shaders get time-driven redraws.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
+#[serde(rename_all = "kebab-case")]
+pub enum ShaderAnimation {
+ /// Detect from the shader which inputs it reads (the default).
+ #[default]
+ Auto,
+ /// Redraw at ~60 Hz all the time, even unfocused.
+ Always,
+ /// Redraw only on terminal updates and shader-input events.
+ Never,
+}
+
+/// Post-process shader config.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct ShaderConfig {
+ /// Shader files, run in order. Empty disables post-processing.
+ pub paths: Vec,
+ /// Animation mode.
+ pub animation: ShaderAnimation,
+}
+
+impl ShaderConfig {
+ /// Schema defaults — no shaders, `auto` animation.
+ #[must_use]
+ pub fn defaults() -> Self {
+ Self {
+ paths: Vec::new(),
+ animation: ShaderAnimation::Auto,
+ }
+ }
+}
+
+impl Default for ShaderConfig {
+ fn default() -> Self {
+ Self::defaults()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn defaults_are_off_and_auto() {
+ let d = ShaderConfig::defaults();
+ assert_eq!(d, ShaderConfig::default());
+ assert!(d.paths.is_empty());
+ assert_eq!(d.animation, ShaderAnimation::Auto);
+ }
+
+ #[test]
+ fn round_trip_and_partial_table() {
+ let c = ShaderConfig {
+ paths: vec!["~/a.glsl".into(), "b.wgsl".into()],
+ animation: ShaderAnimation::Never,
+ };
+ let t = toml::to_string(&c).unwrap();
+ let p: ShaderConfig = toml::from_str(&t).unwrap();
+ assert_eq!(p, c);
+ let only_paths: ShaderConfig = toml::from_str("paths = [\"x.glsl\"]\n").unwrap();
+ assert_eq!(only_paths.paths, vec!["x.glsl".to_string()]);
+ assert_eq!(only_paths.animation, ShaderAnimation::Auto);
+ }
+
+ #[test]
+ fn animation_parses_each_variant() {
+ for (s, want) in [
+ ("auto", ShaderAnimation::Auto),
+ ("always", ShaderAnimation::Always),
+ ("never", ShaderAnimation::Never),
+ ] {
+ let c: ShaderConfig = toml::from_str(&format!("animation = \"{s}\"\n")).unwrap();
+ assert_eq!(c.animation, want);
+ }
+ }
+
+ #[test]
+ fn invalid_values_rejected() {
+ assert!(toml::from_str::("animation = \"sometimes\"\n").is_err());
+ assert!(toml::from_str::("paths = \"a.glsl\"\n").is_err());
+ assert!(toml::from_str::("bogus = 1\n").is_err());
+ }
+}
diff --git a/crates/toastty-config/tests/fixtures/full.toml b/crates/toastty-config/tests/fixtures/full.toml
index f4fef3f9..0ae833d0 100644
--- a/crates/toastty-config/tests/fixtures/full.toml
+++ b/crates/toastty-config/tests/fixtures/full.toml
@@ -62,3 +62,7 @@ word_chars = "_-./~:?&=%+#@"
[text]
blink = true
+
+[shader]
+paths = ["~/.config/toastty/crt.glsl", "bloom.wgsl"]
+animation = "auto"
diff --git a/crates/toastty/Cargo.toml b/crates/toastty/Cargo.toml
index d763dd35..b8c9476a 100644
--- a/crates/toastty/Cargo.toml
+++ b/crates/toastty/Cargo.toml
@@ -54,6 +54,7 @@ libc = "0.2"
pprof = ["dep:pprof"]
[dev-dependencies]
+tempfile = "3"
toastty-graphics.workspace = true
[lints]
diff --git a/crates/toastty/src/cli.rs b/crates/toastty/src/cli.rs
index 0cf0fa1c..516d8067 100644
--- a/crates/toastty/src/cli.rs
+++ b/crates/toastty/src/cli.rs
@@ -627,6 +627,7 @@ mod tests {
"[shell]",
"[scrollback]",
"[window]",
+ "[shader]",
] {
assert!(s.contains(section), "missing section {section} in:\n{s}");
}
diff --git a/crates/toastty/src/config_watcher.rs b/crates/toastty/src/config_watcher.rs
index d1b1455a..ad9e9d2e 100644
--- a/crates/toastty/src/config_watcher.rs
+++ b/crates/toastty/src/config_watcher.rs
@@ -107,7 +107,8 @@ fn matches_target(ev: ¬ify::Event, target: &Path) -> bool {
/// Filter out access / metadata-only notifications so we don't fire a
/// reload for every `stat` the editor does. Create / Modify(Data) /
/// Modify(Name) (atomic rename) / Remove are the interesting kinds.
-fn is_payload_event(kind: EventKind) -> bool {
+/// Shared with [`crate::shader_watcher`].
+pub(crate) fn is_payload_event(kind: EventKind) -> bool {
use notify::event::ModifyKind;
matches!(
kind,
diff --git a/crates/toastty/src/lib.rs b/crates/toastty/src/lib.rs
index 75e9b60c..7b5dac7d 100644
--- a/crates/toastty/src/lib.rs
+++ b/crates/toastty/src/lib.rs
@@ -28,5 +28,7 @@ pub mod paste;
pub mod perf_stats;
pub mod preload;
pub mod pty_log;
+pub mod shader_files;
+pub mod shader_watcher;
pub mod shell;
pub mod theme_bridge;
diff --git a/crates/toastty/src/shader_files.rs b/crates/toastty/src/shader_files.rs
new file mode 100644
index 00000000..9f95a5b7
--- /dev/null
+++ b/crates/toastty/src/shader_files.rs
@@ -0,0 +1,227 @@
+//! Resolve and read the `[shader] paths` files (M13).
+//!
+//! Renderer-agnostic: the binary turns each [`ShaderFile`] into a
+//! `toastty_render::PostShaderSource`. Resolution rules:
+//!
+//! - `~` and `~/…` expand to `$HOME` (`~user` is not supported and is
+//! taken literally).
+//! - Absolute paths are used as is.
+//! - Relative paths are relative to the directory of the config file in
+//! use, or the XDG config dir (`$XDG_CONFIG_HOME/toastty`,
+//! `~/.config/toastty`) when running on defaults. See [`base_dir`].
+
+use std::path::{Path, PathBuf};
+
+use toastty_config::{Config, ConfigSource};
+
+/// One shader file that was read successfully.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ShaderFile {
+ /// Resolved path (what the user sees in error messages).
+ pub path: PathBuf,
+ /// File contents.
+ pub source: String,
+}
+
+/// A shader file that couldn't be read. Displays as `path: message`.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ShaderFileError {
+ pub path: PathBuf,
+ pub message: String,
+}
+
+impl std::fmt::Display for ShaderFileError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}: {}", self.path.display(), self.message)
+ }
+}
+
+/// Directory that relative shader paths are resolved against, for a
+/// config loaded from `source`. `None` when there is no config file and
+/// no XDG/HOME to derive one from (relative paths then stay relative to
+/// the CWD).
+#[must_use]
+pub fn base_dir(source: &ConfigSource) -> Option {
+ base_dir_with(source, Config::default_path().as_deref())
+}
+
+/// [`base_dir`] with the default config path passed in (testable
+/// without touching the process environment).
+fn base_dir_with(source: &ConfigSource, default_path: Option<&Path>) -> Option {
+ let file = match source {
+ ConfigSource::File(p) => p.as_path(),
+ ConfigSource::Defaults => default_path?,
+ };
+ file.parent().map(Path::to_path_buf)
+}
+
+/// Resolve every configured path, in order. `base` comes from
+/// [`base_dir`]; `$HOME` is read from the environment.
+#[must_use]
+pub fn resolve_paths(raw: &[String], base: Option<&Path>) -> Vec {
+ let home = std::env::var_os("HOME").map(PathBuf::from);
+ raw.iter()
+ .map(|r| resolve_path(r, base, home.as_deref()))
+ .collect()
+}
+
+/// Resolve one configured path. See the module docs for the rules.
+#[must_use]
+pub fn resolve_path(raw: &str, base: Option<&Path>, home: Option<&Path>) -> PathBuf {
+ if let Some(home) = home.filter(|h| !h.as_os_str().is_empty()) {
+ if raw == "~" {
+ return home.to_path_buf();
+ }
+ if let Some(rest) = raw.strip_prefix("~/") {
+ return home.join(rest);
+ }
+ }
+ let p = Path::new(raw);
+ match base {
+ Some(base) if p.is_relative() => base.join(p),
+ _ => p.to_path_buf(),
+ }
+}
+
+/// Read every file in `paths`, in order. All-or-nothing: if any file
+/// can't be read, returns one error per failing file.
+pub fn load(paths: &[PathBuf]) -> Result, Vec> {
+ let mut files = Vec::with_capacity(paths.len());
+ let mut errors = Vec::new();
+ for path in paths {
+ match std::fs::read_to_string(path) {
+ Ok(source) => files.push(ShaderFile {
+ path: path.clone(),
+ source,
+ }),
+ Err(e) => errors.push(ShaderFileError {
+ path: path.clone(),
+ message: e.to_string(),
+ }),
+ }
+ }
+ if errors.is_empty() {
+ Ok(files)
+ } else {
+ Err(errors)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn tilde_expands_to_home() {
+ let home = Path::new("/h");
+ let base = Path::new("/cfg");
+ assert_eq!(
+ resolve_path("~/s/crt.glsl", Some(base), Some(home)),
+ PathBuf::from("/h/s/crt.glsl")
+ );
+ assert_eq!(
+ resolve_path("~", Some(base), Some(home)),
+ PathBuf::from("/h")
+ );
+ // `~user` isn't expanded; it's a relative path.
+ assert_eq!(
+ resolve_path("~bob/x.glsl", Some(base), Some(home)),
+ PathBuf::from("/cfg/~bob/x.glsl")
+ );
+ // No HOME: `~/` stays literal (relative to the base).
+ assert_eq!(
+ resolve_path("~/x.glsl", Some(base), None),
+ PathBuf::from("/cfg/~/x.glsl")
+ );
+ }
+
+ #[test]
+ fn relative_joins_base_absolute_kept() {
+ let base = Path::new("/cfg/toastty");
+ assert_eq!(
+ resolve_path("shaders/a.wgsl", Some(base), None),
+ PathBuf::from("/cfg/toastty/shaders/a.wgsl")
+ );
+ assert_eq!(
+ resolve_path("/abs/a.glsl", Some(base), None),
+ PathBuf::from("/abs/a.glsl")
+ );
+ assert_eq!(resolve_path("a.glsl", None, None), PathBuf::from("a.glsl"));
+ }
+
+ #[test]
+ fn base_dir_from_file_or_default() {
+ let file = ConfigSource::File(PathBuf::from("/etc/t/my.toml"));
+ assert_eq!(
+ base_dir_with(&file, Some(Path::new("/x/toastty/config.toml"))),
+ Some(PathBuf::from("/etc/t"))
+ );
+ assert_eq!(
+ base_dir_with(
+ &ConfigSource::Defaults,
+ Some(Path::new("/x/toastty/config.toml"))
+ ),
+ Some(PathBuf::from("/x/toastty"))
+ );
+ assert_eq!(base_dir_with(&ConfigSource::Defaults, None), None);
+ // The env-reading wrapper runs.
+ let _ = base_dir(&ConfigSource::Defaults);
+ }
+
+ #[test]
+ fn resolve_paths_keeps_order() {
+ let base = Path::new("/cfg");
+ let raw = vec!["b.glsl".to_string(), "/a.wgsl".to_string()];
+ assert_eq!(
+ resolve_paths(&raw, Some(base)),
+ vec![PathBuf::from("/cfg/b.glsl"), PathBuf::from("/a.wgsl")]
+ );
+ }
+
+ #[test]
+ fn load_reads_files_in_order() {
+ let dir = tempfile::tempdir().unwrap();
+ let a = dir.path().join("a.glsl");
+ let b = dir.path().join("b.wgsl");
+ std::fs::write(&a, "void mainImage() {}").unwrap();
+ std::fs::write(&b, "fn mainImage() {}").unwrap();
+ let files = load(&[b.clone(), a.clone()]).unwrap();
+ assert_eq!(
+ files,
+ vec![
+ ShaderFile {
+ path: b,
+ source: "fn mainImage() {}".into()
+ },
+ ShaderFile {
+ path: a,
+ source: "void mainImage() {}".into()
+ },
+ ]
+ );
+ assert_eq!(load(&[]).unwrap(), Vec::new());
+ }
+
+ #[test]
+ fn load_reports_every_failing_file() {
+ let dir = tempfile::tempdir().unwrap();
+ let ok = dir.path().join("ok.glsl");
+ std::fs::write(&ok, "x").unwrap();
+ let missing = dir.path().join("missing.glsl");
+ let not_utf8 = dir.path().join("bin.glsl");
+ std::fs::write(¬_utf8, [0xff, 0xfe, 0x00]).unwrap();
+
+ let errs = load(&[missing.clone(), ok, not_utf8.clone()]).unwrap_err();
+ assert_eq!(errs.len(), 2);
+ assert_eq!(errs[0].path, missing);
+ let shown = errs[0].to_string();
+ assert!(
+ shown.starts_with(&format!("{}: No such file", missing.display())),
+ "{shown}"
+ );
+ assert_eq!(errs[1].path, not_utf8);
+ // A directory is not a shader file either.
+ let errs = load(&[dir.path().to_path_buf()]).unwrap_err();
+ assert_eq!(errs.len(), 1);
+ }
+}
diff --git a/crates/toastty/src/shader_watcher.rs b/crates/toastty/src/shader_watcher.rs
new file mode 100644
index 00000000..a0dc3635
--- /dev/null
+++ b/crates/toastty/src/shader_watcher.rs
@@ -0,0 +1,218 @@
+//! Hot-reload of the `[shader] paths` files (M13).
+//!
+//! Same design as [`crate::config_watcher`]: each distinct parent
+//! directory is watched non-recursively (so editor save-via-rename and
+//! files created later are seen), events are filtered to the target
+//! files and to payload kinds, and a hit sets a shared pending flag and
+//! sends `UserEvent::Wake`. The main thread drains the flag with
+//! [`ShaderWatcher::take_pending`] and reloads the whole chain.
+//!
+//! When the path list changes (config reload), the binary drops the old
+//! watcher and spawns a new one; that's one inotify instance, so cheap.
+//! Paths are matched literally: a symlinked shader is reloaded when the
+//! link changes, not when its target is edited in another directory.
+
+use std::collections::{BTreeSet, HashSet};
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, Ordering};
+
+use notify::{RecommendedWatcher, RecursiveMode, Watcher};
+use toastty_window::EventLoopProxy;
+use tracing::{debug, warn};
+
+use crate::config_watcher::is_payload_event;
+
+/// Handle returned to the binary. Drop it to stop watching.
+pub struct ShaderWatcher {
+ /// Kept alive so the watcher thread keeps running.
+ #[allow(dead_code)]
+ inner: RecommendedWatcher,
+ pending: Arc,
+}
+
+impl std::fmt::Debug for ShaderWatcher {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("ShaderWatcher")
+ .field("pending", &self.pending.load(Ordering::Relaxed))
+ .finish_non_exhaustive()
+ }
+}
+
+impl ShaderWatcher {
+ /// Watch `paths` (resolved shader files). Returns `None` when there
+ /// is nothing to watch (empty list, or no parent directory exists)
+ /// or the watcher can't be installed. Non-fatal: shaders still load
+ /// once, just without hot-reload.
+ pub fn spawn(
+ paths: &[PathBuf],
+ proxy: EventLoopProxy,
+ ) -> Option {
+ let dirs: Vec = parent_dirs(paths)
+ .into_iter()
+ .filter(|d| {
+ let ok = d.is_dir();
+ if !ok {
+ debug!(dir = ?d, "shader dir missing — not watching");
+ }
+ ok
+ })
+ .collect();
+ if dirs.is_empty() {
+ return None;
+ }
+ let pending = Arc::new(AtomicBool::new(false));
+ let pending_for_cb = Arc::clone(&pending);
+ let targets: HashSet = paths.iter().cloned().collect();
+
+ let mut watcher =
+ match notify::recommended_watcher(move |res: notify::Result| match res {
+ Ok(ev) => {
+ if !is_relevant(&ev, &targets) {
+ return;
+ }
+ pending_for_cb.store(true, Ordering::SeqCst);
+ // Err means the loop has exited; nothing to do.
+ let _ = proxy.send_event(toastty_window::UserEvent::Wake);
+ }
+ Err(e) => warn!("shader watcher error: {e}"),
+ }) {
+ Ok(w) => w,
+ Err(e) => {
+ warn!("shader watcher init failed: {e}");
+ return None;
+ }
+ };
+
+ let mut n_watched = 0;
+ for dir in &dirs {
+ match watcher.watch(dir, RecursiveMode::NonRecursive) {
+ Ok(()) => n_watched += 1,
+ Err(e) => warn!("shader watch() failed for {}: {e}", dir.display()),
+ }
+ }
+ if n_watched == 0 {
+ return None;
+ }
+
+ debug!(?paths, "shader watcher installed");
+ Some(Self {
+ inner: watcher,
+ pending,
+ })
+ }
+
+ /// Atomically clear and return whether a reload is pending.
+ pub fn take_pending(&self) -> bool {
+ self.pending.swap(false, Ordering::SeqCst)
+ }
+}
+
+/// Distinct parent directories of `paths`, sorted. A bare relative file
+/// name has an empty parent, which means the CWD.
+fn parent_dirs(paths: &[PathBuf]) -> BTreeSet {
+ paths
+ .iter()
+ .filter_map(|p| p.parent())
+ .map(|d| {
+ if d.as_os_str().is_empty() {
+ PathBuf::from(".")
+ } else {
+ d.to_path_buf()
+ }
+ })
+ .collect()
+}
+
+/// True when `ev` is a payload event (see
+/// [`crate::config_watcher`]) touching one of `targets`. Siblings in a
+/// watched directory are ignored.
+fn is_relevant(ev: ¬ify::Event, targets: &HashSet) -> bool {
+ is_payload_event(ev.kind) && ev.paths.iter().any(|p| matches(p, targets))
+}
+
+/// Exact match, plus `./name` for targets that are bare relative names
+/// (their directory is watched as `.`, so events arrive as `./name`).
+fn matches(p: &Path, targets: &HashSet) -> bool {
+ targets.contains(p) || p.strip_prefix(".").is_ok_and(|rest| targets.contains(rest))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use notify::EventKind;
+ use notify::event::{AccessKind, CreateKind, DataChange, ModifyKind, RenameMode};
+
+ fn ev(kind: EventKind, paths: &[&str]) -> notify::Event {
+ notify::Event {
+ kind,
+ paths: paths.iter().map(PathBuf::from).collect(),
+ attrs: notify::event::EventAttributes::default(),
+ }
+ }
+
+ fn targets(paths: &[&str]) -> HashSet {
+ paths.iter().map(PathBuf::from).collect()
+ }
+
+ #[test]
+ fn parent_dirs_are_distinct() {
+ let paths: Vec = ["/a/x.glsl", "/a/y.wgsl", "/b/z.glsl", "rel.glsl"]
+ .iter()
+ .map(PathBuf::from)
+ .collect();
+ let dirs: Vec = parent_dirs(&paths).into_iter().collect();
+ assert_eq!(
+ dirs,
+ vec![PathBuf::from("/a"), PathBuf::from("/b"), PathBuf::from(".")]
+ );
+ assert!(parent_dirs(&[]).is_empty());
+ }
+
+ #[test]
+ fn data_write_to_any_target_is_relevant() {
+ let t = targets(&["/a/x.glsl", "/b/z.glsl"]);
+ let data = EventKind::Modify(ModifyKind::Data(DataChange::Any));
+ assert!(is_relevant(&ev(data, &["/a/x.glsl"]), &t));
+ assert!(is_relevant(&ev(data, &["/b/z.glsl"]), &t));
+ }
+
+ #[test]
+ fn sibling_files_are_ignored() {
+ let t = targets(&["/a/x.glsl"]);
+ let data = EventKind::Modify(ModifyKind::Data(DataChange::Any));
+ assert!(!is_relevant(&ev(data, &["/a/x.glsl.swp"]), &t));
+ assert!(!is_relevant(&ev(data, &["/a/other.glsl"]), &t));
+ }
+
+ #[test]
+ fn rename_onto_target_is_relevant() {
+ // Editor save: write a temp file, rename it over the target.
+ let t = targets(&["/a/x.glsl"]);
+ let rename = EventKind::Modify(ModifyKind::Name(RenameMode::Both));
+ assert!(is_relevant(
+ &ev(rename, &["/a/.x.glsl.tmp", "/a/x.glsl"]),
+ &t
+ ));
+ assert!(is_relevant(
+ &ev(EventKind::Create(CreateKind::File), &["/a/x.glsl"]),
+ &t
+ ));
+ }
+
+ #[test]
+ fn access_events_are_ignored() {
+ let t = targets(&["/a/x.glsl"]);
+ assert!(!is_relevant(
+ &ev(EventKind::Access(AccessKind::Read), &["/a/x.glsl"]),
+ &t
+ ));
+ }
+
+ #[test]
+ fn bare_relative_target_matches_dot_prefixed_event() {
+ let t = targets(&["x.glsl"]);
+ assert!(is_relevant(&ev(EventKind::Any, &["./x.glsl"]), &t));
+ assert!(!is_relevant(&ev(EventKind::Any, &["./y.glsl"]), &t));
+ }
+}
From 46dd3eebaf2d46b2c2185159f4fc12e6c1e5f095 Mon Sep 17 00:00:00 2001
From: EDToaster
Date: Thu, 22 Oct 2026 11:39:57 -0700
Subject: [PATCH 04/16] shader_watcher: absolute targets, canonical parent
dirs; keep io::ErrorKind
notify makes relative watch paths absolute, so bare relative targets
never matched. Two spellings of one directory share an inotify watch
and notify reports events under the last-added one; watch and match
against the canonical parent dir instead (file name kept as is).
ShaderFileError now carries the io::ErrorKind so a watcher-triggered
reload can skip NotFound during rename-based saves.
---
crates/toastty/src/shader_files.rs | 7 ++
crates/toastty/src/shader_watcher.rs | 162 ++++++++++++++++++---------
2 files changed, 119 insertions(+), 50 deletions(-)
diff --git a/crates/toastty/src/shader_files.rs b/crates/toastty/src/shader_files.rs
index 9f95a5b7..c0a41045 100644
--- a/crates/toastty/src/shader_files.rs
+++ b/crates/toastty/src/shader_files.rs
@@ -27,6 +27,10 @@ pub struct ShaderFile {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShaderFileError {
pub path: PathBuf,
+ /// The I/O error kind. On a watcher-triggered reload the caller can
+ /// skip `NotFound`: editors that save by rename briefly remove the
+ /// file.
+ pub kind: std::io::ErrorKind,
pub message: String,
}
@@ -96,6 +100,7 @@ pub fn load(paths: &[PathBuf]) -> Result, Vec>
}),
Err(e) => errors.push(ShaderFileError {
path: path.clone(),
+ kind: e.kind(),
message: e.to_string(),
}),
}
@@ -214,12 +219,14 @@ mod tests {
let errs = load(&[missing.clone(), ok, not_utf8.clone()]).unwrap_err();
assert_eq!(errs.len(), 2);
assert_eq!(errs[0].path, missing);
+ assert_eq!(errs[0].kind, std::io::ErrorKind::NotFound);
let shown = errs[0].to_string();
assert!(
shown.starts_with(&format!("{}: No such file", missing.display())),
"{shown}"
);
assert_eq!(errs[1].path, not_utf8);
+ assert_eq!(errs[1].kind, std::io::ErrorKind::InvalidData);
// A directory is not a shader file either.
let errs = load(&[dir.path().to_path_buf()]).unwrap_err();
assert_eq!(errs.len(), 1);
diff --git a/crates/toastty/src/shader_watcher.rs b/crates/toastty/src/shader_watcher.rs
index a0dc3635..31c03100 100644
--- a/crates/toastty/src/shader_watcher.rs
+++ b/crates/toastty/src/shader_watcher.rs
@@ -9,11 +9,16 @@
//!
//! When the path list changes (config reload), the binary drops the old
//! watcher and spawns a new one; that's one inotify instance, so cheap.
-//! Paths are matched literally: a symlinked shader is reloaded when the
-//! link changes, not when its target is edited in another directory.
+//!
+//! Each parent directory is canonicalized before watching: inotify gives
+//! two spellings of one directory (a stowed `~/.config/toastty` symlink,
+//! `a/../b`) the same watch, and notify then reports every event under
+//! whichever spelling was added last. The file name itself is kept as
+//! is, so a symlinked shader is reloaded when the link changes, not when
+//! its target is edited in another directory.
use std::collections::{BTreeSet, HashSet};
-use std::path::{Path, PathBuf};
+use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -48,22 +53,12 @@ impl ShaderWatcher {
paths: &[PathBuf],
proxy: EventLoopProxy,
) -> Option {
- let dirs: Vec = parent_dirs(paths)
- .into_iter()
- .filter(|d| {
- let ok = d.is_dir();
- if !ok {
- debug!(dir = ?d, "shader dir missing — not watching");
- }
- ok
- })
- .collect();
+ let WatchSet { dirs, targets } = watch_set(paths);
if dirs.is_empty() {
return None;
}
let pending = Arc::new(AtomicBool::new(false));
let pending_for_cb = Arc::clone(&pending);
- let targets: HashSet = paths.iter().cloned().collect();
let mut watcher =
match notify::recommended_watcher(move |res: notify::Result| match res {
@@ -108,33 +103,44 @@ impl ShaderWatcher {
}
}
-/// Distinct parent directories of `paths`, sorted. A bare relative file
-/// name has an empty parent, which means the CWD.
-fn parent_dirs(paths: &[PathBuf]) -> BTreeSet {
- paths
- .iter()
- .filter_map(|p| p.parent())
- .map(|d| {
- if d.as_os_str().is_empty() {
- PathBuf::from(".")
- } else {
- d.to_path_buf()
+/// What to watch and what to match, spelled the way notify reports
+/// events: absolute, with the parent directory canonicalized.
+#[derive(Debug, Default, PartialEq, Eq)]
+struct WatchSet {
+ /// Distinct canonical parent directories.
+ dirs: BTreeSet,
+ /// `canonical parent / file name` for each path.
+ targets: HashSet,
+}
+
+/// Build the [`WatchSet`] for `paths`. Relative paths are made absolute
+/// against the CWD (notify does the same for the watch). Paths whose
+/// parent directory doesn't exist are skipped.
+fn watch_set(paths: &[PathBuf]) -> WatchSet {
+ let mut set = WatchSet::default();
+ for path in paths {
+ let Ok(abs) = std::path::absolute(path) else {
+ continue;
+ };
+ let (Some(parent), Some(name)) = (abs.parent(), abs.file_name()) else {
+ continue;
+ };
+ match std::fs::canonicalize(parent) {
+ Ok(dir) => {
+ set.targets.insert(dir.join(name));
+ set.dirs.insert(dir);
}
- })
- .collect()
+ Err(e) => debug!(dir = ?parent, "shader dir not watchable: {e}"),
+ }
+ }
+ set
}
/// True when `ev` is a payload event (see
/// [`crate::config_watcher`]) touching one of `targets`. Siblings in a
/// watched directory are ignored.
fn is_relevant(ev: ¬ify::Event, targets: &HashSet) -> bool {
- is_payload_event(ev.kind) && ev.paths.iter().any(|p| matches(p, targets))
-}
-
-/// Exact match, plus `./name` for targets that are bare relative names
-/// (their directory is watched as `.`, so events arrive as `./name`).
-fn matches(p: &Path, targets: &HashSet) -> bool {
- targets.contains(p) || p.strip_prefix(".").is_ok_and(|rest| targets.contains(rest))
+ is_payload_event(ev.kind) && ev.paths.iter().any(|p| targets.contains(p))
}
#[cfg(test)]
@@ -155,18 +161,81 @@ mod tests {
paths.iter().map(PathBuf::from).collect()
}
+ #[cfg(unix)]
#[test]
- fn parent_dirs_are_distinct() {
- let paths: Vec = ["/a/x.glsl", "/a/y.wgsl", "/b/z.glsl", "rel.glsl"]
- .iter()
- .map(PathBuf::from)
- .collect();
- let dirs: Vec = parent_dirs(&paths).into_iter().collect();
+ fn watch_set_canonicalizes_parent_and_dedups() {
+ let tmp = tempfile::tempdir().unwrap();
+ let real = std::fs::canonicalize(tmp.path()).unwrap().join("real");
+ std::fs::create_dir(&real).unwrap();
+ let link = real.parent().unwrap().join("link");
+ std::os::unix::fs::symlink(&real, &link).unwrap();
+ let paths = vec![
+ real.join("a.glsl"),
+ link.join("b.glsl"),
+ real.join("sub/../c.glsl"),
+ real.join("missing/d.glsl"),
+ ];
+ std::fs::create_dir(real.join("sub")).unwrap();
+ let set = watch_set(&paths);
+ assert_eq!(set.dirs.into_iter().collect::>(), vec![real.clone()]);
assert_eq!(
- dirs,
- vec![PathBuf::from("/a"), PathBuf::from("/b"), PathBuf::from(".")]
+ set.targets,
+ ["a.glsl", "b.glsl", "c.glsl"]
+ .iter()
+ .map(|n| real.join(n))
+ .collect()
);
- assert!(parent_dirs(&[]).is_empty());
+ assert_eq!(watch_set(&[]), WatchSet::default());
+ }
+
+ #[test]
+ fn watch_set_makes_relative_paths_absolute() {
+ let cwd = std::fs::canonicalize(std::env::current_dir().unwrap()).unwrap();
+ let set = watch_set(&[PathBuf::from("x.glsl")]);
+ assert_eq!(set.dirs.into_iter().collect::>(), vec![cwd.clone()]);
+ assert!(set.targets.contains(&cwd.join("x.glsl")));
+ }
+
+ /// Real notify watcher: writes through a symlinked spelling of the
+ /// directory must match the canonical targets.
+ #[cfg(unix)]
+ #[test]
+ fn real_events_match_targets() {
+ use std::sync::mpsc;
+ use std::time::{Duration, Instant};
+
+ let tmp = tempfile::tempdir().unwrap();
+ let real = std::fs::canonicalize(tmp.path()).unwrap().join("real");
+ std::fs::create_dir(&real).unwrap();
+ let link = real.parent().unwrap().join("link");
+ std::os::unix::fs::symlink(&real, &link).unwrap();
+ let target = link.join("x.glsl");
+ let set = watch_set(&[target.clone(), real.join("y.glsl")]);
+
+ let (tx, rx) = mpsc::channel();
+ let mut w = notify::recommended_watcher(move |res| {
+ let _ = tx.send(res);
+ })
+ .unwrap();
+ for dir in &set.dirs {
+ w.watch(dir, RecursiveMode::NonRecursive).unwrap();
+ }
+ std::fs::write(link.join("sibling.txt"), "x").unwrap();
+ std::fs::write(&target, "void mainImage() {}").unwrap();
+
+ let deadline = Instant::now() + Duration::from_secs(5);
+ let mut saw_sibling = false;
+ loop {
+ let left = deadline.saturating_duration_since(Instant::now());
+ let ev = rx.recv_timeout(left).expect("no event for target").unwrap();
+ if ev.paths.iter().any(|p| p.ends_with("sibling.txt")) {
+ saw_sibling = true;
+ assert!(!is_relevant(&ev, &set.targets), "{ev:?}");
+ } else if is_relevant(&ev, &set.targets) {
+ break;
+ }
+ }
+ assert!(saw_sibling, "sibling write should arrive first");
}
#[test]
@@ -208,11 +277,4 @@ mod tests {
&t
));
}
-
- #[test]
- fn bare_relative_target_matches_dot_prefixed_event() {
- let t = targets(&["x.glsl"]);
- assert!(is_relevant(&ev(EventKind::Any, &["./x.glsl"]), &t));
- assert!(!is_relevant(&ev(EventKind::Any, &["./y.glsl"]), &t));
- }
}
From c720918fc599e85267109457dc3f096788c2350f Mon Sep 17 00:00:00 2001
From: EDToaster
Date: Thu, 22 Oct 2026 11:41:52 -0700
Subject: [PATCH 05/16] readme: post-processing shaders
---
README.md | 45 +++++++++++++++++++++++++++++++++++++++++----
1 file changed, 41 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 25ff77df..8ca45e78 100644
--- a/README.md
+++ b/README.md
@@ -95,6 +95,40 @@ cargo run --release -p molecule-viewer # run it inside toastty
+### ✨ Post-processing shaders
+
+Run your own fragment shaders over the whole terminal: CRT glass, bloom,
+colour grading, cursor trails. Shaders are written in
+**ShaderToy-style GLSL** (`mainImage`) or **WGSL**, and use the same
+uniforms as Ghostty's custom shaders, so Ghostty shaders work unchanged.
+Several shaders can be chained, and saving a shader file reloads it. A
+broken edit shows its error in the window and the last working shader
+keeps running.
+
+toastty adds a few uniforms of its own. `iBells` and `iTimeBell` fire on
+every BEL, with the cell it rang at, and `iCellSize` / `iGridRect`
+describe the cell grid. The bundled
+[`crt-splitflap.glsl`](examples/shaders/crt-splitflap.glsl) uses them for
+a CRT look where each bell sends a split-flap wave across the screen:
+
+
+
+
+
+```toml
+[shader]
+paths = ["~/.config/toastty/shaders/crt-splitflap.glsl"] # run in order
+animation = "auto" # "auto" | "always" | "never"
+```
+
+Shaders that don't use time cost nothing when the screen is idle.
+toastty reads which uniforms a shader uses and only draws frames when
+they can change. A shader whose animation is triggered by events can
+declare `const float TOASTTY_EVENT_DURATION = 1.5;` to animate for that
+long after a bell, cursor move or focus change, then go back to idle.
+The full contract is in
+[docs/milestones/m13-user-shaders.md](docs/milestones/m13-user-shaders.md).
+
### 🔤 Text that looks right
- **Ligatures** and full font fallback with cosmic-text shaping.
@@ -145,7 +179,8 @@ The full matrix is in [docs/protocols.md](docs/protocols.md), and
- **Transparent backgrounds**: give the theme background an alpha, e.g.
`bg = "#1e1e2ecc"`, and only the background goes see-through. Text
stays opaque.
-- **Config hot reload**: save `config.toml` and the window updates.
+- **Config hot reload**: save `config.toml` (or a shader file) and the
+ window updates.
- **Close confirmation** only when something other than the shell is running.
- **Damage-tracked redraws**: frames are only drawn when something changed.
@@ -222,7 +257,8 @@ left = 12
Other tables cover the cursor, shell, scrollback and smooth scrolling,
window size and background extension, the scroll button, selection word
-characters, and the `[security]` switches for OSC 52 and Markdown.
+characters, post-processing `[shader]`s, and the `[security]` switches for
+OSC 52 and Markdown.
## Keyboard shortcuts
@@ -317,10 +353,11 @@ Cargo workspace under `crates/`:
| `toastty-protocols` | One module per protocol handler |
| `toastty-graphics` | Kitty graphics, Sixel, RGP decoders |
| `toastty-markdown` | Markdown → laid-out block (maths, Mermaid, syntax highlighting) |
-| `toastty-render` | wgpu renderer; cosmic-text shaping; image and 3D passes |
+| `toastty-render` | wgpu renderer; cosmic-text shaping; image, 3D and post-process shader passes |
| `toastty-config` | TOML config loading |
-Demos live under `demos/` (`molecule-viewer`).
+Demos live under `demos/` (`molecule-viewer`), example shaders under
+`examples/shaders/`.
### Development
From 8e091b63aeb74b594514d7828fe704e658049333 Mon Sep 17 00:00:00 2001
From: EDToaster
Date: Thu, 22 Oct 2026 11:55:56 -0700
Subject: [PATCH 06/16] render: post-process shader compiler, analysis and GPU
chain (M13)
New crate::post module, not yet wired into the renderer:
- compile: GLSL (Shadertoy/Ghostty dialect) and WGSL preludes, naga
parse + validate, errors mapped back to lines in the user's file,
GLSL -> WGSL for wgpu.
- analysis: uniform-block members read by the entry point and its
callees (on a compacted module), TOASTTY_EVENT_DURATION.
- uniforms: one std140/WGSL-compatible block layout, checked against
naga's offsets for both preludes.
- state: shader clock, iMouse, focus, bells, cursor history, iDate,
and the auto/always/never animation schedule.
- PostChain: one fullscreen pass per shader, ping-pong textures,
non-sRGB views (or copy fallback) so pass-through is bit-exact.
Unit tests for the prelude, error mapping and analysis; GPU tests for
pass-through, invert, multi-pass chains and uniform upload.
---
crates/toastty-render/shaders/post.wgsl | 13 +
crates/toastty-render/src/lib.rs | 1 +
crates/toastty-render/src/post/analysis.rs | 137 ++++
crates/toastty-render/src/post/compile.rs | 699 ++++++++++++++++++
crates/toastty-render/src/post/gpu_tests.rs | 333 +++++++++
crates/toastty-render/src/post/mod.rs | 417 +++++++++++
crates/toastty-render/src/post/state.rs | 760 ++++++++++++++++++++
crates/toastty-render/src/post/uniforms.rs | 201 ++++++
8 files changed, 2561 insertions(+)
create mode 100644 crates/toastty-render/shaders/post.wgsl
create mode 100644 crates/toastty-render/src/post/analysis.rs
create mode 100644 crates/toastty-render/src/post/compile.rs
create mode 100644 crates/toastty-render/src/post/gpu_tests.rs
create mode 100644 crates/toastty-render/src/post/mod.rs
create mode 100644 crates/toastty-render/src/post/state.rs
create mode 100644 crates/toastty-render/src/post/uniforms.rs
diff --git a/crates/toastty-render/shaders/post.wgsl b/crates/toastty-render/shaders/post.wgsl
new file mode 100644
index 00000000..094dfb18
--- /dev/null
+++ b/crates/toastty-render/shaders/post.wgsl
@@ -0,0 +1,13 @@
+// Vertex stage for the user post-process passes (`crate::post`).
+//
+// Build-time validated by `build.rs`. One triangle that covers the
+// target; the user's fragment shader (a separate module, compiled at
+// runtime) reads `@builtin(position)` only, so nothing else is passed
+// down.
+
+@vertex
+fn vs_main(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4 {
+ let x = f32((vid << 1u) & 2u);
+ let y = f32(vid & 2u);
+ return vec4(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0);
+}
diff --git a/crates/toastty-render/src/lib.rs b/crates/toastty-render/src/lib.rs
index 0261a31d..f9c018e5 100644
--- a/crates/toastty-render/src/lib.rs
+++ b/crates/toastty-render/src/lib.rs
@@ -27,6 +27,7 @@ pub mod chip;
pub mod color;
pub mod dialog;
pub mod image;
+pub mod post;
pub mod rgp;
pub mod surface_format;
#[cfg(test)]
diff --git a/crates/toastty-render/src/post/analysis.rs b/crates/toastty-render/src/post/analysis.rs
new file mode 100644
index 00000000..bc902924
--- /dev/null
+++ b/crates/toastty-render/src/post/analysis.rs
@@ -0,0 +1,137 @@
+//! What a compiled post shader reads, found by walking its naga module.
+//!
+//! The animation policy depends on it: a shader that never reads a time
+//! uniform can't change between events, so it needs no time-driven
+//! frames, and a shader that ignores `iMouse` needs no frame when the
+//! pointer moves.
+
+use naga::{Block, Expression, Function, Handle, Literal, Module, Statement};
+
+/// Name of the optional event-duration constant (see the module docs of
+/// [`crate::post`]).
+pub(crate) const EVENT_DURATION_NAME: &str = "TOASTTY_EVENT_DURATION";
+
+/// Bit mask (over [`super::uniforms::MEMBERS`] indexes) of the uniform
+/// block members read by `entry` and every function it calls.
+///
+/// A member counts as read when an `AccessIndex` on the block global
+/// selects it. Any other use of the block global (copying the whole
+/// struct, passing it by pointer) counts as reading every member.
+/// Expressions in unreachable branches still count: the result errs on
+/// the side of rendering.
+pub(crate) fn uniforms_read(
+ module: &Module,
+ block: Handle,
+ entry: &str,
+) -> u64 {
+ let Some(ep) = module.entry_points.iter().find(|ep| ep.name == entry) else {
+ return u64::MAX;
+ };
+ let mut used = 0u64;
+ let mut visited = vec![false; module.functions.len()];
+ let mut stack: Vec<&Function> = vec![&ep.function];
+ let mut calls = Vec::new();
+ while let Some(func) = stack.pop() {
+ used |= members_read_in(func, block);
+ calls.clear();
+ collect_calls(&func.body, &mut calls);
+ for &(callee, whole) in &calls {
+ if whole_block_arg(func, block, whole) {
+ return u64::MAX;
+ }
+ let seen = &mut visited[callee.index()];
+ if !*seen {
+ *seen = true;
+ stack.push(&module.functions[callee]);
+ }
+ }
+ }
+ used
+}
+
+/// [`uniforms_read`] for one function body, ignoring its callees.
+fn members_read_in(func: &Function, block: Handle) -> u64 {
+ let mut used = 0u64;
+ for (_, expr) in func.expressions.iter() {
+ match *expr {
+ Expression::AccessIndex { base, index } if is_block(func, block, base) => {
+ used |= 1u64.checked_shl(index).unwrap_or(0);
+ }
+ // The whole struct copied out.
+ Expression::Load { pointer } if is_block(func, block, pointer) => return u64::MAX,
+ _ => {}
+ }
+ }
+ used
+}
+
+fn is_block(func: &Function, block: Handle, h: Handle) -> bool {
+ matches!(func.expressions[h], Expression::GlobalVariable(g) if g == block)
+}
+
+/// True when one of a call's arguments is the block global itself (a
+/// pointer to the whole struct).
+fn whole_block_arg(
+ func: &Function,
+ block: Handle,
+ args: &[Handle],
+) -> bool {
+ args.iter().any(|&a| is_block(func, block, a))
+}
+
+/// Every `Call` in `block` and its nested blocks: the callee and the
+/// call's arguments.
+fn collect_calls<'a>(
+ block: &'a Block,
+ out: &mut Vec<(Handle, &'a [Handle])>,
+) {
+ for stmt in block {
+ match stmt {
+ Statement::Call {
+ function,
+ arguments,
+ ..
+ } => out.push((*function, arguments)),
+ Statement::Block(b) => collect_calls(b, out),
+ Statement::If { accept, reject, .. } => {
+ collect_calls(accept, out);
+ collect_calls(reject, out);
+ }
+ Statement::Switch { cases, .. } => {
+ for case in cases {
+ collect_calls(&case.body, out);
+ }
+ }
+ Statement::Loop {
+ body, continuing, ..
+ } => {
+ collect_calls(body, out);
+ collect_calls(continuing, out);
+ }
+ _ => {}
+ }
+ }
+}
+
+/// The value of a module-scope `TOASTTY_EVENT_DURATION` constant, if the
+/// shader declares one with a positive, finite number.
+pub(crate) fn event_duration(module: &Module) -> Option {
+ let (_, constant) = module
+ .constants
+ .iter()
+ .find(|(_, c)| c.name.as_deref() == Some(EVENT_DURATION_NAME))?;
+ #[allow(clippy::cast_precision_loss)]
+ let value = match module.global_expressions[constant.init] {
+ Expression::Literal(lit) => match lit {
+ Literal::F32(v) => v,
+ Literal::F64(v) | Literal::AbstractFloat(v) => v as f32,
+ Literal::I32(v) => v as f32,
+ Literal::U32(v) => v as f32,
+ Literal::I64(v) | Literal::AbstractInt(v) => v as f32,
+ Literal::U64(v) => v as f32,
+ _ => return None,
+ },
+ _ => return None,
+ };
+ (value.is_finite() && value > 0.0).then_some(value)
+}
diff --git a/crates/toastty-render/src/post/compile.rs b/crates/toastty-render/src/post/compile.rs
new file mode 100644
index 00000000..9b903827
--- /dev/null
+++ b/crates/toastty-render/src/post/compile.rs
@@ -0,0 +1,699 @@
+//! User shader source → validated WGSL plus what it reads.
+//!
+//! GPU-free, so the prelude, the error mapping and the analysis are unit
+//! tested without a device. The GPU side ([`super::PostChain`]) takes the
+//! WGSL from here.
+//!
+//! Both languages get a prelude before the user's file, so a line in the
+//! wrapped source maps back to the user's file by subtracting the
+//! prelude's line count. GLSL also gets an epilogue after it (its
+//! `main()`, which must follow `mainImage`: naga wants callees declared
+//! before their callers, so a forward prototype in the prelude doesn't
+//! work once `mainImage` calls helpers). Errors in the prelude or the
+//! epilogue have no user line.
+
+use std::fmt::{self, Write as _};
+use std::path::{Path, PathBuf};
+
+use naga::valid::{Capabilities, ValidationFlags, Validator};
+use naga::{Module, ShaderStage, SourceLocation, Span};
+
+use super::analysis;
+use super::uniforms::MEMBERS;
+
+/// A post-process shader to compile: its path (picks the language by
+/// extension and names it in errors) and its source text.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PostShaderSource {
+ /// Where the shader came from. `.glsl` / `.frag` are GLSL, `.wgsl`
+ /// is WGSL.
+ pub path: PathBuf,
+ /// The file's contents.
+ pub source: String,
+}
+
+/// A shader that failed to compile. Locations are in the user's file
+/// (1-based), not in the prelude-wrapped source.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ShaderError {
+ /// The shader's path, as given in [`PostShaderSource::path`].
+ pub path: PathBuf,
+ /// Line in the user's file, when the error points into it.
+ pub line: Option,
+ /// Column (1-based, in bytes) on `line`.
+ pub column: Option,
+ /// What went wrong.
+ pub message: String,
+}
+
+impl fmt::Display for ShaderError {
+ /// `path:line:col: message`, dropping the parts that are unknown.
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self.path.display())?;
+ if let Some(line) = self.line {
+ write!(f, ":{line}")?;
+ if let Some(col) = self.column {
+ write!(f, ":{col}")?;
+ }
+ }
+ write!(f, ": {}", self.message)
+ }
+}
+
+impl std::error::Error for ShaderError {}
+
+/// Shader language, from the file extension.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Language {
+ Glsl,
+ Wgsl,
+}
+
+impl Language {
+ pub(crate) fn from_path(path: &Path) -> Option {
+ let ext = path.extension()?.to_str()?.to_ascii_lowercase();
+ match ext.as_str() {
+ "glsl" | "frag" => Some(Self::Glsl),
+ "wgsl" => Some(Self::Wgsl),
+ _ => None,
+ }
+ }
+}
+
+/// What a compiled shader reads, for the animation policy.
+#[derive(Debug, Clone, Copy, Default, PartialEq)]
+pub(crate) struct ShaderInputs {
+ /// Bit mask over [`MEMBERS`] of the uniforms the shader reads.
+ pub used: u64,
+ /// `TOASTTY_EVENT_DURATION`, in seconds.
+ pub event_duration: Option,
+}
+
+impl ShaderInputs {
+ /// True when the shader reads any of the members in `mask`.
+ pub(crate) fn reads(&self, mask: u64) -> bool {
+ self.used & mask != 0
+ }
+
+ /// The union of two shaders' needs (a chain needs what any of its
+ /// shaders needs; the longest duration wins).
+ pub(crate) fn union(self, other: Self) -> Self {
+ Self {
+ used: self.used | other.used,
+ event_duration: match (self.event_duration, other.event_duration) {
+ (Some(a), Some(b)) => Some(a.max(b)),
+ (a, b) => a.or(b),
+ },
+ }
+ }
+}
+
+/// A shader ready for `wgpu`: WGSL source with a fragment entry point.
+#[derive(Debug, Clone)]
+pub(crate) struct CompiledShader {
+ pub path: PathBuf,
+ pub wgsl: String,
+ pub entry_point: String,
+ pub inputs: ShaderInputs,
+}
+
+/// The user's source between the prelude and the epilogue.
+#[derive(Debug)]
+pub(crate) struct Wrapped {
+ pub text: String,
+ /// Lines before the user's first line.
+ pub prelude_lines: u32,
+ /// Lines in the user's file.
+ pub user_lines: u32,
+}
+
+impl Wrapped {
+ fn new(prelude: &str, user: &str, epilogue: &str) -> Self {
+ debug_assert!(prelude.ends_with('\n'));
+ let prelude_lines = count_lines(prelude);
+ let mut text = String::with_capacity(prelude.len() + user.len() + epilogue.len() + 1);
+ text.push_str(prelude);
+ text.push_str(user);
+ let mut user_lines = count_lines(user);
+ if !user.ends_with('\n') {
+ text.push('\n');
+ user_lines += 1;
+ }
+ text.push_str(epilogue);
+ Self {
+ text,
+ prelude_lines,
+ user_lines,
+ }
+ }
+
+ /// Map a location in the wrapped source to `(line, column)` in the
+ /// user's file. `None` when it falls in the prelude.
+ pub(crate) fn user_location(&self, loc: SourceLocation) -> Option<(u32, u32)> {
+ let line = loc.line_number.checked_sub(self.prelude_lines)?;
+ (1..=self.user_lines)
+ .contains(&line)
+ .then_some((line, loc.line_position))
+ }
+
+ fn span_location(&self, span: Span) -> Option<(u32, u32)> {
+ if !span.is_defined() || span.to_range()?.end > self.text.len() {
+ return None;
+ }
+ self.user_location(span.location(&self.text))
+ }
+}
+
+fn count_lines(s: &str) -> u32 {
+ u32::try_from(s.bytes().filter(|&b| b == b'\n').count()).unwrap_or(u32::MAX)
+}
+
+/// The GLSL prelude: Shadertoy / Ghostty globals as an unnamed uniform
+/// block (so `iTime` names the member directly and nothing is copied per
+/// fragment) and `iChannel0` as a texture + sampler pair.
+pub(crate) fn glsl_prelude() -> String {
+ let mut s = String::from("#version 450 core\n");
+ s.push_str("layout(set = 0, binding = 0, std140) uniform ToasttyUniforms {\n");
+ for m in MEMBERS {
+ let _ = writeln!(s, " {} {}{};", m.glsl, m.name, m.glsl_array);
+ }
+ s.push_str("};\n");
+ s.push_str(
+ "layout(set = 0, binding = 1) uniform texture2D iChannel0_tex;\n\
+ layout(set = 0, binding = 2) uniform sampler iChannel0_smp;\n\
+ #define iChannel0 sampler2D(iChannel0_tex, iChannel0_smp)\n\
+ #define texture2D texture\n\
+ #define CURSORSTYLE_BLOCK 0\n\
+ #define CURSORSTYLE_BLOCK_HOLLOW 1\n\
+ #define CURSORSTYLE_BAR 2\n\
+ #define CURSORSTYLE_UNDERLINE 3\n\
+ #define CURSORSTYLE_LOCK 4\n\
+ layout(location = 0) out vec4 toastty_frag_color;\n",
+ );
+ s
+}
+
+/// The GLSL epilogue: `main()` hands `gl_FragCoord.xy` (y down, pixel
+/// centres at .5) to `mainImage` unchanged.
+const GLSL_EPILOGUE: &str = "void main() {\n\
+ \x20 vec4 color = vec4(0.0);\n\
+ \x20 mainImage(color, gl_FragCoord.xy);\n\
+ \x20 toastty_frag_color = color;\n\
+ }\n";
+
+/// The WGSL prelude: the same block as `var toastty`, the
+/// `iChannel0` texture and sampler, the cursor-style constants and the
+/// fragment entry point.
+pub(crate) fn wgsl_prelude() -> String {
+ let mut s = String::from("struct ToasttyUniforms {\n");
+ for m in MEMBERS {
+ let _ = writeln!(s, " {}: {},", m.name, m.wgsl);
+ }
+ s.push_str("};\n");
+ s.push_str(
+ "@group(0) @binding(0) var toastty: ToasttyUniforms;\n\
+ @group(0) @binding(1) var iChannel0: texture_2d;\n\
+ @group(0) @binding(2) var iChannel0Sampler: sampler;\n\
+ const CURSORSTYLE_BLOCK: i32 = 0;\n\
+ const CURSORSTYLE_BLOCK_HOLLOW: i32 = 1;\n\
+ const CURSORSTYLE_BAR: i32 = 2;\n\
+ const CURSORSTYLE_UNDERLINE: i32 = 3;\n\
+ const CURSORSTYLE_LOCK: i32 = 4;\n\
+ @fragment\n\
+ fn toastty_fs_main(@builtin(position) frag_coord: vec4f) -> @location(0) vec4f {\n\
+ \x20 return mainImage(frag_coord.xy);\n\
+ }\n",
+ );
+ s
+}
+
+/// WGSL entry point name the prelude declares.
+const WGSL_ENTRY: &str = "toastty_fs_main";
+
+/// Blank every `#version` line (the prelude has its own), keeping the
+/// line itself so line numbers stay put.
+pub(crate) fn blank_version_lines(src: &str) -> String {
+ let mut out = String::with_capacity(src.len());
+ for line in src.split_inclusive('\n') {
+ if line.trim_start().starts_with("#version") {
+ // Keep the line break (`\n` or `\r\n`).
+ let body = line.trim_end_matches(['\r', '\n']);
+ out.push_str(&line[body.len()..]);
+ } else {
+ out.push_str(line);
+ }
+ }
+ out
+}
+
+/// Wrap a GLSL user file.
+pub(crate) fn wrap_glsl(user: &str) -> Wrapped {
+ Wrapped::new(&glsl_prelude(), &blank_version_lines(user), GLSL_EPILOGUE)
+}
+
+/// Wrap a WGSL user file.
+pub(crate) fn wrap_wgsl(user: &str) -> Wrapped {
+ Wrapped::new(&wgsl_prelude(), user, "")
+}
+
+/// Compile one shader: parse, validate, analyse, and (for GLSL) emit
+/// WGSL. Every error found is returned.
+pub(crate) fn compile(src: &PostShaderSource) -> Result> {
+ let err = |line: Option<(u32, u32)>, message: String| ShaderError {
+ path: src.path.clone(),
+ line: line.map(|l| l.0),
+ column: line.map(|l| l.1),
+ message,
+ };
+ let Some(lang) = Language::from_path(&src.path) else {
+ return Err(vec![err(
+ None,
+ "unknown shader type: use a .glsl, .frag or .wgsl file".to_owned(),
+ )]);
+ };
+ let wrapped = match lang {
+ Language::Glsl => wrap_glsl(&src.source),
+ Language::Wgsl => wrap_wgsl(&src.source),
+ };
+ let module = match lang {
+ Language::Glsl => {
+ let mut frontend = naga::front::glsl::Frontend::default();
+ frontend
+ .parse(
+ &naga::front::glsl::Options::from(ShaderStage::Fragment),
+ &wrapped.text,
+ )
+ .map_err(|errors| {
+ errors
+ .errors
+ .iter()
+ .map(|e| {
+ let loc = wrapped.span_location(e.meta);
+ err(loc, explain(&e.kind.to_string(), loc.is_some()))
+ })
+ .collect::>()
+ })?
+ }
+ Language::Wgsl => naga::front::wgsl::parse_str(&wrapped.text).map_err(|e| {
+ let loc = e.labels().find_map(|(span, _)| wrapped.span_location(span));
+ let mut message = e.message().to_owned();
+ if let Some((_, label)) = e.labels().next()
+ && !label.is_empty()
+ && label != message
+ {
+ message = format!("{message} ({label})");
+ }
+ vec![err(loc, explain(&message, loc.is_some()))]
+ })?,
+ };
+ let info = Validator::new(ValidationFlags::all(), Capabilities::default())
+ .validate(&module)
+ .map_err(|e| {
+ // The narrowest span in the user's file is the most specific
+ // (the others are the enclosing function and the like).
+ let loc = e
+ .spans()
+ .filter(|(span, _)| wrapped.span_location(*span).is_some())
+ .min_by_key(|(span, _)| span.to_range().map_or(usize::MAX, |r| r.len()))
+ .and_then(|(span, _)| wrapped.span_location(*span));
+ vec![err(loc, explain(&error_chain(e.as_inner()), loc.is_some()))]
+ })?;
+ let entry_point = match lang {
+ Language::Glsl => module
+ .entry_points
+ .iter()
+ .find(|ep| ep.stage == ShaderStage::Fragment)
+ .map_or_else(|| "main".to_owned(), |ep| ep.name.clone()),
+ Language::Wgsl => WGSL_ENTRY.to_owned(),
+ };
+ let inputs = inputs_of(&module, &entry_point);
+ let wgsl = match lang {
+ Language::Glsl => {
+ naga::back::wgsl::write_string(&module, &info, naga::back::wgsl::WriterFlags::empty())
+ .map_err(|e| vec![err(None, format!("internal: GLSL → WGSL failed: {e}"))])?
+ }
+ Language::Wgsl => wrapped.text,
+ };
+ Ok(CompiledShader {
+ path: src.path.clone(),
+ wgsl,
+ entry_point,
+ inputs,
+ })
+}
+
+/// [`ShaderInputs`] of a validated module.
+///
+/// The analysis runs on a compacted copy: naga's GLSL frontend leaves a
+/// dead `AccessIndex` for every block member in every function, and
+/// compaction drops expressions no statement uses (and functions nothing
+/// calls).
+pub(crate) fn inputs_of(module: &Module, entry: &str) -> ShaderInputs {
+ let mut module = module.clone();
+ naga::compact::compact(&mut module, naga::compact::KeepUnused::No);
+ let module = &module;
+ let block = module.global_variables.iter().find_map(|(h, g)| {
+ g.binding
+ .as_ref()
+ .is_some_and(|b| b.group == 0 && b.binding == 0)
+ .then_some(h)
+ });
+ ShaderInputs {
+ used: block.map_or(0, |b| analysis::uniforms_read(module, b, entry)),
+ event_duration: analysis::event_duration(module),
+ }
+}
+
+/// An error and its `source()` chain, joined with `: ` (naga's
+/// validation errors nest: function → expression → cause).
+fn error_chain(e: &dyn std::error::Error) -> String {
+ let mut s = e.to_string();
+ let mut cur = e.source();
+ while let Some(inner) = cur {
+ let part = inner.to_string();
+ if !s.contains(&part) {
+ s.push_str(": ");
+ s.push_str(&part);
+ }
+ cur = inner.source();
+ }
+ s
+}
+
+/// Add a hint to messages that commonly need one.
+fn explain(message: &str, in_user_file: bool) -> String {
+ let mut out = message.to_owned();
+ if ["iChannel1", "iChannel2", "iChannel3"]
+ .iter()
+ .any(|c| message.contains(c))
+ {
+ out.push_str(" (only iChannel0, the terminal image, is available)");
+ } else if !in_user_file && message.contains("mainImage") {
+ out.push_str(" (the shader must define mainImage)");
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::post::uniforms::{bell_mask, focus_mask, mask_of, mouse_mask, time_mask};
+
+ fn src(name: &str, source: &str) -> PostShaderSource {
+ PostShaderSource {
+ path: PathBuf::from(name),
+ source: source.to_owned(),
+ }
+ }
+
+ fn compile_ok(name: &str, source: &str) -> CompiledShader {
+ match compile(&src(name, source)) {
+ Ok(c) => c,
+ Err(errs) => panic!("{name} failed: {errs:?}"),
+ }
+ }
+
+ fn compile_err(name: &str, source: &str) -> Vec {
+ compile(&src(name, source)).expect_err("should fail")
+ }
+
+ const GLSL_PASS: &str = "void mainImage(out vec4 fragColor, in vec2 fragCoord) {\n\
+ \x20 fragColor = texture(iChannel0, fragCoord / iResolution.xy);\n\
+ }\n";
+
+ const WGSL_PASS: &str = "fn mainImage(fragCoord: vec2f) -> vec4f {\n\
+ \x20 return textureSample(iChannel0, iChannel0Sampler, fragCoord / toastty.iResolution.xy);\n\
+ }\n";
+
+ /// naga's offsets for the block (std140 from the GLSL prelude, the
+ /// uniform address-space layout from the WGSL one) match
+ /// [`crate::post::uniforms::PostUniforms`].
+ #[test]
+ fn uniform_layout_matches_both_preludes() {
+ for (name, source) in [("l.glsl", GLSL_PASS), ("l.wgsl", WGSL_PASS)] {
+ let w = match Language::from_path(Path::new(name)).unwrap() {
+ Language::Glsl => wrap_glsl(source),
+ Language::Wgsl => wrap_wgsl(source),
+ };
+ let module = if name.ends_with("glsl") {
+ naga::front::glsl::Frontend::default()
+ .parse(
+ &naga::front::glsl::Options::from(ShaderStage::Fragment),
+ &w.text,
+ )
+ .unwrap()
+ } else {
+ naga::front::wgsl::parse_str(&w.text).unwrap()
+ };
+ let (_, block) = module
+ .global_variables
+ .iter()
+ .find(|(_, g)| g.binding.as_ref().is_some_and(|b| b.binding == 0))
+ .unwrap();
+ let naga::TypeInner::Struct { members, span } = &module.types[block.ty].inner else {
+ panic!("block is not a struct");
+ };
+ assert_eq!(members.len(), MEMBERS.len(), "{name}");
+ for (m, expected) in members.iter().zip(MEMBERS) {
+ assert_eq!(m.name.as_deref(), Some(expected.name), "{name}");
+ assert_eq!(
+ m.offset as usize, expected.offset,
+ "{name}: {}",
+ expected.name
+ );
+ }
+ assert_eq!(
+ *span as usize,
+ size_of::(),
+ "{name}"
+ );
+ }
+ }
+
+ #[test]
+ fn language_from_extension() {
+ assert_eq!(
+ Language::from_path(Path::new("a.glsl")),
+ Some(Language::Glsl)
+ );
+ assert_eq!(
+ Language::from_path(Path::new("a.FRAG")),
+ Some(Language::Glsl)
+ );
+ assert_eq!(
+ Language::from_path(Path::new("a.wgsl")),
+ Some(Language::Wgsl)
+ );
+ assert_eq!(Language::from_path(Path::new("a.txt")), None);
+ assert_eq!(Language::from_path(Path::new("glsl")), None);
+ }
+
+ #[test]
+ fn display_formats_known_parts() {
+ let mut e = ShaderError {
+ path: PathBuf::from("/s/crt.glsl"),
+ line: Some(3),
+ column: Some(7),
+ message: "bad".to_owned(),
+ };
+ assert_eq!(e.to_string(), "/s/crt.glsl:3:7: bad");
+ e.column = None;
+ assert_eq!(e.to_string(), "/s/crt.glsl:3: bad");
+ e.line = None;
+ assert_eq!(e.to_string(), "/s/crt.glsl: bad");
+ }
+
+ #[test]
+ fn prelude_keeps_user_line_numbers() {
+ let user = "// one\n// two\nvoid mainImage(out vec4 c, in vec2 p) { c = vec4(1.0); }\n";
+ let w = wrap_glsl(user);
+ let line3 = w.text.lines().nth(w.prelude_lines as usize + 2).unwrap();
+ assert!(line3.starts_with("void mainImage"), "{line3}");
+ let w = wrap_wgsl(user);
+ let line1 = w.text.lines().nth(w.prelude_lines as usize).unwrap();
+ assert_eq!(line1, "// one");
+ }
+
+ #[test]
+ fn version_line_is_blanked_in_place() {
+ let user = "#version 300 es\nfoo\n #version 450\r\nbar";
+ let out = blank_version_lines(user);
+ assert_eq!(out, "\nfoo\n\r\nbar");
+ // And a shader with its own #version still compiles.
+ compile_ok("v.glsl", &format!("#version 330\n{GLSL_PASS}"));
+ }
+
+ #[test]
+ fn glsl_error_points_at_the_user_line() {
+ let errs = compile_err(
+ "bad.glsl",
+ "// comment\n\nvoid mainImage(out vec4 c, in vec2 p) {\n c = vec4(nope);\n}\n",
+ );
+ let e = &errs[0];
+ assert_eq!(e.line, Some(4), "{e}");
+ assert!(e.column.is_some());
+ assert!(e.message.contains("nope"), "{e}");
+ assert!(e.to_string().starts_with("bad.glsl:4:"), "{e}");
+ }
+
+ #[test]
+ fn wgsl_error_points_at_the_user_line() {
+ let errs = compile_err(
+ "bad.wgsl",
+ "\nfn mainImage(p: vec2f) -> vec4f {\n return vec4f(nope);\n}\n",
+ );
+ let e = &errs[0];
+ assert_eq!(e.line, Some(3), "{e}");
+ assert!(e.message.contains("nope"), "{e}");
+ }
+
+ #[test]
+ fn wgsl_validation_error_points_at_the_user_line() {
+ // Parses, but fails validation (returns the wrong type).
+ let errs = compile_err(
+ "bad.wgsl",
+ "fn mainImage(p: vec2f) -> vec4f {\n let unused = p;\n return 1.0;\n}\n",
+ );
+ let e = &errs[0];
+ assert_eq!(e.line, Some(3), "{e}");
+ }
+
+ #[test]
+ fn ichannel1_gives_a_clean_error() {
+ let errs = compile_err(
+ "multi.glsl",
+ "void mainImage(out vec4 c, in vec2 p) {\n c = texture(iChannel1, p);\n}\n",
+ );
+ let e = &errs[0];
+ assert_eq!(e.line, Some(2), "{e}");
+ assert!(e.message.contains("iChannel1"), "{e}");
+ assert!(e.message.contains("only iChannel0"), "{e}");
+ }
+
+ #[test]
+ fn missing_main_image_is_explained() {
+ let errs = compile_err("empty.wgsl", "fn other() {}\n");
+ assert_eq!(errs[0].line, None);
+ assert!(
+ errs[0].message.contains("must define mainImage"),
+ "{}",
+ errs[0]
+ );
+ let errs = compile_err("empty.glsl", "void other() {}\n");
+ assert!(!errs.is_empty());
+ }
+
+ #[test]
+ fn unknown_extension_is_an_error() {
+ let errs = compile_err("x.hlsl", GLSL_PASS);
+ assert!(errs[0].message.contains("unknown shader type"));
+ }
+
+ #[test]
+ fn pass_through_compiles_in_both_languages() {
+ let g = compile_ok("p.glsl", GLSL_PASS);
+ assert_eq!(g.inputs.used, mask_of(&["iResolution"]));
+ assert!(g.wgsl.contains("@fragment"));
+ let w = compile_ok("p.wgsl", WGSL_PASS);
+ assert_eq!(w.inputs.used, mask_of(&["iResolution"]));
+ assert_eq!(w.entry_point, WGSL_ENTRY);
+ }
+
+ #[test]
+ fn static_shader_is_static() {
+ let shader = "vec3 tint(vec3 c) { return c * iBackgroundColor; }\n\
+ void mainImage(out vec4 c, in vec2 p) {\n\
+ \x20 vec4 s = texture(iChannel0, p / iResolution.xy);\n\
+ \x20 c = vec4(tint(s.rgb) + iPalette[int(p.x) % 16], 1.0);\n\
+ }\n";
+ let c = compile_ok("s.glsl", shader);
+ assert!(!c.inputs.reads(time_mask()));
+ assert!(!c.inputs.reads(mouse_mask() | focus_mask() | bell_mask()));
+ assert!(c.inputs.reads(mask_of(&["iPalette"])));
+ assert!(c.inputs.reads(mask_of(&["iBackgroundColor"])));
+ assert_eq!(c.inputs.event_duration, None);
+ }
+
+ #[test]
+ fn time_read_in_a_nested_helper_is_found() {
+ let shader = "float wave(float x) { return sin(x + iTime); }\n\
+ float outer(float x) { if (x > 0.0) { return wave(x); } return 0.0; }\n\
+ void mainImage(out vec4 c, in vec2 p) { c = vec4(outer(p.x)); }\n";
+ let c = compile_ok("t.glsl", shader);
+ assert!(c.inputs.reads(time_mask()));
+ let shader = "fn wave(x: f32) -> f32 { return sin(x + toastty.iTime); }\n\
+ fn outer(x: f32) -> f32 { for (var i = 0; i < 2; i++) { if x > 0.0 { return wave(x); } } return 0.0; }\n\
+ fn mainImage(p: vec2f) -> vec4f { return vec4f(outer(p.x)); }\n";
+ let c = compile_ok("t.wgsl", shader);
+ assert!(c.inputs.reads(time_mask()));
+ }
+
+ #[test]
+ fn unused_helper_does_not_count() {
+ let shader = "float unused() { return iTime; }\n\
+ void mainImage(out vec4 c, in vec2 p) { c = vec4(iMouse.zw, 0.0, 1.0); }\n";
+ let c = compile_ok("u.glsl", shader);
+ assert!(!c.inputs.reads(time_mask()));
+ assert!(c.inputs.reads(mouse_mask()));
+ }
+
+ #[test]
+ fn bell_array_counts_as_time_and_bell() {
+ let shader = "void mainImage(out vec4 c, in vec2 p) { c = vec4(iBells[1].w); }\n";
+ let c = compile_ok("b.glsl", shader);
+ assert!(c.inputs.reads(time_mask()));
+ assert!(c.inputs.reads(bell_mask()));
+ assert!(!c.inputs.reads(focus_mask()));
+ }
+
+ #[test]
+ fn duration_constant_is_read_in_both_languages() {
+ let g = compile_ok(
+ "d.glsl",
+ &format!("const float TOASTTY_EVENT_DURATION = 1.5;\n{GLSL_PASS}"),
+ );
+ assert_eq!(g.inputs.event_duration, Some(1.5));
+ let w = compile_ok(
+ "d.wgsl",
+ &format!("const TOASTTY_EVENT_DURATION: f32 = 2.25;\n{WGSL_PASS}"),
+ );
+ assert_eq!(w.inputs.event_duration, Some(2.25));
+ }
+
+ #[test]
+ fn palette_index_does_not_copy_the_array() {
+ // A dynamic index into iPalette must index the uniform in place,
+ // not load all 256 entries into a local first.
+ let shader = "void mainImage(out vec4 c, in vec2 p) {\n\
+ \x20 int i = int(p.x) & 255;\n\
+ \x20 c = vec4(iPalette[i], 1.0);\n\
+ }\n";
+ let c = compile_ok("pal.glsl", shader);
+ assert!(
+ !c.wgsl.contains("array, 256>")
+ || c.wgsl.matches("array, 256>").count() == 1,
+ "palette copied:\n{}",
+ c.wgsl
+ );
+ }
+
+ #[test]
+ fn ghostty_style_shader_compiles() {
+ // Uses the Ghostty-only names, the cursor macros and texture2D.
+ let shader = "#version 330 core\n\
+ void mainImage(out vec4 fragColor, in vec2 fragCoord) {\n\
+ \x20 vec2 uv = fragCoord / iResolution.xy;\n\
+ \x20 vec4 col = texture2D(iChannel0, uv);\n\
+ \x20 float d = distance(fragCoord, iCurrentCursor.xy);\n\
+ \x20 if (iCurrentCursorStyle == CURSORSTYLE_BAR) { col.r += 0.1; }\n\
+ \x20 col.rgb += iCurrentCursorColor.rgb * exp(-d * 0.05) * step(iTimeCursorChange, iTime);\n\
+ \x20 col.rgb += iPreviousCursorColor.rgb * float(iFocus) * iTimeDelta * iFrameRate;\n\
+ \x20 col.rgb *= vec3(float(iFrame % 2), iDate.w, iChannelTime[0] + iChannelResolution[0].x);\n\
+ \x20 fragColor = col + iMouse * 0.0;\n\
+ }\n";
+ let c = compile_ok("ghostty.glsl", shader);
+ assert!(c.inputs.reads(time_mask()));
+ assert!(c.inputs.reads(mouse_mask()));
+ assert!(c.inputs.reads(focus_mask()));
+ }
+}
diff --git a/crates/toastty-render/src/post/gpu_tests.rs b/crates/toastty-render/src/post/gpu_tests.rs
new file mode 100644
index 00000000..1b8e5436
--- /dev/null
+++ b/crates/toastty-render/src/post/gpu_tests.rs
@@ -0,0 +1,333 @@
+//! GPU tests for [`PostChain`]: frames go through real wgpu passes and
+//! are read back.
+//!
+//! The "swapchain" here is an `Rgba8UnormSrgb` texture with a non-sRGB
+//! view format, like the configured surface, so both output paths (direct
+//! non-sRGB view, and ping-pong + copy) are covered.
+
+use std::path::PathBuf;
+
+use wgpu::{Device, Queue, TextureFormat};
+
+use super::*;
+
+const FORMAT: TextureFormat = TextureFormat::Rgba8UnormSrgb;
+
+fn shader(name: &str, source: &str) -> PostShaderSource {
+ PostShaderSource {
+ path: PathBuf::from(name),
+ source: source.to_owned(),
+ }
+}
+
+const GLSL_PASS: &str = "void mainImage(out vec4 fragColor, in vec2 fragCoord) {\n\
+ \x20 fragColor = texture(iChannel0, fragCoord / iResolution.xy);\n\
+ }\n";
+
+const WGSL_PASS: &str = "fn mainImage(fragCoord: vec2f) -> vec4f {\n\
+ \x20 return textureSample(iChannel0, iChannel0Sampler, fragCoord / toastty.iResolution.xy);\n\
+ }\n";
+
+const GLSL_INVERT: &str = "void mainImage(out vec4 fragColor, in vec2 fragCoord) {\n\
+ \x20 vec4 c = texture(iChannel0, fragCoord / iResolution.xy);\n\
+ \x20 fragColor = vec4(vec3(1.0) - c.rgb, c.a);\n\
+ }\n";
+
+const WGSL_SWAP_RB: &str = "fn mainImage(fragCoord: vec2f) -> vec4f {\n\
+ \x20 let c = textureSample(iChannel0, iChannel0Sampler, fragCoord / toastty.iResolution.xy);\n\
+ \x20 return c.bgra;\n\
+ }\n";
+
+fn texture(device: &Device, w: u32, h: u32) -> wgpu::Texture {
+ device.create_texture(&wgpu::TextureDescriptor {
+ label: Some("post test texture"),
+ size: wgpu::Extent3d {
+ width: w,
+ height: h,
+ depth_or_array_layers: 1,
+ },
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D2,
+ format: FORMAT,
+ usage: wgpu::TextureUsages::RENDER_ATTACHMENT
+ | wgpu::TextureUsages::TEXTURE_BINDING
+ | wgpu::TextureUsages::COPY_SRC
+ | wgpu::TextureUsages::COPY_DST,
+ view_formats: &linear_view_formats(FORMAT),
+ })
+}
+
+fn upload(queue: &Queue, t: &wgpu::Texture, rgba: &[u8]) {
+ queue.write_texture(
+ t.as_image_copy(),
+ rgba,
+ wgpu::TexelCopyBufferLayout {
+ offset: 0,
+ bytes_per_row: Some(t.width() * 4),
+ rows_per_image: Some(t.height()),
+ },
+ t.size(),
+ );
+}
+
+fn read_back(device: &Device, queue: &Queue, t: &wgpu::Texture) -> Vec {
+ let (w, h) = (t.width(), t.height());
+ let row = (w * 4).div_ceil(256) * 256;
+ let buf = device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some("post test readback"),
+ size: u64::from(row * h),
+ usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
+ mapped_at_creation: false,
+ });
+ let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
+ encoder.copy_texture_to_buffer(
+ t.as_image_copy(),
+ wgpu::TexelCopyBufferInfo {
+ buffer: &buf,
+ layout: wgpu::TexelCopyBufferLayout {
+ offset: 0,
+ bytes_per_row: Some(row),
+ rows_per_image: Some(h),
+ },
+ },
+ t.size(),
+ );
+ queue.submit(std::iter::once(encoder.finish()));
+ buf.slice(..).map_async(wgpu::MapMode::Read, |_| {});
+ device
+ .poll(wgpu::PollType::wait_indefinitely())
+ .expect("poll");
+ let data = buf.slice(..).get_mapped_range();
+ let mut out = Vec::with_capacity((w * h * 4) as usize);
+ for y in 0..h {
+ let start = (y * row) as usize;
+ out.extend_from_slice(&data[start..start + (w * 4) as usize]);
+ }
+ out
+}
+
+/// Every byte value in every channel, with alpha varying too.
+fn pattern(w: u32, h: u32) -> Vec {
+ let mut v = Vec::with_capacity((w * h * 4) as usize);
+ for y in 0..h {
+ for x in 0..w {
+ v.extend_from_slice(&[
+ (x % 256) as u8,
+ (255 - x % 256) as u8,
+ ((x * 7 + y * 13) % 256) as u8,
+ (255 - (y * 31) % 128) as u8,
+ ]);
+ }
+ }
+ v
+}
+
+/// Run `shaders` over `input` (`w × h` RGBA) and read the result back.
+#[allow(clippy::too_many_arguments)]
+fn run(
+ device: &Device,
+ queue: &Queue,
+ shaders: &[PostShaderSource],
+ direct: bool,
+ w: u32,
+ h: u32,
+ input: &[u8],
+ uniforms: &PostUniforms,
+) -> Vec {
+ let compiled = compile_all(shaders).expect("compiles");
+ let mut chain = PostChain::new(device, &compiled, FORMAT, direct).expect("pipelines");
+ let source = texture(device, w, h);
+ let frame = texture(device, w, h);
+ upload(queue, &source, input);
+ chain.bind_source(device, &source);
+ chain.write_uniforms(queue, uniforms, true);
+ let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
+ chain.encode(&mut encoder, &frame);
+ queue.submit(std::iter::once(encoder.finish()));
+ read_back(device, queue, &frame)
+}
+
+fn uniforms_for(w: u32, h: u32) -> PostUniforms {
+ let mut u = PostUniforms::default();
+ #[allow(clippy::cast_precision_loss)]
+ {
+ u.i_resolution = [w as f32, h as f32, 1.0];
+ }
+ u
+}
+
+fn gpu() -> Option<(Device, Queue)> {
+ crate::test_gpu::try_device_and_queue()
+}
+
+#[test]
+fn pass_through_reproduces_the_frame_exactly() {
+ let Some((device, queue)) = gpu() else { return };
+ let (w, h) = (256, 8);
+ let input = pattern(w, h);
+ let u = uniforms_for(w, h);
+ for direct in [true, false] {
+ for s in [shader("p.glsl", GLSL_PASS), shader("p.wgsl", WGSL_PASS)] {
+ let out = run(
+ &device,
+ &queue,
+ std::slice::from_ref(&s),
+ direct,
+ w,
+ h,
+ &input,
+ &u,
+ );
+ assert!(
+ out == input,
+ "{} (direct={direct}) changed pixels",
+ s.path.display()
+ );
+ }
+ }
+}
+
+#[test]
+fn pass_through_reproduces_a_terminal_frame() {
+ // The hello-world golden is a real cell-pass frame.
+ let Some((device, queue)) = gpu() else { return };
+ let path = concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/tests/snapshots/text_hello.png"
+ );
+ let img = image::open(path).expect("golden").to_rgba8();
+ let (w, h) = img.dimensions();
+ let input = img.into_raw();
+ let u = uniforms_for(w, h);
+ let out = run(
+ &device,
+ &queue,
+ &[shader("p.glsl", GLSL_PASS)],
+ true,
+ w,
+ h,
+ &input,
+ &u,
+ );
+ assert!(out == input, "pass-through changed the terminal frame");
+}
+
+#[test]
+fn invert_inverts() {
+ let Some((device, queue)) = gpu() else { return };
+ let (w, h) = (256, 4);
+ let input = pattern(w, h);
+ let out = run(
+ &device,
+ &queue,
+ &[shader("inv.glsl", GLSL_INVERT)],
+ true,
+ w,
+ h,
+ &input,
+ &uniforms_for(w, h),
+ );
+ for (i, (o, p)) in out.chunks(4).zip(input.chunks(4)).enumerate() {
+ assert_eq!(o, [255 - p[0], 255 - p[1], 255 - p[2], p[3]], "pixel {i}");
+ }
+}
+
+#[test]
+fn chain_applies_every_shader_in_order() {
+ let Some((device, queue)) = gpu() else { return };
+ let (w, h) = (256, 4);
+ let input = pattern(w, h);
+ let u = uniforms_for(w, h);
+ let expected: Vec = input
+ .chunks(4)
+ .flat_map(|p| [255 - p[2], 255 - p[1], 255 - p[0], p[3]])
+ .collect();
+ for direct in [true, false] {
+ let out = run(
+ &device,
+ &queue,
+ &[
+ shader("inv.glsl", GLSL_INVERT),
+ shader("swap.wgsl", WGSL_SWAP_RB),
+ ],
+ direct,
+ w,
+ h,
+ &input,
+ &u,
+ );
+ assert!(out == expected, "two-pass chain (direct={direct})");
+ }
+ // Three passes reuse the two ping-pong textures; inverting twice
+ // cancels out.
+ let out = run(
+ &device,
+ &queue,
+ &[
+ shader("a.glsl", GLSL_INVERT),
+ shader("b.glsl", GLSL_INVERT),
+ shader("c.wgsl", WGSL_SWAP_RB),
+ ],
+ false,
+ w,
+ h,
+ &input,
+ &u,
+ );
+ let swapped: Vec = input
+ .chunks(4)
+ .flat_map(|p| [p[2], p[1], p[0], p[3]])
+ .collect();
+ assert!(out == swapped, "three-pass chain");
+}
+
+#[test]
+fn uniforms_reach_both_languages() {
+ // Reads members from the start, middle and end of the block,
+ // including an array element, an int and the palette.
+ let Some((device, queue)) = gpu() else { return };
+ let (w, h) = (4, 4);
+ let mut u = uniforms_for(w, h);
+ u.i_time = 0.25;
+ u.i_frame = 51;
+ u.i_bells[2] = [0.0, 0.0, 0.0, 0.6];
+ u.i_palette[200] = [0.0, 0.0, 0.75, 0.0];
+ let glsl = "void mainImage(out vec4 c, in vec2 p) {\n\
+ \x20 c = vec4(iTime, float(iFrame) / 255.0, iPalette[200].b, iBells[2].w + iResolution.x / 8.0 - 0.5);\n\
+ }\n";
+ let wgsl = "fn mainImage(p: vec2f) -> vec4f {\n\
+ \x20 let u = toastty;\n\
+ \x20 return vec4f(u.iTime, f32(u.iFrame) / 255.0, u.iPalette[200].b, u.iBells[2].w + u.iResolution.x / 8.0 - 0.5);\n\
+ }\n";
+ let input = vec![0u8; (w * h * 4) as usize];
+ for s in [shader("u.glsl", glsl), shader("u.wgsl", wgsl)] {
+ let out = run(
+ &device,
+ &queue,
+ std::slice::from_ref(&s),
+ true,
+ w,
+ h,
+ &input,
+ &u,
+ );
+ assert_eq!(&out[..4], &[64, 51, 191, 153], "{}", s.path.display());
+ }
+}
+
+#[test]
+fn backend_rejection_is_an_error_not_a_panic() {
+ // Valid for naga, but wgpu refuses the pipeline: the fragment output
+ // is an integer while the target is a float format.
+ let Some((device, _)) = gpu() else { return };
+ let bad = CompiledShader {
+ path: PathBuf::from("bad.wgsl"),
+ wgsl: "@fragment fn main() -> @location(0) vec4 { return vec4(1u); }".to_owned(),
+ entry_point: "main".to_owned(),
+ inputs: ShaderInputs::default(),
+ };
+ let errs = PostChain::new(&device, &[bad], FORMAT, true).expect_err("rejected");
+ assert_eq!(errs[0].path, PathBuf::from("bad.wgsl"));
+ assert!(errs[0].message.contains("rejected"), "{}", errs[0]);
+}
diff --git a/crates/toastty-render/src/post/mod.rs b/crates/toastty-render/src/post/mod.rs
new file mode 100644
index 00000000..01c54b88
--- /dev/null
+++ b/crates/toastty-render/src/post/mod.rs
@@ -0,0 +1,417 @@
+//! User post-process shaders (M13): Shadertoy / Ghostty-style fragment
+//! shaders run over the terminal image.
+//!
+//! See [`docs/milestones/m13-user-shaders.md`](../../../docs/milestones/m13-user-shaders.md)
+//! for the user-facing contract (languages, uniforms, coordinates,
+//! animation rules). The pieces:
+//!
+//! - [`compile`]: prelude wrapping, naga parse + validate, error
+//! mapping back to the user's file, GLSL → WGSL.
+//! - [`analysis`]: which uniforms a shader reads, and its
+//! `TOASTTY_EVENT_DURATION`.
+//! - [`uniforms`]: the uniform block layout shared by both languages.
+//! - [`state`]: the clock, pointer, focus, bell and cursor history behind
+//! the uniforms, and the animation schedule.
+//! - [`PostChain`] (here): the GPU passes.
+//!
+//! Colour handling: the scratch texture and the swapchain are sRGB
+//! formats, but every post pass reads and writes them through a non-sRGB
+//! view, so shaders see (and write) sRGB-encoded values and a
+//! pass-through shader reproduces the frame bit for bit. Where the
+//! surface can't be viewed as non-sRGB (no `SURFACE_VIEW_FORMATS`), the
+//! last pass writes a texture that is then copied into the swapchain.
+
+mod analysis;
+mod compile;
+#[cfg(test)]
+mod gpu_tests;
+mod state;
+mod uniforms;
+
+use std::num::NonZeroU64;
+use std::task::{Context, Poll, Waker};
+
+use wgpu::TextureFormat;
+
+pub(crate) use compile::ShaderInputs;
+pub use compile::{PostShaderSource, ShaderError};
+pub use state::PostAnimation;
+pub(crate) use state::{CursorSnap, FrameInputs, PostState};
+pub(crate) use uniforms::{PostUniforms, date_mask};
+
+use compile::CompiledShader;
+use uniforms::PALETTE_OFFSET;
+
+/// Compile every shader. All-or-nothing: any failure returns every
+/// error from every shader.
+pub(crate) fn compile_all(
+ shaders: &[PostShaderSource],
+) -> Result, Vec> {
+ let mut ok = Vec::with_capacity(shaders.len());
+ let mut errors = Vec::new();
+ for s in shaders {
+ match compile::compile(s) {
+ Ok(c) => ok.push(c),
+ Err(e) => errors.extend(e),
+ }
+ }
+ if errors.is_empty() {
+ Ok(ok)
+ } else {
+ Err(errors)
+ }
+}
+
+/// The format post passes read and write: `format` without sRGB.
+pub(crate) fn linear_format(format: TextureFormat) -> TextureFormat {
+ format.remove_srgb_suffix()
+}
+
+/// `view_formats` for a texture of `format` that post passes access
+/// through [`linear_format`].
+pub(crate) fn linear_view_formats(format: TextureFormat) -> Vec {
+ let linear = linear_format(format);
+ if linear == format {
+ Vec::new()
+ } else {
+ vec![linear]
+ }
+}
+
+/// `t` viewed through [`linear_format`].
+fn linear_view(t: &wgpu::Texture) -> wgpu::TextureView {
+ t.create_view(&wgpu::TextureViewDescriptor {
+ label: Some("toastty-post linear view"),
+ format: Some(linear_format(t.format())),
+ ..Default::default()
+ })
+}
+
+/// Size-dependent resources: ping-pong textures and one bind group per
+/// pass.
+#[derive(Debug)]
+struct Targets {
+ size: (u32, u32),
+ textures: Vec,
+ views: Vec,
+ bind_groups: Vec,
+}
+
+/// A compiled, installed chain of post passes.
+#[derive(Debug)]
+pub(crate) struct PostChain {
+ passes: Vec,
+ inputs: ShaderInputs,
+ layout: wgpu::BindGroupLayout,
+ uniform_buf: wgpu::Buffer,
+ sampler: wgpu::Sampler,
+ /// Format of the scratch texture and the swapchain.
+ format: TextureFormat,
+ /// The last pass renders into the swapchain through a non-sRGB view
+ /// (vs. into a ping-pong texture that is then copied over).
+ direct: bool,
+ targets: Option,
+}
+
+impl PostChain {
+ /// Build the GPU pipelines for `shaders` (already compiled by
+ /// [`compile_all`]) rendering for a `format` surface. `direct`: the
+ /// swapchain can be viewed as [`linear_format`]. Fails with an error
+ /// per shader wgpu rejects.
+ pub(crate) fn new(
+ device: &wgpu::Device,
+ shaders: &[CompiledShader],
+ format: TextureFormat,
+ direct: bool,
+ ) -> Result> {
+ let linear = linear_format(format);
+ let layout = bind_group_layout(device);
+ let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+ label: Some("toastty-post pipeline layout"),
+ bind_group_layouts: &[Some(&layout)],
+ immediate_size: 0,
+ });
+ let vs = device.create_shader_module(wgpu::ShaderModuleDescriptor {
+ label: Some("toastty-post vertex shader"),
+ source: wgpu::ShaderSource::Wgsl(include_str!("../../shaders/post.wgsl").into()),
+ });
+
+ let mut passes = Vec::with_capacity(shaders.len());
+ let mut errors = Vec::new();
+ let mut inputs = ShaderInputs::default();
+ for shader in shaders {
+ inputs = inputs.union(shader.inputs);
+ match build_pass(device, &pipeline_layout, &vs, shader, linear) {
+ Ok(p) => passes.push(p),
+ Err(e) => errors.push(e),
+ }
+ }
+ if !errors.is_empty() {
+ return Err(errors);
+ }
+
+ let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some("toastty-post uniforms"),
+ size: size_of::() as u64,
+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ });
+ let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
+ label: Some("toastty-post sampler"),
+ mag_filter: wgpu::FilterMode::Linear,
+ min_filter: wgpu::FilterMode::Linear,
+ address_mode_u: wgpu::AddressMode::ClampToEdge,
+ address_mode_v: wgpu::AddressMode::ClampToEdge,
+ address_mode_w: wgpu::AddressMode::ClampToEdge,
+ ..Default::default()
+ });
+ Ok(Self {
+ passes,
+ inputs,
+ layout,
+ uniform_buf,
+ sampler,
+ format,
+ direct: direct || linear == format,
+ targets: None,
+ })
+ }
+
+ /// What the chain's shaders read (the union over its passes).
+ pub(crate) fn inputs(&self) -> &ShaderInputs {
+ &self.inputs
+ }
+
+ /// Bind `source` (the content texture, same size as the swapchain,
+ /// created with [`linear_view_formats`] and `TEXTURE_BINDING`) and
+ /// (re)create the ping-pong textures for its size. Call after
+ /// creating the chain and whenever `source` is recreated.
+ pub(crate) fn bind_source(&mut self, device: &wgpu::Device, source: &wgpu::Texture) {
+ let size = (source.width(), source.height());
+ let n = self.passes.len();
+ // Pass outputs that aren't the swapchain; two textures cover any
+ // chain length.
+ let off_screen = n - 1 + usize::from(!self.direct);
+ let (textures, views) = match self.targets.take() {
+ Some(t) if t.size == size && t.textures.len() == off_screen.min(2) => {
+ (t.textures, t.views)
+ }
+ _ => {
+ let view_formats = linear_view_formats(self.format);
+ let textures: Vec = (0..off_screen.min(2))
+ .map(|_| {
+ device.create_texture(&wgpu::TextureDescriptor {
+ label: Some("toastty-post ping-pong"),
+ size: wgpu::Extent3d {
+ width: size.0,
+ height: size.1,
+ depth_or_array_layers: 1,
+ },
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D2,
+ format: self.format,
+ usage: wgpu::TextureUsages::RENDER_ATTACHMENT
+ | wgpu::TextureUsages::TEXTURE_BINDING
+ | wgpu::TextureUsages::COPY_SRC,
+ view_formats: &view_formats,
+ })
+ })
+ .collect();
+ let views = textures.iter().map(linear_view).collect();
+ (textures, views)
+ }
+ };
+ let source_view = linear_view(source);
+ let bind_groups = (0..n)
+ .map(|i| {
+ let input = if i == 0 {
+ &source_view
+ } else {
+ &views[(i - 1) % 2]
+ };
+ device.create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some("toastty-post bind group"),
+ layout: &self.layout,
+ entries: &[
+ wgpu::BindGroupEntry {
+ binding: 0,
+ resource: self.uniform_buf.as_entire_binding(),
+ },
+ wgpu::BindGroupEntry {
+ binding: 1,
+ resource: wgpu::BindingResource::TextureView(input),
+ },
+ wgpu::BindGroupEntry {
+ binding: 2,
+ resource: wgpu::BindingResource::Sampler(&self.sampler),
+ },
+ ],
+ })
+ })
+ .collect();
+ self.targets = Some(Targets {
+ size,
+ textures,
+ views,
+ bind_groups,
+ });
+ }
+
+ /// Upload the uniforms: the block up to the palette always, the
+ /// palette when `palette` is set.
+ pub(crate) fn write_uniforms(&self, queue: &wgpu::Queue, u: &PostUniforms, palette: bool) {
+ let bytes = bytemuck::bytes_of(u);
+ let end = if palette { bytes.len() } else { PALETTE_OFFSET };
+ queue.write_buffer(&self.uniform_buf, 0, &bytes[..end]);
+ }
+
+ /// Record the passes: the bound source through every shader, into
+ /// `frame` (the swapchain texture, or any texture of the chain's
+ /// format with [`linear_view_formats`] and `COPY_DST`).
+ pub(crate) fn encode(&self, encoder: &mut wgpu::CommandEncoder, frame: &wgpu::Texture) {
+ let Some(t) = self.targets.as_ref() else {
+ debug_assert!(false, "PostChain::encode before bind_source");
+ return;
+ };
+ let n = self.passes.len();
+ let frame_view = self.direct.then(|| linear_view(frame));
+ for (i, pipeline) in self.passes.iter().enumerate() {
+ let view = match frame_view.as_ref() {
+ Some(v) if i + 1 == n => v,
+ _ => &t.views[i % 2],
+ };
+ let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("toastty-post pass"),
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view,
+ depth_slice: None,
+ resolve_target: None,
+ ops: wgpu::Operations {
+ // The triangle covers every pixel.
+ load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ depth_stencil_attachment: None,
+ timestamp_writes: None,
+ occlusion_query_set: None,
+ multiview_mask: None,
+ });
+ rp.set_pipeline(pipeline);
+ rp.set_bind_group(0, &t.bind_groups[i], &[]);
+ rp.draw(0..3, 0..1);
+ }
+ if !self.direct {
+ encoder.copy_texture_to_texture(
+ t.textures[(n - 1) % 2].as_image_copy(),
+ frame.as_image_copy(),
+ wgpu::Extent3d {
+ width: t.size.0,
+ height: t.size.1,
+ depth_or_array_layers: 1,
+ },
+ );
+ }
+ }
+}
+
+/// Uniform block at 0, `iChannel0` texture at 1, its sampler at 2.
+fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
+ device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+ label: Some("toastty-post bind group layout"),
+ entries: &[
+ wgpu::BindGroupLayoutEntry {
+ binding: 0,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Buffer {
+ ty: wgpu::BufferBindingType::Uniform,
+ has_dynamic_offset: false,
+ min_binding_size: NonZeroU64::new(size_of::() as u64),
+ },
+ count: None,
+ },
+ wgpu::BindGroupLayoutEntry {
+ binding: 1,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Texture {
+ sample_type: wgpu::TextureSampleType::Float { filterable: true },
+ view_dimension: wgpu::TextureViewDimension::D2,
+ multisampled: false,
+ },
+ count: None,
+ },
+ wgpu::BindGroupLayoutEntry {
+ binding: 2,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
+ count: None,
+ },
+ ],
+ })
+}
+
+/// One post pass's pipeline, created inside wgpu error scopes so a
+/// shader that naga accepted but wgpu rejects comes back as an error
+/// instead of reaching the uncaptured-error handler.
+fn build_pass(
+ device: &wgpu::Device,
+ layout: &wgpu::PipelineLayout,
+ vs: &wgpu::ShaderModule,
+ shader: &CompiledShader,
+ format: TextureFormat,
+) -> Result {
+ let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
+ let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
+ let label = shader.path.display().to_string();
+ let fs = device.create_shader_module(wgpu::ShaderModuleDescriptor {
+ label: Some(&label),
+ source: wgpu::ShaderSource::Wgsl(shader.wgsl.as_str().into()),
+ });
+ let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+ label: Some(&label),
+ layout: Some(layout),
+ vertex: wgpu::VertexState {
+ module: vs,
+ entry_point: Some("vs_main"),
+ compilation_options: wgpu::PipelineCompilationOptions::default(),
+ buffers: &[],
+ },
+ fragment: Some(wgpu::FragmentState {
+ module: &fs,
+ entry_point: Some(&shader.entry_point),
+ compilation_options: wgpu::PipelineCompilationOptions::default(),
+ targets: &[Some(wgpu::ColorTargetState {
+ format,
+ blend: None,
+ write_mask: wgpu::ColorWrites::ALL,
+ })],
+ }),
+ primitive: wgpu::PrimitiveState::default(),
+ depth_stencil: None,
+ multisample: wgpu::MultisampleState::default(),
+ multiview_mask: None,
+ cache: None,
+ });
+ // Pop in reverse push order.
+ match poll_error(internal.pop()).or_else(|| poll_error(validation.pop())) {
+ Some(e) => Err(ShaderError {
+ path: shader.path.clone(),
+ line: None,
+ column: None,
+ message: format!("rejected by the GPU backend: {e}"),
+ }),
+ None => Ok(pipeline),
+ }
+}
+
+/// Poll an error-scope future once. wgpu's native ones are ready
+/// immediately; a pending one reports no error (there is nothing we
+/// could wait on in the render loop anyway).
+fn poll_error(f: impl Future