From bdb4da01c555337fe08167e420b06a3e3f5548ff Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 08:45:01 -0300 Subject: T2: JumpTrail, a terminal cursor's jump leaves a short fading trail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JumpTrail on|off (off; the tty shell's, §9.2 T2): a jump of three cells or more leaves three to five cells of trail behind the cursor along its path, their ground mixed toward the text's colour in OKLab (most beside the cursor), fading out over 120 ms. Truecolor terminals only (vaxis's answer, or COLORTERM truecolor/24bit); never the cursor's own cell, a selection or anything outside the focused pane's body; none under Motion off. It rides the continuous path (Pardes.shell_continuous) while it lasts; a few cells of output a frame. docs/effects.md opens the terminal track with the audit under decision 2 (keep and polish, remove nothing). Tests: a ten-cell jump lights 3 to 5 cells behind the cursor on its row, gone by 120 ms; a one-cell step and a terminal without truecolor light none. Feel review: .scratch/render/tty/trail/ (a real tty session replayed through pyte at 60 fps). --- src/tty/tty.zig | 138 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 137 insertions(+), 1 deletion(-) (limited to 'src/tty') diff --git a/src/tty/tty.zig b/src/tty/tty.zig index e7eddec4..8f07d7fc 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -914,6 +914,8 @@ const Shell = struct { check_files: bool = false, in_paste: bool = false, tracks: []const pardes.animation.Track = &.{}, + /// T2: the cursor's jump trail (JumpTrail). + trail: JumpTrail = .{}, /// PARDES_TEST_CLOCK's virtual time (host_io.testClock), and where the /// running-out wait moves it: the core's next wake. test_clock: ?u64 = null, @@ -1161,7 +1163,8 @@ const Shell = struct { ) catch return; const tz_cells = tracy.zone(@src(), "surface->vaxis"); const win = vx.window(); - paintCells(win, surface.cells, surface.cols, surface.rows); + const trailed = s.trail.apply(s.frame.allocator(), surface, s.core, vx.caps.rgb or colortermTruecolor()) catch surface; + paintCells(win, trailed.cells, trailed.cols, trailed.rows); tz_cells.end(); for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; @@ -1804,6 +1807,95 @@ fn paintCells(win: vaxis.Window, cells: []const pardes.Cell, cols: u16, rows: u1 } } +/// Truecolor by COLORTERM (truecolor or 24bit), for a terminal that does +/// not answer vaxis's query (§9.2: vaxis caps or COLORTERM). +fn colortermTruecolor() bool { + const value = std.mem.span(std.c.getenv("COLORTERM") orelse return false); + return std.mem.eql(u8, value, "truecolor") or std.mem.eql(u8, value, "24bit"); +} + +/// T2 (docs/render-pipeline.md §9.2): the cursor jumping three cells or +/// more leaves a trail of three to five cells along its path, just behind +/// it, their ground drawn toward the text's colour, most beside the +/// cursor, all fading out over 120 ms. Truecolor terminals only; a cell a +/// frame changes, a few bytes. Never the cursor's own cell, a selection or +/// anything outside the focused pane's body; off with Motion off. +const JumpTrail = struct { + last: ?[2]u16 = null, + from: [2]u16 = .{ 0, 0 }, + to: [2]u16 = .{ 0, 0 }, + start_ns: u64 = 0, + active: bool = false, + + const life_ns: u64 = 120 * std.time.ns_per_ms; + + fn apply(trail: *JumpTrail, arena: std.mem.Allocator, surface: *pardes.Surface, core: *pardes.Pardes, truecolor: bool) !*pardes.Surface { + defer core.shell_continuous = trail.active; + const on = core.settings.jump_trail and truecolor and core.settings.motion != .off; + const cursor = surface.cursor orelse { + trail.active = false; + return surface; + }; + const here: [2]u16 = .{ cursor.x, cursor.y }; + if (trail.last) |was| { + const dx = @as(i32, here[0]) - was[0]; + const dy = @as(i32, here[1]) - was[1]; + if (on and @max(@abs(dx), @abs(dy)) >= 3) { + trail.* = .{ .last = here, .from = was, .to = here, .start_ns = core.now_ns, .active = true }; + } + } + trail.last = here; + if (!on or !trail.active) { + trail.active = false; + return surface; + } + const age = core.now_ns -| trail.start_ns; + if (age >= life_ns or !std.meta.eql(trail.to, here)) { + trail.active = false; + return surface; + } + const out = try arena.create(pardes.Surface); + out.* = surface.*; + out.cells = try arena.dupe(pardes.Cell, surface.cells); + const theme = core.theme(); + const ink = theme.fg orelse .{ 0xcc, 0xcc, 0xcc }; + const page = theme.bg orelse pardes.colors.null_page; + const body = for (surface.regionList()) |region| { + if (region.kind == .body and region.owner == core.active) break region.rect; + } else return out; + const t = @as(f32, @floatFromInt(age)) / @as(f32, @floatFromInt(life_ns)); + const fade = (1 - t) * (1 - t); + const dx = @as(f32, @floatFromInt(@as(i32, trail.from[0]) - trail.to[0])); + const dy = @as(f32, @floatFromInt(@as(i32, trail.from[1]) - trail.to[1])); + const span = @max(@abs(dx), @abs(dy)); + const n: usize = @intFromFloat(std.math.clamp(@round(span / 4), 3, 5)); + var k: usize = 1; + var last_cell: ?[2]i32 = null; + var step: f32 = 1; + while (k <= n and step <= span) : (step += 1) { + const x: i32 = @intFromFloat(@round(@as(f32, @floatFromInt(trail.to[0])) + dx * step / span)); + const y: i32 = @intFromFloat(@round(@as(f32, @floatFromInt(trail.to[1])) + dy * step / span)); + if (last_cell) |seen| if (seen[0] == x and seen[1] == y) continue; + last_cell = .{ x, y }; + defer k += 1; + if (x < body.x or y < body.y or x >= @as(i32, body.x) + body.w or y >= @as(i32, body.y) + body.h) continue; + const cell = &out.cells[@as(usize, @intCast(y)) * out.cols + @as(usize, @intCast(x))]; + const ground: [3]u8 = if (cell.default) page else switch (cell.style.bg) { + .rgb => |rgb| rgb, + else => page, + }; + if (std.mem.eql(u8, &ground, &theme.sel_bg)) continue; + const strength = 0.55 * fade * (1 - @as(f32, @floatFromInt(k - 1)) / @as(f32, @floatFromInt(n))); + if (strength < 0.02) continue; + const was_default = cell.default; + cell.default = false; + if (was_default) cell.style = .{}; + cell.style.bg = .{ .rgb = pardes.colors.mixOklab(ground, ink, strength) }; + } + return out; + } +}; + fn paintCursor(win: vaxis.Window, x: u16, y: u16, bar: bool) void { win.showCursor(x, y); win.setCursorShape(if (bar) .beam else .default); @@ -2064,3 +2156,47 @@ fn attachSession(init: std.process.Init, name: []const u8, tty: *vaxis.Tty, vx: return attachLoop(&a); } + +test "a jump leaves a trail of a few cells behind the cursor that fades in 120 ms" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer core.deinit(); + _ = try core.setTestFile("some text on a line\n" ** 20); + try std.testing.expect(core.executeBuiltinLine(0, "Theme forge")); + core.settings.jump_trail = true; + for (0..40) |_| core.update(.tick); + core.sync(); + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + const s = try core.render(arena.allocator()); + const body = for (s.regionList()) |region| { + if (region.kind == .body) break region.rect; + } else return error.NoBody; + var trail: JumpTrail = .{}; + core.now_ns = std.time.ns_per_s; + s.cursor = .{ .x = body.x + 2, .y = body.y + 1 }; + _ = try trail.apply(arena.allocator(), s, core, true); + // Ten cells right: a trail back toward where it was. + s.cursor = .{ .x = body.x + 12, .y = body.y + 1 }; + core.now_ns += 16 * std.time.ns_per_ms; + const lit = try trail.apply(arena.allocator(), s, core, true); + try std.testing.expect(trail.active and core.shell_continuous); + var changed: usize = 0; + for (lit.cells, s.cells, 0..) |a, b, i| { + if (std.meta.eql(a.style.bg, b.style.bg)) continue; + changed += 1; + // On the cursor's row, behind it, never its own cell. + try std.testing.expectEqual(@as(usize, body.y + 1), i / s.cols); + try std.testing.expect(i % s.cols < body.x + 12 and i % s.cols > body.x + 2); + } + try std.testing.expect(changed >= 3 and changed <= 5); + // Gone by 120 ms; a step of a cell leaves none; nor a terminal without + // truecolor. + core.now_ns += 130 * std.time.ns_per_ms; + const faded = try trail.apply(arena.allocator(), s, core, true); + try std.testing.expect(faded == s and !trail.active and !core.shell_continuous); + s.cursor = .{ .x = body.x + 13, .y = body.y + 1 }; + try std.testing.expect(try trail.apply(arena.allocator(), s, core, true) == s); + s.cursor = .{ .x = body.x + 2, .y = body.y + 1 }; + try std.testing.expect(try trail.apply(arena.allocator(), s, core, false) == s); +} -- cgit v1.3 From a544c0b1af65427f2186baaa5dff176bcd310915 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 08:59:20 -0300 Subject: T1: ChipShadow, a terminal's notice chips cast a cell's shadow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChipShadow on|off (off; the tty shell's, §9.2 T1): each notice chip casts a shadow one cell right and one down inside its own pane's body: on a blank cell a half block (▌ right, ▀ below, ▘ at the corner) in a colour 35% darker than the ground (OKLab), so the shadow is half a cell thick; under text the ground itself darkened. Never the cursor's cell, a selection, a tag or another chip; truecolor terminals only. Tests: the shadow lies right of or below the chip, never in it; nothing with it off or without truecolor. Snapshots unchanged. Feel review: .scratch/render/tty/shadow/. --- docs/config.md | 2 ++ docs/effects.md | 1 + src/config.zig | 8 ++++- src/tty/tty.zig | 104 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 113 insertions(+), 2 deletions(-) (limited to 'src/tty') diff --git a/docs/config.md b/docs/config.md index 1d83bd8e..45677170 100644 --- a/docs/config.md +++ b/docs/config.md @@ -585,6 +585,8 @@ Afterglow on text that changes glows for a moment in the theme's accent JumpTrail on in a terminal, a jump of the cursor of three cells or more leaves a trail of a few cells that fades in 120 ms (truecolor terminals; a pixel shell glides instead) +ChipShadow on in a terminal, a notice chip casts a shadow a cell right and + down: half blocks on blank cells, a darker ground on text 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 ac202579..c447b344 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -264,6 +264,7 @@ it does in a pixel shell. | # | effect | switch | state | review material | verdict | |---|---|---|---|---|---| | T2 | cursor jump trail | `JumpTrail on\|off` (off) | a jump of three cells or more leaves 3 to 5 cells of trail behind the cursor, their ground drawn toward the text's colour in OKLab, most beside it, fading in 120 ms; truecolor terminals (vaxis's answer or COLORTERM); never the cursor's cell, a selection or outside the focused body; off under Motion off; a few cells a frame | .scratch/render/tty/trail/ (pyte replay of a real tty session at 60 fps: trail-forge.mp4, zooms) | pending | +| T1 chip shadow | `ChipShadow on\|off` (off) | a notice chip casts a shadow a cell right and down in its body: half blocks (▌ ▀ ▘) in a darker ground on blank cells, the ground 35% darker (OKLab) under text; never the cursor, a selection, a tag or another chip; truecolor only | .scratch/render/tty/shadow/ (forge, acme; zooms) | pending | | T4 ordered swap | `PanelDissolve` (off) | the terminal's dissolve swaps each changed cell once at its place in a 4x4 Bayer matrix (shifted per pane), an even patterned screen instead of noise; hit-testing follows the same order | .scratch/render/tty/dissolve/ (ordered-swap-sim: the matrix applied to two real frames; see note) | pending | Note on T4's material: a pane opening in a tty session recorded through diff --git a/src/config.zig b/src/config.zig index c0266e40..316ed3a0 100644 --- a/src/config.zig +++ b/src/config.zig @@ -128,6 +128,7 @@ pub const leader_path = paths: { if (builtins.capabilities.lift) table.set(.Parallax, null); if (builtins.capabilities.lift) table.set(.Afterglow, null); table.set(.JumpTrail, null); + table.set(.ChipShadow, null); if (builtins.capabilities.lift) table.set(.Lift, null); table.set(.Motion, null); table.set(.InactiveDim, null); @@ -690,6 +691,9 @@ pub const Runtime = struct { /// T2: in a terminal, a jump of the cursor leaves a short trail that /// fades in 120 ms (a truecolor terminal's; a pixel shell glides). jump_trail: bool = false, + /// T1: in a terminal, a notice chip casts a cell's shadow right and + /// down (truecolor terminals). + chip_shadow: 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, @@ -913,7 +917,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, afterglow, jump_trail }; + 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, jump_trail, chip_shadow }; /// 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 }; @@ -978,6 +982,7 @@ pub const Runtime = struct { .{ .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 = "JumpTrail", .action = .{ .toggle = .jump_trail }, .doc = "In a terminal, a jump of the cursor leaves a short trail that fades, on or off." }, + .{ .word = "ChipShadow", .action = .{ .toggle = .chip_shadow }, .doc = "In a terminal, a notice chip casts a cell's shadow right and down, 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." }, @@ -1717,6 +1722,7 @@ pub const Runtime = struct { .parallax => "Parallax on\n", .afterglow => "Afterglow on\n", .jump_trail => "JumpTrail on\n", + .chip_shadow => "ChipShadow on\n", }, .shell => "Shell requested (new panes) fish\n", .tagline_size => "TaglineSize 73\n", diff --git a/src/tty/tty.zig b/src/tty/tty.zig index 8f07d7fc..69934dd3 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -1163,7 +1163,9 @@ const Shell = struct { ) catch return; const tz_cells = tracy.zone(@src(), "surface->vaxis"); const win = vx.window(); - const trailed = s.trail.apply(s.frame.allocator(), surface, s.core, vx.caps.rgb or colortermTruecolor()) catch surface; + const truecolor = vx.caps.rgb or colortermTruecolor(); + const shadowed = chipShadows(s.frame.allocator(), surface, s.core, truecolor) catch surface; + const trailed = s.trail.apply(s.frame.allocator(), shadowed, s.core, truecolor) catch shadowed; paintCells(win, trailed.cells, trailed.cols, trailed.rows); tz_cells.end(); for (surface.images[0..surface.nimages]) |maybe| { @@ -1807,6 +1809,70 @@ fn paintCells(win: vaxis.Window, cells: []const pardes.Cell, cols: u16, rows: u1 } } +/// T1 (§9.2): each notice chip casts a shadow one cell right and one down, +/// inside its own pane's body: its ground 35% toward black in OKLab on a +/// cell with text; on a blank one a half block (▌ on the right, ▀ below, +/// ▘ at the corner) in the darker colour, so the shadow is half a cell +/// thick and reads as one. Never the cursor's cell, a selection, a tag or +/// another chip. Truecolor terminals only. +fn chipShadows(arena: std.mem.Allocator, surface: *pardes.Surface, core: *pardes.Pardes, truecolor: bool) !*pardes.Surface { + if (!core.settings.chip_shadow or !truecolor) return surface; + const regions = surface.regionList(); + const any = for (regions) |region| { + if (region.kind == .notice) break true; + } else false; + if (!any) return surface; + const out = try arena.create(pardes.Surface); + out.* = surface.*; + out.cells = try arena.dupe(pardes.Cell, surface.cells); + const theme = core.theme(); + const page = theme.bg orelse pardes.colors.null_page; + for (regions) |chip| { + if (chip.kind != .notice or chip.rect.w == 0) continue; + const body = for (regions) |region| { + if (region.kind == .body and region.owner == chip.owner) break region.rect; + } else continue; + const right = chip.rect.x + chip.rect.w; + const below = chip.rect.y + chip.rect.h; + // The column right of it (from its second row), the row below (from + // its second column), and the corner. + var y = chip.rect.y; + while (y <= below) : (y += 1) { + var x = chip.rect.x; + while (x <= right) : (x += 1) { + const side = x == right and y > chip.rect.y and y < below; + const foot = y == below and x > chip.rect.x and x < right; + const corner = x == right and y == below; + if (!side and !foot and !corner) continue; + if (x < body.x or y < body.y or x >= body.x + body.w or y >= body.y + body.h or x >= out.cols or y >= out.rows) continue; + if (surface.cursor) |cu| if (cu.x == x and cu.y == y) continue; + const covered = for (regions) |region| { + if ((region.kind == .notice or region.kind == .tag or region.kind == .grip) and x >= region.rect.x and x < region.rect.x + region.rect.w and y >= region.rect.y and y < region.rect.y + region.rect.h) break true; + } else false; + if (covered) continue; + const cell = &out.cells[@as(usize, y) * out.cols + x]; + const ground: [3]u8 = if (cell.default) page else switch (cell.style.bg) { + .rgb => |rgb| rgb, + else => page, + }; + if (std.mem.eql(u8, &ground, &theme.sel_bg)) continue; + const dark = pardes.colors.mixOklab(ground, .{ 0, 0, 0 }, 0.35); + const blank = cell.default or cell.len == 0 or (cell.len == 1 and cell.text[0] == ' '); + if (blank) { + const glyph: []const u8 = if (corner) "▘" else if (side) "▌" else "▀"; + cell.* = .{ .default = false, .style = .{ .fg = .{ .rgb = dark }, .bg = .{ .rgb = ground } } }; + @memcpy(cell.text[0..glyph.len], glyph); + cell.len = @intCast(glyph.len); + } else { + cell.default = false; + cell.style.bg = .{ .rgb = dark }; + } + } + } + } + return out; +} + /// Truecolor by COLORTERM (truecolor or 24bit), for a terminal that does /// not answer vaxis's query (§9.2: vaxis caps or COLORTERM). fn colortermTruecolor() bool { @@ -2200,3 +2266,39 @@ test "a jump leaves a trail of a few cells behind the cursor that fades in 120 m s.cursor = .{ .x = body.x + 2, .y = body.y + 1 }; try std.testing.expect(try trail.apply(arena.allocator(), s, core, false) == s); } + +test "a notice chip casts a cell's shadow right and down, inside its body, off the cursor" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer core.deinit(); + _ = try core.setTestFile("some text on a line\n" ** 20); + try std.testing.expect(core.executeBuiltinLine(0, "Theme forge")); + core.settings.chip_shadow = true; + try std.testing.expect(core.executeBuiltinLine(0, "Msg a notice")); + for (0..60) |_| core.update(.tick); + core.sync(); + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + const s = try core.render(arena.allocator()); + const chip = for (s.regionList()) |region| { + if (region.kind == .notice) break region.rect; + } else return error.NoNotice; + s.cursor = null; + const out = try chipShadows(arena.allocator(), s, core, true); + try std.testing.expect(out != s); + var changed: usize = 0; + for (out.cells, s.cells, 0..) |a, b, i| { + if (std.meta.eql(a, b)) continue; + changed += 1; + const x = i % s.cols; + const y = i / s.cols; + // Right of the chip or below it, never inside it. + try std.testing.expect(x == chip.x + chip.w or y == chip.y + chip.h); + try std.testing.expect(!(x >= chip.x and x < chip.x + chip.w and y >= chip.y and y < chip.y + chip.h)); + } + try std.testing.expect(changed >= chip.w - 1); + // Off, or without truecolor: the frame as it was. + try std.testing.expect(try chipShadows(arena.allocator(), s, core, false) == s); + core.settings.chip_shadow = false; + try std.testing.expect(try chipShadows(arena.allocator(), s, core, true) == s); +} -- cgit v1.3 From 99149638c2b89fd134131d081bfd5fc8f967c53e Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 09:04:44 -0300 Subject: T7: the scroll thumb flashes as a terminal pane scrolls ThumbFlash (off by default) brightens a body's scroll thumb toward the ink when its view moves and fades it back over 250 ms, truecolor only. The tty present path now clears shell_continuous once before its effects and each effect only raises it, so the trail and the flash no longer mask each other. --- docs/config.md | 2 ++ docs/effects.md | 1 + src/config.zig | 8 ++++- src/tty/tty.zig | 96 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 104 insertions(+), 3 deletions(-) (limited to 'src/tty') diff --git a/docs/config.md b/docs/config.md index 45677170..8b995e8a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -587,6 +587,8 @@ JumpTrail on in a terminal, a jump of the cursor of three cells or more (truecolor terminals; a pixel shell glides instead) ChipShadow on in a terminal, a notice chip casts a shadow a cell right and down: half blocks on blank cells, a darker ground on text +ThumbFlash on in a terminal, a pane's scroll thumb brightens as it scrolls + and fades back over 250 ms 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 c447b344..f5588e93 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -266,6 +266,7 @@ it does in a pixel shell. | T2 | cursor jump trail | `JumpTrail on\|off` (off) | a jump of three cells or more leaves 3 to 5 cells of trail behind the cursor, their ground drawn toward the text's colour in OKLab, most beside it, fading in 120 ms; truecolor terminals (vaxis's answer or COLORTERM); never the cursor's cell, a selection or outside the focused body; off under Motion off; a few cells a frame | .scratch/render/tty/trail/ (pyte replay of a real tty session at 60 fps: trail-forge.mp4, zooms) | pending | | T1 chip shadow | `ChipShadow on\|off` (off) | a notice chip casts a shadow a cell right and down in its body: half blocks (▌ ▀ ▘) in a darker ground on blank cells, the ground 35% darker (OKLab) under text; never the cursor, a selection, a tag or another chip; truecolor only | .scratch/render/tty/shadow/ (forge, acme; zooms) | pending | | T4 ordered swap | `PanelDissolve` (off) | the terminal's dissolve swaps each changed cell once at its place in a 4x4 Bayer matrix (shifted per pane), an even patterned screen instead of noise; hit-testing follows the same order | .scratch/render/tty/dissolve/ (ordered-swap-sim: the matrix applied to two real frames; see note) | pending | +| T7 thumb flash | `ThumbFlash on\|off` (off) | a pane whose view moves has its scroll thumb drawn toward the text's colour, 60% at once and back over 250 ms (quadratic); the thumb only, never a focus indicator; truecolor only; a few cells a frame while it fades | .scratch/render/tty/thumb-flash.mp4, thumb-flash-strip.png (pyte replay, forge) | pending | Note on T4's material: a pane opening in a tty session recorded through pyte did not animate (PanelSlide did not either there), so the review diff --git a/src/config.zig b/src/config.zig index 316ed3a0..1b432415 100644 --- a/src/config.zig +++ b/src/config.zig @@ -129,6 +129,7 @@ pub const leader_path = paths: { if (builtins.capabilities.lift) table.set(.Afterglow, null); table.set(.JumpTrail, null); table.set(.ChipShadow, null); + table.set(.ThumbFlash, null); if (builtins.capabilities.lift) table.set(.Lift, null); table.set(.Motion, null); table.set(.InactiveDim, null); @@ -694,6 +695,9 @@ pub const Runtime = struct { /// T1: in a terminal, a notice chip casts a cell's shadow right and /// down (truecolor terminals). chip_shadow: bool = false, + /// T7: in a terminal, a pane's scroll thumb brightens as it scrolls and + /// fades back (truecolor terminals). + thumb_flash: 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, @@ -917,7 +921,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, afterglow, jump_trail, chip_shadow }; + 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, jump_trail, chip_shadow, thumb_flash }; /// 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 }; @@ -983,6 +987,7 @@ pub const Runtime = struct { .{ .word = "Afterglow", .action = .{ .toggle = .afterglow }, .availability = .lift, .doc = "Text that changes glows for a moment and fades, on or off." }, .{ .word = "JumpTrail", .action = .{ .toggle = .jump_trail }, .doc = "In a terminal, a jump of the cursor leaves a short trail that fades, on or off." }, .{ .word = "ChipShadow", .action = .{ .toggle = .chip_shadow }, .doc = "In a terminal, a notice chip casts a cell's shadow right and down, on or off." }, + .{ .word = "ThumbFlash", .action = .{ .toggle = .thumb_flash }, .doc = "In a terminal, a pane's scroll thumb brightens as it scrolls and fades back, 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." }, @@ -1723,6 +1728,7 @@ pub const Runtime = struct { .afterglow => "Afterglow on\n", .jump_trail => "JumpTrail on\n", .chip_shadow => "ChipShadow on\n", + .thumb_flash => "ThumbFlash on\n", }, .shell => "Shell requested (new panes) fish\n", .tagline_size => "TaglineSize 73\n", diff --git a/src/tty/tty.zig b/src/tty/tty.zig index 69934dd3..c4096faf 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -916,6 +916,8 @@ const Shell = struct { tracks: []const pardes.animation.Track = &.{}, /// T2: the cursor's jump trail (JumpTrail). trail: JumpTrail = .{}, + /// T7: when each pane's view last moved (ThumbFlash). + flash: ThumbFlash = .{}, /// PARDES_TEST_CLOCK's virtual time (host_io.testClock), and where the /// running-out wait moves it: the core's next wake. test_clock: ?u64 = null, @@ -1164,8 +1166,11 @@ const Shell = struct { const tz_cells = tracy.zone(@src(), "surface->vaxis"); const win = vx.window(); const truecolor = vx.caps.rgb or colortermTruecolor(); + // Each of the shell's own motions below asks for frames while it runs. + s.core.shell_continuous = false; const shadowed = chipShadows(s.frame.allocator(), surface, s.core, truecolor) catch surface; - const trailed = s.trail.apply(s.frame.allocator(), shadowed, s.core, truecolor) catch shadowed; + const flashed = s.flash.apply(s.frame.allocator(), shadowed, s.core, truecolor) catch shadowed; + const trailed = s.trail.apply(s.frame.allocator(), flashed, s.core, truecolor) catch flashed; paintCells(win, trailed.cells, trailed.cols, trailed.rows); tz_cells.end(); for (surface.images[0..surface.nimages]) |maybe| { @@ -1873,6 +1878,60 @@ fn chipShadows(arena: std.mem.Allocator, surface: *pardes.Surface, core: *pardes return out; } +/// T7 (§9.2): a pane whose view moved (Region.line) has its scroll thumb +/// brighten, drawn most of the way to the text's colour, and fade back +/// over 250 ms with an ease-out: where you are in the file, as you move. +/// The rail is chrome, not a focus indicator. Truecolor terminals only. +const ThumbFlash = struct { + lines: [pardes.MAX_PANES]i32 = @splat(std.math.minInt(i32)), + since: [pardes.MAX_PANES]u64 = @splat(0), + + const life_ns: u64 = 250 * std.time.ns_per_ms; + + fn apply(flash: *ThumbFlash, arena: std.mem.Allocator, surface: *pardes.Surface, core: *pardes.Pardes, truecolor: bool) !*pardes.Surface { + const on = core.settings.thumb_flash and truecolor and core.settings.motion != .off; + var out: ?*pardes.Surface = null; + const theme = core.theme(); + const ink = theme.fg orelse .{ 0xcc, 0xcc, 0xcc }; + for (surface.regionList()) |body| { + if (body.kind != .body or body.owner >= flash.lines.len) continue; + const id = body.owner; + if (flash.lines[id] != std.math.minInt(i32) and flash.lines[id] != body.line) flash.since[id] = core.now_ns; + flash.lines[id] = body.line; + const age = core.now_ns -| flash.since[id]; + if (!on or flash.since[id] == 0 or age >= life_ns) continue; + const rail = for (surface.regionList()) |region| { + if (region.kind == .rail and region.owner == id) break region; + } else continue; + if (rail.thumb_h == 0) continue; + const t = @as(f32, @floatFromInt(age)) / @as(f32, @floatFromInt(life_ns)); + const strength = 0.6 * (1 - t) * (1 - t); + if (out == null) { + const copy = try arena.create(pardes.Surface); + copy.* = surface.*; + copy.cells = try arena.dupe(pardes.Cell, surface.cells); + out = copy; + } + const o = out.?; + var y = rail.rect.y + rail.thumb_y; + while (y < rail.rect.y + rail.thumb_y + rail.thumb_h and y < o.rows) : (y += 1) { + var x = rail.rect.x; + while (x < rail.rect.x + rail.rect.w and x < o.cols) : (x += 1) { + const cell = &o.cells[@as(usize, y) * o.cols + x]; + const ground: [3]u8 = switch (cell.style.bg) { + .rgb => |rgb| rgb, + else => continue, + }; + if (!std.mem.eql(u8, &ground, &core.chromeTheme().scroll_thumb)) continue; + cell.style.bg = .{ .rgb = pardes.colors.mixOklab(ground, ink, strength) }; + } + } + core.shell_continuous = true; + } + return out orelse surface; + } +}; + /// Truecolor by COLORTERM (truecolor or 24bit), for a terminal that does /// not answer vaxis's query (§9.2: vaxis caps or COLORTERM). fn colortermTruecolor() bool { @@ -1896,7 +1955,7 @@ const JumpTrail = struct { const life_ns: u64 = 120 * std.time.ns_per_ms; fn apply(trail: *JumpTrail, arena: std.mem.Allocator, surface: *pardes.Surface, core: *pardes.Pardes, truecolor: bool) !*pardes.Surface { - defer core.shell_continuous = trail.active; + defer core.shell_continuous = trail.active or core.shell_continuous; const on = core.settings.jump_trail and truecolor and core.settings.motion != .off; const cursor = surface.cursor orelse { trail.active = false; @@ -2259,6 +2318,7 @@ test "a jump leaves a trail of a few cells behind the cursor that fades in 120 m // Gone by 120 ms; a step of a cell leaves none; nor a terminal without // truecolor. core.now_ns += 130 * std.time.ns_per_ms; + core.shell_continuous = false; const faded = try trail.apply(arena.allocator(), s, core, true); try std.testing.expect(faded == s and !trail.active and !core.shell_continuous); s.cursor = .{ .x = body.x + 13, .y = body.y + 1 }; @@ -2302,3 +2362,35 @@ test "a notice chip casts a cell's shadow right and down, inside its body, off t core.settings.chip_shadow = false; try std.testing.expect(try chipShadows(arena.allocator(), s, core, true) == s); } + +test "a scrolled pane's thumb brightens and fades back in 250 ms" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer core.deinit(); + const pane = try core.setTestFile("some text on a line\n" ** 200); + try std.testing.expect(core.executeBuiltinLine(0, "Theme forge")); + core.settings.thumb_flash = true; + for (0..40) |_| core.update(.tick); + core.sync(); + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + var flash: ThumbFlash = .{}; + core.now_ns = std.time.ns_per_s; + const first = try core.render(arena.allocator()); + try std.testing.expect(try flash.apply(arena.allocator(), first, core, true) == first); + pane.scrollBy(20); + core.now_ns += 16 * std.time.ns_per_ms; + const moved = try core.render(arena.allocator()); + const lit = try flash.apply(arena.allocator(), moved, core, true); + try std.testing.expect(lit != moved and core.shell_continuous); + var brighter: usize = 0; + for (lit.cells, moved.cells) |a, b| { + if (!std.meta.eql(a.style.bg, b.style.bg)) brighter += 1; + } + try std.testing.expect(brighter >= 1); + core.now_ns += 300 * std.time.ns_per_ms; + core.shell_continuous = false; + const later = try core.render(arena.allocator()); + try std.testing.expect(try flash.apply(arena.allocator(), later, core, true) == later); + try std.testing.expect(!core.shell_continuous); +} -- 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/tty') 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