summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-02 21:41:13 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commit81992cc92743a98066ee1a9025bc74f91cf0e967 (patch)
tree12c07f68f8ad030f6c6f8a2b3415998d0a87eee7
parentbead9f0b78380d1428f40fdab339c1c8f88fa626 (diff)
downloadpardes-81992cc92743a98066ee1a9025bc74f91cf0e967.tar.gz
pardes-81992cc92743a98066ee1a9025bc74f91cf0e967.zip
storage and replay
-rw-r--r--build.zig45
-rw-r--r--dump.zig200
-rw-r--r--image.zig39
-rw-r--r--main.zig472
-rw-r--r--paint.zig324
-rw-r--r--replay.zig712
-rw-r--r--term.zig5
-rw-r--r--tests.zig129
8 files changed, 1585 insertions, 341 deletions
diff --git a/build.zig b/build.zig
index ca8560fd..14fefaf5 100644
--- a/build.zig
+++ b/build.zig
@@ -1,5 +1,7 @@
const std = @import("std");
+const AppKind = enum { main, replay };
+
pub fn build(b: *std.Build) void {
const optimize = b.standardOptimizeOption(.{});
const target = b.standardTargetOptions(.{});
@@ -33,6 +35,16 @@ pub fn build(b: *std.Build) void {
.strip = strip,
}),
});
+ const replay = b.addExecutable(.{
+ .name = "pardes-replay",
+ .linkage = exe_linkage,
+ .root_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("replay.zig"),
+ .strip = strip,
+ }),
+ });
const tests = b.addExecutable(.{
.name = "pardes-e2e",
.linkage = exe_linkage,
@@ -44,6 +56,14 @@ pub fn build(b: *std.Build) void {
}),
});
+ const main_app_config = b.addOptions();
+ main_app_config.addOption(AppKind, "app_kind", .main);
+ exe.root_module.addOptions("app_config", main_app_config);
+
+ const replay_app_config = b.addOptions();
+ replay_app_config.addOption(AppKind, "app_kind", .replay);
+ replay.root_module.addOptions("app_config", replay_app_config);
+
// ghostty-vt and vaxis both depend on uucode, but Zig 0.16 forbids the
// same source file from belonging to two modules, so they can't each have
// their own uucode instance. We build a single uucode module here (with our
@@ -89,6 +109,7 @@ pub fn build(b: *std.Build) void {
const ghostty_vt = dep.module("ghostty-vt");
ghostty_vt.addImport("uucode", uucode_mod);
exe.root_module.addImport("ghostty-vt", ghostty_vt);
+ replay.root_module.addImport("ghostty-vt", ghostty_vt);
// The e2e test harness parses the app's pty output with its own ghostty
// terminal (reads the 2D screen grid, not raw bytes), so it needs the
// same module.
@@ -111,6 +132,7 @@ pub fn build(b: *std.Build) void {
});
ui_mod.addImport("vaxis", vaxis_mod);
exe.root_module.addImport("ui", ui_mod);
+ replay.root_module.addImport("ui", ui_mod);
// SDL3 GPU backend wiring (`-Dsdl`). The sdl package builds SDL3 from source
// as a static lib + emits the SDL3/ headers; we @cImport those headers in
@@ -150,12 +172,14 @@ pub fn build(b: *std.Build) void {
// C source + include path + libc, so importing it is all that's needed.
const zstbi_mod = b.dependency("zstbi", .{ .target = target, .optimize = optimize }).module("root");
exe.root_module.addImport("zstbi", zstbi_mod);
+ replay.root_module.addImport("zstbi", zstbi_mod);
// tree-sitter syntax highlighting for file panes: the Zig bindings + C runtime
// (the `tree-sitter` module), each grammar's parser.c linked as a static lib,
// and its highlights.scm read at build time into the `ts_queries` options module.
const tree_sitter = b.dependency("tree_sitter", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("tree-sitter", tree_sitter.module("tree_sitter"));
+ replay.root_module.addImport("tree-sitter", tree_sitter.module("tree_sitter"));
const Grammar = struct {
name: []const u8,
@@ -209,18 +233,26 @@ pub fn build(b: *std.Build) void {
if (g.scanner) lib.root_module.addCSourceFile(.{ .file = dep.path(g.src ++ "/scanner.c") });
lib.root_module.addIncludePath(dep.path(g.src));
exe.root_module.linkLibrary(lib);
+ replay.root_module.linkLibrary(lib);
}
exe.root_module.addOptions("ts_queries", ts_queries);
+ replay.root_module.addOptions("ts_queries", ts_queries);
// Install the exe so `zig build` (and `zig build install`) produces it in
// zig-out/bin for global install, not just as a hidden dep of `run`/`test`.
b.installArtifact(exe);
+ b.installArtifact(replay);
const run_exe = b.addRunArtifact(exe);
const run = b.step("run", "descend to the pardes");
run.dependOn(&run_exe.step);
+ const run_replay = b.addRunArtifact(replay);
+ if (b.args) |args| run_replay.addArgs(args);
+ const replay_step = b.step("replay", "replay a pardes dump: zig build replay -- <dump.zon>");
+ replay_step.dependOn(&run_replay.step);
+
// tutor_gen: a separate binary that parses tutor.txt and writes
// tutor_cases.zig (a file of `test` blocks, one per practice block).
// `zig build tutor-gen` runs it; the unit-test step depends on the output.
@@ -241,6 +273,7 @@ pub fn build(b: *std.Build) void {
const run_tests = b.addRunArtifact(tests);
run_tests.addArtifactArg(exe);
+ run_tests.addArtifactArg(replay);
// Expose build-time flags (e.g. -Dtrace) to the test runner as an importable
// `config` module. tests.zig reads `config.trace` and hands it to the harness.
@@ -302,6 +335,18 @@ pub fn build(b: *std.Build) void {
unit_step.dependOn(&run_petscii.step);
test_step.dependOn(&run_petscii.step);
+ const dump_tests = b.addTest(.{
+ .root_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("dump.zig"),
+ }),
+ });
+ dump_tests.linkage = exe_linkage;
+ const run_dump = b.addRunArtifact(dump_tests);
+ unit_step.dependOn(&run_dump.step);
+ test_step.dependOn(&run_dump.step);
+
// Tutor practice blocks as unit tests: a second `zig test` target over the
// generated tutor_cases.zig (which imports modal.zig). Depends on tutor-gen
// so the file is (re)generated first.
diff --git a/dump.zig b/dump.zig
new file mode 100644
index 00000000..5a55f04f
--- /dev/null
+++ b/dump.zig
@@ -0,0 +1,200 @@
+const std = @import("std");
+
+pub const default_path = "/tmp/pardes-dump.zon";
+pub const magic = "pardes-dump";
+pub const version: u32 = 1;
+pub const max_panes: usize = 16;
+pub const max_cols: usize = 6;
+
+pub const Size = struct {
+ cols: u16,
+ rows: u16,
+};
+
+pub const Cursor = struct {
+ col: u16 = 0,
+ row: u16 = 0,
+};
+
+pub const PaneKind = enum {
+ terminal,
+ file,
+ image,
+};
+
+pub const Terminal = struct {
+ cwd: []const u8 = "",
+ stream: []const u8 = "",
+ stream_b64: []const u8 = "",
+ cursor: Cursor = .{},
+};
+
+pub const File = struct {
+ path: []const u8 = "",
+ content: []const u8 = "",
+ content_b64: []const u8 = "",
+};
+
+pub const Image = struct {
+ path: []const u8 = "",
+ bytes_b64: []const u8 = "",
+};
+
+pub const Pane = struct {
+ kind: PaneKind,
+ tag: []const u8,
+ body: []const u8,
+ scroll: usize = 0,
+ cols: u16 = 0,
+ rows: u16 = 0,
+ vweight: f32 = 1,
+ terminal: ?Terminal = null,
+ file: ?File = null,
+ image: ?Image = null,
+};
+
+pub const Column = struct {
+ weight: f32 = 1,
+ panes: []const usize = &.{},
+};
+
+pub const State = struct {
+ magic: []const u8 = magic,
+ version: u32 = version,
+ screen: Size,
+ active: usize = 0,
+ topbar: []const u8 = "",
+ theme: []const u8 = "dark",
+ columns: []const Column = &.{},
+ panes: []const Pane = &.{},
+};
+
+pub fn validate(state: State) !void {
+ if (!std.mem.eql(u8, state.magic, magic)) return error.BadDumpMagic;
+ if (state.version != version) return error.BadDumpVersion;
+ if (state.panes.len == 0 or state.panes.len > max_panes) return error.BadDumpPanes;
+ if (state.columns.len == 0 or state.columns.len > max_cols) return error.BadDumpColumns;
+ if (state.active >= state.panes.len) return error.BadDumpActive;
+ for (state.columns) |col| {
+ if (col.panes.len == 0 or col.panes.len > max_panes) return error.BadDumpColumns;
+ for (col.panes) |pane| {
+ if (pane >= state.panes.len) return error.BadDumpPaneRef;
+ }
+ }
+ for (state.panes) |pane| {
+ switch (pane.kind) {
+ .terminal => if (pane.terminal == null) return error.BadDumpPaneKind,
+ .file => if (pane.file == null) return error.BadDumpPaneKind,
+ .image => if (pane.image == null) return error.BadDumpPaneKind,
+ }
+ }
+}
+
+pub fn writeFile(io: std.Io, gpa: std.mem.Allocator, path: []const u8, state: State) !void {
+ try validate(state);
+ var out: std.Io.Writer.Allocating = .init(gpa);
+ defer out.deinit();
+ try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer);
+
+ var file = try std.Io.Dir.createFileAbsolute(io, path, .{});
+ defer file.close(io);
+ try file.writeStreamingAll(io, out.written());
+}
+
+pub fn readFile(io: std.Io, gpa: std.mem.Allocator, path: []const u8) !State {
+ const bytes = try std.Io.Dir.cwd().readFileAlloc(io, path, gpa, .limited(64 * 1024 * 1024));
+ defer gpa.free(bytes);
+ const source = try gpa.dupeZ(u8, bytes);
+ defer gpa.free(source);
+
+ var diag: std.zon.parse.Diagnostics = .{};
+ defer diag.deinit(gpa);
+ const state = std.zon.parse.fromSliceAlloc(State, gpa, source, &diag, .{}) catch |err| {
+ std.log.err("parse dump {s}: {f}", .{ path, diag });
+ return err;
+ };
+ errdefer std.zon.parse.free(gpa, state);
+ try validate(state);
+ return state;
+}
+
+pub fn free(gpa: std.mem.Allocator, state: State) void {
+ std.zon.parse.free(gpa, state);
+}
+
+pub fn encodeBytes(alloc: std.mem.Allocator, bytes: []const u8) ![]const u8 {
+ const enc = std.base64.standard.Encoder;
+ const out = try alloc.alloc(u8, enc.calcSize(bytes.len));
+ return enc.encode(out, bytes);
+}
+
+pub fn decodeBytes(alloc: std.mem.Allocator, b64: []const u8) ![]u8 {
+ const dec = std.base64.standard.Decoder;
+ const n = try dec.calcSizeForSlice(b64);
+ const out = try alloc.alloc(u8, n);
+ errdefer alloc.free(out);
+ try dec.decode(out, b64);
+ return out;
+}
+
+test "dump zon roundtrip" {
+ const gpa = std.testing.allocator;
+ const tty_b64 = try encodeBytes(gpa, "old\nhello\nworld");
+ defer gpa.free(tty_b64);
+ const file_b64 = try encodeBytes(gpa, "alpha\nbeta\n");
+ defer gpa.free(file_b64);
+ const panes = [_]Pane{
+ .{
+ .kind = .terminal,
+ .tag = "nm /tmp Del",
+ .body = "hello\nworld",
+ .scroll = 1,
+ .cols = 40,
+ .rows = 10,
+ .terminal = .{ .cwd = "/tmp", .stream = "old\nhello\nworld", .stream_b64 = tty_b64, .cursor = .{ .col = 3, .row = 2 } },
+ },
+ .{
+ .kind = .file,
+ .tag = "nm /tmp/a.txt Save Del",
+ .body = " 1 alpha",
+ .cols = 20,
+ .rows = 5,
+ .file = .{ .path = "/tmp/a.txt", .content = "alpha\nbeta\n", .content_b64 = file_b64 },
+ },
+ };
+ const col_panes = [_]usize{ 0, 1 };
+ const columns = [_]Column{.{ .weight = 1, .panes = &col_panes }};
+ const state: State = .{
+ .screen = .{ .cols = 80, .rows = 24 },
+ .topbar = "Kill Newcol Dump",
+ .theme = "dark",
+ .columns = &columns,
+ .panes = &panes,
+ };
+
+ var out: std.Io.Writer.Allocating = .init(gpa);
+ defer out.deinit();
+ try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer);
+ const source = try out.toOwnedSliceSentinel(0);
+ defer gpa.free(source);
+
+ var diag: std.zon.parse.Diagnostics = .{};
+ defer diag.deinit(gpa);
+ const parsed = try std.zon.parse.fromSliceAlloc(State, gpa, source, &diag, .{});
+ defer std.zon.parse.free(gpa, parsed);
+ try validate(parsed);
+ try std.testing.expectEqual(@as(usize, 2), parsed.panes.len);
+ try std.testing.expectEqualStrings("dark", parsed.theme);
+ try std.testing.expectEqualStrings("old\nhello\nworld", parsed.panes[0].terminal.?.stream);
+ try std.testing.expectEqualStrings("alpha\nbeta\n", parsed.panes[1].file.?.content);
+ {
+ const bytes = try decodeBytes(gpa, parsed.panes[0].terminal.?.stream_b64);
+ defer gpa.free(bytes);
+ try std.testing.expectEqualStrings("old\nhello\nworld", bytes);
+ }
+ {
+ const bytes = try decodeBytes(gpa, parsed.panes[1].file.?.content_b64);
+ defer gpa.free(bytes);
+ try std.testing.expectEqualStrings("alpha\nbeta\n", bytes);
+ }
+}
diff --git a/image.zig b/image.zig
index 4c83f8ad..79e7775f 100644
--- a/image.zig
+++ b/image.zig
@@ -78,7 +78,8 @@ pub fn deinitView(view: *View, gpa: std.mem.Allocator) void {
pub fn isImagePath(path: []const u8) bool {
const exts = [_][]const u8{
".png", ".jpg", ".jpeg", ".jpe", ".bmp", ".tga",
- ".gif", ".psd", ".hdr", ".pic", ".pnm", ".ppm", ".pgm",
+ ".gif", ".psd", ".hdr", ".pic", ".pnm", ".ppm",
+ ".pgm",
};
for (exts) |ext| {
if (path.len >= ext.len and std.ascii.eqlIgnoreCase(path[path.len - ext.len ..], ext)) return true;
@@ -86,17 +87,7 @@ pub fn isImagePath(path: []const u8) bool {
return false;
}
-// Decode (RGBA), downscale to MAX_DIM, keep the pixels for PETSCII, and — when the
-// terminal supports kitty graphics — transmit them once. Runs from the render loop,
-// which owns the tty writer. On failure `handle` stays null (PETSCII still works).
-pub fn ensureLoaded(view: *View, vx: *vaxis.Vaxis, gpa: std.mem.Allocator, writer: *std.Io.Writer) void {
- if (view.tried) return;
- view.tried = true;
- var pathbuf: [4096]u8 = undefined;
- const pz = std.fmt.bufPrintZ(&pathbuf, "{s}", .{view.path}) catch return;
- var img = zstbi.Image.loadFromFile(pz, 4) catch return; // force 4 components = RGBA
- defer img.deinit();
-
+fn keepDecodedImage(view: *View, vx: *vaxis.Vaxis, gpa: std.mem.Allocator, writer: *std.Io.Writer, img: *const zstbi.Image) void {
// downscale the longest side to MAX_DIM (keeping aspect) when it's bigger.
var scaled: ?zstbi.Image = null;
defer if (scaled) |*s| s.deinit();
@@ -106,7 +97,7 @@ pub fn ensureLoaded(view: *View, vx: *vaxis.Vaxis, gpa: std.mem.Allocator, write
const nh = @max(1, img.height * MAX_DIM / longest);
scaled = img.resize(nw, nh);
break :blk &scaled.?;
- } else &img;
+ } else img;
// keep a gpa-owned copy of the pixels for the PETSCII renderer.
view.rgba = gpa.dupe(u8, src.data) catch &.{};
@@ -123,6 +114,28 @@ pub fn ensureLoaded(view: *View, vx: *vaxis.Vaxis, gpa: std.mem.Allocator, write
view.handle = vx.transmitPreEncodedImage(writer, b64, @intCast(src.width), @intCast(src.height), .rgba) catch null;
}
+// Decode (RGBA), downscale to MAX_DIM, keep the pixels for PETSCII, and — when the
+// terminal supports kitty graphics — transmit them once. Runs from the render loop,
+// which owns the tty writer. On failure `handle` stays null (PETSCII still works).
+pub fn ensureLoaded(view: *View, vx: *vaxis.Vaxis, gpa: std.mem.Allocator, writer: *std.Io.Writer) void {
+ if (view.tried) return;
+ view.tried = true;
+ var pathbuf: [4096]u8 = undefined;
+ const pz = std.fmt.bufPrintZ(&pathbuf, "{s}", .{view.path}) catch return;
+ var img = zstbi.Image.loadFromFile(pz, 4) catch return; // force 4 components = RGBA
+ defer img.deinit();
+ keepDecodedImage(view, vx, gpa, writer, &img);
+}
+
+pub fn ensureLoadedFromMemory(view: *View, bytes: []const u8, vx: *vaxis.Vaxis, gpa: std.mem.Allocator, writer: *std.Io.Writer) void {
+ if (view.tried) return;
+ view.tried = true;
+ if (bytes.len == 0) return;
+ var img = zstbi.Image.loadFromMemory(bytes, 4) catch return; // force 4 components = RGBA
+ defer img.deinit();
+ keepDecodedImage(view, vx, gpa, writer, &img);
+}
+
// (Re)build the cached PETSCII grid for the current body size, if PETSCII drawing
// is active and the size changed. Runs from the render loop (which has gpa).
pub fn ensureGrid(view: *View, gpa: std.mem.Allocator, body_cols: u16, body_rows: u16) void {
diff --git a/main.zig b/main.zig
index d5cfa6c0..019d2a44 100644
--- a/main.zig
+++ b/main.zig
@@ -4,37 +4,8 @@ const ghostty_vt = @import("ghostty-vt");
const Term = @import("term.zig");
const modal = @import("modal.zig");
const image = @import("image.zig");
-const ts = @import("tree-sitter");
-const ts_queries = @import("ts_queries");
-
-// grammars whose parser.c we link (see build.zig). The query strings ride along
-// in the ts_queries module.
-extern fn tree_sitter_ada() callconv(.c) *const ts.Language;
-extern fn tree_sitter_bash() callconv(.c) *const ts.Language;
-extern fn tree_sitter_c() callconv(.c) *const ts.Language;
-extern fn tree_sitter_c_sharp() callconv(.c) *const ts.Language;
-extern fn tree_sitter_clojure() callconv(.c) *const ts.Language;
-extern fn tree_sitter_cpp() callconv(.c) *const ts.Language;
-extern fn tree_sitter_css() callconv(.c) *const ts.Language;
-extern fn tree_sitter_elixir() callconv(.c) *const ts.Language;
-extern fn tree_sitter_erlang() callconv(.c) *const ts.Language;
-extern fn tree_sitter_fortran() callconv(.c) *const ts.Language;
-extern fn tree_sitter_go() callconv(.c) *const ts.Language;
-extern fn tree_sitter_haskell() callconv(.c) *const ts.Language;
-extern fn tree_sitter_html() callconv(.c) *const ts.Language;
-extern fn tree_sitter_java() callconv(.c) *const ts.Language;
-extern fn tree_sitter_javascript() callconv(.c) *const ts.Language;
-extern fn tree_sitter_json() callconv(.c) *const ts.Language;
-extern fn tree_sitter_kotlin() callconv(.c) *const ts.Language;
-extern fn tree_sitter_ocaml() callconv(.c) *const ts.Language;
-extern fn tree_sitter_pascal() callconv(.c) *const ts.Language;
-extern fn tree_sitter_php() callconv(.c) *const ts.Language;
-extern fn tree_sitter_powershell() callconv(.c) *const ts.Language;
-extern fn tree_sitter_python() callconv(.c) *const ts.Language;
-extern fn tree_sitter_ruby() callconv(.c) *const ts.Language;
-extern fn tree_sitter_rust() callconv(.c) *const ts.Language;
-extern fn tree_sitter_scala() callconv(.c) *const ts.Language;
-extern fn tree_sitter_zig() callconv(.c) *const ts.Language;
+const dump = @import("dump.zig");
+const paint = @import("paint.zig");
// A TUI can't let std.log hit stderr — it's the same tty as our alt-screen, so the
// message lands on top of the UI (this is the stray text you saw when opening yazi:
@@ -224,10 +195,8 @@ const gray = struct {
const lit = [3]u8{ 0x6e, 0x6e, 0x6e }; // dashed resize preview
const txt = [3]u8{ 0x96, 0x96, 0x96 }; // muted text
};
-// the drag button: an indigo-purple accent (brighter when the window is focused),
-// dimming to a dark grey-blue when idle.
-const accent = [3]u8{ 0x6e, 0x4f, 0xd0 };
-const accent_dim = [3]u8{ 0x38, 0x3c, 0x54 };
+const accent = paint.accent;
+const accent_dim = paint.accent_dim;
// per-button selection highlight: distinct (muted) colors so left/middle/right
// selections are easy to tell apart against the otherwise-monochrome chrome.
const SEL_BG = [3][3]u8{ .{ 0x2c, 0x46, 0x74 }, .{ 0x2c, 0x60, 0x38 }, .{ 0x64, 0x2c, 0x58 } };
@@ -237,6 +206,13 @@ const sel_fg = [3]u8{ 0xd2, 0xd2, 0xd2 };
const msel_bg = [3]u8{ 0x4a, 0x3a, 0x1e };
const msel_fg = [3]u8{ 0xd8, 0xc8, 0xa8 };
+const T = paint.T;
+const rgbColor = paint.rgbColor;
+const colorOpt = paint.colorOpt;
+const synStyle = paint.synStyle;
+const highlightFile = paint.highlightFile;
+const cellStyle = paint.cellStyle;
+
// Master switch for the per-cell recolor passes (tty ansi + file syntax). Off =
// the plain-text render path, exactly as before colors existed; the recolor loops
// are skipped so there's no per-frame cost. Toggled by the "Colors" tagbar command.
@@ -246,99 +222,13 @@ var colors_on: bool = true;
// central command dispatcher can flip it; the render reads it.
var show_debug: bool = false;
-// Color themes. Chrome (scrollbar/box/selection/edit colors above) stays fixed;
-// only the page background, text, tag bar and syntax colors swap. `bg`/`fg` are
-// null in the dark theme so it keeps the terminal's own default colors (i.e. looks
-// exactly as it did before themes existed); the acme light theme paints an explicit
-// pale-yellow page with black text. Cycle with the "NextColor" tagbar command.
-const Theme = struct {
- name: []const u8,
- bg: ?[3]u8, // body background (null = terminal default, no paint)
- fg: ?[3]u8, // body text (null = terminal default)
- tag_bg: [3]u8,
- tag_fg: [3]u8,
- // syntax (keyword + comment render bold)
- kw: [3]u8,
- str: [3]u8,
- num: [3]u8,
- comment: [3]u8,
- lineno: [3]u8, // muted file-pane line-number gutter
- scroll_track: [3]u8, // scrollbar strip
- scroll_thumb: [3]u8, // scrollbar visible-portion indicator
- // the 16 ANSI colors for terminal-pane output; null = pass palette indices
- // through to the real terminal (dark keeps the terminal's own colors).
- palette: ?[16][3]u8,
-};
-const themes = [_]Theme{
- .{
- .name = "dark",
- .bg = null,
- .fg = null,
- .tag_bg = .{ 0x2c, 0x2a, 0x3e }, // dim gray, slight indigo tint
- .tag_fg = .{ 0x96, 0x96, 0x96 },
- .kw = .{ 0xcc, 0x70, 0xd8 }, // magenta
- .str = .{ 0x8a, 0xb8, 0x7a }, // green
- .num = .{ 0xd0, 0xc0, 0x70 }, // yellow
- .comment = .{ 0xf0, 0xf0, 0xf0 }, // bold white
- .lineno = .{ 0x5a, 0x5a, 0x5a }, // dim gray gutter
- .scroll_track = .{ 0x2a, 0x2a, 0x2a }, // gray.bar (unchanged dark scrollbar)
- .scroll_thumb = .{ 0x52, 0x52, 0x52 }, // gray.mid
- .palette = null, // terminal-native ANSI colors
- },
- .{
- .name = "acme",
- .bg = .{ 0xff, 0xff, 0xea }, // classic acme pale yellow
- .fg = .{ 0x00, 0x00, 0x00 },
- .tag_bg = .{ 0xea, 0xff, 0xff }, // acme pale cyan
- .tag_fg = .{ 0x00, 0x00, 0x00 },
- .kw = .{ 0x88, 0x22, 0x99 },
- .str = .{ 0x00, 0x77, 0x33 },
- .num = .{ 0xa0, 0x52, 0x00 },
- .comment = .{ 0x55, 0x55, 0x55 }, // bold, darkened to read on yellow
- .lineno = .{ 0x99, 0x99, 0x80 }, // muted gutter, reads on the pale page
- .scroll_track = .{ 0x99, 0x99, 0x4c }, // acme olive strip (BORD)
- .scroll_thumb = .{ 0xff, 0xff, 0xea }, // acme pale-yellow scroller (BACK)
- // darkened ANSI palette so shell output (ls colors, prompts) reads on the
- // pale-yellow page; 7/15 (the "whites") map to gray/black, not invisible.
- .palette = .{
- .{ 0x00, 0x00, 0x00 }, .{ 0xaa, 0x22, 0x22 }, .{ 0x22, 0x80, 0x22 }, .{ 0x88, 0x66, 0x00 },
- .{ 0x22, 0x44, 0xbb }, .{ 0x99, 0x33, 0x99 }, .{ 0x11, 0x88, 0x88 }, .{ 0x55, 0x55, 0x55 },
- .{ 0x88, 0x88, 0x88 }, .{ 0xcc, 0x33, 0x33 }, .{ 0x33, 0xaa, 0x33 }, .{ 0xaa, 0x77, 0x00 },
- .{ 0x33, 0x55, 0xdd }, .{ 0xbb, 0x44, 0xbb }, .{ 0x22, 0xaa, 0xaa }, .{ 0x00, 0x00, 0x00 },
- },
- },
-};
-var theme_idx: usize = 0;
-fn T() *const Theme {
- return &themes[theme_idx];
-}
-fn rgbColor(c: [3]u8) vaxis.Color {
- return .{ .rgb = c };
-}
-fn colorOpt(c: ?[3]u8) vaxis.Color {
- return if (c) |x| .{ .rgb = x } else .default;
-}
-
-// tree-sitter syntax classes; also the per-byte style id stored in File.highlights
-// (0 = none). synStyle maps each to the active theme's color (null for .none).
-const Syn = enum(u8) { none, keyword, string, number, comment };
-fn synStyle(s: Syn) ?struct { fg: [3]u8, bold: bool } {
- return switch (s) {
- .none => null,
- .keyword => .{ .fg = T().kw, .bold = true },
- .string => .{ .fg = T().str, .bold = false },
- .number => .{ .fg = T().num, .bold = false },
- .comment => .{ .fg = T().comment, .bold = true },
- };
-}
-
// Builtins. Executing the name (middle-click / Tab) runs the action through the ONE
// dispatcher (onSelect + actOnSelection), no matter where the name is shown — a pane
// body, a file, the tagbar. onSelect matches against the whole list, so a name works
// wherever it appears. Window-specific ones (Save, Del, Delcol) live in each
// pane's tag or can be typed/executed; window-agnostic ones (Kill, Newcol, Tutor,
// Debug, Colors, NextColor) in the tagbar.
-const SelectAction = enum { none, send, newcol, delcol, del, save, tutor, kill, debug, colors, nextcolor };
+const SelectAction = enum { none, send, newcol, delcol, del, save, tutor, kill, debug, colors, nextcolor, dump };
const Builtin = struct { name: []const u8, action: SelectAction };
const builtins = [_]Builtin{
.{ .name = "Kill", .action = .kill },
@@ -347,6 +237,7 @@ const builtins = [_]Builtin{
.{ .name = "Debug", .action = .debug },
.{ .name = "Colors", .action = .colors },
.{ .name = "NextColor", .action = .nextcolor },
+ .{ .name = "Dump", .action = .dump },
.{ .name = "Save", .action = .save },
.{ .name = "Del", .action = .del },
.{ .name = "Delcol", .action = .delcol },
@@ -360,7 +251,7 @@ const pane_tail = " " ++ pane_builtins_str;
const file_pane_tail = " " ++ file_pane_builtins_str;
// the global tagbar: window-agnostic commands. Kill closes everything; Debug toggles
// the stats overlay, Colors the ansi/syntax recolor, NextColor cycles the theme.
-const topbar_str = "Kill Newcol Tutor Debug Colors NextColor";
+const topbar_str = "Kill Newcol Tutor Debug Colors NextColor Dump";
// Path of the generated bash rc that adds OSC 133 semantic prompts (shell
// integration). bash --rcfile reads it for interactive shells; it sources the
@@ -788,6 +679,7 @@ fn feedPtyRead(gpa: std.mem.Allocator, terms: *[MAX_TERMS]?*Term, active: usize,
defer gpa.free(pr.bytes);
if (pr.id >= MAX_TERMS) return;
if (terms[pr.id]) |t| {
+ t.tty_stream.appendSlice(gpa, pr.bytes) catch {};
t.stream.nextSlice(pr.bytes);
// follow new output to the bottom, so a command's result shows even in a
// pane you didn't focus. Exception: the pane you're actively navigating
@@ -888,154 +780,6 @@ fn readFileAlloc(gpa: std.mem.Allocator, path: [*:0]const u8) ![]u8 {
return buf.toOwnedSlice(gpa);
}
-// Map a tree-sitter capture name (e.g. "keyword.function", "string.special") to a
-// syntax color, by substring — the grammars' highlights.scm use many sub-captures
-// we don't care to distinguish. Returns .none for captures we don't color.
-fn synFor(name: []const u8) Syn {
- for ([_]struct { []const u8, Syn }{
- .{ "comment", .comment },
- .{ "string", .string },
- .{ "character", .string },
- .{ "number", .number },
- .{ "float", .number },
- .{ "boolean", .number },
- .{ "keyword", .keyword },
- .{ "include", .keyword },
- .{ "operator", .keyword },
- .{ "conditional", .keyword },
- .{ "repeat", .keyword },
- }) |m| {
- if (std.mem.indexOf(u8, name, m[0]) != null) return m[1];
- }
- return .none;
-}
-
-fn extIs(ext: []const u8, comptime choices: anytype) bool {
- inline for (choices) |choice| {
- if (std.ascii.eqlIgnoreCase(ext, choice)) return true;
- }
- return false;
-}
-
-// Syntax-highlight `content` with tree-sitter: returns a gpa-owned byte per content
-// byte (a `Syn` id; 0 = none), or empty when the extension has no grammar. Caller
-// frees (stored in File.highlights; see term.zig). Best-effort — any parse/query
-// failure just yields fewer styled bytes, never an error.
-fn highlightFile(gpa: std.mem.Allocator, path: []const u8, content: []const u8) ![]u8 {
- const ext = std.fs.path.extension(path);
- var lang: *const ts.Language = undefined;
- var query_src: []const u8 = undefined;
- if (extIs(ext, .{ ".adb", ".ads", ".ada" })) {
- lang = tree_sitter_ada();
- query_src = ts_queries.ada_highlights;
- } else if (extIs(ext, .{ ".sh", ".bash", ".zsh" })) {
- lang = tree_sitter_bash();
- query_src = ts_queries.bash_highlights;
- } else if (extIs(ext, .{ ".c", ".h" })) {
- lang = tree_sitter_c();
- query_src = ts_queries.c_highlights;
- } else if (extIs(ext, .{ ".cs", ".csx" })) {
- lang = tree_sitter_c_sharp();
- query_src = ts_queries.c_sharp_highlights;
- } else if (extIs(ext, .{ ".clj", ".cljs", ".cljc", ".edn" })) {
- lang = tree_sitter_clojure();
- query_src = ts_queries.clojure_highlights;
- } else if (extIs(ext, .{ ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx" })) {
- lang = tree_sitter_cpp();
- query_src = ts_queries.cpp_highlights;
- } else if (extIs(ext, .{".css"})) {
- lang = tree_sitter_css();
- query_src = ts_queries.css_highlights;
- } else if (extIs(ext, .{ ".ex", ".exs" })) {
- lang = tree_sitter_elixir();
- query_src = ts_queries.elixir_highlights;
- } else if (extIs(ext, .{ ".erl", ".hrl" })) {
- lang = tree_sitter_erlang();
- query_src = ts_queries.erlang_highlights;
- } else if (extIs(ext, .{ ".f", ".for", ".ftn", ".f90", ".f95", ".f03", ".f08" })) {
- lang = tree_sitter_fortran();
- query_src = ts_queries.fortran_highlights;
- } else if (extIs(ext, .{".go"})) {
- lang = tree_sitter_go();
- query_src = ts_queries.go_highlights;
- } else if (extIs(ext, .{ ".hs", ".lhs" })) {
- lang = tree_sitter_haskell();
- query_src = ts_queries.haskell_highlights;
- } else if (extIs(ext, .{ ".html", ".htm" })) {
- lang = tree_sitter_html();
- query_src = ts_queries.html_highlights;
- } else if (extIs(ext, .{".java"})) {
- lang = tree_sitter_java();
- query_src = ts_queries.java_highlights;
- } else if (extIs(ext, .{ ".js", ".jsx", ".mjs", ".cjs" })) {
- lang = tree_sitter_javascript();
- query_src = ts_queries.javascript_highlights;
- } else if (extIs(ext, .{".json"})) {
- lang = tree_sitter_json();
- query_src = ts_queries.json_highlights;
- } else if (extIs(ext, .{ ".kt", ".kts" })) {
- lang = tree_sitter_kotlin();
- query_src = ts_queries.kotlin_highlights;
- } else if (extIs(ext, .{ ".ml", ".mli" })) {
- lang = tree_sitter_ocaml();
- query_src = ts_queries.ocaml_highlights;
- } else if (extIs(ext, .{ ".pas", ".pp", ".p" })) {
- lang = tree_sitter_pascal();
- query_src = ts_queries.pascal_highlights;
- } else if (extIs(ext, .{ ".php", ".phtml", ".php3", ".php4", ".php5" })) {
- lang = tree_sitter_php();
- query_src = ts_queries.php_highlights;
- } else if (extIs(ext, .{ ".ps1", ".psm1", ".psd1" })) {
- lang = tree_sitter_powershell();
- query_src = ts_queries.powershell_highlights;
- } else if (extIs(ext, .{ ".py", ".pyw" })) {
- lang = tree_sitter_python();
- query_src = ts_queries.python_highlights;
- } else if (extIs(ext, .{ ".rb", ".rake" })) {
- lang = tree_sitter_ruby();
- query_src = ts_queries.ruby_highlights;
- } else if (extIs(ext, .{".rs"})) {
- lang = tree_sitter_rust();
- query_src = ts_queries.rust_highlights;
- } else if (extIs(ext, .{ ".scala", ".sc" })) {
- lang = tree_sitter_scala();
- query_src = ts_queries.scala_highlights;
- } else if (extIs(ext, .{ ".zig", ".zon" })) {
- lang = tree_sitter_zig();
- query_src = ts_queries.zig_highlights;
- } else return &.{};
- // note: no lang.destroy() — tree_sitter_*() return static singletons reused on
- // every open; deleting one would use-after-free the next file's parse.
-
- const styles = try gpa.alloc(u8, content.len);
- errdefer gpa.free(styles);
- @memset(styles, 0);
-
- const parser = ts.Parser.create();
- defer parser.destroy();
- parser.setLanguage(lang) catch return styles;
- const tree = parser.parseString(content, null) orelse return styles;
- defer tree.destroy();
-
- var error_offset: u32 = 0;
- const query = ts.Query.create(lang, query_src, &error_offset) catch return styles;
- defer query.destroy();
- const cursor = ts.QueryCursor.create();
- defer cursor.destroy();
- cursor.exec(query, tree.rootNode());
-
- while (cursor.nextMatch()) |match| {
- for (match.captures) |cap| {
- const syn = synFor(query.captureNameForId(cap.index) orelse "");
- if (syn == .none) continue;
- var b: usize = cap.node.startByte();
- const end = @min(@as(usize, cap.node.endByte()), styles.len);
- while (b < end) : (b += 1) styles[b] = @intFromEnum(syn);
- }
- }
- return styles;
-}
-
// Replace a file pane's content (freeing the old buffer) and re-derive syntax
// highlighting to match. Every file edit funnels through here, so highlights stay
// in sync with content (and can never index past it). The reparse is skipped when
@@ -1347,60 +1091,6 @@ fn computeGeom(
}
}
-// A ghostty palette index → vaxis color. With a themed palette (light) the 16
-// ANSI colors resolve to the theme's rgb so shell output matches the page; without
-// one (dark) they pass through as palette indices for the real terminal to render.
-fn palColor(idx: u8) vaxis.Color {
- if (T().palette) |p| if (idx < 16) return rgbColor(p[idx]);
- return .{ .index = idx };
-}
-
-// Translate a ghostty cell color to vaxis. `default` (no explicit color) resolves
-// to the theme's page fg/bg — so on the acme theme the terminal's own default text
-// becomes black-on-yellow instead of the real terminal's colors. dark keeps null
-// (→ vaxis .default → terminal native, unchanged).
-fn ghostColor(color: ghostty_vt.Style.Color, is_bg: bool) vaxis.Color {
- return switch (color) {
- .none => colorOpt(if (is_bg) T().bg else T().fg),
- .palette => |idx| palColor(idx),
- .rgb => |rgb| .{ .rgb = .{ rgb.r, rgb.g, rgb.b } },
- };
-}
-
-// The vaxis style for one emulator cell: fg/bg/attrs translated from ghostty.
-fn cellStyle(ci: ghostty_vt.PageList.Cell) vaxis.Style {
- const style = ci.style();
- var vs: vaxis.Style = .{
- .fg = ghostColor(style.fg_color, false),
- .bg = ghostColor(style.bg_color, true),
- .bold = style.flags.bold,
- .dim = style.flags.faint,
- .italic = style.flags.italic,
- .blink = style.flags.blink,
- .reverse = style.flags.inverse,
- .invisible = style.flags.invisible,
- .strikethrough = style.flags.strikethrough,
- .ul_style = switch (style.flags.underline) {
- .none => .off,
- .single => .single,
- .double => .double,
- .curly => .curly,
- .dotted => .dotted,
- .dashed => .dashed,
- },
- };
- // a cell can carry its bg directly (palette/rgb) rather than via its style.
- switch (ci.cell.content_tag) {
- .bg_color_palette => vs.bg = palColor(ci.cell.content.color_palette.data),
- .bg_color_rgb => {
- const rgb = ci.cell.content.color_rgb;
- vs.bg = .{ .rgb = .{ rgb.r, rgb.g, rgb.b } };
- },
- else => {},
- }
- return vs;
-}
-
// `alloc` must be a per-frame arena, NOT freed before vx.render(): vaxis is a
// retained renderer and stores each cell's grapheme as a *slice* into the text
// we hand it, so plainString has to outlive the render call.
@@ -2833,7 +2523,10 @@ fn actOnSelection(
.none => {},
.debug => show_debug = !show_debug,
.colors => colors_on = !colors_on,
- .nextcolor => theme_idx = (theme_idx + 1) % themes.len,
+ .nextcolor => paint.nextTheme(),
+ .dump => dumpSnapshot(io, gpa, terms, layout, active.*, sw, sh) catch |err| {
+ std.log.warn("dump {s}: {}", .{ dump.default_path, err });
+ },
.kill => return true, // close everything: signal the main loop to quit
.save => {
if (t.file) |*f| saveFile(io, f) catch |err| {
@@ -3863,6 +3556,133 @@ fn bodyText(alloc: std.mem.Allocator, t: *Term) ![]u8 {
return out.toOwnedSlice(alloc);
}
+fn terminalReplayText(alloc: std.mem.Allocator, t: *Term) ![]u8 {
+ const full = try t.term.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} });
+ defer alloc.free(full);
+
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(alloc);
+ var lines = std.mem.splitAny(u8, full, "\n");
+ var prompts = t.term.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null);
+ var row: usize = 0;
+ while (lines.next()) |raw| : (row += 1) {
+ if (row > 0) try out.append(alloc, '\n');
+ const is_prompt = if (t.mode != .tty)
+ if (prompts.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false
+ else
+ false;
+ const line = if (is_prompt) "" else raw;
+ if (t.mode == .tty) {
+ try out.appendSlice(alloc, line);
+ } else {
+ try spliceRow(alloc, &out, t, @intCast(row), line);
+ }
+ }
+ return out.toOwnedSlice(alloc);
+}
+
+fn dumpSnapshot(
+ io: std.Io,
+ gpa: std.mem.Allocator,
+ terms: *[MAX_TERMS]?*Term,
+ layout: *const Layout,
+ active: usize,
+ sw: u16,
+ sh: u16,
+) !void {
+ var arena: std.heap.ArenaAllocator = .init(gpa);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var slot_to_pane: [MAX_TERMS]?usize = @splat(null);
+ var panes: std.ArrayList(dump.Pane) = .empty;
+ for (terms.*, 0..) |slot, id| {
+ const t = slot orelse continue;
+ const compact = panes.items.len;
+ slot_to_pane[id] = compact;
+
+ const tag = try fullTag(alloc, t);
+ const body = try bodyText(alloc, t);
+ const scroll: usize = @intCast(@max(0, paneScroll(t)));
+ const pane: dump.Pane = if (t.file) |f| .{
+ .kind = .file,
+ .tag = tag,
+ .body = body,
+ .scroll = scroll,
+ .cols = t.cols,
+ .rows = t.rows,
+ .vweight = t.vweight,
+ .file = .{
+ .path = f.path,
+ .content = f.content,
+ .content_b64 = try dump.encodeBytes(alloc, f.content),
+ },
+ } else if (t.image) |iv| blk: {
+ const pz = try std.fmt.allocPrintSentinel(alloc, "{s}", .{iv.path}, 0);
+ const bytes = readFileAlloc(alloc, pz) catch "";
+ break :blk .{
+ .kind = .image,
+ .tag = tag,
+ .body = body,
+ .scroll = scroll,
+ .cols = t.cols,
+ .rows = t.rows,
+ .vweight = t.vweight,
+ .image = .{
+ .path = iv.path,
+ .bytes_b64 = if (bytes.len > 0) try dump.encodeBytes(alloc, bytes) else "",
+ },
+ };
+ } else blk: {
+ var cwdbuf: [1024]u8 = undefined;
+ const cwd = try alloc.dupe(u8, termCwd(t, &cwdbuf));
+ const stream = try terminalReplayText(alloc, t);
+ break :blk .{
+ .kind = .terminal,
+ .tag = tag,
+ .body = body,
+ .scroll = scroll,
+ .cols = t.cols,
+ .rows = t.rows,
+ .vweight = t.vweight,
+ .terminal = .{
+ .cwd = cwd,
+ .stream = stream,
+ .stream_b64 = try dump.encodeBytes(alloc, t.tty_stream.items),
+ .cursor = .{
+ .col = t.term.screens.active.cursor.x,
+ .row = t.term.screens.active.cursor.y,
+ },
+ },
+ };
+ };
+ try panes.append(alloc, pane);
+ }
+
+ var columns: std.ArrayList(dump.Column) = .empty;
+ var c: usize = 0;
+ while (c < layout.ncol) : (c += 1) {
+ var ids: std.ArrayList(usize) = .empty;
+ var k: usize = 0;
+ while (k < layout.col_n[c]) : (k += 1) {
+ const old_id = layout.col_terms[c][k];
+ if (slot_to_pane[old_id]) |compact| try ids.append(alloc, compact);
+ }
+ if (ids.items.len > 0)
+ try columns.append(alloc, .{ .weight = layout.col_weight[c], .panes = try ids.toOwnedSlice(alloc) });
+ }
+
+ const state: dump.State = .{
+ .screen = .{ .cols = sw, .rows = sh },
+ .active = slot_to_pane[active] orelse 0,
+ .topbar = topbar_str,
+ .theme = paint.currentThemeName(),
+ .columns = try columns.toOwnedSlice(alloc),
+ .panes = try panes.toOwnedSlice(alloc),
+ };
+ try dump.writeFile(io, gpa, dump.default_path, state);
+}
+
// The full rendered pane: tag at visual row 0, spliced body below. Caller frees.
fn paneText(gpa: std.mem.Allocator, t: *Term) ![]u8 {
const tag = try fullTag(gpa, t);
diff --git a/paint.zig b/paint.zig
new file mode 100644
index 00000000..d8526cb4
--- /dev/null
+++ b/paint.zig
@@ -0,0 +1,324 @@
+const std = @import("std");
+const ui = @import("ui");
+const app_config = @import("app_config");
+const ghostty_vt = @import("ghostty-vt");
+const ts = @import("tree-sitter");
+const ts_queries = @import("ts_queries");
+
+extern fn tree_sitter_ada() callconv(.c) *const ts.Language;
+extern fn tree_sitter_bash() callconv(.c) *const ts.Language;
+extern fn tree_sitter_c() callconv(.c) *const ts.Language;
+extern fn tree_sitter_c_sharp() callconv(.c) *const ts.Language;
+extern fn tree_sitter_clojure() callconv(.c) *const ts.Language;
+extern fn tree_sitter_cpp() callconv(.c) *const ts.Language;
+extern fn tree_sitter_css() callconv(.c) *const ts.Language;
+extern fn tree_sitter_elixir() callconv(.c) *const ts.Language;
+extern fn tree_sitter_erlang() callconv(.c) *const ts.Language;
+extern fn tree_sitter_fortran() callconv(.c) *const ts.Language;
+extern fn tree_sitter_go() callconv(.c) *const ts.Language;
+extern fn tree_sitter_haskell() callconv(.c) *const ts.Language;
+extern fn tree_sitter_html() callconv(.c) *const ts.Language;
+extern fn tree_sitter_java() callconv(.c) *const ts.Language;
+extern fn tree_sitter_javascript() callconv(.c) *const ts.Language;
+extern fn tree_sitter_json() callconv(.c) *const ts.Language;
+extern fn tree_sitter_kotlin() callconv(.c) *const ts.Language;
+extern fn tree_sitter_ocaml() callconv(.c) *const ts.Language;
+extern fn tree_sitter_pascal() callconv(.c) *const ts.Language;
+extern fn tree_sitter_php() callconv(.c) *const ts.Language;
+extern fn tree_sitter_powershell() callconv(.c) *const ts.Language;
+extern fn tree_sitter_python() callconv(.c) *const ts.Language;
+extern fn tree_sitter_ruby() callconv(.c) *const ts.Language;
+extern fn tree_sitter_rust() callconv(.c) *const ts.Language;
+extern fn tree_sitter_scala() callconv(.c) *const ts.Language;
+extern fn tree_sitter_zig() callconv(.c) *const ts.Language;
+
+pub const accent = if (app_config.app_kind == .replay) [3]u8{ 0xc8, 0x58, 0x58 } else [3]u8{ 0x6e, 0x4f, 0xd0 };
+pub const accent_dim = if (app_config.app_kind == .replay) [3]u8{ 0x54, 0x38, 0x38 } else [3]u8{ 0x38, 0x3c, 0x54 };
+const dark_tag_bg = if (app_config.app_kind == .replay) [3]u8{ 0x3e, 0x2a, 0x2a } else [3]u8{ 0x2c, 0x2a, 0x3e };
+
+pub const Theme = struct {
+ name: []const u8,
+ bg: ?[3]u8,
+ fg: ?[3]u8,
+ tag_bg: [3]u8,
+ tag_fg: [3]u8,
+ kw: [3]u8,
+ str: [3]u8,
+ num: [3]u8,
+ comment: [3]u8,
+ lineno: [3]u8,
+ scroll_track: [3]u8,
+ scroll_thumb: [3]u8,
+ palette: ?[16][3]u8,
+};
+
+pub const themes = [_]Theme{
+ .{
+ .name = "dark",
+ .bg = null,
+ .fg = null,
+ .tag_bg = dark_tag_bg,
+ .tag_fg = .{ 0x96, 0x96, 0x96 },
+ .kw = .{ 0xcc, 0x70, 0xd8 },
+ .str = .{ 0x8a, 0xb8, 0x7a },
+ .num = .{ 0xd0, 0xc0, 0x70 },
+ .comment = .{ 0xf0, 0xf0, 0xf0 },
+ .lineno = .{ 0x5a, 0x5a, 0x5a },
+ .scroll_track = .{ 0x2a, 0x2a, 0x2a },
+ .scroll_thumb = .{ 0x52, 0x52, 0x52 },
+ .palette = null,
+ },
+ .{
+ .name = "acme",
+ .bg = .{ 0xff, 0xff, 0xea },
+ .fg = .{ 0x00, 0x00, 0x00 },
+ .tag_bg = .{ 0xea, 0xff, 0xff },
+ .tag_fg = .{ 0x00, 0x00, 0x00 },
+ .kw = .{ 0x88, 0x22, 0x99 },
+ .str = .{ 0x00, 0x77, 0x33 },
+ .num = .{ 0xa0, 0x52, 0x00 },
+ .comment = .{ 0x55, 0x55, 0x55 },
+ .lineno = .{ 0x99, 0x99, 0x80 },
+ .scroll_track = .{ 0x99, 0x99, 0x4c },
+ .scroll_thumb = .{ 0xff, 0xff, 0xea },
+ .palette = .{
+ .{ 0x00, 0x00, 0x00 }, .{ 0xaa, 0x22, 0x22 }, .{ 0x22, 0x80, 0x22 }, .{ 0x88, 0x66, 0x00 },
+ .{ 0x22, 0x44, 0xbb }, .{ 0x99, 0x33, 0x99 }, .{ 0x11, 0x88, 0x88 }, .{ 0x55, 0x55, 0x55 },
+ .{ 0x88, 0x88, 0x88 }, .{ 0xcc, 0x33, 0x33 }, .{ 0x33, 0xaa, 0x33 }, .{ 0xaa, 0x77, 0x00 },
+ .{ 0x33, 0x55, 0xdd }, .{ 0xbb, 0x44, 0xbb }, .{ 0x22, 0xaa, 0xaa }, .{ 0x00, 0x00, 0x00 },
+ },
+ },
+};
+
+var theme_idx: usize = 0;
+
+pub fn T() *const Theme {
+ return &themes[theme_idx];
+}
+
+pub fn nextTheme() void {
+ theme_idx = (theme_idx + 1) % themes.len;
+}
+
+pub fn currentThemeName() []const u8 {
+ return T().name;
+}
+
+pub fn setThemeByName(name: []const u8) bool {
+ for (themes, 0..) |theme, i| {
+ if (std.mem.eql(u8, theme.name, name)) {
+ theme_idx = i;
+ return true;
+ }
+ }
+ return false;
+}
+
+pub fn rgbColor(c: [3]u8) ui.Color {
+ return .{ .rgb = c };
+}
+
+pub fn colorOpt(c: ?[3]u8) ui.Color {
+ return if (c) |x| .{ .rgb = x } else .default;
+}
+
+pub const Syn = enum(u8) { none, keyword, string, number, comment };
+
+pub const SynStyle = struct {
+ fg: [3]u8,
+ bold: bool,
+};
+
+pub fn synStyle(s: Syn) ?SynStyle {
+ return switch (s) {
+ .none => null,
+ .keyword => .{ .fg = T().kw, .bold = true },
+ .string => .{ .fg = T().str, .bold = false },
+ .number => .{ .fg = T().num, .bold = false },
+ .comment => .{ .fg = T().comment, .bold = true },
+ };
+}
+
+fn synFor(name: []const u8) Syn {
+ for ([_]struct { []const u8, Syn }{
+ .{ "comment", .comment },
+ .{ "string", .string },
+ .{ "character", .string },
+ .{ "number", .number },
+ .{ "float", .number },
+ .{ "boolean", .number },
+ .{ "keyword", .keyword },
+ .{ "include", .keyword },
+ .{ "operator", .keyword },
+ .{ "conditional", .keyword },
+ .{ "repeat", .keyword },
+ }) |m| {
+ if (std.mem.indexOf(u8, name, m[0]) != null) return m[1];
+ }
+ return .none;
+}
+
+fn extIs(ext: []const u8, comptime choices: anytype) bool {
+ inline for (choices) |choice| {
+ if (std.ascii.eqlIgnoreCase(ext, choice)) return true;
+ }
+ return false;
+}
+
+pub fn highlightFile(gpa: std.mem.Allocator, path: []const u8, content: []const u8) ![]u8 {
+ const ext = std.fs.path.extension(path);
+ var lang: *const ts.Language = undefined;
+ var query_src: []const u8 = undefined;
+ if (extIs(ext, .{ ".adb", ".ads", ".ada" })) {
+ lang = tree_sitter_ada();
+ query_src = ts_queries.ada_highlights;
+ } else if (extIs(ext, .{ ".sh", ".bash", ".zsh" })) {
+ lang = tree_sitter_bash();
+ query_src = ts_queries.bash_highlights;
+ } else if (extIs(ext, .{ ".c", ".h" })) {
+ lang = tree_sitter_c();
+ query_src = ts_queries.c_highlights;
+ } else if (extIs(ext, .{ ".cs", ".csx" })) {
+ lang = tree_sitter_c_sharp();
+ query_src = ts_queries.c_sharp_highlights;
+ } else if (extIs(ext, .{ ".clj", ".cljs", ".cljc", ".edn" })) {
+ lang = tree_sitter_clojure();
+ query_src = ts_queries.clojure_highlights;
+ } else if (extIs(ext, .{ ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx" })) {
+ lang = tree_sitter_cpp();
+ query_src = ts_queries.cpp_highlights;
+ } else if (extIs(ext, .{".css"})) {
+ lang = tree_sitter_css();
+ query_src = ts_queries.css_highlights;
+ } else if (extIs(ext, .{ ".ex", ".exs" })) {
+ lang = tree_sitter_elixir();
+ query_src = ts_queries.elixir_highlights;
+ } else if (extIs(ext, .{ ".erl", ".hrl" })) {
+ lang = tree_sitter_erlang();
+ query_src = ts_queries.erlang_highlights;
+ } else if (extIs(ext, .{ ".f", ".for", ".ftn", ".f90", ".f95", ".f03", ".f08" })) {
+ lang = tree_sitter_fortran();
+ query_src = ts_queries.fortran_highlights;
+ } else if (extIs(ext, .{".go"})) {
+ lang = tree_sitter_go();
+ query_src = ts_queries.go_highlights;
+ } else if (extIs(ext, .{ ".hs", ".lhs" })) {
+ lang = tree_sitter_haskell();
+ query_src = ts_queries.haskell_highlights;
+ } else if (extIs(ext, .{ ".html", ".htm" })) {
+ lang = tree_sitter_html();
+ query_src = ts_queries.html_highlights;
+ } else if (extIs(ext, .{".java"})) {
+ lang = tree_sitter_java();
+ query_src = ts_queries.java_highlights;
+ } else if (extIs(ext, .{ ".js", ".jsx", ".mjs", ".cjs" })) {
+ lang = tree_sitter_javascript();
+ query_src = ts_queries.javascript_highlights;
+ } else if (extIs(ext, .{".json"})) {
+ lang = tree_sitter_json();
+ query_src = ts_queries.json_highlights;
+ } else if (extIs(ext, .{ ".kt", ".kts" })) {
+ lang = tree_sitter_kotlin();
+ query_src = ts_queries.kotlin_highlights;
+ } else if (extIs(ext, .{ ".ml", ".mli" })) {
+ lang = tree_sitter_ocaml();
+ query_src = ts_queries.ocaml_highlights;
+ } else if (extIs(ext, .{ ".pas", ".pp", ".p" })) {
+ lang = tree_sitter_pascal();
+ query_src = ts_queries.pascal_highlights;
+ } else if (extIs(ext, .{ ".php", ".phtml", ".php3", ".php4", ".php5" })) {
+ lang = tree_sitter_php();
+ query_src = ts_queries.php_highlights;
+ } else if (extIs(ext, .{ ".ps1", ".psm1", ".psd1" })) {
+ lang = tree_sitter_powershell();
+ query_src = ts_queries.powershell_highlights;
+ } else if (extIs(ext, .{ ".py", ".pyw" })) {
+ lang = tree_sitter_python();
+ query_src = ts_queries.python_highlights;
+ } else if (extIs(ext, .{ ".rb", ".rake" })) {
+ lang = tree_sitter_ruby();
+ query_src = ts_queries.ruby_highlights;
+ } else if (extIs(ext, .{".rs"})) {
+ lang = tree_sitter_rust();
+ query_src = ts_queries.rust_highlights;
+ } else if (extIs(ext, .{ ".scala", ".sc" })) {
+ lang = tree_sitter_scala();
+ query_src = ts_queries.scala_highlights;
+ } else if (extIs(ext, .{ ".zig", ".zon" })) {
+ lang = tree_sitter_zig();
+ query_src = ts_queries.zig_highlights;
+ } else return &.{};
+
+ const styles = try gpa.alloc(u8, content.len);
+ errdefer gpa.free(styles);
+ @memset(styles, 0);
+
+ const parser = ts.Parser.create();
+ defer parser.destroy();
+ parser.setLanguage(lang) catch return styles;
+ const tree = parser.parseString(content, null) orelse return styles;
+ defer tree.destroy();
+
+ var error_offset: u32 = 0;
+ const query = ts.Query.create(lang, query_src, &error_offset) catch return styles;
+ defer query.destroy();
+ const cursor = ts.QueryCursor.create();
+ defer cursor.destroy();
+ cursor.exec(query, tree.rootNode());
+
+ while (cursor.nextMatch()) |match| {
+ for (match.captures) |cap| {
+ const syn = synFor(query.captureNameForId(cap.index) orelse "");
+ if (syn == .none) continue;
+ var b: usize = cap.node.startByte();
+ const end = @min(@as(usize, cap.node.endByte()), styles.len);
+ while (b < end) : (b += 1) styles[b] = @intFromEnum(syn);
+ }
+ }
+ return styles;
+}
+
+fn palColor(idx: u8) ui.Color {
+ if (T().palette) |p| if (idx < 16) return rgbColor(p[idx]);
+ return .{ .index = idx };
+}
+
+fn ghostColor(color: ghostty_vt.Style.Color, is_bg: bool) ui.Color {
+ return switch (color) {
+ .none => colorOpt(if (is_bg) T().bg else T().fg),
+ .palette => |idx| palColor(idx),
+ .rgb => |rgb| .{ .rgb = .{ rgb.r, rgb.g, rgb.b } },
+ };
+}
+
+pub fn cellStyle(ci: ghostty_vt.PageList.Cell) ui.Style {
+ const style = ci.style();
+ var vs: ui.Style = .{
+ .fg = ghostColor(style.fg_color, false),
+ .bg = ghostColor(style.bg_color, true),
+ .bold = style.flags.bold,
+ .dim = style.flags.faint,
+ .italic = style.flags.italic,
+ .blink = style.flags.blink,
+ .reverse = style.flags.inverse,
+ .invisible = style.flags.invisible,
+ .strikethrough = style.flags.strikethrough,
+ .ul_style = switch (style.flags.underline) {
+ .none => .off,
+ .single => .single,
+ .double => .double,
+ .curly => .curly,
+ .dotted => .dotted,
+ .dashed => .dashed,
+ },
+ };
+ switch (ci.cell.content_tag) {
+ .bg_color_palette => vs.bg = palColor(ci.cell.content.color_palette.data),
+ .bg_color_rgb => {
+ const rgb = ci.cell.content.color_rgb;
+ vs.bg = .{ .rgb = .{ rgb.r, rgb.g, rgb.b } };
+ },
+ else => {},
+ }
+ return vs;
+}
diff --git a/replay.zig b/replay.zig
new file mode 100644
index 00000000..75227c42
--- /dev/null
+++ b/replay.zig
@@ -0,0 +1,712 @@
+const std = @import("std");
+const ui = @import("ui");
+const ghostty_vt = @import("ghostty-vt");
+const dump = @import("dump.zig");
+const paint = @import("paint.zig");
+const image = @import("image.zig");
+
+const TOPBAR_H: u16 = 1;
+const GUTTER: u16 = 2;
+const BOX_H: u16 = 1;
+const MINW: u16 = 10;
+const MINH: u16 = 3;
+const PREFIX_W: u16 = 5;
+
+const Rect = struct { x: u16, y: u16, w: u16, h: u16 };
+
+const ReplayTerm = struct {
+ term: ghostty_vt.Terminal,
+ stream: ghostty_vt.TerminalStream,
+};
+
+const Pane = struct {
+ kind: dump.PaneKind,
+ tag: []const u8,
+ content: []const u8,
+ owned_content: ?[]u8 = null,
+ path: []const u8,
+ terminal: ?ReplayTerm = null,
+ image_view: ?image.View = null,
+ image_bytes: []const u8 = &.{},
+ tty_mode: bool = false,
+ highlights: []u8 = &.{},
+ scroll: usize,
+ cols: u16,
+ rows: u16,
+ vweight: f32,
+
+ fn isTerminal(self: *const Pane) bool {
+ return self.terminal != null;
+ }
+};
+
+const Layout = struct {
+ ncol: usize,
+ col_weight: [dump.max_cols]f32,
+ col_terms: [dump.max_cols][dump.max_panes]usize,
+ col_n: [dump.max_cols]usize,
+};
+
+const Drag = union(enum) {
+ none,
+ border_v: struct { left_col: usize, cur_x: u16 },
+ border_h: struct { col: usize, top_idx: usize, cur_y: u16 },
+ move: struct { id: usize, cur_x: u16, cur_y: u16 },
+};
+
+pub const Command = struct {
+ prev: ?*Command = null,
+ pub var value: union(enum) {
+ close,
+ nop,
+ quit,
+ tick,
+ key_press: ui.Key,
+ winsize: ui.Winsize,
+ mouse: ui.Mouse,
+ paste: []const u8,
+ } = .nop;
+};
+
+const Loop = ui.Loop(@TypeOf(Command.value));
+
+const accent = paint.accent;
+const accent_dim = paint.accent_dim;
+const T = paint.T;
+const rgbColor = paint.rgbColor;
+const colorOpt = paint.colorOpt;
+const synStyle = paint.synStyle;
+const highlightFile = paint.highlightFile;
+const cellStyle = paint.cellStyle;
+
+fn printUsage(io: std.Io) !void {
+ try std.Io.File.stdout().writeStreamingAll(io, "Usage: pardes-replay <dump.zon>\n");
+}
+
+fn lineCount(text: []const u8) usize {
+ return std.mem.count(u8, text, "\n") + 1;
+}
+
+fn lineAt(text: []const u8, row: usize) []const u8 {
+ var it = std.mem.splitScalar(u8, text, '\n');
+ var i: usize = 0;
+ while (it.next()) |line| : (i += 1) {
+ if (i == row) return line;
+ }
+ return "";
+}
+
+fn clampScroll(p: *Pane) void {
+ if (p.isTerminal()) return;
+ const total = lineCount(p.content);
+ const max_scroll = total -| @max(1, p.rows);
+ if (p.scroll > max_scroll) p.scroll = max_scroll;
+}
+
+fn scrollBy(p: *Pane, delta: i32) void {
+ if (p.terminal) |*rt| {
+ rt.term.screens.active.scroll(.{ .delta_row = @intCast(delta) });
+ return;
+ }
+ const total = lineCount(p.content);
+ const max_scroll: i64 = @intCast(total -| @max(1, p.rows));
+ const next = std.math.clamp(@as(i64, @intCast(p.scroll)) + delta, 0, max_scroll);
+ p.scroll = @intCast(next);
+}
+
+fn promptFlags(rt: *ReplayTerm, flags: []bool) void {
+ @memset(flags, false);
+ var it = rt.term.screens.active.pages.rowIterator(.right_down, .{ .viewport = .{} }, null);
+ var i: usize = 0;
+ while (it.next()) |pin| : (i += 1) {
+ if (i >= flags.len) break;
+ flags[i] = pin.rowAndCell().row.semantic_prompt != .none;
+ }
+}
+
+fn terminalBodyText(alloc: std.mem.Allocator, p: *Pane) ![]const u8 {
+ const rt = &p.terminal.?;
+ const body = try rt.term.plainString(alloc);
+ var flagbuf: [512]bool = undefined;
+ const nrows: usize = @min(p.rows, flagbuf.len);
+ const flags = flagbuf[0..nrows];
+ if (p.tty_mode) @memset(flags, false) else promptFlags(rt, flags);
+
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(alloc);
+ var it = std.mem.splitAny(u8, body, "\n");
+ var i: usize = 0;
+ while (i < nrows) : (i += 1) {
+ if (i > 0) try out.append(alloc, '\n');
+ const raw = it.next() orelse "";
+ try out.appendSlice(alloc, if (flags[i]) "" else raw);
+ }
+ return out.toOwnedSlice(alloc);
+}
+
+fn layoutFindTerm(l: *const Layout, id: usize) ?struct { col: usize, idx: usize } {
+ var c: usize = 0;
+ while (c < l.ncol) : (c += 1) {
+ var k: usize = 0;
+ while (k < l.col_n[c]) : (k += 1) {
+ if (l.col_terms[c][k] == id) return .{ .col = c, .idx = k };
+ }
+ }
+ return null;
+}
+
+fn layoutRemove(l: *Layout, id: usize) void {
+ const f = layoutFindTerm(l, id) orelse return;
+ const c = f.col;
+ var k = f.idx;
+ while (k + 1 < l.col_n[c]) : (k += 1) l.col_terms[c][k] = l.col_terms[c][k + 1];
+ l.col_n[c] -= 1;
+ if (l.col_n[c] == 0 and l.ncol > 1) {
+ l.col_weight[if (c > 0) c - 1 else c + 1] += l.col_weight[c];
+ var j = c;
+ while (j + 1 < l.ncol) : (j += 1) {
+ l.col_terms[j] = l.col_terms[j + 1];
+ l.col_n[j] = l.col_n[j + 1];
+ l.col_weight[j] = l.col_weight[j + 1];
+ }
+ l.ncol -= 1;
+ }
+}
+
+fn layoutInsert(l: *Layout, c: usize, idx: usize, id: usize) void {
+ var k = l.col_n[c];
+ while (k > idx) : (k -= 1) l.col_terms[c][k] = l.col_terms[c][k - 1];
+ l.col_terms[c][idx] = id;
+ l.col_n[c] += 1;
+}
+
+fn computeGeom(
+ l: *const Layout,
+ panes: []const Pane,
+ sw: u16,
+ sh: u16,
+ rects: *[dump.max_panes]Rect,
+ col_x: *[dump.max_cols]u16,
+ col_w: *[dump.max_cols]u16,
+) void {
+ if (l.ncol == 0) return;
+ var wsum: f32 = 0;
+ for (0..l.ncol) |c| wsum += l.col_weight[c];
+ if (wsum <= 0) wsum = 1;
+
+ var x: u16 = 0;
+ for (0..l.ncol) |c| {
+ const last = c + 1 == l.ncol;
+ const cw: u16 = if (last) (sw -| x) else @max(1, @as(u16, @intFromFloat(@round(@as(f32, @floatFromInt(sw)) * l.col_weight[c] / wsum))));
+ col_x[c] = x;
+ col_w[c] = cw;
+
+ var vsum: f32 = 0;
+ for (0..l.col_n[c]) |k| vsum += panes[l.col_terms[c][k]].vweight;
+ if (vsum <= 0) vsum = 1;
+
+ var y: u16 = TOPBAR_H;
+ const avail_h = sh -| TOPBAR_H;
+ for (0..l.col_n[c]) |k| {
+ const id = l.col_terms[c][k];
+ const lastk = k + 1 == l.col_n[c];
+ const ch: u16 = if (lastk) (sh -| y) else @max(1, @as(u16, @intFromFloat(@round(@as(f32, @floatFromInt(avail_h)) * panes[id].vweight / vsum))));
+ rects[id] = .{ .x = x, .y = y, .w = cw, .h = ch };
+ y +|= ch;
+ }
+ x +|= cw;
+ }
+}
+
+fn moveTerm(
+ l: *Layout,
+ rects: *const [dump.max_panes]Rect,
+ col_x: *const [dump.max_cols]u16,
+ col_w: *const [dump.max_cols]u16,
+ id: usize,
+ cur_x: u16,
+ cur_y: u16,
+) void {
+ const src = layoutFindTerm(l, id) orelse return;
+ var tc: usize = if (l.ncol > 0) l.ncol - 1 else 0;
+ for (0..l.ncol) |c| {
+ if (cur_x >= col_x[c] and cur_x < col_x[c] + col_w[c]) {
+ tc = c;
+ break;
+ }
+ }
+
+ var ti: usize = 0;
+ var k: usize = 0;
+ while (k < l.col_n[tc]) : (k += 1) {
+ const cid = l.col_terms[tc][k];
+ if (cid == id) continue;
+ const r = rects[cid];
+ if (cur_y < r.y + r.h / 2) break;
+ ti += 1;
+ }
+
+ const src_solo = l.col_n[src.col] == 1;
+ if (tc == src.col and src_solo) return;
+ layoutRemove(l, id);
+ if (src_solo and src.col < tc) tc -= 1;
+ if (tc >= l.ncol) tc = l.ncol - 1;
+ if (ti > l.col_n[tc]) ti = l.col_n[tc];
+ layoutInsert(l, tc, ti, id);
+}
+
+fn renderPane(alloc: std.mem.Allocator, root: ui.Window, p: *Pane, r: Rect, active: bool) !void {
+ if (r.w <= GUTTER or r.h == 0) return;
+ const text = root.child(.{ .x_off = r.x + GUTTER, .y_off = r.y, .width = r.w - GUTTER, .height = r.h });
+ text.clear();
+ if (T().bg) |bg| text.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(bg) } });
+
+ const tagw = text.child(.{ .height = BOX_H });
+ tagw.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(T().tag_bg) } });
+ _ = tagw.printSegment(.{ .text = p.tag, .style = .{ .fg = rgbColor(T().tag_fg), .bg = rgbColor(T().tag_bg) } }, .{ .wrap = .none });
+
+ const body_h = if (r.h > BOX_H) r.h - BOX_H else 0;
+ const body_w = r.w - GUTTER;
+ const body_style: ui.Style = .{ .fg = colorOpt(T().fg), .bg = colorOpt(T().bg) };
+
+ if (p.image_view) |*iv| {
+ if (body_h > 0) image.draw(iv, text.child(.{ .y_off = BOX_H, .height = body_h }), body_w, body_h);
+ const gut = root.child(.{ .x_off = r.x, .y_off = r.y, .width = GUTTER, .height = r.h });
+ const box_bg = if (active) accent else accent_dim;
+ var gy: u16 = 0;
+ while (gy < r.h) : (gy += 1) {
+ const bg = if (gy < BOX_H) box_bg else T().scroll_track;
+ var gx: u16 = 0;
+ while (gx < GUTTER) : (gx += 1) {
+ gut.writeCell(gx, gy, .{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(bg) } });
+ }
+ }
+ return;
+ }
+
+ if (p.terminal != null) {
+ const body = try terminalBodyText(alloc, p);
+ var it = std.mem.splitAny(u8, body, "\n");
+ var vr: u16 = 0;
+ while (it.next()) |line| : (vr += 1) {
+ if (vr >= body_h) break;
+ _ = text.printSegment(.{ .text = line, .style = body_style }, .{ .row_offset = BOX_H + vr, .wrap = .none });
+ }
+
+ const rt = &p.terminal.?;
+ vr = 0;
+ while (vr < body_h and vr < p.rows) : (vr += 1) {
+ var c: u16 = 0;
+ while (c < body_w) : (c += 1) {
+ const ci = rt.term.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(c), .y = @intCast(vr) } }) orelse continue;
+ if (ci.cell.wide == .spacer_tail) continue;
+ if (text.readCell(c, BOX_H + vr)) |cell| {
+ var nc = cell;
+ nc.default = false;
+ nc.style = cellStyle(ci);
+ text.writeCell(c, BOX_H + vr, nc);
+ }
+ }
+ }
+ } else if (p.kind == .file) {
+ var vr: u16 = 0;
+ while (vr < body_h) : (vr += 1) {
+ const abs = p.scroll + vr;
+ const prefix = try std.fmt.allocPrint(alloc, "{d: >4} ", .{abs + 1});
+ _ = text.printSegment(.{ .text = prefix, .style = body_style }, .{ .row_offset = BOX_H + vr, .wrap = .none });
+ _ = text.printSegment(.{ .text = lineAt(p.content, abs), .style = body_style }, .{ .row_offset = BOX_H + vr, .col_offset = PREFIX_W, .wrap = .none });
+ }
+
+ const ln_fg = rgbColor(T().lineno);
+ vr = 0;
+ while (vr < body_h) : (vr += 1) {
+ var c: u16 = 0;
+ while (c < PREFIX_W and c < body_w) : (c += 1) {
+ if (text.readCell(c, BOX_H + vr)) |cell| {
+ var nc = cell;
+ nc.default = false;
+ nc.style.fg = ln_fg;
+ text.writeCell(c, BOX_H + vr, nc);
+ }
+ }
+ }
+
+ if (p.highlights.len > 0) {
+ var lines = std.mem.splitScalar(u8, p.content, '\n');
+ var skip: usize = 0;
+ while (skip < p.scroll) : (skip += 1) if (lines.next() == null) break;
+ vr = 0;
+ while (vr < body_h) : (vr += 1) {
+ const line = lines.next() orelse break;
+ const base = @intFromPtr(line.ptr) - @intFromPtr(p.content.ptr);
+ var c: usize = 0;
+ while (c < line.len and PREFIX_W + c < body_w and base + c < p.highlights.len) : (c += 1) {
+ const ss = synStyle(@enumFromInt(p.highlights[base + c])) orelse continue;
+ const cx: u16 = @intCast(PREFIX_W + c);
+ if (text.readCell(cx, BOX_H + vr)) |cell| {
+ var nc = cell;
+ nc.default = false;
+ nc.style.fg = rgbColor(ss.fg);
+ nc.style.bold = ss.bold;
+ text.writeCell(cx, BOX_H + vr, nc);
+ }
+ }
+ }
+ }
+ } else {
+ var vr: u16 = 0;
+ while (vr < body_h) : (vr += 1) {
+ const abs = p.scroll + vr;
+ _ = text.printSegment(.{ .text = lineAt(p.content, abs), .style = body_style }, .{ .row_offset = BOX_H + vr, .wrap = .none });
+ }
+ }
+
+ const gut = root.child(.{ .x_off = r.x, .y_off = r.y, .width = GUTTER, .height = r.h });
+ const box_bg = if (active) accent else accent_dim;
+ var gy: u16 = 0;
+ while (gy < r.h) : (gy += 1) {
+ const bg = if (gy < BOX_H) box_bg else T().scroll_track;
+ var gx: u16 = 0;
+ while (gx < GUTTER) : (gx += 1) {
+ gut.writeCell(gx, gy, .{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(bg) } });
+ }
+ }
+
+ const track_h: usize = if (r.h > BOX_H) r.h - BOX_H else 0;
+ if (track_h > 0) {
+ var sb_total: usize = undefined;
+ var sb_off: usize = undefined;
+ var sb_len: usize = undefined;
+ if (p.terminal) |*rt| {
+ const sb = rt.term.screens.active.pages.scrollbar();
+ sb_total = sb.total;
+ sb_off = sb.offset;
+ sb_len = sb.len;
+ } else {
+ sb_total = lineCount(p.content);
+ sb_off = p.scroll;
+ sb_len = p.rows;
+ }
+ const total = if (sb_total == 0) 1 else sb_total;
+ const len = @max(1, (track_h * @max(1, sb_len)) / total);
+ const pos = (track_h * sb_off) / total;
+ var sy: usize = 0;
+ while (sy < track_h) : (sy += 1) {
+ if (sy < pos or sy >= pos + len) continue;
+ var gx: u16 = 0;
+ while (gx < GUTTER) : (gx += 1) {
+ gut.writeCell(gx, @intCast(BOX_H + sy), .{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(T().scroll_thumb) } });
+ }
+ }
+ }
+}
+
+fn render(
+ vx: *ui.Vaxis,
+ gpa: std.mem.Allocator,
+ writer: *std.Io.Writer,
+ state: dump.State,
+ panes: []Pane,
+ rects: *[dump.max_panes]Rect,
+ screen_w: u16,
+ active: usize,
+) !void {
+ var arena: std.heap.ArenaAllocator = .init(gpa);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ const win = vx.window();
+ win.clear();
+ win.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(T().scroll_track) } });
+
+ for (panes, 0..) |*p, id| {
+ if (p.image_view) |*iv| {
+ image.ensureLoadedFromMemory(iv, p.image_bytes, vx, gpa, writer);
+ image.ensureGrid(iv, gpa, p.cols, p.rows);
+ }
+ try renderPane(alloc, win, p, rects[id], id == active);
+ }
+
+ const bar = win.child(.{ .x_off = 0, .y_off = 0, .width = screen_w, .height = TOPBAR_H });
+ bar.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(T().tag_bg) } });
+ const topbar = if (state.topbar.len > 0) state.topbar else "pardes replay";
+ _ = bar.printSegment(.{ .text = topbar, .style = .{ .fg = rgbColor(T().tag_fg), .bg = rgbColor(T().tag_bg) } }, .{ .wrap = .none });
+
+ try vx.render(writer);
+}
+
+fn paneAt(rects: *const [dump.max_panes]Rect, pane_count: usize, col: u16, row: u16) ?usize {
+ for (0..pane_count) |i| {
+ const r = rects[i];
+ if (col >= r.x and col < r.x + r.w and row >= r.y and row < r.y + r.h) return i;
+ }
+ return null;
+}
+
+fn deinitPanes(gpa: std.mem.Allocator, panes: []Pane) void {
+ for (panes) |*p| {
+ if (p.terminal) |*rt| {
+ rt.stream.deinit();
+ rt.term.deinit(gpa);
+ }
+ if (p.image_view) |*iv| image.deinitView(iv, gpa);
+ if (p.highlights.len > 0) gpa.free(p.highlights);
+ if (p.owned_content) |bytes| gpa.free(bytes);
+ }
+}
+
+fn buildPane(gpa: std.mem.Allocator, src: dump.Pane, out: *Pane) !void {
+ out.* = .{
+ .kind = src.kind,
+ .tag = src.tag,
+ .content = src.body,
+ .path = "",
+ .tty_mode = std.mem.startsWith(u8, src.tag, "sy "),
+ .scroll = src.scroll,
+ .cols = src.cols,
+ .rows = src.rows,
+ .vweight = src.vweight,
+ };
+
+ switch (src.kind) {
+ .file => if (src.file) |f| {
+ out.path = f.path;
+ if (f.content_b64.len > 0) {
+ const bytes = try dump.decodeBytes(gpa, f.content_b64);
+ out.owned_content = bytes;
+ out.content = bytes;
+ } else {
+ out.content = f.content;
+ }
+ out.highlights = highlightFile(gpa, out.path, out.content) catch &.{};
+ },
+ .terminal => if (src.terminal) |t| {
+ out.path = t.cwd;
+ if (t.stream_b64.len > 0) {
+ const bytes = try dump.decodeBytes(gpa, t.stream_b64);
+ out.owned_content = bytes;
+ out.content = bytes;
+ out.terminal = .{
+ .term = try ghostty_vt.Terminal.init(gpa, .{
+ .cols = @max(1, src.cols),
+ .rows = @max(1, src.rows),
+ .max_scrollback = 16 * 1024 * 1024,
+ }),
+ .stream = undefined,
+ };
+ if (out.terminal) |*rt| {
+ rt.stream = rt.term.vtStream();
+ rt.stream.nextSlice(bytes);
+ rt.term.screens.active.scroll(.active);
+ if (src.scroll > 0) rt.term.screens.active.scroll(.{ .delta_row = -@as(isize, @intCast(src.scroll)) });
+ }
+ } else {
+ out.content = if (t.stream.len > 0) t.stream else src.body;
+ }
+ },
+ .image => if (src.image) |im| {
+ out.path = im.path;
+ out.content = src.body;
+ if (im.bytes_b64.len > 0) {
+ const bytes = try dump.decodeBytes(gpa, im.bytes_b64);
+ out.owned_content = bytes;
+ out.image_bytes = bytes;
+ }
+ out.image_view = .{ .path = im.path };
+ },
+ }
+ clampScroll(out);
+}
+
+pub fn main(init: std.process.Init) !void {
+ const io = init.io;
+ const gpa = init.gpa;
+ const args = try init.minimal.args.toSlice(init.arena.allocator());
+ if (args.len != 2) {
+ try printUsage(io);
+ return error.BadArgs;
+ }
+
+ const state = try dump.readFile(io, gpa, args[1]);
+ defer dump.free(gpa, state);
+ _ = paint.setThemeByName(state.theme);
+ image.start(io, gpa);
+ defer image.stop();
+
+ var panes_buf: [dump.max_panes]Pane = undefined;
+ for (state.panes, 0..) |src, i| try buildPane(gpa, src, &panes_buf[i]);
+ var panes = panes_buf[0..state.panes.len];
+ defer deinitPanes(gpa, panes);
+
+ var layout: Layout = .{
+ .ncol = state.columns.len,
+ .col_weight = @splat(1),
+ .col_terms = undefined,
+ .col_n = @splat(0),
+ };
+ for (state.columns, 0..) |col, c| {
+ layout.col_weight[c] = col.weight;
+ layout.col_n[c] = col.panes.len;
+ for (col.panes, 0..) |pane, k| layout.col_terms[c][k] = pane;
+ }
+
+ var screen_w: u16 = if (state.screen.cols > 0) state.screen.cols else 80;
+ var screen_h: u16 = if (state.screen.rows > 0) state.screen.rows else 24;
+ var active = state.active;
+
+ var tty_buf: [0x10000]u8 = undefined;
+ var tty = try ui.Tty.init(io, &tty_buf);
+ var vx = try ui.init(io, gpa, init.environ_map, .{});
+ defer vx.deinit(gpa, tty.writer());
+ try vx.setMouseMode(tty.writer(), true);
+ try vx.enterAltScreen(tty.writer());
+ defer vx.exitAltScreen(tty.writer()) catch {};
+
+ var loop: Loop = .init(io, &tty, &vx);
+ try loop.start();
+ defer loop.stop();
+ try loop.installResizeHandler();
+
+ var rects: [dump.max_panes]Rect = undefined;
+ var col_x: [dump.max_cols]u16 = undefined;
+ var col_w: [dump.max_cols]u16 = undefined;
+ computeGeom(&layout, panes, screen_w, screen_h, &rects, &col_x, &col_w);
+
+ var drag: Drag = .none;
+ while (true) {
+ const event = try loop.nextEvent();
+ switch (event) {
+ .winsize => |ws| {
+ screen_w = ws.cols;
+ screen_h = ws.rows;
+ try vx.resize(gpa, tty.writer(), ws);
+ },
+ .mouse => |mouse| {
+ const mcol: u16 = if (mouse.col < 0) 0 else @min(@as(u16, @intCast(mouse.col)), screen_w -| 1);
+ const mrow: u16 = if (mouse.row < 0) 0 else @min(@as(u16, @intCast(mouse.row)), screen_h -| 1);
+ const hovered = paneAt(&rects, panes.len, mcol, mrow);
+
+ if (mouse.button == .wheel_down and mouse.type == .press) {
+ if (hovered) |id| scrollBy(&panes[id], 1);
+ } else if (mouse.button == .wheel_up and mouse.type == .press) {
+ if (hovered) |id| scrollBy(&panes[id], -1);
+ } else if (mouse.type == .press and mouse.button == .left) {
+ var handled = false;
+ var c: usize = 0;
+ while (c + 1 < layout.ncol and !handled) : (c += 1) {
+ if (mcol == col_x[c] + col_w[c] -| 1) {
+ drag = .{ .border_v = .{ .left_col = c, .cur_x = mcol } };
+ handled = true;
+ }
+ }
+ if (!handled) {
+ var cc: usize = 0;
+ outer: while (cc < layout.ncol) : (cc += 1) {
+ if (mcol < col_x[cc] or mcol >= col_x[cc] + col_w[cc]) continue;
+ var k: usize = 0;
+ while (k + 1 < layout.col_n[cc]) : (k += 1) {
+ const id = layout.col_terms[cc][k];
+ const r = rects[id];
+ if (mrow == r.y + r.h -| 1) {
+ drag = .{ .border_h = .{ .col = cc, .top_idx = k, .cur_y = mrow } };
+ handled = true;
+ break :outer;
+ }
+ }
+ }
+ }
+ if (!handled) if (hovered) |id| {
+ active = id;
+ const r = rects[id];
+ if (mcol < r.x + GUTTER and mrow < r.y + BOX_H) {
+ drag = .{ .move = .{ .id = id, .cur_x = mcol, .cur_y = mrow } };
+ } else if (mcol < r.x + GUTTER) {
+ const local: i32 = @as(i32, mrow) - @as(i32, r.y + BOX_H);
+ scrollBy(&panes[id], -local);
+ }
+ };
+ } else if (mouse.type == .press) {
+ if (hovered) |id| active = id;
+ } else if (mouse.type == .drag) {
+ switch (drag) {
+ .border_v => |*d| {
+ const c = d.left_col;
+ if (c + 1 < layout.ncol) {
+ const lo = col_x[c] + MINW;
+ const hi = col_x[c] +| col_w[c] +| col_w[c + 1] -| MINW;
+ d.cur_x = if (lo <= hi) std.math.clamp(mcol, lo, hi) else mcol;
+ }
+ },
+ .border_h => |*d| {
+ const c = d.col;
+ const k = d.top_idx;
+ if (k + 1 < layout.col_n[c]) {
+ const a = layout.col_terms[c][k];
+ const b = layout.col_terms[c][k + 1];
+ const lo = rects[a].y + MINH;
+ const hi = rects[a].y +| rects[a].h +| rects[b].h -| MINH;
+ d.cur_y = if (lo <= hi) std.math.clamp(mrow, lo, hi) else mrow;
+ }
+ },
+ .move => |*d| {
+ d.cur_x = mcol;
+ d.cur_y = mrow;
+ },
+ .none => {},
+ }
+ } else if (mouse.type == .release) {
+ switch (drag) {
+ .border_v => |d| {
+ const c = d.left_col;
+ if (c + 1 < layout.ncol) {
+ const combined: f32 = @floatFromInt(col_w[c] + col_w[c + 1]);
+ var nl: f32 = @floatFromInt((d.cur_x + 1) -| col_x[c]);
+ nl = std.math.clamp(nl, @as(f32, MINW), @max(@as(f32, MINW), combined - @as(f32, MINW)));
+ const pair = layout.col_weight[c] + layout.col_weight[c + 1];
+ layout.col_weight[c] = pair * (nl / combined);
+ layout.col_weight[c + 1] = pair - layout.col_weight[c];
+ }
+ },
+ .border_h => |d| {
+ const c = d.col;
+ const k = d.top_idx;
+ if (k + 1 < layout.col_n[c]) {
+ const a = layout.col_terms[c][k];
+ const b = layout.col_terms[c][k + 1];
+ const combined: f32 = @floatFromInt(rects[a].h + rects[b].h);
+ var nt: f32 = @floatFromInt((d.cur_y + 1) -| rects[a].y);
+ nt = std.math.clamp(nt, @as(f32, MINH), @max(@as(f32, MINH), combined - @as(f32, MINH)));
+ const pair = panes[a].vweight + panes[b].vweight;
+ panes[a].vweight = pair * (nt / combined);
+ panes[b].vweight = pair - panes[a].vweight;
+ }
+ },
+ .move => |d| moveTerm(&layout, &rects, &col_x, &col_w, d.id, d.cur_x, d.cur_y),
+ .none => {},
+ }
+ drag = .none;
+ }
+ },
+ .key_press => |key| {
+ if (key.matches('q', .{}) or key.matches(ui.Key.escape, .{}) or key.matches('c', .{ .ctrl = true })) break;
+ },
+ .paste => |text| gpa.free(text),
+ .quit, .close => break,
+ else => {},
+ }
+
+ computeGeom(&layout, panes, screen_w, screen_h, &rects, &col_x, &col_w);
+ for (panes, 0..) |*p, id| {
+ const r = rects[id];
+ const cols = @max(1, r.w -| GUTTER);
+ const rows = @max(1, r.h -| BOX_H);
+ if (p.terminal) |*rt| {
+ if (cols != p.cols or rows != p.rows) rt.term.resize(gpa, cols, rows) catch {};
+ }
+ p.cols = cols;
+ p.rows = rows;
+ clampScroll(p);
+ }
+ try render(&vx, gpa, tty.writer(), state, panes, &rects, screen_w, active);
+ }
+}
diff --git a/term.zig b/term.zig
index 5e472a9e..d332488e 100644
--- a/term.zig
+++ b/term.zig
@@ -114,6 +114,10 @@ greet: bool = false,
cols: u16,
rows: u16,
+/// Raw bytes read from the pty and fed into ghostty. Dump/replay uses this as the
+/// backend-neutral terminal recording instead of serialized rendered cells.
+tty_stream: std.ArrayList(u8) = .empty,
+
/// vertical weight within the column (siblings split the column height by this).
vweight: f32 = 1,
@@ -186,6 +190,7 @@ pub fn deinit(self: *Term, io: std.Io, gpa: std.mem.Allocator) void {
_ = self.reader.cancel(io) catch {};
self.pty.close(io);
}
+ self.tty_stream.deinit(gpa);
self.ed_undo.deinit(gpa);
self.ed_redo.deinit(gpa);
self.tag_tail.deinit(gpa);
diff --git a/tests.zig b/tests.zig
index 701b153a..0604fb98 100644
--- a/tests.zig
+++ b/tests.zig
@@ -2,6 +2,7 @@ const std = @import("std");
const h = @import("e2e_harness.zig");
const Harness = h.Harness;
const config = @import("config");
+const dump = @import("dump.zig");
// E2E tests for pardes. The harness forks the app in a pty and parses its
// output with a ghostty terminal emulator, so all assertions read the 2D screen
@@ -36,15 +37,70 @@ fn colOf(text: []const u8, needle: []const u8) ?usize {
return null;
}
+fn writeReplayFixture(io: std.Io, gpa: std.mem.Allocator, path: []const u8) !void {
+ const file_content =
+ "const std = @import(\"std\");\n" ++
+ "\n" ++
+ "// utf8: \xe2\x80\x94\n";
+ const content_b64 = try dump.encodeBytes(gpa, file_content);
+ defer gpa.free(content_b64);
+ const panes = [_]dump.Pane{.{
+ .kind = .file,
+ .tag = "nm /tmp/replay_fixture.zig Save Del",
+ .body = "",
+ .cols = 78,
+ .rows = 22,
+ .file = .{
+ .path = "/tmp/replay_fixture.zig",
+ .content = file_content,
+ .content_b64 = content_b64,
+ },
+ }};
+ const col_panes = [_]usize{0};
+ const columns = [_]dump.Column{.{ .weight = 1, .panes = &col_panes }};
+ try dump.writeFile(io, gpa, path, .{
+ .screen = .{ .cols = 80, .rows = 24 },
+ .topbar = "Kill Newcol Tutor Debug Colors NextColor Dump",
+ .theme = "dark",
+ .columns = &columns,
+ .panes = &panes,
+ });
+}
+
+fn writeReplayImageFixture(io: std.Io, gpa: std.mem.Allocator, path: []const u8) !void {
+ const png_b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
+ const panes = [_]dump.Pane{.{
+ .kind = .image,
+ .tag = "img /tmp/replay.png Petscii Del",
+ .body = "",
+ .cols = 78,
+ .rows = 22,
+ .image = .{
+ .path = "/tmp/replay.png",
+ .bytes_b64 = png_b64,
+ },
+ }};
+ const col_panes = [_]usize{0};
+ const columns = [_]dump.Column{.{ .weight = 1, .panes = &col_panes }};
+ try dump.writeFile(io, gpa, path, .{
+ .screen = .{ .cols = 80, .rows = 24 },
+ .topbar = "Kill Newcol Tutor Debug Colors NextColor Dump",
+ .theme = "dark",
+ .columns = &columns,
+ .panes = &panes,
+ });
+}
+
pub fn main(init: std.process.Init) !void {
const gpa = init.gpa;
const io = init.io;
const args = try init.minimal.args.toSlice(init.arena.allocator());
- if (args.len != 2) {
- std.debug.print("usage: {s} <pardes-exe>\n", .{args[0]});
+ if (args.len != 3) {
+ std.debug.print("usage: {s} <pardes-exe> <pardes-replay-exe>\n", .{args[0]});
return error.BadArgs;
}
const exe = args[1];
+ const replay_exe = args[2];
var hs = try Harness.init(gpa, exe.ptr, 24, 80);
hs.trace = config.trace;
@@ -61,6 +117,75 @@ pub fn main(init: std.process.Init) !void {
// the resize counter to 1 and repaints, so "resizes=1" shows in the debug
// overlay. Confirms the app is up and handled its first size.
try hs.expectWaitFor("resizes=1", 6000, "app never handled its initial winsize (no 'resizes=1')");
+ try hs.expectWaitFor("build.zig", 8000, "new terminals did not auto-ls their directory on open");
+
+ // 1b. Dump is a global-tagbar builtin. It writes a backend-independent ZON
+ // snapshot with compact pane ids, layout columns, rendered pane text, and
+ // pane backing contents.
+ std.Io.Dir.deleteFileAbsolute(io, dump.default_path) catch {};
+ try hs.send("\x1b[<1;43;1M\x1b[<1;43;1m"); // middle-click "Dump" in the topbar
+ try hs.pump(800);
+ {
+ const dumped = dump.readFile(io, gpa, dump.default_path) catch {
+ hs.dump("Dump did not write a parseable /tmp/pardes-dump.zon");
+ return error.ExpectFailed;
+ };
+ defer dump.free(gpa, dumped);
+ if (dumped.panes.len == 0 or dumped.columns.len == 0 or dumped.active >= dumped.panes.len) {
+ hs.dump("Dump wrote an invalid empty layout");
+ return error.ExpectFailed;
+ }
+ if (std.mem.indexOf(u8, dumped.topbar, "Dump") == null) {
+ hs.dump("Dump snapshot did not include the main tagline");
+ return error.ExpectFailed;
+ }
+ if (!std.mem.eql(u8, dumped.theme, "dark")) {
+ hs.dump("Dump snapshot did not include the active theme");
+ return error.ExpectFailed;
+ }
+ if (dumped.panes[0].kind != .terminal or dumped.panes[0].terminal == null) {
+ hs.dump("Dump snapshot did not capture the terminal pane stream");
+ return error.ExpectFailed;
+ }
+ if (dumped.panes[0].terminal.?.stream_b64.len == 0) {
+ hs.dump("Dump snapshot did not include base64 terminal bytes");
+ return error.ExpectFailed;
+ }
+ const tty_bytes = try dump.decodeBytes(gpa, dumped.panes[0].terminal.?.stream_b64);
+ defer gpa.free(tty_bytes);
+ if (std.mem.indexOf(u8, tty_bytes, "build.zig") == null) {
+ hs.dump("Dump terminal byte stream does not replay the visible ls output");
+ return error.ExpectFailed;
+ }
+ }
+ {
+ var replay_h = try Harness.initArgs(gpa, replay_exe.ptr, 24, 80, dump.default_path);
+ replay_h.trace = config.trace;
+ defer replay_h.deinit();
+ try replay_h.pump(1200);
+ try replay_h.expectContains("Kill Newcol", "replay did not render the dump topbar");
+ try replay_h.expectContains("build.zig", "replay did not rebuild the terminal from the dumped byte stream");
+ try replay_h.expectNotContains("\xc2\xaa", "replay rendered corrupt prefix bytes");
+ }
+ {
+ const fixture_path = "/tmp/pardes-replay-fixture.zon";
+ try writeReplayFixture(io, gpa, fixture_path);
+ var replay_file = try Harness.initArgs(gpa, replay_exe.ptr, 24, 80, fixture_path);
+ replay_file.trace = config.trace;
+ defer replay_file.deinit();
+ try replay_file.pump(1200);
+ try replay_file.expectContains(" 1 const std", "replay file pane line numbers are broken");
+ try replay_file.expectNotContains("\xc2\xaa", "replay file line numbers used invalid stack bytes");
+ }
+ {
+ const fixture_path = "/tmp/pardes-replay-image.zon";
+ try writeReplayImageFixture(io, gpa, fixture_path);
+ var replay_image = try Harness.initArgs(gpa, replay_exe.ptr, 24, 80, fixture_path);
+ replay_image.trace = config.trace;
+ defer replay_image.deinit();
+ try replay_image.pump(1200);
+ try replay_image.expectContains("/tmp/replay.png", "replay image pane did not render from dumped bytes");
+ }
// 2. Regression: grow the pty -> SIGWINCH -> app re-resizes. Counter bumps
// to 2 only if the resize handler is installed.