summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--build.zig28
-rw-r--r--docs/render-pipeline.md28
-rw-r--r--shaders/crt.frag.glsl175
-rw-r--r--shaders/crt.vert.glsl9
-rw-r--r--shaders/post.vert.glsl10
-rw-r--r--shaders/post/crt.glsl79
-rw-r--r--shaders/post/glitch.glsl60
-rw-r--r--shaders/post/prefix.glsl67
-rw-r--r--shaders/post/ripple.glsl49
-rw-r--r--shaders/prebuilt/crt.frag.glsl175
-rw-r--r--shaders/prebuilt/crt.frag.spvbin13832 -> 0 bytes
-rw-r--r--shaders/prebuilt/crt.vert.glsl9
-rw-r--r--shaders/prebuilt/post-crt.frag.glsl146
-rw-r--r--shaders/prebuilt/post-crt.frag.spvbin0 -> 9480 bytes
-rw-r--r--shaders/prebuilt/post-glitch.frag.glsl127
-rw-r--r--shaders/prebuilt/post-glitch.frag.spvbin0 -> 8308 bytes
-rw-r--r--shaders/prebuilt/post-ripple.frag.glsl116
-rw-r--r--shaders/prebuilt/post-ripple.frag.spvbin0 -> 7180 bytes
-rw-r--r--shaders/prebuilt/post.vert.glsl10
-rw-r--r--shaders/prebuilt/post.vert.spv (renamed from shaders/prebuilt/crt.vert.spv)bin1208 -> 1208 bytes
-rw-r--r--src/animation.zig6
-rw-r--r--src/builtins.zig2
-rw-r--r--src/config.zig150
-rw-r--r--src/effect_sources.zig23
-rw-r--r--src/gui/Post.zig559
-rw-r--r--src/gui/crt.zig101
-rw-r--r--src/gui/gui.zig334
-rw-r--r--src/macos.zig23
-rw-r--r--src/ninep/ctl.zig4
-rw-r--r--src/pardes.zig28
30 files changed, 1609 insertions, 709 deletions
diff --git a/build.zig b/build.zig
index 803f773d..c1117593 100644
--- a/build.zig
+++ b/build.zig
@@ -50,7 +50,11 @@ const gui_shaders = [_][]const u8{
"decor.frag",
"overlay.vert", "overlay.frag",
"image.vert", "image.frag",
- "crt.vert", "crt.frag",
+ "post.vert",
+ // The bundled post passes: shaders/post/<name>.glsl behind the Shadertoy
+ // prefix (shaderSource).
+ "post-crt.frag", "post-ripple.frag",
+ "post-glitch.frag",
};
pub fn build(b: *std.Build) void {
@@ -161,7 +165,7 @@ pub fn build(b: *std.Build) void {
for (gui_shaders) |name| {
update_shaders.addCopyFileToSource(compileGlsl(b, name), b.fmt("shaders/prebuilt/{s}.spv", .{name}));
update_shaders.addCopyFileToSource(
- b.path(b.fmt("shaders/{s}.glsl", .{name})),
+ shaderSource(b, name),
b.fmt("shaders/prebuilt/{s}.glsl", .{name}),
);
}
@@ -691,7 +695,7 @@ pub fn build(b: *std.Build) void {
mod.addOptions("pardes_isolation", if (isolated_mod == mod) iso_on else iso_off);
if (shell == .gui) for (gui_shaders) |name| mod.addAnonymousImport(
b.fmt("effect-source-{s}.glsl", .{name}),
- .{ .root_source_file = b.path(b.fmt("{s}/{s}.glsl", .{ gui_effect_source_dir, name })) },
+ .{ .root_source_file = if (prebuilt_shaders) b.path(b.fmt("{s}/{s}.glsl", .{ gui_effect_source_dir, name })) else shaderSource(b, name) },
);
if (shell == .macos) mod.addAnonymousImport("effect-source-crt.ci.metal", .{
.root_source_file = b.path("shaders/crt.ci.metal"),
@@ -756,6 +760,8 @@ pub fn build(b: *std.Build) void {
gm.addAnonymousImport("AdwaitaMono-Regular.ttf", .{
.root_source_file = b.path("assets/AdwaitaMono-Regular.ttf"),
});
+ // Put before a user's Shadertoy file when the gui compiles it.
+ gm.addAnonymousImport("post-prefix.glsl", .{ .root_source_file = b.path("shaders/post/prefix.glsl") });
for (gui_shaders) |name| gm.addAnonymousImport(b.fmt("{s}.spv", .{name}), .{
.root_source_file = if (prebuilt_shaders)
b.path(b.fmt("shaders/prebuilt/{s}.spv", .{name}))
@@ -1444,7 +1450,6 @@ pub fn build(b: *std.Build) void {
.{ .path = "src/esp32p4/input_rescue.zig", .libc = false },
.{ .path = "src/fonts.zig", .libc = true },
.{ .path = "src/c_heap.zig", .libc = false },
- .{ .path = "src/gui/crt.zig", .libc = false },
}) |entry| {
const tests = b.addTest(.{
.root_module = b.createModule(.{
@@ -1626,10 +1631,23 @@ fn reindexed(b: *std.Build, archive: std.Build.LazyPath, index: usize) std.Build
// glslc shaders/<name>.glsl -fshader-stage=<vertex|fragment> -o <name>.spv,
// returned as a LazyPath for @embedFile. The stage is the name's own suffix.
+/// The GLSL a shader is compiled from: shaders/<name>.glsl, or for a bundled
+/// post pass (post-<name>.frag) shaders/post/<name>.glsl behind the Shadertoy
+/// prefix, the one the gui puts before a user's file at run time.
+fn shaderSource(b: *std.Build, name: []const u8) std.Build.LazyPath {
+ if (!std.mem.startsWith(u8, name, "post-")) return b.path(b.fmt("shaders/{s}.glsl", .{name}));
+ const joined = b.addSystemCommand(&.{"cat"});
+ joined.addFileArg(b.path("shaders/post/prefix.glsl"));
+ joined.addFileArg(b.path(b.fmt("shaders/post/{s}.glsl", .{name["post-".len .. name.len - ".frag".len]})));
+ return joined.captureStdOut(.{});
+}
+
+/// The one glslc invocation every shader is compiled with, at build time here
+/// and at run time for a user's post pass (src/gui/Post.zig).
fn compileGlsl(b: *std.Build, name: []const u8) std.Build.LazyPath {
const stage = if (std.mem.endsWith(u8, name, ".vert")) "vertex" else "fragment";
const cmd = b.addSystemCommand(&.{ "glslc", b.fmt("-fshader-stage={s}", .{stage}) });
- cmd.addFileArg(b.path(b.fmt("shaders/{s}.glsl", .{name})));
+ cmd.addFileArg(shaderSource(b, name));
cmd.addArg("-o");
return cmd.addOutputFileArg(b.fmt("{s}.spv", .{name}));
}
diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md
index d25323b6..254187a3 100644
--- a/docs/render-pipeline.md
+++ b/docs/render-pipeline.md
@@ -491,21 +491,27 @@ Mirror ghostty 1.3.2 (`zig-pkg/ghostty-*/src/renderer/shadertoy.zig`,
≈3 cells, at the corners of a 4K screen, where the grips and rails are.
`crt.zig` is deleted. If the user insists on barrel in the bundled CRT, keep
`crt.zig`'s inverse keyed to "bundled crt active".
-- **Compile**: GLSL → SPIR-V at runtime with glslang (ghostty's `pkg/glslang`,
- the same vendored package in zig-pkg, GUI build only, behind `-Dshadertoy`).
- Compiled on load and on file change (file_watch), never on the frame path;
- the last good pipeline stays on error; the error is a pane message. glslang is
- C++ with its own allocator: a recorded exception to the "Zig allocator hooks
- in all C deps" policy.
+- **Compile** (decision 3): a user's file is compiled by spawning `glslc` with
+ the build's own invocation (`-fshader-stage=fragment <file> -o -`, SPIR-V on
+ stdout), on a thread of its own when the file joins the chain, never on the
+ frame path; the result is taken in at the next loop step. The prefix ends in
+ `#line 1`, so glslc's errors count the file's own lines, and they name the
+ file, not the temporary source. A failed compile keeps the file's last good
+ pipeline and says glslc's first line as a message; glslc missing is said
+ once, and only files stay off (the bundled passes are compiled with the
+ build, through the same prefix). No file watching: `Shader <path>` twice
+ (out, then in) compiles it again.
- **Animation mode**: `ShaderAnimation off|on|always` (ghostty's
`custom-shader-animation`): off = redraw only when content changes; on =
continuous while the window is focused; always = continuous. Continuous means
the shell redraws its retained Surface (3.1), never a core tick or render.
- **Existing crt/ripple/glitch** become bundled Shadertoy files in
- `shaders/post/` selected by the same builtins; `scene_effects` and
- `crt.frag.glsl`/`crt.zig` are deleted. The bundled CRT is rewritten to
- quality (6.1); ripple and glitch are dropped unless the feel review keeps a
- rewritten version.
+ `shaders/post/` selected by the same builtins (decision 2: kept, polished);
+ `scene_effects` and `crt.frag.glsl`/`crt.zig` are deleted. The chain is
+ `Crt`, `Ripple`, `Glitch` and `Shader <path>` in the order they were put in
+ (`Shader off` empties it of files), `ShaderAnimation off|on|always`
+ (default on). The core keeps no clock for it: level A redraws are the
+ shell's.
- macOS (out of scope) would need spirv-cross → MSL, as ghostty does.
Region filters (e.g. "tint/blur one pane") are NOT built. If one is ever needed:
@@ -811,7 +817,7 @@ stripped. No visual change until stage 9 unless stated.
| 6 | body layer joined the same way (paint once, copy visible rows) | riskiest join; A/B the old double paint in a temporary test with tag_bottom on and off, then delete it |
| 7 | Layer merge (TagLayer + BodyLayer), wire v8, web accessors | after the other agent lands; touches mouse hit paths; breaks the macOS shell's layer ABI (accepted: macOS build ignored for now); done: one `Layer` (src/Layer.zig) with `rows` (0 = no layer) and a `cursor{x, y}`; a tag of N rows is ONE layer of N grid rows (`tagHit` answers the row as `line`, `bodyHit` keeps its meaning), so the per-line layer bases are gone. Wire v8 ships rows, cursor y and the region list in the one bump; v7 and v9 peers are refused in both directions (tests). web: `tag_layer_value` 11 = rows, 12 = cursor y, and app.mjs lays every row. macOS: its Zig side compiles against `Layer`, but pardes.h still sees one row per tag layer (a taller tag shows its first row there) |
| 8 | GUI draws from regions: role tiers with track groups in tiers 2-3, page cover, hard-edged snapped decor for rules/rails/grips (pane chrome in the pane's tier), per-instance clip; delete inference functions, `transient_on` and `mark_hover` (breaks macOS glass hover; accepted, macOS ignored for now) | stage-0 PPM goldens byte-identical (possible only because hard decor is snapped and not anti-aliased); pane chrome now also shows during transitions, which is the one allowed visible delta, listed Done: the frame is drawn in groups (makeGroups): tier 0, one group per track in paint order (tiers 2 and 3), tier 4 as two groups (the notices; then the guides and the debug box, which the core paints over them), tier 5 (bar cursors), each drawing cells, images, decor. Decor is every rule, rail, thumb, grip mark, spine, the workspace and column rules, the bottom band, notice rules and bar cursors: whole-pixel rects drawn by `decor.frag` over `ui.vert` as cell instances (colour in the ground, coverage in fg.r, blended like the overlay), so each carries its track's transition and clip; a closing pane's comes from the last frame's regions (`Surface.previous_regions`). Only `solid` exists yet: `decor.vert` and the other kinds (§5.1) come with the first effect that needs them. Deleted: taglineBaseRgb, topbarPaneBorderHeight, bottomTaglinePresent, frameChromeBg, cellBackgroundIs and the rail inference, paneGripCell's scan, transient_on, PaintPlan; one cover map (coverFrame: layer, grip and offset, focus, anchor, floating) is marked from layers and regions once a frame. New regions: `column` (spines, anchors, the focused column's tint), `guide`, `debug`; `Surface.chrome` is the palette, on the wire in v8 (not yet shipped, so no bump), and an attached GUI draws the same chrome (test). Per-instance clip (`CellInstance.clip_*`, 104 → 120 bytes an instance, ~15% more upload a frame) replaces the vertical transition's scissor. While any pane moves, opens or closes, notices stay in their own panes' groups, under whatever slides over them; tier 4 holds them only when nothing moves. Goldens: 01-12 byte-identical; 13-17 differ only in pixels past the grid (with a picture on screen the image pass left the scissor at the grid's size, cutting every rule end, rail foot and band that runs into the leftover pixels; they now run to the edge as in every scene without one); 16-debug shows the debug box (it was drawn from the grid under the source's context-row layer, so the GUI never showed it there); 18-mid-transition is new (virtual clock, PanelSlide Newcol with the picture, frame 6 of 12: chrome moves with its pane). Other deltas, outside the goldens: a guide over a tag or a context-row body now shows; with WindowOpacity < 100 a layer's bar cursor is ink like the grid's; an attached GUI gains the focus tint, notice rules, spines and the theme's page and caret colours. Deferrals fixed: the GUI sleeps when idle (SDL and queue events wake it; a smooth scroll, a gamepad, the test feed, a shell's kill deadline, a present to retry still poll), and a minimized or occluded window sleeps through animation. Tracy, `gui frame build`, 200x60, terminal output plus scrolling, ReleaseFast, two interleaved runs of ~180 frames: before 992/1015 µs median, after 989/987 µs |
-| 9 | post chain: glslang, Shadertoy prefix, ping-pong, ShaderAnimation, redraw levels A/B (§5.5); bundled CRT without barrel; delete scene_effects/crt.zig/crt.frag | first visual change (the CRT look) |
+| 9 | post chain: glslang, Shadertoy prefix, ping-pong, ShaderAnimation, redraw levels A/B (§5.5); bundled CRT without barrel; delete scene_effects/crt.zig/crt.frag | first visual change (the CRT look) Done: src/gui/Post.zig, shaders/post/ (prefix, crt, ripple, glitch), post.vert. glslc (decision 3) with compileGlsl's flags, off the frame path; ghostty's Uniforms matched offset for offset by a test against a copy, and the prefix's block checked name by name; ghostty's test_shadertoy_crt and _focus compile, _invalid fails with glslc's words. Level A measured (Tracy, 200x60, Crt, idle 10 s): 215 chain-only redraws at 34 µs median CPU each, 6 core frames (957 µs) in the same time; the core's clock stays idle. Input is identity (test: the corner click with Crt on). The three bundled passes were rewritten: Crt without barrel or tube edge, scanlines and mask that average to one, dithered vignette; Ripple as rings in pixels, eased in, lit on their slopes in linear light, dithered; Glitch as short eased bursts of torn bands with an RGB split, keyed to iTime. Before/after stills for the user's judgement. |
| 10+ | `fx` bookmark: G1–G3 → feel review → G4 bundled → G5 lapis theme → P2s; tty T1–T2 → feel review → removals of audited effects (after user decision) → T3–T5 | each effect its own change, default off |
Tests to add: fixed-clock animation tests (Tween/Spring closed form, retarget
diff --git a/shaders/crt.frag.glsl b/shaders/crt.frag.glsl
deleted file mode 100644
index b0b0b93b..00000000
--- a/shaders/crt.frag.glsl
+++ /dev/null
@@ -1,175 +0,0 @@
-#version 450
-
-// The Crt builtin's post-process. Screen point cc shows the scene at
-// uv' = cc*(1 + BARREL*r2), and src/gui/crt.zig maps mouse input through that
-// same transform — keep them equal or clicks drift near the edges.
-
-layout(set = 2, binding = 0) uniform sampler2D u_scene;
-layout(std140, set = 3, binding = 0) uniform CrtUniforms {
- float time_seconds;
- float frame;
- vec2 viewport;
- // x = CRT, y = ripple, z = glitch. One scene pass can combine them.
- vec4 effects;
-} u_crt;
-
-layout(location = 0) out vec4 o_col;
-
-const float TAU = 6.28318530718;
-const float BARREL = 0.016; // MUST match src/gui/crt.zig barrel
-const float EDGE_SOFTNESS_PX = 3.5;
-const float CHROMA_PX = 0.28; // radial R/B separation in physical pixels at r2=1
-const float BLOOM_RADIUS_PX = 1.75;
-const float BLOOM_THRESHOLD = 0.60; // linear-light channel peak
-const float BLOOM_SOFTNESS = 0.25;
-const float BLOOM_STRENGTH = 0.045;
-const float SCAN_PERIOD_PX = 3.0;
-const float SCAN_STRENGTH = 0.055;
-const float MASK_PERIOD_PX = 3.0;
-const float MASK_STRENGTH = 0.015;
-const float VIGNETTE_STRENGTH = 0.11; // 11% only at the extreme corners
-const float HUM_STRENGTH = 0.0025;
-const float NOISE_STRENGTH = 0.0020;
-const float EXPOSURE = 1.012;
-
-vec3 srgbToLinear(vec3 c) {
- vec3 low = c / 12.92;
- vec3 high = pow((c + 0.055) / 1.055, vec3(2.4));
- return mix(high, low, lessThanEqual(c, vec3(0.04045)));
-}
-
-vec3 linearToSrgb(vec3 c) {
- c = max(c, vec3(0.0));
- vec3 low = c * 12.92;
- vec3 high = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;
- return mix(high, low, lessThanEqual(c, vec3(0.0031308)));
-}
-
-vec3 sceneLinear(vec2 uv) {
- return srgbToLinear(texture(u_scene, uv).rgb);
-}
-
-vec3 brightPass(vec2 uv) {
- vec3 c = sceneLinear(uv);
- float peak = max(c.r, max(c.g, c.b));
- return c * smoothstep(BLOOM_THRESHOLD, BLOOM_THRESHOLD + BLOOM_SOFTNESS, peak);
-}
-
-float noiseAt(vec2 p) {
- vec3 q = fract(vec3(p.xyx) * 0.1031);
- q += dot(q, q.yzx + 33.33);
- return fract((q.x + q.y) * q.z);
-}
-
-// The non-CRT scene effects deliberately distort the source coordinates,
-// rather than remapping the final colors. Themes retain their palette and the
-// effects remain composable with the linear-light CRT treatment below.
-vec2 sceneUv(vec2 uv) {
- if (u_crt.effects.y > 0.5) {
- vec2 centered = uv - 0.5;
- float radius = length(centered);
- float envelope = 1.0 - smoothstep(0.04, 0.72, radius);
- float wave = sin(radius * 42.0 - u_crt.time_seconds * 5.2);
- uv += centered / max(radius, 0.00001) * wave * envelope * 0.0032;
- }
- if (u_crt.effects.z > 0.5) {
- float band = floor(uv.y * 96.0);
- float seed = noiseAt(vec2(band, floor(u_crt.time_seconds * 13.0)));
- float tear = smoothstep(0.955, 1.0, seed);
- uv.x += (noiseAt(vec2(seed, band)) - 0.5) * tear * 0.020;
- }
- return uv;
-}
-
-// The scene, ALPHA INCLUDED: with a see-through ground the grid arrives
-// premultiplied and partly empty, and a pass that dropped .a would turn the
-// desktop behind a `*_transparent` theme into opaque black the moment Crt,
-// Ripple or Glitch came on.
-vec4 sceneSrgb(vec2 uv) {
- vec2 warped = sceneUv(uv);
- vec4 center = texture(u_scene, warped);
- if (u_crt.effects.z <= 0.5) return center;
-
- float band = floor(warped.y * 96.0);
- float seed = noiseAt(vec2(band, floor(u_crt.time_seconds * 13.0)));
- float tear = smoothstep(0.955, 1.0, seed);
- vec2 chroma = vec2((0.35 + 1.25 * tear) / u_crt.viewport.x, 0.0);
- return vec4(
- texture(u_scene, warped + chroma).r,
- center.g,
- texture(u_scene, warped - chroma).b,
- center.a
- );
-}
-
-vec3 effectSceneLinear(vec2 uv) {
- return srgbToLinear(sceneSrgb(uv).rgb);
-}
-
-void main() {
- vec2 size = u_crt.viewport;
- vec2 texel = 1.0 / size;
- vec2 cc = gl_FragCoord.xy / size * 2.0 - 1.0;
- float r2 = dot(cc, cc);
- vec2 uv = cc * (1.0 + BARREL * r2 * u_crt.effects.x) * 0.5 + 0.5;
-
- // Ripple and glitch are useful without the CRT. They only move samples;
- // skip every tube-specific light/mask/exposure adjustment.
- if (u_crt.effects.x <= 0.5) {
- // The scene texture is already sRGB. Avoid a needless transfer-curve
- // round trip so a coordinate-only effect preserves theme bytes.
- vec4 s = sceneSrgb(uv);
- o_col = vec4(clamp(s.rgb, 0.0, 1.0), clamp(s.a, 0.0, 1.0));
- return;
- }
-
- vec3 col = vec3(0.0);
- // Outside the tube nothing is drawn, so nothing is covered either: the
- // old opaque black there would have punched a rectangle out of the
- // compositor's backdrop.
- float alpha = 0.0;
- float edge = min(min(uv.x, uv.y), min(1.0 - uv.x, 1.0 - uv.y));
- if (edge > 0.0) {
- vec3 center = effectSceneLinear(uv);
- vec2 chroma = cc * (CHROMA_PX * r2) * texel;
- // Each radial sample follows the same ripple/glitch path from its own
- // pre-warp coordinate. This is also how the Metal kernel composes the
- // effects; sampling a once-warped center here made combined effects
- // diverge between the two GUI backends.
- col = vec3(
- effectSceneLinear(uv + chroma).r,
- center.g,
- effectSceneLinear(uv - chroma).b
- );
-
- vec2 bloom_px = texel * BLOOM_RADIUS_PX;
- vec3 bloom = brightPass(uv + vec2(bloom_px.x, 0.0)) +
- brightPass(uv - vec2(bloom_px.x, 0.0)) +
- brightPass(uv + vec2(0.0, bloom_px.y)) +
- brightPass(uv - vec2(0.0, bloom_px.y));
- col += bloom * (0.25 * BLOOM_STRENGTH);
-
- // The mask has unit mean and the scan loss stays small; neither needs
- // the old global lift that remapped every theme toward white.
- float scan_wave = 0.5 + 0.5 * cos(gl_FragCoord.y * TAU / SCAN_PERIOD_PX);
- col *= 1.0 - SCAN_STRENGTH * scan_wave * scan_wave;
- vec3 mask = 1.0 + MASK_STRENGTH * cos(
- gl_FragCoord.x * TAU / MASK_PERIOD_PX + vec3(0.0, TAU / 3.0, 2.0 * TAU / 3.0)
- );
- col *= mask;
-
- vec2 edge_px = min(uv, 1.0 - uv) * size;
- float tube = smoothstep(0.0, EDGE_SOFTNESS_PX, min(edge_px.x, edge_px.y));
- float vignette = 1.0 - VIGNETTE_STRENGTH * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6);
- float hum = 1.0 + HUM_STRENGTH * sin((uv.y * 1.35 - u_crt.time_seconds * 0.11) * TAU);
- col *= tube * vignette * hum * EXPOSURE;
- // The tube's edge falloff dims coverage as well as colour; everything
- // else here is a light adjustment and leaves it alone.
- alpha = sceneSrgb(uv).a * tube;
-
- float luma = dot(col, vec3(0.2126, 0.7152, 0.0722));
- vec2 noise_seed = gl_FragCoord.xy + vec2(u_crt.frame * 17.0, u_crt.frame * 59.0);
- col += (noiseAt(noise_seed) - 0.5) * NOISE_STRENGTH * (0.15 + 0.85 * clamp(luma, 0.0, 1.0));
- }
- o_col = vec4(linearToSrgb(clamp(col, 0.0, 1.0)), clamp(alpha, 0.0, 1.0));
-}
diff --git a/shaders/crt.vert.glsl b/shaders/crt.vert.glsl
deleted file mode 100644
index 4e15fcdf..00000000
--- a/shaders/crt.vert.glsl
+++ /dev/null
@@ -1,9 +0,0 @@
-#version 450
-
-// CRT post-process pass: one fullscreen triangle from gl_VertexIndex, no
-// vertex buffer. The fragment shader derives uv from gl_FragCoord.
-
-void main() {
- vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2));
- gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
-}
diff --git a/shaders/post.vert.glsl b/shaders/post.vert.glsl
new file mode 100644
index 00000000..86739a65
--- /dev/null
+++ b/shaders/post.vert.glsl
@@ -0,0 +1,10 @@
+#version 450
+
+// A post pass (docs/render-pipeline.md §6): one fullscreen triangle from
+// gl_VertexIndex, no vertex buffer. The Shadertoy fragment shader works from
+// gl_FragCoord, y down, as its fragCoord.
+
+void main() {
+ vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2));
+ gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
+}
diff --git a/shaders/post/crt.glsl b/shaders/post/crt.glsl
new file mode 100644
index 00000000..16a5cdd9
--- /dev/null
+++ b/shaders/post/crt.glsl
@@ -0,0 +1,79 @@
+// Crt: a slot-mask tube's light, with nothing that moves a pixel. There is
+// no barrel: every click lands where it is drawn, rails and grips at the
+// window's edges included. What is left is light: a beam profile for the
+// scanlines, an aperture mask, a soft bloom of the brightest ink, a faint
+// vignette and the hum of a live tube. Everything is worked out in linear
+// light and dithered back into 8 bits, so the vignette never bands. It is a
+// Shadertoy file: pardes puts its prefix before it (shaders/post/prefix.glsl).
+
+const float TAU = 6.28318530718;
+const float CHROMA_PX = 0.35; // red and blue apart, at the corners
+const float BLOOM_RADIUS_PX = 1.75;
+const float BLOOM_THRESHOLD = 0.60; // linear-light channel peak
+const float BLOOM_SOFTNESS = 0.25;
+const float BLOOM_STRENGTH = 0.05;
+const float SCAN_PERIOD_PX = 3.0;
+const float SCAN_STRENGTH = 0.07;
+const float MASK_PERIOD_PX = 3.0;
+const float MASK_STRENGTH = 0.018;
+const float VIGNETTE_STRENGTH = 0.12; // at the far corners only
+const float HUM_STRENGTH = 0.004;
+const float HUM_HZ = 0.11;
+const float EXPOSURE = 1.015;
+
+vec3 toLinear(vec3 c) {
+ return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045)));
+}
+
+vec3 toSrgb(vec3 c) {
+ c = max(c, vec3(0.0));
+ return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308)));
+}
+
+vec3 scene(vec2 uv) {
+ return toLinear(texture(iChannel0, uv).rgb);
+}
+
+vec3 brightPass(vec2 uv) {
+ vec3 c = scene(uv);
+ float peak = max(c.r, max(c.g, c.b));
+ return c * smoothstep(BLOOM_THRESHOLD, BLOOM_THRESHOLD + BLOOM_SOFTNESS, peak);
+}
+
+// Interleaved gradient noise: a dither with no visible pattern.
+float ign(vec2 p) {
+ return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715))));
+}
+
+void mainImage(out vec4 fragColor, in vec2 fragCoord) {
+ vec2 size = iResolution.xy;
+ vec2 texel = 1.0 / size;
+ vec2 uv = fragCoord / size;
+ vec2 cc = uv * 2.0 - 1.0;
+ float r2 = dot(cc, cc);
+
+ // The beam converges least at the corners: red and blue drift apart
+ // there, by a third of a pixel.
+ vec2 chroma = cc * (CHROMA_PX * r2 * 0.5) * texel;
+ vec3 col = vec3(scene(uv + chroma).r, scene(uv).g, scene(uv - chroma).b);
+
+ vec2 b = texel * BLOOM_RADIUS_PX;
+ vec3 bloom = brightPass(uv + vec2(b.x, 0.0)) + brightPass(uv - vec2(b.x, 0.0)) +
+ brightPass(uv + vec2(0.0, b.y)) + brightPass(uv - vec2(0.0, b.y));
+ col += bloom * (0.25 * BLOOM_STRENGTH);
+
+ // A beam is brightest on its line and falls off between lines; the
+ // mask is three phosphor stripes a triad. Both average to one, so the
+ // theme keeps its brightness.
+ float line = 0.5 + 0.5 * cos(fragCoord.y * TAU / SCAN_PERIOD_PX);
+ col *= 1.0 - SCAN_STRENGTH * (line * line - 0.375) / 0.625;
+ col *= 1.0 + MASK_STRENGTH * cos(fragCoord.x * TAU / MASK_PERIOD_PX + vec3(0.0, TAU / 3.0, 2.0 * TAU / 3.0));
+
+ float vignette = 1.0 - VIGNETTE_STRENGTH * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6);
+ float hum = 1.0 + HUM_STRENGTH * sin((uv.y * 1.35 - iTime * HUM_HZ) * TAU);
+ col *= vignette * hum * EXPOSURE;
+
+ vec3 srgb = toSrgb(col);
+ srgb += (ign(fragCoord + float(iFrame % 64) * vec2(5.588238, 5.588238)) - 0.5) / 255.0;
+ fragColor = vec4(clamp(srgb, 0.0, 1.0), texture(iChannel0, uv).a);
+}
diff --git a/shaders/post/glitch.glsl b/shaders/post/glitch.glsl
new file mode 100644
index 00000000..ff9ebcb8
--- /dev/null
+++ b/shaders/post/glitch.glsl
@@ -0,0 +1,60 @@
+// Glitch: now and then the signal slips. Every couple of seconds a short
+// burst tears a few horizontal bands sideways and pulls their red and blue
+// apart; the bands are sized in pixels and the burst eases in and out over
+// a fraction of a second, so it reads as one event rather than a flicker,
+// and it looks the same at 60 or 144 Hz because every step is keyed to
+// iTime, not to frames. Between bursts the page is left exactly as it is.
+// A torn band's brightness dips in linear light. It is a Shadertoy file:
+// pardes puts its prefix before it.
+
+const float PERIOD = 2.4; // seconds between bursts, on average
+const float BURST = 0.28; // seconds a burst lasts
+const float STEP_HZ = 24.0; // how often a burst re-tears
+const float SHIFT_PX = 14.0;
+const float SPLIT_PX = 2.5;
+const float DIP = 0.12;
+
+float hash(vec2 p) {
+ vec3 q = fract(vec3(p.xyx) * 0.1031);
+ q += dot(q, q.yzx + 33.33);
+ return fract((q.x + q.y) * q.z);
+}
+
+vec3 toLinear(vec3 c) {
+ return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045)));
+}
+
+vec3 toSrgb(vec3 c) {
+ c = max(c, vec3(0.0));
+ return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308)));
+}
+
+void mainImage(out vec4 fragColor, in vec2 fragCoord) {
+ vec2 size = iResolution.xy;
+ vec2 uv = fragCoord / size;
+ // Which burst window this is, when in it the burst starts, and how far
+ // into the burst we are: 0 outside one.
+ float window = floor(iTime / PERIOD);
+ float start = window * PERIOD + hash(vec2(window, 7.0)) * (PERIOD - BURST);
+ float t = (iTime - start) / BURST;
+ float live = step(0.0, t) * step(t, 1.0);
+ float envelope = live * sin(3.14159265 * clamp(t, 0.0, 1.0));
+ if (envelope <= 0.0) {
+ fragColor = texture(iChannel0, uv);
+ return;
+ }
+ // Bands of 6 to 40 px, re-cut at STEP_HZ during the burst; about one
+ // band in four tears.
+ float tick = floor(iTime * STEP_HZ);
+ float band_px = mix(6.0, 40.0, hash(vec2(tick, 3.0)));
+ float band = floor(fragCoord.y / band_px);
+ float roll = hash(vec2(band, tick));
+ float tear = smoothstep(0.72, 0.9, roll) * envelope;
+ float shift = (hash(vec2(tick, band + 11.0)) - 0.5) * 2.0 * SHIFT_PX * tear;
+ vec2 at = vec2(fragCoord.x + shift, fragCoord.y);
+ vec2 split = vec2(SPLIT_PX * tear, 0.0);
+ vec4 center = texture(iChannel0, at / size);
+ vec3 col = vec3(texture(iChannel0, (at + split) / size).r, center.g, texture(iChannel0, (at - split) / size).b);
+ col = toSrgb(toLinear(col) * (1.0 - DIP * tear));
+ fragColor = vec4(col, center.a);
+}
diff --git a/shaders/post/prefix.glsl b/shaders/post/prefix.glsl
new file mode 100644
index 00000000..efa49483
--- /dev/null
+++ b/shaders/post/prefix.glsl
@@ -0,0 +1,67 @@
+#version 450
+// pardes's Shadertoy prefix (docs/render-pipeline.md §6), put before every
+// post pass: a bundled one in shaders/post/ at build time, a user's file at
+// run time. The uniform names, types and std140 order are ghostty 1.3.2's
+// (src/renderer/shaders/shadertoy_prefix.glsl), so a shader written for
+// ghostty runs here; only the bindings are SDL GPU's (a fragment shader's
+// textures in set 2, its uniform blocks in set 3). Y is down everywhere, as
+// on ghostty's Metal renderer: fragCoord, iChannel0's uv and the cursor
+// rectangles share one top-left space, and iCurrentCursor is (left x,
+// BOTTOM-edge y, width, height) in framebuffer pixels.
+
+layout(set = 3, binding = 0, std140) uniform Globals {
+ uniform vec3 iResolution;
+ uniform float iTime;
+ uniform float iTimeDelta;
+ uniform float iFrameRate;
+ uniform int iFrame;
+ uniform float iChannelTime[4];
+ uniform vec3 iChannelResolution[4];
+ uniform vec4 iMouse;
+ uniform vec4 iDate;
+ uniform float iSampleRate;
+ uniform vec4 iCurrentCursor;
+ uniform vec4 iPreviousCursor;
+ uniform vec4 iCurrentCursorColor;
+ uniform vec4 iPreviousCursorColor;
+ uniform int iCurrentCursorStyle;
+ uniform int iPreviousCursorStyle;
+ uniform int iCursorVisible;
+ uniform float iTimeCursorChange;
+ uniform float iTimeFocus;
+ uniform int iFocus;
+ uniform vec3 iPalette[256];
+ uniform vec3 iBackgroundColor;
+ uniform vec3 iForegroundColor;
+ uniform vec3 iCursorColor;
+ uniform vec3 iCursorText;
+ uniform vec3 iSelectionForegroundColor;
+ uniform vec3 iSelectionBackgroundColor;
+};
+
+// pardes's own: 1 when the window is opaque, so a pass cannot punch holes
+// in it through its alpha.
+layout(set = 3, binding = 1, std140) uniform PardesPost {
+ float pardesOpaque;
+};
+
+#define CURSORSTYLE_BLOCK 0
+#define CURSORSTYLE_BLOCK_HOLLOW 1
+#define CURSORSTYLE_BAR 2
+#define CURSORSTYLE_UNDERLINE 3
+#define CURSORSTYLE_LOCK 4
+
+layout(set = 2, binding = 0) uniform sampler2D iChannel0;
+
+layout(location = 0) out vec4 _fragColor;
+
+#define texture2D texture
+
+void mainImage(out vec4 fragColor, in vec2 fragCoord);
+void main() {
+ mainImage(_fragColor, gl_FragCoord.xy);
+ if (pardesOpaque > 0.5) _fragColor.a = 1.0;
+}
+
+// The shader's own lines count from 1, so glslc's errors name them.
+#line 1
diff --git a/shaders/post/ripple.glsl b/shaders/post/ripple.glsl
new file mode 100644
index 00000000..ffecbf70
--- /dev/null
+++ b/shaders/post/ripple.glsl
@@ -0,0 +1,49 @@
+// Ripple: the page as a still pool a slow swell crosses. Rings travel out
+// from the middle of the window at a fixed speed in pixels, so they look
+// the same at any size and frame rate; the displacement is at most a couple
+// of pixels, so text stays readable, and it eases in over its first half
+// second instead of snapping on. Each ring catches the light a little on its
+// rising side, in linear light, and the result is dithered so the shading
+// never bands. It is a Shadertoy file: pardes puts its prefix before it.
+
+const float TAU = 6.28318530718;
+const float WAVELENGTH_PX = 150.0;
+const float SPEED_PX = 55.0; // outward, per second
+const float AMPLITUDE_PX = 2.2;
+const float SHADE = 0.035; // light on a slope, linear
+const float EASE_IN = 0.5; // seconds
+
+vec3 toLinear(vec3 c) {
+ return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045)));
+}
+
+vec3 toSrgb(vec3 c) {
+ c = max(c, vec3(0.0));
+ return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308)));
+}
+
+float ign(vec2 p) {
+ return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715))));
+}
+
+void mainImage(out vec4 fragColor, in vec2 fragCoord) {
+ vec2 size = iResolution.xy;
+ vec2 center = 0.5 * size;
+ vec2 offset = fragCoord - center;
+ float radius = length(offset);
+ float reach = 0.5 * length(size);
+ // Strongest a third of the way out, fading to nothing at the middle
+ // and at the edges, so the window's frame never wobbles.
+ float envelope = smoothstep(0.0, 0.18, radius / reach) * (1.0 - smoothstep(0.35, 0.95, radius / reach));
+ float ease = smoothstep(0.0, EASE_IN, iTime);
+ float phase = (radius - iTime * SPEED_PX) * TAU / WAVELENGTH_PX;
+ float height = sin(phase);
+ float slope = cos(phase);
+ vec2 dir = offset / max(radius, 1e-3);
+ vec2 moved = fragCoord + dir * height * AMPLITUDE_PX * envelope * ease;
+
+ vec4 s = texture(iChannel0, moved / size);
+ vec3 col = toLinear(s.rgb) * (1.0 + SHADE * slope * envelope * ease);
+ vec3 srgb = toSrgb(col) + (ign(fragCoord + float(iFrame % 64) * 5.588238) - 0.5) / 255.0;
+ fragColor = vec4(clamp(srgb, 0.0, 1.0), s.a);
+}
diff --git a/shaders/prebuilt/crt.frag.glsl b/shaders/prebuilt/crt.frag.glsl
deleted file mode 100644
index b0b0b93b..00000000
--- a/shaders/prebuilt/crt.frag.glsl
+++ /dev/null
@@ -1,175 +0,0 @@
-#version 450
-
-// The Crt builtin's post-process. Screen point cc shows the scene at
-// uv' = cc*(1 + BARREL*r2), and src/gui/crt.zig maps mouse input through that
-// same transform — keep them equal or clicks drift near the edges.
-
-layout(set = 2, binding = 0) uniform sampler2D u_scene;
-layout(std140, set = 3, binding = 0) uniform CrtUniforms {
- float time_seconds;
- float frame;
- vec2 viewport;
- // x = CRT, y = ripple, z = glitch. One scene pass can combine them.
- vec4 effects;
-} u_crt;
-
-layout(location = 0) out vec4 o_col;
-
-const float TAU = 6.28318530718;
-const float BARREL = 0.016; // MUST match src/gui/crt.zig barrel
-const float EDGE_SOFTNESS_PX = 3.5;
-const float CHROMA_PX = 0.28; // radial R/B separation in physical pixels at r2=1
-const float BLOOM_RADIUS_PX = 1.75;
-const float BLOOM_THRESHOLD = 0.60; // linear-light channel peak
-const float BLOOM_SOFTNESS = 0.25;
-const float BLOOM_STRENGTH = 0.045;
-const float SCAN_PERIOD_PX = 3.0;
-const float SCAN_STRENGTH = 0.055;
-const float MASK_PERIOD_PX = 3.0;
-const float MASK_STRENGTH = 0.015;
-const float VIGNETTE_STRENGTH = 0.11; // 11% only at the extreme corners
-const float HUM_STRENGTH = 0.0025;
-const float NOISE_STRENGTH = 0.0020;
-const float EXPOSURE = 1.012;
-
-vec3 srgbToLinear(vec3 c) {
- vec3 low = c / 12.92;
- vec3 high = pow((c + 0.055) / 1.055, vec3(2.4));
- return mix(high, low, lessThanEqual(c, vec3(0.04045)));
-}
-
-vec3 linearToSrgb(vec3 c) {
- c = max(c, vec3(0.0));
- vec3 low = c * 12.92;
- vec3 high = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;
- return mix(high, low, lessThanEqual(c, vec3(0.0031308)));
-}
-
-vec3 sceneLinear(vec2 uv) {
- return srgbToLinear(texture(u_scene, uv).rgb);
-}
-
-vec3 brightPass(vec2 uv) {
- vec3 c = sceneLinear(uv);
- float peak = max(c.r, max(c.g, c.b));
- return c * smoothstep(BLOOM_THRESHOLD, BLOOM_THRESHOLD + BLOOM_SOFTNESS, peak);
-}
-
-float noiseAt(vec2 p) {
- vec3 q = fract(vec3(p.xyx) * 0.1031);
- q += dot(q, q.yzx + 33.33);
- return fract((q.x + q.y) * q.z);
-}
-
-// The non-CRT scene effects deliberately distort the source coordinates,
-// rather than remapping the final colors. Themes retain their palette and the
-// effects remain composable with the linear-light CRT treatment below.
-vec2 sceneUv(vec2 uv) {
- if (u_crt.effects.y > 0.5) {
- vec2 centered = uv - 0.5;
- float radius = length(centered);
- float envelope = 1.0 - smoothstep(0.04, 0.72, radius);
- float wave = sin(radius * 42.0 - u_crt.time_seconds * 5.2);
- uv += centered / max(radius, 0.00001) * wave * envelope * 0.0032;
- }
- if (u_crt.effects.z > 0.5) {
- float band = floor(uv.y * 96.0);
- float seed = noiseAt(vec2(band, floor(u_crt.time_seconds * 13.0)));
- float tear = smoothstep(0.955, 1.0, seed);
- uv.x += (noiseAt(vec2(seed, band)) - 0.5) * tear * 0.020;
- }
- return uv;
-}
-
-// The scene, ALPHA INCLUDED: with a see-through ground the grid arrives
-// premultiplied and partly empty, and a pass that dropped .a would turn the
-// desktop behind a `*_transparent` theme into opaque black the moment Crt,
-// Ripple or Glitch came on.
-vec4 sceneSrgb(vec2 uv) {
- vec2 warped = sceneUv(uv);
- vec4 center = texture(u_scene, warped);
- if (u_crt.effects.z <= 0.5) return center;
-
- float band = floor(warped.y * 96.0);
- float seed = noiseAt(vec2(band, floor(u_crt.time_seconds * 13.0)));
- float tear = smoothstep(0.955, 1.0, seed);
- vec2 chroma = vec2((0.35 + 1.25 * tear) / u_crt.viewport.x, 0.0);
- return vec4(
- texture(u_scene, warped + chroma).r,
- center.g,
- texture(u_scene, warped - chroma).b,
- center.a
- );
-}
-
-vec3 effectSceneLinear(vec2 uv) {
- return srgbToLinear(sceneSrgb(uv).rgb);
-}
-
-void main() {
- vec2 size = u_crt.viewport;
- vec2 texel = 1.0 / size;
- vec2 cc = gl_FragCoord.xy / size * 2.0 - 1.0;
- float r2 = dot(cc, cc);
- vec2 uv = cc * (1.0 + BARREL * r2 * u_crt.effects.x) * 0.5 + 0.5;
-
- // Ripple and glitch are useful without the CRT. They only move samples;
- // skip every tube-specific light/mask/exposure adjustment.
- if (u_crt.effects.x <= 0.5) {
- // The scene texture is already sRGB. Avoid a needless transfer-curve
- // round trip so a coordinate-only effect preserves theme bytes.
- vec4 s = sceneSrgb(uv);
- o_col = vec4(clamp(s.rgb, 0.0, 1.0), clamp(s.a, 0.0, 1.0));
- return;
- }
-
- vec3 col = vec3(0.0);
- // Outside the tube nothing is drawn, so nothing is covered either: the
- // old opaque black there would have punched a rectangle out of the
- // compositor's backdrop.
- float alpha = 0.0;
- float edge = min(min(uv.x, uv.y), min(1.0 - uv.x, 1.0 - uv.y));
- if (edge > 0.0) {
- vec3 center = effectSceneLinear(uv);
- vec2 chroma = cc * (CHROMA_PX * r2) * texel;
- // Each radial sample follows the same ripple/glitch path from its own
- // pre-warp coordinate. This is also how the Metal kernel composes the
- // effects; sampling a once-warped center here made combined effects
- // diverge between the two GUI backends.
- col = vec3(
- effectSceneLinear(uv + chroma).r,
- center.g,
- effectSceneLinear(uv - chroma).b
- );
-
- vec2 bloom_px = texel * BLOOM_RADIUS_PX;
- vec3 bloom = brightPass(uv + vec2(bloom_px.x, 0.0)) +
- brightPass(uv - vec2(bloom_px.x, 0.0)) +
- brightPass(uv + vec2(0.0, bloom_px.y)) +
- brightPass(uv - vec2(0.0, bloom_px.y));
- col += bloom * (0.25 * BLOOM_STRENGTH);
-
- // The mask has unit mean and the scan loss stays small; neither needs
- // the old global lift that remapped every theme toward white.
- float scan_wave = 0.5 + 0.5 * cos(gl_FragCoord.y * TAU / SCAN_PERIOD_PX);
- col *= 1.0 - SCAN_STRENGTH * scan_wave * scan_wave;
- vec3 mask = 1.0 + MASK_STRENGTH * cos(
- gl_FragCoord.x * TAU / MASK_PERIOD_PX + vec3(0.0, TAU / 3.0, 2.0 * TAU / 3.0)
- );
- col *= mask;
-
- vec2 edge_px = min(uv, 1.0 - uv) * size;
- float tube = smoothstep(0.0, EDGE_SOFTNESS_PX, min(edge_px.x, edge_px.y));
- float vignette = 1.0 - VIGNETTE_STRENGTH * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6);
- float hum = 1.0 + HUM_STRENGTH * sin((uv.y * 1.35 - u_crt.time_seconds * 0.11) * TAU);
- col *= tube * vignette * hum * EXPOSURE;
- // The tube's edge falloff dims coverage as well as colour; everything
- // else here is a light adjustment and leaves it alone.
- alpha = sceneSrgb(uv).a * tube;
-
- float luma = dot(col, vec3(0.2126, 0.7152, 0.0722));
- vec2 noise_seed = gl_FragCoord.xy + vec2(u_crt.frame * 17.0, u_crt.frame * 59.0);
- col += (noiseAt(noise_seed) - 0.5) * NOISE_STRENGTH * (0.15 + 0.85 * clamp(luma, 0.0, 1.0));
- }
- o_col = vec4(linearToSrgb(clamp(col, 0.0, 1.0)), clamp(alpha, 0.0, 1.0));
-}
diff --git a/shaders/prebuilt/crt.frag.spv b/shaders/prebuilt/crt.frag.spv
deleted file mode 100644
index f0e14627..00000000
--- a/shaders/prebuilt/crt.frag.spv
+++ /dev/null
Binary files differ
diff --git a/shaders/prebuilt/crt.vert.glsl b/shaders/prebuilt/crt.vert.glsl
deleted file mode 100644
index 4e15fcdf..00000000
--- a/shaders/prebuilt/crt.vert.glsl
+++ /dev/null
@@ -1,9 +0,0 @@
-#version 450
-
-// CRT post-process pass: one fullscreen triangle from gl_VertexIndex, no
-// vertex buffer. The fragment shader derives uv from gl_FragCoord.
-
-void main() {
- vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2));
- gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
-}
diff --git a/shaders/prebuilt/post-crt.frag.glsl b/shaders/prebuilt/post-crt.frag.glsl
new file mode 100644
index 00000000..11b95272
--- /dev/null
+++ b/shaders/prebuilt/post-crt.frag.glsl
@@ -0,0 +1,146 @@
+#version 450
+// pardes's Shadertoy prefix (docs/render-pipeline.md §6), put before every
+// post pass: a bundled one in shaders/post/ at build time, a user's file at
+// run time. The uniform names, types and std140 order are ghostty 1.3.2's
+// (src/renderer/shaders/shadertoy_prefix.glsl), so a shader written for
+// ghostty runs here; only the bindings are SDL GPU's (a fragment shader's
+// textures in set 2, its uniform blocks in set 3). Y is down everywhere, as
+// on ghostty's Metal renderer: fragCoord, iChannel0's uv and the cursor
+// rectangles share one top-left space, and iCurrentCursor is (left x,
+// BOTTOM-edge y, width, height) in framebuffer pixels.
+
+layout(set = 3, binding = 0, std140) uniform Globals {
+ uniform vec3 iResolution;
+ uniform float iTime;
+ uniform float iTimeDelta;
+ uniform float iFrameRate;
+ uniform int iFrame;
+ uniform float iChannelTime[4];
+ uniform vec3 iChannelResolution[4];
+ uniform vec4 iMouse;
+ uniform vec4 iDate;
+ uniform float iSampleRate;
+ uniform vec4 iCurrentCursor;
+ uniform vec4 iPreviousCursor;
+ uniform vec4 iCurrentCursorColor;
+ uniform vec4 iPreviousCursorColor;
+ uniform int iCurrentCursorStyle;
+ uniform int iPreviousCursorStyle;
+ uniform int iCursorVisible;
+ uniform float iTimeCursorChange;
+ uniform float iTimeFocus;
+ uniform int iFocus;
+ uniform vec3 iPalette[256];
+ uniform vec3 iBackgroundColor;
+ uniform vec3 iForegroundColor;
+ uniform vec3 iCursorColor;
+ uniform vec3 iCursorText;
+ uniform vec3 iSelectionForegroundColor;
+ uniform vec3 iSelectionBackgroundColor;
+};
+
+// pardes's own: 1 when the window is opaque, so a pass cannot punch holes
+// in it through its alpha.
+layout(set = 3, binding = 1, std140) uniform PardesPost {
+ float pardesOpaque;
+};
+
+#define CURSORSTYLE_BLOCK 0
+#define CURSORSTYLE_BLOCK_HOLLOW 1
+#define CURSORSTYLE_BAR 2
+#define CURSORSTYLE_UNDERLINE 3
+#define CURSORSTYLE_LOCK 4
+
+layout(set = 2, binding = 0) uniform sampler2D iChannel0;
+
+layout(location = 0) out vec4 _fragColor;
+
+#define texture2D texture
+
+void mainImage(out vec4 fragColor, in vec2 fragCoord);
+void main() {
+ mainImage(_fragColor, gl_FragCoord.xy);
+ if (pardesOpaque > 0.5) _fragColor.a = 1.0;
+}
+
+// The shader's own lines count from 1, so glslc's errors name them.
+#line 1
+// Crt: a slot-mask tube's light, with nothing that moves a pixel. There is
+// no barrel: every click lands where it is drawn, rails and grips at the
+// window's edges included. What is left is light: a beam profile for the
+// scanlines, an aperture mask, a soft bloom of the brightest ink, a faint
+// vignette and the hum of a live tube. Everything is worked out in linear
+// light and dithered back into 8 bits, so the vignette never bands. It is a
+// Shadertoy file: pardes puts its prefix before it (shaders/post/prefix.glsl).
+
+const float TAU = 6.28318530718;
+const float CHROMA_PX = 0.35; // red and blue apart, at the corners
+const float BLOOM_RADIUS_PX = 1.75;
+const float BLOOM_THRESHOLD = 0.60; // linear-light channel peak
+const float BLOOM_SOFTNESS = 0.25;
+const float BLOOM_STRENGTH = 0.05;
+const float SCAN_PERIOD_PX = 3.0;
+const float SCAN_STRENGTH = 0.07;
+const float MASK_PERIOD_PX = 3.0;
+const float MASK_STRENGTH = 0.018;
+const float VIGNETTE_STRENGTH = 0.12; // at the far corners only
+const float HUM_STRENGTH = 0.004;
+const float HUM_HZ = 0.11;
+const float EXPOSURE = 1.015;
+
+vec3 toLinear(vec3 c) {
+ return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045)));
+}
+
+vec3 toSrgb(vec3 c) {
+ c = max(c, vec3(0.0));
+ return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308)));
+}
+
+vec3 scene(vec2 uv) {
+ return toLinear(texture(iChannel0, uv).rgb);
+}
+
+vec3 brightPass(vec2 uv) {
+ vec3 c = scene(uv);
+ float peak = max(c.r, max(c.g, c.b));
+ return c * smoothstep(BLOOM_THRESHOLD, BLOOM_THRESHOLD + BLOOM_SOFTNESS, peak);
+}
+
+// Interleaved gradient noise: a dither with no visible pattern.
+float ign(vec2 p) {
+ return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715))));
+}
+
+void mainImage(out vec4 fragColor, in vec2 fragCoord) {
+ vec2 size = iResolution.xy;
+ vec2 texel = 1.0 / size;
+ vec2 uv = fragCoord / size;
+ vec2 cc = uv * 2.0 - 1.0;
+ float r2 = dot(cc, cc);
+
+ // The beam converges least at the corners: red and blue drift apart
+ // there, by a third of a pixel.
+ vec2 chroma = cc * (CHROMA_PX * r2 * 0.5) * texel;
+ vec3 col = vec3(scene(uv + chroma).r, scene(uv).g, scene(uv - chroma).b);
+
+ vec2 b = texel * BLOOM_RADIUS_PX;
+ vec3 bloom = brightPass(uv + vec2(b.x, 0.0)) + brightPass(uv - vec2(b.x, 0.0)) +
+ brightPass(uv + vec2(0.0, b.y)) + brightPass(uv - vec2(0.0, b.y));
+ col += bloom * (0.25 * BLOOM_STRENGTH);
+
+ // A beam is brightest on its line and falls off between lines; the
+ // mask is three phosphor stripes a triad. Both average to one, so the
+ // theme keeps its brightness.
+ float line = 0.5 + 0.5 * cos(fragCoord.y * TAU / SCAN_PERIOD_PX);
+ col *= 1.0 - SCAN_STRENGTH * (line * line - 0.375) / 0.625;
+ col *= 1.0 + MASK_STRENGTH * cos(fragCoord.x * TAU / MASK_PERIOD_PX + vec3(0.0, TAU / 3.0, 2.0 * TAU / 3.0));
+
+ float vignette = 1.0 - VIGNETTE_STRENGTH * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6);
+ float hum = 1.0 + HUM_STRENGTH * sin((uv.y * 1.35 - iTime * HUM_HZ) * TAU);
+ col *= vignette * hum * EXPOSURE;
+
+ vec3 srgb = toSrgb(col);
+ srgb += (ign(fragCoord + float(iFrame % 64) * vec2(5.588238, 5.588238)) - 0.5) / 255.0;
+ fragColor = vec4(clamp(srgb, 0.0, 1.0), texture(iChannel0, uv).a);
+}
diff --git a/shaders/prebuilt/post-crt.frag.spv b/shaders/prebuilt/post-crt.frag.spv
new file mode 100644
index 00000000..e4d98138
--- /dev/null
+++ b/shaders/prebuilt/post-crt.frag.spv
Binary files differ
diff --git a/shaders/prebuilt/post-glitch.frag.glsl b/shaders/prebuilt/post-glitch.frag.glsl
new file mode 100644
index 00000000..46538452
--- /dev/null
+++ b/shaders/prebuilt/post-glitch.frag.glsl
@@ -0,0 +1,127 @@
+#version 450
+// pardes's Shadertoy prefix (docs/render-pipeline.md §6), put before every
+// post pass: a bundled one in shaders/post/ at build time, a user's file at
+// run time. The uniform names, types and std140 order are ghostty 1.3.2's
+// (src/renderer/shaders/shadertoy_prefix.glsl), so a shader written for
+// ghostty runs here; only the bindings are SDL GPU's (a fragment shader's
+// textures in set 2, its uniform blocks in set 3). Y is down everywhere, as
+// on ghostty's Metal renderer: fragCoord, iChannel0's uv and the cursor
+// rectangles share one top-left space, and iCurrentCursor is (left x,
+// BOTTOM-edge y, width, height) in framebuffer pixels.
+
+layout(set = 3, binding = 0, std140) uniform Globals {
+ uniform vec3 iResolution;
+ uniform float iTime;
+ uniform float iTimeDelta;
+ uniform float iFrameRate;
+ uniform int iFrame;
+ uniform float iChannelTime[4];
+ uniform vec3 iChannelResolution[4];
+ uniform vec4 iMouse;
+ uniform vec4 iDate;
+ uniform float iSampleRate;
+ uniform vec4 iCurrentCursor;
+ uniform vec4 iPreviousCursor;
+ uniform vec4 iCurrentCursorColor;
+ uniform vec4 iPreviousCursorColor;
+ uniform int iCurrentCursorStyle;
+ uniform int iPreviousCursorStyle;
+ uniform int iCursorVisible;
+ uniform float iTimeCursorChange;
+ uniform float iTimeFocus;
+ uniform int iFocus;
+ uniform vec3 iPalette[256];
+ uniform vec3 iBackgroundColor;
+ uniform vec3 iForegroundColor;
+ uniform vec3 iCursorColor;
+ uniform vec3 iCursorText;
+ uniform vec3 iSelectionForegroundColor;
+ uniform vec3 iSelectionBackgroundColor;
+};
+
+// pardes's own: 1 when the window is opaque, so a pass cannot punch holes
+// in it through its alpha.
+layout(set = 3, binding = 1, std140) uniform PardesPost {
+ float pardesOpaque;
+};
+
+#define CURSORSTYLE_BLOCK 0
+#define CURSORSTYLE_BLOCK_HOLLOW 1
+#define CURSORSTYLE_BAR 2
+#define CURSORSTYLE_UNDERLINE 3
+#define CURSORSTYLE_LOCK 4
+
+layout(set = 2, binding = 0) uniform sampler2D iChannel0;
+
+layout(location = 0) out vec4 _fragColor;
+
+#define texture2D texture
+
+void mainImage(out vec4 fragColor, in vec2 fragCoord);
+void main() {
+ mainImage(_fragColor, gl_FragCoord.xy);
+ if (pardesOpaque > 0.5) _fragColor.a = 1.0;
+}
+
+// The shader's own lines count from 1, so glslc's errors name them.
+#line 1
+// Glitch: now and then the signal slips. Every couple of seconds a short
+// burst tears a few horizontal bands sideways and pulls their red and blue
+// apart; the bands are sized in pixels and the burst eases in and out over
+// a fraction of a second, so it reads as one event rather than a flicker,
+// and it looks the same at 60 or 144 Hz because every step is keyed to
+// iTime, not to frames. Between bursts the page is left exactly as it is.
+// A torn band's brightness dips in linear light. It is a Shadertoy file:
+// pardes puts its prefix before it.
+
+const float PERIOD = 2.4; // seconds between bursts, on average
+const float BURST = 0.28; // seconds a burst lasts
+const float STEP_HZ = 24.0; // how often a burst re-tears
+const float SHIFT_PX = 14.0;
+const float SPLIT_PX = 2.5;
+const float DIP = 0.12;
+
+float hash(vec2 p) {
+ vec3 q = fract(vec3(p.xyx) * 0.1031);
+ q += dot(q, q.yzx + 33.33);
+ return fract((q.x + q.y) * q.z);
+}
+
+vec3 toLinear(vec3 c) {
+ return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045)));
+}
+
+vec3 toSrgb(vec3 c) {
+ c = max(c, vec3(0.0));
+ return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308)));
+}
+
+void mainImage(out vec4 fragColor, in vec2 fragCoord) {
+ vec2 size = iResolution.xy;
+ vec2 uv = fragCoord / size;
+ // Which burst window this is, when in it the burst starts, and how far
+ // into the burst we are: 0 outside one.
+ float window = floor(iTime / PERIOD);
+ float start = window * PERIOD + hash(vec2(window, 7.0)) * (PERIOD - BURST);
+ float t = (iTime - start) / BURST;
+ float live = step(0.0, t) * step(t, 1.0);
+ float envelope = live * sin(3.14159265 * clamp(t, 0.0, 1.0));
+ if (envelope <= 0.0) {
+ fragColor = texture(iChannel0, uv);
+ return;
+ }
+ // Bands of 6 to 40 px, re-cut at STEP_HZ during the burst; about one
+ // band in four tears.
+ float tick = floor(iTime * STEP_HZ);
+ float band_px = mix(6.0, 40.0, hash(vec2(tick, 3.0)));
+ float band = floor(fragCoord.y / band_px);
+ float roll = hash(vec2(band, tick));
+ float tear = smoothstep(0.72, 0.9, roll) * envelope;
+ float shift = (hash(vec2(tick, band + 11.0)) - 0.5) * 2.0 * SHIFT_PX * tear;
+ vec2 at = vec2(fragCoord.x + shift, fragCoord.y);
+ vec2 split = vec2(SPLIT_PX * tear, 0.0);
+ vec4 center = texture(iChannel0, at / size);
+ vec3 col = vec3(texture(iChannel0, (at + split) / size).r, center.g, texture(iChannel0, (at - split) / size).b);
+ col = toSrgb(toLinear(col) * (1.0 - DIP * tear));
+ fragColor = vec4(col, center.a);
+}
diff --git a/shaders/prebuilt/post-glitch.frag.spv b/shaders/prebuilt/post-glitch.frag.spv
new file mode 100644
index 00000000..8a224958
--- /dev/null
+++ b/shaders/prebuilt/post-glitch.frag.spv
Binary files differ
diff --git a/shaders/prebuilt/post-ripple.frag.glsl b/shaders/prebuilt/post-ripple.frag.glsl
new file mode 100644
index 00000000..28059def
--- /dev/null
+++ b/shaders/prebuilt/post-ripple.frag.glsl
@@ -0,0 +1,116 @@
+#version 450
+// pardes's Shadertoy prefix (docs/render-pipeline.md §6), put before every
+// post pass: a bundled one in shaders/post/ at build time, a user's file at
+// run time. The uniform names, types and std140 order are ghostty 1.3.2's
+// (src/renderer/shaders/shadertoy_prefix.glsl), so a shader written for
+// ghostty runs here; only the bindings are SDL GPU's (a fragment shader's
+// textures in set 2, its uniform blocks in set 3). Y is down everywhere, as
+// on ghostty's Metal renderer: fragCoord, iChannel0's uv and the cursor
+// rectangles share one top-left space, and iCurrentCursor is (left x,
+// BOTTOM-edge y, width, height) in framebuffer pixels.
+
+layout(set = 3, binding = 0, std140) uniform Globals {
+ uniform vec3 iResolution;
+ uniform float iTime;
+ uniform float iTimeDelta;
+ uniform float iFrameRate;
+ uniform int iFrame;
+ uniform float iChannelTime[4];
+ uniform vec3 iChannelResolution[4];
+ uniform vec4 iMouse;
+ uniform vec4 iDate;
+ uniform float iSampleRate;
+ uniform vec4 iCurrentCursor;
+ uniform vec4 iPreviousCursor;
+ uniform vec4 iCurrentCursorColor;
+ uniform vec4 iPreviousCursorColor;
+ uniform int iCurrentCursorStyle;
+ uniform int iPreviousCursorStyle;
+ uniform int iCursorVisible;
+ uniform float iTimeCursorChange;
+ uniform float iTimeFocus;
+ uniform int iFocus;
+ uniform vec3 iPalette[256];
+ uniform vec3 iBackgroundColor;
+ uniform vec3 iForegroundColor;
+ uniform vec3 iCursorColor;
+ uniform vec3 iCursorText;
+ uniform vec3 iSelectionForegroundColor;
+ uniform vec3 iSelectionBackgroundColor;
+};
+
+// pardes's own: 1 when the window is opaque, so a pass cannot punch holes
+// in it through its alpha.
+layout(set = 3, binding = 1, std140) uniform PardesPost {
+ float pardesOpaque;
+};
+
+#define CURSORSTYLE_BLOCK 0
+#define CURSORSTYLE_BLOCK_HOLLOW 1
+#define CURSORSTYLE_BAR 2
+#define CURSORSTYLE_UNDERLINE 3
+#define CURSORSTYLE_LOCK 4
+
+layout(set = 2, binding = 0) uniform sampler2D iChannel0;
+
+layout(location = 0) out vec4 _fragColor;
+
+#define texture2D texture
+
+void mainImage(out vec4 fragColor, in vec2 fragCoord);
+void main() {
+ mainImage(_fragColor, gl_FragCoord.xy);
+ if (pardesOpaque > 0.5) _fragColor.a = 1.0;
+}
+
+// The shader's own lines count from 1, so glslc's errors name them.
+#line 1
+// Ripple: the page as a still pool a slow swell crosses. Rings travel out
+// from the middle of the window at a fixed speed in pixels, so they look
+// the same at any size and frame rate; the displacement is at most a couple
+// of pixels, so text stays readable, and it eases in over its first half
+// second instead of snapping on. Each ring catches the light a little on its
+// rising side, in linear light, and the result is dithered so the shading
+// never bands. It is a Shadertoy file: pardes puts its prefix before it.
+
+const float TAU = 6.28318530718;
+const float WAVELENGTH_PX = 150.0;
+const float SPEED_PX = 55.0; // outward, per second
+const float AMPLITUDE_PX = 2.2;
+const float SHADE = 0.035; // light on a slope, linear
+const float EASE_IN = 0.5; // seconds
+
+vec3 toLinear(vec3 c) {
+ return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045)));
+}
+
+vec3 toSrgb(vec3 c) {
+ c = max(c, vec3(0.0));
+ return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308)));
+}
+
+float ign(vec2 p) {
+ return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715))));
+}
+
+void mainImage(out vec4 fragColor, in vec2 fragCoord) {
+ vec2 size = iResolution.xy;
+ vec2 center = 0.5 * size;
+ vec2 offset = fragCoord - center;
+ float radius = length(offset);
+ float reach = 0.5 * length(size);
+ // Strongest a third of the way out, fading to nothing at the middle
+ // and at the edges, so the window's frame never wobbles.
+ float envelope = smoothstep(0.0, 0.18, radius / reach) * (1.0 - smoothstep(0.35, 0.95, radius / reach));
+ float ease = smoothstep(0.0, EASE_IN, iTime);
+ float phase = (radius - iTime * SPEED_PX) * TAU / WAVELENGTH_PX;
+ float height = sin(phase);
+ float slope = cos(phase);
+ vec2 dir = offset / max(radius, 1e-3);
+ vec2 moved = fragCoord + dir * height * AMPLITUDE_PX * envelope * ease;
+
+ vec4 s = texture(iChannel0, moved / size);
+ vec3 col = toLinear(s.rgb) * (1.0 + SHADE * slope * envelope * ease);
+ vec3 srgb = toSrgb(col) + (ign(fragCoord + float(iFrame % 64) * 5.588238) - 0.5) / 255.0;
+ fragColor = vec4(clamp(srgb, 0.0, 1.0), s.a);
+}
diff --git a/shaders/prebuilt/post-ripple.frag.spv b/shaders/prebuilt/post-ripple.frag.spv
new file mode 100644
index 00000000..1a105a33
--- /dev/null
+++ b/shaders/prebuilt/post-ripple.frag.spv
Binary files differ
diff --git a/shaders/prebuilt/post.vert.glsl b/shaders/prebuilt/post.vert.glsl
new file mode 100644
index 00000000..86739a65
--- /dev/null
+++ b/shaders/prebuilt/post.vert.glsl
@@ -0,0 +1,10 @@
+#version 450
+
+// A post pass (docs/render-pipeline.md §6): one fullscreen triangle from
+// gl_VertexIndex, no vertex buffer. The Shadertoy fragment shader works from
+// gl_FragCoord, y down, as its fragCoord.
+
+void main() {
+ vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2));
+ gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
+}
diff --git a/shaders/prebuilt/crt.vert.spv b/shaders/prebuilt/post.vert.spv
index 8a75ebb3..8a75ebb3 100644
--- a/shaders/prebuilt/crt.vert.spv
+++ b/shaders/prebuilt/post.vert.spv
Binary files differ
diff --git a/src/animation.zig b/src/animation.zig
index d6ef1ec3..948a6679 100644
--- a/src/animation.zig
+++ b/src/animation.zig
@@ -77,12 +77,6 @@ pub const Transition = enum(u8) {
}
};
-pub const SceneEffect = struct {
- crt: bool = false,
- ripple: bool = false,
- glitch: bool = false,
-};
-
pub const Phase = enum(u8) {
opening = 0,
moving = 1,
diff --git a/src/builtins.zig b/src/builtins.zig
index 6bfc9b26..1864086f 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -156,7 +156,7 @@ pub const registry = struct {
if (@intFromEnum(b) == i) return switch (setting.action) {
// a switch flips bare, and DumpDir bare is the default
.toggle, .transition, .scene, .dump_dir => false,
- .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms => true,
+ .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .shader_animation => true,
};
unreachable;
}
diff --git a/src/config.zig b/src/config.zig
index 122c0e75..1d4043ab 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -131,6 +131,8 @@ pub const leader_path = paths: {
table.set(.Crt, "tr");
table.set(.Ripple, "tR");
table.set(.Glitch, "tg");
+ table.set(.Shader, null);
+ table.set(.ShaderAnimation, null);
}
if (builtins.EffectCode.enabled) table.set(.EffectCode, null);
if (builtins.TreeContext.enabled) table.set(.TreeContext, null);
@@ -611,7 +613,64 @@ pub const Runtime = struct {
} = .{},
panel_transition: animation.Transition = .off,
- scene_effects: animation.SceneEffect = .{},
+ /// The post chain (docs/render-pipeline.md §6), in the order it runs.
+ post: Post = .{},
+ /// When the chain redraws on its own, past the frames it post-processes:
+ /// never, while the window has the focus, or always (ghostty's
+ /// custom-shader-animation).
+ shader_animation: ShaderAnimation = .on,
+
+ pub const ShaderAnimation = enum { off, on, always };
+
+ /// Each pass a bundled effect (Crt, Ripple, Glitch) or a Shadertoy file.
+ pub const Post = struct {
+ pub const max = 8;
+ pub const Entry = struct {
+ scene: ?Scene = null,
+ path: Text(limits.host_path_cap) = .{},
+ };
+ entries: [max]Entry = @splat(.{}),
+ len: u8 = 0,
+ /// Bumped on every change: a shell compiles the chain again when it
+ /// moves.
+ generation: u32 = 0,
+
+ pub fn list(post: *const Post) []const Entry {
+ return post.entries[0..post.len];
+ }
+
+ pub fn has(post: *const Post, scene: Scene) bool {
+ for (post.list()) |entry| if (entry.scene == scene) return true;
+ return false;
+ }
+
+ /// Puts the pass in the chain, at its end, or takes it out; `want`
+ /// null flips it. False when the chain is full or the path too long.
+ pub fn set(post: *Post, scene: ?Scene, path: []const u8, want: ?bool) bool {
+ const at: ?usize = for (post.list(), 0..) |entry, i| {
+ if (entry.scene == scene and std.mem.eql(u8, entry.path.get(), path)) break i;
+ } else null;
+ const on = want orelse (at == null);
+ if (on == (at != null)) return true;
+ post.generation +%= 1;
+ if (at) |i| {
+ std.mem.copyForwards(Entry, post.entries[i .. post.len - 1], post.entries[i + 1 .. post.len]);
+ post.len -= 1;
+ return true;
+ }
+ if (post.len == max) return false;
+ post.entries[post.len] = .{ .scene = scene };
+ if (!post.entries[post.len].path.set(path)) return false;
+ post.len += 1;
+ return true;
+ }
+
+ pub fn clear(post: *Post) void {
+ if (post.len == 0) return;
+ post.len = 0;
+ post.generation +%= 1;
+ }
+ };
pub fn toggleTransition(state: *Runtime, effect: animation.Transition) void {
std.debug.assert(effect != .off);
@@ -690,7 +749,8 @@ pub const Runtime = struct {
pub const MessageTime = enum { linger, fall, dissolve };
pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, column_tags, syntax_bold, verbose, message_animation, ligatures };
- pub const Scene = std.meta.FieldEnum(animation.SceneEffect);
+ /// The bundled post passes, each a Shadertoy file in shaders/post/.
+ pub const Scene = enum { crt, ripple, glitch };
pub const Action = union(enum) {
toggle: Toggle,
@@ -704,6 +764,10 @@ pub const Runtime = struct {
dump_dir,
transition: animation.Transition,
scene: Scene,
+ /// A Shadertoy file into the post chain or out of it; `off` empties
+ /// it of files.
+ shader,
+ shader_animation,
};
pub const Setting = struct {
@@ -756,6 +820,8 @@ pub const Runtime = struct {
.{ .word = "Crt", .action = .{ .scene = .crt }, .availability = .scene_shaders },
.{ .word = "Ripple", .action = .{ .scene = .ripple }, .availability = .scene_shaders },
.{ .word = "Glitch", .action = .{ .scene = .glitch }, .availability = .scene_shaders },
+ .{ .word = "Shader", .action = .shader, .availability = .scene_shaders },
+ .{ .word = "ShaderAnimation", .action = .shader_animation, .availability = .scene_shaders },
};
pub fn find(name: []const u8) ?Setting {
@@ -789,7 +855,7 @@ pub const Runtime = struct {
@compileError("window blur must use the window-blur capability"),
.transition => if (setting.availability != .panel_transitions)
@compileError("panel effects must use the panel-transition capability"),
- .scene => if (setting.availability != .scene_shaders)
+ .scene, .shader, .shader_animation => if (setting.availability != .scene_shaders)
@compileError("scene effects must use the scene-shader capability"),
// A toggle is plain state any shell can hold, except one for
// what only some shells draw: that one exists where they do.
@@ -815,9 +881,9 @@ pub const Runtime = struct {
for (std.enums.values(Scene)) |effect| {
if (actionCount(.{ .scene = effect }) != 1)
@compileError("scene effect must occur exactly once: " ++ @tagName(effect));
- if (@FieldType(animation.SceneEffect, @tagName(effect)) != bool)
- @compileError("scene effect fields must be booleans: " ++ @tagName(effect));
}
+ if (actionCount(.shader) != 1 or actionCount(.shader_animation) != 1)
+ @compileError("Shader and ShaderAnimation must each occur exactly once");
}
pub fn apply(state: *Runtime, setting: Setting, argument: ?[]const u8) bool {
@@ -884,12 +950,24 @@ pub const Runtime = struct {
},
.scene => |effect| {
const want = switchedTo(argument) catch return false;
- switch (effect) {
- inline else => |field| {
- const value = &@field(state.scene_effects, @tagName(field));
- value.* = want orelse !value.*;
- },
+ return state.post.set(effect, "", want);
+ },
+ .shader => {
+ const path = std.mem.trim(u8, argument orelse return false, " \t\r\n");
+ if (path.len == 0) return false;
+ if (std.mem.eql(u8, path, "off")) {
+ var i: usize = 0;
+ while (i < state.post.len) {
+ const entry = state.post.entries[i];
+ if (entry.scene == null) _ = state.post.set(null, entry.path.get(), false) else i += 1;
+ }
+ return true;
}
+ return state.post.set(null, path, null);
+ },
+ .shader_animation => {
+ const text = std.mem.trim(u8, argument orelse return false, " \t\r\n");
+ state.shader_animation = std.meta.stringToEnum(ShaderAnimation, text) orelse return false;
},
.theme, .font => return false,
}
@@ -1041,11 +1119,24 @@ pub const Runtime = struct {
try out.print("{s} unsupported\n", .{setting.word});
continue;
}
- const enabled = switch (effect) {
- inline else => |field| @field(state.scene_effects, @tagName(field)),
+ try out.print("{s} {s}\n", .{ setting.word, onOff(state.post.has(effect)) });
+ },
+ .shader => {
+ if (!setting.enabled(context.capabilities)) {
+ try out.print("{s} unsupported\n", .{setting.word});
+ continue;
+ }
+ var files: usize = 0;
+ for (state.post.list()) |entry| if (entry.scene == null) {
+ try out.print("{s} {s}\n", .{ setting.word, entry.path.get() });
+ files += 1;
};
- try out.print("{s} {s}\n", .{ setting.word, onOff(enabled) });
+ if (files == 0) try out.print("{s} off\n", .{setting.word});
},
+ .shader_animation => if (setting.enabled(context.capabilities))
+ try out.print("{s} {s}\n", .{ setting.word, @tagName(state.shader_animation) })
+ else
+ try out.print("{s} unsupported\n", .{setting.word}),
};
if (context.startup_config_path) |path|
@@ -1092,9 +1183,7 @@ pub const Runtime = struct {
const scene = findAction(.{ .scene = std.enums.values(Scene)[0] }).?;
try std.testing.expect(apply(&state, scene, "on"));
try std.testing.expect(apply(&state, scene, "on"));
- switch (std.enums.values(Scene)[0]) {
- inline else => |f| try std.testing.expect(@field(state.scene_effects, @tagName(f))),
- }
+ try std.testing.expect(state.post.has(std.enums.values(Scene)[0]));
}
test "a toggle flips when bare and sets when told on or off" {
@@ -1131,7 +1220,30 @@ pub const Runtime = struct {
try std.testing.expect(apply(&state, find("PanelAscii").?, null));
try std.testing.expectEqual(animation.Transition.off, state.panel_transition);
try std.testing.expect(apply(&state, find("Crt").?, null));
- try std.testing.expect(state.scene_effects.crt);
+ try std.testing.expect(state.post.has(.crt));
+ }
+
+ test "the post chain runs in the order its passes were put in, and Shader off empties it of files" {
+ var state: Runtime = .{};
+ try std.testing.expect(apply(&state, find("Shader").?, " ~/a.glsl\n"));
+ try std.testing.expect(apply(&state, find("Glitch").?, "on"));
+ try std.testing.expect(apply(&state, find("Shader").?, "/b.glsl"));
+ const chain = state.post.list();
+ try std.testing.expectEqual(@as(usize, 3), chain.len);
+ try std.testing.expectEqualStrings("~/a.glsl", chain[0].path.get());
+ try std.testing.expectEqual(Scene.glitch, chain[1].scene.?);
+ try std.testing.expectEqualStrings("/b.glsl", chain[2].path.get());
+ const moved = state.post.generation;
+ // A file named again leaves; the others keep their order.
+ try std.testing.expect(apply(&state, find("Shader").?, "~/a.glsl"));
+ try std.testing.expect(state.post.generation != moved);
+ try std.testing.expectEqual(Scene.glitch, state.post.list()[0].scene.?);
+ try std.testing.expect(apply(&state, find("Shader").?, "off"));
+ try std.testing.expectEqual(@as(usize, 1), state.post.len);
+ try std.testing.expect(!apply(&state, find("Shader").?, null));
+ try std.testing.expect(apply(&state, find("ShaderAnimation").?, "always"));
+ try std.testing.expectEqual(ShaderAnimation.always, state.shader_animation);
+ try std.testing.expect(!apply(&state, find("ShaderAnimation").?, "sometimes"));
}
test "tagline size validates before mutating live state" {
@@ -1252,6 +1364,8 @@ pub const Runtime = struct {
.window_blur => "40",
.message_ms => "1500",
.dump_dir => "~/dumps",
+ .shader => "~/crt.glsl",
+ .shader_animation => "always",
else => null,
};
try std.testing.expect(apply(&state, setting, argument));
@@ -1302,6 +1416,8 @@ pub const Runtime = struct {
.ripple => "Ripple on\n",
.glitch => "Glitch on\n",
},
+ .shader => "Shader ~/crt.glsl\n",
+ .shader_animation => "ShaderAnimation always\n",
.theme, .font => unreachable,
};
try std.testing.expect(std.mem.indexOf(u8, report, expected) != null);
diff --git a/src/effect_sources.zig b/src/effect_sources.zig
index 08680860..6ce11160 100644
--- a/src/effect_sources.zig
+++ b/src/effect_sources.zig
@@ -11,8 +11,11 @@ const ui_vertex_path = shader_dir ++ "ui.vert.glsl";
const ui_fragment_path = shader_dir ++ "ui.frag.glsl";
const image_vertex_path = shader_dir ++ "image.vert.glsl";
const image_fragment_path = shader_dir ++ "image.frag.glsl";
-const scene_vertex_path = shader_dir ++ "crt.vert.glsl";
-const scene_fragment_path = shader_dir ++ "crt.frag.glsl";
+const post_vertex_path = shader_dir ++ "post.vert.glsl";
+// The bundled post passes as compiled: the prefix and the pass together.
+const post_crt_path = shader_dir ++ "post-crt.frag.glsl";
+const post_ripple_path = shader_dir ++ "post-ripple.frag.glsl";
+const post_glitch_path = shader_dir ++ "post-glitch.frag.glsl";
pub const Backend = @TypeOf(build_config.platform);
pub const backend: Backend = build_config.platform;
@@ -45,13 +48,15 @@ pub const files = switch (backend) {
},
.gui => [_]filesystem.Source{
.{ .path = "src/gui/gui.zig", .contents = @embedFile("gui/gui.zig") },
- .{ .path = "src/gui/crt.zig", .contents = @embedFile("gui/crt.zig") },
+ .{ .path = "src/gui/Post.zig", .contents = @embedFile("gui/Post.zig") },
.{ .path = ui_vertex_path, .contents = @embedFile("effect-source-ui.vert.glsl") },
.{ .path = ui_fragment_path, .contents = @embedFile("effect-source-ui.frag.glsl") },
.{ .path = image_vertex_path, .contents = @embedFile("effect-source-image.vert.glsl") },
.{ .path = image_fragment_path, .contents = @embedFile("effect-source-image.frag.glsl") },
- .{ .path = scene_vertex_path, .contents = @embedFile("effect-source-crt.vert.glsl") },
- .{ .path = scene_fragment_path, .contents = @embedFile("effect-source-crt.frag.glsl") },
+ .{ .path = post_vertex_path, .contents = @embedFile("effect-source-post.vert.glsl") },
+ .{ .path = post_crt_path, .contents = @embedFile("effect-source-post-crt.frag.glsl") },
+ .{ .path = post_ripple_path, .contents = @embedFile("effect-source-post-ripple.frag.glsl") },
+ .{ .path = post_glitch_path, .contents = @embedFile("effect-source-post-glitch.frag.glsl") },
},
.macos => [_]filesystem.Source{
.{ .path = "src/macos/Sources/PardesView.swift", .contents = @embedFile("macos/Sources/PardesView.swift") },
@@ -69,8 +74,12 @@ pub fn forSetting(setting: config.Runtime.Setting) ?[]const []const u8 {
.macos => &.{ "src/layout.zig", "src/pardes.zig", "src/macos/Sources/PardesView.swift", "src/macos/Sources/ScenePostprocessor.swift", "shaders/crt.ci.metal" },
.web, .esp32p4 => null,
},
- .scene => switch (backend) {
- .gui => &.{ "src/gui/crt.zig", "src/gui/gui.zig", scene_vertex_path, scene_fragment_path },
+ .scene => |effect| switch (backend) {
+ .gui => switch (effect) {
+ .crt => &.{ "src/gui/Post.zig", "src/gui/gui.zig", post_vertex_path, post_crt_path },
+ .ripple => &.{ "src/gui/Post.zig", "src/gui/gui.zig", post_vertex_path, post_ripple_path },
+ .glitch => &.{ "src/gui/Post.zig", "src/gui/gui.zig", post_vertex_path, post_glitch_path },
+ },
.macos => &.{ "src/macos/Sources/PardesView.swift", "src/macos/Sources/ScenePostprocessor.swift", "shaders/crt.ci.metal" },
.tty, .web, .esp32p4 => null,
},
diff --git a/src/gui/Post.zig b/src/gui/Post.zig
new file mode 100644
index 00000000..d92623d7
--- /dev/null
+++ b/src/gui/Post.zig
@@ -0,0 +1,559 @@
+//! The post chain (docs/render-pipeline.md §6): Shadertoy passes over the
+//! finished frame, each reading the one before it (the frame first) and the
+//! last one writing the window. A pass is bundled (Crt, Ripple, Glitch:
+//! shaders/post/, compiled with the build) or a user's file (Shader <path>),
+//! compiled when it joins the chain by the build's own glslc invocation on a
+//! thread of its own, never on the frame path. A file whose compile fails
+//! keeps its last good pipeline, and glslc missing is said once.
+//!
+//! The uniforms are ghostty's (Uniforms), so a shader written for ghostty runs
+//! here. Their clock is the shell's and moves only when the chain draws:
+//! with a frame of the core's, or on its own while ShaderAnimation asks for
+//! it (redraw level A, §5.5: the chain alone, over the retained frame).
+const std = @import("std");
+const libc = std.c;
+const ghostty_vt = @import("ghostty-vt");
+const pardes = @import("../pardes.zig");
+const filesystem = @import("../fs.zig");
+const host_io = @import("../host_io.zig");
+const gui = @import("gui.zig");
+const c = gui.c;
+
+const Post = @This();
+const Scene = pardes.config.Runtime.Scene;
+const Chain = pardes.config.Runtime.Post;
+const Pipeline = c.SDL_GPUGraphicsPipeline;
+pub const max = Chain.max;
+
+const prefix = @embedFile("post-prefix.glsl");
+const vert_spv = @embedFile("post.vert.spv");
+const bundled_spv = std.EnumArray(Scene, []const u8).init(.{
+ .crt = @embedFile("post-crt.frag.spv"),
+ .ripple = @embedFile("post-ripple.frag.spv"),
+ .glitch = @embedFile("post-glitch.frag.spv"),
+});
+
+/// ghostty's Shadertoy uniform block, member for member in the GLSL block's
+/// order and std140 layout (shaders/post/prefix.glsl). Filled by the GLSL
+/// names: ghostty's own Zig mirror calls the second-to-last slot
+/// selection_background, so there the two selection colours arrive swapped
+/// (docs §6, open point 1); here each is what its GLSL name says.
+pub const Uniforms = extern struct {
+ resolution: [3]f32 align(16) = .{ 0, 0, 1 },
+ time: f32 align(4) = 0,
+ time_delta: f32 align(4) = 0,
+ frame_rate: f32 align(4) = 60,
+ frame: i32 align(4) = 0,
+ channel_time: [4][4]f32 align(16) = @splat(@splat(0)),
+ channel_resolution: [4][4]f32 align(16) = @splat(@splat(0)),
+ mouse: [4]f32 align(16) = @splat(0),
+ date: [4]f32 align(16) = @splat(0),
+ sample_rate: f32 align(4) = 44100,
+ current_cursor: [4]f32 align(16) = @splat(0),
+ previous_cursor: [4]f32 align(16) = @splat(0),
+ current_cursor_color: [4]f32 align(16) = @splat(0),
+ previous_cursor_color: [4]f32 align(16) = @splat(0),
+ current_cursor_style: i32 align(4) = 0,
+ previous_cursor_style: i32 align(4) = 0,
+ cursor_visible: i32 align(4) = 0,
+ cursor_change_time: f32 align(4) = 0,
+ time_focus: f32 align(4) = 0,
+ focus: i32 align(4) = 1,
+ palette: [256][4]f32 align(16) = @splat(@splat(0)),
+ background_color: [4]f32 align(16) = @splat(0),
+ foreground_color: [4]f32 align(16) = @splat(0),
+ cursor_color: [4]f32 align(16) = @splat(0),
+ cursor_text: [4]f32 align(16) = @splat(0),
+ selection_foreground_color: [4]f32 align(16) = @splat(0),
+ selection_background_color: [4]f32 align(16) = @splat(0),
+};
+
+pub const Pass = struct {
+ scene: ?Scene = null,
+ /// A user's file, owned; empty for a bundled pass.
+ path: []u8 = &.{},
+ pipeline: ?*Pipeline = null,
+};
+
+/// One compile of a chain's new files, on its own thread.
+const Job = struct {
+ const Result = union(enum) { none, spirv: []u8, failed: []u8, missing };
+ io: std.Io,
+ gpa: std.mem.Allocator,
+ paths: [max][]u8 = @splat(&.{}),
+ results: [max]Result = @splat(.none),
+ len: usize = 0,
+ done: std.atomic.Value(bool) = .init(false),
+ thread: std.Thread = undefined,
+};
+
+passes: [max]Pass = @splat(.{}),
+len: usize = 0,
+/// The chain generation `passes` follow; null before the first.
+generation: ?u32 = null,
+bundled: std.EnumArray(Scene, ?*Pipeline) = .initFill(null),
+job: ?*Job = null,
+/// Said once: without glslc, files never compile.
+missing_said: bool = false,
+ping: [2]?*c.SDL_GPUTexture = .{ null, null },
+ping_w: u32 = 0,
+ping_h: u32 = 0,
+uniforms: Uniforms = .{},
+started_ns: ?u64 = null,
+last_ns: u64 = 0,
+
+/// Brings the passes to the chain the core holds, and takes in a finished
+/// compile. Called each loop step, off the frame path.
+pub fn sync(post: *Post, gpa: std.mem.Allocator, io: std.Io, device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, core: *pardes.Pardes) void {
+ if (post.job) |job| {
+ if (!job.done.load(.acquire)) return;
+ job.thread.join();
+ post.job = null;
+ post.collect(gpa, device, format, core, job);
+ }
+ const chain = &core.settings.post;
+ if (post.generation == chain.generation) return;
+ post.generation = chain.generation;
+ var next: [max]Pass = @splat(.{});
+ for (chain.list(), 0..) |entry, i| {
+ if (entry.scene) |scene| {
+ if (post.bundled.get(scene) == null) post.bundled.set(scene, makePipeline(device, format, bundled_spv.get(scene)) catch null);
+ next[i] = .{ .scene = scene, .pipeline = post.bundled.get(scene) };
+ continue;
+ }
+ // A file already in the chain keeps its pipeline and its path.
+ for (post.passes[0..post.len]) |*pass| {
+ if (pass.scene != null or !std.mem.eql(u8, pass.path, entry.path.get())) continue;
+ next[i] = pass.*;
+ pass.* = .{};
+ break;
+ } else next[i] = .{ .path = gpa.dupe(u8, entry.path.get()) catch &.{} };
+ }
+ for (post.passes[0..post.len]) |pass| {
+ if (pass.scene == null) if (pass.pipeline) |pipeline| c.SDL_ReleaseGPUGraphicsPipeline(device, pipeline);
+ gpa.free(pass.path);
+ }
+ post.passes = next;
+ post.len = chain.len;
+ // An emptied chain's clock starts again with the next pass put in it.
+ if (post.len == 0) {
+ post.started_ns = null;
+ post.last_ns = 0;
+ post.uniforms.frame = 0;
+ }
+ // The new files compile together, on a thread of their own.
+ const job = gpa.create(Job) catch return;
+ job.* = .{ .io = io, .gpa = gpa };
+ for (post.passes[0..post.len]) |pass| {
+ if (pass.scene != null or pass.pipeline != null or pass.path.len == 0) continue;
+ job.paths[job.len] = gpa.dupe(u8, pass.path) catch continue;
+ job.len += 1;
+ }
+ if (job.len == 0) return gpa.destroy(job);
+ job.thread = std.Thread.spawn(.{}, compile, .{job}) catch {
+ for (job.paths[0..job.len]) |path| gpa.free(path);
+ return gpa.destroy(job);
+ };
+ post.job = job;
+}
+
+fn collect(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, core: *pardes.Pardes, job: *Job) void {
+ defer gpa.destroy(job);
+ for (job.paths[0..job.len], job.results[0..job.len]) |path, result| {
+ defer gpa.free(path);
+ const pass = for (post.passes[0..post.len]) |*pass| {
+ if (pass.scene == null and std.mem.eql(u8, pass.path, path)) break pass;
+ } else null;
+ var buf: [512]u8 = undefined;
+ switch (result) {
+ .none => {},
+ .missing => if (!post.missing_said) {
+ post.missing_said = true;
+ core.setMessage(core.active, "Shader: glslc is not on PATH, so Shadertoy files do not run (Crt, Ripple and Glitch do)");
+ },
+ .failed => |text| {
+ defer gpa.free(text);
+ // glslc's first line says where: in the file's own lines.
+ const first = std.mem.sliceTo(text, '\n');
+ core.setMessage(core.active, std.fmt.bufPrint(&buf, "Shader {s}: {s}", .{ path, first[0..@min(first.len, 400)] }) catch "Shader: compile failed");
+ },
+ .spirv => |spirv| {
+ defer gpa.free(spirv);
+ const target = pass orelse continue;
+ const made = makePipeline(device, format, spirv) catch {
+ core.setMessage(core.active, std.fmt.bufPrint(&buf, "Shader {s}: the GPU refused it", .{path}) catch "Shader: the GPU refused it");
+ continue;
+ };
+ if (target.pipeline) |old| c.SDL_ReleaseGPUGraphicsPipeline(device, old);
+ target.pipeline = made;
+ },
+ }
+ }
+}
+
+/// The compile thread: each file behind the prefix, through glslc exactly as
+/// the build calls it (build.zig compileGlsl), its SPIR-V on stdout.
+fn compile(job: *Job) void {
+ defer job.done.store(true, .release);
+ defer {
+ var event = std.mem.zeroes(c.SDL_Event);
+ event.type = c.SDL_EVENT_USER;
+ _ = c.SDL_PushEvent(&event);
+ }
+ for (job.paths[0..job.len], 0..) |path, i| job.results[i] = compileOne(job.gpa, job.io, path, i);
+}
+
+fn compileOne(gpa: std.mem.Allocator, io: std.Io, path: []const u8, index: usize) Job.Result {
+ var home_buf: [4096]u8 = undefined;
+ const expanded = if (std.mem.startsWith(u8, path, "~/")) if (libc.getenv("HOME")) |home|
+ std.fmt.bufPrint(&home_buf, "{s}/{s}", .{ std.mem.span(home), path[2..] }) catch path
+ else
+ path else path;
+ const body = filesystem.readFile(gpa, expanded) catch |err|
+ return .{ .failed = std.fmt.allocPrint(gpa, "cannot read it ({t})", .{err}) catch return .none };
+ defer gpa.free(body);
+ const dir = if (libc.getenv("XDG_RUNTIME_DIR")) |d| std.mem.span(d) else "/tmp";
+ var source_buf: [4096]u8 = undefined;
+ const source = std.fmt.bufPrintSentinel(&source_buf, "{s}/pardes-post-{d}-{d}.frag.glsl", .{ dir, libc.getpid(), index }, 0) catch return .none;
+ defer _ = libc.unlink(source);
+ {
+ const fd = libc.open(source, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o600));
+ if (fd < 0) return .{ .failed = gpa.dupe(u8, "cannot write its source for glslc") catch return .none };
+ defer _ = libc.close(fd);
+ if (!host_io.writeFd(fd, prefix) or !host_io.writeFd(fd, body))
+ return .{ .failed = gpa.dupe(u8, "cannot write its source for glslc") catch return .none };
+ }
+ const run = std.process.run(gpa, io, .{
+ .argv = &.{ "glslc", "-fshader-stage=fragment", source, "-o", "-" },
+ .stdout_limit = .limited(16 << 20),
+ .stderr_limit = .limited(64 << 10),
+ }) catch |err| return switch (err) {
+ error.FileNotFound => .missing,
+ else => .{ .failed = std.fmt.allocPrint(gpa, "glslc did not run ({t})", .{err}) catch return .none },
+ };
+ const clean = switch (run.term) {
+ .exited => |code| code == 0,
+ else => false,
+ };
+ if (clean) {
+ gpa.free(run.stderr);
+ return .{ .spirv = run.stdout };
+ }
+ gpa.free(run.stdout);
+ // glslc names the temporary source; the file is what a person knows.
+ defer gpa.free(run.stderr);
+ const named = std.mem.replaceOwned(u8, gpa, run.stderr, source, path) catch return .{ .failed = gpa.dupe(u8, "glslc failed") catch return .none };
+ return .{ .failed = named };
+}
+
+fn makePipeline(device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, frag: []const u8) !*Pipeline {
+ const vs = try gui.makeShader(device, vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0, 0);
+ defer c.SDL_ReleaseGPUShader(device, vs);
+ const fs = try gui.makeShader(device, frag, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 2);
+ defer c.SDL_ReleaseGPUShader(device, fs);
+ var target = c.SDL_GPUColorTargetDescription{ .format = format, .blend_state = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) };
+ var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo);
+ info.vertex_shader = vs;
+ info.fragment_shader = fs;
+ info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST;
+ info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL;
+ info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE;
+ info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1;
+ info.target_info = .{ .color_target_descriptions = &target, .num_color_targets = 1 };
+ return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate;
+}
+
+/// A pass is ready to draw.
+pub fn ready(post: *const Post) bool {
+ for (post.passes[0..post.len]) |pass| if (pass.pipeline != null) return true;
+ return false;
+}
+
+/// The chain redraws on its own (level A) as ShaderAnimation says.
+pub fn animating(post: *const Post, mode: pardes.config.Runtime.ShaderAnimation) bool {
+ return post.ready() and switch (mode) {
+ .off => false,
+ .on => post.uniforms.focus != 0,
+ .always => true,
+ };
+}
+
+/// What a frame of the core's tells the passes: where its cursor is drawn
+/// and in what, and the theme's colours.
+pub const Frame = struct {
+ pub const Cursor = struct { x0: f32, y0: f32, x1: f32, y1: f32, rgb: [3]u8, text: [3]u8, bar: bool };
+ cursor: ?Cursor,
+ background: [3]u8,
+ foreground: [3]u8,
+ selection_foreground: [3]u8,
+ selection_background: [3]u8,
+};
+
+fn rgba(rgb: [3]u8) [4]f32 {
+ return .{ @as(f32, @floatFromInt(rgb[0])) / 255, @as(f32, @floatFromInt(rgb[1])) / 255, @as(f32, @floatFromInt(rgb[2])) / 255, 1 };
+}
+
+/// Takes in a frame of the core's. The cursor's rectangle is (left, BOTTOM
+/// edge, width, height) in framebuffer pixels, y down; a move or a new
+/// colour makes the old one the previous and stamps the change.
+pub fn describe(post: *Post, frame: Frame) void {
+ const u = &post.uniforms;
+ u.background_color = rgba(frame.background);
+ u.foreground_color = rgba(frame.foreground);
+ u.selection_foreground_color = rgba(frame.selection_foreground);
+ u.selection_background_color = rgba(frame.selection_background);
+ if (u.palette[1][3] == 0) for (&u.palette, 0..) |*slot, i| {
+ const color = ghostty_vt.color.default[i];
+ slot.* = rgba(.{ color.r, color.g, color.b });
+ };
+ u.cursor_visible = @intFromBool(frame.cursor != null);
+ const cursor = frame.cursor orelse return;
+ const rect: [4]f32 = .{ cursor.x0, cursor.y1, cursor.x1 - cursor.x0, cursor.y1 - cursor.y0 };
+ const style: i32 = if (cursor.bar) 2 else 0;
+ const color = rgba(cursor.rgb);
+ u.cursor_color = color;
+ u.cursor_text = rgba(cursor.text);
+ if (std.meta.eql(rect, u.current_cursor) and std.meta.eql(color, u.current_cursor_color) and style == u.current_cursor_style) return;
+ u.previous_cursor = u.current_cursor;
+ u.previous_cursor_color = u.current_cursor_color;
+ u.previous_cursor_style = u.current_cursor_style;
+ u.current_cursor = rect;
+ u.current_cursor_color = color;
+ u.current_cursor_style = style;
+ u.cursor_change_time = u.time;
+}
+
+/// The window gained or lost the focus.
+pub fn focus(post: *Post, focused: bool) void {
+ if (focused and post.uniforms.focus == 0) post.uniforms.time_focus = post.uniforms.time;
+ post.uniforms.focus = @intFromBool(focused);
+}
+
+/// The pointer, in framebuffer pixels: Shadertoy's iMouse, xy where it is,
+/// zw where the last press was, negative once released.
+pub fn mouse(post: *Post, x: f32, y: f32, press: ?bool) void {
+ const m = &post.uniforms.mouse;
+ m[0] = x;
+ m[1] = y;
+ if (press) |down| {
+ if (down) {
+ m[2] = x;
+ m[3] = y;
+ } else {
+ m[2] = -@abs(m[2]);
+ m[3] = -@abs(m[3]);
+ }
+ }
+}
+
+/// Runs the passes over `source`, the finished frame, into `target`: each
+/// reads the one before, the ones between write two ping-pong textures that
+/// start cleared, and iTime and iFrame move by this one draw.
+pub fn draw(post: *Post, device: *c.SDL_GPUDevice, cmd: *c.SDL_GPUCommandBuffer, sampler: *c.SDL_GPUSampler, format: c.SDL_GPUTextureFormat, source: *c.SDL_GPUTexture, target: *c.SDL_GPUTexture, w: u32, h: u32, now_ns: u64, opaque_window: bool) !void {
+ var live: usize = 0;
+ for (post.passes[0..post.len]) |pass| live += @intFromBool(pass.pipeline != null);
+ if (live == 0) return;
+ if (live > 1 and (post.ping[0] == null or post.ping_w != w or post.ping_h != h)) {
+ for (&post.ping) |*texture| {
+ if (texture.*) |old| c.SDL_ReleaseGPUTexture(device, old);
+ var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo);
+ info.type = c.SDL_GPU_TEXTURETYPE_2D;
+ info.format = format;
+ info.usage = c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | c.SDL_GPU_TEXTUREUSAGE_SAMPLER;
+ info.width = w;
+ info.height = h;
+ info.layer_count_or_depth = 1;
+ info.num_levels = 1;
+ info.sample_count = c.SDL_GPU_SAMPLECOUNT_1;
+ texture.* = c.SDL_CreateGPUTexture(device, &info) orelse return error.GpuCreate;
+ }
+ post.ping_w = w;
+ post.ping_h = h;
+ }
+ const u = &post.uniforms;
+ const started = post.started_ns orelse now_ns;
+ post.started_ns = started;
+ const elapsed = @as(f64, @floatFromInt(now_ns -| started)) / std.time.ns_per_s;
+ const delta: f32 = if (post.last_ns == 0) 0 else @floatCast(@as(f64, @floatFromInt(now_ns -| post.last_ns)) / std.time.ns_per_s);
+ post.last_ns = now_ns;
+ u.time = @floatCast(elapsed);
+ u.time_delta = delta;
+ u.frame_rate = if (delta > 0) 1 / delta else 60;
+ u.resolution = .{ @floatFromInt(w), @floatFromInt(h), 1 };
+ u.channel_resolution[0] = .{ @floatFromInt(w), @floatFromInt(h), 1, 0 };
+ // UTC: the year, the month from 0, the day, the second of the day.
+ var wall: libc.timespec = undefined;
+ if (libc.clock_gettime(.REALTIME, &wall) != 0) wall.sec = 0;
+ const epoch: std.time.epoch.EpochSeconds = .{ .secs = @intCast(@max(0, wall.sec)) };
+ const day = epoch.getEpochDay().calculateYearDay();
+ const month = day.calculateMonthDay();
+ u.date = .{ @floatFromInt(day.year), @floatFromInt(month.month.numeric() - 1), @floatFromInt(month.day_index + 1), @floatFromInt(epoch.getDaySeconds().secs) };
+ const own: [4]f32 = .{ @floatFromInt(@intFromBool(opaque_window)), 0, 0, 0 };
+
+ var input = source;
+ var index: usize = 0;
+ for (post.passes[0..post.len]) |pass| {
+ const pipeline = pass.pipeline orelse continue;
+ index += 1;
+ const last = index == live;
+ const output = if (last) target else post.ping[index % 2].?;
+ var info = std.mem.zeroes(c.SDL_GPUColorTargetInfo);
+ info.texture = output;
+ info.load_op = if (last) c.SDL_GPU_LOADOP_DONT_CARE else c.SDL_GPU_LOADOP_CLEAR;
+ info.store_op = c.SDL_GPU_STOREOP_STORE;
+ const pass_rp = c.SDL_BeginGPURenderPass(cmd, &info, 1, null);
+ c.SDL_BindGPUGraphicsPipeline(pass_rp, pipeline);
+ const binding = c.SDL_GPUTextureSamplerBinding{ .texture = input, .sampler = sampler };
+ c.SDL_BindGPUFragmentSamplers(pass_rp, 0, &binding, 1);
+ c.SDL_PushGPUFragmentUniformData(cmd, 0, u, @sizeOf(Uniforms));
+ c.SDL_PushGPUFragmentUniformData(cmd, 1, &own, @sizeOf(@TypeOf(own)));
+ c.SDL_DrawGPUPrimitives(pass_rp, 3, 1, 0, 0);
+ c.SDL_EndGPURenderPass(pass_rp);
+ input = output;
+ }
+ u.frame +%= 1;
+}
+
+pub fn deinit(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice) void {
+ if (post.job) |job| {
+ job.thread.join();
+ for (job.paths[0..job.len], job.results[0..job.len]) |path, result| {
+ gpa.free(path);
+ switch (result) {
+ .spirv, .failed => |bytes| gpa.free(bytes),
+ .none, .missing => {},
+ }
+ }
+ gpa.destroy(job);
+ }
+ for (post.passes[0..post.len]) |pass| {
+ if (pass.scene == null) if (pass.pipeline) |pipeline| c.SDL_ReleaseGPUGraphicsPipeline(device, pipeline);
+ gpa.free(pass.path);
+ }
+ for (post.bundled.values) |pipeline| if (pipeline) |p| c.SDL_ReleaseGPUGraphicsPipeline(device, p);
+ for (post.ping) |texture| if (texture) |t| c.SDL_ReleaseGPUTexture(device, t);
+}
+
+/// ghostty 1.3.2's Uniforms (src/renderer/shadertoy.zig), copied: the offsets
+/// ours must match. Its last two names are swapped against its own GLSL.
+const GhosttyUniforms = extern struct {
+ resolution: [3]f32 align(16),
+ time: f32 align(4),
+ time_delta: f32 align(4),
+ frame_rate: f32 align(4),
+ frame: i32 align(4),
+ channel_time: [4][4]f32 align(16),
+ channel_resolution: [4][4]f32 align(16),
+ mouse: [4]f32 align(16),
+ date: [4]f32 align(16),
+ sample_rate: f32 align(4),
+ current_cursor: [4]f32 align(16),
+ previous_cursor: [4]f32 align(16),
+ current_cursor_color: [4]f32 align(16),
+ previous_cursor_color: [4]f32 align(16),
+ current_cursor_style: i32 align(4),
+ previous_cursor_style: i32 align(4),
+ cursor_visible: i32 align(4),
+ cursor_change_time: f32 align(4),
+ time_focus: f32 align(4),
+ focus: i32 align(4),
+ palette: [256][4]f32 align(16),
+ background_color: [4]f32 align(16),
+ foreground_color: [4]f32 align(16),
+ cursor_color: [4]f32 align(16),
+ cursor_text: [4]f32 align(16),
+ selection_background_color: [4]f32 align(16),
+ selection_foreground_color: [4]f32 align(16),
+};
+
+test "the uniform block is ghostty's, offset for offset, and the prefix names it in that order" {
+ const ours = @typeInfo(Uniforms).@"struct".fields;
+ const theirs = @typeInfo(GhosttyUniforms).@"struct".fields;
+ try std.testing.expectEqual(theirs.len, ours.len);
+ try std.testing.expectEqual(@sizeOf(GhosttyUniforms), @sizeOf(Uniforms));
+ inline for (ours, theirs) |a, b| {
+ try std.testing.expectEqual(@offsetOf(GhosttyUniforms, b.name), @offsetOf(Uniforms, a.name));
+ try std.testing.expectEqual(b.type, a.type);
+ }
+ // By NAME: iSelectionForegroundColor sits where ghostty's Zig says
+ // selection_background, which is where its GLSL puts the foreground.
+ try std.testing.expectEqual(@offsetOf(GhosttyUniforms, "selection_background_color"), @offsetOf(Uniforms, "selection_foreground_color"));
+ // The prefix's block, member by member, in ghostty's GLSL names and types.
+ const glsl = [_]struct { []const u8, []const u8 }{
+ .{ "vec3", "iResolution" }, .{ "float", "iTime" }, .{ "float", "iTimeDelta" },
+ .{ "float", "iFrameRate" }, .{ "int", "iFrame" }, .{ "float", "iChannelTime[4]" },
+ .{ "vec3", "iChannelResolution[4]" }, .{ "vec4", "iMouse" }, .{ "vec4", "iDate" },
+ .{ "float", "iSampleRate" }, .{ "vec4", "iCurrentCursor" }, .{ "vec4", "iPreviousCursor" },
+ .{ "vec4", "iCurrentCursorColor" }, .{ "vec4", "iPreviousCursorColor" }, .{ "int", "iCurrentCursorStyle" },
+ .{ "int", "iPreviousCursorStyle" }, .{ "int", "iCursorVisible" }, .{ "float", "iTimeCursorChange" },
+ .{ "float", "iTimeFocus" }, .{ "int", "iFocus" }, .{ "vec3", "iPalette[256]" },
+ .{ "vec3", "iBackgroundColor" }, .{ "vec3", "iForegroundColor" }, .{ "vec3", "iCursorColor" },
+ .{ "vec3", "iCursorText" }, .{ "vec3", "iSelectionForegroundColor" }, .{ "vec3", "iSelectionBackgroundColor" },
+ };
+ try std.testing.expectEqual(ours.len, glsl.len);
+ const block = prefix[std.mem.indexOf(u8, prefix, "uniform Globals {").?..];
+ var lines = std.mem.tokenizeScalar(u8, block[0..std.mem.indexOfScalar(u8, block, '}').?], '\n');
+ _ = lines.next();
+ for (glsl) |member| {
+ var words = std.mem.tokenizeAny(u8, lines.next().?, " ;");
+ try std.testing.expectEqualStrings("uniform", words.next().?);
+ try std.testing.expectEqualStrings(member[0], words.next().?);
+ try std.testing.expectEqualStrings(member[1], words.next().?);
+ }
+ try std.testing.expect(lines.next() == null);
+}
+
+test "a moved cursor becomes the previous one, stamped with the time of the move" {
+ var post: Post = .{};
+ const frame: Frame = .{ .cursor = .{ .x0 = 10, .y0 = 20, .x1 = 11, .y1 = 40, .rgb = .{ 255, 0, 0 }, .text = .{ 0, 0, 0 }, .bar = true }, .background = .{ 0, 0, 0 }, .foreground = .{ 255, 255, 255 }, .selection_foreground = .{ 1, 2, 3 }, .selection_background = .{ 4, 5, 6 } };
+ post.uniforms.time = 1.5;
+ post.describe(frame);
+ // Left, BOTTOM edge, width, height, in framebuffer pixels, y down.
+ try std.testing.expectEqual([4]f32{ 10, 40, 1, 20 }, post.uniforms.current_cursor);
+ try std.testing.expectEqual(@as(i32, 2), post.uniforms.current_cursor_style);
+ try std.testing.expectEqual(@as(f32, 1.5), post.uniforms.cursor_change_time);
+ post.uniforms.time = 2;
+ post.describe(frame);
+ try std.testing.expectEqual(@as(f32, 1.5), post.uniforms.cursor_change_time);
+ var moved = frame;
+ moved.cursor.?.x0 = 20;
+ moved.cursor.?.x1 = 21;
+ post.describe(moved);
+ try std.testing.expectEqual([4]f32{ 10, 40, 1, 20 }, post.uniforms.previous_cursor);
+ try std.testing.expectEqual(@as(f32, 2), post.uniforms.cursor_change_time);
+ try std.testing.expectEqual([4]f32{ 1.0 / 255.0, 2.0 / 255.0, 3.0 / 255.0, 1 }, post.uniforms.selection_foreground_color);
+}
+
+test "ghostty's test shaders compile behind the prefix, and its invalid one fails with glslc's words" {
+ const gpa = std.testing.allocator;
+ const io = std.testing.io;
+ // ghostty's own test shaders, from its package in zig-pkg.
+ var pkg = std.Io.Dir.cwd().openDir(io, "zig-pkg", .{ .iterate = true }) catch return error.SkipZigTest;
+ defer pkg.close(io);
+ var it = pkg.iterate();
+ const ghostty = while (it.next(io) catch null) |entry| {
+ if (std.mem.startsWith(u8, entry.name, "ghostty-")) break entry.name;
+ } else return error.SkipZigTest;
+ var buf: [512]u8 = undefined;
+ for ([_][]const u8{ "crt", "focus", "invalid" }, 0..) |name, i| {
+ const path = try std.fmt.bufPrint(&buf, "zig-pkg/{s}/src/renderer/shaders/test_shadertoy_{s}.glsl", .{ ghostty, name });
+ const result = compileOne(gpa, io, path, i);
+ switch (result) {
+ .missing => return error.SkipZigTest, // no glslc here
+ .spirv => |spirv| {
+ defer gpa.free(spirv);
+ try std.testing.expect(!std.mem.eql(u8, name, "invalid"));
+ // SPIR-V's magic number, little-endian.
+ try std.testing.expectEqual(@as(u32, 0x07230203), std.mem.readInt(u32, spirv[0..4], .little));
+ },
+ .failed => |text| {
+ defer gpa.free(text);
+ try std.testing.expectEqualStrings("invalid", name);
+ // Named by the file, not by the temporary source glslc read.
+ try std.testing.expect(std.mem.indexOf(u8, text, path) != null);
+ try std.testing.expect(std.mem.indexOf(u8, text, "pardes-post-") == null);
+ },
+ .none => return error.TestUnexpectedResult,
+ }
+ }
+}
diff --git a/src/gui/crt.zig b/src/gui/crt.zig
deleted file mode 100644
index 2a5ea7e4..00000000
--- a/src/gui/crt.zig
+++ /dev/null
@@ -1,101 +0,0 @@
-//! The Crt builtin's screen geometry, shared between the gui shell's input
-//! mapping and its shader: crt.frag.glsl shows the scene point
-//! uv' = cc*(1 + barrel*r2) at screen point cc, so a physical mouse position
-//! must pass through the SAME forward transform before it becomes a cell.
-//! Pure std, so the unit-test step can pin it against the shader formula.
-
-const std = @import("std");
-
-/// MUST match BARREL in shaders/crt.frag.glsl.
-pub const barrel: f32 = 0.016;
-
-pub const Point = struct { x: f32, y: f32 };
-pub const Effects = struct { crt: bool = false, ripple: bool = false, glitch: bool = false };
-pub const Frame = struct { effects: Effects = .{}, time_seconds: f32 = 0 };
-
-fn fract(x: f32) f32 {
- return x - @floor(x);
-}
-
-fn smoothstep(lo: f32, hi: f32, value: f32) f32 {
- const t = std.math.clamp((value - lo) / (hi - lo), 0, 1);
- return t * t * (3 - 2 * t);
-}
-
-/// Match `sceneUv` in crt.frag.glsl. This is a forward screen→sample mapping:
-/// a physical pointer is sent to the logical scene pixel displayed under it.
-pub fn mapScene(x: f32, y: f32, w: f32, h: f32, effects: Effects, time_seconds: f32) ?Point {
- const safe_w = @max(1, w);
- const safe_h = @max(1, h);
- const ccx = x / safe_w * 2.0 - 1.0;
- const ccy = y / safe_h * 2.0 - 1.0;
- const s = 1.0 + barrel * (ccx * ccx + ccy * ccy) * @as(f32, @floatFromInt(@intFromBool(effects.crt)));
- var u = ccx * s * 0.5 + 0.5;
- var v = ccy * s * 0.5 + 0.5;
- // The CRT shader paints no scene when this barrel-mapped coordinate lies
- // on or beyond the tube edge. There is no logical edge cell under those
- // black pixels, so input must be inert there rather than clamped.
- if (effects.crt and (u <= 0 or u >= 1 or v <= 0 or v >= 1)) return null;
- if (effects.ripple) {
- const cx = u - 0.5;
- const cy = v - 0.5;
- const radius = @sqrt(cx * cx + cy * cy);
- const envelope = 1.0 - smoothstep(0.04, 0.72, radius);
- const wave = @sin(radius * 42.0 - time_seconds * 5.2);
- const len = @max(radius, 0.00001);
- u += cx / len * wave * envelope * 0.0032;
- v += cy / len * wave * envelope * 0.0032;
- }
- if (effects.glitch) {
- const band = @floor(v * 96.0);
- const tick = @floor(time_seconds * 13.0);
- const seed = noiseAt(band, tick);
- const tear = smoothstep(0.955, 1.0, seed);
- u += (noiseAt(seed, band) - 0.5) * tear * 0.020;
- }
- return .{
- .x = std.math.clamp(u * safe_w, 0, safe_w - 1),
- .y = std.math.clamp(v * safe_h, 0, safe_h - 1),
- };
-}
-
-fn noiseAt(x: f32, y: f32) f32 {
- var qx = fract(x * 0.1031);
- var qy = fract(y * 0.1031);
- var qz = fract(x * 0.1031);
- const d = qx * (qy + 33.33) + qy * (qz + 33.33) + qz * (qx + 33.33);
- qx += d;
- qy += d;
- qz += d;
- return fract((qx + qy) * qz);
-}
-
-test "map matches the shader's barrel formula" {
- // center is a fixed point
- const center = mapScene(500, 250, 1000, 500, .{ .crt = true }, 0).?;
- try std.testing.expectApproxEqAbs(@as(f32, 500), center.x, 0.001);
- try std.testing.expectApproxEqAbs(@as(f32, 250), center.y, 0.001);
- // Corners barrel beyond the tube and are black/inert in the shader.
- try std.testing.expect(mapScene(0, 0, 1000, 500, .{ .crt = true }, 0) == null);
- try std.testing.expect(mapScene(1000, 500, 1000, 500, .{ .crt = true }, 0) == null);
- // (100,100) of 1000x500: cc=(-0.8,-0.6), r2=1.0, s=1.016 ->
- // cc'=(-0.8128,-0.6096) -> (93.6, 97.6)
- const near_edge = mapScene(100, 100, 1000, 500, .{ .crt = true }, 0).?;
- try std.testing.expectApproxEqAbs(@as(f32, 93.6), near_edge.x, 0.01);
- try std.testing.expectApproxEqAbs(@as(f32, 97.6), near_edge.y, 0.01);
- // (900,250): cc=(0.8,0), r2=0.64, s=1.01024 -> x'=904.096
- const side = mapScene(900, 250, 1000, 500, .{ .crt = true }, 0).?;
- try std.testing.expectApproxEqAbs(@as(f32, 904.096), side.x, 0.01);
-}
-
-test "scene mapping composes ripple and deterministic glitch" {
- const plain = mapScene(300, 200, 800, 600, .{}, 2.5).?;
- try std.testing.expectApproxEqAbs(@as(f32, 300), plain.x, 0.001);
- try std.testing.expectApproxEqAbs(@as(f32, 200), plain.y, 0.001);
- const ripple = mapScene(300, 200, 800, 600, .{ .ripple = true }, 2.5).?;
- try std.testing.expect(ripple.x != plain.x or ripple.y != plain.y);
- try std.testing.expectEqual(
- mapScene(300, 200, 800, 600, .{ .glitch = true }, 2.5).?,
- mapScene(300, 200, 800, 600, .{ .glitch = true }, 2.5).?,
- );
-}
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 2cde5edf..ab1875b7 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -12,7 +12,7 @@ const look = @import("../look.zig");
const message = pardes.Messages.Message;
const file_watch = @import("../file_watch.zig");
const deck = @import("deck.zig");
-const crt = @import("crt.zig");
+const Post = @import("Post.zig");
const pet = @import("pet.zig");
const fonts = @import("../fonts.zig"); // the Font builtin's half of the seam
const selection_pipe = @import("../selection_pipe.zig");
@@ -43,8 +43,6 @@ const overlay_vert_spv = @embedFile("overlay.vert.spv");
const overlay_frag_spv = @embedFile("overlay.frag.spv");
const image_vert_spv = @embedFile("image.vert.spv");
const image_frag_spv = @embedFile("image.frag.spv");
-const crt_vert_spv = @embedFile("crt.vert.spv");
-const crt_frag_spv = @embedFile("crt.frag.spv");
const bg_default = [3]u8{ 18, 18, 18 };
const fg_default = [3]u8{ 204, 204, 204 };
@@ -202,18 +200,17 @@ const touch_click_flash_max_frames: u8 = 14;
const touch_click_flash_vertices: usize = 1400;
const touch_scroll_tick: f32 = 0.02;
-test "a persistent scene presents a new panel's frame zero before advancing it" {
+test "a new panel's frame zero is presented before it advances" {
const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer core.deinit();
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const frame = pardes.animation.frame_ns;
- core.settings.scene_effects.crt = true;
core.advance(100);
- const scene_frame = try core.render(arena.allocator());
- core.acknowledgePanelPresentation(scene_frame.panelTracks());
- try std.testing.expectEqual(@as(?u64, 100 + frame), core.nextWake());
+ const idle = try core.render(arena.allocator());
+ core.acknowledgePanelPresentation(idle.panelTracks());
+ try std.testing.expectEqual(@as(?u64, null), core.nextWake());
core.settings.panel_transition = .slide;
core.update(.{ .command = "Newcol" });
@@ -235,10 +232,9 @@ test "a persistent scene presents a new panel's frame zero before advancing it"
for (second.panelTracks()) |track| try std.testing.expectEqual(@as(u16, 1), track.frame);
}
-// Per pane: background, rule, two grip strokes, two square rails, and the
-// rule under each of its notice chips.
-const pane_chrome_vertices: usize = 6 * (6 + pardes.Pane.Notices.max);
-const max_overlay_vertices: usize = 24 + pet.max_vertices + pardes.MAX_COLS * 6 + pardes.MAX_PANES * pane_chrome_vertices + touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices);
+// The triangle overlay holds only the pet and the touch view: the chrome is
+// decor.
+const max_overlay_vertices: usize = pet.max_vertices + touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices);
const font_px_step: f32 = 2.0;
const font_px_min: f32 = 8.0;
@@ -1765,7 +1761,6 @@ const Gui = struct {
decor_pipeline: *c.SDL_GPUGraphicsPipeline,
overlay_pipeline: *c.SDL_GPUGraphicsPipeline,
image_pipeline: *c.SDL_GPUGraphicsPipeline,
- crt_pipeline: *c.SDL_GPUGraphicsPipeline,
atlas_tex: *c.SDL_GPUTexture,
atlas_sampler: *c.SDL_GPUSampler,
linear_sampler: *c.SDL_GPUSampler,
@@ -1838,7 +1833,8 @@ const Gui = struct {
scene_tex_h: u32 = 0,
scene_failures: u8 = 0,
scene_target_failed: bool = false,
- presented_scene: crt.Frame = .{},
+ /// The post chain, run over the frame in scene_tex.
+ post: Post = .{},
pointer_present: bool = false,
arrow_cursor: ?*c.SDL_Cursor = null,
link_cursor: ?*c.SDL_Cursor = null,
@@ -1977,7 +1973,9 @@ fn windowCells(g: *const Gui) GridCells {
};
}
-fn windowPointToPixels(geometry: WindowGeometry, x: f32, y: f32) crt.Point {
+const Point = struct { x: f32, y: f32 };
+
+fn windowPointToPixels(geometry: WindowGeometry, x: f32, y: f32) Point {
return .{
.x = x * geometry.pixel_w / geometry.window_w,
.y = y * geometry.pixel_h / geometry.window_h,
@@ -1991,7 +1989,7 @@ test "high-density window coordinates map once to render pixels" {
.pixel_w = 1120,
.pixel_h = 720,
};
- try std.testing.expectEqual(crt.Point{ .x = 700, .y = 240 }, windowPointToPixels(geometry, 350, 120));
+ try std.testing.expectEqual(Point{ .x = 700, .y = 240 }, windowPointToPixels(geometry, 350, 120));
}
fn fixedCellLayout(g: *const Gui) CellLayout {
@@ -2397,7 +2395,6 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
const decor_pipeline = try makePipeline(device, swapchain_format, decor_frag_spv, 0, backgroundLayerBlend());
const overlay_pipeline = try makeOverlayPipeline(device, swapchain_format);
const image_pipeline = try makeImagePipeline(device, swapchain_format);
- const crt_pipeline = try makeCrtPipeline(device, swapchain_format);
const overlay_buffer_size: u32 = @intCast(max_overlay_vertices * @sizeOf(OverlayVertex));
var ovb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = overlay_buffer_size, .props = 0 };
@@ -2427,7 +2424,6 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
.decor_pipeline = decor_pipeline,
.overlay_pipeline = overlay_pipeline,
.image_pipeline = image_pipeline,
- .crt_pipeline = crt_pipeline,
.atlas_tex = atlas_tex,
.atlas_sampler = atlas_sampler,
.linear_sampler = linear_sampler,
@@ -2464,7 +2460,6 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
c.SDL_ReleaseGPUGraphicsPipeline(device, g.pipeline);
c.SDL_ReleaseGPUGraphicsPipeline(device, g.decor_pipeline);
c.SDL_ReleaseGPUGraphicsPipeline(device, g.overlay_pipeline);
- c.SDL_ReleaseGPUGraphicsPipeline(device, g.crt_pipeline);
c.SDL_ReleaseGPUTexture(device, g.atlas_tex);
c.SDL_ReleaseGPUSampler(device, g.atlas_sampler);
c.SDL_ReleaseGPUSampler(device, g.linear_sampler);
@@ -2490,6 +2485,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
g.prepared_images.deinit(gpa);
g.cover.deinit(gpa);
g.decor.deinit(gpa);
+ g.post.deinit(gpa, device);
}
defer c.SDL_ReleaseGPUTransferBuffer(device, g.image_vxfer);
defer c.SDL_ReleaseGPUBuffer(device, g.image_vbuf);
@@ -2796,7 +2792,6 @@ fn paintAttached(g: *Gui, gpa: std.mem.Allocator, client: *detached_client.Clien
gpa,
null,
&surface,
- .{},
false,
) catch |err| blk: {
log.err("render: {t}", .{err});
@@ -3065,23 +3060,14 @@ const StdinFeed = struct {
f.fill = len - seq_start;
break;
};
- if (parseFinger(body[0..end])) |finger_in| {
- var finger = finger_in;
+ if (parseFinger(body[0..end])) |finger| {
if (g) |gp| {
var wpw: c_int = 0;
var wph: c_int = 0;
_ = c.SDL_GetWindowSizeInPixels(gp.window, &wpw, &wph);
const win_w: f32 = @floatFromInt(@max(wpw, 1));
const win_h: f32 = @floatFromInt(@max(wph, 1));
- if (mapScenePoint(gp, finger.x * win_w, finger.y * win_h, win_w, win_h)) |m| {
- finger.x = m.x / win_w;
- finger.y = m.y / win_h;
- handleFinger(&gp.touch, in, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h), @floatFromInt(gp.tagline_width));
- } else {
- finger.kind = .cancel;
- handleFinger(&gp.touch, in, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h), @floatFromInt(gp.tagline_width));
- in.post(.pointer_leave);
- }
+ handleFinger(&gp.touch, in, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h), @floatFromInt(gp.tagline_width));
} else if (in.core) |core| {
handleFinger(&grid_touch, in, finger, @floatFromInt(core.screen_w), @floatFromInt(core.screen_h), 1, 1, 1);
}
@@ -3288,6 +3274,20 @@ const Input = struct {
};
fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
+ // What the post chain sees of the pointer and the focus.
+ switch (sev.type) {
+ c.SDL_EVENT_MOUSE_MOTION => {
+ const at = windowPointToPixels(windowGeometry(g.window), sev.motion.x, sev.motion.y);
+ g.post.mouse(at.x, at.y, null);
+ },
+ c.SDL_EVENT_MOUSE_BUTTON_DOWN, c.SDL_EVENT_MOUSE_BUTTON_UP => {
+ const at = windowPointToPixels(windowGeometry(g.window), sev.button.x, sev.button.y);
+ g.post.mouse(at.x, at.y, sev.button.down);
+ },
+ c.SDL_EVENT_WINDOW_FOCUS_GAINED => g.post.focus(true),
+ c.SDL_EVENT_WINDOW_FOCUS_LOST => g.post.focus(false),
+ else => {},
+ }
switch (sev.type) {
c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => in.close(),
c.SDL_EVENT_WINDOW_MOUSE_LEAVE => {
@@ -3460,7 +3460,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
},
c.SDL_EVENT_FINGER_DOWN, c.SDL_EVENT_FINGER_MOTION, c.SDL_EVENT_FINGER_UP, c.SDL_EVENT_FINGER_CANCELED => {
const f = sev.tfinger;
- var finger: Finger = .{
+ const finger: Finger = .{
.kind = switch (sev.type) {
c.SDL_EVENT_FINGER_DOWN => .down,
c.SDL_EVENT_FINGER_MOTION => .motion,
@@ -3479,15 +3479,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
const win_h: f32 = @floatFromInt(@max(ph, 1));
const fcw: f32 = @floatFromInt(g.cell_w);
const fch: f32 = @floatFromInt(g.cell_h);
- if (mapScenePoint(g, finger.x * win_w, finger.y * win_h, win_w, win_h)) |m| {
- finger.x = m.x / win_w;
- finger.y = m.y / win_h;
- handleFinger(&g.touch, in, finger, win_w, win_h, fcw, fch, @floatFromInt(g.tagline_width));
- } else {
- finger.kind = .cancel;
- handleFinger(&g.touch, in, finger, win_w, win_h, fcw, fch, @floatFromInt(g.tagline_width));
- in.post(.pointer_leave);
- }
+ handleFinger(&g.touch, in, finger, win_w, win_h, fcw, fch, @floatFromInt(g.tagline_width));
},
c.SDL_EVENT_PINCH_BEGIN, c.SDL_EVENT_PINCH_UPDATE => in.post(.{ .pinch = sev.pinch.scale }),
c.SDL_EVENT_GAMEPAD_ADDED => {
@@ -3747,14 +3739,30 @@ test "tagline pointer mapping retains physical grips and carries logical text co
try std.testing.expectEqual(pardes.TagKind.workspace, topbar.tag_hit.?.kind);
}
-fn mapScenePoint(g: *const Gui, x: f32, y: f32, w: f32, h: f32) ?crt.Point {
- return crt.mapScene(x, y, w, h, g.presented_scene.effects, g.presented_scene.time_seconds);
+test "a click at the window's corner lands in the corner cell with a post chain on" {
+ const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer core.deinit();
+ try std.testing.expect(core.settings.post.set(.crt, "", true));
+ var g: Gui = undefined;
+ g.cell_w = 10;
+ g.cell_h = 20;
+ g.tagline_width = 6;
+ g.tagline_height = 12;
+ const geometry: WindowGeometry = .{ .window_w = 400, .window_h = 240, .pixel_w = 800, .pixel_h = 480 };
+ const last = mouseCellWithGeometry(&g, core, 399.9, 239.9, geometry).?;
+ try std.testing.expectEqual(@as(u16, 79), last.col);
+ try std.testing.expectEqual(@as(u16, 23), last.row);
+ const first = mouseCellWithGeometry(&g, core, 0, 0, geometry).?;
+ try std.testing.expectEqual(@as(u16, 0), first.col);
+ try std.testing.expectEqual(@as(u16, 0), first.row);
}
+/// Where a window point lands: the cell drawn under it. Input is identity:
+/// no post pass moves a pixel (the bundled Crt has no barrel), so a click
+/// is exactly where it looks.
fn mouseCellWithGeometry(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32, geometry: WindowGeometry) ?MouseCell {
const physical = windowPointToPixels(geometry, x, y);
- const mapped = mapScenePoint(g, physical.x, physical.y, geometry.pixel_w, geometry.pixel_h) orelse return null;
- return gridCellAtPixels(g, core, mapped.x, mapped.y);
+ return gridCellAtPixels(g, core, physical.x, physical.y);
}
fn mouseCell(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32) ?MouseCell {
@@ -4064,6 +4072,7 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void {
// shell's queue raises for a terminal's output, a completion, a file
// system request. What the shell moves or times itself polls every
// frame: a smooth scroll, a gamepad's sticks, the test stdin feed,
+ // a post chain that moves on its own (ShaderAnimation),
// a retired shell waiting to be killed, a present to retry. A
// minimized window sleeps through the core's animation too: nothing
// shows it, and it is caught up at once when the window is back.
@@ -4072,7 +4081,8 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void {
} else for (s.ptys) |slot| {
if (slot) |pt| if (pt.kill_at != 0) break true;
} else false;
- const polls = g.scroll_lag != 0 or g.scroll_delta != 0 or g.gamepad != null or s.test_mode or core.needs_frame or retiring;
+ const polls = g.scroll_lag != 0 or g.scroll_delta != 0 or g.gamepad != null or s.test_mode or core.needs_frame or retiring or
+ g.post.animating(core.settings.shader_animation);
const minimized = !s.test_mode and c.SDL_GetWindowFlags(g.window) & (c.SDL_WINDOW_MINIMIZED | c.SDL_WINDOW_OCCLUDED) != 0;
// A latency trace is fed on stdin, whose poll below paces the loop:
// waiting here too would make every tick cost two frames.
@@ -4157,6 +4167,10 @@ fn pollFrame(ctx: ?*anyopaque) void {
const geom = windowCells(g);
if (updateCoreResize(core, geom.cols, geom.rows, g.cell_w, g.cell_h, g.tagline_width, g.tagline_height)) resetScroll(g);
stepScroll(g, core, s.gpa);
+ g.post.sync(s.gpa, s.io, g.device, g.swapchain_format, core);
+ // Between the core's frames, a chain that moves on its own redraws alone.
+ if (g.post.animating(core.settings.shader_animation) and !core.needs_frame and s.presented and
+ c.SDL_GetTicksNS() -| g.post.last_ns >= pardes.animation.frame_ns) redrawPost(g, s.gpa);
}
fn finishWindowOpacity(core: *pardes.Pardes, applied: *u8, failure: ?[]const u8) void {
@@ -4216,15 +4230,13 @@ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void {
frame_started_ns = monoNs();
const frame = @constCast(surface);
s.surface = frame;
- const scene_requested = core.settings.scene_effects.crt or
- core.settings.scene_effects.ripple or core.settings.scene_effects.glitch;
+ const scene_requested = g.post.ready();
if (!scene_requested) g.scene_failures = 0;
s.presented = renderFrame(
g,
s.gpa,
core,
frame,
- core.settings.scene_effects,
core.settings.debug,
) catch |err| blk: {
log.err("render: {t}", .{err});
@@ -5190,7 +5202,6 @@ fn renderFrame(
gpa: std.mem.Allocator,
core: ?*const pardes.Pardes,
surface: *pardes.Surface,
- scene_effects: pardes.animation.SceneEffect,
debug_on: bool,
) !bool {
if (surface.pointer_shape != g.pointer_shape) {
@@ -5205,48 +5216,19 @@ fn renderFrame(
defer if (!command_consumed) {
_ = c.SDL_SubmitGPUCommandBuffer(cmd);
};
- var sw: u32 = 0;
- var sh: u32 = 0;
- var target: *c.SDL_GPUTexture = undefined;
- if (g.capture) {
- sw = @as(u32, surface.cols) * g.cell_w + g.capture_pad;
- sh = @as(u32, surface.rows) * g.cell_h + g.capture_pad;
- if (sw == 0 or sh == 0) {
- command_consumed = true;
- _ = c.SDL_SubmitGPUCommandBuffer(cmd);
- return false;
- }
- try ensureCaptureTexture(g, sw, sh);
- target = g.capture_tex.?;
- } else if (g.soft_present) {
- var pw: c_int = 0;
- var ph: c_int = 0;
- if (!c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph) or pw <= 0 or ph <= 0) {
- command_consumed = true;
- _ = c.SDL_SubmitGPUCommandBuffer(cmd);
- return false;
- }
- sw = @intCast(pw);
- sh = @intCast(ph);
- try ensureCaptureTexture(g, sw, sh);
- target = g.capture_tex.?;
- } else {
- var swap_tex: ?*c.SDL_GPUTexture = null;
- if (!c.SDL_AcquireGPUSwapchainTexture(cmd, g.window, &swap_tex, &sw, &sh)) {
- command_consumed = true;
- _ = c.SDL_SubmitGPUCommandBuffer(cmd);
- return false;
- }
- target = swap_tex orelse { // swapchain busy: skip the frame
- command_consumed = true;
- _ = c.SDL_SubmitGPUCommandBuffer(cmd);
- return false;
- };
- }
+ const frame_target = try acquireTarget(g, cmd, @as(u32, surface.cols) * g.cell_w + g.capture_pad, @as(u32, surface.rows) * g.cell_h + g.capture_pad) orelse {
+ command_consumed = true;
+ _ = c.SDL_SubmitGPUCommandBuffer(cmd);
+ return false;
+ };
+ const target = frame_target.texture;
+ const sw = frame_target.w;
+ const sh = frame_target.h;
const win_w: f32 = @floatFromInt(sw);
const win_h: f32 = @floatFromInt(sh);
- var scene_on = scene_effects.crt or scene_effects.ripple or scene_effects.glitch;
+ // The post chain runs over a local frame, from the scene texture.
+ var scene_on = core != null and g.post.ready();
if (scene_on) ensureSceneTexture(g, sw, sh) catch {
g.scene_target_failed = true;
scene_on = false;
@@ -5559,64 +5541,87 @@ fn renderFrame(
drawOverlayGpu(g, cmd, rp, overlay_count);
c.SDL_EndGPURenderPass(rp);
- var rendered_scene: crt.Frame = .{};
if (scene_on) {
- var crt_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo);
- crt_target.texture = target;
- crt_target.load_op = c.SDL_GPU_LOADOP_DONT_CARE;
- crt_target.store_op = c.SDL_GPU_STOREOP_STORE;
- const crt_pass = c.SDL_BeginGPURenderPass(cmd, &crt_target, 1, null);
- c.SDL_BindGPUGraphicsPipeline(crt_pass, g.crt_pipeline);
- const samp_binding = c.SDL_GPUTextureSamplerBinding{ .texture = g.scene_tex.?, .sampler = g.linear_sampler };
- c.SDL_BindGPUFragmentSamplers(crt_pass, 0, &samp_binding, 1);
- const now_ns = c.SDL_GetTicksNS();
- const time_wrap_ns: u64 = 4096 * std.time.ns_per_s;
- const time_seconds: f32 = @floatCast(
- @as(f64, @floatFromInt(now_ns % time_wrap_ns)) / @as(f64, std.time.ns_per_s),
- );
- rendered_scene = .{
- .effects = .{
- .crt = scene_effects.crt,
- .ripple = scene_effects.ripple,
- .glitch = scene_effects.glitch,
- },
- .time_seconds = time_seconds,
- };
- const crt_uniforms = [8]f32{
- time_seconds,
- @floatFromInt((now_ns / (std.time.ns_per_s / 60)) % 65_536),
- @floatFromInt(sw),
- @floatFromInt(sh),
- @floatFromInt(@intFromBool(scene_effects.crt)),
- @floatFromInt(@intFromBool(scene_effects.ripple)),
- @floatFromInt(@intFromBool(scene_effects.glitch)),
- 0,
+ if (core) |p| {
+ var boxes: [max_cursors]CursorBox = undefined;
+ const cursors = if (cursors_shown) cursorBoxes(g, surface, layout, win_h, &boxes) else 0;
+ g.post.describe(.{
+ .cursor = if (cursors > 0) boxes[0] else null,
+ .background = chrome.page orelse bg_default,
+ .foreground = chrome.fg orelse fg_default,
+ .selection_foreground = p.theme().sel_fg,
+ .selection_background = p.theme().sel_bg,
+ });
+ }
+ const chain_zone = tracy.zone(@src(), "post chain");
+ defer chain_zone.end();
+ g.post.draw(g.device, cmd, g.linear_sampler, g.swapchain_format, g.scene_tex.?, target, sw, sh, c.SDL_GetTicksNS(), background_opacity >= 1) catch {
+ g.scene_target_failed = true;
};
- c.SDL_PushGPUFragmentUniformData(cmd, 0, &crt_uniforms, @sizeOf(@TypeOf(crt_uniforms)));
- c.SDL_DrawGPUPrimitives(crt_pass, 3, 1, 0, 0);
- c.SDL_EndGPURenderPass(crt_pass);
}
+ command_consumed = true;
+ const shown = try finishFrame(g, gpa, cmd, frame_target, if (groups.tracks != 0) groups.items[1].track.?.frame else null);
+ if (shown and groups.tracks == 0) rememberPresentedImages(g, gpa, surface);
+ return shown;
+}
+
+const Target = struct { texture: *c.SDL_GPUTexture, w: u32, h: u32 };
+
+/// What this frame draws into: the capture texture in test mode (the grid's
+/// size plus PARDES_TEST_PAD) or with a soft presenter, else the swapchain's
+/// image; null when there is none to be had this time.
+fn acquireTarget(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, capture_w: u32, capture_h: u32) !?Target {
if (g.capture) {
- command_consumed = true;
- try captureFrame(g, gpa, cmd, target, sw, sh, if (groups.tracks != 0) groups.items[1].track.?.frame else null);
- g.presented_scene = rendered_scene;
- if (groups.tracks == 0) rememberPresentedImages(g, gpa, surface);
+ if (capture_w == 0 or capture_h == 0) return null;
+ try ensureCaptureTexture(g, capture_w, capture_h);
+ return .{ .texture = g.capture_tex.?, .w = capture_w, .h = capture_h };
+ }
+ if (g.soft_present) {
+ var pw: c_int = 0;
+ var ph: c_int = 0;
+ if (!c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph) or pw <= 0 or ph <= 0) return null;
+ try ensureCaptureTexture(g, @intCast(pw), @intCast(ph));
+ return .{ .texture = g.capture_tex.?, .w = @intCast(pw), .h = @intCast(ph) };
+ }
+ var swap_tex: ?*c.SDL_GPUTexture = null;
+ var sw: u32 = 0;
+ var sh: u32 = 0;
+ if (!c.SDL_AcquireGPUSwapchainTexture(cmd, g.window, &swap_tex, &sw, &sh)) return null;
+ // A busy swapchain skips the frame.
+ return .{ .texture = swap_tex orelse return null, .w = sw, .h = sh };
+}
+
+/// Submits the frame: captured, handed to the soft presenter, or presented.
+fn finishFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, target: Target, transition: ?u16) !bool {
+ if (g.capture) {
+ try captureFrame(g, gpa, cmd, target.texture, target.w, target.h, transition);
return true;
}
if (g.soft_present) {
- command_consumed = true;
- try softPresentFrame(g, cmd, target, sw, sh);
- g.presented_scene = rendered_scene;
- if (groups.tracks == 0) rememberPresentedImages(g, gpa, surface);
+ try softPresentFrame(g, cmd, target.texture, target.w, target.h);
return true;
}
- command_consumed = true;
- const submitted = c.SDL_SubmitGPUCommandBuffer(cmd);
- if (submitted) {
- g.presented_scene = rendered_scene;
- if (groups.tracks == 0) rememberPresentedImages(g, gpa, surface);
+ return c.SDL_SubmitGPUCommandBuffer(cmd);
+}
+
+/// Redraw level A (docs §5.5): the post chain alone, over the frame the core
+/// last drew, for a pass that moves on its own. No core render, no cell.
+fn redrawPost(g: *Gui, gpa: std.mem.Allocator) void {
+ const scene = g.scene_tex orelse return;
+ const cmd = c.SDL_AcquireGPUCommandBuffer(g.device) orelse return;
+ const target = (acquireTarget(g, cmd, g.scene_tex_w, g.scene_tex_h) catch null) orelse {
+ _ = c.SDL_SubmitGPUCommandBuffer(cmd);
+ return;
+ };
+ // A window the retained frame no longer fits waits for the core's.
+ if (target.w != g.scene_tex_w or target.h != g.scene_tex_h) {
+ _ = c.SDL_SubmitGPUCommandBuffer(cmd);
+ return;
}
- return submitted;
+ const zone = tracy.zone(@src(), "post chain redraw");
+ g.post.draw(g.device, cmd, g.linear_sampler, g.swapchain_format, scene, target.texture, target.w, target.h, c.SDL_GetTicksNS(), backgroundOpacity(g.applied_window_opacity) >= 1) catch {};
+ zone.end();
+ _ = finishFrame(g, gpa, cmd, target, null) catch {};
}
const ResolvedCell = struct {
@@ -7016,25 +7021,7 @@ fn makeImagePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFor
return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate;
}
-fn makeCrtPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline {
- const vs = try makeShader(device, crt_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0, 0);
- defer c.SDL_ReleaseGPUShader(device, vs);
- const fs = try makeShader(device, crt_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 1);
- defer c.SDL_ReleaseGPUShader(device, fs);
-
- var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) };
- var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo);
- info.vertex_shader = vs;
- info.fragment_shader = fs;
- info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST;
- info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL;
- info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE;
- info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1;
- info.target_info = .{ .color_target_descriptions = &col_desc, .num_color_targets = 1 };
- return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate;
-}
-
-fn makeShader(device: *c.SDL_GPUDevice, code: []const u8, stage: c.SDL_GPUShaderStage, n_samplers: u32, n_uniform_buffers: u32) !*c.SDL_GPUShader {
+pub fn makeShader(device: *c.SDL_GPUDevice, code: []const u8, stage: c.SDL_GPUShaderStage, n_samplers: u32, n_uniform_buffers: u32) !*c.SDL_GPUShader {
var info = std.mem.zeroes(c.SDL_GPUShaderCreateInfo);
info.code = code.ptr;
info.code_size = code.len;
@@ -7519,25 +7506,32 @@ test "decor is read from the regions, each pane's in its own group" {
try std.testing.expectEqual(@as(u32, 0), groups.items[groups.ink].decor_count);
}
-/// The bar cursors: the grid's where no layer has the cell, and each layer's
-/// at its own pitch.
-fn cursorDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32) !void {
+const CursorBox = Post.Frame.Cursor;
+const max_cursors = 1 + pardes.MAX_PANES + pardes.MAX_TAG_LAYERS;
+
+/// Every cursor on the frame where it is drawn, in window pixels: the grid's
+/// where no layer has its cell, and each layer's at that layer's pitch. A bar
+/// is an eighth of its cell wide, a block the whole cell.
+fn cursorBoxes(g: *const Gui, surface: *const pardes.Surface, layout: CellLayout, win_h: f32, out: *[max_cursors]CursorBox) usize {
const chrome = &surface.chrome;
+ const page = chrome.page orelse bg_default;
+ var n: usize = 0;
if (surface.cursor) |cursor| {
- if (cursor.bar and cursor.x < surface.cols and cursor.y < surface.rows and bodyLayerAt(surface, cursor.x, cursor.y) == null and tagLayerIn(surface.tagLayers(), cursor.x, cursor.y) == null) {
+ if (cursor.x < surface.cols and cursor.y < surface.rows and bodyLayerAt(surface, cursor.x, cursor.y) == null and tagLayerIn(surface.tagLayers(), cursor.x, cursor.y) == null) {
const cell = &surface.cells[@as(usize, cursor.y) * surface.cols + cursor.x];
const role = cellFontRole(cell);
const width: f32 = if (role == .tagline) @floatFromInt(g.tagline_width) else layout.w;
const height = std.math.clamp(if (role == .tagline) @as(f32, @floatFromInt(g.tagline_height)) else layout.h, 1.0, layout.h);
const x0 = @as(f32, @floatFromInt(cursor.x)) * layout.w;
const y0 = @as(f32, @floatFromInt(cursor.y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(cursor.y, win_h, @intFromFloat(layout.h), @intFromFloat(height))));
- try addDecor(g, gpa, x0, y0, x0 + @max(1.0, width / 8.0), y0 + height, cursorColor(chrome, cell, role), 1, null, win_w, win_h);
+ out[n] = .{ .x0 = x0, .y0 = y0, .x1 = x0 + if (cursor.bar) @max(1.0, width / 8.0) else width, .y1 = y0 + height, .rgb = cursorColor(chrome, cell, role), .text = page, .bar = cursor.bar };
+ n += 1;
}
}
for (surface.bodyLayers()) |*layer| {
if (layer.rows == 0) continue;
const cursor = layer.cursor orelse continue;
- if (!cursor.bar or cursor.y >= layer.rows or cursor.x >= layer.cols) continue;
+ if (cursor.y >= layer.rows or cursor.x >= layer.cols) continue;
const left = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w;
const bottom = @as(f32, @floatFromInt(layer.viewport.y + layer.viewport.h)) * layout.h;
const top = layer.rowTop(cursor.y, layout.h, @floatFromInt(g.tagline_height));
@@ -7547,12 +7541,13 @@ fn cursorDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface,
const width: f32 = if (role == .tagline) @floatFromInt(g.tagline_width) else layout.w;
const x = left + @as(f32, @floatFromInt(cursor.x)) * width;
const cell = &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x];
- try addDecor(g, gpa, x, top, x + @max(1, width / 8), @min(bottom, top + height), cursorColor(chrome, cell, role), 1, null, win_w, win_h);
+ out[n] = .{ .x0 = x, .y0 = top, .x1 = x + if (cursor.bar) @max(1, width / 8) else width, .y1 = @min(bottom, top + height), .rgb = cursorColor(chrome, cell, role), .text = page, .bar = cursor.bar };
+ n += 1;
}
for (surface.tagLayers()) |*layer| {
if (layer.rows == 0) continue;
const cursor = layer.cursor orelse continue;
- if (!cursor.bar or cursor.x >= layer.cols or cursor.y >= layer.rows) continue;
+ if (cursor.x >= layer.cols or cursor.y >= layer.rows) continue;
const width: f32 = @floatFromInt(g.tagline_width);
const left = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w;
const right = left + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w;
@@ -7560,8 +7555,17 @@ fn cursorDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface,
if (x >= right) continue;
const y = layer.viewport.y + cursor.y;
const top = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, win_h, g.cell_h, g.tagline_height)));
- try addDecor(g, gpa, x, top, @min(right, x + @max(1, width / 8)), top + @as(f32, @floatFromInt(g.tagline_height)), cursorColor(chrome, &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x], .tagline), 1, null, win_w, win_h);
+ out[n] = .{ .x0 = x, .y0 = top, .x1 = @min(right, x + if (cursor.bar) @max(1, width / 8) else width), .y1 = top + @as(f32, @floatFromInt(g.tagline_height)), .rgb = cursorColor(chrome, &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x], .tagline), .text = page, .bar = cursor.bar };
+ n += 1;
}
+ return n;
+}
+
+/// The bar cursors as decor; a block cursor is its cell's own colours.
+fn cursorDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32) !void {
+ var boxes: [max_cursors]CursorBox = undefined;
+ for (boxes[0..cursorBoxes(g, surface, layout, win_h, &boxes)]) |box|
+ if (box.bar) try addDecor(g, gpa, box.x0, box.y0, box.x1, box.y1, box.rgb, 1, null, win_w, win_h);
}
/// The triangle overlay: the workspace pet and the touch debug view, over
diff --git a/src/macos.zig b/src/macos.zig
index 4d8f2883..0bad86ee 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -850,16 +850,18 @@ export fn pardes_should_quit() bool {
return st.core.quit;
}
-fn encodeSceneEffects(effects: animation.SceneEffect) u32 {
+// The Metal shell keeps its own CRT, ripple and glitch kernel: the bundled
+// passes of the GUI's post chain, as flags.
+fn encodeSceneEffects(post: *const pardes.config.Runtime.Post) u32 {
var flags: u32 = 0;
- if (effects.crt) flags |= scene_flag_crt;
- if (effects.ripple) flags |= scene_flag_ripple;
- if (effects.glitch) flags |= scene_flag_glitch;
+ if (post.has(.crt)) flags |= scene_flag_crt;
+ if (post.has(.ripple)) flags |= scene_flag_ripple;
+ if (post.has(.glitch)) flags |= scene_flag_glitch;
return flags;
}
fn currentSceneFlags(st: *const State) u32 {
- return encodeSceneEffects(st.core.settings.scene_effects);
+ return encodeSceneEffects(&st.core.settings.post);
}
fn advanceSceneClock(st: *State, elapsed_ns: u64) void {
@@ -2912,10 +2914,13 @@ test "pardes.h matches the Zig boundary" {
test "scene effect flags and display clock are compact and independent" {
const expectEqual = std.testing.expectEqual;
- try expectEqual(@as(u32, 0), encodeSceneEffects(.{}));
- try expectEqual(scene_flag_crt, encodeSceneEffects(.{ .crt = true }));
- try expectEqual(scene_flag_ripple, encodeSceneEffects(.{ .ripple = true }));
- try expectEqual(scene_flag_glitch, encodeSceneEffects(.{ .glitch = true }));
+ var post: pardes.config.Runtime.Post = .{};
+ try expectEqual(@as(u32, 0), encodeSceneEffects(&post));
+ _ = post.set(.crt, "", true);
+ try expectEqual(scene_flag_crt, encodeSceneEffects(&post));
+ _ = post.set(.ripple, "", true);
+ _ = post.set(.glitch, "", true);
+ try expectEqual(scene_flag_crt | scene_flag_ripple | scene_flag_glitch, encodeSceneEffects(&post));
try expectEqual(
scene_flag_crt | scene_flag_ripple | scene_flag_glitch,
encodeSceneEffects(.{ .crt = true, .ripple = true, .glitch = true }),
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index c1f78bab..ed10dd70 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -263,7 +263,9 @@ fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void {
// Bare, it names the default directory.
.dump_dir => if (s.dump_dir.len > 0) try w.print("{s} {s}\n", .{ word, s.dump_dir.get() }) else try w.print("{s}\n", .{word}),
.transition => |effect| try w.print("{s} {s}\n", .{ word, if (s.panel_transition == effect) "on" else "off" }),
- .scene => |effect| try w.print("{s} {s}\n", .{ word, if (@field(s.scene_effects, @tagName(effect))) "on" else "off" }),
+ .scene => |effect| try w.print("{s} {s}\n", .{ word, if (s.post.has(effect)) "on" else "off" }),
+ .shader => for (s.post.list()) |entry| if (entry.scene == null) try w.print("{s} {s}\n", .{ word, entry.path.get() }),
+ .shader_animation => try w.print("{s} {s}\n", .{ word, @tagName(s.shader_animation) }),
}
}
try w.writeAll("LocationsConfig ");
diff --git a/src/pardes.zig b/src/pardes.zig
index 616747fa..5fa3307b 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -584,18 +584,10 @@ test "startup config runs builtin lines in order and isolates bad lines" {
};
}
-test "scene effects keep the idle frontend animation clock active" {
+test "a post chain keeps no core clock running: the shell animates it" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
- try std.testing.expect(!p.animationActive());
-
- p.settings.scene_effects.crt = true;
- try std.testing.expect(p.animationActive());
- p.settings.scene_effects = .{ .ripple = true };
- try std.testing.expect(p.animationActive());
- p.settings.scene_effects = .{ .glitch = true };
- try std.testing.expect(p.animationActive());
- p.settings.scene_effects = .{};
+ try std.testing.expect(p.settings.post.set(.crt, "", true));
try std.testing.expect(!p.animationActive());
}
@@ -5443,10 +5435,10 @@ pub const Pardes = struct {
look.cancelLookHover(p);
}
- /// A native scene pass proved unavailable. Config reads these exact bits,
- /// and the idle-clock predicate stops immediately once they are clear.
+ /// A native post pass proved unavailable: the chain is emptied, which
+ /// Config then reports.
pub fn disableSceneEffects(p: *Pardes) void {
- p.settings.scene_effects = .{};
+ p.settings.post.clear();
}
// ---- doc panes ----
@@ -5744,8 +5736,6 @@ pub const Pardes = struct {
pub fn nextWake(p: *const Pardes) ?u64 {
const next = p.stepped_ns + animation.frame_ns;
if (p.chrome_animation.isActive() or p.presentation.animating()) return next;
- const scene = p.settings.scene_effects;
- if (scene.crt or scene.ripple or scene.glitch) return next;
var frames: ?u64 = null;
if (p.look_hover_wait) |waiting| {
const delay = config.look_preview_delay_frames orelse return next;
@@ -6953,8 +6943,8 @@ test "core animation keeps wall time at 60, 120 and 144 Hz and across a stall" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
defer p.deinit();
const frame = animation.frame_ns;
- // Something that animates for as long as it is on.
- p.settings.scene_effects.crt = true;
+ // Something that animates for as long as the test wants: the theme
+ // fade, held at its start before every step.
for ([_]u64{ 60, 120, 144 }) |hz| {
const start: u64 = 5 * std.time.ns_per_s;
p.clock_started = false;
@@ -6963,6 +6953,7 @@ test "core animation keeps wall time at 60, 120 and 144 Hz and across a stall" {
var at: u64 = 1;
while (at <= hz) : (at += 1) {
const before = p.stepped_ns;
+ p.chrome_animation.step = 0;
p.advance(start + at * std.time.ns_per_s / hz);
const taken = (p.stepped_ns - before + frame / 2) / frame;
// Never two steps in one display frame: that is a visible hitch.
@@ -6979,9 +6970,10 @@ test "core animation keeps wall time at 60, 120 and 144 Hz and across a stall" {
}
// A stall of minutes is not minutes of frames.
const before = p.stepped_ns;
+ p.chrome_animation.step = 0;
p.advance(before + 600 * std.time.ns_per_s);
try std.testing.expectEqual(before + 600 * std.time.ns_per_s, p.stepped_ns);
- p.settings.scene_effects.crt = false;
+ p.chrome_animation.step = animation.transition_steps;
p.advance(p.stepped_ns + frame);
try std.testing.expectEqual(@as(?u64, null), p.nextWake());
}