From 87ad46cb7e7410c53419c20cef7df53ed4c15f60 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 07:31:35 -0300 Subject: G11: HoverGlow, a soft underline of light under a look-hover's word HoverGlow on|off (off, a pixel shell's; Chrome.glows bit 1, SelectionGlow bit 0 now that there are two): under the word a look-hover would open (its cells in the affordance's tint) a strip three logical pixels tall along its foot, blurred by one, in the theme's accent, at most 22% and under the text's contrast floor, fading in over 80 ms; kept to the bodies and split round a cursor. glowBlocks serves G9 and G11. The strip is cast from a row away so addSoft's caster cut-out misses it. Tests: the glow comes up by 90 ms and never falls on the cursor's cell. Gates as G10 (a loaded machine shifted the GUI goldens a scene once; a rerun matched 01-08). Feel review: .scratch/render/hover/. --- src/gui/gui.zig | 148 ++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 127 insertions(+), 21 deletions(-) (limited to 'src/gui/gui.zig') diff --git a/src/gui/gui.zig b/src/gui/gui.zig index bd2dca69..45861ea4 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -1870,8 +1870,10 @@ 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 = .{}, /// 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. @@ -4988,7 +4990,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; glide.now_ns = core.now_ns; glide.motion = pardes.animation.Motion.of(core.settings.motion); if (!glide.spring.settled) { @@ -5813,7 +5815,9 @@ 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.glows & 1 == 0 or surface.panelTracks().len != 0) g.sel_glow = .{ .now_ns = g.sel_glow.now_ns }; + g.hover_glow.now_ns = g.sel_glow.now_ns; + if (surface.chrome.glows & 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); // Plaques need an opaque window: with WindowOpacity under 100 the cells @@ -8704,7 +8708,9 @@ 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.glows & 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.glows & 2 != 0) try glowBlocks(g, gpa, surface, .hover, layout, win_w, win_h); // A single spine runs down each column, through every tag and body. if (index == 0) for (regions) |column| { if (column.kind != .column) continue; @@ -8956,19 +8962,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 +9030,66 @@ 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]); + }, + } } - g.sel_glow = next; + tracker.* = next; } /// A theme's tag plaques (Chrome.decor_box_*), drawn under the cells of @@ -9480,7 +9536,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.glows); // 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 +9578,56 @@ 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.glows); + 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); +} + /// 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 -- cgit v1.3 From ab668719385c94df3e274f71cb744f5917c1da7e Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 07:47:39 -0300 Subject: G12: Occlusion, pane bodies darkening faintly toward their edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Occlusion on|off (off, a pixel shell's; Chrome.fx bit 2 — the glow bits field is now `fx`): four soft strips cast from just outside each pane body inward, clipped to the body and round a cursor, black pre-warped to linear light, 2% at most (§9.1) and under the lift's contrast ceiling, in the pane's own group. Tests: the shade stays inside the body, off the cursor, at most 2%. Gates as G11. Feel review: .scratch/render/occlusion/ (with an x40 diff). --- docs/config.md | 2 ++ docs/effects.md | 8 +++++ src/config.zig | 12 +++++-- src/detached/wire.zig | 2 +- src/draw.zig | 2 +- src/gui/gui.zig | 91 +++++++++++++++++++++++++++++++++++++++++++++++---- src/surface.zig | 9 ++--- 7 files changed, 111 insertions(+), 15 deletions(-) (limited to 'src/gui/gui.zig') diff --git a/docs/config.md b/docs/config.md index cd1244f7..44f37079 100644 --- a/docs/config.md +++ b/docs/config.md @@ -576,6 +576,8 @@ SelectionGlow on a soft halo of the selection's colour round it in the body, fading in over 100 ms, never over a tag, a grip or the cursor HoverGlow on a soft glow under the word a look-hover would open, fading in over 80 ms +Occlusion on pane bodies darken faintly toward their edges (2%), never + over the cursor 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 c11ff070..ad454775 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -45,6 +45,7 @@ why. The verdict is the user's. | G9 selection glow | `SelectionGlow on\|off` (off) | a soft halo of the selection's colour round each block of it in a body, fading in over 100 ms | .scratch/render/selection/ (forge, acme, lapis; lapis-glow-zoom.png) | pending | | G10 theme crossfade | always (a theme change) | the chrome's colours fade through OKLab, easing in and out (out when retargeted) | .scratch/render/crossfade/ (forge, lapis, acme cut short by dusk) | pending | | 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 | ## Motion flavours @@ -219,3 +220,10 @@ Found while reviewing: with the machine loaded, test/gui_golden.py's two-captures-agree settle can take a scene's frame before the scene's change lands, and every later hash moves up a scene; a rerun on a quiet machine matches. + +Notes on G12: four soft strips cast from just outside each body inward, +clipped to it and round a cursor, black in linear light, 2% at most and +under the lift's contrast ceiling, in the pane's own group so it moves +with it. At §9.1's strength it is all but invisible on a dark page (a +level of 255 on forge) and faint on a light one (acme's page steps one +level toward the edges); acme-diff-x40.png shows where it falls. diff --git a/src/config.zig b/src/config.zig index 4e159d01..22e65c46 100644 --- a/src/config.zig +++ b/src/config.zig @@ -124,6 +124,7 @@ pub const leader_path = paths: { if (builtins.capabilities.lift) table.set(.CursorBlink, null); if (builtins.capabilities.lift) table.set(.SelectionGlow, null); if (builtins.capabilities.lift) table.set(.HoverGlow, null); + if (builtins.capabilities.lift) table.set(.Occlusion, null); if (builtins.capabilities.lift) table.set(.Lift, null); table.set(.Motion, null); table.set(.InactiveDim, null); @@ -674,6 +675,9 @@ pub const Runtime = struct { /// G11: a soft glow under the word a look-hover would open, fading in /// over 80 ms, a pixel shell's. hover_glow: bool = false, + /// G12: pane bodies darken faintly toward their edges (ambient + /// occlusion, at most 2%), a pixel shell's. + occlusion: 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, @@ -897,7 +901,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 }; + 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 }; /// 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 }; @@ -958,6 +962,7 @@ pub const Runtime = struct { .{ .word = "CursorBlink", .action = .{ .toggle = .cursor_blink }, .availability = .lift, .doc = "The cursor blinks, solid while typing, on or off." }, .{ .word = "SelectionGlow", .action = .{ .toggle = .selection_glow }, .availability = .lift, .doc = "A soft halo of the selection's colour round it in the body, on or off." }, .{ .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 = "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." }, @@ -1034,9 +1039,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) { + } else if (field == .cursor_blink or field == .selection_glow or field == .hover_glow or field == .occlusion) { if (setting.availability != .lift) - @compileError("CursorBlink, SelectionGlow and HoverGlow must use the lift capability (a pixel shell's)"); + @compileError("CursorBlink, SelectionGlow, HoverGlow and Occlusion 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) @@ -1693,6 +1698,7 @@ pub const Runtime = struct { .cursor_blink => "CursorBlink off\n", .selection_glow => "SelectionGlow on\n", .hover_glow => "HoverGlow on\n", + .occlusion => "Occlusion on\n", }, .shell => "Shell requested (new panes) fish\n", .tagline_size => "TaglineSize 73\n", diff --git a/src/detached/wire.zig b/src/detached/wire.zig index cf5a8711..3a914e42 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -13,7 +13,7 @@ pub const version: u16 = 8; /// The Chrome palette is carried field by field as its declaration lists /// them, so any change to it changes the wire: bump `version`, then this. -const chrome_layout: u64 = 0x3195816f182fe5bc; +const chrome_layout: u64 = 0x5e758b47fb0cf8da; comptime { @setEvalBranchQuota(10_000); var hash = std.hash.Fnv1a_64.init(); diff --git a/src/draw.zig b/src/draw.zig index 5e40f0fa..027326fa 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -472,7 +472,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, - .glows = @as(u8, @intFromBool(p.settings.selection_glow)) | @as(u8, @intFromBool(p.settings.hover_glow)) << 1, + .fx = @as(u8, @intFromBool(p.settings.selection_glow)) | @as(u8, @intFromBool(p.settings.hover_glow)) << 1 | @as(u8, @intFromBool(p.settings.occlusion)) << 2, }; 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 45861ea4..6bde8b60 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -5815,9 +5815,9 @@ 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.glows & 1 == 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 }; g.hover_glow.now_ns = g.sel_glow.now_ns; - if (surface.chrome.glows & 2 == 0 or surface.panelTracks().len != 0) 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); // Plaques need an opaque window: with WindowOpacity under 100 the cells @@ -8708,9 +8708,11 @@ 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.glows & 1 != 0) try glowBlocks(g, gpa, surface, .selection, 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.glows & 2 != 0) try glowBlocks(g, gpa, surface, .hover, layout, win_w, win_h); + if (index == 0 and groups.tracks == 0 and chrome.fx & 2 != 0) try glowBlocks(g, gpa, surface, .hover, 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; @@ -9092,6 +9094,44 @@ fn glowBlocks(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, k 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]); + } + } +} + /// 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 @@ -9536,7 +9576,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.glows); + 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; @@ -9586,7 +9626,7 @@ test "a hovered word glows along its foot, never over the cursor, fading in over 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.glows); + 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; @@ -9628,6 +9668,45 @@ test "a hovered word glows along its foot, never over the cursor, fading in over 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); +} + /// 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 26783df4..02e4bb5d 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -448,10 +448,11 @@ pub const Chrome = struct { decor_title_px: u8 = 2, name_ink: [3]u8 = .{ 0, 0, 0 }, active_name_ink: [3]u8 = .{ 0, 0, 0 }, - /// A pixel shell's glows: bit 0 round the body's selection (G9, - /// SelectionGlow), bit 1 under the word a look-hover would open (G11, - /// HoverGlow). - glows: u8 = 0, + /// 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). + fx: u8 = 0, }; /// The canonical interface: what a frame of pardes IS. The tty shell writes -- cgit v1.3 From 6f7c79650857da578b9e6f8c49c772eb7c40bcae Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 08:17:38 -0300 Subject: GUI goldens settle on the window's idle, not two captures that agree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test/gui_golden.py hashed a scene once two captures 400 ms apart agreed; on a loaded machine a slow frame agreed with itself before the scene's change landed and every later hash moved up a scene. A test window now writes a status file into its capture directory each loop step (gui.zig writeTestStatus: step, frames captured, idle — nothing owes a frame, nothing moves, nothing waits on the core's clock), and the harness waits for an idle step two past the one current when it asks: a 9P write has been served by then, so the frame is the scene's. Two things kept a window from ever going idle, both fixed: - Look-hover re-armed forever: resting on a word with nothing to open, the hover wait ran its two frames, found nothing, cancelled, and the next present's pointer refresh started it again: a frame every other frame for as long as the pointer rested there, live too (CPU). The place last refused is remembered (Pardes.look_hover_refused) until the pointer moves off it. - The virtual clock moved only on a wait that timed out, so a steady trickle of events held it still; it now moves to the core's wake too when an event cut the wait short and nothing owes a frame. Captures go to a directory of their own, outside the session's (a file written there every frame is a change in the directory its panes look at). All 17 goldens match the file as recorded at oooopqyv; eight runs, five alongside a ReleaseFast build and three with all 16 cores saturated (load 13-19), were byte-identical, 14 s a run unloaded (was ~30). Tests: the resting pointer lets the core go idle. --- src/gui/gui.zig | 35 ++++++++++++++++++++++++++++++- src/look.zig | 33 +++++++++++++++++++++++++++-- src/pardes.zig | 3 +++ test/gui_golden.py | 61 ++++++++++++++++++++++++++++++++++-------------------- 4 files changed, 107 insertions(+), 25 deletions(-) (limited to 'src/gui/gui.zig') diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 6bde8b60..8d9c29ab 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -4273,7 +4273,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 @@ -4348,6 +4354,7 @@ fn pollFrame(ctx: ?*anyopaque) void { 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); + 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). @@ -8235,6 +8242,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`: " ". +/// 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. diff --git a/src/look.zig b/src/look.zig index 4ee5a638..39f7420e 100644 --- a/src/look.zig +++ b/src/look.zig @@ -934,6 +934,12 @@ fn noteLookHover(p: *Pardes, col: u16, row: u16, body_hit: ?Mouse.BodyHit, tag_h if (p.look_hover_wait) |waiting| if (waiting.col == col and waiting.row == row and waiting.pane == id and waiting.serial == pane.serial and mouse.sameBodyCell(waiting.body_hit, body_hit) and tag_layer.sameCell(waiting.tag_hit, tag_hit)) return; + // Found nothing to open here a moment ago: the pointer resting on it + // does not start another wait (and another frame) every frame. + if (p.look_hover_refused) |refused| + if (refused.col == col and refused.row == row and refused.pane == id and refused.serial == pane.serial and mouse.sameBodyCell(refused.body_hit, body_hit) and tag_layer.sameCell(refused.tag_hit, tag_hit)) return; + p.look_hover_refused = null; + cancelLookHover(p); p.look_hover_wait = .{ .col = col, .row = row, .pane = id, .serial = pane.serial, .body_hit = body_hit, .tag_hit = tag_hit }; } @@ -958,8 +964,10 @@ pub fn advanceLookHover(p: *Pardes) void { if (waiting.frames < delay) return; const pane = p.panes[waiting.pane] orelse return cancelLookHover(p); if (pane.serial != waiting.serial) return cancelLookHover(p); - const pointed = edit.pointerTextSelection(p, waiting.pane, waiting.col, waiting.row, waiting.body_hit, waiting.tag_hit) orelse + const pointed = edit.pointerTextSelection(p, waiting.pane, waiting.col, waiting.row, waiting.body_hit, waiting.tag_hit) orelse { + p.look_hover_refused = waiting.*; return cancelLookHover(p); + }; if (comptime pdf_enabled) if (!pointed.on_tag and panes.Pdf.paneNativeReady(p, pane)) { const probe = panes.Pdf.probeAt(p, pane, waiting.col, waiting.row) orelse return cancelLookHover(p); @@ -976,7 +984,10 @@ pub fn advanceLookHover(p: *Pardes) void { return; }; const operand = exec.pointerOperand(p, pane, pointed.sel); - if (operand.text == null or operand.text.?.len == 0) return cancelLookHover(p); + if (operand.text == null or operand.text.?.len == 0) { + p.look_hover_refused = waiting.*; + return cancelLookHover(p); + } var preview = operand.preview; if (preview) |*sel| sel.state = .done; p.look_hover_preview = .{ @@ -1727,3 +1738,21 @@ test "Look ignores missing and out of bounds pane addresses" { try std.testing.expect(pane.body.vsel.active); // a miss changes nothing, the selection included } } + +test "a pointer resting where a look-hover found nothing does not wake the core every frame" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); + defer p.deinit(); + _ = try p.setTestFile(" \n" ** 30); + p.sync(); + const r = p.rects[p.active]; + // Resting on blanks in the body: a wait, then nothing to open. + p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = r.x + 20, .row = r.y + 5 } }); + var frames: usize = 0; + while (p.nextWake() != null and frames < 20) : (frames += 1) { + p.update(.tick); + // What a shell's present does after every frame. + p.acknowledgePanelPresentation(&.{}); + } + try std.testing.expect(frames < 20); + try std.testing.expect(p.look_hover_wait == null and p.look_hover_preview == null); +} diff --git a/src/pardes.zig b/src/pardes.zig index 72a90244..d6385e6f 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -4377,6 +4377,9 @@ pub const Pardes = struct { /// The look just run found nothing (look.zig missed). look_missed: bool = false, look_hover_wait: ?look.LookHoverWait = null, + /// The place a look-hover last found nothing to open: the pointer + /// resting there does not ask again every frame (look.noteLookHover). + look_hover_refused: ?look.LookHoverWait = null, look_hover_preview: ?look.LookHoverPreview = null, pdf_hover_preview: if (pdf_enabled) ?look.PdfWordPreview else void = if (pdf_enabled) null else {}, wheel_guard: u8 = 0, diff --git a/test/gui_golden.py b/test/gui_golden.py index 99e8c636..24fc5986 100644 --- a/test/gui_golden.py +++ b/test/gui_golden.py @@ -9,7 +9,8 @@ The window is hidden (PARDES_TEST renders into an offscreen capture texture), and the capture carries PARDES_TEST_PAD leftover pixels so edge bugs show. GPU rasterisation differs between machines and drivers: the goldens in test/gui-goldens.txt are a LOCAL gate for one machine, never a CI one. Each -scene waits until two captures 400 ms apart agree before it is hashed. +scene waits until the window says it is idle (its status file) before it is +hashed, so a loaded machine gives the same frames. """ import argparse import hashlib @@ -60,24 +61,34 @@ def panes(client): return {serial: client.read(f'/pane/{serial}/tag').decode(errors='replace') for serial in serials} +def status(root): + """The window's (step, frames captured, idle), or None before the first.""" + try: + step, frames, idle = (root / 'status').read_text().split()[:3] + return int(step), int(frames), idle == '1' + except (FileNotFoundError, ValueError): + return None + + def settled(root): - """The capture once it stops changing: two reads 400 ms apart agree.""" - latest = root / 'latest.ppm' - previous = None - deadline = time.monotonic() + 20 + """The capture once the window says it is idle: a loop step past the one + current when this was asked, with nothing owing a frame (the window's + status file, gui.zig writeTestStatus). Every command before this call has + been served by then (a 9P write returns once the core has it), so the + frame is the scene's however loaded the machine is; the digest of it.""" + deadline = time.monotonic() + 60 + start = None while time.monotonic() < deadline: - time.sleep(.4) - try: - data = latest.read_bytes() - except FileNotFoundError: - continue - if not data.startswith(b'P6'): - continue - digest = hashlib.sha256(data).hexdigest() - if digest == previous: - return digest - previous = digest - raise RuntimeError('capture never settled') + now = status(root) + if now is not None: + if start is None: + start = now[0] + elif now[0] >= start + 2 and now[2] and now[1] > 0: + data = (root / 'latest.ppm').read_bytes() + if data.startswith(b'P6'): + return hashlib.sha256(data).hexdigest() + time.sleep(.02) + raise RuntimeError('the window never went idle') def run(args): @@ -86,6 +97,12 @@ def run(args): root = Path(tempfile.gettempdir()) / 'pardes-gui-golden' shutil.rmtree(root, ignore_errors=True) root.mkdir(mode=0o700) + # Captures go outside the session's own directory: a file written there + # every frame is a change in the directory its panes look at, which + # would ask for another frame, and so on for ever. + captures = Path(tempfile.gettempdir()) / 'pardes-gui-golden-captures' + shutil.rmtree(captures, ignore_errors=True) + captures.mkdir(mode=0o700) name = 'gui-golden' config = root / 'config/pardes' config.mkdir(parents=True) @@ -109,7 +126,7 @@ def run(args): # The virtual clock: a transition's frames are the same frames on any # machine, so one partway through can be hashed too. inherited = {'PARDES_TEST': '1', 'PARDES_TEST_CLOCK': '1', 'PARDES_TEST_COLS': '120', 'PARDES_TEST_ROWS': '40', - 'PARDES_TEST_PAD': '7', 'PARDES_TEST_CAPTURE_DIR': str(root), + 'PARDES_TEST_PAD': '7', 'PARDES_TEST_CAPTURE_DIR': str(captures), 'SDL_VIDEODRIVER': 'wayland', 'WAYLAND_DISPLAY': str(display)} launch = ['-c', 'stty cols 120 rows 40; exec "$@"', name, str(args.binary.resolve()), '--9p=' + name, str(source)] @@ -120,11 +137,11 @@ def run(args): if 'shapes.zig' in tag), None)), 'source pane missing') def scene(label, frame='latest.ppm'): - settled(root) - results.append((label, hashlib.sha256((root / frame).read_bytes()).hexdigest())) + settled(captures) + results.append((label, hashlib.sha256((captures / frame).read_bytes()).hexdigest())) if args.keep: args.keep.mkdir(parents=True, exist_ok=True) - shutil.copyfile(root / frame, args.keep / (label + '.ppm')) + shutil.copyfile(captures / frame, args.keep / (label + '.ppm')) print(label, results[-1][1][:16], flush=True) def mouse(kind, button, x, y): @@ -201,7 +218,7 @@ def run(args): # chrome slides with each pane. client.write('/ctl', b'PanelSlide\n') client.write(f'/pane/{first}/exec', b'Newcol\n') - require(wait_for(lambda: (root / 'transition-6.ppm').exists()), 'transition frame 6 never drawn') + require(wait_for(lambda: (captures / 'transition-6.ppm').exists()), 'transition frame 6 never drawn') scene('18-mid-transition', 'transition-6.ppm') lines = ''.join(f'{label} {digest}\n' for label, digest in results) if args.update: -- cgit v1.3 From 0a2694e935fa8a79f1a399f2d2cbc643f8c3734e Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 08:28:40 -0300 Subject: G13: Parallax, lapis's dots drift with the text at a quarter of its speed Parallax on|off (off, a pixel shell's; Chrome.fx bit 3). A body region carries its first line (Region.line, a file's scroll or a terminal's grid offset; the wire's region record grows a u32). The GUI offsets each body's page dots by a quarter of its line in rows, and of G6's glide offset while one runs, so the pattern drifts with the text and settles with it; the cell shader reads it from the instance's serial slot, whose per-cell dissolve noise G7 left unused (the dead noise code goes). Tests: a scrolled body tells its line; the shift handed to the shader is a quarter of it within the dots' period. Measured in a capture: four lines move the dots 13 px (140 px / 4 mod 22). Gates as before, goldens byte-identical. Feel review: .scratch/render/parallax/. --- docs/config.md | 2 ++ docs/effects.md | 10 ++++++++ shaders/prebuilt/ui.frag.glsl | 18 +++---------- shaders/prebuilt/ui.frag.spv | Bin 8176 -> 6968 bytes shaders/ui.frag.glsl | 18 +++---------- src/config.zig | 12 ++++++--- src/detached/wire.zig | 4 ++- src/draw.zig | 5 ++-- src/gui/gui.zig | 57 +++++++++++++++++++++++++++++++++++++++++- src/surface.zig | 6 ++++- 10 files changed, 96 insertions(+), 36 deletions(-) (limited to 'src/gui/gui.zig') diff --git a/docs/config.md b/docs/config.md index 44f37079..97bf95a1 100644 --- a/docs/config.md +++ b/docs/config.md @@ -578,6 +578,8 @@ HoverGlow on a soft glow under the word a look-hover would open, fading in over 80 ms 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 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 ad454775..fbb44dfa 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -46,6 +46,7 @@ why. The verdict is the user's. | G10 theme crossfade | always (a theme change) | the chrome's colours fade through OKLab, easing in and out (out when retargeted) | .scratch/render/crossfade/ (forge, lapis, acme cut short by dusk) | pending | | 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 | ## Motion flavours @@ -227,3 +228,12 @@ under the lift's contrast ceiling, in the pane's own group so it moves with it. At §9.1's strength it is all but invisible on a dark page (a level of 255 on forge) and faint on a light one (acme's page steps one level toward the edges); acme-diff-x40.png shows where it falls. + +Notes on G13: a body region now carries its first line (Region.line: a +file's scroll, a terminal's grid offset; on the wire, a u32 more a +region). Under Parallax the dots under each body are offset by a quarter +of its line times the row height, plus G6's glide offset while one runs, +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. diff --git a/shaders/prebuilt/ui.frag.glsl b/shaders/prebuilt/ui.frag.glsl index cb38fff1..51ec5b8c 100644 --- a/shaders/prebuilt/ui.frag.glsl +++ b/shaders/prebuilt/ui.frag.glsl @@ -27,16 +27,6 @@ layout(location = 5) flat in vec4 v_clip; layout(location = 0) out vec4 o_col; -float cellNoise(uint serial, uint col, uint row) { - uint x = serial ^ (col * 0x9e3779b9u) ^ (row * 0x85ebca6bu); - x ^= x >> 16; - x *= 0x7feb352du; - x ^= x >> 15; - x *= 0x846ca68bu; - x ^= x >> 16; - return float(x & 0xffffu) / 65535.0; -} - void main() { if (v_clip.z >= v_clip.x && (gl_FragCoord.x < v_clip.x || gl_FragCoord.x >= v_clip.z || gl_FragCoord.y < v_clip.y || gl_FragCoord.y >= v_clip.w)) discard; @@ -51,9 +41,6 @@ void main() { : clamp(u_ui.background_opacity, 0.0, 1.0); uint effect = v_effect.x & 0x07ffffffu; float progress = clamp(uintBitsToFloat(v_effect.y), 0.0, 1.0); - uint cellCol = v_effect.w & 0xffffu; - uint cellRow = v_effect.w >> 16; - float noise = cellNoise(v_effect.z, cellCol, cellRow); // Ink over ground, both premultiplied: v_fg*a + (v_bg*bgA)*(1-a), which is // mix() with the ground already premultiplied. At bgA = 1 that is exactly @@ -63,7 +50,10 @@ void main() { // dot every period pixels, fixed to the window, under the text. if ((v_effect.x & 0x10000000u) != 0u) { float period = u_ui.decor_dot_geom.x; - vec2 d = mod(gl_FragCoord.xy, period) - 0.5 * period; + // v_effect.z: how far its pane's text has moved the dots (G13's + // parallax), in pixels, as a float's bits (0 without). + vec2 at = gl_FragCoord.xy - vec2(0.0, uintBitsToFloat(v_effect.z)); + vec2 d = mod(at, period) - 0.5 * period; float dot_a = 1.0 - smoothstep(u_ui.decor_dot_geom.y, u_ui.decor_dot_geom.z, length(d)); ground = mix(ground, u_ui.decor_dots.rgb, u_ui.decor_dots.a * dot_a); } diff --git a/shaders/prebuilt/ui.frag.spv b/shaders/prebuilt/ui.frag.spv index 15318214..02e3edc4 100644 Binary files a/shaders/prebuilt/ui.frag.spv and b/shaders/prebuilt/ui.frag.spv differ diff --git a/shaders/ui.frag.glsl b/shaders/ui.frag.glsl index cb38fff1..51ec5b8c 100644 --- a/shaders/ui.frag.glsl +++ b/shaders/ui.frag.glsl @@ -27,16 +27,6 @@ layout(location = 5) flat in vec4 v_clip; layout(location = 0) out vec4 o_col; -float cellNoise(uint serial, uint col, uint row) { - uint x = serial ^ (col * 0x9e3779b9u) ^ (row * 0x85ebca6bu); - x ^= x >> 16; - x *= 0x7feb352du; - x ^= x >> 15; - x *= 0x846ca68bu; - x ^= x >> 16; - return float(x & 0xffffu) / 65535.0; -} - void main() { if (v_clip.z >= v_clip.x && (gl_FragCoord.x < v_clip.x || gl_FragCoord.x >= v_clip.z || gl_FragCoord.y < v_clip.y || gl_FragCoord.y >= v_clip.w)) discard; @@ -51,9 +41,6 @@ void main() { : clamp(u_ui.background_opacity, 0.0, 1.0); uint effect = v_effect.x & 0x07ffffffu; float progress = clamp(uintBitsToFloat(v_effect.y), 0.0, 1.0); - uint cellCol = v_effect.w & 0xffffu; - uint cellRow = v_effect.w >> 16; - float noise = cellNoise(v_effect.z, cellCol, cellRow); // Ink over ground, both premultiplied: v_fg*a + (v_bg*bgA)*(1-a), which is // mix() with the ground already premultiplied. At bgA = 1 that is exactly @@ -63,7 +50,10 @@ void main() { // dot every period pixels, fixed to the window, under the text. if ((v_effect.x & 0x10000000u) != 0u) { float period = u_ui.decor_dot_geom.x; - vec2 d = mod(gl_FragCoord.xy, period) - 0.5 * period; + // v_effect.z: how far its pane's text has moved the dots (G13's + // parallax), in pixels, as a float's bits (0 without). + vec2 at = gl_FragCoord.xy - vec2(0.0, uintBitsToFloat(v_effect.z)); + vec2 d = mod(at, period) - 0.5 * period; float dot_a = 1.0 - smoothstep(u_ui.decor_dot_geom.y, u_ui.decor_dot_geom.z, length(d)); ground = mix(ground, u_ui.decor_dots.rgb, u_ui.decor_dots.a * dot_a); } diff --git a/src/config.zig b/src/config.zig index 22e65c46..d11cbe76 100644 --- a/src/config.zig +++ b/src/config.zig @@ -125,6 +125,7 @@ pub const leader_path = paths: { if (builtins.capabilities.lift) table.set(.SelectionGlow, null); 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(.Lift, null); table.set(.Motion, null); table.set(.InactiveDim, null); @@ -678,6 +679,9 @@ pub const Runtime = struct { /// G12: pane bodies darken faintly toward their edges (ambient /// occlusion, at most 2%), a pixel shell's. occlusion: bool = false, + /// 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, /// 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, @@ -901,7 +905,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 }; + 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 }; /// 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 }; @@ -963,6 +967,7 @@ pub const Runtime = struct { .{ .word = "SelectionGlow", .action = .{ .toggle = .selection_glow }, .availability = .lift, .doc = "A soft halo of the selection's colour round it in the body, on or off." }, .{ .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 = "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." }, @@ -1039,9 +1044,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) { + } else if (field == .cursor_blink or field == .selection_glow or field == .hover_glow or field == .occlusion or field == .parallax) { if (setting.availability != .lift) - @compileError("CursorBlink, SelectionGlow, HoverGlow and Occlusion must use the lift capability (a pixel shell's)"); + @compileError("CursorBlink, SelectionGlow, HoverGlow, Occlusion and Parallax 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) @@ -1699,6 +1704,7 @@ pub const Runtime = struct { .selection_glow => "SelectionGlow on\n", .hover_glow => "HoverGlow on\n", .occlusion => "Occlusion on\n", + .parallax => "Parallax on\n", }, .shell => "Shell requested (new panes) fish\n", .tagline_size => "TaglineSize 73\n", diff --git a/src/detached/wire.zig b/src/detached/wire.zig index 3a914e42..6903a1e8 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -270,6 +270,7 @@ pub const Frame = struct { region.notice = try r.getByte(); region.clip = try r.getU16(); region.lift = @bitCast(try r.getU32()); + region.line = @bitCast(try r.getU32()); // A springy flavour overshoots, and a wind-up dips below. if (!(region.lift >= -1 and region.lift <= 2)) return error.BadValue; if (region.rect.x > f.cols or region.rect.w > f.cols - region.rect.x or @@ -754,7 +755,7 @@ pub fn layersBound(layers: []const pardes.Layer, tags: []const pardes.Layer, reg return n + regions.len * region_len + chrome_len; } -const region_len = 1 + 1 + 4 + 8 + 1 + 2 + 2 + 1 + 2 + 4; +const region_len = 1 + 1 + 4 + 8 + 1 + 2 + 2 + 1 + 2 + 4 + 4; /// The palette: each colour three bytes, a theme's optional ones a flag /// before them. const chrome_len = @typeInfo(pardes.Chrome).@"struct".fields.len * 4 + 8 * 4; @@ -839,6 +840,7 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point try w.putByte(region.notice); try w.putU16(region.clip); try w.putU32(@bitCast(region.lift)); + try w.putU32(@bitCast(region.line)); } inline for (@typeInfo(pardes.Chrome).@"struct".fields) |field| { const rgb = @field(chrome, field.name); diff --git a/src/draw.zig b/src/draw.zig index 027326fa..aef93f51 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -63,7 +63,8 @@ pub fn place(p: *Pardes, s: *Surface) void { // A folded pane is its tag rows and nothing else. if (pane.collapsed) continue; const body_h = r.h -| pane.tag_rows; - s.addRegion(.{ .kind = .body, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x + config.GUTTER, .y = body_y, .w = r.w - config.GUTTER, .h = body_h } }); + const line: i32 = if (pane.file) |f| @intCast(@min(f.scroll, std.math.maxInt(i32))) else if (pane.isTerminal()) panes.Terminal.gridOffset(pane) else 0; + s.addRegion(.{ .kind = .body, .owner = owner, .serial = pane.serial, .active = active, .line = line, .rect = .{ .x = r.x + config.GUTTER, .y = body_y, .w = r.w - config.GUTTER, .h = body_h } }); var rail: Region = .{ .kind = .rail, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x, .y = body_y, .w = config.GUTTER, .h = body_h } }; // An image's rail has no thumb; a native PDF's is measured as its // pages are placed (Pdf.draw); every other pane's is here. @@ -472,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, + .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, }; 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 8d9c29ab..dc58ee3f 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -5827,6 +5827,17 @@ fn renderFrame( 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; @@ -7004,7 +7015,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; @@ -8954,6 +8971,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; @@ -9740,6 +9768,33 @@ test "occlusion shades each body's edges, inside it, never a tag, a grip or the 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)); +} + /// 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 02e4bb5d..d1ea78b1 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -370,6 +370,10 @@ pub const Region = struct { /// focus (Lift, on a spring), a notice's while Lift is on. A pixel shell /// casts a soft shadow from it; the tier it is drawn in never changes. lift: f32 = 0, + /// A body's view: the first line it shows (a file's scroll, a + /// terminal's grid offset), for what a pixel shell moves with the text + /// (G13's parallax). + line: i32 = 0, /// Cells the column-move rail takes off the right edge while it runs /// there: layers and pixel shells stop short of them, the grid does not. clip: u16 = 0, @@ -451,7 +455,7 @@ 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). + /// Occlusion), bit 3 the page pattern's parallax (G13, Parallax). fx: u8 = 0, }; -- cgit v1.3 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(-) (limited to 'src/gui/gui.zig') 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 From 544ba807b4d33835a953d80b44bc49e146bb940b Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 09:24:20 -0300 Subject: Lapis plaques stacked on each other share one edge and line up A column's plaque now starts at its grip's end, where the pane plaque under it starts, and every plaque in a column ends at the same x, far enough short of the next column's rule that no shadow meets it. Where tag bands meet (the workspace's over the columns', a column's over its first pane's), the upper box runs down to the ruled edge with no shadow down, and the lower box lays its top frame over that edge, so there is one line between them, not two frames and a gap. --- src/gui/gui.zig | 84 +++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 73 insertions(+), 11 deletions(-) (limited to 'src/gui/gui.zig') diff --git a/src/gui/gui.zig b/src/gui/gui.zig index b631a1cb..127f9b5d 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -7114,11 +7114,12 @@ 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; for (s.regionList()) |grip| { if (grip.kind != .grip and grip.kind != .column_grip) continue; @@ -7137,6 +7138,26 @@ 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]); + 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); @@ -9296,8 +9317,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; @@ -9316,7 +9340,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 }; @@ -9325,16 +9349,28 @@ 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; + // the plaque's margin, so its frame is never under the first letter. A + // column's text sits a cell further in than a pane's; its plaque starts + // where the pane's under it does, at its grip's end. + const start = if (tag.kind == .column_tag) for (regions) |grip| { + if (grip.kind == .column_grip and grip.owner == tag.owner) break grip.rect.x + grip.rect.w; + } else tag.rect.x else tag.rect.x; + const x0 = @as(f32, @floatFromInt(@min(start, 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))); @@ -9344,16 +9380,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 { -- cgit v1.3 From 826ec1fac3c012e71ab34207328faaac5b5827b7 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 09:43:05 -0300 Subject: Shader files compile again on save; an attached GUI runs the session's post chain shader_build.zig takes the Shadertoy compile out of the GUI's Post: the process that holds the core compiles each chain file behind the prefix on a thread, watches its directory (file_watch's new shader slots), and on a change reads and hashes each file, compiling only one whose bytes moved. A failed compile keeps the last good SPIR-V and is said once; the same bytes are never compiled or reported twice. A file is read with std, not fs.zig's readFile, whose turn hand-off a worker thread does not hold. A detached session compiles for its frontends and sends them the chain (wire post: each pass's scene and level or the file's SPIR-V, and ShaderAnimation) on attach and on every change, so an attached GUI runs the same passes, levels and animation as a local one while still reading no disk and running no program. The attached GUI describes its frames to the chain from the session's chrome and redraws an animating chain on its own. --- build.zig | 5 + docs/config.md | 7 +- docs/render-pipeline.md | 11 +- src/config.zig | 2 +- src/detached/client.zig | 33 +++- src/detached/server.zig | 36 ++++- src/detached/wire.zig | 71 ++++++++- src/file_watch.zig | 13 +- src/gui/Post.zig | 234 ++++------------------------ src/gui/gui.zig | 58 +++++-- src/main.zig | 1 + src/shader_build.zig | 402 ++++++++++++++++++++++++++++++++++++++++++++++++ src/tty/tty.zig | 2 + 13 files changed, 645 insertions(+), 230 deletions(-) create mode 100644 src/shader_build.zig (limited to 'src/gui/gui.zig') diff --git a/build.zig b/build.zig index 34acc007..48987edf 100644 --- a/build.zig +++ b/build.zig @@ -842,6 +842,11 @@ pub fn build(b: *std.Build) void { }); } + // A detached session compiles its attached GUIs' Shadertoy files + // (shader_build.zig), whichever shell it was built for. + if (gui_shell_mod != root_mod) + root_mod.addAnonymousImport("post-prefix.glsl", .{ .root_source_file = b.path("shaders/post/prefix.glsl") }); + if (is_web) { const dp = dump_path.?; root_mod.addAnonymousImport("embedded.dump.zon", .{ diff --git a/docs/config.md b/docs/config.md index 189c5382..59883b59 100644 --- a/docs/config.md +++ b/docs/config.md @@ -543,8 +543,11 @@ Crt 3 ``` The SDL GUI runs it as the bundled pass of its post chain, which also takes -Shadertoy files written for ghostty (`Shader ~/crt.glsl`, `Shader off`), and -`ShaderAnimation off|on|always` says when the chain animates on its own. With +Shadertoy files written for ghostty (`Shader ~/crt.glsl`, `Shader off`; +a file compiles again when it is saved, and a save that fails to compile +keeps the last good one and says why), and +`ShaderAnimation off|on|always` says when the chain animates on its own. A +GUI attached to a detached session runs the session's chain the same way. With the chain empty the pass is bypassed. CRT works in linear light with restrained scanlines, mask, bloom and vignette, and no curvature, so clicks land where they are drawn. diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md index b8f735d9..88309d4a 100644 --- a/docs/render-pipeline.md +++ b/docs/render-pipeline.md @@ -501,8 +501,15 @@ Mirror ghostty 1.3.2 (`zig-pkg/ghostty-*/src/renderer/shadertoy.zig`, file, not ``. Level A redraws once a refresh of the window's display. A failed compile keeps the file's last good pipeline and says glslc's first line as a message; glslc missing is said once, and only files stay off (the bundled passes are compiled with the - build, through the same prefix). No file watching: `Shader ` twice - (out, then in) compiles it again. + build, through the same prefix). The files' directories are watched + (file_watch shader slots): a change there has each file read and hashed, + and one whose bytes moved compiles again; the same bytes (good or bad) + compile nothing and say nothing twice. The process that holds the core + compiles (shader_build.zig): a local GUI, or a detached session, which + sends its attached GUIs the chain with each file's SPIR-V (wire `post`, + on attach and on every change), so an attached GUI runs the same passes, + levels and ShaderAnimation as a local one and still reads no disk and runs + no program. - **Animation mode**: `ShaderAnimation off|on|always` (ghostty's `custom-shader-animation`): off = redraw only when content changes; on = continuous while the window is focused; always = continuous. Continuous means diff --git a/src/config.zig b/src/config.zig index 1b432415..86f3e7eb 100644 --- a/src/config.zig +++ b/src/config.zig @@ -1017,7 +1017,7 @@ pub const Runtime = struct { .{ .word = "Bloom", .action = .{ .scene = .bloom }, .availability = .scene_shaders, .doc = "The brightest ink glows a little: a level, 0 (off) through 3." }, .{ .word = "Vignette", .action = .{ .scene = .vignette }, .availability = .scene_shaders, .doc = "The window's corners fall into shade: a level, 0 (off) through 3." }, .{ .word = "Grain", .action = .{ .scene = .grain }, .availability = .scene_shaders, .doc = "The page takes a fine, still grain, like paper: a level, 0 (off) through 3." }, - .{ .word = "Shader", .action = .shader, .availability = .scene_shaders, .doc = "A Shadertoy file into the post chain, or out of it; off empties the chain." }, + .{ .word = "Shader", .action = .shader, .availability = .scene_shaders, .doc = "A Shadertoy file into the post chain, or out of it, compiled again when saved; off empties the chain." }, .{ .word = "ShaderAnimation", .action = .shader_animation, .availability = .scene_shaders, .doc = "When the post chain animates on its own: off, on (while focused) or always." }, .{ .word = "Placement", .action = .{ .choice = .placement }, .doc = "Where a new pane goes: acme, as makenewwindow does, or pardes, the older rules." }, .{ .word = "BootShell", .action = .{ .choice = .boot_shell }, .doc = "A document dropped where a lone untouched shell stands: keep the shell, or replace it." }, diff --git a/src/detached/client.zig b/src/detached/client.zig index 8c9a2560..69ab2fdd 100644 --- a/src/detached/client.zig +++ b/src/detached/client.zig @@ -109,6 +109,10 @@ pub const Client = struct { regions: std.ArrayListUnmanaged(pardes.Region) = .empty, /// The palette its chrome is drawn in, as its last frame gave it. chrome: pardes.Chrome = .{}, + /// The post chain its frames are drawn through, as the session last + /// sent it; its paths and SPIR-V owned here. `post_key` moves with it. + post: wire.Post = .{}, + post_key: u32 = 0, in: std.ArrayListUnmanaged(u8) = .empty, out: std.ArrayListUnmanaged(u8) = .empty, /// Bytes of `in` belonging to the message `next` returned last. Compacted at @@ -187,6 +191,7 @@ pub const Client = struct { } for (&c.tag_layers) |*layer| c.gpa.free(layer.cells); c.regions.deinit(c.gpa); + c.freePost(); c.in.deinit(c.gpa); c.out.deinit(c.gpa); } @@ -356,10 +361,30 @@ pub const Client = struct { // open a URI on this display or read this display's selection back // over the socket. `Ungreeted` is the same refusal a frame gets. .set_clipboard, .read_clipboard, .open_link => if (!c.attached()) return error.Ungreeted, + // Kept, like the chrome: the frames that follow are drawn + // through it. + .post => |chain| { + if (!c.attached()) return error.Ungreeted; + c.freePost(); + c.post = chain; + for (c.post.passes[0..c.post.len]) |*pass| { + pass.path = c.gpa.dupe(u8, pass.path) catch return error.Closed; + pass.spirv = c.gpa.dupe(u8, pass.spirv) catch return error.Closed; + } + c.post_key +%= 1; + }, } return msg; } + fn freePost(c: *Client) void { + for (c.post.passes[0..c.post.len]) |pass| { + c.gpa.free(pass.path); + c.gpa.free(pass.spirv); + } + c.post = .{}; + } + // ---- internals -------------------------------------------------------- fn reshape(c: *Client, cols: u16, rows: u16) (Error || wire.Error)!void { @@ -1131,7 +1156,7 @@ test "detached session: the seam's own routing rules, per surviving effect" { test "detached session: a frontend is never asked to fork, write, or watch" { // THE INVARIANT OF THE WHOLE DETACHED DESIGN, pinned as a property of the // protocol rather than of one code path: a frontend is input and screen, so - // the set of messages that can reach it is exactly the eight below. If a + // the set of messages that can reach it is exactly the nine below. If a // machine-local effect is ever put back on the wire, a frontend becomes the // process that owns a pane's shell again — and a pane whose shell belongs to // a frontend dies when that frontend leaves, which is the bug this replaced. @@ -1149,6 +1174,10 @@ test "detached session: a frontend is never asked to fork, write, or watch" { // The three that need THIS human's display and cannot be done by a // daemon nobody is looking at. "set_clipboard", "read_clipboard", "open_link", + // Display state, like a frame's chrome: the passes this display + // draws its frames through. A file's SPIR-V arrives compiled, so the + // frontend still reads no disk and runs no program. + "post", }; // One: the union a frontend decodes into has no other variant. Named @@ -1183,7 +1212,7 @@ test "detached session: a frontend is never asked to fork, write, or watch" { if (@intFromEnum(want) == tag) break true; } else false; if (!named) { - std.debug.print("tag 0x{x:0>2} is decodable by a frontend and is not one of the seven\n", .{tag}); + std.debug.print("tag 0x{x:0>2} is decodable by a frontend and is not one of the nine\n", .{tag}); return error.MachineLocalEffectOnTheWire; } } diff --git a/src/detached/server.zig b/src/detached/server.zig index 13cf4488..99a2afc2 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -11,6 +11,7 @@ const host_io = @import("../host_io.zig"); const selection_pipe = @import("../selection_pipe.zig"); const file_watch = @import("../file_watch.zig"); +const shader_build = @import("../shader_build.zig"); const ninep_io = @import("../9p_io.zig"); @@ -63,6 +64,8 @@ const Client = struct { mirror: std.ArrayListUnmanaged(pardes.Cell) = .empty, need_full: bool = true, accepted_ms: i64 = 0, + /// The post chain last sent (Session.sendPost); null: none yet. + post_sent: ?u64 = null, }; const Pty = struct { @@ -173,6 +176,8 @@ pub const Session = struct { /// the next poll returns at once, to draw it. ninep_wake: std.atomic.Value(bool) = .init(false), watches: file_watch.Table = @splat(null), + /// The post chain's files, compiled here for the GUIs attached. + shaders: shader_build = .{}, check_files: bool = false, in_loop: bool = false, greet_deadline_ms: u32 = greet_deadline_default_ms, @@ -254,6 +259,8 @@ pub const Session = struct { for (0..pardes.MAX_PANES) |pane| file_watch.watchPane(s.inotify_fd, &s.watches, @intCast(pane), null, 0, .{ .text = 0 }); _ = file_watch.applyThemeEffect(s.core, s.gpa, s.inotify_fd, &s.watches, 0, false, false); + // The new core's chain generations are its own. + s.shaders.generation = null; s.check_files = false; if (s.ninep) |listener| listener.reset(replacement); s.ninep_pending = false; @@ -267,6 +274,8 @@ pub const Session = struct { } pub fn deinit(s: *Session) void { + // First: its compile thread wakes the mailbox, closed below. + s.shaders.deinit(s.gpa, s.inotify_fd, &s.watches); if (s.mailbox.wake[0] >= 0) pardes.lsp.setStatusSink(null, null); if (s.mailbox.wake[0] >= 0) host_io.setExitWake(null, null); s.cancelWorkers(); @@ -766,6 +775,7 @@ pub const Session = struct { fn reloadWatched(s: *Session) void { if (!s.check_files) return; s.check_files = false; + s.shaders.recheck(); for (0..reload_retries) |_| { if (!file_watch.reloadChanged(s.core, s.io, s.gpa, &s.watches)) return; } @@ -823,6 +833,8 @@ pub const Session = struct { const completed = s.ninep_wake.swap(false, .acq_rel) or drained; for (&s.clients) |*c| if (c.fd >= 0) s.flush(c); const regridded = s.reconcile(); + // After the greeting reconcile sends: a chain before it is refused. + const compiled = s.sendPost(); // Nobody attached, nobody to dismiss a message: the loop expires // them itself, between the editor's steps (expireUnattended). s.core.unattended = s.primary() == null; @@ -891,7 +903,7 @@ pub const Session = struct { } var timeout: c_int = if (timeout_ms == 0) -1 else @intCast(@min(timeout_ms, std.math.maxInt(c_int))); if (s.nextWake(now)) |due| timeout = if (timeout < 0) due else @min(timeout, due); - if (completed or s.check_files or regridded or s.ninep_pending) timeout = 0; + if (completed or compiled or s.check_files or regridded or s.ninep_pending) timeout = 0; pardes.turn.rest(); const ready = libc.poll(&fds, @intCast(n), timeout); pardes.turn.wake(); @@ -1102,6 +1114,28 @@ pub const Session = struct { return regridded; } + /// Compiles the post chain's files (a finished compile wakes the loop + /// through the mailbox) and sends each attached frontend the chain it + /// has not seen: on attach, and when it or a file's SPIR-V changes. True + /// when a compile was taken in, so the loop draws what it said. + /// ponytail: a terminal frontend is sent it too and drops it; a hello + /// saying which frontend it is would spare those bytes. + fn sendPost(s: *Session) bool { + const files = for (s.core.settings.post.list()) |entry| { + if (entry.scene == null) break true; + } else false; + const compiled = s.shaders.sync(s.gpa, s.io, s.core, if (files) s.inotify() else s.inotify_fd, &s.watches, .{ .ctx = &s.mailbox, .call = wakeForExit }); + const key = @as(u64, s.shaders.revision) << 8 | @intFromEnum(s.core.settings.shader_animation); + var chain: wire.Post = .{ .animation = s.core.settings.shader_animation, .len = s.core.settings.post.len }; + _ = s.shaders.view(&s.core.settings.post, &chain.passes); + for (&s.clients) |*c| { + if (!c.attached or c.greet or c.post_sent == key) continue; + c.post_sent = key; + s.send(c, .{ .post = chain }); + } + return compiled; + } + fn send(s: *Session, c: *Client, msg: wire.ServerMsg) void { const want = wire.serverBound(msg); s.scratch.ensureTotalCapacity(s.gpa, want) catch return s.close(c, .oom); diff --git a/src/detached/wire.zig b/src/detached/wire.zig index 6903a1e8..ee8c72fb 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -3,6 +3,9 @@ const std = @import("std"); const pardes = @import("../pardes.zig"); const limits = @import("../memory.zig").limits; +const shader_build = @import("../shader_build.zig"); +const Scene = pardes.config.Runtime.Scene; +const ShaderAnimation = pardes.config.Runtime.ShaderAnimation; /// 7: tag layers gained the `.notice` kind, so a frame can carry a tag value /// an older frontend would reject mid-session. The greeting is where that has @@ -73,6 +76,9 @@ pub const ServerTag = enum(u8) { set_clipboard = 0x10, read_clipboard = 0x11, open_link = 0x12, + /// The post chain this frontend draws its frames through: sent on + /// attach and whenever it or a file's SPIR-V changes. + post = 0x13, }; /// Why the server hung up on a connect. Sent as a `refuse` and followed by a @@ -353,6 +359,22 @@ pub const ServerMsg = union(enum) { set_clipboard: []const u8, read_clipboard, open_link: []const u8, + post: Post, +}; + +/// The session's post chain, as a GUI attached to it draws it: its passes in +/// order, each a bundled scene at its level or a Shadertoy file's SPIR-V +/// (compiled by the session, shader_build.zig: a frontend reads no disk and +/// runs no program), and when it animates on its own. Slices borrow the +/// payload. +pub const Post = struct { + animation: ShaderAnimation = .on, + passes: [shader_build.max]shader_build.Pass = @splat(.{}), + len: u8 = 0, + + pub fn list(p: *const Post) []const shader_build.Pass { + return p.passes[0..p.len]; + } }; // --------------------------------------------------------------------------- @@ -1149,6 +1171,18 @@ pub fn encodeServer(out: []u8, msg: ServerMsg) Error![]const u8 { .set_clipboard => |t| try w.putSlice32(t), .read_clipboard => {}, .open_link => |u| try w.putSlice16(u), + .post => |p| { + try w.putByte(@intFromEnum(p.animation)); + try w.putByte(p.len); + for (p.list()) |pass| { + // 0 a file, else a bundled scene and one. + try w.putByte(if (pass.scene) |scene| @as(u8, @intFromEnum(scene)) + 1 else 0); + try w.putByte(pass.level); + try w.putU32(pass.revision); + try w.putSlice16(pass.path); + try w.putSlice32(pass.spirv); + } + }, } try finishMessage(&w, at); return w.written(); @@ -1170,6 +1204,11 @@ pub fn serverBound(msg: ServerMsg) usize { .frame => |f| frameBound(f.cols, f.rows), .set_clipboard => |t| t.len, .open_link => |u| u.len, + .post => |p| blk: { + var n: usize = 0; + for (p.list()) |pass| n += 12 + pass.path.len + pass.spirv.len; + break :blk n; + }, }; } @@ -1232,6 +1271,22 @@ pub fn decodeServer(tag: u8, payload: []const u8) Error!ServerMsg { .set_clipboard => .{ .set_clipboard = try r.getSlice32() }, .read_clipboard => .read_clipboard, .open_link => .{ .open_link = try r.getSlice16() }, + .post => blk: { + var p: Post = .{ .animation = try r.getTag(ShaderAnimation) }; + p.len = try r.getByte(); + if (p.len > shader_build.max) return error.Overlong; + for (p.passes[0..p.len]) |*pass| { + const kind = try r.getByte(); + pass.* = .{ + .scene = if (kind == 0) null else std.enums.fromInt(Scene, kind - 1) orelse return error.BadValue, + .level = try r.getByte(), + .revision = try r.getU32(), + .path = try r.getSlice16(), + .spirv = try r.getSlice32(), + }; + } + break :blk .{ .post = p }; + }, }; try r.end(); return msg; @@ -1349,6 +1404,18 @@ test "detached wire: every server message round-trips" { try testing.expectEqualStrings("yank", (try roundServer(&buf, .{ .set_clipboard = "yank" })).set_clipboard); try testing.expectEqual(ServerMsg.read_clipboard, try roundServer(&buf, .read_clipboard)); try testing.expectEqualStrings("https://x", (try roundServer(&buf, .{ .open_link = "https://x" })).open_link); + var chain: Post = .{ .animation = .always, .len = 2 }; + chain.passes[0] = .{ .scene = .bloom, .level = 3 }; + chain.passes[1] = .{ .path = "~/wave.glsl", .spirv = "\x03\x02\x23\x07spirv", .revision = 7 }; + const back = (try roundServer(&buf, .{ .post = chain })).post; + try testing.expectEqual(ShaderAnimation.always, back.animation); + try testing.expectEqual(@as(u8, 2), back.len); + try testing.expectEqual(Scene.bloom, back.passes[0].scene.?); + try testing.expectEqual(@as(u8, 3), back.passes[0].level); + try testing.expect(back.passes[1].scene == null); + try testing.expectEqualStrings("~/wave.glsl", back.passes[1].path); + try testing.expectEqualStrings("\x03\x02\x23\x07spirv", back.passes[1].spirv); + try testing.expectEqual(@as(u32, 7), back.passes[1].revision); } test "detached wire: only the display's own effects are on the wire" { @@ -1357,9 +1424,9 @@ test "detached wire: only the display's own effects are on the wire" { // is the direction that matters: it would hand a frontend work that must // outlive it. The count fails if one of the three is dropped, which would // silently leave a clipboard or a link unanswered on every frontend. - try testing.expectEqual(@as(usize, 8), std.enums.values(ServerTag).len); + try testing.expectEqual(@as(usize, 9), std.enums.values(ServerTag).len); for (std.enums.values(ServerTag)) |t| switch (t) { - .welcome, .refuse, .frame, .quit, .detach, .set_clipboard, .read_clipboard, .open_link => {}, + .welcome, .refuse, .frame, .quit, .detach, .set_clipboard, .read_clipboard, .open_link, .post => {}, }; } diff --git a/src/file_watch.zig b/src/file_watch.zig index d028488e..8bd9de8b 100644 --- a/src/file_watch.zig +++ b/src/file_watch.zig @@ -70,7 +70,11 @@ pub const Watch = struct { /// Sharing the table also shares directory descriptors correctly when a user /// happens to open that .zon as an ordinary document. pub const theme_slot = pardes.MAX_PANES; -pub const Table = [pardes.MAX_PANES + 1]?Watch; +/// After it, one slot a post chain file (shader_build.zig): marked only so +/// a save there wakes the host, which has the files read again; no +/// generation of theirs is kept here. +pub const shader_slot = theme_slot + 1; +pub const Table = [shader_slot + pardes.config.Runtime.Post.max]?Watch; /// Whether this OS has a watcher at all. Off it every entry point below /// returns the inert answer: `watchPath` installs nothing, so `watches` stays @@ -423,6 +427,13 @@ pub fn watchPane( watchPath(fd, watches, id, path, serial, generation); } +/// Mark the post chain files' directories, one slot each, dropping the +/// marks of files no longer in it. +pub fn watchShaders(fd: c_int, watches: *Table, paths: []const []const u8) void { + for (0..pardes.config.Runtime.Post.max) |i| + watchPath(fd, watches, shader_slot + i, if (i < paths.len) paths[i] else null, 0, .{ .text = 0 }); +} + /// Reconcile one marked pane. A true result requests an immediate second pass: /// the PDF pathname changed between the stat before MuPDF reopened it and the /// stat after, so committing either identity would lose a generation. diff --git a/src/gui/Post.zig b/src/gui/Post.zig index 61ebc2cd..06310b7f 100644 --- a/src/gui/Post.zig +++ b/src/gui/Post.zig @@ -1,10 +1,10 @@ //! The post chain (docs/render-pipeline.md §6): Shadertoy passes over the //! finished frame, each reading the one before it (the frame first) and the //! last one writing the window. A pass is the bundled Crt (shaders/post/, -//! compiled with the build) or a user's file (Shader ), -//! compiled when it joins the chain by the build's own glslc invocation on a -//! thread of its own, never on the frame path. A file whose compile fails -//! keeps its last good pipeline, and glslc missing is said once. +//! compiled with the build) or a user's file (Shader ), whose SPIR-V +//! comes from shader_build.zig: compiled by the process that holds the core, +//! this one or the detached session this GUI is attached to. A file whose +//! compile fails keeps its last good pipeline. //! //! The uniforms are ghostty's (Uniforms), so a shader written for ghostty runs //! here. Their clock is the shell's and moves only when the chain draws: @@ -14,7 +14,6 @@ const std = @import("std"); const libc = std.c; const ghostty_vt = @import("ghostty-vt"); const pardes = @import("../pardes.zig"); -const filesystem = @import("../fs.zig"); const gui = @import("gui.zig"); const c = gui.c; @@ -25,6 +24,7 @@ const Pipeline = c.SDL_GPUGraphicsPipeline; pub const max = Chain.max; const prefix = @embedFile("post-prefix.glsl"); +const Built = @import("../shader_build.zig").Pass; const vert_spv = @embedFile("post.vert.spv"); const bundled_spv = std.EnumArray(Scene, []const u8).init(.{ .crt = @embedFile("post-crt.frag.spv"), @@ -92,28 +92,15 @@ pub const Pass = struct { /// A user's file, owned; empty for a bundled pass. path: []u8 = &.{}, pipeline: ?*Pipeline = null, -}; - -/// One compile of a chain's new files, on its own thread. -const Job = struct { - const Result = union(enum) { none, spirv: []u8, failed: []u8, missing }; - io: std.Io, - gpa: std.mem.Allocator, - paths: [max][]u8 = @splat(&.{}), - results: [max]Result = @splat(.none), - len: usize = 0, - done: std.atomic.Value(bool) = .init(false), - thread: std.Thread = undefined, + /// The SPIR-V revision `pipeline` was made from (shader_build.Pass). + revision: u32 = 0, }; passes: [max]Pass = @splat(.{}), len: usize = 0, -/// The chain generation `passes` follow; null before the first. -generation: ?u32 = null, +/// The chain key `passes` follow (sync); null before the first. +key: ?u32 = null, bundled: std.EnumArray(Scene, ?*Pipeline) = .initFill(null), -job: ?*Job = null, -/// Said once: without glslc, files never compile. -missing_said: bool = false, ping: [2]?*c.SDL_GPUTexture = .{ null, null }, ping_w: u32 = 0, ping_h: u32 = 0, @@ -136,20 +123,17 @@ bloom_up: ?*Pipeline = null, started_ns: ?u64 = null, last_ns: u64 = 0, -/// Brings the passes to the chain the core holds, and takes in a finished -/// compile. Called each loop step, off the frame path. -pub fn sync(post: *Post, gpa: std.mem.Allocator, io: std.Io, device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, core: *pardes.Pardes) void { - if (post.job) |job| { - if (!job.done.load(.acquire)) return; - job.thread.join(); - post.job = null; - post.collect(gpa, device, format, core, job); - } - const chain = &core.settings.post; - if (post.generation == chain.generation) return; - post.generation = chain.generation; +/// Brings the passes to the chain a shell was given (shader_build.zig: a +/// local core's, or an attached session's over the wire), whose `key` moves +/// whenever the chain or a file's SPIR-V does. A file whose SPIR-V has not +/// moved keeps its pipeline; one the GPU refuses keeps its last, and is said +/// to `core`, or logged where there is none. Each loop step, off the frame +/// path. +pub fn sync(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, chain: []const Built, key: u32, core: ?*pardes.Pardes) void { + if (post.key == key) return; + post.key = key; var next: [max]Pass = @splat(.{}); - for (chain.list(), 0..) |entry, i| { + for (chain, 0..) |entry, i| { if (entry.scene) |scene| { if (post.bundled.get(scene) == null) post.bundled.set(scene, makePipeline(device, format, bundled_spv.get(scene), if (scene == .bloom) 2 else 1, 4) catch null); if (scene == .bloom and post.bloom_down == null) { @@ -161,11 +145,22 @@ pub fn sync(post: *Post, gpa: std.mem.Allocator, io: std.Io, device: *c.SDL_GPUD } // A file already in the chain keeps its pipeline and its path. for (post.passes[0..post.len]) |*pass| { - if (pass.scene != null or !std.mem.eql(u8, pass.path, entry.path.get())) continue; + if (pass.scene != null or !std.mem.eql(u8, pass.path, entry.path)) continue; next[i] = pass.*; pass.* = .{}; break; - } else next[i] = .{ .path = gpa.dupe(u8, entry.path.get()) catch &.{} }; + } else next[i] = .{ .path = gpa.dupe(u8, entry.path) catch &.{} }; + const pass = &next[i]; + if (pass.revision == entry.revision or entry.spirv.len == 0) continue; + pass.revision = entry.revision; + const made = makePipeline(device, format, entry.spirv, 1, 4) catch { + var buf: [512]u8 = undefined; + const text = std.fmt.bufPrint(&buf, "Shader {s}: the GPU refused it", .{entry.path}) catch "Shader: the GPU refused it"; + if (core) |p| p.setMessage(p.active, text) else std.log.err("{s}", .{text}); + continue; + }; + if (pass.pipeline) |old| c.SDL_ReleaseGPUGraphicsPipeline(device, old); + pass.pipeline = made; } for (post.passes[0..post.len]) |pass| { if (pass.scene == null) if (pass.pipeline) |pipeline| c.SDL_ReleaseGPUGraphicsPipeline(device, pipeline); @@ -179,127 +174,6 @@ pub fn sync(post: *Post, gpa: std.mem.Allocator, io: std.Io, device: *c.SDL_GPUD post.last_ns = 0; post.uniforms.frame = 0; } - // The new files compile together, on a thread of their own. - const job = gpa.create(Job) catch return; - job.* = .{ .io = io, .gpa = gpa }; - for (post.passes[0..post.len]) |pass| { - if (pass.scene != null or pass.pipeline != null or pass.path.len == 0) continue; - job.paths[job.len] = gpa.dupe(u8, pass.path) catch continue; - job.len += 1; - } - if (job.len == 0) return gpa.destroy(job); - job.thread = std.Thread.spawn(.{}, compile, .{job}) catch { - for (job.paths[0..job.len]) |path| gpa.free(path); - return gpa.destroy(job); - }; - post.job = job; -} - -fn collect(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, core: *pardes.Pardes, job: *Job) void { - defer gpa.destroy(job); - for (job.paths[0..job.len], job.results[0..job.len]) |path, result| { - defer gpa.free(path); - const pass = for (post.passes[0..post.len]) |*pass| { - if (pass.scene == null and std.mem.eql(u8, pass.path, path)) break pass; - } else null; - var buf: [512]u8 = undefined; - switch (result) { - .none => {}, - .missing => if (!post.missing_said) { - post.missing_said = true; - core.setMessage(core.active, "Shader: glslc is not on PATH, so Shadertoy files do not run (Crt does)"); - }, - .failed => |text| { - defer gpa.free(text); - // glslc's first line says where: in the file's own lines. - const first = std.mem.sliceTo(text, '\n'); - core.setMessage(core.active, std.fmt.bufPrint(&buf, "Shader {s}: {s}", .{ path, first[0..@min(first.len, 400)] }) catch "Shader: compile failed"); - }, - .spirv => |spirv| { - defer gpa.free(spirv); - const target = pass orelse continue; - const made = makePipeline(device, format, spirv, 1, 4) catch { - core.setMessage(core.active, std.fmt.bufPrint(&buf, "Shader {s}: the GPU refused it", .{path}) catch "Shader: the GPU refused it"); - continue; - }; - if (target.pipeline) |old| c.SDL_ReleaseGPUGraphicsPipeline(device, old); - target.pipeline = made; - }, - } - } -} - -/// The compile thread: each file behind the prefix, through glslc with the -/// build's own flags (build.zig compileGlsl), source on stdin, SPIR-V on -/// stdout. -fn compile(job: *Job) void { - defer job.done.store(true, .release); - defer { - var event = std.mem.zeroes(c.SDL_Event); - event.type = c.SDL_EVENT_USER; - _ = c.SDL_PushEvent(&event); - } - for (job.paths[0..job.len], 0..) |path, i| job.results[i] = compileOne(job.gpa, job.io, path); -} - -fn compileOne(gpa: std.mem.Allocator, io: std.Io, path: []const u8) Job.Result { - var home_buf: [4096]u8 = undefined; - const expanded = if (std.mem.startsWith(u8, path, "~/")) if (libc.getenv("HOME")) |home| - std.fmt.bufPrint(&home_buf, "{s}/{s}", .{ std.mem.span(home), path[2..] }) catch path - else - path else path; - const body = filesystem.readFile(gpa, expanded) catch |err| - return .{ .failed = std.fmt.allocPrint(gpa, "cannot read it ({t})", .{err}) catch return .none }; - defer gpa.free(body); - // The prefix and the file go to glslc on its stdin: no file of ours is - // written anywhere. std's spawn does every allocation before fork and - // searches PATH on the stack after it (Io/Threaded.zig spawnPosix, - // posixExecv), so a child forked from this thread cannot wedge on a - // lock another thread held (the forkShell hazard). - var child = std.process.spawn(io, .{ - .argv = &.{ "glslc", "-fshader-stage=fragment", "-o", "-", "-" }, - .stdin = .pipe, - .stdout = .pipe, - .stderr = .pipe, - }) catch |err| return switch (err) { - error.FileNotFound => .missing, - else => .{ .failed = std.fmt.allocPrint(gpa, "glslc did not run ({t})", .{err}) catch return .none }, - }; - defer child.kill(io); - // glslc reads all of its input before it writes a byte, so writing it - // first cannot deadlock against a full output pipe. - { - var stdin = child.stdin.?; - child.stdin = null; - defer stdin.close(io); - var buf: [4096]u8 = undefined; - var writer = stdin.writer(io, &buf); - writer.interface.writeAll(prefix) catch {}; - writer.interface.writeAll(body) catch {}; - writer.interface.flush() catch {}; - } - var streams: std.Io.File.MultiReader.Buffer(2) = undefined; - var reader: std.Io.File.MultiReader = undefined; - reader.init(gpa, io, streams.toStreams(), &.{ child.stdout.?, child.stderr.? }); - defer reader.deinit(); - while (reader.fill(64, .none)) |_| { - if (reader.reader(0).buffered().len > 16 << 20 or reader.reader(1).buffered().len > 64 << 10) - return .{ .failed = gpa.dupe(u8, "glslc said too much") catch return .none }; - } else |err| switch (err) { - error.EndOfStream => {}, - else => return .{ .failed = gpa.dupe(u8, "reading glslc failed") catch return .none }, - } - const term = child.wait(io) catch return .{ .failed = gpa.dupe(u8, "glslc did not finish") catch return .none }; - const clean = switch (term) { - .exited => |code| code == 0, - else => false, - }; - if (clean) return .{ .spirv = reader.toOwnedSlice(0) catch return .none }; - const said = reader.toOwnedSlice(1) catch return .none; - // glslc calls its input ; the file is what a person knows. - defer gpa.free(said); - const named = std.mem.replaceOwned(u8, gpa, said, "", path) catch return .{ .failed = gpa.dupe(u8, "glslc failed") catch return .none }; - return .{ .failed = named }; } fn makePipeline(device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, frag: []const u8, samplers: u32, uniform_buffers: u32) !*Pipeline { @@ -686,17 +560,6 @@ fn bloomStep(cmd: *c.SDL_GPUCommandBuffer, pipeline: *Pipeline, sampler: *c.SDL_ pub fn deinit(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice) void { for (post.bloom_tex) |texture| if (texture) |t| c.SDL_ReleaseGPUTexture(device, t); for ([_]?*Pipeline{ post.bloom_down, post.bloom_up }) |pipeline| if (pipeline) |p| c.SDL_ReleaseGPUGraphicsPipeline(device, p); - if (post.job) |job| { - job.thread.join(); - for (job.paths[0..job.len], job.results[0..job.len]) |path, result| { - gpa.free(path); - switch (result) { - .spirv, .failed => |bytes| gpa.free(bytes), - .none, .missing => {}, - } - } - gpa.destroy(job); - } for (post.passes[0..post.len]) |pass| { if (pass.scene == null) if (pass.pipeline) |pipeline| c.SDL_ReleaseGPUGraphicsPipeline(device, pipeline); gpa.free(pass.path); @@ -801,36 +664,3 @@ test "a moved cursor becomes the previous one, stamped with the time of the move try std.testing.expectEqual([4]f32{ 1.0 / 255.0, 2.0 / 255.0, 3.0 / 255.0, 1 }, post.uniforms.selection_foreground_color); } -test "ghostty's test shaders compile behind the prefix, and its invalid one fails with glslc's words" { - const gpa = std.testing.allocator; - const io = std.testing.io; - // ghostty's own test shaders, from its package in zig-pkg. - var pkg = std.Io.Dir.cwd().openDir(io, "zig-pkg", .{ .iterate = true }) catch return error.SkipZigTest; - defer pkg.close(io); - var it = pkg.iterate(); - const ghostty = while (it.next(io) catch null) |entry| { - if (std.mem.startsWith(u8, entry.name, "ghostty-")) break entry.name; - } else return error.SkipZigTest; - var buf: [512]u8 = undefined; - for ([_][]const u8{ "crt", "focus", "invalid" }) |name| { - const path = try std.fmt.bufPrint(&buf, "zig-pkg/{s}/src/renderer/shaders/test_shadertoy_{s}.glsl", .{ ghostty, name }); - const result = compileOne(gpa, io, path); - switch (result) { - .missing => return error.SkipZigTest, // no glslc here - .spirv => |spirv| { - defer gpa.free(spirv); - try std.testing.expect(!std.mem.eql(u8, name, "invalid")); - // SPIR-V's magic number, little-endian. - try std.testing.expectEqual(@as(u32, 0x07230203), std.mem.readInt(u32, spirv[0..4], .little)); - }, - .failed => |text| { - defer gpa.free(text); - try std.testing.expectEqualStrings("invalid", name); - // Named by the file, not by glslc's . - try std.testing.expect(std.mem.indexOf(u8, text, path) != null); - try std.testing.expect(std.mem.indexOf(u8, text, "") == null); - }, - .none => return error.TestUnexpectedResult, - } - } -} diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 127f9b5d..2d31416b 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"); @@ -2693,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(); @@ -2862,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; @@ -2877,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); } @@ -3993,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 = .{}, @@ -4209,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); @@ -4356,7 +4377,11 @@ 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 @@ -5805,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; @@ -6179,19 +6205,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; diff --git a/src/main.zig b/src/main.zig index 86c302fe..c23f8183 100644 --- a/src/main.zig +++ b/src/main.zig @@ -410,6 +410,7 @@ test { _ = @import("locations_cache.zig"); _ = @import("fs.zig"); _ = @import("detached/wire.zig"); + _ = @import("shader_build.zig"); _ = @import("detached/server.zig"); _ = @import("detached/client.zig"); _ = @import("9p_io.zig"); diff --git a/src/shader_build.zig b/src/shader_build.zig new file mode 100644 index 00000000..8aa7eb5e --- /dev/null +++ b/src/shader_build.zig @@ -0,0 +1,402 @@ +//! The post chain's Shadertoy files as SPIR-V (docs/render-pipeline.md §6), +//! for whichever process holds the core: a local GUI makes its pipelines +//! from them, a detached session sends them to the GUIs attached to it +//! (wire `post`), which never read a disk or run a program themselves. +//! +//! Each file compiles behind the prefix by the build's own glslc invocation, +//! on a thread of its own and never on the frame path, when it joins the +//! chain and again whenever a save changes its bytes: the files' directories +//! are watched (file_watch.watchShaders) and a change there has every file +//! read and hashed, an unchanged one compiling nothing. A compile that fails +//! keeps the last good SPIR-V and is said once, and glslc missing is said +//! once for good. +const std = @import("std"); +const libc = std.c; +const pardes = @import("pardes.zig"); +const file_watch = @import("file_watch.zig"); + +const Build = @This(); +const Chain = pardes.config.Runtime.Post; +const Scene = pardes.config.Runtime.Scene; +pub const max = Chain.max; + +const prefix = @embedFile("post-prefix.glsl"); + +/// A pass as a shell draws it: a bundled scene at its level, or a file with +/// its last good SPIR-V (empty until a compile succeeds), whose `revision` +/// moves each time that SPIR-V does. Slices borrow the build's (or, in an +/// attached GUI, the client's) storage until its next change. +pub const Pass = struct { + scene: ?Scene = null, + level: u8 = 0, + path: []const u8 = &.{}, + spirv: []const u8 = &.{}, + revision: u32 = 0, +}; + +/// Who to wake when a compile is done: the host's loop. +pub const Wake = struct { + ctx: ?*anyopaque = null, + call: ?*const fn (?*anyopaque) void = null, +}; + +const File = struct { + path: []u8 = &.{}, + /// The hash of the source last compiled, good or not: the same bytes + /// saved again compile nothing and say nothing twice. + source: ?u64 = null, + spirv: []u8 = &.{}, + revision: u32 = 0, + /// Wants a compile: new to the chain, or its directory changed. + stale: bool = true, +}; + +const Result = union(enum) { + none, + unchanged, + missing, + failed: struct { text: []u8, source: u64 }, + spirv: struct { bytes: []u8, source: u64 }, +}; + +/// One compile of the stale files, on its own thread. +const Job = struct { + io: std.Io, + gpa: std.mem.Allocator, + wake: Wake, + paths: [max][]u8 = @splat(&.{}), + known: [max]?u64 = @splat(null), + results: [max]Result = @splat(.none), + len: usize = 0, + done: std.atomic.Value(bool) = .init(false), + thread: std.Thread = undefined, +}; + +files: [max]File = @splat(.{}), +len: usize = 0, +/// The chain generation `files` follow; null before the first. +generation: ?u32 = null, +/// Moves whenever what `view` gives does: the chain or a file's SPIR-V. +revision: u32 = 0, +job: ?*Job = null, +missing_said: bool = false, + +/// Brings the files to the chain the core holds, watches their directories, +/// takes in a finished compile and starts the next. Each loop step, off the +/// frame path. True when a compile was taken in: what it said wants a frame. +pub fn sync(b: *Build, gpa: std.mem.Allocator, io: std.Io, core: *pardes.Pardes, fd: c_int, watches: *file_watch.Table, wake: Wake) bool { + var collected = false; + if (b.job) |job| { + if (!job.done.load(.acquire)) return false; + job.thread.join(); + b.job = null; + b.collect(gpa, core, job); + collected = true; + } + const chain = &core.settings.post; + if (b.generation != chain.generation) { + b.generation = chain.generation; + b.revision +%= 1; + var next: [max]File = @splat(.{}); + var len: usize = 0; + var paths: [max][]const u8 = undefined; + for (chain.list()) |entry| { + if (entry.scene != null) continue; + next[len] = for (b.files[0..b.len]) |*file| { + if (!std.mem.eql(u8, file.path, entry.path.get())) continue; + const kept = file.*; + file.* = .{}; + break kept; + } else .{ .path = gpa.dupe(u8, entry.path.get()) catch continue }; + paths[len] = next[len].path; + len += 1; + } + for (b.files[0..b.len]) |file| free(gpa, file); + b.files = next; + b.len = len; + var bufs: [max][4096]u8 = undefined; + var expanded: [max][]const u8 = undefined; + for (paths[0..len], 0..) |path, i| expanded[i] = expand(&bufs[i], path); + file_watch.watchShaders(fd, watches, expanded[0..len]); + } + var stale = false; + for (b.files[0..b.len]) |file| stale = stale or file.stale; + if (!stale) return collected; + const job = gpa.create(Job) catch return collected; + job.* = .{ .io = io, .gpa = gpa, .wake = wake }; + for (b.files[0..b.len]) |*file| { + if (!file.stale) continue; + job.paths[job.len] = gpa.dupe(u8, file.path) catch continue; + job.known[job.len] = file.source; + job.len += 1; + file.stale = false; + } + job.thread = std.Thread.spawn(.{}, compile, .{job}) catch { + for (job.paths[0..job.len]) |path| gpa.free(path); + gpa.destroy(job); + return collected; + }; + b.job = job; + return collected; +} + +/// A watched directory changed: every file is read again at the next sync. +pub fn recheck(b: *Build) void { + for (b.files[0..b.len]) |*file| file.stale = true; +} + +/// The chain as passes, in its order. +pub fn view(b: *const Build, chain: *const Chain, out: *[max]Pass) []const Pass { + for (chain.list(), 0..) |entry, i| { + out[i] = if (entry.scene) |scene| .{ .scene = scene, .level = entry.level } else for (b.files[0..b.len]) |file| { + if (std.mem.eql(u8, file.path, entry.path.get())) break .{ .path = file.path, .spirv = file.spirv, .revision = file.revision }; + } else .{ .path = entry.path.get() }; + } + return out[0..chain.len]; +} + +pub fn deinit(b: *Build, gpa: std.mem.Allocator, fd: c_int, watches: *file_watch.Table) void { + if (b.job) |job| { + job.thread.join(); + freeJob(gpa, job); + } + for (b.files[0..b.len]) |file| free(gpa, file); + b.len = 0; + file_watch.watchShaders(fd, watches, &.{}); +} + +fn free(gpa: std.mem.Allocator, file: File) void { + gpa.free(file.path); + gpa.free(file.spirv); +} + +fn freeJob(gpa: std.mem.Allocator, job: *Job) void { + for (job.paths[0..job.len], job.results[0..job.len]) |path, result| { + gpa.free(path); + switch (result) { + .spirv => |r| gpa.free(r.bytes), + .failed => |r| gpa.free(r.text), + .none, .unchanged, .missing => {}, + } + } + gpa.destroy(job); +} + +fn collect(b: *Build, gpa: std.mem.Allocator, core: *pardes.Pardes, job: *Job) void { + defer gpa.destroy(job); + for (job.paths[0..job.len], job.results[0..job.len]) |path, result| { + defer gpa.free(path); + const file = for (b.files[0..b.len]) |*file| { + if (std.mem.eql(u8, file.path, path)) break file; + } else null; + var buf: [512]u8 = undefined; + switch (result) { + .none, .unchanged => {}, + .missing => if (!b.missing_said) { + b.missing_said = true; + core.setMessage(core.active, "Shader: glslc is not on PATH, so Shadertoy files do not run (Crt does)"); + }, + .failed => |r| { + defer gpa.free(r.text); + if (file) |f| f.source = r.source; + // glslc's first line says where: in the file's own lines. + const first = std.mem.sliceTo(r.text, '\n'); + core.setMessage(core.active, std.fmt.bufPrint(&buf, "Shader {s}: {s}", .{ path, first[0..@min(first.len, 400)] }) catch "Shader: compile failed"); + }, + .spirv => |r| { + const f = file orelse { + gpa.free(r.bytes); + continue; + }; + gpa.free(f.spirv); + f.spirv = r.bytes; + f.source = r.source; + f.revision +%= 1; + b.revision +%= 1; + }, + } + } +} + +/// `~/` is the home directory. +fn expand(buf: []u8, path: []const u8) []const u8 { + if (!std.mem.startsWith(u8, path, "~/")) return path; + const home = libc.getenv("HOME") orelse return path; + return std.fmt.bufPrint(buf, "{s}/{s}", .{ std.mem.span(home), path[2..] }) catch path; +} + +/// The compile thread: each file behind the prefix, through glslc with the +/// build's own flags (build.zig compileGlsl), source on stdin, SPIR-V on +/// stdout. +fn compile(job: *Job) void { + for (job.paths[0..job.len], 0..) |path, i| job.results[i] = compileOne(job.gpa, job.io, path, job.known[i]); + job.done.store(true, .release); + if (job.wake.call) |call| call(job.wake.ctx); +} + +fn compileOne(gpa: std.mem.Allocator, io: std.Io, path: []const u8, known: ?u64) Result { + var home_buf: [4096]u8 = undefined; + // Not fs.zig's readFile: that hands the core's turn over for the read, + // a turn this thread never holds. + const body = std.Io.Dir.cwd().readFileAlloc(io, expand(&home_buf, path), gpa, .limited(1 << 20)) catch |err| + return .{ .failed = .{ .text = std.fmt.allocPrint(gpa, "cannot read it ({t})", .{err}) catch return .none, .source = 0 } }; + defer gpa.free(body); + const source = std.hash.Wyhash.hash(0, body); + if (known == source) return .unchanged; + // The prefix and the file go to glslc on its stdin: no file of ours is + // written anywhere. std's spawn does every allocation before fork and + // searches PATH on the stack after it (Io/Threaded.zig spawnPosix, + // posixExecv), so a child forked from this thread cannot wedge on a + // lock another thread held (the forkShell hazard). + var child = std.process.spawn(io, .{ + .argv = &.{ "glslc", "-fshader-stage=fragment", "-o", "-", "-" }, + .stdin = .pipe, + .stdout = .pipe, + .stderr = .pipe, + }) catch |err| return switch (err) { + error.FileNotFound => .missing, + else => .{ .failed = .{ .text = std.fmt.allocPrint(gpa, "glslc did not run ({t})", .{err}) catch return .none, .source = source } }, + }; + defer child.kill(io); + // glslc reads all of its input before it writes a byte, so writing it + // first cannot deadlock against a full output pipe. + { + var stdin = child.stdin.?; + child.stdin = null; + defer stdin.close(io); + var buf: [4096]u8 = undefined; + var writer = stdin.writer(io, &buf); + writer.interface.writeAll(prefix) catch {}; + writer.interface.writeAll(body) catch {}; + writer.interface.flush() catch {}; + } + var streams: std.Io.File.MultiReader.Buffer(2) = undefined; + var reader: std.Io.File.MultiReader = undefined; + reader.init(gpa, io, streams.toStreams(), &.{ child.stdout.?, child.stderr.? }); + defer reader.deinit(); + const failed = struct { + fn of(a: std.mem.Allocator, text: []const u8, hash: u64) Result { + return .{ .failed = .{ .text = a.dupe(u8, text) catch return .none, .source = hash } }; + } + }.of; + while (reader.fill(64, .none)) |_| { + if (reader.reader(0).buffered().len > 16 << 20 or reader.reader(1).buffered().len > 64 << 10) + return failed(gpa, "glslc said too much", source); + } else |err| switch (err) { + error.EndOfStream => {}, + else => return failed(gpa, "reading glslc failed", source), + } + const term = child.wait(io) catch return failed(gpa, "glslc did not finish", source); + const clean = switch (term) { + .exited => |code| code == 0, + else => false, + }; + if (clean) return .{ .spirv = .{ .bytes = reader.toOwnedSlice(0) catch return .none, .source = source } }; + const said = reader.toOwnedSlice(1) catch return .none; + // glslc calls its input ; the file is what a person knows. + defer gpa.free(said); + const named = std.mem.replaceOwned(u8, gpa, said, "", path) catch return failed(gpa, "glslc failed", source); + return .{ .failed = .{ .text = named, .source = source } }; +} + +test "ghostty's test shaders compile behind the prefix, and its invalid one fails with glslc's words" { + const gpa = std.testing.allocator; + const io = std.testing.io; + // ghostty's own test shaders, from its package in zig-pkg. + var pkg = std.Io.Dir.cwd().openDir(io, "zig-pkg", .{ .iterate = true }) catch return error.SkipZigTest; + defer pkg.close(io); + var it = pkg.iterate(); + const ghostty = while (it.next(io) catch null) |entry| { + if (std.mem.startsWith(u8, entry.name, "ghostty-")) break entry.name; + } else return error.SkipZigTest; + var buf: [512]u8 = undefined; + for ([_][]const u8{ "crt", "focus", "invalid" }) |name| { + const path = try std.fmt.bufPrint(&buf, "zig-pkg/{s}/src/renderer/shaders/test_shadertoy_{s}.glsl", .{ ghostty, name }); + switch (compileOne(gpa, io, path, null)) { + .missing => return error.SkipZigTest, // no glslc here + .spirv => |r| { + defer gpa.free(r.bytes); + try std.testing.expect(!std.mem.eql(u8, name, "invalid")); + // SPIR-V's magic number, little-endian. + try std.testing.expectEqual(@as(u32, 0x07230203), std.mem.readInt(u32, r.bytes[0..4], .little)); + // The same bytes again compile nothing. + try std.testing.expectEqual(Result.unchanged, compileOne(gpa, io, path, r.source)); + }, + .failed => |r| { + defer gpa.free(r.text); + try std.testing.expectEqualStrings("invalid", name); + // Named by the file, not by glslc's . + try std.testing.expect(std.mem.indexOf(u8, r.text, path) != null); + try std.testing.expect(std.mem.indexOf(u8, r.text, "") == null); + }, + .none, .unchanged => return error.TestUnexpectedResult, + } + } +} + +test "a save that changes a file compiles it again, a failed one keeps the last good SPIR-V and is said once" { + const gpa = std.testing.allocator; + const io = std.testing.io; + const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + _ = try core.newShell(1, ""); + const pane = core.panes[core.active].?; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(io, &dir_buf)]; + var path_buf: [4200]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, "{s}/wave.glsl", .{dir}); + const good = "void mainImage(out vec4 o, in vec2 p) { o = texture(iChannel0, p / iResolution.xy); }\n"; + try tmp.dir.writeFile(io, .{ .sub_path = "wave.glsl", .data = good }); + try std.testing.expect(core.settings.post.set(null, path, true)); + + const fd = file_watch.init(true); + defer if (fd >= 0) { + _ = libc.close(fd); + }; + var watches: file_watch.Table = @splat(null); + var b: Build = .{}; + defer b.deinit(gpa, fd, &watches); + const settle = struct { + fn run(build: *Build, c: *pardes.Pardes, f: c_int, w: *file_watch.Table) void { + _ = build.sync(std.testing.allocator, std.testing.io, c, f, w, .{}); + while (build.job) |job| { + while (!job.done.load(.acquire)) std.Thread.yield() catch {}; + _ = build.sync(std.testing.allocator, std.testing.io, c, f, w, .{}); + } + } + }.run; + settle(&b, core, fd, &watches); + if (b.missing_said) return error.SkipZigTest; // no glslc here + var out: [max]Pass = undefined; + const first = b.view(&core.settings.post, &out)[0]; + try std.testing.expect(first.spirv.len != 0); + if (file_watch.supported) try std.testing.expect(watches[file_watch.shader_slot] != null); + + // Unchanged bytes: nothing moves. + const at = b.revision; + b.recheck(); + settle(&b, core, fd, &watches); + try std.testing.expectEqual(at, b.revision); + + // A bad save: the last good SPIR-V stays, the error is said, and the + // same bad bytes again say nothing more. + try tmp.dir.writeFile(io, .{ .sub_path = "wave.glsl", .data = "void mainImage(" }); + pane.msg_len = 0; + b.recheck(); + settle(&b, core, fd, &watches); + try std.testing.expectEqual(at, b.revision); + try std.testing.expect(std.mem.startsWith(u8, pane.msg[0..pane.msg_len], "Shader ")); + pane.msg_len = 0; + b.recheck(); + settle(&b, core, fd, &watches); + try std.testing.expectEqual(@as(u16, 0), pane.msg_len); + + // A good save again: new SPIR-V, a new revision. + try tmp.dir.writeFile(io, .{ .sub_path = "wave.glsl", .data = "// saved again\n" ++ good }); + b.recheck(); + settle(&b, core, fd, &watches); + const second = b.view(&core.settings.post, &out)[0]; + try std.testing.expect(second.revision != first.revision and b.revision != at); +} diff --git a/src/tty/tty.zig b/src/tty/tty.zig index c4096faf..c5491a73 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -2157,6 +2157,8 @@ const Attach = struct { .set_clipboard => |text| copyToClipboard(a.vx, a.tty, a.gpa, text), .read_clipboard => requestClipboard(a.vx, a.tty), .open_link => |url| look.openLink(url), + // A terminal has no post chain to draw through. + .post => {}, } return null; } -- cgit v1.3 From 3d262763b304021720f1141ab33265759869d2fe Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 10:03:10 -0300 Subject: The focused cursor rides with a moving pane, and a focus change glides to the new one G3's springs now keep the cursor's place in its pane: a pane that moves in the layout carries them, and the quad is drawn through the pane's presented box (its transition track), so through a slide, zoom or drag the cursor rides with the text, and with no transition it lands with it. A focus change, to another pane or into a column's or the workspace's tag, glides from where the cursor was drawn to the new place instead of snapping, unclamped while it crosses. While panes move the cursor is drawn as the overlay quad instead of vanishing (golden 18-mid-transition re-recorded for that: the only change is the quad, gliding into the new column's tag). Lapis: a tag plaque starts flush against its grip's button, the scroll gap between them part of the plaque, so grip and plaque read as one piece, on pane and column rows alike; below the button's row the grip keeps its band. Themes without plaques are unchanged (their grip band and tag band are one colour, no sliver). --- docs/effects.md | 7 ++ src/animation.zig | 2 +- src/draw.zig | 231 ++++++++++++++++++++++++++++++++++++++++++++++----- src/gui/gui.zig | 54 ++++++++---- src/pardes.zig | 4 +- test/gui-goldens.txt | 2 +- 6 files changed, 258 insertions(+), 42 deletions(-) (limited to 'src/gui/gui.zig') diff --git a/docs/effects.md b/docs/effects.md index 8564b756..a0edc2ab 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -96,6 +96,13 @@ Which principles each motion uses: the flavour's arc), anticipation (playful's wind-up). A step of a cell or less, or any move in insert mode, lands at once, so typing never trails; a scroll carries the cursor with the text. Only the focused cursor moves. + A pane that moves carries it too: the springs keep its place in the pane + and the quad is drawn through the pane's presented box, so through a + slide, a zoom or a drag it rides with the text, and a layout change with + no transition lands it with the text. A focus change (to another pane, or + into a column's or the workspace's tag) glides from where the cursor is + drawn to the new one, free of the old pane's edges while it crosses + (.scratch/render/cursor/move/: ride-*, cross-*). Notes on G1: the lift and the dim run on one focus spring per pane, at the diff --git a/src/animation.zig b/src/animation.zig index 840412f3..19f7ce0b 100644 --- a/src/animation.zig +++ b/src/animation.zig @@ -1002,7 +1002,7 @@ pub const CursorGlide = struct { } } - fn centreAt(glide: *const CursorGlide, now_ns: u64) [2]f32 { + pub fn centreAt(glide: *const CursorGlide, now_ns: u64) [2]f32 { var c: [2]f32 = .{ 0, 0 }; for (glide.corners) |corner| for (&c, corner) |*sum, axis| { sum.* += axis.value(now_ns) / 4; diff --git a/src/draw.zig b/src/draw.zig index b0b16e12..15411770 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -212,6 +212,14 @@ fn focusedCursorBox(p: *const Pardes, s: *const Surface) ?animation.Box { /// at the Motion flavour's pace, a jump gliding and a step or a keystroke in /// insert mode landing at once; blinking unless typing. A pixel shell's: a /// grid's cursor is the terminal's own. +/// +/// The springs hold the cursor where the pane's final layout puts it, and +/// the quad is drawn through the pane's frame (paneFrame): while the pane +/// slides, zooms or is dragged, the cursor rides with its text. A pane that +/// moved in the layout carries the springs with it (they keep their place +/// in the pane), so with no transition the cursor lands with the text. A +/// focus change glides from where the cursor is drawn to the new pane's +/// cursor, wherever that pane is drawn now. fn glideCursor(p: *Pardes, s: *Surface) void { s.chrome.cursor_glide = 0; s.chrome.cursor_alpha = 1; @@ -220,26 +228,59 @@ fn glideCursor(p: *Pardes, s: *Surface) void { const box = focusedCursorBox(p, s) orelse return; const motion = animation.Motion.of(p.settings.motion); var snap = if (s.cursor) |cursor| cursor.bar else false; + var frame: PaneFrame = .{}; + // In a column's or the workspace's tag the cursor is not the pane's, + // and does not move with it. + var inside = false; if (p.panes[p.active]) |pane| { - // A focus change lands at once: a glide between panes would be - // squashed against the new one's edge. - if (p.cursor_view.serial != pane.serial) snap = true; - // Typing never trails: insert mode lands every move. - if (pane.body.mode == .insert) snap = true; - // Scrolled (a file's view, a terminal's output): the quad moves with - // the text and lands, never gliding against text that jumped (every - // Enter at a prompt scrolls one and moves the other). + const rect = p.rects[p.active]; + const cx = box.x + box.w / 2; + const cy = box.y + box.h / 2; + inside = cx >= @as(f32, @floatFromInt(rect.x)) and cx <= @as(f32, @floatFromInt(rect.x + rect.w)) and + cy >= @as(f32, @floatFromInt(rect.y)) and cy <= @as(f32, @floatFromInt(rect.y + rect.h)); + if (inside) frame = paneFrame(s, pane.serial, rect); + const origin: [2]f32 = .{ @floatFromInt(rect.x), @floatFromInt(rect.y) }; + const view = p.cursor_view; const line: i64 = if (pane.file) |f| @intCast(f.scroll) else if (pane.isTerminal()) panes.Terminal.gridOffset(pane) else 0; - if (p.cursor_view.serial == pane.serial and line != p.cursor_view.line) { - p.cursor_glide.shift(0, @floatFromInt(p.cursor_view.line - line)); - snap = true; + if (view.serial == pane.serial and view.inside == inside) { + // The pane moved in the layout: the springs move with it (its + // own cursor's; one in a column's tag stays). + if (inside) p.cursor_glide.shift(origin[0] - view.origin[0], origin[1] - view.origin[1]); + // Typing never trails: insert mode lands every move. + if (pane.body.mode == .insert) snap = true; + // Scrolled (a file's view, a terminal's output): the quad moves + // with the text and lands, never gliding against text that + // jumped (every Enter at a prompt scrolls one and moves the + // other). + if (inside and line != view.line) { + p.cursor_glide.shift(0, @floatFromInt(view.line - line)); + snap = true; + } + } else if (view.serial != 0 and p.cursor_glide.placed) { + // Focus moved (to another pane, or between a pane and a + // column's or the workspace's tag): the glide starts where the + // cursor is drawn, in the old place's frame, and heads for the + // new cursor through the new one's. + const old: PaneFrame = if (!view.inside) .{} else for (p.panes, 0..) |slot, id| { + const other = slot orelse continue; + if (other.serial != view.serial) continue; + const r = p.rects[id]; + p.cursor_glide.shift(@as(f32, @floatFromInt(r.x)) - view.origin[0], @as(f32, @floatFromInt(r.y)) - view.origin[1]); + break paneFrame(s, view.serial, r); + } else .{}; + const at = p.cursor_glide.centreAt(p.now_ns); + const seen = frame.unplace(old.place(at)); + p.cursor_glide.shift(seen[0] - at[0], seen[1] - at[1]); + p.cursor_crossing = true; } - p.cursor_view = .{ .serial = pane.serial, .line = line }; + p.cursor_view = .{ .serial = pane.serial, .line = line, .origin = origin, .inside = inside }; } p.cursor_glide.aim(box, p.now_ns, motion, snap); _ = p.cursor_glide.step(p.now_ns); const moving = p.cursor_glide.moving(); - s.chrome.cursor_glide = 2 | @as(u8, @intFromBool(moving)); + if (!moving) p.cursor_crossing = false; + // Drawn as the quad while it glides and while its pane moves. + s.chrome.cursor_glide = 2 | @as(u8, @intFromBool(moving or frame.moving())); // The shell blinks it, from how long ago the last key or click was. if (p.settings.cursor_blink) s.chrome.cursor_idle_ms = @intCast(@min(std.math.maxInt(u32) - 1, (p.now_ns -| p.input_ns) / std.time.ns_per_ms)); @@ -249,13 +290,51 @@ fn glideCursor(p: *Pardes, s: *Surface) void { const see_through: f32 = if (motion.zeta < 1) 0.7 else 0.85; s.chrome.cursor_alpha = if (moving) see_through + (1 - see_through) * std.math.clamp(1 - remaining / 0.5, 0, 1) else 1; const quad = p.cursor_glide.sample(p.now_ns, motion); - // Never outside the focused pane, however far a flavour overshoots or - // a scroll carried it: it squashes against the pane's edges. - const r = p.rects[p.active]; - for (quad, 0..) |point, i| { - s.chrome.cursor_quad[i * 2] = std.math.clamp(point[0], @as(f32, @floatFromInt(r.x)), @as(f32, @floatFromInt(r.x + r.w))); - s.chrome.cursor_quad[i * 2 + 1] = std.math.clamp(point[1], @as(f32, @floatFromInt(r.y)), @as(f32, @floatFromInt(r.y + r.h))); + // Never outside the focused pane as drawn, however far a flavour + // overshoots or a scroll carried it: it squashes against the pane's + // edges. Crossing to another pane, it may pass over any of the grid. + const bounds: animation.Box = if (p.cursor_crossing or !inside) + .{ .w = @floatFromInt(s.cols), .h = @floatFromInt(s.rows) } + else + frame.at; + for (quad, 0..) |corner, i| { + const point = frame.place(corner); + s.chrome.cursor_quad[i * 2] = std.math.clamp(point[0], bounds.x, bounds.x + bounds.w); + s.chrome.cursor_quad[i * 2 + 1] = std.math.clamp(point[1], bounds.y, bounds.y + bounds.h); + } +} + +/// Where a pane is drawn against where the layout puts it: its transition +/// track's presented box against the track's target, or the layout's box +/// both ways when it is still. Maps grid points between the two. +const PaneFrame = struct { + to: animation.Box = .{ .w = 1, .h = 1 }, + at: animation.Box = .{ .w = 1, .h = 1 }, + + fn moving(f: PaneFrame) bool { + return !std.meta.eql(f.to, f.at); + } + + fn place(f: PaneFrame, point: [2]f32) [2]f32 { + return .{ f.at.x + (point[0] - f.to.x) * scale(f.at.w, f.to.w), f.at.y + (point[1] - f.to.y) * scale(f.at.h, f.to.h) }; + } + + fn unplace(f: PaneFrame, point: [2]f32) [2]f32 { + return .{ f.to.x + (point[0] - f.at.x) * scale(f.to.w, f.at.w), f.to.y + (point[1] - f.at.y) * scale(f.to.h, f.at.h) }; + } + + fn scale(num: f32, den: f32) f32 { + return if (den > 0.001) num / den else 1; + } +}; + +fn paneFrame(s: *const Surface, serial: u32, rect: Rect) PaneFrame { + const still = layout.panelBox(rect); + for (s.panelTracks()) |track| { + if (track.serial != serial or track.phase == .closing or !track.active()) continue; + return .{ .to = track.to, .at = track.visualBox() }; } + return .{ .to = still, .at = still }; } /// The Lift style as drawn: `auto` is a shadow on a light page, and on a @@ -667,8 +746,10 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { } } }; - glideCursor(p, s); + // After the tracks are submitted: the cursor is drawn through its + // pane's. try p.presentation.submit(p, s); + glideCursor(p, s); return composeAsciiTransitions(p, arena, s); } @@ -1706,11 +1787,13 @@ test "a dim leaves a pair already under 4.5 alone, and reads a theme's own sixte try std.testing.expect(reverse.bg.rgb[0] < 255); } -test "with no lift and no dim a focus switch asks for no frames" { +test "with no lift, no dim and no motion a focus switch asks for no frames" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); defer p.deinit(); _ = try p.setTestFile("text\n"); p.newScratchBelow(0); + // With motion the cursor glides to the other pane (glideCursor). + p.settings.motion = .off; var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); p.advance(std.time.ns_per_s); @@ -2059,3 +2142,109 @@ test "a body with context rows is painted once, and the grid's rows are what a p try std.testing.expect(layer.rows > body.rect.h); } } + +/// Two columns, the file in the left one and a shell in the right, the +/// right one focused with its cursor settled. +fn twoColumns(p: *Pardes, arena: *std.heap.ArenaAllocator) !void { + _ = try p.setTestFile("some text on a line\n" ** 6); + _ = try p.newShell(1, ""); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); + p.sync(); + p.advance(std.time.ns_per_s); + const boot = try p.render(arena.allocator()); + p.acknowledgePanelPresentation(boot.panelTracks()); + p.advance(2 * std.time.ns_per_s); + _ = arena.reset(.retain_capacity); + const settled = try p.render(arena.allocator()); + p.acknowledgePanelPresentation(settled.panelTracks()); + try std.testing.expect(!p.cursor_glide.moving()); +} + +test "while its pane slides the cursor rides with it, at the same cell of the pane every frame" { + if (!builtins.capabilities.lift) return error.SkipZigTest; + const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20 }); + defer p.deinit(); + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + p.settings.motion = .bouncy; + try twoColumns(p, &arena); + p.active = 1; + p.advance(3 * std.time.ns_per_s); + _ = arena.reset(.retain_capacity); + p.acknowledgePanelPresentation((try p.render(arena.allocator())).panelTracks()); + p.advance(4 * std.time.ns_per_s); + p.settings.panel_transition = .slide; + // The right column's pane moves right. + p.col_weight[0] = layout.column_weight_unit * 2; + p.col_weight[1] = layout.column_weight_unit; + p.sync(); + var now: u64 = 4 * std.time.ns_per_s; + var riding: usize = 0; + for (0..40) |_| { + now += 16 * std.time.ns_per_ms; + p.advance(now); + _ = arena.reset(.retain_capacity); + const s = try p.render(arena.allocator()); + p.acknowledgePanelPresentation(s.panelTracks()); + const frame = paneFrame(s, p.panes[1].?.serial, p.rects[1]); + const box = focusedCursorBox(p, s).?; + const want = frame.place(.{ box.x, box.y }); + try std.testing.expectApproxEqAbs(want[0], s.chrome.cursor_quad[0], 0.01); + try std.testing.expectApproxEqAbs(want[1], s.chrome.cursor_quad[1], 0.01); + if (frame.moving()) { + riding += 1; + // Drawn as the quad while the pane moves. + try std.testing.expect(s.chrome.cursor_glide & 1 != 0); + } + } + try std.testing.expect(riding >= 3); + + // With no transition, the cursor lands with the text at once. + p.settings.panel_transition = .off; + p.col_weight[0] = layout.column_weight_unit; + p.col_weight[1] = layout.column_weight_unit * 2; + p.sync(); + now += 16 * std.time.ns_per_ms; + p.advance(now); + _ = arena.reset(.retain_capacity); + const landed = try p.render(arena.allocator()); + const box = focusedCursorBox(p, landed).?; + try std.testing.expectApproxEqAbs(box.x, landed.chrome.cursor_quad[0], 0.01); + try std.testing.expect(!p.cursor_glide.moving()); +} + +test "focus moved to another column glides from where the cursor was drawn" { + if (!builtins.capabilities.lift) return error.SkipZigTest; + const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20 }); + defer p.deinit(); + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + p.settings.motion = .smooth; + try twoColumns(p, &arena); + p.active = 0; + p.advance(3 * std.time.ns_per_s); + _ = arena.reset(.retain_capacity); + const before = try p.render(arena.allocator()); + try std.testing.expect(!p.cursor_glide.moving()); + const was: [2]f32 = .{ before.chrome.cursor_quad[0], before.chrome.cursor_quad[1] }; + + p.active = 1; + _ = arena.reset(.retain_capacity); + const start = try p.render(arena.allocator()); + // It sets off from where it was drawn, in the left column... + try std.testing.expectApproxEqAbs(was[0], start.chrome.cursor_quad[0], 0.01); + try std.testing.expectApproxEqAbs(was[1], start.chrome.cursor_quad[1], 0.01); + try std.testing.expect(p.cursor_glide.moving() and start.chrome.cursor_glide & 1 != 0); + // ...crosses the grid, not squashed against the right one's edge... + p.advance(3 * std.time.ns_per_s + 40 * std.time.ns_per_ms); + _ = arena.reset(.retain_capacity); + const mid = try p.render(arena.allocator()); + try std.testing.expect(mid.chrome.cursor_quad[0] > was[0] and mid.chrome.cursor_quad[0] < @as(f32, @floatFromInt(p.rects[1].x)) + 1); + // ...and lands on the right one's cursor. + p.advance(5 * std.time.ns_per_s); + _ = arena.reset(.retain_capacity); + const landed = try p.render(arena.allocator()); + const box = focusedCursorBox(p, landed).?; + try std.testing.expectApproxEqAbs(box.x, landed.chrome.cursor_quad[0], 0.01); + try std.testing.expect(!p.cursor_glide.moving() and !p.cursor_crossing); +} diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 2d31416b..18a1c8cb 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -5882,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; @@ -6706,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. @@ -7145,10 +7149,13 @@ test "lapis plaques stay in their tag's band, clear of every grip; only the focu 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] - 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); @@ -7173,6 +7180,11 @@ test "lapis plaques stay in their tag's band, clear of every grip; only the focu 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]); @@ -9382,14 +9394,15 @@ fn plaqueBox(g: *const Gui, chrome: *const pardes.Chrome, regions: []const parde 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. A - // column's text sits a cell further in than a pane's; its plaque starts - // where the pane's under it does, at its grip's end. - const start = if (tag.kind == .column_tag) for (regions) |grip| { - if (grip.kind == .column_grip and grip.owner == tag.owner) break grip.rect.x + grip.rect.w; - } else tag.rect.x else tag.rect.x; - const x0 = @as(f32, @floatFromInt(@min(start, 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 window_edge = win_w - x1_cells < layout.w; const x1 = if (window_edge) @max(x1_cells, win_w) else x1_cells; @@ -9454,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 @@ -10160,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 }; diff --git a/src/pardes.zig b/src/pardes.zig index d6385e6f..2ee4e4ce 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -4347,7 +4347,9 @@ pub const Pardes = struct { /// scroll, G6): the core then keeps the continuous path running, so the /// shell draws every loop and a virtual clock moves on, as for a lift. shell_continuous: bool = false, - cursor_view: struct { serial: u32 = 0, line: i64 = 0 } = .{}, + cursor_view: struct { serial: u32 = 0, line: i64 = 0, origin: [2]f32 = .{ 0, 0 }, inside: bool = true } = .{}, + /// The cursor glides from one pane to another: it may cross the grid. + cursor_crossing: bool = false, input_ns: u64 = 0, animate_theme_changes: bool = false, native_images: bool = false, diff --git a/test/gui-goldens.txt b/test/gui-goldens.txt index 01bdfc7c..473e50a7 100644 --- a/test/gui-goldens.txt +++ b/test/gui-goldens.txt @@ -14,4 +14,4 @@ 15-theme-acme-light ca5252f5117ac8d6c679cef917c9f316c9ca311fc7a8dcbac2796b4fc04d3b64 16-debug 591bb7c45b4b9c46cd2458330b4fa3962afc6dac827fa6f5e20e927a72c586e6 17-terminal 38299427891e31de0b5597159501c161474620eb26fd7708a6396f0fd685209e -18-mid-transition 80cf350f8736fa4b348225a3872572a799ce57c763939796b68e8c839418f8ee +18-mid-transition 5d3e37d874988b460297e18df9508a77b602d5f675e28983c2c793871d4301f6 -- cgit v1.3