summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-29 08:37:12 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:16 -0300
commit52f9c1423bc4e58079b008e763b5485ae49911f7 (patch)
tree92d58a7713796f3dbe87daa492ea0306ee9fd674 /src
parent0a2694e935fa8a79f1a399f2d2cbc643f8c3734e (diff)
downloadpardes-52f9c1423bc4e58079b008e763b5485ae49911f7.tar.gz
pardes-52f9c1423bc4e58079b008e763b5485ae49911f7.zip
G14: Afterglow, changed text glows a moment and fades
Afterglow on|off (off, a pixel shell's; Chrome.fx bit 4): the GUI keeps the grid it last drew and when each cell changed; a body cell whose text changed glows in the theme's accent, at most 18% and under the text's contrast floor, fading with an ease-out over 250 ms, one flat rectangle a run of cells that changed together; never a tag or a grip, the cursor or a selection; a scrolled body (its first line moved) starts over rather than lighting up whole. On the continuous path while anything glows. Tests: three changed cells glow as a run of two (the cursor's left out), gone by 250 ms, a scroll lights nothing. Gates as before; goldens byte-identical. Feel review: .scratch/render/afterglow/.
Diffstat (limited to 'src')
-rw-r--r--src/config.zig12
-rw-r--r--src/draw.zig2
-rw-r--r--src/gui/gui.zig154
-rw-r--r--src/surface.zig3
4 files changed, 165 insertions, 6 deletions
diff --git a/src/config.zig b/src/config.zig
index d11cbe76..2a764118 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -126,6 +126,7 @@ pub const leader_path = paths: {
if (builtins.capabilities.lift) table.set(.HoverGlow, null);
if (builtins.capabilities.lift) table.set(.Occlusion, null);
if (builtins.capabilities.lift) table.set(.Parallax, null);
+ if (builtins.capabilities.lift) table.set(.Afterglow, null);
if (builtins.capabilities.lift) table.set(.Lift, null);
table.set(.Motion, null);
table.set(.InactiveDim, null);
@@ -682,6 +683,9 @@ pub const Runtime = struct {
/// G13: a theme's page pattern under the bodies (lapis's dots) moves
/// with the text at a quarter of its speed, a pixel shell's.
parallax: bool = false,
+ /// G14: text that changes glows for a moment in the theme's accent and
+ /// fades, a pixel shell's.
+ afterglow: bool = false,
/// How long a message stays after the key or click that dismisses it.
message_linger_ms: u16 = 800,
/// How long its fall into the row takes, easing out (ยง8.1: arriving,
@@ -905,7 +909,7 @@ pub const Runtime = struct {
/// The message timings a setting word sets, each in milliseconds.
pub const MessageTime = enum { linger, fall, dissolve };
- pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, syntax_bold, verbose, message_animation, ligatures, cursor_blink, selection_glow, hover_glow, occlusion, parallax };
+ pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, syntax_bold, verbose, message_animation, ligatures, cursor_blink, selection_glow, hover_glow, occlusion, parallax, afterglow };
/// A bundled post pass, a Shadertoy file in shaders/post/: the CRT, and
/// G4's bloom, vignette and grain (docs/effects.md).
pub const Scene = enum { crt, bloom, vignette, grain };
@@ -968,6 +972,7 @@ pub const Runtime = struct {
.{ .word = "HoverGlow", .action = .{ .toggle = .hover_glow }, .availability = .lift, .doc = "A soft underline of light under the word a look-hover would open, on or off." },
.{ .word = "Occlusion", .action = .{ .toggle = .occlusion }, .availability = .lift, .doc = "Pane bodies darken faintly toward their edges, on or off." },
.{ .word = "Parallax", .action = .{ .toggle = .parallax }, .availability = .lift, .doc = "A theme's page pattern moves with the text at a quarter of its speed, on or off." },
+ .{ .word = "Afterglow", .action = .{ .toggle = .afterglow }, .availability = .lift, .doc = "Text that changes glows for a moment and fades, on or off." },
.{ .word = "Lift", .action = .lift, .availability = .lift, .doc = "How the focused pane stands off the page: off, shadow, rim or auto." },
.{ .word = "Motion", .action = .motion, .doc = "How animations move: off, crisp, smooth, bouncy or playful." },
.{ .word = "InactiveDim", .action = .inactive_dim, .doc = "Fade the unfocused panes' text by a percentage, 0 through 100, 0 off." },
@@ -1044,9 +1049,9 @@ pub const Runtime = struct {
.toggle => |field| if (field == .ligatures) {
if (setting.availability != .ligatures)
@compileError("the ligatures toggle must use the ligatures capability");
- } else if (field == .cursor_blink or field == .selection_glow or field == .hover_glow or field == .occlusion or field == .parallax) {
+ } else if (field == .cursor_blink or field == .selection_glow or field == .hover_glow or field == .occlusion or field == .parallax or field == .afterglow) {
if (setting.availability != .lift)
- @compileError("CursorBlink, SelectionGlow, HoverGlow, Occlusion and Parallax must use the lift capability (a pixel shell's)");
+ @compileError("CursorBlink, SelectionGlow, HoverGlow, Occlusion, Parallax and Afterglow must use the lift capability (a pixel shell's)");
} else if (setting.availability != null)
@compileError("unconditional toggles cannot carry a backend capability"),
else => if (setting.availability != null)
@@ -1705,6 +1710,7 @@ pub const Runtime = struct {
.hover_glow => "HoverGlow on\n",
.occlusion => "Occlusion on\n",
.parallax => "Parallax on\n",
+ .afterglow => "Afterglow on\n",
},
.shell => "Shell requested (new panes) fish\n",
.tagline_size => "TaglineSize 73\n",
diff --git a/src/draw.zig b/src/draw.zig
index aef93f51..93d5b2d9 100644
--- a/src/draw.zig
+++ b/src/draw.zig
@@ -473,7 +473,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
.rail_px = @intCast(@min(255, @as(u32, p.theme().rail_px orelse 12) * p.settings.grip_width / 100)),
.name_ink = chrome.tag_name_fg,
.active_name_ink = if (p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg,
- .fx = @as(u8, @intFromBool(p.settings.selection_glow)) | @as(u8, @intFromBool(p.settings.hover_glow)) << 1 | @as(u8, @intFromBool(p.settings.occlusion)) << 2 | @as(u8, @intFromBool(p.settings.parallax)) << 3,
+ .fx = @as(u8, @intFromBool(p.settings.selection_glow)) | @as(u8, @intFromBool(p.settings.hover_glow)) << 1 | @as(u8, @intFromBool(p.settings.occlusion)) << 2 | @as(u8, @intFromBool(p.settings.parallax)) << 3 | @as(u8, @intFromBool(p.settings.afterglow)) << 4,
};
if (p.theme().decor) |decor| {
s.chrome.decor_dots = decor.page_dots;
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index dc58ee3f..b631a1cb 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -1874,6 +1874,8 @@ const Gui = struct {
/// 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.
@@ -2583,6 +2585,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);
@@ -4997,7 +5000,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 or g.hover_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) {
@@ -5823,6 +5826,7 @@ fn renderFrame(
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.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);
@@ -8761,6 +8765,8 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g
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.
@@ -9193,6 +9199,91 @@ fn occlusion(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, gr
}
}
+/// 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
/// group `index`: each pane tag, and with the still panes the column and
/// workspace tags, becomes a framed box inside its own band with a hard
@@ -9795,6 +9886,67 @@ test "a body tells its first line, and lapis's dots under it move a quarter as f
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
diff --git a/src/surface.zig b/src/surface.zig
index d1ea78b1..22a71a27 100644
--- a/src/surface.zig
+++ b/src/surface.zig
@@ -455,7 +455,8 @@ pub const Chrome = struct {
/// A pixel shell's small effects: bit 0 a glow round the body's
/// selection (G9, SelectionGlow), bit 1 one under the word a look-hover
/// would open (G11, HoverGlow), bit 2 the bodies' edge shade (G12,
- /// Occlusion), bit 3 the page pattern's parallax (G13, Parallax).
+ /// Occlusion), bit 3 the page pattern's parallax (G13, Parallax), bit 4
+ /// changed text's afterglow (G14, Afterglow).
fx: u8 = 0,
};