diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-02 21:41:13 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | 81992cc92743a98066ee1a9025bc74f91cf0e967 (patch) | |
| tree | 12c07f68f8ad030f6c6f8a2b3415998d0a87eee7 | |
| parent | bead9f0b78380d1428f40fdab339c1c8f88fa626 (diff) | |
| download | pardes-81992cc92743a98066ee1a9025bc74f91cf0e967.tar.gz pardes-81992cc92743a98066ee1a9025bc74f91cf0e967.zip | |
storage and replay
| -rw-r--r-- | build.zig | 45 | ||||
| -rw-r--r-- | dump.zig | 200 | ||||
| -rw-r--r-- | image.zig | 39 | ||||
| -rw-r--r-- | main.zig | 472 | ||||
| -rw-r--r-- | paint.zig | 324 | ||||
| -rw-r--r-- | replay.zig | 712 | ||||
| -rw-r--r-- | term.zig | 5 | ||||
| -rw-r--r-- | tests.zig | 129 |
8 files changed, 1585 insertions, 341 deletions
@@ -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); + } +} @@ -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 { @@ -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); + } +} @@ -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); @@ -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. |
