From 52f9c1423bc4e58079b008e763b5485ae49911f7 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 08:37:12 -0300 Subject: 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/. --- docs/config.md | 2 + docs/effects.md | 10 ++++ src/config.zig | 12 +++-- src/draw.zig | 2 +- src/gui/gui.zig | 154 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- src/surface.zig | 3 +- 6 files changed, 177 insertions(+), 6 deletions(-) diff --git a/docs/config.md b/docs/config.md index 97bf95a1..c66b124e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -580,6 +580,8 @@ Occlusion on pane bodies darken faintly toward their edges (2%), never over the cursor Parallax on a theme's page pattern (lapis's dots) moves with the text at a quarter of its speed +Afterglow on text that changes glows for a moment in the theme's accent + and fades (250 ms), never on a tag, the cursor or a selection CursorBlink on the cursor blinks, solid while typing and half a second after, eased at each edge, solid after 10 idle seconds GripWidth 150 the grip's button and the scrollbar under it, percent of the diff --git a/docs/effects.md b/docs/effects.md index fbb44dfa..6a283e45 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -47,6 +47,7 @@ why. The verdict is the user's. | G11 look-hover glow | `HoverGlow on\|off` (off) | a soft underline of the theme's accent under the word a look-hover would open, fading in over 80 ms | .scratch/render/hover/ (forge, acme, lapis; zooms) | pending | | G12 ambient occlusion | `Occlusion on\|off` (off) | pane bodies darken toward their edges, at most 2% (§9.1: 1 to 2%) | .scratch/render/occlusion/ (on/off on forge, acme, lapis; acme-diff-x40.png) | pending | | G13 parallax | `Parallax on\|off` (off) | a theme's page pattern under the bodies (lapis's dots) moves with the text at a quarter of its speed, glides included | .scratch/render/parallax/ (lapis, notches down and up) | pending | +| G14 afterglow | `Afterglow on\|off` (off) | a body cell whose text changed glows in the accent at 18% and fades over 250 ms | .scratch/render/afterglow/ (forge, acme: two in-place edits) | pending | ## Motion flavours @@ -237,3 +238,12 @@ so they drift with the text as it glides and settle with it (measured: 4 lines at 35 px move the 22 px grid 13 px, 35 mod 22). The cell shader reads the offset from the instance's serial slot, free since G7 took the dissolve's per-cell noise out. Without a pattern, nothing happens. + +Notes on G14: the GUI keeps the grid it last drew and when each cell last +changed; a changed cell of a pane body glows in the theme's accent, 18% at +most and under the text's contrast floor, and fades with an ease-out over +250 ms, drawn as one flat rectangle a run of cells that changed together. +Never on a tag or a grip (bodies only), the cursor's cell or a selection; +a body whose first line moved (a scroll) starts over instead of lighting +up whole. It runs on the continuous path while anything glows, and costs +a compare of the grid a frame while on, nothing while off. 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, }; -- cgit v1.3