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