From c6a5aaf5c4a020eaefe24943ecb6cf9d4eb26836 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 15:57:22 -0300 Subject: Post chain: Shadertoy passes compiled by glslc; Crt, Ripple and Glitch rewritten as Shadertoy files Stage 9 of docs/render-pipeline.md. src/gui/Post.zig runs the chain over the finished frame: each pass reads the one before, ping-pong textures between, the last writes the window; an empty chain is the direct path. The prefix (shaders/post/prefix.glsl) is ghostty's uniform block, name for name and offset for offset (tested against a copy of its Uniforms, the selection colours by their GLSL names), at SDL GPU's bindings, y down. A user's file (Shader ) is compiled by glslc with compileGlsl's own flags on a thread of its own, taken in at the next loop step; errors name the file's own lines; a failed compile keeps the last good pipeline; glslc missing is said once. The bundled passes compile with the build, through the same prefix. Crt, Ripple and Glitch are Shadertoy files, rewritten: no barrel (input is identity, tested), everything in linear light and dithered, keyed to iTime. scene_effects, crt.zig and crt.frag.glsl are gone; the core keeps no clock for the chain. ShaderAnimation off|on|always drives redraw level A: the chain alone over the retained frame (34 us CPU a redraw, measured). Shared files touched: pardes.zig (nextWake loses the scene line, disableSceneEffects empties the chain, two tests), builtins.zig (one switch arm), ninep/ctl.zig (two switch arms), macos.zig (flags from the chain), gui.zig. Not touched: Messages.zig, mouse.zig, detached/*, host_io, tty. --- src/config.zig | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 133 insertions(+), 17 deletions(-) (limited to 'src/config.zig') 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); -- cgit v1.3