//! 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 ), //! 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 ; the file is what a person knows. defer gpa.free(said); const named = std.mem.replaceOwned(u8, gpa, said, "", 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 . try std.testing.expect(std.mem.indexOf(u8, text, path) != null); try std.testing.expect(std.mem.indexOf(u8, text, "") == null); }, .none => return error.TestUnexpectedResult, } } }