diff options
Diffstat (limited to 'src/gui')
| -rw-r--r-- | src/gui/Post.zig | 559 | ||||
| -rw-r--r-- | src/gui/crt.zig | 101 | ||||
| -rw-r--r-- | src/gui/gui.zig | 334 |
3 files changed, 728 insertions, 266 deletions
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 |
