summaryrefslogtreecommitdiff
path: root/main.zig
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 /main.zig
parentbead9f0b78380d1428f40fdab339c1c8f88fa626 (diff)
downloadpardes-81992cc92743a98066ee1a9025bc74f91cf0e967.tar.gz
pardes-81992cc92743a98066ee1a9025bc74f91cf0e967.zip
storage and replay
Diffstat (limited to 'main.zig')
-rw-r--r--main.zig472
1 files changed, 146 insertions, 326 deletions
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);