summaryrefslogtreecommitdiff
path: root/src/gui
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-29 05:44:11 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:16 -0300
commit4452a8f467765cbfa0dd0444a9bf416ac8bd0bf5 (patch)
tree0806bb947d2a0d17dcb784d9fc017d66859186db /src/gui
parentfde030a699b3d6667d1fa21ae192b5011e3f373f (diff)
downloadpardes-4452a8f467765cbfa0dd0444a9bf416ac8bd0bf5.tar.gz
pardes-4452a8f467765cbfa0dd0444a9bf416ac8bd0bf5.zip
G4: bloom, vignette and grain in the post chain
Three bundled Shadertoy passes beside Crt, each `Bloom|Vignette|Grain 0..3` (off by default, nothing drawn while off), in shaders/post/: - Bloom: what is brighter than the page (a channel's linear peak over 0.75/0.7/0.6 and over the page's own + 0.15, so a light page does not bloom) glows, 2/3.5/5% (ยง8.2's budget). The glow is a dual Kawase blur in RGBA16F textures at half size and down, 3/4/5 steps by level (shaders/bloom-down, bloom-up; Post.bloomChain), composited in linear light and dithered. - Vignette: round, from halfway to a corner, 10/18/28% at the far corners, in linear light, dithered. - Grain: a still, triangular-spread grain of 1.5/3/5 of 255 on page-coloured pixels only, a logical pixel big; still, so it asks for no frames. Focus rule: the shell passes every bundled pass the rectangles of the tags with their grips, the column and workspace tags, the notices and the cursor (pardesSpare, at most 32, neighbours on one row merged), and a bundled pass leaves them as they are; and a per-theme ceiling (pardesCap, Post.capsOf) keeps the page's text and the selection's text at min(their contrast, 4.5) under each pass at its strongest. User files get no ceiling and no spared rectangles. Fix found on the way: SDL GPU binds at most 4 KiB of a uniform block, so every Shadertoy colour after iPalette (iBackgroundColor, iForegroundColor, the cursor and selection colours, iPalette[>=4]) read zero. The prefix's block is cut in three (Globals, Palette, Colors) with ghostty's names and order, each pushed as a slice of the one struct; pardes's own block moves to binding 3. Tests: the caps hold over every native theme; each uniform slice fits 4 KiB; the prefix's members in ghostty's order across its blocks. Gates: tty/gui unit-test and core-test pass; snap fails only nested-optout; GUI goldens 01-08 byte-identical (09-18 wait on wkzlsqvy, as in somtrmsz); web builds. Feel review material: .scratch/render/{bloom,vignette,grain}/ (levels on forge, dusk, acme, crops, a levels mp4).
Diffstat (limited to 'src/gui')
-rw-r--r--src/gui/Post.zig291
-rw-r--r--src/gui/gui.zig42
2 files changed, 316 insertions, 17 deletions
diff --git a/src/gui/Post.zig b/src/gui/Post.zig
index 45798f8d..61ebc2cd 100644
--- a/src/gui/Post.zig
+++ b/src/gui/Post.zig
@@ -28,7 +28,27 @@ 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"),
+ .bloom = @embedFile("post-bloom.frag.spv"),
+ .vignette = @embedFile("post-vignette.frag.spv"),
+ .grain = @embedFile("post-grain.frag.spv"),
});
+const bloom_down_spv = @embedFile("bloom-down.frag.spv");
+const bloom_up_spv = @embedFile("bloom-up.frag.spv");
+/// Bloom's steps below the frame, at levels 1 to 3: a half, a quarter, ...
+const bloom_steps = [_]u8{ 3, 4, 5 };
+const bloom_format = c.SDL_GPU_TEXTUREFORMAT_R16G16B16A16_FLOAT;
+
+/// pardes's own block (shaders/post/prefix.glsl PardesPost), std140.
+pub const Own = extern struct {
+ opaque_window: f32 = 1,
+ level: f32 = 0,
+ scale: f32 = 1,
+ cap: f32 = 0,
+ spare_count: f32 = 0,
+ _pad: [3]f32 = @splat(0),
+ spare: [max_spare][4]f32 = @splat(@splat(0)),
+};
+pub const max_spare = 32;
/// 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
@@ -101,6 +121,18 @@ uniforms: Uniforms = .{},
/// The display's density, for a pass whose pattern is in logical pixels
/// (pardesScale); the shell sets it.
scale: f32 = 1,
+/// What a bundled pass may do on this theme (pardesCap), each scene's, and
+/// the rectangles every bundled pass leaves alone (pardesSpare): both from
+/// the last frame described.
+caps: std.EnumArray(Scene, f32) = .initFill(0),
+spare: [max_spare][4]f32 = @splat(@splat(0)),
+spare_len: usize = 0,
+/// Bloom's float chain, a half the frame and down, and its two steps.
+bloom_tex: [bloom_steps[bloom_steps.len - 1]]?*c.SDL_GPUTexture = @splat(null),
+bloom_w: u32 = 0,
+bloom_h: u32 = 0,
+bloom_down: ?*Pipeline = null,
+bloom_up: ?*Pipeline = null,
started_ns: ?u64 = null,
last_ns: u64 = 0,
@@ -119,7 +151,11 @@ pub fn sync(post: *Post, gpa: std.mem.Allocator, io: std.Io, device: *c.SDL_GPUD
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);
+ if (post.bundled.get(scene) == null) post.bundled.set(scene, makePipeline(device, format, bundled_spv.get(scene), if (scene == .bloom) 2 else 1, 4) catch null);
+ if (scene == .bloom and post.bloom_down == null) {
+ post.bloom_down = makePipeline(device, bloom_format, bloom_down_spv, 1, 1) catch null;
+ post.bloom_up = makePipeline(device, bloom_format, bloom_up_spv, 1, 1) catch null;
+ }
next[i] = .{ .scene = scene, .level = entry.level, .pipeline = post.bundled.get(scene) };
continue;
}
@@ -182,7 +218,7 @@ fn collect(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice, format
.spirv => |spirv| {
defer gpa.free(spirv);
const target = pass orelse continue;
- const made = makePipeline(device, format, spirv) catch {
+ const made = makePipeline(device, format, spirv, 1, 4) catch {
core.setMessage(core.active, std.fmt.bufPrint(&buf, "Shader {s}: the GPU refused it", .{path}) catch "Shader: the GPU refused it");
continue;
};
@@ -266,10 +302,10 @@ fn compileOne(gpa: std.mem.Allocator, io: std.Io, path: []const u8) Job.Result {
return .{ .failed = named };
}
-fn makePipeline(device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, frag: []const u8) !*Pipeline {
+fn makePipeline(device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, frag: []const u8, samplers: u32, uniform_buffers: u32) !*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);
+ const fs = try gui.makeShader(device, frag, c.SDL_GPU_SHADERSTAGE_FRAGMENT, samplers, uniform_buffers);
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);
@@ -303,6 +339,9 @@ pub fn animating(post: *const Post, mode: pardes.config.Runtime.ShaderAnimation)
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,
+ /// What a bundled pass leaves alone, in framebuffer pixels: the tags,
+ /// grips and notices (and the cursor, added here).
+ spare: []const [4]f32 = &.{},
background: [3]u8,
foreground: [3]u8,
selection_foreground: [3]u8,
@@ -318,6 +357,10 @@ fn rgba(rgb: [3]u8) [4]f32 {
/// colour makes the old one the previous and stamps the change.
pub fn describe(post: *Post, frame: Frame) void {
const u = &post.uniforms;
+ post.caps = capsOf(frame);
+ post.spare_len = 0;
+ for (frame.spare) |rect| post.addSpare(rect);
+ if (frame.cursor) |cursor| post.addSpare(.{ cursor.x0, cursor.y0, cursor.x1, cursor.y1 });
u.background_color = rgba(frame.background);
u.foreground_color = rgba(frame.foreground);
u.selection_foreground_color = rgba(frame.selection_foreground);
@@ -343,6 +386,102 @@ pub fn describe(post: *Post, frame: Frame) void {
u.cursor_change_time = u.time;
}
+/// A rectangle a bundled pass leaves alone; past `max_spare` the last one
+/// grows to cover the rest (sparing more, never less).
+fn addSpare(post: *Post, rect: [4]f32) void {
+ if (rect[2] <= rect[0] or rect[3] <= rect[1]) return;
+ if (post.spare_len < max_spare) {
+ post.spare[post.spare_len] = rect;
+ post.spare_len += 1;
+ return;
+ }
+ const last = &post.spare[max_spare - 1];
+ last.* = .{ @min(last[0], rect[0]), @min(last[1], rect[1]), @max(last[2], rect[2]), @max(last[3], rect[3]) };
+}
+
+/// sRGB's relative luminance (WCAG) and a pair's contrast.
+fn luminance(rgb: [3]f32) f32 {
+ var sum: f32 = 0;
+ for (rgb, [3]f32{ 0.2126, 0.7152, 0.0722 }) |v, weight|
+ sum += weight * (if (v <= 0.04045) v / 12.92 else std.math.pow(f32, (v + 0.055) / 1.055, 2.4));
+ return sum;
+}
+fn contrastOf(a: f32, b: f32) f32 {
+ return (@max(a, b) + 0.05) / (@min(a, b) + 0.05);
+}
+fn unit(rgb: [3]u8) [3]f32 {
+ return .{ @as(f32, @floatFromInt(rgb[0])) / 255, @as(f32, @floatFromInt(rgb[1])) / 255, @as(f32, @floatFromInt(rgb[2])) / 255 };
+}
+
+/// Each bundled pass's ceiling on this theme (pardesCap, docs/effects.md):
+/// the most it may do while the text on the page, and the selection's text
+/// on its own ground, keep min(their contrast, 4.5). Bloom adds light to
+/// both of a pair (linear, as a share of full white); a vignette darkens
+/// both (a share); grain moves the page alone, either way (sRGB units).
+pub fn capsOf(frame: Frame) std.EnumArray(Scene, f32) {
+ const pairs = [_][2][3]f32{ .{ unit(frame.foreground), unit(frame.background) }, .{ unit(frame.selection_foreground), unit(frame.selection_background) } };
+ var bloom: f32 = 1;
+ var shade: f32 = 1;
+ for (pairs) |pair| {
+ const a = luminance(pair[0]);
+ const b = luminance(pair[1]);
+ const light = @max(a, b);
+ const dark = @min(a, b);
+ const target = @min(contrastOf(a, b), 4.5);
+ // (light + e + .05) / (dark + e + .05) >= target
+ bloom = @min(bloom, @max(0, (light + 0.05 - target * (dark + 0.05)) / @max(target - 1, 1e-3)));
+ // (k light + .05) / (k dark + .05) >= target, darkened by 1 - k
+ const room = light - target * dark;
+ shade = @min(shade, if (room <= 0) 0 else std.math.clamp(1 - 0.05 * (target - 1) / room, 0, 1));
+ }
+ // Grain: the largest step off the page, up and down, that keeps the text
+ // on it at its contrast or 4.5 (searched in 1/1020 steps up to 8/255).
+ const ink = luminance(unit(frame.foreground));
+ const page = unit(frame.background);
+ const target = @min(contrastOf(ink, luminance(page)), 4.5);
+ var grain: f32 = 0;
+ var step: f32 = 0.25 / 255.0;
+ while (step <= 8.0 / 255.0) : (step += 0.25 / 255.0) {
+ var up = page;
+ var down = page;
+ for (&up, &down) |*u, *d| {
+ u.* = @min(1, u.* + step);
+ d.* = @max(0, d.* - step);
+ }
+ if (contrastOf(ink, luminance(up)) < target - 1e-4 or contrastOf(ink, luminance(down)) < target - 1e-4) break;
+ grain = step;
+ }
+ var caps: std.EnumArray(Scene, f32) = .initFill(1);
+ caps.set(.bloom, bloom);
+ caps.set(.vignette, shade);
+ caps.set(.grain, grain);
+ return caps;
+}
+
+test "no bundled pass at its ceiling takes a native theme's text or selection below its contrast or 4.5" {
+ for (pardes.themes[0..pardes.colors.native_count]) |*theme| {
+ const frame: Frame = .{ .cursor = null, .background = theme.bg orelse .{ 18, 18, 18 }, .foreground = theme.fg orelse .{ 204, 204, 204 }, .selection_foreground = theme.sel_fg, .selection_background = theme.sel_bg };
+ const caps = capsOf(frame);
+ for ([_][2][3]u8{ .{ frame.foreground, frame.background }, .{ frame.selection_foreground, frame.selection_background } }, 0..) |pair, i| {
+ const a = luminance(unit(pair[0]));
+ const b = luminance(unit(pair[1]));
+ const target = @min(contrastOf(a, b), 4.5) - 1e-3;
+ // Bloom at its ceiling, over both.
+ const e = @min(caps.get(.bloom), 0.05);
+ try std.testing.expect(contrastOf(a + e, b + e) >= target);
+ // The vignette at its ceiling, over both.
+ const k = 1 - @min(caps.get(.vignette), 0.28);
+ try std.testing.expect(contrastOf(a * k, b * k) >= target);
+ // Grain moves the page alone.
+ if (i == 0) {
+ var up = unit(pair[1]);
+ for (&up) |*v| v.* = @min(1, v.* + caps.get(.grain));
+ try std.testing.expect(contrastOf(a, luminance(up)) >= target);
+ }
+ }
+ }
+}
+
/// 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;
@@ -420,13 +559,25 @@ pub fn draw(post: *Post, device: *c.SDL_GPUDevice, cmd: *c.SDL_GPUCommandBuffer,
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;
+ // Bloom's glow first, from this pass's input, at half size and down.
+ // A chain that could not be made adds no glow, and the pass is the
+ // frame as it was.
+ const glow = if (pass.scene == .bloom) post.bloomChain(device, cmd, sampler, input, w, h, pass.level) catch null else null;
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), post.scale, 0 };
- c.SDL_PushGPUFragmentUniformData(cmd, 1, &own, @sizeOf(@TypeOf(own)));
+ const bindings = [2]c.SDL_GPUTextureSamplerBinding{ .{ .texture = input, .sampler = sampler }, .{ .texture = glow orelse input, .sampler = sampler } };
+ c.SDL_BindGPUFragmentSamplers(pass_rp, 0, &bindings, if (pass.scene == .bloom) 2 else 1);
+ pushUniforms(cmd, u);
+ var own: Own = .{
+ .opaque_window = @floatFromInt(@intFromBool(opaque_window)),
+ .level = @floatFromInt(pass.level),
+ .scale = post.scale,
+ // A user's file has no ceiling of ours, and no spared rectangles.
+ .cap = if (pass.scene) |scene| (if (scene == .bloom and glow == null) 0 else post.caps.get(scene)) else 1,
+ .spare_count = if (pass.scene != null) @floatFromInt(post.spare_len) else 0,
+ };
+ if (pass.scene != null) @memcpy(own.spare[0..post.spare_len], post.spare[0..post.spare_len]);
+ c.SDL_PushGPUFragmentUniformData(cmd, 3, &own, @sizeOf(Own));
c.SDL_DrawGPUPrimitives(pass_rp, 3, 1, 0, 0);
c.SDL_EndGPURenderPass(pass_rp);
input = output;
@@ -434,7 +585,107 @@ pub fn draw(post: *Post, device: *c.SDL_GPUDevice, cmd: *c.SDL_GPUCommandBuffer,
u.frame +%= 1;
}
+/// ghostty's block as the prefix cuts it (SDL GPU binds at most 4 KiB of a
+/// block): the globals up to the palette, the palette, the colours after it,
+/// each a slice of the one struct, at set 3 bindings 0 to 2.
+fn pushUniforms(cmd: *c.SDL_GPUCommandBuffer, u: *const Uniforms) void {
+ const bytes = std.mem.asBytes(u);
+ const palette = @offsetOf(Uniforms, "palette");
+ const colors = @offsetOf(Uniforms, "background_color");
+ c.SDL_PushGPUFragmentUniformData(cmd, 0, bytes.ptr, @intCast(palette));
+ c.SDL_PushGPUFragmentUniformData(cmd, 1, bytes.ptr + palette, @intCast(colors - palette));
+ c.SDL_PushGPUFragmentUniformData(cmd, 2, bytes.ptr + colors, @intCast(@sizeOf(Uniforms) - colors));
+}
+
+test "each slice of the uniforms fits the 4 KiB SDL GPU binds of a block" {
+ try std.testing.expect(@offsetOf(Uniforms, "palette") <= 4096);
+ try std.testing.expect(@offsetOf(Uniforms, "background_color") - @offsetOf(Uniforms, "palette") <= 4096);
+ try std.testing.expect(@sizeOf(Uniforms) - @offsetOf(Uniforms, "background_color") <= 4096);
+ try std.testing.expect(@sizeOf(Own) <= 4096);
+}
+
+/// Bloom's glow of `input` (w x h): a bright pass and dual Kawase steps
+/// down to 1/2^n of the frame and back up to a half, in float textures;
+/// the half-size result, for the composite (shaders/post/bloom.glsl).
+fn bloomChain(post: *Post, device: *c.SDL_GPUDevice, cmd: *c.SDL_GPUCommandBuffer, sampler: *c.SDL_GPUSampler, input: *c.SDL_GPUTexture, w: u32, h: u32, level: u8) !*c.SDL_GPUTexture {
+ const down = post.bloom_down orelse return error.GpuCreate;
+ const up = post.bloom_up orelse return error.GpuCreate;
+ const steps = bloom_steps[std.math.clamp(level, 1, 3) - 1];
+ if (post.bloom_tex[0] == null or post.bloom_w != w or post.bloom_h != h) {
+ for (&post.bloom_tex, 0..) |*texture, i| {
+ 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 = bloom_format;
+ info.usage = c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | c.SDL_GPU_TEXTUREUSAGE_SAMPLER;
+ info.width = @max(1, w >> @intCast(i + 1));
+ info.height = @max(1, h >> @intCast(i + 1));
+ 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.bloom_w = w;
+ post.bloom_h = h;
+ }
+ const Step = extern struct { texels: [4]f32, pass: [4]f32 };
+ const size = struct {
+ fn of(full: u32, i: usize) f32 {
+ return @floatFromInt(if (i == 0) full else @max(1, full >> @intCast(i)));
+ }
+ };
+ // Down: the frame (0) to 1, 1 to 2, ...; the first through the bright
+ // pass (a channel's linear peak over 0.6 to 0.75, by level).
+ const threshold: f32 = switch (std.math.clamp(level, 1, 3)) {
+ 1 => 0.75,
+ 2 => 0.7,
+ else => 0.6,
+ };
+ // Only what is brighter than the page glows: on a light page, where the
+ // ground itself would pass, nothing does.
+ const page_peak = peak: {
+ var peak: f32 = 0;
+ for (post.uniforms.background_color[0..3]) |v| peak = @max(peak, if (v <= 0.04045) v / 12.92 else std.math.pow(f32, (v + 0.055) / 1.055, 2.4));
+ break :peak peak;
+ };
+ for (0..steps) |i| {
+ const from = if (i == 0) input else post.bloom_tex[i - 1].?;
+ const to = post.bloom_tex[i].?;
+ const params: Step = .{
+ .texels = .{ 1 / size.of(w, i), 1 / size.of(h, i), 1 / size.of(w, i + 1), 1 / size.of(h, i + 1) },
+ .pass = .{ if (i == 0) @max(threshold, page_peak + 0.15) else 0, 0.25, 0, 0 },
+ };
+ bloomStep(cmd, down, sampler, from, to, &params, @sizeOf(Step));
+ }
+ // Up: each level into the one above it, back to the half.
+ var i: usize = steps - 1;
+ while (i > 0) : (i -= 1) {
+ const params: Step = .{
+ .texels = .{ 1 / size.of(w, i + 1), 1 / size.of(h, i + 1), 1 / size.of(w, i), 1 / size.of(h, i) },
+ .pass = @splat(0),
+ };
+ bloomStep(cmd, up, sampler, post.bloom_tex[i].?, post.bloom_tex[i - 1].?, &params, @sizeOf(Step));
+ }
+ return post.bloom_tex[0].?;
+}
+
+fn bloomStep(cmd: *c.SDL_GPUCommandBuffer, pipeline: *Pipeline, sampler: *c.SDL_GPUSampler, from: *c.SDL_GPUTexture, to: *c.SDL_GPUTexture, params: *const anyopaque, len: u32) void {
+ var info = std.mem.zeroes(c.SDL_GPUColorTargetInfo);
+ info.texture = to;
+ info.load_op = c.SDL_GPU_LOADOP_DONT_CARE;
+ info.store_op = c.SDL_GPU_STOREOP_STORE;
+ const rp = c.SDL_BeginGPURenderPass(cmd, &info, 1, null);
+ c.SDL_BindGPUGraphicsPipeline(rp, pipeline);
+ const binding = c.SDL_GPUTextureSamplerBinding{ .texture = from, .sampler = sampler };
+ c.SDL_BindGPUFragmentSamplers(rp, 0, &binding, 1);
+ c.SDL_PushGPUFragmentUniformData(cmd, 0, params, len);
+ c.SDL_DrawGPUPrimitives(rp, 3, 1, 0, 0);
+ c.SDL_EndGPURenderPass(rp);
+}
+
pub fn deinit(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice) void {
+ for (post.bloom_tex) |texture| if (texture) |t| c.SDL_ReleaseGPUTexture(device, t);
+ for ([_]?*Pipeline{ post.bloom_down, post.bloom_up }) |pipeline| if (pipeline) |p| c.SDL_ReleaseGPUGraphicsPipeline(device, p);
if (post.job) |job| {
job.thread.join();
for (job.paths[0..job.len], job.results[0..job.len]) |path, result| {
@@ -511,16 +762,22 @@ test "the uniform block is ghostty's, offset for offset, and the prefix names it
.{ "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().?, " ;");
+ // Its three blocks (Globals, Palette, Colors) read as one, in order.
+ const start = std.mem.indexOf(u8, prefix, "uniform Globals {").?;
+ const end = std.mem.indexOf(u8, prefix, "uniform PardesPost {").?;
+ var lines = std.mem.tokenizeScalar(u8, prefix[start..end], '\n');
+ var n: usize = 0;
+ while (lines.next()) |line| {
+ if (!std.mem.startsWith(u8, line, " uniform ")) continue;
+ var words = std.mem.tokenizeAny(u8, line, " ;");
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.expectEqualStrings(glsl[n][0], words.next().?);
+ try std.testing.expectEqualStrings(glsl[n][1], words.next().?);
+ n += 1;
}
- try std.testing.expect(lines.next() == null);
+ try std.testing.expectEqual(glsl.len, n);
+ // Each block starts where its first member is in the one struct.
+ try std.testing.expect(std.mem.indexOf(u8, prefix, "uniform Palette {\n uniform vec3 iPalette[256];\n};") != null);
}
test "a moved cursor becomes the previous one, stamped with the time of the move" {
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index fdcd7b2a..bde9aecd 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -5732,8 +5732,10 @@ fn renderFrame(
if (core) |p| {
var boxes: [max_cursors]CursorBox = undefined;
const cursors = if (cursors_shown) cursorBoxes(g, surface, layout, win_h, &boxes) else 0;
+ var spare: [Post.max_spare][4]f32 = undefined;
g.post.describe(.{
.cursor = if (cursors > 0) boxes[0] else null,
+ .spare = spare[0..postSpare(surface, layout, &spare)],
.background = chrome.page orelse bg_default,
.foreground = chrome.fg orelse fg_default,
.selection_foreground = p.theme().sel_fg,
@@ -8594,6 +8596,46 @@ 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);
}
+/// What a bundled post pass leaves alone (Post.Frame.spare), in window
+/// pixels: every tag with its grip, the column and workspace tags, and the
+/// notices, the chrome the focus indicators live in. A grip, the gap after
+/// it and the tag beside it are one rectangle; past the list's end the last
+/// grows to cover the rest.
+fn postSpare(surface: *const pardes.Surface, layout: CellLayout, out: *[Post.max_spare][4]f32) usize {
+ var n: usize = 0;
+ for (surface.regionList()) |region| {
+ switch (region.kind) {
+ .grip, .tag, .notice, .column_grip, .column_tag, .workspace_tag => {},
+ else => continue,
+ }
+ if (region.rect.w == 0 or region.rect.h == 0) continue;
+ const rect: [4]f32 = .{
+ @as(f32, @floatFromInt(region.rect.x)) * layout.w,
+ @as(f32, @floatFromInt(region.rect.y)) * layout.h,
+ @as(f32, @floatFromInt(region.rect.x + region.rect.w)) * layout.w,
+ @as(f32, @floatFromInt(region.rect.y + region.rect.h)) * layout.h,
+ };
+ const joined = for (out[0..n]) |*other| {
+ // The same rows, a gap cell or two apart (a grip, the gap after
+ // it, its tag): one band.
+ const gap = 2 * layout.w + 0.5;
+ if (other[1] == rect[1] and other[3] == rect[3] and rect[0] <= other[2] + gap and other[0] <= rect[2] + gap) {
+ other.* = .{ @min(other[0], rect[0]), rect[1], @max(other[2], rect[2]), rect[3] };
+ break true;
+ }
+ } else false;
+ if (joined) continue;
+ if (n == out.len) {
+ const last = &out[n - 1];
+ last.* = .{ @min(last[0], rect[0]), @min(last[1], rect[1]), @max(last[2], rect[2]), @max(last[3], rect[3]) };
+ continue;
+ }
+ out[n] = rect;
+ n += 1;
+ }
+ return n;
+}
+
const CursorBox = Post.Frame.Cursor;
const max_cursors = 1 + pardes.MAX_PANES + pardes.MAX_TAG_LAYERS;