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.zig653
1 files changed, 591 insertions, 62 deletions
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index d36cf187..cb3bdd97 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -11,6 +11,7 @@ const config = @import("../config.zig");
const look = @import("../look.zig");
const message = pardes.Messages.Message;
const file_watch = @import("../file_watch.zig");
+const shader_build = @import("../shader_build.zig");
const deck = @import("deck.zig");
const Post = @import("Post.zig");
const pet = @import("pet.zig");
@@ -1870,8 +1871,12 @@ const Gui = struct {
/// picture follows on a spring (Glide), never the other way round.
scroll_notch: i32 = 0,
glide: Glide = .{},
- /// G9: the selection's glow blocks as last drawn, for their fade-in.
+ /// G9 and G11: the selection's and the hovered word's glow blocks as
+ /// last drawn, for their fade-in.
sel_glow: SelGlow = .{},
+ hover_glow: SelGlow = .{},
+ /// G14: the cells last drawn and when each last changed.
+ afterglow: Afterglow = .{},
/// The cursor's blink (blinkNow): when the frame drawn last arrived,
/// a redraw owed for an edge, and this frame's cursor mode and alpha.
@@ -2581,6 +2586,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
defer c.ui_font_free(g.font);
defer gpa.free(g.scroll_edge); // grown on demand by stepScroll
defer g.glide.deinit(gpa);
+ defer g.afterglow.deinit(gpa);
resetGlyphAtlas(&g);
if (attach) |requested| return attachRequested(gpa, &g, requested);
@@ -2688,6 +2694,7 @@ fn localSession(
.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();
+ defer shell.shaders.deinit(gpa, inotify_fd, &watches);
// A virtual clock never blinks: goldens and feel series hold still.
g.no_blink = shell.test_clock != null;
const host = shell.host();
@@ -2857,6 +2864,7 @@ fn attachedLoop(gpa: std.mem.Allocator, g: *Gui, client: *detached_client.Client
in.post(.{ .paste = text });
},
.open_link => |url| look.openLink(url),
+ .post => dirty = true,
}
}
try link;
@@ -2872,7 +2880,15 @@ fn attachedLoop(gpa: std.mem.Allocator, g: *Gui, client: *detached_client.Client
geom = now;
try attachedResize(client, g, now.cols, now.rows);
}
- if (!dirty) continue;
+ // The session's chain, its files compiled there.
+ g.post.sync(gpa, g.device, g.swapchain_format, client.post.list(), client.post_key, null);
+ if (!dirty) {
+ // As a local window does: a chain that moves on its own redraws
+ // alone, once a refresh.
+ if (g.post.animating(client.post.animation) and g.scene_tex != null and
+ c.SDL_GetTicksNS() -| g.post.last_ns + std.time.ns_per_ms >= refreshNs(g)) redrawPost(g, gpa);
+ continue;
+ }
dirty = false;
paintAttached(g, gpa, client);
}
@@ -3988,6 +4004,8 @@ const Shell = struct {
pipe_tasks: *PipeTasks,
inotify_fd: c_int,
watches: *file_watch.Table,
+ /// The post chain's files as SPIR-V.
+ shaders: shader_build = .{},
threads_ok: bool = false,
test_mode: bool = false,
feed: StdinFeed = .{},
@@ -4204,11 +4222,19 @@ const Shell = struct {
.files_changed => check_files = true,
.fs_ready => {},
};
+ if (check_files) s.shaders.recheck();
if (check_files and file_watch.reloadChanged(s.core, s.io, s.gpa, s.watches))
s.queue.push(.files_changed);
}
};
+/// A post chain compile is done (shader_build.zig): the loop's SDL wait ends.
+fn wakeSdl(_: ?*anyopaque) void {
+ var event = std.mem.zeroes(c.SDL_Event);
+ event.type = c.SDL_EVENT_USER;
+ _ = c.SDL_PushEvent(&event);
+}
+
fn wakeFs(ctx: ?*anyopaque) void {
const q: *Queue = @ptrCast(@alignCast(ctx.?));
q.push(.fs_ready);
@@ -4271,7 +4297,13 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void {
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.*);
+ } else if (timeout_ms != 0 and (!got or !core.needs_frame)) {
+ // A wait that ran out, or one a stray event cut short while
+ // nothing owes a frame: time moves to the core's wake all
+ // the same, so a steady trickle of events (a pty's, the
+ // test feed's) cannot hold a virtual clock still forever.
+ virtual.* = @max(virtual.*, core.nextWake() orelse virtual.*);
+ }
}
if (got) {
// A run of queued motions is one move to its last position (the
@@ -4345,7 +4377,12 @@ fn pollFrame(ctx: ?*anyopaque) void {
const geom = windowCells(g);
if (updateCoreResize(core, geom.cols, geom.rows, g.cell_w, g.cell_h, g.tagline_width, g.tagline_height)) resetScroll(g);
stepScroll(g, core, s.gpa);
- g.post.sync(s.gpa, s.io, g.device, g.swapchain_format, core);
+ _ = s.shaders.sync(s.gpa, s.io, core, s.inotify_fd, s.watches, .{ .call = wakeSdl });
+ {
+ var passes: [Post.max]shader_build.Pass = undefined;
+ g.post.sync(s.gpa, g.device, g.swapchain_format, s.shaders.view(&core.settings.post, &passes), s.shaders.revision, core);
+ }
+ if (s.test_mode) writeTestStatus(g, core, s.presented);
// Between the core's frames, a chain that moves on its own redraws
// alone, once a refresh of the display the window is on (a millisecond
// early is on time: the wait that paces it is in whole milliseconds).
@@ -4988,7 +5025,7 @@ fn stepGlide(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void {
const glide = &g.glide;
// The glide runs on the core's continuous path: frames every loop, and
// a virtual clock carried on.
- defer core.shell_continuous = glide.moving() or g.sel_glow.fading;
+ defer core.shell_continuous = glide.moving() or g.sel_glow.fading or g.hover_glow.fading or g.afterglow.glowing;
glide.now_ns = core.now_ns;
glide.motion = pardes.animation.Motion.of(core.settings.motion);
if (!glide.spring.settled) {
@@ -5793,8 +5830,9 @@ fn renderFrame(
const win_w: f32 = @floatFromInt(sw);
const win_h: f32 = @floatFromInt(sh);
- // The post chain runs over a local frame, from the scene texture.
- var scene_on = core != null and g.post.ready();
+ // The post chain runs from the scene texture, over a local frame or an
+ // attached session's alike.
+ var scene_on = g.post.ready();
if (scene_on) ensureSceneTexture(g, sw, sh) catch {
g.scene_target_failed = true;
scene_on = false;
@@ -5813,9 +5851,23 @@ fn renderFrame(
if (g.glide.pending) g.glide.measure(gpa, surface);
g.sel_glow.now_ns = if (core) |p| p.now_ns else c.SDL_GetTicksNS();
// Off, or while panes move, no glow and nothing fading.
- if (surface.chrome.selection_glow == 0 or surface.panelTracks().len != 0) g.sel_glow = .{ .now_ns = g.sel_glow.now_ns };
+ if (surface.chrome.fx & 1 == 0 or surface.panelTracks().len != 0) g.sel_glow = .{ .now_ns = g.sel_glow.now_ns };
+ if (surface.chrome.fx & 16 == 0) g.afterglow.glowing = false;
+ g.hover_glow.now_ns = g.sel_glow.now_ns;
+ if (surface.chrome.fx & 2 == 0 or surface.panelTracks().len != 0) g.hover_glow = .{ .now_ns = g.sel_glow.now_ns };
if (g.glide.moving()) g.glide.offset = g.glide.spring.value(g.glide.now_ns);
cell_decor = CellDecor.of(chrome);
+ // G13: a body's dots follow its text at a quarter of its speed: its
+ // first line, and a glide's picture offset, in rows.
+ if (chrome.fx & 8 != 0 and chrome.decor_dots != null) for (surface.regionList()) |region| {
+ if (region.kind != .body or cell_decor.nbodies == cell_decor.bodies.len) continue;
+ var rows: f32 = @floatFromInt(region.line);
+ if (g.glide.moving() and g.glide.pane == region.owner) rows += g.glide.offset;
+ const period = cell_decor.uniforms[4];
+ cell_decor.bodies[cell_decor.nbodies] = bodyPixels(region, layout);
+ cell_decor.shifts[cell_decor.nbodies] = @mod(-rows * layout.h * 0.25, period);
+ cell_decor.nbodies += 1;
+ };
// Plaques need an opaque window: with WindowOpacity under 100 the cells
// are drawn as they are, and the plaques not at all.
cell_decor.plaques_off = backgroundOpacity(g.applied_window_opacity) < 1;
@@ -5830,9 +5882,10 @@ fn renderFrame(
}
}
var groups = makeGroups(surface.panelTracks(), surface.hasPanelDiff());
- // Cursors stand still while panes move, as they always have; while the
- // focused one glides or blinks, it is the overlay's quad instead. One
- // or the other every frame (none only at a blink's off half).
+ // While panes move the cells' cursors are not drawn; the focused one is
+ // the overlay's quad, riding with its pane (draw.glideCursor), as it is
+ // while it glides or blinks. One or the other every frame (none only at
+ // a blink's off half).
const blink_alpha: f32 = if (blinkNow(g, chrome)) |b| b.alpha else 1;
const quad_mode = chrome.cursor_glide & 2 != 0 and (chrome.cursor_glide & 1 != 0 or blink_alpha < 1);
g.cursor_quad_alpha = if (quad_mode) chrome.cursor_alpha * blink_alpha else 0;
@@ -6153,19 +6206,17 @@ fn renderFrame(
c.SDL_EndGPURenderPass(rp);
if (scene_on) {
- 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,
- .selection_background = p.theme().sel_bg,
- });
- }
+ 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 = if (core) |p| p.theme().sel_fg else chrome.sel_fg,
+ .selection_background = if (core) |p| p.theme().sel_bg else chrome.sel_bg,
+ });
const chain_zone = tracy.zone(@src(), "post chain");
defer chain_zone.end();
g.post.scale = display_scale;
@@ -6656,9 +6707,12 @@ fn emitSurfaceCell(
// this underlay at tagline height leaves page-colored gutters between
// adjacent workspace, column and pane tags at TaglineSize < 100.
emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .body, (&band_base)[0..1], 0, null, old_layer, false, page);
- // Over a theme's plaque (not a grip's button) the underlay lets it
- // show (tagPlaques).
- if (cell_decor.nplaques != 0 and !here.grip and cell_decor.inPlaque((@as(f32, @floatFromInt(col)) + 0.5) * body_layout.w + body_layout.x_off, @as(f32, @floatFromInt(row)) * body_layout.h + body_layout.y_off + 0.5))
+ // Over a theme's plaque the underlay lets it show (tagPlaques),
+ // in a grip's cell too where the plaque reaches into it past the
+ // button (which is drawn over the cells).
+ const cell_x = @as(f32, @floatFromInt(col)) * body_layout.w + body_layout.x_off;
+ const cell_y = @as(f32, @floatFromInt(row)) * body_layout.h + body_layout.y_off + 0.5;
+ if (cell_decor.nplaques != 0 and cell_decor.inPlaque(if (here.grip) cell_x + body_layout.w - 0.5 else cell_x + 0.5, cell_y))
instances[next.*].effect |= clear_bg_bit;
next.* += 1;
// Tag glyphs keep their font size inside a full physical-width grip.
@@ -6993,7 +7047,13 @@ fn emitInstance(
(cell_decor.inPlaque(px0 + 0.5, py0 + 0.5) or cell_decor.inPlaque(px0 + layout.w - 0.5, py0 + 0.5)))
instances[idx].effect |= clear_bg_bit;
if (cell_decor.dots) |dotted| {
- if (!resolved.clear_bg and std.mem.eql(u8, &resolved.bg, &dotted)) instances[idx].effect |= dotted_bit;
+ if (!resolved.clear_bg and std.mem.eql(u8, &resolved.bg, &dotted)) {
+ instances[idx].effect |= dotted_bit;
+ // G13: the dots under a body move with its text, a quarter
+ // as far; the serial slot carries it (the shader reads it
+ // as the dots' offset alone).
+ instances[idx].serial = @bitCast(cell_decor.parallaxAt(px0 + 0.5, py0 + 0.5));
+ }
}
if (cell_decor.title) |inks| {
if (resolved.role == .tagline and (std.mem.eql(u8, &resolved.fg, &inks[0]) or std.mem.eql(u8, &resolved.fg, &inks[1]))) instances[idx].effect |= title_bit;
@@ -7082,16 +7142,20 @@ test "lapis plaques stay in their tag's band, clear of every grip; only the focu
for (under) |item| {
const r: [4]f32 = .{ (item.x0 + 1) / 2 * 1280, (1 - item.y0) / 2 * 768, (item.x1 + 1) / 2 * 1280, (1 - item.y1) / 2 * 768 };
// Inside one tag's band, whole: never on a body, a grip or the
- // next pane.
+ // next pane. A box stacked under another tag's lays its top frame
+ // over their shared edge, its own width up into the band above.
const home = for (s.regionList()) |region| {
if (region.kind != .tag and region.kind != .column_tag and region.kind != .workspace_tag) continue;
const band = plaqueBox(&g, &s.chrome, s.regionList(), region, layout, 1280, 768) orelse continue;
- if (r[0] >= band.band[0] - 0.01 and r[1] >= band.band[1] - 0.01 and r[2] <= band.band[2] + 0.01 and r[3] <= band.band[3] + 0.01) break region;
+ if (r[0] >= band.band[0] - 0.01 and r[1] >= band.band[1] - band.border - 0.01 and r[2] <= band.band[2] + 0.01 and r[3] <= band.band[3] + 0.01) break region;
} else return error.PlaqueOutsideItsBand;
+ // Clear of every grip's button: a plaque starts flush against it,
+ // in the gap between it and the text.
for (s.regionList()) |grip| {
if (grip.kind != .grip and grip.kind != .column_grip) continue;
- const gx0 = @as(f32, @floatFromInt(grip.rect.x)) * 16;
- const gx1 = @as(f32, @floatFromInt(grip.rect.x + grip.rect.w)) * 16;
+ const column = scrollColumn(&g, &s.chrome, grip.rect.x, layout);
+ const gx0 = column[0];
+ const gx1 = column[1];
const gy0 = @as(f32, @floatFromInt(grip.rect.y)) * 32;
const gy1 = @as(f32, @floatFromInt(grip.rect.y + grip.rect.h)) * 32;
try std.testing.expect(r[2] <= gx0 + 0.01 or r[0] >= gx1 - 0.01 or r[3] <= gy0 + 0.01 or r[1] >= gy1 - 0.01);
@@ -7105,6 +7169,31 @@ test "lapis plaques stay in their tag's band, clear of every grip; only the focu
}
try std.testing.expectEqual(@as(usize, 1), stripes);
try std.testing.expect(shadows >= 2);
+ // Stacked boxes share an edge and line up: a column's box spans its
+ // first pane's across, runs down to the edge that pane's frame lies
+ // over, and no shadow reaches the next column.
+ var stacks: usize = 0;
+ for (s.regionList()) |column| {
+ if (column.kind != .column_tag) continue;
+ const upper = plaqueBox(&g, &s.chrome, s.regionList(), column, layout, 1280, 768).?;
+ for (s.regionList()) |tag| {
+ if (tag.kind != .tag or tag.rect.y != column.rect.y + column.rect.h or tag.rect.x + tag.rect.w != column.rect.x + column.rect.w) continue;
+ const lower = plaqueBox(&g, &s.chrome, s.regionList(), tag, layout, 1280, 768).?;
+ try std.testing.expectEqual(upper.box[0], lower.box[0]);
+ // Flush against the grips' buttons, which line up too.
+ const grip = for (s.regionList()) |part| {
+ if (part.kind == .grip and part.owner == tag.owner) break part;
+ } else unreachable;
+ try std.testing.expectEqual(scrollColumn(&g, &s.chrome, grip.rect.x, layout)[1], lower.box[0]);
+ try std.testing.expectEqual(upper.box[2], lower.box[2]);
+ try std.testing.expectEqual(upper.band[3], upper.box[3]);
+ try std.testing.expectEqual(lower.band[1] - lower.border, lower.box[1]);
+ try std.testing.expectEqual(@as(f32, 0), upper.shadow[1]);
+ if (lower.band[2] < 1280) try std.testing.expect(lower.box[2] + lower.shadow[0] < lower.band[2]);
+ stacks += 1;
+ }
+ }
+ try std.testing.expectEqual(@as(usize, 2), stacks);
// The scroll track is chequered.
var checkers: usize = 0;
for (g.decor.items[groups.items[0].decor_start + groups.items[0].under_count ..]) |item| checkers += @intFromBool(item.effect & checker_bit != 0);
@@ -8231,6 +8320,32 @@ fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, t
g.captured +%= 1;
}
+/// A test window's state for a harness (test/gui_golden.py), in the
+/// capture directory's `status`: "<loop step> <frames captured> <idle>".
+/// Idle is 1 once the frame last captured is the one that stays: nothing
+/// owes a frame, nothing moves, nothing waits on a clock. A harness that
+/// sees a step past the one it acted on, idle, has its frame, however slow
+/// the machine is.
+var test_status_step: u64 = 0;
+fn writeTestStatus(g: *const Gui, core: *const pardes.Pardes, presented: bool) void {
+ test_status_step +%= 1;
+ if (g.capture_dir.len == 0) return;
+ const idle = presented and !core.needs_frame and !core.continuous() and core.nextWake() == null and
+ !g.glide.moving() and g.scroll_lag == 0 and g.scroll_delta == 0 and g.scroll_notch == 0;
+ var line_buf: [64]u8 = undefined;
+ // What keeps it busy, after: owes a frame, moves, waits, not shown.
+ const line = std.fmt.bufPrint(&line_buf, "{d} {d} {d} {d}{d}{d}{d}\n", .{ test_status_step, g.captured, @intFromBool(idle), @intFromBool(core.needs_frame), @intFromBool(core.continuous()), @intFromBool(core.nextWake() != null), @intFromBool(!presented) }) catch return;
+ var tmp_buf: [4096]u8 = undefined;
+ var final_buf: [4096]u8 = undefined;
+ const tmp_path = std.fmt.bufPrintSentinel(&tmp_buf, "{s}/status.tmp", .{g.capture_dir}, 0) catch return;
+ const final_path = std.fmt.bufPrintSentinel(&final_buf, "{s}/status", .{g.capture_dir}, 0) catch return;
+ const fd = libc.open(tmp_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
+ if (fd < 0) return;
+ _ = libc.write(fd, line.ptr, line.len);
+ _ = libc.close(fd);
+ _ = libc.rename(tmp_path, final_path);
+}
+
fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32, name: []const u8) !void {
// A latency trace times frames, and an 8 MB file write per frame would
// stall the loop it is timing.
@@ -8704,7 +8819,13 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g
const bodies = body_buf[0..nbodies];
try paneDecor(g, gpa, placed, groups, index, chrome, layout, surface.cols, surface.rows, win_w, win_h, track);
// G9, while nothing moves: a halo round the body's selection.
- if (index == 0 and groups.tracks == 0 and chrome.selection_glow != 0) try selectionGlow(g, gpa, surface, ceilings, layout, win_w, win_h);
+ if (index == 0 and groups.tracks == 0 and chrome.fx & 1 != 0) try glowBlocks(g, gpa, surface, .selection, layout, win_w, win_h);
+ // G11: a glow under the word a look-hover would open.
+ if (index == 0 and groups.tracks == 0 and chrome.fx & 2 != 0) try glowBlocks(g, gpa, surface, .hover, layout, win_w, win_h);
+ // G14: what changed glows, while nothing moves.
+ if (index == 0 and groups.tracks == 0 and chrome.fx & 16 != 0) try afterglowDecor(g, gpa, surface, layout, win_w, win_h);
+ // G12: the bodies' edges shaded, a pane's in its own group.
+ if (chrome.fx & 4 != 0) try occlusion(g, gpa, placed, groups, index, surface, ceilings, layout, win_w, win_h, track);
// A single spine runs down each column, through every tag and body.
if (index == 0) for (regions) |column| {
if (column.kind != .column) continue;
@@ -8913,6 +9034,17 @@ const CellDecor = struct {
grounds: [2][3]u8 = .{ .{ 0, 0, 0 }, .{ 0, 0, 0 } },
plaques_off: bool = false,
+ /// G13: each body's rectangle and how far its dots have moved.
+ bodies: [pardes.MAX_PANES][4]f32 = undefined,
+ shifts: [pardes.MAX_PANES]f32 = undefined,
+ nbodies: usize = 0,
+
+ fn parallaxAt(decor: *const CellDecor, x: f32, y: f32) f32 {
+ for (decor.bodies[0..decor.nbodies], decor.shifts[0..decor.nbodies]) |band, shift|
+ if (x >= band[0] and x < band[2] and y >= band[1] and y < band[3]) return shift;
+ return 0;
+ }
+
fn inPlaque(decor: *const CellDecor, x: f32, y: f32) bool {
for (decor.plaques[0..decor.nplaques]) |band|
if (x >= band[0] and x < band[2] and y >= band[1] and y < band[3]) return true;
@@ -8956,19 +9088,31 @@ const SelGlow = struct {
/// How long a glow takes to come up (§8.1's micro-feedback: 80 to 120 ms).
const sel_glow_ns: u64 = 100 * std.time.ns_per_ms;
+/// A glow's kind: G9's halo round the selection, G11's under a hovered word.
+const GlowKind = enum { selection, hover };
+
/// G9: a soft halo of the selection's own colour round each block of it in
/// a pane body (rows of selected cells that touch), fading in over 100 ms
/// with an ease-out. The selection itself is the halo's caster, never
/// touched; so is a cursor beside it; the halo stays on bodies (never a
/// tag or a grip) and at most 22%, less where the text round it would drop
/// below min(its contrast, 4.5).
-fn selectionGlow(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, ceilings: LiftCeilings, layout: CellLayout, win_w: f32, win_h: f32) !void {
- _ = ceilings;
+/// G11: under the word a look-hover would open (its cells in the
+/// affordance's tint), a soft line of the theme's accent along its foot,
+/// fading in over 80 ms, as strong at most, never under a cursor.
+fn glowBlocks(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, kind: GlowKind, layout: CellLayout, win_w: f32, win_h: f32) !void {
const chrome = &surface.chrome;
- const sel = chrome.sel_bg;
const page = chrome.page orelse bg_default;
const fg = chrome.fg orelse fg_default;
- const cap = @min(0.22, lightenCeiling(fg, page, sel));
+ // The selection's halo is its own colour; a hovered word's underline is
+ // the theme's accent (its focused grip), like a link's.
+ const glow = if (kind == .selection) chrome.sel_bg else chrome.accent;
+ // The cells a block is made of: the selection's, or the hover
+ // affordance's tint (draw.zig: halfway to a quarter of the selection).
+ const sel = if (kind == .selection) chrome.sel_bg else mixRgb(page, mixRgb(page, chrome.sel_bg));
+ const tracker = if (kind == .selection) &g.sel_glow else &g.hover_glow;
+ const rise_ns: u64 = if (kind == .selection) sel_glow_ns else 80 * std.time.ns_per_ms;
+ const cap = @min(0.22, lightenCeiling(fg, page, glow));
var blocks: [32][4]u16 = undefined;
var nblocks: usize = 0;
var bodies: [pardes.MAX_PANES][4]f32 = undefined;
@@ -9012,28 +9156,189 @@ fn selectionGlow(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface
}
nblocks = @min(nblocks, blocks.len);
// Each block keeps the start of the one it overlaps from the last frame.
- var next: SelGlow = .{ .now_ns = g.sel_glow.now_ns };
+ var next: SelGlow = .{ .now_ns = tracker.now_ns };
for (blocks[0..nblocks]) |block| {
- const start = for (g.sel_glow.blocks[0..g.sel_glow.len]) |old| {
+ const start = for (tracker.blocks[0..tracker.len]) |old| {
if (old.box[0] < block[2] and block[0] < old.box[2] and old.box[1] < block[3] and block[1] < old.box[3]) break old.start_ns;
- } else g.sel_glow.now_ns;
+ } else tracker.now_ns;
next.blocks[next.len] = .{ .box = block, .start_ns = start };
next.len += 1;
- const t = @as(f32, @floatFromInt(g.sel_glow.now_ns -| start)) / @as(f32, @floatFromInt(sel_glow_ns));
+ const t = @as(f32, @floatFromInt(tracker.now_ns -| start)) / @as(f32, @floatFromInt(rise_ns));
if (t < 1) next.fading = true;
const u = std.math.clamp(t, 0, 1);
const fade = 1 - (1 - u) * (1 - u) * (1 - u);
var caster: [4]f32 = .{ @as(f32, @floatFromInt(block[0])) * layout.w, @as(f32, @floatFromInt(block[1])) * layout.h, @as(f32, @floatFromInt(block[2])) * layout.w, @as(f32, @floatFromInt(block[3])) * layout.h };
- // A cursor beside the selection is not glowed over: it joins the
- // caster.
- if (surface.cursor) |cu| if (cu.x + 1 >= block[0] and cu.x <= block[2] and cu.y + 1 >= block[1] and cu.y <= block[3]) {
- caster = .{ @min(caster[0], @as(f32, @floatFromInt(cu.x)) * layout.w), @min(caster[1], @as(f32, @floatFromInt(cu.y)) * layout.h), @max(caster[2], @as(f32, @floatFromInt(cu.x + 1)) * layout.w), @max(caster[3], @as(f32, @floatFromInt(cu.y + 1)) * layout.h) };
- };
+ const cursor_box: ?[4]f32 = if (surface.cursor) |cu| .{ @as(f32, @floatFromInt(cu.x)) * layout.w, @as(f32, @floatFromInt(cu.y)) * layout.h, @as(f32, @floatFromInt(cu.x + 1)) * layout.w, @as(f32, @floatFromInt(cu.y + 1)) * layout.h } else null;
const first = g.decor.items.len;
- try addSoft(g, gpa, caster, 0.3 * layout.h, 0, 0, cap * fade, sel, null, win_w, win_h);
- try clipToBodies(g, gpa, first, bodies[0..nbodies]);
+ switch (kind) {
+ .selection => {
+ // A cursor beside the selection is not glowed over: it
+ // joins the caster.
+ if (surface.cursor) |cu| if (cu.x + 1 >= block[0] and cu.x <= block[2] and cu.y + 1 >= block[1] and cu.y <= block[3]) {
+ const cb = cursor_box.?;
+ caster = .{ @min(caster[0], cb[0]), @min(caster[1], cb[1]), @max(caster[2], cb[2]), @max(caster[3], cb[3]) };
+ };
+ try addSoft(g, gpa, caster, 0.3 * layout.h, 0, 0, cap * fade, glow, null, win_w, win_h);
+ try clipToBodies(g, gpa, first, bodies[0..nbodies]);
+ },
+ .hover => {
+ // A strip three logical pixels tall along the word's foot,
+ // its edges blurred by one: an underline of light. It is
+ // cast from a row away (addSoft leaves its caster's own
+ // rectangle out), so all of it shows.
+ const foot = caster[3] - dp(1);
+ const away = layout.h;
+ try addSoft(g, gpa, .{ caster[0] + dp(1), foot - dp(3) - away, caster[2] - dp(1), foot - away }, dp(1), 0, away, cap * fade, glow, null, win_w, win_h);
+ // Kept to the bodies, and off a cursor: the parts either
+ // side of it.
+ var parts: [4 * pardes.MAX_PANES][4]f32 = undefined;
+ var nparts: usize = 0;
+ for (bodies[0..nbodies]) |b| {
+ const cb = cursor_box orelse {
+ parts[nparts] = b;
+ nparts += 1;
+ continue;
+ };
+ if (cb[0] >= b[2] or cb[2] <= b[0] or cb[1] >= b[3] or cb[3] <= b[1]) {
+ parts[nparts] = b;
+ nparts += 1;
+ continue;
+ }
+ // Left and right of it, and above and below it.
+ parts[nparts] = .{ b[0], b[1], cb[0], b[3] };
+ parts[nparts + 1] = .{ cb[2], b[1], b[2], b[3] };
+ parts[nparts + 2] = .{ cb[0], b[1], cb[2], cb[1] };
+ parts[nparts + 3] = .{ cb[0], cb[3], cb[2], b[3] };
+ nparts += 4;
+ }
+ try clipToBodies(g, gpa, first, parts[0..nparts]);
+ },
+ }
+ }
+ tracker.* = next;
+}
+
+/// G12: ambient occlusion, a pane body darkening faintly toward its edges
+/// (§9.1: an inner shadow of 1 to 2%): four soft strips cast from just
+/// outside the body inward, clipped to it and round a cursor, black in
+/// linear light, under the same contrast ceiling as a lift.
+fn occlusion(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, groups: *const Groups, index: usize, surface: *const pardes.Surface, ceilings: LiftCeilings, layout: CellLayout, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void {
+ const strength = @min(0.02, ceilings.shadow);
+ if (strength <= 0.002) return;
+ const reach = 0.5 * layout.h;
+ for (regions) |body| {
+ if (body.kind != .body or body.rect.w == 0 or body.rect.h == 0 or groupOf(groups, body.serial) != index) continue;
+ const b = bodyPixels(body, layout);
+ // The body, less a cursor's cell in it: left, right, above, below.
+ var parts: [4][4]f32 = undefined;
+ var nparts: usize = 0;
+ const cursor: ?[4]f32 = if (surface.cursor) |cu| .{ @as(f32, @floatFromInt(cu.x)) * layout.w, @as(f32, @floatFromInt(cu.y)) * layout.h, @as(f32, @floatFromInt(cu.x + 1)) * layout.w, @as(f32, @floatFromInt(cu.y + 1)) * layout.h } else null;
+ if (cursor) |cb| if (!(cb[0] >= b[2] or cb[2] <= b[0] or cb[1] >= b[3] or cb[3] <= b[1])) {
+ parts = .{ .{ b[0], b[1], cb[0], b[3] }, .{ cb[2], b[1], b[2], b[3] }, .{ cb[0], b[1], cb[2], cb[1] }, .{ cb[0], cb[3], cb[2], b[3] } };
+ nparts = 4;
+ };
+ if (nparts == 0) {
+ parts[0] = b;
+ nparts = 1;
+ }
+ const t = reach / 3;
+ const strips = [_][4]f32{
+ .{ b[0], b[1] - t, b[2], b[1] },
+ .{ b[0], b[3], b[2], b[3] + t },
+ .{ b[0] - t, b[1], b[0], b[3] },
+ .{ b[2], b[1], b[2] + t, b[3] },
+ };
+ for (strips) |strip| {
+ const first = g.decor.items.len;
+ try addSoft(g, gpa, strip, reach / 3, 0, 0, strength, .{ 0, 0, 0 }, track, win_w, win_h);
+ try clipToBodies(g, gpa, first, parts[0..nparts]);
+ }
}
- g.sel_glow = next;
+}
+
+/// G14's state: the grid last drawn, when each cell last changed, and each
+/// body's line then (a scroll changes every cell and is not a change).
+const Afterglow = struct {
+ cells: []pardes.Cell = &.{},
+ changed: []u64 = &.{},
+ cols: u16 = 0,
+ rows: u16 = 0,
+ lines: [pardes.MAX_PANES]i32 = @splat(std.math.minInt(i32)),
+ /// A cell still glows: the core keeps frames coming.
+ glowing: bool = false,
+
+ fn deinit(glow: *Afterglow, gpa: std.mem.Allocator) void {
+ gpa.free(glow.cells);
+ gpa.free(glow.changed);
+ }
+};
+
+const afterglow_ns: u64 = 250 * std.time.ns_per_ms;
+
+/// G14: a cell of a pane body whose text changed since the last frame
+/// glows in the theme's accent at 18% and fades out over 250 ms with an
+/// ease-out, as a run of cells a row. Never on a tag or a grip (bodies
+/// only), the cursor or a selection; a body whose view moved (a scroll)
+/// starts over rather than lighting up whole.
+fn afterglowDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32) !void {
+ const glow = &g.afterglow;
+ const now = g.sel_glow.now_ns;
+ const n = @as(usize, surface.cols) * surface.rows;
+ const fresh = glow.cols != surface.cols or glow.rows != surface.rows or glow.cells.len != n;
+ if (fresh) {
+ glow.cells = try gpa.realloc(glow.cells, n);
+ glow.changed = try gpa.realloc(glow.changed, n);
+ @memcpy(glow.cells, surface.cells);
+ @memset(glow.changed, 0);
+ glow.cols = surface.cols;
+ glow.rows = surface.rows;
+ }
+ const chrome = &surface.chrome;
+ const page = chrome.page orelse bg_default;
+ const fg = chrome.fg orelse fg_default;
+ const cap = @min(0.18, lightenCeiling(fg, page, chrome.accent));
+ var glowing = false;
+ for (surface.regionList()) |body| {
+ if (body.kind != .body or body.owner >= glow.lines.len) continue;
+ const scrolled = glow.lines[body.owner] != body.line;
+ glow.lines[body.owner] = body.line;
+ var row = body.rect.y;
+ while (row < body.rect.y + body.rect.h and row < surface.rows) : (row += 1) {
+ var run_start: ?u16 = null;
+ var run_age: u64 = 0;
+ var col = body.rect.x;
+ while (col <= body.rect.x + body.rect.w and col <= surface.cols) : (col += 1) {
+ var age: ?u64 = null;
+ if (col < body.rect.x + body.rect.w and col < surface.cols) {
+ const at = @as(usize, row) * surface.cols + col;
+ const cell = surface.cells[at];
+ const differs = !std.meta.eql(cell.style, glow.cells[at].style) or !std.mem.eql(u8, cell.grapheme(), glow.cells[at].grapheme()) or cell.default != glow.cells[at].default;
+ if (differs and !fresh) glow.changed[at] = if (scrolled) 0 else now;
+ const selected = !cell.default and switch (cell.style.bg) {
+ .rgb => |rgb| std.mem.eql(u8, &rgb, &chrome.sel_bg),
+ else => false,
+ };
+ const cursor_here = if (surface.cursor) |cu| cu.x == col and cu.y == row else false;
+ const since = now -| glow.changed[at];
+ if (glow.changed[at] != 0 and since < afterglow_ns and !selected and !cursor_here) age = since;
+ }
+ // A run is cells that changed together.
+ if (run_start) |start| if (age == null or age.? != run_age) {
+ const t = @as(f32, @floatFromInt(run_age)) / @as(f32, @floatFromInt(afterglow_ns));
+ const fade = (1 - t) * (1 - t) * (1 - t);
+ try addDecor(g, gpa, @as(f32, @floatFromInt(start)) * layout.w, @as(f32, @floatFromInt(row)) * layout.h, @as(f32, @floatFromInt(col)) * layout.w, @as(f32, @floatFromInt(row + 1)) * layout.h, chrome.accent, cap * fade, null, win_w, win_h);
+ glowing = true;
+ run_start = null;
+ };
+ if (age) |a| if (run_start == null) {
+ run_start = col;
+ run_age = a;
+ };
+ }
+ }
+ }
+ @memcpy(glow.cells, surface.cells);
+ glow.glowing = glowing;
}
/// A theme's tag plaques (Chrome.decor_box_*), drawn under the cells of
@@ -9048,8 +9353,11 @@ fn tagPlaques(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, g
const border_rgb = chrome.decor_box_border orelse return;
if (cell_decor.plaques_off) return;
const page = chrome.page orelse bg_default;
- for (regions) |tag| {
+ // The workspace and column plaques first: a pane's plaque right under
+ // them shares their bottom edge (plaqueBox) and draws its frame last.
+ for ([_]bool{ false, true }) |panes| for (regions) |tag| {
const pane = tag.kind == .tag;
+ if (pane != panes) continue;
if (!pane and tag.kind != .column_tag and tag.kind != .workspace_tag) continue;
if (tag.rect.w == 0 or tag.rect.h == 0) continue;
if (pane and groupOf(groups, tag.serial) != index) continue;
@@ -9068,7 +9376,7 @@ fn tagPlaques(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, g
try addPattern(g, gpa, inner[0], inner[1], inner[2], inner[3], chrome.decor_box_stripe.?, fill, dp(@floatFromInt(@max(1, chrome.decor_box_stripe_px))), true, track, win_w, win_h)
else
try addDecor(g, gpa, inner[0], inner[1], inner[2], inner[3], fill, 1, track, win_w, win_h);
- }
+ };
}
const Plaque = struct { band: [4]f32, box: [4]f32, border: f32, shadow: [2]f32 };
@@ -9077,16 +9385,29 @@ const Plaque = struct { band: [4]f32, box: [4]f32, border: f32, shadow: [2]f32 }
/// grip to the end), the framed box round the tagline's text, and the
/// frame's and the shadow's widths, the shadow cut to what the band's slack
/// below the text and its right end leave. Null where there is no room.
+/// Tags stacked on each other (the workspace's over the columns', a
+/// column's over its first pane's) share one edge, the ruled one between
+/// their bands: the upper box runs down to it and casts no shadow down, the
+/// lower one's frame lies over it. Every box in a column spans the same
+/// pixels across, right of the grips, its shadow short of the next column.
fn plaqueBox(g: *const Gui, chrome: *const pardes.Chrome, regions: []const pardes.Region, tag: pardes.Region, layout: CellLayout, win_w: f32, win_h: f32) ?Plaque {
const pane = tag.kind == .tag;
const first = tag.rect.y;
const last = tag.rect.y + tag.rect.h - 1;
- // From its start, right of its grip: the gap cell before its text is
- // the plaque's margin, so its frame is never under the first letter.
- _ = regions;
- const x0 = @as(f32, @floatFromInt(tag.rect.x)) * layout.w;
+ // Flush against its grip's button, so the two read as one piece: the
+ // gap between button and text (the scroll gap) is the plaque's, and so
+ // is the cell before its text, so its frame is never under the first
+ // letter. A column's plaque starts where the pane's under it does.
+ const grip_kind: pardes.Region.Kind = if (tag.kind == .column_tag) .column_grip else .grip;
+ const x0 = for (regions) |grip| {
+ if (tag.kind != .workspace_tag and grip.kind == grip_kind and grip.owner == tag.owner)
+ break scrollColumn(g, chrome, grip.rect.x, layout)[1];
+ } else @as(f32, @floatFromInt(tag.rect.x)) * layout.w;
const x1_cells = @as(f32, @floatFromInt(tag.rect.x + tag.rect.w)) * layout.w;
- const x1 = if (win_w - x1_cells < layout.w) @max(x1_cells, win_w) else x1_cells;
+ const window_edge = win_w - x1_cells < layout.w;
+ const x1 = if (window_edge) @max(x1_cells, win_w) else x1_cells;
+ const stacked_on = tagBandAt(regions, tag, .above);
+ const stacked_under = tagBandAt(regions, tag, .below);
const row_top = @as(f32, @floatFromInt(first)) * layout.h;
const row_bottom = @as(f32, @floatFromInt(last + 1)) * layout.h;
const text_top = row_top + @as(f32, @floatFromInt(taglineBandOffset(first, win_h, g.cell_h, g.tagline_height)));
@@ -9096,16 +9417,42 @@ fn plaqueBox(g: *const Gui, chrome: *const pardes.Chrome, regions: []const parde
// The shadow's full offset to the right; down, what the slack under the
// text leaves (a pixel or three at a tagline under the body's size).
const want: f32 = if (pane) dp(@floatFromInt(chrome.decor_box_shadow_px)) else dp(2);
- const drop = std.math.clamp(@floor(row_bottom - text_bottom - border), 0, want);
- if (x1 - x0 < 4 * border + want) return null;
+ const drop = if (stacked_under) 0 else std.math.clamp(@floor(row_bottom - text_bottom - border), 0, want);
+ // Every box ends where a pane's does, short of the next column's rule
+ // by a pane frame's width past its shadow, so no shadow meets the rule
+ // and a column's box lines up with its panes'; the window's edge needs
+ // no gap.
+ const pane_shadow = dp(@floatFromInt(chrome.decor_box_shadow_px));
+ const gap = @max(0, pane_shadow - want) + if (window_edge) 0 else dp(@floatFromInt(@max(1, chrome.decor_box_border_px)));
+ if (x1 - x0 < 4 * border + want + gap) return null;
return .{
.band = .{ x0, row_top, x1, row_bottom },
- .box = .{ x0, text_top - border, x1 - want, text_bottom + border },
+ .box = .{
+ x0,
+ if (stacked_on) row_top - border else text_top - border,
+ x1 - want - gap,
+ if (stacked_under) row_bottom else text_bottom + border,
+ },
.border = border,
.shadow = .{ want, drop },
};
}
+/// Whether another tag's band (the workspace's, a column's, a pane's) meets
+/// `tag`'s right above or below it, across some of its width.
+fn tagBandAt(regions: []const pardes.Region, tag: pardes.Region, side: enum { above, below }) bool {
+ for (regions) |other| {
+ if (other.kind != .tag and other.kind != .column_tag and other.kind != .workspace_tag) continue;
+ if (other.rect.h == 0 or std.meta.eql(other.rect, tag.rect)) continue;
+ const meets = switch (side) {
+ .above => other.rect.y + other.rect.h == tag.rect.y,
+ .below => tag.rect.y + tag.rect.h == other.rect.y,
+ };
+ if (meets and other.rect.x < tag.rect.x + tag.rect.w and tag.rect.x < other.rect.x + other.rect.w) return true;
+ }
+ return false;
+}
+
/// 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, chrome: *const pardes.Chrome, layout: CellLayout, cols: u16, rows: u16, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void {
@@ -9120,7 +9467,13 @@ fn gripDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, ce
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);
+ // Right of it, a theme's plaque starts flush against it (plaqueBox).
+ const plaqued = chrome.decor_box_border != null and !cell_decor.plaques_off;
+ if (!plaqued) try addDecor(g, gpa, button[2], button[1], right, button[3], band, 1, track, win_w, win_h);
+ // Its cells over the plaque's reach are let through (emitInstance):
+ // under the button's row, the band goes on as far as the plaque.
+ if (plaqued and grip.rect.h > 1)
+ try addDecor(g, gpa, left, button[3], button[2], @as(f32, @floatFromInt(grip.rect.y + grip.rect.h)) * layout.h, 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
@@ -9480,7 +9833,7 @@ test "the selection glows round its block, on the body only, fading in over 100
core.settings.selection_glow = true;
pardes.test_api.sync(core);
const s = try core.render(core.scratch.allocator());
- try std.testing.expectEqual(@as(u8, 1), s.chrome.selection_glow);
+ try std.testing.expectEqual(@as(u8, 1), s.chrome.fx);
// Two rows of a selection in the body, as the core paints one.
const body = for (s.regionList()) |region| {
if (region.kind == .body) break region;
@@ -9522,6 +9875,183 @@ test "the selection glows round its block, on the body only, fading in over 100
try std.testing.expect(!g.sel_glow.fading);
}
+test "a hovered word glows along its foot, never over the cursor, fading in over 80 ms" {
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 });
+ defer core.deinit();
+ _ = try core.setTestFile("some text on a line\n" ** 30);
+ core.settings.hover_glow = true;
+ pardes.test_api.sync(core);
+ const s = try core.render(core.scratch.allocator());
+ try std.testing.expectEqual(@as(u8, 2), s.chrome.fx);
+ const body = for (s.regionList()) |region| {
+ if (region.kind == .body) break region;
+ } else return error.NoBody;
+ const page = s.chrome.page orelse bg_default;
+ const tint = mixRgb(page, mixRgb(page, s.chrome.sel_bg));
+ const row = body.rect.y + 4;
+ for (5..12) |x| {
+ const cell = &s.cells[@as(usize, row) * s.cols + body.rect.x + x];
+ cell.default = false;
+ cell.style.bg = .{ .rgb = tint };
+ }
+ s.cursor = .{ .x = body.rect.x + 8, .y = row };
+ 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;
+ g.sel_glow = .{};
+ g.hover_glow = .{ .now_ns = std.time.ns_per_s };
+ var strongest: f32 = 0;
+ for ([_]u64{ 0, 90 * std.time.ns_per_ms }) |later| {
+ g.decor.clearRetainingCapacity();
+ g.hover_glow.now_ns = std.time.ns_per_s + later;
+ var groups = makeGroups(&.{}, false);
+ try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 800, 480, true);
+ for (g.decor.items) |item| {
+ if (item.effect & soft_shadow_bit == 0) continue;
+ if (later == 0) try std.testing.expect(false);
+ strongest = @max(strongest, item.fr);
+ // Never over the cursor's cell.
+ const cx0: f32 = @floatFromInt(@as(u32, s.cursor.?.x) * 10);
+ const cy0: f32 = @floatFromInt(@as(u32, s.cursor.?.y) * 20);
+ try std.testing.expect(item.clip_x1 <= cx0 + 0.01 or item.clip_x0 >= cx0 + 10 - 0.01 or item.clip_y1 <= cy0 + 0.01 or item.clip_y0 >= cy0 + 20 - 0.01);
+ }
+ }
+ try std.testing.expect(strongest > 0.05);
+ try std.testing.expect(!g.hover_glow.fading);
+}
+
+test "occlusion shades each body's edges, inside it, never a tag, a grip or the cursor" {
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 });
+ defer core.deinit();
+ _ = try core.setTestFile("some text on a line\n" ** 30);
+ core.settings.occlusion = true;
+ pardes.test_api.sync(core);
+ const s = try core.render(core.scratch.allocator());
+ try std.testing.expectEqual(@as(u8, 4), s.chrome.fx);
+ const body = for (s.regionList()) |region| {
+ if (region.kind == .body) break region;
+ } else return error.NoBody;
+ // The cursor on the body's top-left cell, where the shade is.
+ s.cursor = .{ .x = body.rect.x, .y = body.rect.y };
+ 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;
+ g.sel_glow = .{};
+ g.hover_glow = .{};
+ var groups = makeGroups(&.{}, false);
+ try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 800, 480, true);
+ const r = bodyPixels(body, fixedCellLayout(&g));
+ var shades: usize = 0;
+ for (g.decor.items) |item| {
+ if (item.effect & soft_shadow_bit == 0) continue;
+ shades += 1;
+ try std.testing.expect(item.fr <= 0.02 + 1e-6);
+ try std.testing.expect(item.clip_x0 >= r[0] - 0.01 and item.clip_x1 <= r[2] + 0.01 and item.clip_y0 >= r[1] - 0.01 and item.clip_y1 <= r[3] + 0.01);
+ const cx: f32 = @floatFromInt(@as(u32, s.cursor.?.x) * 10);
+ const cy: f32 = @floatFromInt(@as(u32, s.cursor.?.y) * 20);
+ try std.testing.expect(item.clip_x1 <= cx + 0.01 or item.clip_x0 >= cx + 10 - 0.01 or item.clip_y1 <= cy + 0.01 or item.clip_y0 >= cy + 20 - 0.01);
+ }
+ try std.testing.expect(shades >= 4);
+}
+
+test "a body tells its first line, and lapis's dots under it move a quarter as far" {
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 });
+ defer core.deinit();
+ const pane = try core.setTestFile("line\n" ** 80);
+ pane.scrollBy(8);
+ try std.testing.expect(core.executeBuiltinLine(0, "Theme lapis"));
+ core.settings.parallax = true;
+ for (0..40) |_| core.update(.tick);
+ pardes.test_api.sync(core);
+ const s = try core.render(core.scratch.allocator());
+ const body = for (s.regionList()) |region| {
+ if (region.kind == .body) break region;
+ } else return error.NoBody;
+ try std.testing.expectEqual(@as(i32, 8), body.line);
+ try std.testing.expect(s.chrome.fx & 8 != 0);
+ // The shift the cell shader is handed: a quarter of 8 rows of 20 px,
+ // upward, within the dots' period.
+ var decor = CellDecor.of(&s.chrome);
+ const period = decor.uniforms[4];
+ decor.bodies[0] = .{ 0, 0, 800, 480 };
+ decor.shifts[0] = @mod(-8.0 * 20.0 * 0.25, period);
+ decor.nbodies = 1;
+ try std.testing.expectApproxEqAbs(@mod(-40.0, period), decor.parallaxAt(10, 10), 1e-4);
+ try std.testing.expectEqual(@as(f32, 0), decor.parallaxAt(900, 10));
+}
+
+test "changed text glows and fades in 250 ms; the cursor and a scroll do not light up" {
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 });
+ defer core.deinit();
+ const pane = try core.setTestFile("line\n" ** 60);
+ core.settings.afterglow = true;
+ pardes.test_api.sync(core);
+ const s = try core.render(core.scratch.allocator());
+ const body = for (s.regionList()) |region| {
+ if (region.kind == .body) break region;
+ } else return error.NoBody;
+ s.cursor = .{ .x = body.rect.x + 12, .y = body.rect.y + 2 };
+ var g: Gui = undefined;
+ g.decor = .empty;
+ defer g.decor.deinit(gpa);
+ g.cell_w = 10;
+ g.cell_h = 20;
+ g.afterglow = .{};
+ defer g.afterglow.deinit(gpa);
+ const layout = fixedCellLayout(&g);
+ const Count = struct {
+ fn glows(items: []const CellInstance, accent: [3]u8) usize {
+ var n: usize = 0;
+ for (items) |item| {
+ const rgb: [3]u8 = .{ @intFromFloat(@round(item.br * 255)), @intFromFloat(@round(item.bg * 255)), @intFromFloat(@round(item.bb * 255)) };
+ if (std.mem.eql(u8, &rgb, &accent) and item.fr > 0.001) n += 1;
+ }
+ return n;
+ }
+ };
+ g.sel_glow = .{ .now_ns = std.time.ns_per_s };
+ try afterglowDecor(&g, gpa, s, layout, 800, 480);
+ try std.testing.expectEqual(@as(usize, 0), g.decor.items.len);
+ // Three cells of a row change, the cursor's among them.
+ for (10..13) |x| {
+ const cell = &s.cells[@as(usize, body.rect.y + 2) * s.cols + body.rect.x + x];
+ cell.* = .{ .default = false, .style = .{ .fg = .{ .rgb = .{ 1, 2, 3 } } } };
+ cell.text[0] = 'X';
+ cell.len = 1;
+ }
+ g.sel_glow.now_ns += 16 * std.time.ns_per_ms;
+ try afterglowDecor(&g, gpa, s, layout, 800, 480);
+ try std.testing.expectEqual(@as(usize, 1), Count.glows(g.decor.items, s.chrome.accent));
+ const lit = g.decor.items[0];
+ // Two cells wide: the cursor's is left out.
+ try std.testing.expectApproxEqAbs(@as(f32, 2 * 10 * 2.0 / 800.0), lit.x1 - lit.x0, 1e-4);
+ try std.testing.expect(g.afterglow.glowing);
+ // Gone by 250 ms.
+ g.decor.clearRetainingCapacity();
+ g.sel_glow.now_ns += 260 * std.time.ns_per_ms;
+ try afterglowDecor(&g, gpa, s, layout, 800, 480);
+ try std.testing.expectEqual(@as(usize, 0), g.decor.items.len);
+ try std.testing.expect(!g.afterglow.glowing);
+ // A scroll changes every cell and lights none.
+ pane.scrollBy(5);
+ const scrolled = try core.render(core.scratch.allocator());
+ g.sel_glow.now_ns += 16 * std.time.ns_per_ms;
+ try afterglowDecor(&g, gpa, scrolled, layout, 800, 480);
+ try std.testing.expectEqual(@as(usize, 0), g.decor.items.len);
+}
+
/// 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
@@ -9649,7 +10179,6 @@ fn buildOverlay(
fn appendCursorGlide(g: *Gui, surface: *const pardes.Surface, builder: *OverlayBuilder) void {
const chrome = &surface.chrome;
if (g.cursor_quad_alpha <= 0.001) return;
- if (surface.panelTracks().len != 0) return;
const layout = fixedCellLayout(g);
var px: [4][2]f32 = undefined;
var centre: [2]f32 = .{ 0, 0 };