summaryrefslogtreecommitdiff
path: root/src/gui/Post.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/gui/Post.zig')
-rw-r--r--src/gui/Post.zig576
1 files changed, 576 insertions, 0 deletions
diff --git a/src/gui/Post.zig b/src/gui/Post.zig
new file mode 100644
index 00000000..f715965f
--- /dev/null
+++ b/src/gui/Post.zig
@@ -0,0 +1,576 @@
+//! 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 the bundled Crt (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 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"),
+});
+
+/// 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 bundled pass's strength (pardesLevel); 0 for a file.
+ level: u8 = 0,
+ /// 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, .level = entry.level, .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 does)");
+ },
+ .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 with the
+/// build's own flags (build.zig compileGlsl), source on stdin, 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);
+}
+
+fn compileOne(gpa: std.mem.Allocator, io: std.Io, path: []const u8) 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);
+ // The prefix and the file go to glslc on its stdin: no file of ours is
+ // written anywhere. std's spawn does every allocation before fork and
+ // searches PATH on the stack after it (Io/Threaded.zig spawnPosix,
+ // posixExecv), so a child forked from this thread cannot wedge on a
+ // lock another thread held (the forkShell hazard).
+ var child = std.process.spawn(io, .{
+ .argv = &.{ "glslc", "-fshader-stage=fragment", "-o", "-", "-" },
+ .stdin = .pipe,
+ .stdout = .pipe,
+ .stderr = .pipe,
+ }) catch |err| return switch (err) {
+ error.FileNotFound => .missing,
+ else => .{ .failed = std.fmt.allocPrint(gpa, "glslc did not run ({t})", .{err}) catch return .none },
+ };
+ defer child.kill(io);
+ // glslc reads all of its input before it writes a byte, so writing it
+ // first cannot deadlock against a full output pipe.
+ {
+ var stdin = child.stdin.?;
+ child.stdin = null;
+ defer stdin.close(io);
+ var buf: [4096]u8 = undefined;
+ var writer = stdin.writer(io, &buf);
+ writer.interface.writeAll(prefix) catch {};
+ writer.interface.writeAll(body) catch {};
+ writer.interface.flush() catch {};
+ }
+ var streams: std.Io.File.MultiReader.Buffer(2) = undefined;
+ var reader: std.Io.File.MultiReader = undefined;
+ reader.init(gpa, io, streams.toStreams(), &.{ child.stdout.?, child.stderr.? });
+ defer reader.deinit();
+ while (reader.fill(64, .none)) |_| {
+ if (reader.reader(0).buffered().len > 16 << 20 or reader.reader(1).buffered().len > 64 << 10)
+ return .{ .failed = gpa.dupe(u8, "glslc said too much") catch return .none };
+ } else |err| switch (err) {
+ error.EndOfStream => {},
+ else => return .{ .failed = gpa.dupe(u8, "reading glslc failed") catch return .none },
+ }
+ const term = child.wait(io) catch return .{ .failed = gpa.dupe(u8, "glslc did not finish") catch return .none };
+ const clean = switch (term) {
+ .exited => |code| code == 0,
+ else => false,
+ };
+ if (clean) return .{ .spirv = reader.toOwnedSlice(0) catch return .none };
+ const said = reader.toOwnedSlice(1) catch return .none;
+ // glslc calls its input <stdin>; the file is what a person knows.
+ defer gpa.free(said);
+ const named = std.mem.replaceOwned(u8, gpa, said, "<stdin>", 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) };
+
+ 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));
+ const own: [4]f32 = .{ @floatFromInt(@intFromBool(opaque_window)), @floatFromInt(pass.level), 0, 0 };
+ 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" }) |name| {
+ 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);
+ 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 glslc's <stdin>.
+ try std.testing.expect(std.mem.indexOf(u8, text, path) != null);
+ try std.testing.expect(std.mem.indexOf(u8, text, "<stdin>") == null);
+ },
+ .none => return error.TestUnexpectedResult,
+ }
+ }
+}