summaryrefslogtreecommitdiff
path: root/src/gui
diff options
context:
space:
mode:
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;