summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/config.zig2
-rw-r--r--src/detached/client.zig33
-rw-r--r--src/detached/server.zig36
-rw-r--r--src/detached/wire.zig71
-rw-r--r--src/file_watch.zig13
-rw-r--r--src/gui/Post.zig234
-rw-r--r--src/gui/gui.zig58
-rw-r--r--src/main.zig1
-rw-r--r--src/shader_build.zig402
-rw-r--r--src/tty/tty.zig2
10 files changed, 626 insertions, 226 deletions
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 <path>),
-//! 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 <path>), 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 <stdin>; the file is what a person knows.
- defer gpa.free(said);
- const named = std.mem.replaceOwned(u8, gpa, said, "<stdin>", 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 <stdin>.
- try std.testing.expect(std.mem.indexOf(u8, text, path) != null);
- try std.testing.expect(std.mem.indexOf(u8, text, "<stdin>") == 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 <stdin>; the file is what a person knows.
+ defer gpa.free(said);
+ const named = std.mem.replaceOwned(u8, gpa, said, "<stdin>", 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 <stdin>.
+ try std.testing.expect(std.mem.indexOf(u8, r.text, path) != null);
+ try std.testing.expect(std.mem.indexOf(u8, r.text, "<stdin>") == 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;
}