summaryrefslogtreecommitdiff
path: root/src/gui/gui.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/gui/gui.zig')
-rw-r--r--src/gui/gui.zig934
1 files changed, 894 insertions, 40 deletions
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 02f7bd9e..5dfacee9 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -73,6 +73,24 @@ const p9_arrow_set = [32]u8{
0x7F, 0xFC, 0x7F, 0xFE, 0x7F, 0xFC, 0x73, 0xF8,
0x61, 0xF0, 0x60, 0xE0, 0x40, 0x40, 0x00, 0x00,
};
+/// acme's boxcursor (acme.c:956), while a grip carries a pane or column.
+const p9_box_clr = [32]u8{
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xF8, 0x1F, 0xF8, 0x1F, 0xF8, 0x1F,
+ 0xF8, 0x1F, 0xF8, 0x1F, 0xF8, 0x1F, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+};
+const p9_box_set = [32]u8{
+ 0x00, 0x00, 0x7F, 0xFE, 0x7F, 0xFE, 0x7F, 0xFE,
+ 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E,
+ 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E,
+ 0x7F, 0xFE, 0x7F, 0xFE, 0x7F, 0xFE, 0x00, 0x00,
+};
+const p9_box_mask = blk: {
+ var m: [32]u8 = undefined;
+ for (&m, p9_box_set, p9_box_clr) |*b, s, cl| b.* = s | cl;
+ break :blk m;
+};
const p9_arrow_mask = blk: {
var m: [32]u8 = undefined;
for (&m, p9_arrow_set, p9_arrow_clr) |*b, s, cl| b.* = s | cl;
@@ -487,6 +505,8 @@ const initial_image_capacity: u32 = pardes.MAX_PANES;
const old_layer_bit: u32 = 0x8000_0000;
const clear_bg_bit: u32 = 0x4000_0000;
const opaque_bg_bit: u32 = 0x2000_0000;
+/// Decor that is a soft shadow, not a flat fill (decor.frag.glsl).
+const soft_shadow_bit: u32 = 0x1000_0000;
const SavedImagePlace = struct {
key: pardes.ImageCacheKey,
@@ -1847,6 +1867,7 @@ const Gui = struct {
pointer_present: bool = false,
arrow_cursor: ?*c.SDL_Cursor = null,
link_cursor: ?*c.SDL_Cursor = null,
+ box_cursor: ?*c.SDL_Cursor = null,
pointer_shape: pardes.Surface.PointerShape = .arrow,
pointer_mapped: bool = false,
pointer_cell: ?MouseCell = null,
@@ -1855,6 +1876,10 @@ const Gui = struct {
/// Pixels the capture holds past the grid (PARDES_TEST_PAD).
capture_pad: u32 = 0,
capture_dir: []const u8 = "",
+ /// PARDES_TEST_SERIES: every captured frame is kept too, numbered, for a
+ /// feel review's frame series (docs/render-pipeline.md §8.4).
+ capture_series: bool = false,
+ captured: u32 = 0,
capture_tex: ?*c.SDL_GPUTexture = null,
capture_tex_w: u32 = 0,
capture_tex_h: u32 = 0,
@@ -2105,7 +2130,13 @@ const Cover = packed struct(u16) {
focus: bool = false,
/// At a column's left edge, where a tag's anchor is inset.
anchor: bool = false,
- _: u7 = 0,
+ /// In a dirty pane's grip.
+ dirty: bool = false,
+ /// In a pane's grip (not a column's).
+ pane_grip: bool = false,
+ /// In a grip's first row, where its button is.
+ grip_top: bool = false,
+ _: u4 = 0,
};
/// Marks the grid cells a body or tag layer covers, which the layers paint
@@ -2137,10 +2168,13 @@ fn coverFrame(cover: []Cover, cols: u16, rows: u16, body_layers: []const pardes.
if (region.kind == .column_grip) bar = region.rect;
}
for (regions) |region| switch (region.kind) {
- .grip => {
+ .grip, .column_grip => {
mark.rect(cover, cols, rows, region.rect, "grip", true);
for (0..@min(region.rect.w, 16)) |offset| mark.rect(cover, cols, rows, .{ .x = region.rect.x +| @as(u16, @intCast(offset)), .y = region.rect.y, .w = 1, .h = region.rect.h }, "grip_offset", @as(u4, @intCast(offset)));
- if (region.active) mark.rect(cover, cols, rows, region.rect, "focus", true);
+ if (region.kind == .grip and region.active) mark.rect(cover, cols, rows, region.rect, "focus", true);
+ if (region.dirty) mark.rect(cover, cols, rows, region.rect, "dirty", true);
+ if (region.kind == .grip) mark.rect(cover, cols, rows, region.rect, "pane_grip", true);
+ mark.rect(cover, cols, rows, .{ .x = region.rect.x, .y = region.rect.y, .w = region.rect.w, .h = @min(1, region.rect.h) }, "grip_top", true);
},
.tag => if (region.active) mark.rect(cover, cols, rows, region.rect, "focus", true),
.column => {
@@ -2292,6 +2326,9 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
defer if (arrow_cursor) |cursor| c.SDL_DestroyCursor(cursor);
const link_cursor = c.SDL_CreateSystemCursor(c.SDL_SYSTEM_CURSOR_POINTER);
defer if (link_cursor) |cursor| c.SDL_DestroyCursor(cursor);
+ // Its hot spot the middle, as acme's {-7, -7} offset puts it.
+ const box_cursor = c.SDL_CreateCursor(&p9_box_set, &p9_box_mask, 16, 16, 7, 7);
+ defer if (box_cursor) |cursor| c.SDL_DestroyCursor(cursor);
if (arrow_cursor) |cur| {
_ = c.SDL_SetCursor(cur);
} else log.err("SDL_CreateCursor: {s}", .{c.SDL_GetError()});
@@ -2427,6 +2464,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
.window = window,
.arrow_cursor = arrow_cursor,
.link_cursor = link_cursor,
+ .box_cursor = box_cursor,
.device = device,
.swapchain_format = swapchain_format,
.pipeline = pipeline,
@@ -2457,6 +2495,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
.glyphs = std.AutoHashMap(GlyphKey, Slot).init(gpa),
.capture = test_mode,
.capture_pad = if (test_mode) envU16(env, "PARDES_TEST_PAD") orelse 0 else 0,
+ .capture_series = test_mode and env.get("PARDES_TEST_SERIES") != null,
.capture_dir = capture_dir orelse "",
.soft_present = soft_present,
.transparent = config.gui_transparent,
@@ -2606,6 +2645,7 @@ fn localSession(
.fs = fs,
.test_mode = test_mode,
.test_clock = if (test_mode) host_io.testClock() else null,
+ .test_hz = if (std.c.getenv("PARDES_TEST_HZ")) |hz| @max(1, std.fmt.parseInt(u64, std.mem.span(hz), 10) catch 60) else 60,
};
defer shell.shutdownPtys();
const host = shell.host();
@@ -3892,6 +3932,8 @@ const Shell = struct {
retired_shells: [pardes.MAX_PANES]RetiredShell = @splat(.{}),
/// PARDES_TEST_CLOCK's virtual time (host_io.testClock), in test mode.
test_clock: ?u64 = null,
+ /// The display rate a virtual clock stands in for (PARDES_TEST_HZ).
+ test_hz: u64 = 60,
fn reap(s: *Shell) void {
const now = shellClock();
@@ -4142,10 +4184,17 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void {
pardes.turn.rest();
const got = c.SDL_WaitEventTimeout(&sev, ms);
pardes.turn.wake();
- // The core's wake ran out: a virtual clock moves exactly there.
- if (!got and timeout_ms != 0) if (s.test_clock) |*virtual| {
- virtual.* = @max(virtual.*, core.nextWake() orelse virtual.*);
- };
+ // A virtual clock: while a render-time animation moves, each loop
+ // is a presented frame and moves it by one display frame
+ // (PARDES_TEST_HZ), events or not, as a real display's present
+ // would; otherwise a wait that ran out moves it exactly to the
+ // core's wake.
+ if (s.test_clock) |*virtual| {
+ if (core.continuous()) {
+ const display = virtual.* + std.time.ns_per_s / s.test_hz;
+ virtual.* = if (core.nextWake()) |due| @max(virtual.*, @min(due, display)) else display;
+ } else if (!got and timeout_ms != 0) virtual.* = @max(virtual.*, core.nextWake() orelse virtual.*);
+ }
if (got) {
// A run of queued motions is one move to its last position (the
// handler reads only absolute x/y and held buttons): acting on
@@ -5269,7 +5318,11 @@ fn renderFrame(
) !bool {
if (surface.pointer_shape != g.pointer_shape) {
g.pointer_shape = surface.pointer_shape;
- const cursor = if (g.pointer_shape == .link) g.link_cursor else g.arrow_cursor orelse c.SDL_GetDefaultCursor();
+ const cursor = switch (g.pointer_shape) {
+ .link => g.link_cursor,
+ .box => g.box_cursor orelse g.arrow_cursor,
+ else => g.arrow_cursor orelse c.SDL_GetDefaultCursor(),
+ };
if (cursor) |value| _ = c.SDL_SetCursor(value);
}
g.scene_target_failed = false;
@@ -5498,7 +5551,13 @@ fn renderFrame(
const track_index = groupAt(&groups, layer.viewport.x, layer.viewport.y);
// A notice floats over the panes, moving with its own.
const group_index = if (layer.kind == .notice) noticeGroup(&groups, layer) else track_index;
+ const start = cell_next[group_index];
emitTagLayer(g, instances, &cell_next[group_index], layer, win_w, win_h, groups.items[track_index].track, page, cursors_shown, false);
+ // A notice dropping past its row (a flavour's overshoot) stays
+ // inside its pane's body.
+ if (layer.kind == .notice and layer.slide > 0) if (bodyBottom(surface, layer.id, layout)) |bottom| {
+ for (instances[start..cell_next[group_index]]) |*instance| clipInstance(instance, .{ -1e9, -1e9, 1e9, bottom }, win_w, win_h);
+ };
}
for (groups.items[1..][0..groups.tracks], 1..) |group, index| {
const track = group.track.?;
@@ -5809,7 +5868,6 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand
var notices = false;
for (0..32) |step| {
core.settings.tag_bottom = step & 1 != 0;
- core.settings.column_tags = step & 2 != 0;
if (step & 3 == 0) try std.testing.expect(core.executeBuiltinLine(2, "Collapse"));
core.leader_on = step & 4 != 0;
if (step & 8 != 0) {
@@ -5867,6 +5925,14 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand
}
}
+/// The bottom of pane `owner`'s body, in window pixels.
+fn bodyBottom(surface: *const pardes.Surface, owner: u16, layout: CellLayout) ?f32 {
+ for (surface.regionList()) |region| {
+ if (region.kind == .body and region.owner == owner) return @as(f32, @floatFromInt(region.rect.y + region.rect.h)) * layout.h;
+ }
+ return null;
+}
+
fn tagLayerCellCount(g: *const Gui, layer: *const pardes.Layer) u32 {
const pixels = @as(u32, layer.viewport.w) * g.cell_w;
return @min(layer.cols, (pixels + g.tagline_width - 1) / g.tagline_width);
@@ -6004,6 +6070,67 @@ fn anchorInset(g: *const Gui, row: u16, win_h: f32) u32 {
return taglineBandOffset(row, win_h, g.cell_h, g.tagline_height);
}
+/// acme's Scrollwid column at a pane's or column's left edge, in window
+/// pixels: where its button sits over its scrollbar, one width for both
+/// (`rail_px` at acme's 17px font, scaled with the tagline, and always
+/// short of the cell so the text keeps a gap), right of the column's rule.
+fn scrollColumn(g: *const Gui, chrome: *const pardes.Chrome, col: u16, layout: CellLayout) [2]f32 {
+ const rule: f32 = if (col == 0) 0 else rulePx(chrome);
+ const x0 = @round(@as(f32, @floatFromInt(col)) * layout.w + rule);
+ const scaled: f32 = @round(@as(f32, @floatFromInt(@max(1, chrome.rail_px))) * @as(f32, @floatFromInt(g.tagline_height)) / 17);
+ // The rail and the grip are GUTTER cells wide; the text starts after.
+ const w = std.math.clamp(scaled, 4, @max(4, @floor(layout.w * @as(f32, @floatFromInt(config.GUTTER))) - rule - 3));
+ return .{ x0, x0 + w };
+}
+
+/// acme's button for the grip whose first cell is (col, row), in window
+/// pixels: the scroll column wide, its tag row tall, from the rule above to
+/// the rule below, so button and scrollbar read as one strip.
+fn gripButton(g: *const Gui, chrome: *const pardes.Chrome, col: u16, row: u16, layout: CellLayout, win_h: f32) [4]f32 {
+ _ = win_h;
+ const x = scrollColumn(g, chrome, col, layout);
+ const y0 = @round(@as(f32, @floatFromInt(row)) * layout.h);
+ return .{ x[0], y0, x[1], @round(@as(f32, @floatFromInt(row + 1)) * layout.h) };
+}
+
+/// A pane's button fill, from its region's state, not the grid's cells
+/// (which are drawn for cell shells): dirty, focused, or the tag's ground.
+fn gripFill(chrome: *const pardes.Chrome, focus: bool, dirty: bool) [3]u8 {
+ if (dirty) return chrome.grip_dirty;
+ return if (focus) chrome.grip_focus_ring else chrome.tag_bg;
+}
+
+fn inkOn(bg: [3]u8) [3]u8 {
+ const lum = (@as(u16, bg[0]) * 3 + @as(u16, bg[1]) * 6 + @as(u16, bg[2])) / 10;
+ return if (lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff };
+}
+
+/// Cuts an instance's quad to a pixel rectangle, its atlas window with it so
+/// the glyph neither moves nor stretches; nothing left, an empty quad.
+fn clipInstance(instance: *CellInstance, r: [4]f32, win_w: f32, win_h: f32) void {
+ const x0 = (instance.x0 + 1) / 2 * win_w;
+ const x1 = (instance.x1 + 1) / 2 * win_w;
+ const y0 = (1 - instance.y0) / 2 * win_h;
+ const y1 = (1 - instance.y1) / 2 * win_h;
+ const left = @max(0, r[0] - x0);
+ const right = @max(0, x1 - r[2]);
+ const top = @max(0, r[1] - y0);
+ const bottom = @max(0, y1 - r[3]);
+ if (x1 - right <= x0 + left or y1 - bottom <= y0 + top) {
+ instance.x1 = instance.x0;
+ instance.y1 = instance.y0;
+ return;
+ }
+ instance.x0 += left / win_w * 2;
+ instance.u0 += left / atlas_w;
+ instance.x1 -= right / win_w * 2;
+ instance.u1 -= right / atlas_w;
+ instance.y0 -= top / win_h * 2;
+ instance.v0 += top / atlas_h;
+ instance.y1 += bottom / win_h * 2;
+ instance.v1 -= bottom / atlas_h;
+}
+
fn emitSurfaceCell(
g: *Gui,
chrome: *const pardes.Chrome,
@@ -6036,33 +6163,82 @@ fn emitSurfaceCell(
emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .body, (&band_base)[0..1], 0, null, old_layer, false, page);
next.* += 1;
// Tag glyphs keep their font size inside a full physical-width grip.
- emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, line, at, cursor, old_layer, is_cursor, page);
+ // A pane's grip cells are the cell shells' form (a dirty `*`): the
+ // button draws its own glyph over a blank of them.
+ var blank = line[at];
+ blank.text[0] = ' ';
+ blank.len = 1;
+ if (here.pane_grip)
+ emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, (&blank)[0..1], 0, null, old_layer, false, page)
+ else
+ emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, line, at, cursor, old_layer, is_cursor, page);
// A grip's glyph is the cell at its left edge, and a blank one is
// no glyph: its marks are decor.
const glyph = &line[at - @min(here.grip_offset, at)];
- const grip = here.grip and !glyph.default and glyph.printableAscii() != ' ' and !glyph.style.invisible;
+ const grip = here.grip and here.grip_top and !glyph.default and glyph.printableAscii() != ' ' and !glyph.style.invisible;
const offset: u16 = if (grip) @min(here.grip_offset, at) else 0;
// An anchor starts at its column's rule: its left edge is inset by
// its top margin so the band frames it on the left as above and
// below, and its mark moves right by half that to stay centered.
const inset: u32 = if (line_cover[at - offset].anchor) anchorInset(g, row, win_h) else 0;
+ // A grip is acme's button: its fill is this cell's ground, cut to
+ // the button (and a dirty one's inset a pixel inside its ring, so
+ // the ring reads round it), its glyph centered in it.
+ const button = if (here.grip and here.grip_top) gripButton(g, chrome, col - @min(here.grip_offset, @as(u16, @intCast(at))), row, body_layout, win_h) else null;
if (grip) {
// A grip is one glyph centered across the gutter, not text: it is
// looked up by its codepoint's glyph, never shaped.
const drawn = glyphForCodepoint(g, cellCodepoint(glyph));
const cached = cachedGlyph(g, .{ .face = drawn.face, .glyph = drawn.glyph, .role = .tagline, .decoration = GlyphDecoration.fromCell(glyph), .centered = true });
const slot = cached orelse g.space_slot;
- const shift: f32 = if (cached != null) @as(f32, @floatFromInt(@as(u32, offset) * g.cell_w)) - @as(f32, @floatFromInt(inset / 2)) else 0;
+ const b = button.?;
+ const center = (b[0] + b[2]) / 2 - @as(f32, @floatFromInt(col - offset)) * body_layout.w;
+ const shift: f32 = if (cached != null) @as(f32, @floatFromInt(@as(u32, offset) * g.cell_w)) + @as(f32, @floatFromInt(config.GUTTER * g.cell_w)) / 2 - center else 0;
const resolved = resolveCell(g, glyph[0..1], 0, null, .tagline, false, page);
- instances[next.*].fr = @as(f32, @floatFromInt(resolved.fg[0])) / 255;
- instances[next.*].fg = @as(f32, @floatFromInt(resolved.fg[1])) / 255;
- instances[next.*].fb = @as(f32, @floatFromInt(resolved.fg[2])) / 255;
+ const ink = if (here.pane_grip) inkOn(gripFill(chrome, here.focus, here.dirty)) else resolved.fg;
+ instances[next.*].fr = @as(f32, @floatFromInt(ink[0])) / 255;
+ instances[next.*].fg = @as(f32, @floatFromInt(ink[1])) / 255;
+ instances[next.*].fb = @as(f32, @floatFromInt(ink[2])) / 255;
instances[next.*].u0 = (@as(f32, @floatFromInt(slot.u)) + shift) / atlas_w;
instances[next.*].v0 = @as(f32, @floatFromInt(slot.v)) / atlas_h;
instances[next.*].v1 = @as(f32, @floatFromInt(slot.v + g.tagline_height)) / atlas_h;
}
instances[next.*].u1 = instances[next.*].u0 + @as(f32, @floatFromInt(g.cell_w)) / atlas_w;
- if (inset > 0 and (!grip or offset == 0)) {
+ if (button) |b| {
+ // acme's button fills its tag row from the rule above to the rule
+ // below: the underlay is its fill, cut to its columns; a pane's
+ // from its region's state, not the grid's cell form (whose dirty
+ // `*` is the cell shells'), a column's its cell's own ground.
+ const base = &instances[next.* - 1];
+ const own: [3]f32 = .{ instances[next.*].br, instances[next.*].bg, instances[next.*].bb };
+ if (here.pane_grip) {
+ const fill = gripFill(chrome, here.focus, here.dirty);
+ base.br = @as(f32, @floatFromInt(fill[0])) / 255;
+ base.bg = @as(f32, @floatFromInt(fill[1])) / 255;
+ base.bb = @as(f32, @floatFromInt(fill[2])) / 255;
+ } else {
+ base.br = own[0];
+ base.bg = own[1];
+ base.bb = own[2];
+ }
+ base.effect &= ~clear_bg_bit;
+ clipInstance(base, .{ b[0], -1e9, b[2], 1e9 }, win_w, win_h);
+ // Over it, only its own glyph; the band round it and the ring
+ // are decor (gripDecor).
+ if (here.pane_grip and !grip) {
+ instances[next.*].u0 = @as(f32, @floatFromInt(g.space_slot.u)) / atlas_w;
+ instances[next.*].u1 = instances[next.*].u0 + @as(f32, @floatFromInt(g.cell_w)) / atlas_w;
+ instances[next.*].v0 = @as(f32, @floatFromInt(g.space_slot.v)) / atlas_h;
+ instances[next.*].v1 = @as(f32, @floatFromInt(g.space_slot.v + g.tagline_height)) / atlas_h;
+ }
+ // Its ground is the fill again (a see-through ground would be
+ // written, not blended, in an opaque window).
+ instances[next.*].br = base.br;
+ instances[next.*].bg = base.bg;
+ instances[next.*].bb = base.bb;
+ instances[next.*].effect &= ~clear_bg_bit;
+ clipInstance(&instances[next.*], b, win_w, win_h);
+ } else if (inset > 0) {
instances[next.*].x0 += @as(f32, @floatFromInt(inset)) / win_w * 2;
instances[next.*].u0 += @as(f32, @floatFromInt(inset)) / atlas_w;
}
@@ -6073,6 +6249,125 @@ fn emitSurfaceCell(
next.* += 1;
}
+test "every pixel of a grip column is band, button or ring, and the button sits over the scrollbar" {
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .cols = 60, .rows = 20 });
+ defer core.deinit();
+ const pane = try core.setTestFile("text\n" ** 60);
+ pane.tag.own = try gpa.dupe(u8, "second\nthird");
+ core.newScratchBelow(0);
+ for ([_]struct { w: u32, h: u32, tag: u32 }{ .{ .w = 10, .h = 20, .tag = 12 }, .{ .w = 17, .h = 35, .tag = 30 }, .{ .w = 9, .h = 19, .tag = 15 }, .{ .w = 12, .h = 24, .tag = 24 } }) |m| for ([_]bool{ false, true }) |bottom| for ([_]usize{ 0, 1 }) |focus| for ([_]bool{ false, true }) |dirty| {
+ core.settings.tag_bottom = bottom;
+ core.active = focus;
+ pane.file.?.revision = pane.file.?.saved_revision + @intFromBool(dirty);
+ pardes.test_api.sync(core);
+ const s = try core.render(core.scratch.allocator());
+ var g: Gui = undefined;
+ g.words = .{};
+ g.decor = .empty;
+ defer g.decor.deinit(gpa);
+ g.cell_w = m.w;
+ g.cell_h = m.h;
+ g.tagline_width = m.w;
+ g.tagline_height = m.tag;
+ g.space_slot = .{ .u = 0, .v = 0 };
+ const layout = fixedCellLayout(&g);
+ const win_w: f32 = @floatFromInt(60 * m.w);
+ const win_h: f32 = @floatFromInt(20 * m.h);
+ const cover = try gpa.alloc(Cover, @as(usize, s.cols) * s.rows);
+ defer gpa.free(cover);
+ coverFrame(cover, s.cols, s.rows, s.bodyLayers(), s.tagLayers(), s.regionList());
+ var groups = makeGroups(&.{}, false);
+ try buildDecor(&g, gpa, s, &groups, layout, win_w, win_h, true);
+ const grip = s.region(.grip, 0).?;
+ // The grip column's pixels, band first, then its cells, then decor.
+ const x_px: u32 = grip.rect.x * m.w;
+ const w_px: u32 = config.GUTTER * m.w;
+ const y_px: u32 = grip.rect.y * m.h;
+ const h_px: u32 = grip.rect.h * m.h;
+ const pixels = try gpa.alloc(?[3]f32, w_px * h_px);
+ defer gpa.free(pixels);
+ @memset(pixels, null);
+ const paint = struct {
+ fn quad(into: []?[3]f32, x0n: f32, y0n: f32, x1n: f32, y1n: f32, rgb: [3]f32, ww: f32, wh: f32, ox: u32, oy: u32, pw: u32, ph: u32) void {
+ const px0: i64 = @intFromFloat(@round((x0n + 1) / 2 * ww));
+ const px1: i64 = @intFromFloat(@round((x1n + 1) / 2 * ww));
+ const py0: i64 = @intFromFloat(@round((1 - y0n) / 2 * wh));
+ const py1: i64 = @intFromFloat(@round((1 - y1n) / 2 * wh));
+ var y = @max(py0, oy);
+ while (y < @min(py1, oy + ph)) : (y += 1) {
+ var x = @max(px0, ox);
+ while (x < @min(px1, ox + pw)) : (x += 1) into[@intCast((y - oy) * pw + (x - ox))] = rgb;
+ }
+ }
+ };
+ var instances: [4]CellInstance = undefined;
+ for (0..grip.rect.h) |line| for (0..config.GUTTER) |dx| {
+ const col: u16 = grip.rect.x + @as(u16, @intCast(dx));
+ const row: u16 = grip.rect.y + @as(u16, @intCast(line));
+ const at = @as(usize, row) * s.cols;
+ var next: u32 = 0;
+ emitSurfaceCell(&g, &s.chrome, &instances, &next, col, row, layout, win_w, win_h, null, s.cells[at..][0..s.cols], cover[at..][0..s.cols], col, null, false, false, Ground.opaqueRgb(bg_default));
+ for (instances[0..next]) |i| paint.quad(pixels, i.x0, i.y0, i.x1, i.y1, .{ i.br, i.bg, i.bb }, win_w, win_h, x_px, y_px, w_px, h_px);
+ };
+ for (g.decor.items) |i| if (i.fr >= 1 and i.clip_x1 < i.clip_x0) paint.quad(pixels, i.x0, i.y0, i.x1, i.y1, .{ i.br, i.bg, i.bb }, win_w, win_h, x_px, y_px, w_px, h_px);
+ const rgb = struct {
+ fn of(v: [3]u8) [3]f32 {
+ return .{ @as(f32, @floatFromInt(v[0])) / 255, @as(f32, @floatFromInt(v[1])) / 255, @as(f32, @floatFromInt(v[2])) / 255 };
+ }
+ }.of;
+ const chrome = &s.chrome;
+ const band = if (focus == 0) chrome.tag_focus_bg else chrome.tag_bg;
+ // Band, fills, rings, the rules between stacked panes and under the
+ // tag, and the mode marks' ink (the grip's first cell's).
+ const ink = inkOn(gripFill(chrome, grip.active, grip.dirty));
+ const allowed = [_][3]f32{ rgb(band), rgb(core.chromeTheme().box), rgb(pardes.draw.boxDirty(core)), rgb(chrome.grip_border), rgb(chrome.grip_focus_ring), rgb(chrome.border), rgb(ink), rgb(chrome.tag_rule orelse mixRgb(chrome.tag_bg, chrome.page orelse bg_default)) };
+ for (pixels, 0..) |pixel, n| {
+ const got = pixel orelse return error.TestUnpaintedGripPixel;
+ const known = for (allowed) |a| {
+ if (std.meta.eql(a, got)) break true;
+ } else false;
+ // Under the first row there is no button: band and rules only.
+ if (n / w_px >= m.h and n / w_px < h_px - 1 and !std.meta.eql(got, rgb(band)) and !std.meta.eql(got, rgb(chrome.border)))
+ return error.TestButtonUnderFirstRow;
+ if (!known) std.debug.print("sliver {any} at {d},{d} of {d}x{d} (metrics {d}x{d}/{d}, bottom {}, focus {d}, dirty {}) page {any}\n", .{ got, n % w_px, n / w_px, w_px, h_px, m.w, m.h, m.tag, bottom, focus, dirty, s.chrome.page });
+ try std.testing.expect(known);
+ }
+ // The rule under the tag runs unbroken across the grip column.
+ if (!bottom) for (pixels[(h_px - 1) * w_px ..][0..w_px]) |pixel|
+ try std.testing.expectEqual(rgb(chrome.tag_rule orelse mixRgb(chrome.tag_bg, chrome.page orelse bg_default)), pixel.?);
+ // The button's edges are the scrollbar's.
+ const button = gripButton(&g, chrome, grip.rect.x, grip.rect.y, layout, win_h);
+ const column = scrollColumn(&g, chrome, s.region(.rail, 0).?.rect.x, layout);
+ try std.testing.expectEqual(column[0], button[0]);
+ try std.testing.expectEqual(column[1], button[2]);
+ };
+ pane.file.?.revision = pane.file.?.saved_revision;
+}
+
+test "a rising pane past its mark stays inside its box" {
+ // Bouncy, a vertical rise at its highest overshoot: drawn past its box,
+ // clipped to it.
+ var g: Gui = undefined;
+ g.cell_w = 10;
+ g.cell_h = 20;
+ var track: pardes.animation.Track = .{ .serial = 3, .phase = .opening, .effect = .vertical, .motion = @intFromEnum(pardes.animation.Motion.Flavour.bouncy) + 1, .frame_count = 20, .from = .{ .x = 0, .y = 10, .w = 40, .h = 10 }, .to = .{ .x = 0, .y = 0, .w = 40, .h = 10 } };
+ var peak: u16 = 0;
+ var highest: f32 = 1e9;
+ while (track.frame < track.frames()) : (track.frame += 1) {
+ if (track.presented().y < highest) {
+ highest = track.presented().y;
+ peak = track.frame;
+ }
+ }
+ track.frame = peak;
+ try std.testing.expect(track.presented().y < 0);
+ var instance: CellInstance = std.mem.zeroes(CellInstance);
+ setTransitionFields(&instance, track, fixedCellLayout(&g), 800, 480, 0);
+ try std.testing.expectEqual(@as(f32, 0), instance.clip_y0);
+ try std.testing.expectEqual(@as(f32, 200), instance.clip_y1);
+}
+
test "small tagline fonts leave no background gutters between adjacent tags" {
const chrome: pardes.Chrome = .{};
var g: Gui = undefined;
@@ -6760,9 +7055,9 @@ test "pane indicator halves share dissolve changes and safe exhausted atlas" {
try std.testing.expect(gripCellChanged(&surface, &cover, &previous_cover, 1, 0));
try std.testing.expect(!gripCellChanged(&surface, &cover, &previous_cover, 2, 0));
try std.testing.expect(!cover[2].grip);
- // A column's grip has no glyph of a pane's.
+ // A column's grip is a button too, cut the same way (it has no glyph).
coverFrame(&cover, 6, 2, &.{}, &.{}, &.{.{ .kind = .column_grip, .rect = .{ .x = 0, .y = 0, .w = 2, .h = 1 } }});
- try std.testing.expect(!cover[0].grip);
+ try std.testing.expect(cover[0].grip);
coverFrame(&cover, 6, 2, &.{}, &.{}, &grip);
const chrome: pardes.Chrome = .{};
@@ -7300,6 +7595,8 @@ fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, t
try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, "latest");
var name: [24]u8 = undefined;
if (transition) |frame| try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, std.fmt.bufPrint(&name, "transition-{d}", .{frame}) catch unreachable);
+ if (g.capture_series) try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, std.fmt.bufPrint(&name, "frame-{d:0>5}", .{g.captured}) catch unreachable);
+ g.captured +%= 1;
}
fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32, name: []const u8) !void {
@@ -7374,6 +7671,303 @@ fn addDecor(g: *Gui, gpa: std.mem.Allocator, x0_in: f32, y0_in: f32, x1_in: f32,
try g.decor.append(gpa, instance);
}
+/// How a lift of 0 to 1 stands off the page: the shadow's blur and offset
+/// in cell heights, and its darkest, where it leaves the caster's edge.
+/// Within §8.2's budget: sigma 0.4-0.8 cell, at most 30% darker at the edge.
+const Shadow = struct {
+ // A key light above: the drop is most of the blur, so the shadow hangs
+ // below and to the right and barely reaches over the caster's top.
+ const sigma: f32 = 0.42;
+ const drop: f32 = 0.34;
+ const side: f32 = 0.1;
+ const strength: f32 = 0.42;
+};
+
+/// A soft rectangle around `caster` (window pixels), blurred by `sigma`
+/// and moved by (dx, dy): black is a shadow, a light colour a rim or a
+/// glow. The caster's own rectangle is left alone, and `track` moves it all
+/// as its caster moves.
+fn addSoft(g: *Gui, gpa: std.mem.Allocator, caster: [4]f32, sigma_in: f32, dx: f32, dy: f32, strength: f32, rgb: [3]u8, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void {
+ if (strength <= 0.002) return;
+ const sigma = @max(0.5, sigma_in);
+ const x0 = caster[0] + dx - 3 * sigma;
+ const y0 = caster[1] + dy - 3 * sigma;
+ const x1 = caster[2] + dx + 3 * sigma;
+ const y1 = caster[3] + dy + 3 * sigma;
+ const w = x1 - x0;
+ const ht = y1 - y0;
+ if (w <= 0 or ht <= 0) return;
+ // In the quad's own 0..1 space (decor.frag.glsl): sigma per axis, the
+ // caster's offset from its shadow, the strength.
+ var instance: CellInstance = .{
+ .x0 = (x0 / win_w) * 2.0 - 1.0,
+ .y0 = 1.0 - (y0 / win_h) * 2.0,
+ .x1 = (x1 / win_w) * 2.0 - 1.0,
+ .y1 = 1.0 - (y1 / win_h) * 2.0,
+ .u0 = sigma / w,
+ .v0 = sigma / ht,
+ .u1 = sigma / w,
+ .v1 = sigma / ht,
+ .fr = strength,
+ .fg = dx / w,
+ .fb = dy / ht,
+ .br = @as(f32, @floatFromInt(rgb[0])) / 255,
+ .bg = @as(f32, @floatFromInt(rgb[1])) / 255,
+ .bb = @as(f32, @floatFromInt(rgb[2])) / 255,
+ };
+ setTransitionFields(&instance, track, fixedCellLayout(g), win_w, win_h, 0);
+ instance.effect |= soft_shadow_bit;
+ try g.decor.append(gpa, instance);
+}
+
+/// How far each lift style may go on this theme without its text falling
+/// below min(its contrast, 4.5): a shadow darkens, the others lighten.
+const LiftCeilings = struct {
+ shadow: f32,
+ white: f32,
+ /// A rim is light on a dark page and shade on a light one, where light
+ /// would not show.
+ rim: [3]u8,
+
+ /// Over the text on the page, and over a selection's text on its own
+ /// ground: the lower of the two.
+ fn of(chrome: *const pardes.Chrome) LiftCeilings {
+ const fg = chrome.fg orelse fg_default;
+ const bg = chrome.page orelse bg_default;
+ return .{
+ .shadow = @min(shadowCeiling(fg, bg), shadowCeiling(chrome.sel_fg, chrome.sel_bg)),
+ .white = @min(lightenCeiling(fg, bg, .{ 255, 255, 255 }), lightenCeiling(chrome.sel_fg, chrome.sel_bg, .{ 255, 255, 255 })),
+ .rim = if (luminance(bg) < 0.18) .{ 255, 255, 255 } else .{ 0, 0, 0 },
+ };
+ }
+};
+
+/// What stands a lifted part off the page, `lift` 0 to 1 (a pane's spring,
+/// a notice's 1), in the style chosen: a soft shadow, or a rim along the
+/// top of the focused pane's tag rows (`rim_edge`, a pane's only:
+/// never a notice's).
+fn addLift(g: *Gui, gpa: std.mem.Allocator, style: pardes.config.Runtime.LiftStyle, ceilings: LiftCeilings, caster: [4]f32, rim_edge: ?[3]f32, bodies: []const [4]f32, lift: f32, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void {
+ if (lift <= 0.001) return;
+ const h: f32 = @floatFromInt(g.cell_h);
+ // The core sends `auto` already resolved (draw.liftStyle).
+ const shadow = switch (style) {
+ .off => return,
+ .shadow, .auto => true,
+ .rim => false,
+ };
+ if (shadow) {
+ // Nothing of a shadow may touch a focus indicator (docs/effects.md):
+ // it falls on pane bodies and rails, never a tag, a grip or a header.
+ const first = g.decor.items.len;
+ try addSoft(g, gpa, caster, Shadow.sigma * h * lift, Shadow.side * h * lift, Shadow.drop * h * lift, @min(Shadow.strength * lift, ceilings.shadow), .{ 0, 0, 0 }, track, win_w, win_h);
+ try clipToBodies(g, gpa, first, bodies);
+ }
+ // A hairline just above the focused tag rows, never on them: it adds to
+ // the indicator without changing its colour.
+ if (style == .rim) if (rim_edge) |edge| {
+ const light = ceilings.rim[0] == 255;
+ const alpha = @min(Lift.rim * lift, if (light) ceilings.white else ceilings.shadow);
+ try addDecor(g, gpa, edge[0], edge[2] - 1, edge[1], edge[2], ceilings.rim, alpha, track, win_w, win_h);
+ };
+}
+
+/// A rim's strength, in the §8.2 budget for coloured light.
+const Lift = struct {
+ const rim: f32 = 0.35;
+};
+
+/// A body region in window pixels.
+fn bodyPixels(region: pardes.Region, layout: CellLayout) [4]f32 {
+ return .{
+ @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,
+ };
+}
+
+/// Replaces the decor from `first` on with a copy per pane body it reaches,
+/// each clipped to that body (and to what a vertical transition already
+/// clipped it to): the lift then never reaches a tag, a grip or a header.
+fn clipToBodies(g: *Gui, gpa: std.mem.Allocator, first: usize, bodies: []const [4]f32) !void {
+ const added = g.decor.items.len - first;
+ if (added == 0) return;
+ var own: [8]CellInstance = undefined;
+ const n = @min(added, own.len);
+ @memcpy(own[0..n], g.decor.items[first..][0..n]);
+ g.decor.shrinkRetainingCapacity(first);
+ for (own[0..n]) |instance| for (bodies) |body| {
+ var copy = instance;
+ const vertical = copy.clip_x1 >= copy.clip_x0;
+ copy.clip_x0 = if (vertical) @max(copy.clip_x0, body[0]) else body[0];
+ copy.clip_y0 = if (vertical) @max(copy.clip_y0, body[1]) else body[1];
+ copy.clip_x1 = if (vertical) @min(copy.clip_x1, body[2]) else body[2];
+ copy.clip_y1 = if (vertical) @min(copy.clip_y1, body[3]) else body[3];
+ if (copy.clip_x1 <= copy.clip_x0 or copy.clip_y1 <= copy.clip_y0) continue;
+ try g.decor.append(gpa, copy);
+ };
+}
+
+
+/// Like shadowCeiling for a light colour laid over at `a` (sRGB blend, as
+/// the decor pipeline blends a colour that is not black): the largest `a`
+/// that keeps text and page apart by min(their contrast, 4.5).
+fn lightenCeiling(fg: [3]u8, bg: [3]u8, rgb: [3]u8) f32 {
+ const contrast = struct {
+ fn of(a: [3]u8, b: [3]u8) f32 {
+ const la = luminance(a);
+ const lb = luminance(b);
+ return (@max(la, lb) + 0.05) / (@min(la, lb) + 0.05);
+ }
+ fn blend(under: [3]u8, over: [3]u8, a: f32) [3]u8 {
+ var out: [3]u8 = undefined;
+ for (&out, under, over) |*o, x, y| o.* = @intFromFloat(@round(@as(f32, @floatFromInt(x)) * (1 - a) + @as(f32, @floatFromInt(y)) * a));
+ return out;
+ }
+ };
+ const target = @min(contrast.of(fg, bg), 4.5);
+ var lo: f32 = 0;
+ var hi: f32 = 1;
+ for (0..24) |_| {
+ const mid = (lo + hi) / 2;
+ if (contrast.of(contrast.blend(fg, rgb, mid), contrast.blend(bg, rgb, mid)) >= target - 1e-3) lo = mid else hi = mid;
+ }
+ return lo;
+}
+
+/// sRGB's relative luminance (WCAG).
+fn luminance(rgb: [3]u8) f32 {
+ var sum: f32 = 0;
+ for (rgb, [3]f32{ 0.2126, 0.7152, 0.0722 }) |channel, weight| {
+ const v = @as(f32, @floatFromInt(channel)) / 255;
+ sum += weight * (if (v <= 0.04045) v / 12.92 else std.math.pow(f32, (v + 0.055) / 1.055, 2.4));
+ }
+ return sum;
+}
+
+/// How dark a shadow may make the page and its text, both together, before
+/// the pair drops below its own contrast or 4.5, whichever is lower (§8.2):
+/// darkening in linear light by k leaves (k L1 + .05) / (k L2 + .05).
+fn shadowCeiling(fg: [3]u8, bg: [3]u8) f32 {
+ const a = luminance(fg);
+ const b = luminance(bg);
+ const light = @max(a, b);
+ const dark = @min(a, b);
+ const target = @min((light + 0.05) / (dark + 0.05), 4.5);
+ const room = light - target * dark;
+ if (room <= 0) return 0;
+ return std.math.clamp(1 - 0.05 * (target - 1) / room, 0, 1);
+}
+
+test "a rim never lightens a native theme's text below its contrast or 4.5" {
+ for (pardes.themes) |theme| {
+ const fg = theme.fg orelse fg_default;
+ const bg = theme.bg orelse bg_default;
+ for ([_][3]u8{.{ 255, 255, 255 }}) |light| {
+ const a = @min(Lift.rim, lightenCeiling(fg, bg, light));
+ const blend = struct {
+ fn of(under: [3]u8, over: [3]u8, t: f32) [3]u8 {
+ var out: [3]u8 = undefined;
+ for (&out, under, over) |*o, x, y| o.* = @intFromFloat(@round(@as(f32, @floatFromInt(x)) * (1 - t) + @as(f32, @floatFromInt(y)) * t));
+ return out;
+ }
+ }.of;
+ const ratio = struct {
+ fn of(x: [3]u8, y: [3]u8) f32 {
+ return (@max(luminance(x), luminance(y)) + 0.05) / (@min(luminance(x), luminance(y)) + 0.05);
+ }
+ }.of;
+ try std.testing.expect(ratio(blend(fg, light, a), blend(bg, light, a)) >= @min(ratio(fg, bg), 4.5) - 2e-3);
+ }
+ }
+}
+
+test "a lift never touches a tag, a grip or a header, on a light theme or a dark one" {
+ const gpa = std.testing.allocator;
+ for ([_][]const u8{ "forge", "daybreak" }) |name| {
+ const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 });
+ defer core.deinit();
+ core.update(.{ .resize = .{ .cols = 80, .rows = 24, .row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 6, .tagline_h = 12 } } });
+ _ = try core.newShell(1, "");
+ try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false));
+ var line: [64]u8 = undefined;
+ try std.testing.expect(core.executeBuiltinLine(0, try std.fmt.bufPrint(&line, "Theme {s}", .{name})));
+ for (0..40) |_| core.update(.tick);
+ for (std.enums.values(pardes.config.Runtime.LiftStyle)) |style| {
+ core.settings.lift = style;
+ try std.testing.expect(core.executeBuiltinLine(0, "Msg a floating notice"));
+ core.advance(core.now_ns + 2 * std.time.ns_per_s);
+ pardes.test_api.sync(core);
+ const s = try core.render(core.scratch.allocator());
+ core.advance(core.now_ns + 2 * std.time.ns_per_s);
+ const settled = try core.render(core.scratch.allocator());
+ _ = s;
+ // The focus indicator itself: the focused pane's tag is not the
+ // others' colour, whatever the lift.
+ var active: ?[3]u8 = null;
+ var other: ?[3]u8 = null;
+ for (settled.tagLayers()) |layer| if (layer.rows != 0 and layer.kind == .pane) {
+ if (layer.id == core.active) active = layer.bg else other = layer.bg;
+ };
+ try std.testing.expect(!std.mem.eql(u8, &active.?, &other.?));
+ var g: Gui = undefined;
+ g.cell_w = 10;
+ g.cell_h = 20;
+ g.tagline_width = 6;
+ g.tagline_height = 12;
+ g.decor = .empty;
+ defer g.decor.deinit(gpa);
+ var groups = makeGroups(&.{}, false);
+ try buildDecor(&g, gpa, settled, &groups, fixedCellLayout(&g), 800, 480, true);
+ var lifted: usize = 0;
+ for (g.decor.items) |item| {
+ if (item.clip_x1 < item.clip_x0) continue;
+ lifted += 1;
+ // Inside a body or a rail, all of it.
+ const inside = for (settled.regionList()) |region| {
+ if (region.kind != .body and region.kind != .rail) continue;
+ const r = bodyPixels(region, fixedCellLayout(&g));
+ if (item.clip_x0 >= r[0] and item.clip_y0 >= r[1] and item.clip_x1 <= r[2] and item.clip_y1 <= r[3]) break true;
+ } else false;
+ try std.testing.expect(inside);
+ }
+ const light_page = std.mem.eql(u8, name, "daybreak");
+ try std.testing.expectEqual(style == .shadow or (style == .auto and light_page), lifted != 0);
+ // A rim is one hairline, just above the focused pane's tag row.
+ var rims: usize = 0;
+ for (g.decor.items) |item| {
+ if (item.clip_x1 >= item.clip_x0) continue;
+ const top = (1 - item.y0) / 2 * 480;
+ const height = (item.y0 - item.y1) / 2 * 480;
+ const on_active_tag = for (settled.regionList()) |region| {
+ if (region.kind == .tag and region.owner == core.active and @abs(top + 1 - @as(f32, @floatFromInt(region.rect.y)) * 20) < 0.01 and @abs(height - 1) < 0.01) break true;
+ } else false;
+ // Not the rule under a column bar, which may run there too.
+ rims += @intFromBool(on_active_tag and item.fr < 1);
+ }
+ try std.testing.expectEqual(@as(usize, @intFromBool(style == .rim)), rims);
+ }
+ }
+}
+
+test "a shadow never darkens a native theme's text below its contrast or 4.5" {
+ var shaded: usize = 0;
+ for (pardes.themes) |theme| {
+ const fg = theme.fg orelse fg_default;
+ const bg = theme.bg orelse bg_default;
+ const ceiling = shadowCeiling(fg, bg);
+ const k = 1 - @min(Shadow.strength, ceiling);
+ const light = @max(luminance(fg), luminance(bg));
+ const dark = @min(luminance(fg), luminance(bg));
+ const before = (light + 0.05) / (dark + 0.05);
+ const after = (k * light + 0.05) / (k * dark + 0.05);
+ try std.testing.expect(after >= @min(before, 4.5) - 1e-3);
+ shaded += @intFromBool(ceiling > 0.05);
+ }
+ // And most themes have room for one.
+ try std.testing.expect(shaded * 2 > pardes.themes.len);
+}
+
/// The frame's decor, group by group (docs/render-pipeline.md §3.4), all of it
/// read from the regions and the palette: the page's rules and the still
/// panes' rails, rules and grip marks in tier 0, a moving or closing pane's
@@ -7382,9 +7976,27 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g
g.decor.clearRetainingCapacity();
const chrome = &surface.chrome;
const regions = surface.regionList();
+ const ceilings = LiftCeilings.of(chrome);
+ var current_buf: [pardes.MAX_PANES][4]f32 = undefined;
+ var ncurrent: usize = 0;
+ for (regions) |part| if ((part.kind == .body or part.kind == .rail) and ncurrent < current_buf.len) {
+ current_buf[ncurrent] = bodyPixels(part, layout);
+ ncurrent += 1;
+ };
+ const current_bodies = current_buf[0..ncurrent];
+ const lift_style = std.enums.fromInt(pardes.config.Runtime.LiftStyle, chrome.lift_style) orelse .off;
+ const notice_lift = for (regions) |region| {
+ if (region.kind == .notice) break region.lift;
+ } else 0;
for (groups.items[0..groups.len], 0..) |*group, index| {
group.decor_start = @intCast(g.decor.items.len);
defer group.decor_count = @as(u32, @intCast(g.decor.items.len)) - group.decor_start;
+ // The grips' buttons before any rule: the rules over their rows cut
+ // them, and the band round them never covers a rule.
+ if (index <= groups.tracks) {
+ const closing = if (group.track) |active| active.phase == .closing else false;
+ try gripDecor(g, gpa, if (closing) surface.previous_regions else regions, if (closing) surface.previous_cells else surface.cells, groups, index, chrome, layout, surface.cols, surface.rows, win_w, win_h, group.track);
+ }
if (index == 0) {
const top = for (regions) |region| {
if (region.kind == .workspace_tag) break region.rect.h;
@@ -7394,7 +8006,8 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g
.grip, .tag, .column_grip, .column_tag => if (region.rect.y == top) break true,
else => {},
} else false;
- const height = topbarPaneBorderPixels(g.cell_h, g.tagline_height);
+ // As wide as the other rules, where the tagline leaves the room.
+ const height: u32 = @max(1, @min(chrome.rule_px, (g.cell_h -| g.tagline_height) * 2));
if (top != 0 and top < surface.rows and tag_under and height != 0) {
const y0: f32 = @floatFromInt(top * g.cell_h - height / 2);
try addDecor(g, gpa, 0, y0, win_w, y0 + @as(f32, @floatFromInt(height)), chrome.topbar_rule, 1, null, win_w, win_h);
@@ -7403,7 +8016,7 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g
for (regions) |bar| {
if (bar.kind != .column_grip) continue;
const y = @as(f32, @floatFromInt(bar.rect.y + bar.rect.h)) * layout.h;
- try addDecor(g, gpa, 0, y - 1, win_w, y, chrome.border, 1, null, win_w, win_h);
+ try addDecor(g, gpa, 0, y - rulePx(chrome), win_w, y, chrome.border, 1, null, win_w, win_h);
break;
}
// A tag on the last row runs on through the pixels under the grid.
@@ -7419,7 +8032,14 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g
// A closing pane is gone: its chrome is the last frame's.
const closing = if (track) |active| active.phase == .closing else false;
const placed = if (closing) surface.previous_regions else regions;
- const grid = if (closing) surface.previous_cells else surface.cells;
+ // Where a lift may fall: pane bodies, never tags (addLift).
+ var body_buf: [pardes.MAX_PANES][4]f32 = undefined;
+ var nbodies: usize = 0;
+ for (placed) |part| if ((part.kind == .body or part.kind == .rail) and nbodies < body_buf.len) {
+ body_buf[nbodies] = bodyPixels(part, layout);
+ nbodies += 1;
+ };
+ const bodies = body_buf[0..nbodies];
try paneDecor(g, gpa, placed, groups, index, chrome, layout, surface.cols, surface.rows, win_w, win_h, track);
// A single spine runs down each column, through every tag and body.
if (index == 0) for (regions) |column| {
@@ -7428,14 +8048,42 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g
const y0 = @as(f32, @floatFromInt(column.rect.y)) * layout.h;
const grid_y1 = @as(f32, @floatFromInt(surface.rows)) * layout.h;
const y1 = if (win_h - grid_y1 < layout.h) @max(grid_y1, win_h) else grid_y1;
- if (y1 > y0) try addDecor(g, gpa, x, y0, x + 1, y1, chrome.border, 1, null, win_w, win_h);
+ // None at the window's edge: nothing to rule apart there.
+ if (column.rect.x == 0) continue;
+ if (y1 > y0) try addDecor(g, gpa, x, y0, x + rulePx(chrome), y1, chrome.border, 1, null, win_w, win_h);
};
- try gripDecor(g, gpa, placed, grid, groups, index, layout, surface.cols, surface.rows, win_w, win_h, track);
+ // Cast last in the group, over its cells, images and chrome: a
+ // lifted pane's shadow darkens its neighbours' rails and rules
+ // as it darkens their text, and never the pane itself.
+ for (placed) |grip| {
+ if (grip.kind != .grip or grip.lift <= 0.001 or groupOf(groups, grip.serial) != index) continue;
+ var box: [4]f32 = .{ @as(f32, @floatFromInt(grip.rect.x)) * layout.w, @as(f32, @floatFromInt(grip.rect.y)) * layout.h, 0, @as(f32, @floatFromInt(grip.rect.y + grip.rect.h)) * layout.h };
+ box[2] = box[0];
+ for (placed) |part| {
+ if (part.owner != grip.owner or (part.kind != .tag and part.kind != .rail and part.kind != .body)) continue;
+ box[1] = @min(box[1], @as(f32, @floatFromInt(part.rect.y)) * layout.h);
+ box[2] = @max(box[2], @as(f32, @floatFromInt(part.rect.x + part.rect.w)) * layout.w);
+ box[3] = @max(box[3], @as(f32, @floatFromInt(part.rect.y + part.rect.h)) * layout.h);
+ }
+ // The top edge of its tag rows, for a rim: x0, x1, y.
+ const rim_edge: ?[3]f32 = for (placed) |part| {
+ if (part.owner == grip.owner and part.kind == .tag) break .{ @as(f32, @floatFromInt(grip.rect.x)) * layout.w, @as(f32, @floatFromInt(part.rect.x + part.rect.w)) * layout.w, @as(f32, @floatFromInt(part.rect.y)) * layout.h };
+ } else null;
+ try addLift(g, gpa, lift_style, ceilings, box, rim_edge, bodies, grip.lift, track, win_w, win_h);
+ }
}
// A notice chip is tagline hung over the body's top rows, so it is
// ruled off from the text beneath it the way a tag is: the same
// colour and thickness along its bottom, running to the window's
// edge when it does, fading up and dissolving with its band.
+ // A notice floats: its shadow first, then every rule.
+ for (surface.tagLayers()) |*layer| {
+ if (layer.rows == 0 or layer.kind != .notice or noticeGroup(groups, layer) != index) continue;
+ const x = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w;
+ const top = (@as(f32, @floatFromInt(layer.viewport.y)) + layer.slide) * layout.h;
+ const track = groups.items[groupAt(groups, layer.viewport.x, layer.viewport.y)].track;
+ try addLift(g, gpa, lift_style, ceilings, .{ x, top, x + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w, top + layout.h }, null, current_bodies, notice_lift * layer.fade, track, win_w, win_h);
+ }
for (surface.tagLayers()) |*layer| {
if (layer.rows == 0 or layer.kind != .notice or noticeGroup(groups, layer) != index) continue;
const x0 = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w;
@@ -7473,47 +8121,245 @@ fn paneDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, gr
const rule_x1 = if (win_w - x1 < layout.w) @max(x1, win_w) else x1;
const tag_bottom = tag.rect.y > rail.rect.y;
const rule_y = @as(f32, @floatFromInt(if (tag_bottom) tag.rect.y else tag.rect.y + tag.rect.h)) * layout.h;
- try addDecor(g, gpa, x0, if (tag_bottom) rule_y else rule_y - 1, rule_x1, if (tag_bottom) rule_y + 1 else rule_y, chrome.border, 1, track, win_w, win_h);
+ try addDecor(g, gpa, x0, if (tag_bottom) rule_y else rule_y - 1, rule_x1, if (tag_bottom) rule_y + 1 else rule_y, chrome.tag_rule orelse mixRgb(chrome.tag_bg, page), 1, track, win_w, win_h);
+ // Stacked panes are ruled apart as columns are, where one pane's
+ // body meets the next one's tag (acme's Border).
+ {
+ const pane_top = @min(tag.rect.y, rail.rect.y);
+ const stacked = for (regions) |other| {
+ if ((other.kind == .body or other.kind == .tag) and other.owner != rail.owner and other.rect.y + other.rect.h == pane_top and
+ other.rect.x < tag.rect.x + tag.rect.w and tag.rect.x < other.rect.x + other.rect.w) break true;
+ } else false;
+ // Inside the tag band's slack, never over its glyphs: the top
+ // slack of a tag on top, the bottom slack of the one above when
+ // tags are at the bottom; a pixel at least.
+ const y = @as(f32, @floatFromInt(pane_top)) * layout.h;
+ const spare = g.cell_h -| g.tagline_height;
+ if (stacked and !tag_bottom) {
+ const room = taglineBandOffset(pane_top, win_h, g.cell_h, g.tagline_height);
+ try addDecor(g, gpa, x0, y, rule_x1, y + @as(f32, @floatFromInt(@max(1, @min(chrome.rule_px, room)))), chrome.border, 1, track, win_w, win_h);
+ } else if (stacked and pane_top > 0) {
+ const room = spare - taglineBandOffset(pane_top - 1, win_h, g.cell_h, g.tagline_height);
+ try addDecor(g, gpa, x0, y - @as(f32, @floatFromInt(@max(1, @min(chrome.rule_px, room)))), rule_x1, y, chrome.border, 1, track, win_w, win_h);
+ }
+ }
const y0 = @as(f32, @floatFromInt(rail.rect.y)) * layout.h;
const body_end = rail.rect.y + rail.rect.h;
const body_y1 = @as(f32, @floatFromInt(body_end)) * layout.h;
const y1 = if (body_end == rows and win_h - body_y1 < layout.h) @max(body_y1, win_h) else body_y1;
+ // A theme's own track fills its width, the thumb one pixel short
+ // of it so the track edges it (acme's scrollbar); else a thin rail.
+ // acme's scrollbar: the track fills the scroll column, under the
+ // grip's button; the thumb is a pixel short of its right edge, so
+ // the track edges it.
+ const column = scrollColumn(g, chrome, rail.rect.x, layout);
try addDecor(g, gpa, x0, y0, x0 + layout.w, y1, page, 1, track, win_w, win_h);
- try addDecor(g, gpa, x0, y0, x0 + @min(layout.w, 2), y1, chrome.scroll_track, 1, track, win_w, win_h);
+ try addDecor(g, gpa, column[0], y0, column[1], y1, chrome.scroll_track, 1, track, win_w, win_h);
if (rail.thumb_h != 0) {
const thumb_y0 = @as(f32, @floatFromInt(rail.rect.y + rail.thumb_y)) * layout.h;
const thumb_y1 = if (rail.thumb_y + rail.thumb_h == rail.rect.h) y1 else @as(f32, @floatFromInt(rail.rect.y + rail.thumb_y + rail.thumb_h)) * layout.h;
- try addDecor(g, gpa, x0, thumb_y0, x0 + @min(layout.w, @max(2, @floor(layout.w * 0.5))), thumb_y1, chrome.scroll_thumb, 1, track, win_w, win_h);
+ try addDecor(g, gpa, column[0], thumb_y0, column[1] - 1, thumb_y1, chrome.scroll_thumb, 1, track, win_w, win_h);
}
}
}
+/// Halfway between two colours.
+fn mixRgb(a: [3]u8, b: [3]u8) [3]u8 {
+ var out: [3]u8 = undefined;
+ for (&out, a, b) |*o, x, y| o.* = @intCast((@as(u16, x) + y) / 2);
+ return out;
+}
+
+/// A rule's width: the theme's own, or one pixel.
+fn rulePx(chrome: *const pardes.Chrome) f32 {
+ return @floatFromInt(@max(1, chrome.rule_px));
+}
+
/// The two short bars on each row of a grip with no mode glyph there,
/// centered in its anchor. Only the panes of group `index`.
-fn gripDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, cells: []const pardes.Cell, groups: *const Groups, index: usize, layout: CellLayout, cols: u16, rows: u16, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void {
+fn gripDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, cells: []const pardes.Cell, groups: *const Groups, index: usize, chrome: *const pardes.Chrome, layout: CellLayout, cols: u16, rows: u16, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void {
if (g.tagline_height < 6 or g.tagline_width < 3) return;
for (regions) |grip| {
- if (grip.kind != .grip or groupOf(groups, grip.serial) != index) continue;
+ if ((grip.kind != .grip and grip.kind != .column_grip) or groupOf(groups, grip.serial) != index) continue;
+ if (grip.rect.h == 0 or grip.rect.y >= rows) continue;
+ const button = gripButton(g, chrome, grip.rect.x, grip.rect.y, layout, win_h);
+ // Round the button, its first row is the tag's band: nothing of the
+ // cells under it shows there.
+ const band = if (grip.active) chrome.tag_focus_bg else chrome.tag_bg;
+ const left = @as(f32, @floatFromInt(grip.rect.x)) * layout.w;
+ const right = @as(f32, @floatFromInt(grip.rect.x + grip.rect.w)) * layout.w;
+ try addDecor(g, gpa, left, button[1], button[0], button[3], band, 1, track, win_w, win_h);
+ try addDecor(g, gpa, button[2], button[1], right, button[3], band, 1, track, win_w, win_h);
+ if (grip.kind != .grip) continue;
+ // acme's button ring, two pixels (ButtonBorder) inside the button:
+ // the focused one's in its own fill. An unfocused dirty one keeps a
+ // pixel of band inside its ring, so the four states differ in shape
+ // whatever a theme's colours (a focused dirty one is acme's
+ // modbutton, filled up to its ring).
+ {
+ // The rules drawn over the row (paneDecor, after this) cut into
+ // it: the ring runs inside them, from rule to rule.
+ const tag = for (regions) |part| {
+ if (part.kind == .tag and part.owner == grip.owner) break part;
+ } else grip;
+ const tag_bottom = for (regions) |part| {
+ if (part.kind == .rail and part.owner == grip.owner) break part.rect.y < tag.rect.y;
+ } else false;
+ const stacked = !tag_bottom and for (regions) |other| {
+ if ((other.kind == .body or other.kind == .tag) and other.owner != grip.owner and other.rect.y + other.rect.h == grip.rect.y and
+ other.rect.x < tag.rect.x + tag.rect.w and tag.rect.x < other.rect.x + other.rect.w) break true;
+ } else false;
+ const top_rule: f32 = if (tag_bottom) 1 else if (stacked) @floatFromInt(@max(1, @min(chrome.rule_px, taglineBandOffset(grip.rect.y, win_h, g.cell_h, g.tagline_height)))) else 0;
+ const bottom_rule: f32 = if (!tag_bottom and grip.rect.h == 1) 1 else 0;
+ const x0 = button[0];
+ const x1 = button[2];
+ const y0 = button[1] + top_rule;
+ const y1 = button[3] - bottom_rule;
+ const ring = if (grip.active) chrome.grip_focus_ring else chrome.grip_border;
+ try addDecor(g, gpa, x0, y0, x1, y0 + 2, ring, 1, track, win_w, win_h);
+ try addDecor(g, gpa, x0, y1 - 2, x1, y1, ring, 1, track, win_w, win_h);
+ try addDecor(g, gpa, x0, y0 + 2, x0 + 2, y1 - 2, ring, 1, track, win_w, win_h);
+ try addDecor(g, gpa, x1 - 2, y0 + 2, x1, y1 - 2, ring, 1, track, win_w, win_h);
+ if (grip.dirty and !grip.active) {
+ try addDecor(g, gpa, x0 + 2, y0 + 2, x1 - 2, y0 + 3, band, 1, track, win_w, win_h);
+ try addDecor(g, gpa, x0 + 2, y1 - 3, x1 - 2, y1 - 2, band, 1, track, win_w, win_h);
+ try addDecor(g, gpa, x0 + 2, y0 + 3, x0 + 3, y1 - 3, band, 1, track, win_w, win_h);
+ try addDecor(g, gpa, x1 - 3, y0 + 3, x1 - 2, y1 - 3, band, 1, track, win_w, win_h);
+ }
+ }
const x = grip.rect.x;
for (0..grip.rect.h) |line| {
const y = grip.rect.y + @as(u16, @intCast(line));
if (x >= cols or y >= rows) continue;
const cell = &cells[@as(usize, y) * cols + x];
if (cell.default or cell.printableAscii() != ' ') continue;
- const rgb = switch (cell.style.fg) {
- .rgb => |rgb| rgb,
- else => continue,
- };
- const center_x = (@as(f32, @floatFromInt(x)) + @as(f32, @floatFromInt(config.GUTTER)) / 2) * layout.w + @as(f32, @floatFromInt(anchorInset(g, y, win_h) / 2));
+ // The marks are in the button's ink, from the region's state.
+ const rgb = inkOn(gripFill(chrome, grip.active, grip.dirty));
+ // The marks are the first row's alone, centered in the button.
+ if (line != 0) continue;
+ const center_x = @floor((button[0] + button[2]) / 2);
const center_y = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, win_h, g.cell_h, g.tagline_height))) + @as(f32, @floatFromInt(g.tagline_height)) / 2;
- const half_w = @max(1, @min(3, layout.w - 2));
+ const half_w = @max(1, @min(3, @floor((button[2] - button[0] - 6) / 2)));
try addDecor(g, gpa, @floor(center_x - half_w), @floor(center_y - 2), @floor(center_x + half_w), @floor(center_y - 1), rgb, 1, track, win_w, win_h);
try addDecor(g, gpa, @floor(center_x - half_w), @floor(center_y + 1), @floor(center_x + half_w), @floor(center_y + 2), rgb, 1, track, win_w, win_h);
}
}
}
+test "a theme's own rules and scrollbar are drawn at its widths: acme's 2px black rules and 12px track" {
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 });
+ defer core.deinit();
+ _ = try core.newShell(1, "");
+ try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false));
+ try std.testing.expect(core.executeBuiltinLine(0, "Theme acme"));
+ for (0..40) |_| core.update(.tick);
+ pardes.test_api.sync(core);
+ const s = try core.render(core.scratch.allocator());
+ try std.testing.expectEqual(@as(u8, 2), s.chrome.rule_px);
+ // acme's 12px, at GripWidth's 150% default.
+ try std.testing.expectEqual(@as(u8, 18), s.chrome.rail_px);
+ var g: Gui = undefined;
+ g.decor = .empty;
+ defer g.decor.deinit(gpa);
+ g.cell_w = 16;
+ g.cell_h = 20;
+ g.tagline_width = 10;
+ g.tagline_height = 12;
+ var groups = makeGroups(&.{}, false);
+ try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 1280, 480, true);
+ const px = struct {
+ fn w(item: CellInstance) f32 {
+ return (item.x1 - item.x0) / 2 * 1280;
+ }
+ };
+ var spines: usize = 0;
+ var tracks: usize = 0;
+ for (g.decor.items) |item| {
+ const black = item.br == 0 and item.bg == 0 and item.bb == 0;
+ // No rule at the window's left edge; one 2px spine where columns meet.
+ if (black and @abs(px.w(item) - 2) < 0.01 and item.y1 < -0.9) {
+ try std.testing.expect(item.x0 > -1);
+ spines += 1;
+ }
+ const column = scrollColumn(&g, &s.chrome, 40, fixedCellLayout(&g));
+ if (@abs(item.br - @as(f32, 0x99) / 255) < 1e-3 and @abs(item.bb - @as(f32, 0x4c) / 255) < 1e-3 and @abs(px.w(item) - (column[1] - column[0])) < 0.01) tracks += 1;
+ }
+ try std.testing.expectEqual(@as(usize, 1), spines);
+ try std.testing.expect(tracks >= 2);
+}
+
+test "the rule between stacked panes stays in the tag band's slack, at any tagline size" {
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 });
+ defer core.deinit();
+ _ = try core.newShell(1, "");
+ core.newScratchBelow(0);
+ pardes.test_api.sync(core);
+ const s = try core.render(core.scratch.allocator());
+ const lower = for (s.regionList()) |region| {
+ if (region.kind == .tag and region.rect.y > 2) break region;
+ } else return error.TestExpectedStackedTag;
+ for ([_]u32{ 12, 20 }) |tagline_h| {
+ var g: Gui = undefined;
+ g.decor = .empty;
+ defer g.decor.deinit(gpa);
+ g.cell_w = 10;
+ g.cell_h = 20;
+ g.tagline_width = 6;
+ g.tagline_height = tagline_h;
+ var groups = makeGroups(&.{}, false);
+ try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 800, 480, true);
+ const top = @as(f32, @floatFromInt(lower.rect.y)) * 20;
+ const slack: f32 = @floatFromInt(@max(1, pardes.taglineBandOffset(lower.rect.y, 480, 20, tagline_h)));
+ var found = false;
+ for (g.decor.items) |item| {
+ const y0 = (1 - item.y0) / 2 * 480;
+ const y1 = (1 - item.y1) / 2 * 480;
+ if (@abs(y0 - top) > 0.01 or @abs(item.br - @as(f32, @floatFromInt(s.chrome.border[0])) / 255) > 1e-3) continue;
+ found = true;
+ try std.testing.expect(y1 - y0 <= slack + 0.01);
+ }
+ try std.testing.expect(found);
+ }
+}
+
+test "an empty column is ruled on both sides, as between two filled ones" {
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 });
+ defer core.deinit();
+ _ = try core.newShell(1, "");
+ try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false));
+ // Newcol from the left pane: an empty column between the two filled ones.
+ core.newColumn(0);
+ pardes.test_api.sync(core);
+ try std.testing.expectEqual(@as(usize, 3), core.ncol);
+ try std.testing.expectEqual(@as(usize, 0), core.col_n[1]);
+ const s = try core.render(core.scratch.allocator());
+ var g: Gui = undefined;
+ g.decor = .empty;
+ defer g.decor.deinit(gpa);
+ g.cell_w = 10;
+ g.cell_h = 20;
+ g.tagline_width = 6;
+ g.tagline_height = 12;
+ const layout = fixedCellLayout(&g);
+ var groups = makeGroups(&.{}, false);
+ try buildDecor(&g, gpa, s, &groups, layout, 800, 480, true);
+ const border = [3]f32{ @as(f32, @floatFromInt(s.chrome.border[0])) / 255, @as(f32, @floatFromInt(s.chrome.border[1])) / 255, @as(f32, @floatFromInt(s.chrome.border[2])) / 255 };
+ // Its left edge and its right edge (the next column's left): each a
+ // rule `rule_px` wide in the border colour, down to the window's foot.
+ for ([_]usize{ 1, 2 }) |column| {
+ const px: f32 = @floatFromInt(@as(u32, core.col_x[column]) * 10);
+ const rule = for (g.decor.items) |item| {
+ if (@abs(item.x0 - (px / 800 * 2 - 1)) < 1e-4 and @abs(item.x1 - ((px + @as(f32, @floatFromInt(s.chrome.rule_px))) / 800 * 2 - 1)) < 1e-4 and
+ std.mem.eql(f32, &.{ item.br, item.bg, item.bb }, &border)) break item;
+ } else return error.TestExpectedColumnRule;
+ try std.testing.expectEqual(@as(f32, -1), rule.y1);
+ }
+}
+
test "decor is read from the regions, each pane's in its own group" {
const gpa = std.testing.allocator;
const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 });
@@ -7541,15 +8387,23 @@ test "decor is read from the regions, each pane's in its own group" {
// workspace tag; a rail two pixels wide at each pane's.
for (core.col_x[0..core.ncol]) |x| {
const left = ndc.x(@as(f32, @floatFromInt(x)) * 10);
+ // Two pixels (rule_px), and none at the window's own edge.
const spine = for (g.decor.items) |item| {
- if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 1)) < 0.0001) break item;
- } else return error.TestExpectedSpine;
- try std.testing.expectApproxEqAbs(@as(f32, 1 - 20.0 / 480.0 * 2), spine.y0, 0.0001);
- try std.testing.expectEqual(@as(f32, -1), spine.y1);
+ if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 2)) < 0.0001 and
+ @abs(item.y0 - (1 - 20.0 / 480.0 * 2)) < 0.0001 and std.mem.eql(f32, &.{ item.br, item.bg, item.bb }, &.{ @as(f32, @floatFromInt(s.chrome.border[0])) / 255, @as(f32, @floatFromInt(s.chrome.border[1])) / 255, @as(f32, @floatFromInt(s.chrome.border[2])) / 255 })) break item;
+ } else null;
+ try std.testing.expectEqual(x == 0, spine == null);
+ if (spine) |rule| {
+ try std.testing.expectApproxEqAbs(@as(f32, 1 - 20.0 / 480.0 * 2), rule.y0, 0.0001);
+ try std.testing.expectEqual(@as(f32, -1), rule.y1);
+ }
+ // The scrollbar fills the scroll column right of the rule.
+ const column = scrollColumn(&g, &s.chrome, x, layout);
const rail = for (g.decor.items) |item| {
- if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 2)) < 0.0001) break item;
+ if (@abs(item.x0 - ndc.x(column[0])) < 0.0001 and @abs(item.x1 - ndc.x(column[1])) < 0.0001 and
+ @abs(item.br - @as(f32, @floatFromInt(s.chrome.scroll_track[0])) / 255) < 0.0001) break item;
} else return error.TestExpectedRail;
- try std.testing.expectApproxEqAbs(@as(f32, s.chrome.scroll_track[0]) / 255, rail.br, 0.0001);
+ _ = rail;
}
try std.testing.expectEqual(@as(u32, 0), groups.items[groups.notices].decor_count);
const still = groups.items[0].decor_count;