summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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
10 files changed, 905 insertions, 325 deletions
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());
}