const std = @import("std"); pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); // libghostty-vt provides the terminal emulator core: escape sequence // parsing, screen/scrollback state, and input encoding. Everything above // it (PTY, rendering, windowing) is ours. const ghostty = b.dependency("ghostty", .{ .target = target, .optimize = optimize, }); // GTK4/libadwaita bindings. This is the same generated binding set // Ghostty uses for its Linux apprt, so it matches the GObject // introspection data of the GTK we link against. const gobject = b.dependency("gobject", .{ .target = target, .optimize = optimize, }); const exe = b.addExecutable(.{ .name = "playpen", .root_module = b.createModule(.{ .root_source_file = b.path("src/main.zig"), .target = target, .optimize = optimize, .link_libc = true, }), }); exe.root_module.addImport("ghostty-vt", ghostty.module("ghostty-vt")); // The symbolic icons the app names are compiled into the binary, so that // showing a terminal or web-browser glyph doesn't depend on which icon // theme the host has installed. glib-compile-resources comes from the same // Nix shell as GTK itself; the compiled bundle is embedded by src/icons.zig // and registered at startup. const gresource = b.addSystemCommand(&.{"glib-compile-resources"}); gresource.addArg("--sourcedir"); gresource.addDirectoryArg(b.path("assets/icons")); gresource.addArg("--target"); const icons_gresource = gresource.addOutputFileArg("icons.gresource"); gresource.addFileArg(b.path("assets/icons/icons.gresource.xml")); exe.root_module.addAnonymousImport("icons.gresource", .{ .root_source_file = icons_gresource, }); // The review pane's UI. It is a React app (see `web/`), built by Vite here so // the bundle embedded in the binary can never be older than the source it // came from, and embedded rather than installed alongside so a review pane // is a web view pointed at this process and nothing else. // // The four filenames are pinned in `web/vite.config.ts` precisely so they // can be named at compile time; `src/review/assets.zig` is the other half of // that agreement. const web_build = b.addSystemCommand(&.{ "npm", "run", "--silent", "build", "--" }); web_build.setCwd(b.path("web")); web_build.setName("vite build (review UI)"); web_build.addArg("--outDir"); const web_dist = web_build.addOutputDirectoryArg("dist"); web_build.addArg("--emptyOutDir"); // Vite is not told what its inputs are, so the Run step has to be. Without // this the bundle is cached against its argv alone and editing the UI would // rebuild nothing; with it, `zig build` after a `.tsx` edit does the right // thing and `zig build` after a `.zig` edit does not re-run npm. addWebInputs(b, web_build); exe.root_module.addAnonymousImport("review-index.html", .{ .root_source_file = web_dist.path(b, "index.html"), }); exe.root_module.addAnonymousImport("review-app.js", .{ .root_source_file = web_dist.path(b, "assets/app.js"), }); exe.root_module.addAnonymousImport("review-app.css", .{ .root_source_file = web_dist.path(b, "assets/app.css"), }); exe.root_module.addAnonymousImport("review-favicon.svg", .{ .root_source_file = web_dist.path(b, "favicon.svg"), }); const gobject_imports = .{ .{ "adw", "adw1" }, .{ "cairo", "cairo1" }, .{ "gdk", "gdk4" }, .{ "gio", "gio2" }, .{ "glib", "glib2" }, .{ "glibunix", "glibunix2" }, .{ "gobject", "gobject2" }, .{ "gtk", "gtk4" }, .{ "pango", "pango1" }, .{ "pangocairo", "pangocairo1" }, }; inline for (gobject_imports) |import| { const name, const module = import; exe.root_module.addImport(name, gobject.module(module)); } exe.root_module.linkSystemLibrary("gtk4", .{}); exe.root_module.linkSystemLibrary("libadwaita-1", .{}); // WebKitGTK backs the web panes. There are no generated bindings for it in // the zig-gobject set we use, so `src/webkit.zig` declares the handful of // C entry points we need by hand and this links them. exe.root_module.linkSystemLibrary("webkitgtk-6.0", .{}); b.installArtifact(exe); const run_cmd = b.addRunArtifact(exe); run_cmd.step.dependOn(b.getInstallStep()); if (b.args) |args| run_cmd.addArgs(args); b.step("run", "Run the app").dependOn(&run_cmd.step); // Tests are rooted at Layouts.zig rather than main.zig: the parts of this // app worth testing in isolation are the ones that are pure data — layout // parsing, parameter substitution, the `$(...)` expansion and the script // runner underneath it. Everything else is a widget tree, which wants a // display and a person looking at it. // // Rooting there also keeps the test binary off GTK entirely; Layouts.zig // and script.zig reach for GLib and nothing above it. const tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("src/Layouts.zig"), .target = target, .optimize = optimize, .link_libc = true, }), }); tests.root_module.addImport("glib", gobject.module("glib2")); const test_step = b.step("test", "Run the tests"); test_step.dependOn(&b.addRunArtifact(tests).step); // The session snapshot is its own root for the same reason, and is the same // kind of thing: a file the app writes and reads back, whose shape is worth // being sure about. It imports Layouts.zig for the node grammar the two // share, and needs libc for the one call GLib does not expose — see // `Snapshot.remove`. const snapshot_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("src/Snapshot.zig"), .target = target, .optimize = optimize, .link_libc = true, }), }); snapshot_tests.root_module.addImport("glib", gobject.module("glib2")); test_step.dependOn(&b.addRunArtifact(snapshot_tests).step); // The directory field's rules are their own root, for the same reason as // the layouts file: which directory a half-typed path names, which entries // in it are offered and how far they agree is text and filesystem, and the // popover above it is the part that wants a display. It reaches GLib and // nothing else, so it tests without one. const paths_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("src/paths.zig"), .target = target, .optimize = optimize, .link_libc = true, }), }); paths_tests.root_module.addImport("glib", gobject.module("glib2")); test_step.dependOn(&b.addRunArtifact(paths_tests).step); // The palette is the third root: a table of colors, the rules for // resolving one, and the CSS it is written out as. It reaches libghostty-vt // for the terminal's own default palette and nothing else, so it tests // without a display in the same way the two above do. const palette_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("src/palette.zig"), .target = target, .optimize = optimize, }), }); palette_tests.root_module.addImport("ghostty-vt", ghostty.module("ghostty-vt")); test_step.dependOn(&b.addRunArtifact(palette_tests).step); // And a root for the settings file itself. `Settings.zig` reaches GLib for // where the file lives, but reading and writing its *contents* is pure // enough to test — which is what these cover, since the palette overrides // are a nested object whose shape one launch has to agree with the next on. const settings_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("src/Settings.zig"), .target = target, .optimize = optimize, .link_libc = true, }), }); settings_tests.root_module.addImport("glib", gobject.module("glib2")); settings_tests.root_module.addImport("ghostty-vt", ghostty.module("ghostty-vt")); test_step.dependOn(&b.addRunArtifact(settings_tests).step); // Paste safety is its own root, and for a sharper reason than the rest: // when it says no it says so silently — the terminal simply does not // receive what you pasted — so the rule that decides has to be checkable // without a display. It reaches libghostty-vt for the encoder and nothing // else; the clipboard round trip around it is GTK's and stays in // `Terminal.zig`. const paste_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("src/paste.zig"), .target = target, .optimize = optimize, }), }); paste_tests.root_module.addImport("ghostty-vt", ghostty.module("ghostty-vt")); test_step.dependOn(&b.addRunArtifact(paste_tests).step); // The shortcut table is its own root for the same reason: chords are parsed // from text, written back out as text, and looked up by a key press, and // all three are pure data. It reaches libghostty-vt for the key enum and // nothing else, which is what keeps it — and the settings tests above, which // import it — off GTK. const shortcut_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("src/shortcuts.zig"), .target = target, .optimize = optimize, }), }); shortcut_tests.root_module.addImport("ghostty-vt", ghostty.module("ghostty-vt")); test_step.dependOn(&b.addRunArtifact(shortcut_tests).step); // A second root for the same reason, one step further out: `emoji.zig` is a // table and a search over it, and it imports nothing at all. It cannot hang // off the root above because a test binary has exactly one root, and // Layouts.zig has no reason to reach for the emoji table. // The review server's two testable halves. `git.zig` is git's own output // formats and the rule for picking a base ref — text in, text out. `Store.zig` // is the review file, whose round trip is the one piece of this that has to // survive the process. Neither reaches GTK, so both run without a display; // the HTTP layer above them wants a socket and a browser, which is a // different kind of test than this project has. const git_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("src/review/git.zig"), .target = target, .optimize = optimize, .link_libc = true, }), }); test_step.dependOn(&b.addRunArtifact(git_tests).step); const store_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("src/review/Store.zig"), .target = target, .optimize = optimize, .link_libc = true, }), }); test_step.dependOn(&b.addRunArtifact(store_tests).step); // The mute rules are their own root. Whether a tab is silent at this instant, // and what its row menu says about that, is arithmetic on a deadline — and // it is the half of notifications that can be wrong without anyone noticing, // since the symptom is a popup that didn't arrive. It reaches gio to hand a // notification to the session, which is the part these tests leave alone. const notify_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("src/notify.zig"), .target = target, .optimize = optimize, .link_libc = true, }), }); notify_tests.root_module.addImport("gio", gobject.module("gio2")); notify_tests.root_module.addImport("glib", gobject.module("glib2")); test_step.dependOn(&b.addRunArtifact(notify_tests).step); const emoji_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("src/emoji.zig"), .target = target, .optimize = optimize, }), }); test_step.dependOn(&b.addRunArtifact(emoji_tests).step); } /// Declare every file the Vite build reads as an input of that build step. /// /// Zig hashes a `Run` step's file *arguments*, but a directory passed as an /// argument is hashed by path and not by contents — so the source tree has to be /// enumerated here, at configure time, for the cache to be honest about when the /// bundle is stale. /// /// `node_modules` is deliberately not walked. It is tens of thousands of files /// whose contents are already pinned by `package-lock.json`, which *is* listed. fn addWebInputs(b: *std.Build, run: *std.Build.Step.Run) void { for ([_][]const u8{ "web/index.html", "web/package.json", "web/package-lock.json", "web/tsconfig.json", "web/vite.config.ts", }) |file| { run.addFileInput(b.path(file)); } const io = b.graph.io; for ([_][]const u8{ "web/src", "web/public" }) |root| { var dir = b.build_root.handle.openDir(io, root, .{ .iterate = true }) catch continue; defer dir.close(io); var walker = dir.walk(b.allocator) catch continue; defer walker.deinit(); while (walker.next(io) catch null) |entry| { if (entry.kind != .file) continue; run.addFileInput(b.path(b.pathJoin(&.{ root, entry.path }))); } } }