summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig3728
1 files changed, 3728 insertions, 0 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
new file mode 100644
index 00000000..c4893b25
--- /dev/null
+++ b/src/pardes.zig
@@ -0,0 +1,3728 @@
+//! The pardes core: a text environment as a library, the way ghostty-vt is a
+//! library. The platform shell owns the event loop and all IO; it feeds this
+//! core events (input, pty bytes, resizes) and reads back two plain values:
+//! a Surface — the canonical cell-grid interface, which the tty shell hands to
+//! vaxis nearly verbatim and the SDL shells rasterize — and a queue of Effects,
+//! the IO the core wants performed (spawn a shell, write a pty, open a link).
+//! The core never does IO and never blocks; it is a state machine.
+//!
+//! Platform divergence inside the core is the `platform` comptime tag, used
+//! the way the stdlib uses os.tag. Click-on-text semantics live in look.zig.
+const std = @import("std");
+const ghostty_vt = @import("ghostty-vt");
+const uucode = @import("uucode");
+const modal = @import("modal.zig");
+const look = @import("look.zig");
+const syntax = @import("syntax.zig");
+pub const image = @import("image.zig");
+pub const dump = @import("dump.zig");
+
+const SYNTAX_CONTEXT_BEFORE_ROWS: usize = 0;
+const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2;
+
+pub const Platform = enum { tty, gui, web };
+pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config").platform));
+
+pub const MAX_PANES = 16;
+pub const MAX_COLS = 6;
+pub const TOPBAR_H: u16 = 1;
+pub const GUTTER: u16 = 2;
+pub const BOX_H: u16 = 1;
+pub const MINW: u16 = 10;
+pub const MINH: u16 = 3;
+pub const PREFIX_W: u16 = 5;
+
+pub const topbar_str = "Kill Newcol Tutor Debug Colors NextColor Dump";
+const pane_builtins_str = "Del";
+const file_pane_builtins_str = "Save Del";
+const pane_tail = " " ++ pane_builtins_str;
+const file_pane_tail = " " ++ file_pane_builtins_str;
+
+// Builtins: executing the name (middle-click / Tab) runs the action through the
+// ONE dispatcher (onSelect + actOnSelection), no matter where the name appears.
+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 },
+ .{ .name = "Newcol", .action = .newcol },
+ .{ .name = "Tutor", .action = .tutor },
+ .{ .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 },
+};
+
+fn onSelect(button: Mouse.Button, text: []const u8) SelectAction {
+ if (button != .middle) return .none;
+ const cmd = std.mem.trim(u8, text, " \t\r\n");
+ if (cmd.len == 0) return .none;
+ inline for (builtins) |b| {
+ if (std.mem.eql(u8, cmd, b.name)) return b.action;
+ }
+ return .send;
+}
+
+/// the whitespace-delimited word covering `col` in `str` (topbar dispatch)
+fn wordAtCol(str: []const u8, col: u16) []const u8 {
+ if (col >= str.len or str[col] == ' ') return "";
+ var lo: usize = col;
+ while (lo > 0 and str[lo - 1] != ' ') lo -= 1;
+ var hi: usize = col;
+ while (hi < str.len and str[hi] != ' ') hi += 1;
+ return str[lo..hi];
+}
+
+fn fileLineCount(content: []const u8) usize {
+ return std.mem.count(u8, content, "\n") + 1;
+}
+
+const tutor_text = @embedFile("tutor.txt");
+const rc_path = "/tmp/pardes-osc133.bash";
+pub const bash_rc =
+ \\[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc"
+ \\PS1='\[\e]133;A;cl=line\a\]'"$PS1"'\[\e]133;B\a\]'
+ \\PROMPT_COMMAND='printf "\e]133;D\a"'"${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
+ \\trap 'printf "\e]133;C\a"' DEBUG
+ \\
+;
+
+// ---- theme ----
+
+pub const accent = [3]u8{ 0x6e, 0x4f, 0xd0 };
+pub const accent_dim = [3]u8{ 0x38, 0x3c, 0x54 };
+// per-button selection highlight: distinct muted colors per mouse button
+const SEL_BG = [3][3]u8{ .{ 0x2c, 0x46, 0x74 }, .{ 0x2c, 0x60, 0x38 }, .{ 0x64, 0x2c, 0x58 } };
+const sel_fg = [3]u8{ 0xd2, 0xd2, 0xd2 };
+// modal line/char selection: warm tan, reads as keyboard-driven
+const msel_bg = [3]u8{ 0x4a, 0x3a, 0x1e };
+const msel_fg = [3]u8{ 0xd8, 0xc8, 0xa8 };
+const UNDO_MAX = 256;
+const EDIT_UNDO_MAX = 64; // terminal snapshots are ~2KB each; cap tighter
+const gray = struct {
+ const bar = [3]u8{ 0x2a, 0x2a, 0x2a }; // dim bars (overlay bg)
+ const mid = [3]u8{ 0x52, 0x52, 0x52 }; // move preview
+ const txt = [3]u8{ 0x96, 0x96, 0x96 }; // muted text
+};
+
+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 = .{ 0x2c, 0x2a, 0x3e },
+ .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 },
+ },
+ },
+};
+
+// ---- the boundary types ----
+
+pub const Color = union(enum) { default, index: u8, rgb: [3]u8 };
+
+pub const CellStyle = struct {
+ fg: Color = .default,
+ bg: Color = .default,
+ bold: bool = false,
+ dim: bool = false,
+ italic: bool = false,
+ blink: bool = false,
+ reverse: bool = false,
+ invisible: bool = false,
+ strikethrough: bool = false,
+ ul: enum { off, single, double, curly, dotted, dashed } = .off,
+};
+
+/// One surface cell. `default = true` means "never painted this frame": the
+/// shell renders it as the terminal's default cell (vaxis clear semantics).
+pub const Cell = struct {
+ text: [7]u8 = @splat(' '),
+ len: u8 = 1,
+ style: CellStyle = .{},
+ default: bool = true,
+
+ pub fn grapheme(c: *const Cell) []const u8 {
+ return c.text[0..c.len];
+ }
+};
+
+/// A pixel image riding the surface: the shell transmits/places it over the
+/// given cell rect (tty: kitty graphics; SDL: texture blit).
+pub const ImagePlace = struct {
+ pane: u8, // stable key for the shell's transmit-once cache
+ x: u16,
+ y: u16,
+ w: u16,
+ h: u16,
+ rgba: []const u8,
+ iw: usize,
+ ih: usize,
+};
+
+/// The canonical interface: what a frame of pardes IS. The tty shell writes
+/// these cells to vaxis one-to-one; the SDL shells rasterize them.
+pub const Surface = struct {
+ cols: u16 = 0,
+ rows: u16 = 0,
+ cells: []Cell = &.{},
+ cursor: ?struct { x: u16, y: u16 } = null,
+ images: [MAX_PANES]?ImagePlace = @splat(null),
+ nimages: usize = 0,
+
+ pub fn at(s: *Surface, x: u16, y: u16) *Cell {
+ std.debug.assert(x < s.cols and y < s.rows);
+ return &s.cells[@as(usize, y) * s.cols + x];
+ }
+
+ fn set(s: *Surface, x: u16, y: u16, text: []const u8, style: CellStyle) void {
+ const c = s.at(x, y);
+ std.debug.assert(text.len <= c.text.len);
+ @memcpy(c.text[0..text.len], text);
+ c.len = @intCast(text.len);
+ c.style = style;
+ c.default = false;
+ }
+
+ /// Print UTF-8 text into a row, no wrap, clipped to [x, x+w). Returns the
+ /// column after the last written cell. Wide glyphs take two cells.
+ fn print(s: *Surface, x: u16, y: u16, w: u16, text: []const u8, style: CellStyle) u16 {
+ var col = x;
+ const end = x + w;
+ var it = std.unicode.Utf8View.initUnchecked(text).iterator();
+ while (it.nextCodepointSlice()) |cp_slice| {
+ if (col >= end) break;
+ const cp = std.unicode.utf8Decode(cp_slice) catch continue;
+ if (cp == '\r') continue;
+ const width: u16 = if (cp < 0x80) 1 else uucode.get(.width, cp);
+ if (width == 0) continue;
+ s.set(col, y, cp_slice, style);
+ if (width == 2 and col + 1 < end) {
+ // spacer: empty cell under the wide glyph's tail
+ s.set(col + 1, y, "", style);
+ }
+ col += width;
+ }
+ return col;
+ }
+
+ fn fill(s: *Surface, x: u16, y: u16, w: u16, h: u16, style: CellStyle) void {
+ var yy = y;
+ while (yy < y + h) : (yy += 1) {
+ var xx = x;
+ while (xx < x + w) : (xx += 1) s.set(xx, yy, " ", style);
+ }
+ }
+
+ fn clearRect(s: *Surface, x: u16, y: u16, w: u16, h: u16) void {
+ var yy = y;
+ while (yy < y + h) : (yy += 1) {
+ var xx = x;
+ while (xx < x + w) : (xx += 1) s.at(xx, yy).* = .{};
+ }
+ }
+
+ /// swap ONLY the glyph, keeping the cell's colors — the resize-handle hint
+ fn overlayDash(s: *Surface, x: u16, y: u16, glyph: []const u8) void {
+ const c = s.at(x, y);
+ @memcpy(c.text[0..glyph.len], glyph);
+ c.len = @intCast(glyph.len);
+ c.default = false;
+ }
+};
+
+pub const Mouse = struct {
+ // sel indexing relies on left/middle/right being 0/1/2
+ pub const Button = enum { left, middle, right, wheel_up, wheel_down, none };
+ pub const Kind = enum { press, release, motion, drag };
+ button: Button,
+ kind: Kind,
+ col: u16,
+ row: u16,
+};
+
+pub const Key = struct {
+ cp: u21, // codepoint or one of the specials below
+ text: []const u8 = "",
+ ctrl: bool = false,
+ alt: bool = false,
+
+ pub const enter: u21 = 0x0D;
+ pub const escape: u21 = 0x1B;
+ pub const tab: u21 = 0x09;
+ pub const backspace: u21 = 0x7F;
+ // private-use plane for non-text keys, matching no real codepoint
+ pub const up: u21 = 0xF0001;
+ pub const down: u21 = 0xF0002;
+ pub const left: u21 = 0xF0003;
+ pub const right: u21 = 0xF0004;
+ pub const home: u21 = 0xF0005;
+ pub const end: u21 = 0xF0006;
+ pub const page_up: u21 = 0xF0007;
+ pub const page_down: u21 = 0xF0008;
+};
+
+fn is(key: Key, cp: u21) bool {
+ return key.cp == cp and !key.ctrl and !key.alt;
+}
+
+fn isC(key: Key, cp: u21) bool {
+ return key.cp == cp and key.ctrl and !key.alt;
+}
+
+pub const Event = union(enum) {
+ key: Key,
+ mouse: Mouse,
+ resize: struct { cols: u16, rows: u16 },
+ output: struct { pane: u8, bytes: []const u8 },
+ eof: struct { pane: u8 },
+ paste: []const u8,
+ pinch: f32,
+ touch_scroll: f32,
+ tick,
+};
+
+/// IO the core wants done. Payloads are inline (fixed buffers): effects are
+/// queued values with no lifetime ties back into the core.
+pub const Effect = union(enum) {
+ spawn: struct { pane: u8, cwd: Buf(256) },
+ write: struct { pane: u8, bytes: Buf(64) },
+ resize_pty: struct { pane: u8, cols: u16, rows: u16 },
+ open_link: Buf(256),
+ /// write this pane's file content to its path; the shell reads both off
+ /// the core (content is unbounded, effects are fixed-size values)
+ save_file: struct { pane: u8 },
+ /// a serialized state dump is ready in core.dump_out; write it to
+ /// dump.default_path (acme-style: another instance loads it with -l)
+ write_dump,
+ /// the yank register changed; the shell reads it off the core (OSC 52 out)
+ set_clipboard,
+ /// a normal-mode `p` wants the system clipboard; the shell requests it and
+ /// the reply comes back as Event.paste
+ get_clipboard,
+ quit,
+
+ fn Buf(comptime n: usize) type {
+ return struct {
+ data: [n]u8 = undefined,
+ len: u16 = 0,
+ pub fn slice(b: *const @This()) []const u8 {
+ return b.data[0..b.len];
+ }
+ fn from(text: []const u8) @This() {
+ var b: @This() = .{};
+ std.debug.assert(text.len <= n);
+ @memcpy(b.data[0..text.len], text);
+ b.len = @intCast(text.len);
+ return b;
+ }
+ };
+ }
+};
+
+pub const Mode = enum { normal, insert, tty };
+
+pub const EDIT_CAP = 128; // max chars in one insertion run
+pub const MAX_EDITS = 16; // max concurrent insertion runs per pane
+
+/// A char-mode insertion run, anchored at an ABSOLUTE body row (rides the
+/// scrollback, not the screen). Rendering splices it into the row, shifting
+/// the shell's text right — typed text never overwrites output.
+pub const Edit = struct {
+ row: i32 = 0,
+ col: i32 = 0,
+ len: u16 = 0,
+ text: [EDIT_CAP]u8 = undefined,
+};
+
+/// One mouse selection (block-shaped), per button. c/r are text-area relative;
+/// r counts from the tag row (body starts at BOX_H).
+pub const Sel = struct {
+ state: enum { none, dragging, done } = .none,
+ c0: i32 = 0,
+ c1: i32 = 0,
+ r0: i32 = 0,
+ r1: i32 = 0,
+};
+
+/// A modal line selection (helix `x`): whole rows [r0, r1], absolute.
+pub const LineSel = struct {
+ active: bool = false,
+ r0: i32 = 0,
+ r1: i32 = 0,
+};
+
+/// A modal char-range selection (helix `v`): the anchor lives here, the head is
+/// the pane cursor. Mutually exclusive with LineSel.
+pub const CharSel = struct {
+ active: bool = false,
+ row: i32 = 0,
+ col: i32 = 0,
+};
+
+/// Undo snapshot of a terminal pane's edit state (whole-state, dumb on purpose).
+pub const EditSnap = struct {
+ edits: [MAX_EDITS]Edit,
+ nedits: usize,
+ cur_row: i32,
+ cur_col: i32,
+ cur_off: u16,
+};
+
+/// A file pane's backing: content is gpa-owned; undo/redo are content snapshots.
+pub const File = struct {
+ path: []u8,
+ content: []u8,
+ scroll: usize = 0,
+ /// one syntax.Syn byte per content byte in the highlighted window
+ highlights: []u8 = &.{},
+ highlight_start: usize = 0,
+ syntax_dirty: bool = true,
+ undo: std.ArrayList([]u8) = .empty,
+ redo: std.ArrayList([]u8) = .empty,
+};
+
+/// An image pane's backing: the decoded+downscaled RGBA (kept so the petscii
+/// grid can re-render on resize/toggle) and the cached glyph grid.
+pub const Image = struct {
+ path: []u8,
+ petscii: bool = false,
+ pmode: image.PaletteMode = .commodore,
+ ascii: bool = true,
+ tried: bool = false, // decode attempted — exactly once
+ rgba: []u8 = &.{},
+ iw: usize = 0,
+ ih: usize = 0,
+ /// dump-loaded image bytes (gpa-owned), decoded lazily like a file read
+ raw: []u8 = &.{},
+ grid: []image.petscii.Cell = &.{},
+ gw: usize = 0,
+ gh: usize = 0,
+ grid_w: u16 = 0,
+ grid_h: u16 = 0,
+ grid_mode: image.PaletteMode = .commodore,
+ grid_ascii: bool = true,
+};
+
+pub const Pane = struct {
+ vt: ghostty_vt.Terminal,
+ stream: ghostty_vt.TerminalStream,
+ mode: Mode = .normal,
+ vweight: f32 = 1,
+ cols: u16,
+ rows: u16,
+ greet: bool = false,
+ file: ?File = null,
+ image: ?Image = null,
+ msel: LineSel = .{},
+ vsel: CharSel = .{},
+ /// one pending prefix codepoint (0 = none): `g` and `z`
+ pending: u21 = 0,
+ /// the editable tag tail: seeded with the default builtins on first touch;
+ /// edited in place by a modal one-line editor sharing the pane's mode
+ tag_tail: std.ArrayList(u8) = .empty,
+ tag_init: bool = false,
+ tag_edit: bool = false,
+ tag_sel: bool = false,
+ tag_col: u16 = 0,
+ tag_anchor: u16 = 0,
+ ed_undo: std.ArrayList(EditSnap) = .empty,
+ ed_redo: std.ArrayList(EditSnap) = .empty,
+ /// live working directory, shell-reported (setCwd); shown in the tag and
+ /// the base for look-resolution of relative paths.
+ cwd: [1024]u8 = undefined,
+ cwd_len: u16 = 0,
+ /// modal cursor, anchored at ABSOLUTE body rows. Tracks the shell cursor
+ /// until pinned by a click or a key. In normal mode cur_col is a visual
+ /// column; in insert mode it is the run anchor and cur_off the offset in it.
+ cur_pinned: bool = false,
+ cur_row: i32 = 0,
+ cur_col: i32 = 0,
+ cur_off: u16 = 0,
+ sel: [3]Sel = @splat(.{}),
+ edits: [MAX_EDITS]Edit = @splat(.{}),
+ nedits: usize = 0,
+ /// every raw pty byte, in order — the dump/replay record.
+ tty_stream: std.ArrayList(u8) = .empty,
+ /// query replies ghostty computed (DSR, DA, kitty); the stream handler has
+ /// no path to the effect queue, so they land here and sync() drains them
+ /// into write effects. Bounded: replies are tiny escape sequences.
+ reply: [256]u8 = undefined,
+ reply_len: u16 = 0,
+
+ fn cwdSlice(p: *const Pane) []const u8 {
+ return p.cwd[0..p.cwd_len];
+ }
+
+ fn isTerminal(pane: *const Pane) bool {
+ return pane.file == null and pane.image == null;
+ }
+
+ /// current scroll offset: file top line, or the scrollback offset
+ fn scroll(pane: *Pane) i32 {
+ if (pane.file) |f| return @intCast(f.scroll);
+ return @intCast(pane.vt.screens.active.pages.scrollbar().offset);
+ }
+
+ fn scrollBy(pane: *Pane, delta: i32) void {
+ if (pane.file) |*f| {
+ const max: i64 = @intCast(modal.lineCount(f.content) -| 1);
+ const n = std.math.clamp(@as(i64, @intCast(f.scroll)) + delta, 0, max);
+ const next: usize = @intCast(n);
+ if (next != f.scroll) {
+ f.scroll = next;
+ f.syntax_dirty = true;
+ }
+ } else {
+ pane.vt.screens.active.scroll(.{ .delta_row = @intCast(delta) });
+ }
+ }
+
+ fn ensureCursorVisible(pane: *Pane) void {
+ const off = pane.scroll();
+ const last = off + @as(i32, pane.rows) - 1;
+ if (pane.cur_row < off) pane.scrollBy(pane.cur_row - off) else if (pane.cur_row > last) pane.scrollBy(pane.cur_row - last);
+ }
+
+ /// the last navigable row: file -> last content line; terminal -> viewport bottom
+ fn lastNavRow(pane: *Pane) i32 {
+ if (pane.file) |f| return @intCast(modal.lineCount(f.content) -| 1);
+ return pane.scroll() + @as(i32, pane.rows) - 1;
+ }
+
+ /// the insertion run anchored exactly at (row, col), if any
+ fn runAt(pane: *Pane, row: i32, col: i32) ?usize {
+ for (pane.edits[0..pane.nedits], 0..) |e, i| {
+ if (e.row == row and e.col == col) return i;
+ }
+ return null;
+ }
+
+ fn pinCursor(pane: *Pane) void {
+ if (pane.cur_pinned) return;
+ const off = pane.scroll();
+ if (pane.file != null) {
+ pane.cur_row = off;
+ pane.cur_col = 0;
+ } else {
+ pane.cur_row = @as(i32, @intCast(pane.vt.screens.active.cursor.y)) + off;
+ pane.cur_col = @intCast(pane.vt.screens.active.cursor.x);
+ }
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ }
+
+ /// rendered column of a position: original col + earlier-on-row insertions
+ /// + the offset into the run at this col.
+ fn renderedCol(pane: *Pane, row: i32, col: i32, off: u16) i32 {
+ var rc: i32 = col + @as(i32, off);
+ for (pane.edits[0..pane.nedits]) |e| {
+ if (e.row == row and e.col < col) rc += @as(i32, e.len);
+ }
+ return rc;
+ }
+};
+
+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 },
+ select: struct { id: usize, button: Mouse.Button },
+};
+
+const Rect = struct { x: u16, y: u16, w: u16, h: u16 };
+
+pub const Options = struct {
+ tty_only: bool = false,
+ tty_toggle: u21 = 'b',
+ /// load a dump of another instance instead of spawning shells (acme -l)
+ load_path: ?[]const u8 = null,
+ /// Initial grid. Shell contract: for LIVE sessions leave these at the
+ /// defaults and deliver the real size as the first resize EVENT — the core
+ /// defers each shell's greeting until after a resize (so `ls` wraps to the
+ /// real pane width); pre-sizing here means that resize never fires and the
+ /// greeting never runs. Pre-size only for dump loads (nothing greets, and
+ /// it avoids reflowing replayed content twice).
+ cols: u16 = 80,
+ rows: u16 = 24,
+};
+
+pub const Pardes = struct {
+ gpa: std.mem.Allocator,
+ opts: Options,
+ screen_w: u16,
+ screen_h: u16,
+ resize_count: usize = 0,
+
+ panes: [MAX_PANES]?*Pane = @splat(null),
+ // layout: columns own x by weight; panes own y by vweight within a column.
+ ncol: usize = 0,
+ col_weight: [MAX_COLS]f32 = @splat(1),
+ col_terms: [MAX_COLS][MAX_PANES]usize = undefined,
+ col_n: [MAX_COLS]usize = @splat(0),
+ // derived each sync
+ rects: [MAX_PANES]Rect = undefined,
+ col_x: [MAX_COLS]u16 = undefined,
+ col_w: [MAX_COLS]u16 = undefined,
+
+ active: usize = 0,
+ theme_idx: usize = 0,
+ colors_on: bool = true,
+ show_debug: bool = false,
+ /// kitty graphics support, reported by the shell (image panes fall back
+ /// to the petscii matcher without it)
+ kitty_ok: bool = false,
+ quit: bool = false,
+ drag: Drag = .none,
+ hover_col: u16 = 0,
+ hover_row: u16 = 0,
+ ctrl_w_pending: bool = false,
+ ov_pinch_scale: f32 = 1.0,
+ ov_touch_scroll_delta: f32 = 0.0,
+ /// middle+left chord argument: a kept left selection captured by a left
+ /// press during an active middle select-drag; consumed on middle release
+ chord_arg: ?[]u8 = null,
+
+ effects: [64]Effect = undefined,
+ effects_head: usize = 0,
+ effects_tail: usize = 0,
+
+ /// modal yank register (gpa-owned); a yank also mirrors out to the system
+ /// clipboard via the set_clipboard effect.
+ yank: ?[]u8 = null,
+ /// the last serialized dump (gpa-owned), read by the write_dump effect
+ dump_out: ?[]u8 = null,
+
+ surface: Surface = .{},
+ /// per-update scratch (paneCursorLines, selection text); reset each update
+ scratch: std.heap.ArenaAllocator,
+
+ pub fn init(gpa: std.mem.Allocator, opts: Options) !*Pardes {
+ syntax.compileStartupQueries();
+ const p = try gpa.create(Pardes);
+ p.* = .{ .gpa = gpa, .opts = opts, .screen_w = opts.cols, .screen_h = opts.rows, .scratch = .init(gpa) };
+ if (opts.tty_only) {
+ _ = try p.newPane(0);
+ p.panes[0].?.mode = .tty;
+ p.ncol = 1;
+ p.col_n[0] = 1;
+ p.col_terms[0][0] = 0;
+ } else {
+ // editor default: two columns, the left one split in two.
+ _ = try p.newPane(0);
+ _ = try p.newPane(1);
+ _ = try p.newPane(2);
+ for (p.panes[0..3]) |slot| slot.?.greet = true;
+ p.ncol = 2;
+ p.col_n[0] = 2;
+ p.col_terms[0][0] = 0;
+ p.col_terms[0][1] = 1;
+ p.col_n[1] = 1;
+ p.col_terms[1][0] = 2;
+ }
+ p.sync();
+ return p;
+ }
+
+ pub fn deinit(p: *Pardes) void {
+ for (&p.panes) |*slot| if (slot.*) |pane| {
+ p.deinitPane(pane);
+ slot.* = null;
+ };
+ const gpa = p.gpa;
+ if (p.yank) |y| gpa.free(y);
+ if (p.dump_out) |d| gpa.free(d);
+ if (p.chord_arg) |a| gpa.free(a);
+ p.scratch.deinit();
+ gpa.free(p.surface.cells);
+ gpa.destroy(p);
+ }
+
+ fn deinitPane(p: *Pardes, pane: *Pane) void {
+ if (pane.image) |*iv| {
+ p.gpa.free(iv.path);
+ if (iv.rgba.len > 0) p.gpa.free(iv.rgba);
+ if (iv.raw.len > 0) p.gpa.free(iv.raw);
+ if (iv.grid.len > 0) p.gpa.free(iv.grid);
+ }
+ if (pane.file) |*f| {
+ p.gpa.free(f.path);
+ p.gpa.free(f.content);
+ if (f.highlights.len > 0) p.gpa.free(f.highlights);
+ for (f.undo.items) |s| p.gpa.free(s);
+ f.undo.deinit(p.gpa);
+ for (f.redo.items) |s| p.gpa.free(s);
+ f.redo.deinit(p.gpa);
+ }
+ pane.tag_tail.deinit(p.gpa);
+ pane.ed_undo.deinit(p.gpa);
+ pane.ed_redo.deinit(p.gpa);
+ pane.stream.deinit();
+ pane.vt.deinit(p.gpa);
+ pane.tty_stream.deinit(p.gpa);
+ p.gpa.destroy(pane);
+ }
+
+ /// Allocate a pane slot with a live terminal emulator and queue the spawn
+ /// effect (optionally in a directory); the shell answers by forking a pty
+ /// and wiring reads back as Event.output for this pane id.
+ fn newPane(p: *Pardes, id: usize) !*Pane {
+ return p.newShell(id, "");
+ }
+
+ fn newShell(p: *Pardes, id: usize, cwd: []const u8) !*Pane {
+ std.debug.assert(p.panes[id] == null);
+ const pane = try p.gpa.create(Pane);
+ pane.* = .{
+ .vt = try ghostty_vt.Terminal.init(p.gpa, .{
+ .cols = p.screen_w,
+ .rows = p.screen_h,
+ .max_scrollback = 16 * 1024 * 1024,
+ }),
+ .stream = undefined,
+ .cols = p.screen_w,
+ .rows = p.screen_h,
+ };
+ pane.stream = pane.vt.vtStream();
+ // answer terminal queries (DSR/DA/kitty) back into the pty, else
+ // crossterm apps (nushell, helix, fzf) block on the reply forever.
+ pane.stream.handler.effects.write_pty = ptyReport;
+ pane.stream.handler.effects.device_attributes = ptyDeviceAttrs;
+ p.panes[id] = pane;
+ p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(cwd) } });
+ return pane;
+ }
+
+ /// a doc pane (file/image): no pty, no spawn; a stub 1x1 emulator only
+ /// because the shared machinery touches its allocator-owned bits.
+ fn newDocPane(p: *Pardes, id: usize) !*Pane {
+ std.debug.assert(p.panes[id] == null);
+ const pane = try p.gpa.create(Pane);
+ pane.* = .{
+ .vt = try ghostty_vt.Terminal.init(p.gpa, .{ .cols = 1, .rows = 1 }),
+ .stream = undefined,
+ .cols = p.screen_w,
+ .rows = p.screen_h,
+ };
+ pane.stream = pane.vt.vtStream();
+ p.panes[id] = pane;
+ return pane;
+ }
+
+ fn freeSlot(p: *Pardes) ?usize {
+ return for (p.panes, 0..) |slot, i| {
+ if (slot == null) break i;
+ } else null;
+ }
+
+ /// chunk arbitrary-length bytes into fixed-size write effects (order kept)
+ fn emitWrite(p: *Pardes, id: usize, bytes: []const u8) void {
+ var off: usize = 0;
+ while (off < bytes.len) {
+ const n = @min(bytes.len - off, 64);
+ p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(bytes[off .. off + n]) } });
+ off += n;
+ }
+ }
+
+ /// The shell reports the spawned pane's working directory (and later cwd
+ /// changes it observes, e.g. via /proc/<pid>/cwd before each frame).
+ pub fn setCwd(p: *Pardes, id: usize, cwd: []const u8) void {
+ const pane = p.panes[id] orelse return;
+ const n = @min(cwd.len, pane.cwd.len);
+ @memcpy(pane.cwd[0..n], cwd[0..n]);
+ pane.cwd_len = @intCast(n);
+ }
+
+ fn emit(p: *Pardes, e: Effect) void {
+ std.debug.assert(p.effects_tail - p.effects_head < p.effects.len);
+ p.effects[p.effects_tail % p.effects.len] = e;
+ p.effects_tail += 1;
+ }
+
+ pub fn nextEffect(p: *Pardes) ?Effect {
+ if (p.effects_head == p.effects_tail) return null;
+ const e = p.effects[p.effects_head % p.effects.len];
+ p.effects_head += 1;
+ return e;
+ }
+
+ pub fn update(p: *Pardes, ev: Event) void {
+ switch (ev) {
+ .resize => |sz| {
+ p.screen_w = sz.cols;
+ p.screen_h = sz.rows;
+ p.resize_count += 1;
+ },
+ .output => |o| {
+ const pane = p.panes[o.pane] orelse return;
+ pane.tty_stream.appendSlice(p.gpa, o.bytes) catch {};
+ pane.stream.nextSlice(o.bytes);
+ // follow new output to the bottom — except in the pane being
+ // actively navigated in a text mode.
+ if (!(o.pane == p.active and pane.mode != .tty))
+ pane.vt.screens.active.scroll(.active);
+ },
+ .eof => |e| p.removePane(e.pane),
+ .key => |key| p.handleKey(key),
+ .mouse => |m| p.handleMouse(m),
+ .paste => |bytes| {
+ // the OSC 52 system-clipboard reply for a normal-mode `p`
+ p.setYank(bytes);
+ if (p.panes[p.active]) |pane| p.normalPaste(pane);
+ },
+ .pinch => |scale| p.ov_pinch_scale = scale,
+ .touch_scroll => |delta| p.ov_touch_scroll_delta = delta,
+ .tick => {},
+ }
+ _ = p.scratch.reset(.retain_capacity);
+ p.sync();
+ }
+
+ fn setYank(p: *Pardes, text: []const u8) void {
+ if (p.yank) |y| p.gpa.free(y);
+ p.yank = p.gpa.dupe(u8, text) catch null;
+ p.emit(.{ .set_clipboard = {} });
+ }
+
+ // ---- tag + selection text (chord sources) ----
+
+ /// the live tag prefix: mode + cwd/path (image: the whole toggle tag)
+ fn tagPrefix(p: *Pardes, pane: *Pane) ![]u8 {
+ const arena = p.scratch.allocator();
+ if (pane.image) |iv| {
+ const mode = if (iv.pmode == .commodore) "C64" else "Term";
+ return std.fmt.allocPrint(arena, "img {s} Petscii {s} Ascii {s}", .{ iv.path, mode, pane_builtins_str });
+ }
+ if (pane.file) |f| {
+ const fmode = if (pane.mode == .insert) "in" else "nm";
+ return std.fmt.allocPrint(arena, "{s} {s}", .{ fmode, f.path });
+ }
+ const mode = switch (pane.mode) {
+ .normal => "nm",
+ .insert => "in",
+ .tty => "sy",
+ };
+ return std.fmt.allocPrint(arena, "{s} {s}", .{ mode, pane.cwdSlice() });
+ }
+
+ /// the editable tail: the user's edited buffer once touched, else defaults
+ fn curTail(pane: *Pane) []const u8 {
+ if (pane.image != null) return "";
+ if (pane.tag_init) return pane.tag_tail.items;
+ return if (pane.file != null) file_pane_tail else pane_tail;
+ }
+
+ /// focus the tag for editing, seeding the tail on first touch and placing
+ /// the cursor under the click (a column within the text area)
+ fn enterTagEdit(p: *Pardes, pane: *Pane, click_col: i32) void {
+ if (pane.image != null) return; // images keep a live toggle tag
+ const pfx = p.tagPrefix(pane) catch return;
+ if (!pane.tag_init) {
+ pane.tag_tail.appendSlice(p.gpa, if (pane.file != null) file_pane_tail else pane_tail) catch return;
+ pane.tag_init = true;
+ }
+ pane.tag_edit = true;
+ pane.tag_sel = false;
+ pane.mode = .normal;
+ pane.pending = 0;
+ const cc = std.math.clamp(click_col - @as(i32, @intCast(pfx.len)), 0, @as(i32, @intCast(pane.tag_tail.items.len)));
+ pane.tag_col = @intCast(cc);
+ }
+
+ fn exitTagEdit(pane: *Pane) void {
+ pane.tag_edit = false;
+ pane.tag_sel = false;
+ }
+
+ fn tagSelBounds(pane: *Pane) struct { lo: usize, hi: usize } {
+ const a: usize = pane.tag_anchor;
+ const c: usize = pane.tag_col;
+ return .{ .lo = @min(a, c), .hi = @max(a, c) };
+ }
+
+ /// the text a tag Enter/Tab chord acts on: the char selection, else the
+ /// file-ish word under the cursor
+ fn tagChordText(p: *Pardes, pane: *Pane) ?[]const u8 {
+ const arena = p.scratch.allocator();
+ const tail = pane.tag_tail.items;
+ if (pane.tag_sel) {
+ const b = tagSelBounds(pane);
+ const hi = @min(b.hi + 1, tail.len);
+ if (hi > b.lo) return arena.dupe(u8, tail[b.lo..hi]) catch null;
+ return null;
+ }
+ const col: usize = @min(@as(usize, pane.tag_col), tail.len);
+ var lo = col;
+ while (lo > 0 and look.isFileChar(tail[lo - 1])) lo -= 1;
+ var hi = col;
+ while (hi < tail.len and look.isFileChar(tail[hi])) hi += 1;
+ if (hi > lo) return arena.dupe(u8, tail[lo..hi]) catch null;
+ return null;
+ }
+
+ /// tag-edit key dispatch: a modal one-line editor over the tail, sharing
+ /// the pane's mode. Newlines are always dropped.
+ fn handleTagKey(p: *Pardes, pane: *Pane, key: Key) void {
+ if (pane.mode == .insert) return p.tagInsertKey(pane, key);
+ const lines = [1][]const u8{pane.tag_tail.items};
+ const cur: modal.Cursor = .{ .row = 0, .col = pane.tag_col };
+
+ if (pane.pending == 'g') {
+ pane.pending = 0;
+ const m: modal.Cursor = if (is(key, 'g')) modal.gotoFirst() else if (is(key, 'e')) modal.gotoLast(&lines) else if (is(key, 'h')) modal.lineStart(cur) else if (is(key, 'l')) modal.lineEnd(&lines, cur) else cur;
+ pane.tag_col = @intCast(m.col);
+ return;
+ }
+ if (is(key, 'g')) {
+ pane.pending = 'g';
+ return;
+ }
+ const moved: ?modal.Cursor =
+ if (is(key, 'h') or is(key, Key.left)) modal.charLeft(cur) else if (is(key, 'l') or is(key, Key.right)) modal.charRight(&lines, cur) else if (is(key, 'w')) modal.nextWordStart(&lines, cur, false) else if (is(key, 'W')) modal.nextWordStart(&lines, cur, true) else if (is(key, 'b')) modal.prevWordStart(&lines, cur, false) else if (is(key, 'B')) modal.prevWordStart(&lines, cur, true) else if (is(key, 'e')) modal.nextWordEnd(&lines, cur, false) else if (is(key, 'E')) modal.nextWordEnd(&lines, cur, true) else if (is(key, '0') or is(key, Key.home)) modal.lineStart(cur) else if (is(key, '$') or is(key, Key.end)) modal.lineEnd(&lines, cur) else if (is(key, '^')) modal.firstNonWsOf(&lines, cur) else null;
+ if (moved) |m| {
+ pane.tag_col = @intCast(m.col);
+ return;
+ }
+ if (is(key, 'i')) return tagEnterInsert(pane, pane.tag_col);
+ if (is(key, 'a')) return tagEnterInsert(pane, @intCast(modal.charRight(&lines, cur).col));
+ if (is(key, 'I')) return tagEnterInsert(pane, @intCast(modal.firstNonWsOf(&lines, cur).col));
+ if (is(key, 'A')) return tagEnterInsert(pane, @intCast(pane.tag_tail.items.len));
+ if (is(key, 'v')) {
+ pane.tag_sel = !pane.tag_sel;
+ if (pane.tag_sel) pane.tag_anchor = pane.tag_col;
+ return;
+ }
+ if (is(key, 'x')) { // select the whole one-line tail
+ const len = pane.tag_tail.items.len;
+ if (len > 0) {
+ pane.tag_sel = true;
+ pane.tag_anchor = 0;
+ pane.tag_col = @intCast(len - 1);
+ }
+ return;
+ }
+ if (is(key, 'd')) return p.tagDelete(pane, false);
+ if (is(key, 'c')) return p.tagDelete(pane, true);
+ if (is(key, 'y')) return p.tagYank(pane);
+ if (is(key, 'p')) return p.tagPaste(pane);
+ // Esc escalates: clear the selection, else hand focus back to the body
+ if (is(key, Key.escape)) {
+ if (pane.tag_sel) pane.tag_sel = false else exitTagEdit(pane);
+ return;
+ }
+ }
+
+ fn tagEnterInsert(pane: *Pane, col: u16) void {
+ pane.tag_col = @min(col, @as(u16, @intCast(pane.tag_tail.items.len)));
+ pane.tag_sel = false;
+ pane.mode = .insert;
+ pane.pending = 0;
+ }
+
+ fn tagInsertKey(p: *Pardes, pane: *Pane, key: Key) void {
+ if (is(key, Key.escape)) {
+ pane.mode = .normal;
+ return;
+ }
+ if (key.text.len > 0) {
+ for (key.text) |ch| {
+ if (ch == '\n' or ch == '\r') continue; // one-line tag
+ pane.tag_tail.insert(p.gpa, pane.tag_col, ch) catch return;
+ pane.tag_col += 1;
+ }
+ return;
+ }
+ switch (key.cp) {
+ Key.backspace => if (pane.tag_col > 0) {
+ _ = pane.tag_tail.orderedRemove(pane.tag_col - 1);
+ pane.tag_col -= 1;
+ },
+ Key.left => if (pane.tag_col > 0) {
+ pane.tag_col -= 1;
+ },
+ Key.right => if (pane.tag_col < pane.tag_tail.items.len) {
+ pane.tag_col += 1;
+ },
+ Key.home => pane.tag_col = 0,
+ Key.end => pane.tag_col = @intCast(pane.tag_tail.items.len),
+ else => {},
+ }
+ }
+
+ /// d/c: delete the selection (or the char under the cursor), yanking it;
+ /// c then enters insert
+ fn tagDelete(p: *Pardes, pane: *Pane, change: bool) void {
+ const tail = pane.tag_tail.items;
+ var lo: usize = undefined;
+ var count: usize = undefined;
+ if (pane.tag_sel) {
+ const b = tagSelBounds(pane);
+ lo = b.lo;
+ count = @min(b.hi + 1, tail.len) - b.lo;
+ } else {
+ lo = @min(@as(usize, pane.tag_col), tail.len);
+ count = if (lo < tail.len) 1 else 0;
+ }
+ if (count > 0) {
+ p.setYank(tail[lo .. lo + count]);
+ pane.tag_tail.replaceRange(p.gpa, lo, count, "") catch return;
+ }
+ pane.tag_col = @intCast(lo);
+ pane.tag_sel = false;
+ if (change) pane.mode = .insert;
+ }
+
+ fn tagYank(p: *Pardes, pane: *Pane) void {
+ const tail = pane.tag_tail.items;
+ if (pane.tag_sel) {
+ const b = tagSelBounds(pane);
+ const hi = @min(b.hi + 1, tail.len);
+ p.setYank(tail[b.lo..hi]);
+ pane.tag_col = @intCast(b.lo);
+ pane.tag_sel = false;
+ } else p.setYank(tail); // whole tail, like a line yank
+ }
+
+ fn tagPaste(p: *Pardes, pane: *Pane) void {
+ const y = p.yank orelse return;
+ const col: usize = @min(@as(usize, pane.tag_col), pane.tag_tail.items.len);
+ var at = col;
+ for (y) |ch| {
+ if (ch == '\n' or ch == '\r') continue;
+ pane.tag_tail.insert(p.gpa, at, ch) catch break;
+ at += 1;
+ }
+ pane.tag_col = @intCast(at);
+ }
+
+ /// tag + '\n' + body: the full selectable pane text (row 0 = the tag)
+ fn paneText(p: *Pardes, pane: *Pane) ![]u8 {
+ const arena = p.scratch.allocator();
+ const pfx = try p.tagPrefix(pane);
+ const body = try p.bodyText(arena, pane);
+ return std.fmt.allocPrint(arena, "{s}{s}\n{s}", .{ pfx, curTail(pane), body });
+ }
+
+ /// block-selected text, newline-joined per row; reads the rendered screen
+ /// so typed runs and tty output select identically. Scratch-owned.
+ fn selectionText(p: *Pardes, pane: *Pane, sl: Sel) ![]const u8 {
+ const arena = p.scratch.allocator();
+ const r0 = @min(sl.r0, sl.r1);
+ const r1 = @max(sl.r0, sl.r1);
+ const c0: usize = @intCast(@max(0, @min(sl.c0, sl.c1)));
+ const c1: usize = @intCast(@max(0, @max(sl.c0, sl.c1)));
+ const text = try p.paneText(pane);
+ var out: std.ArrayList(u8) = .empty;
+ var it = std.mem.splitAny(u8, text, "\n");
+ var v: i32 = 0;
+ var first = true;
+ while (it.next()) |line| : (v += 1) {
+ if (v < r0 or v > r1) continue;
+ if (!first) try out.append(arena, '\n');
+ first = false;
+ const b0 = @min(c0, line.len);
+ const b1 = @min(c1 + 1, line.len);
+ try out.appendSlice(arena, line[b0..b1]);
+ }
+ return out.items;
+ }
+
+ /// acme: a no-drag middle/right click expands to the file-ish word under it
+ fn expandSel(p: *Pardes, pane: *Pane, sl: *Sel) void {
+ if (sl.c0 != sl.c1 or sl.r0 != sl.r1) return;
+ const text = p.paneText(pane) catch return;
+ var it = std.mem.splitAny(u8, text, "\n");
+ var v: i32 = 0;
+ while (it.next()) |line| : (v += 1) {
+ if (v != sl.r0) continue;
+ const col: usize = @intCast(@max(0, sl.c0));
+ if (col > line.len) return;
+ var hi = col;
+ while (hi < line.len and look.isFileChar(line[hi])) hi += 1;
+ var lo = col;
+ while (lo > 0 and look.isFileChar(line[lo - 1])) lo -= 1;
+ if (hi > lo) {
+ sl.c0 = @intCast(lo);
+ sl.c1 = @intCast(hi - 1);
+ }
+ return;
+ }
+ }
+
+ /// the word under the modal cursor as a pane-local selection (paneText
+ /// coords: row 0 is the tag; file panes carry the line-number prefix)
+ fn cursorWordSel(pane: *Pane) Sel {
+ const vrow = (pane.cur_row - pane.scroll()) + @as(i32, BOX_H);
+ const vcol = if (pane.file != null) pane.cur_col + @as(i32, PREFIX_W) else pane.cur_col;
+ return .{ .state = .done, .c0 = vcol, .c1 = vcol, .r0 = vrow, .r1 = vrow };
+ }
+
+ /// the active modal selection as text (v range or x lines), if any
+ fn currentSelText(p: *Pardes, pane: *Pane) ?[]const u8 {
+ if (pane.vsel.active) return p.vselText(pane);
+ if (!pane.msel.active) return null;
+ const r0 = @min(pane.msel.r0, pane.msel.r1);
+ const r1 = @max(pane.msel.r0, pane.msel.r1);
+ if (pane.file) |f| {
+ const arena = p.scratch.allocator();
+ var buf: std.ArrayList(u8) = .empty;
+ const lo: usize = @intCast(@max(0, r0));
+ const hi: usize = @intCast(@max(0, r1));
+ var r: usize = lo;
+ while (r <= hi) : (r += 1) {
+ if (r > lo) buf.append(arena, '\n') catch break;
+ buf.appendSlice(arena, modal.lineSlice(f.content, r)) catch break;
+ }
+ return buf.items;
+ }
+ return p.yankRows(pane, r0, r1);
+ }
+
+ fn handleKey(p: *Pardes, key: Key) void {
+ const pane = p.panes[p.active] orelse return;
+ // Ctrl-w prefix: helix-style directional pane focus (h/j/k/l)
+ if (p.ctrl_w_pending) {
+ p.ctrl_w_pending = false;
+ if (is(key, 'h') or is(key, Key.left)) p.focusDir(.left) else if (is(key, 'l') or is(key, Key.right)) p.focusDir(.right) else if (is(key, 'k') or is(key, Key.up)) p.focusDir(.up) else if (is(key, 'j') or is(key, Key.down)) p.focusDir(.down);
+ if (p.panes[p.active]) |nt| nt.pending = 0;
+ return;
+ }
+ if (isC(key, 'w')) {
+ p.ctrl_w_pending = true;
+ return;
+ }
+ // global window ops (any mode): Alt-n new terminal below, Alt-c move
+ // the active terminal into a fresh column
+ if (key.alt and !key.ctrl and key.cp == 'n') {
+ const free = p.freeSlot() orelse return;
+ const nt = p.newShell(free, "") catch return;
+ nt.greet = true;
+ const f = p.layoutFindTerm(p.active).?;
+ p.layoutInsert(f.col, f.idx + 1, free);
+ p.active = free;
+ return;
+ }
+ if (key.alt and !key.ctrl and key.cp == 'c') {
+ const f = p.layoutFindTerm(p.active).?;
+ if (p.ncol < MAX_COLS and p.col_n[f.col] > 1) {
+ p.layoutRemove(p.active);
+ p.layoutAppendColumn(p.active);
+ }
+ return;
+ }
+ // the configured Ctrl-key toggles raw tty mode in and out (terminals
+ // only); tty is deliberately off the normal editing path
+ if (pane.isTerminal() and key.ctrl and key.cp == p.opts.tty_toggle) {
+ if (pane.mode == .tty) {
+ pane.mode = .normal;
+ pane.pending = 0;
+ } else p.enterTty(p.active);
+ return;
+ }
+ // normal-mode Enter/Tab: the acme chords — look at (Enter) or execute
+ // (Tab) the modal selection, else the file-ish word under the cursor
+ // (on the tag tail when it's focused for editing, else the body).
+ if (pane.mode == .normal and (is(key, Key.enter) or is(key, Key.tab))) {
+ const btn: Mouse.Button = if (is(key, Key.tab)) .middle else .right;
+ if (pane.tag_edit) {
+ if (p.tagChordText(pane)) |txt| {
+ pane.tag_sel = false;
+ p.actOnSelection(btn, p.active, txt, null);
+ }
+ return;
+ }
+ pane.pinCursor();
+ if (p.currentSelText(pane)) |txt| {
+ pane.vsel.active = false;
+ pane.msel.active = false;
+ p.actOnSelection(btn, p.active, txt, null);
+ } else {
+ var sel = cursorWordSel(pane);
+ p.expandSel(pane, &sel);
+ const word = p.selectionText(pane, sel) catch return;
+ p.actOnSelection(btn, p.active, word, null);
+ }
+ return;
+ }
+ // tag editing intercepts every other key: a modal one-line editor
+ // over the tail, sharing the pane's mode.
+ if (pane.tag_edit) {
+ p.handleTagKey(pane, key);
+ return;
+ }
+ // normal-mode `p` pastes the SYSTEM clipboard: an async OSC 52 round
+ // trip — request here, the reply arrives later as Event.paste.
+ if (pane.mode == .normal and is(key, 'p')) {
+ p.emit(.get_clipboard);
+ return;
+ }
+ switch (pane.mode) {
+ .normal => p.handleNormal(pane, key),
+ .insert => {
+ if (key.cp == Key.escape and !key.ctrl and !key.alt) {
+ pane.mode = .normal;
+ pane.msel.active = false;
+ pane.pending = 0;
+ return;
+ }
+ p.handleInsert(pane, key);
+ },
+ .tty => p.forwardKey(p.active, key),
+ }
+ }
+
+ /// Enter raw tty. If the shell is at a prompt and the modal cursor sits on
+ /// the input line, move the shell's REAL cursor there via ghostty's
+ /// promptClickMove (arrow keys the shell understands) — dropping into tty
+ /// lands where you navigated. Requires OSC 133 cl=line (our bash rc).
+ fn enterTty(p: *Pardes, id: usize) void {
+ const pane = p.panes[id] orelse return;
+ const screen = pane.vt.screens.active;
+ if (pane.vt.cursorIsAtPrompt()) {
+ const off = pane.scroll();
+ const vp_row: i32 = pane.cur_row - off;
+ if (vp_row >= 0) {
+ if (screen.pages.pin(.{ .viewport = .{ .x = @intCast(@max(0, pane.cur_col)), .y = @intCast(vp_row) } })) |click_pin| {
+ const cursor_pin = screen.cursor.page_pin.*;
+ var pit = cursor_pin.promptIterator(.left_up, null);
+ if (pit.next()) |prompt_pin| {
+ if (!click_pin.before(prompt_pin)) {
+ const m = screen.promptClickMove(click_pin);
+ var i: usize = 0;
+ while (i < m.left) : (i += 1) p.emitWrite(id, "\x1b[D");
+ i = 0;
+ while (i < m.right) : (i += 1) p.emitWrite(id, "\x1b[C");
+ }
+ }
+ }
+ }
+ }
+ pane.mode = .tty;
+ pane.msel.active = false;
+ pane.vsel.active = false;
+ pane.pending = 0;
+ }
+
+ /// move focus to the nearest pane in `dir` (overlap-aware nearest edge)
+ fn focusDir(p: *Pardes, dir: enum { left, right, up, down }) void {
+ const a = p.rects[p.active];
+ var best: ?usize = null;
+ var best_d: i32 = 0;
+ for (p.panes, 0..) |slot, i| {
+ if (slot == null or i == p.active) continue;
+ const r = p.rects[i];
+ const vov = a.y < r.y + r.h and r.y < a.y + a.h;
+ const hov = a.x < r.x + r.w and r.x < a.x + a.w;
+ const ok = switch (dir) {
+ .left => r.x + r.w <= a.x and vov,
+ .right => r.x >= a.x + a.w and vov,
+ .up => r.y + r.h <= a.y and hov,
+ .down => r.y >= a.y + a.h and hov,
+ };
+ if (!ok) continue;
+ const d: i32 = switch (dir) {
+ .left => @as(i32, a.x) - @as(i32, r.x + r.w),
+ .right => @as(i32, r.x) - @as(i32, a.x + a.w),
+ .up => @as(i32, a.y) - @as(i32, r.y + r.h),
+ .down => @as(i32, r.y) - @as(i32, a.y + a.h),
+ };
+ if (best == null or d < best_d) {
+ best = i;
+ best_d = d;
+ }
+ }
+ if (best) |b| p.active = b;
+ }
+
+ // ---- move-drag placement ----
+
+ const MovePlacement = struct {
+ preview_col: usize,
+ above_id: usize,
+ row: u16,
+ above_y: u16,
+ above_h: u16,
+ };
+
+ fn targetColumn(p: *Pardes, cur_x: u16) usize {
+ var tc: usize = if (p.ncol > 0) p.ncol - 1 else 0;
+ for (0..p.ncol) |c| {
+ if (cur_x >= p.col_x[c] and cur_x < p.col_x[c] + p.col_w[c]) {
+ tc = c;
+ break;
+ }
+ }
+ return tc;
+ }
+
+ fn splitRowForExtent(y: u16, h: u16, cur_y: u16) ?u16 {
+ if (h < 2) return null;
+ const min_each: u16 = if (h >= MINH * 2) MINH else 1;
+ const lo = y +| min_each;
+ const hi = y + h - min_each;
+ if (lo > hi) return y + h / 2;
+ return std.math.clamp(cur_y, lo, hi);
+ }
+
+ fn movePlacement(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) ?MovePlacement {
+ const src = p.layoutFindTerm(id) orelse return null;
+ const tc = p.targetColumn(cur_x);
+ if (tc == src.col and p.col_n[src.col] == 1) return null;
+ if (tc == src.col) {
+ const sr = p.rects[id];
+ if (cur_y >= sr.y and cur_y < sr.y + sr.h) return null;
+ }
+ var heights: [MAX_PANES]u16 = @splat(0);
+ for (0..p.ncol) |c| {
+ for (0..p.col_n[c]) |k| {
+ const pid = p.col_terms[c][k];
+ heights[pid] = p.rects[pid].h;
+ }
+ }
+ if (p.col_n[src.col] > 1) {
+ const sib = if (src.idx > 0) p.col_terms[src.col][src.idx - 1] else p.col_terms[src.col][src.idx + 1];
+ heights[sib] +|= p.rects[id].h;
+ }
+ var y: u16 = TOPBAR_H;
+ var last: ?MovePlacement = null;
+ for (0..p.col_n[tc]) |k| {
+ const pid = p.col_terms[tc][k];
+ if (pid == id) continue;
+ const h = heights[pid];
+ const row = splitRowForExtent(y, h, cur_y) orelse {
+ y +|= h;
+ continue;
+ };
+ const placement: MovePlacement = .{
+ .preview_col = tc,
+ .above_id = pid,
+ .row = row,
+ .above_y = y,
+ .above_h = h,
+ };
+ last = placement;
+ if (cur_y < y + h) return placement;
+ y +|= h;
+ }
+ return last;
+ }
+
+ /// drop pane `id` below the pane under the cursor, converting on-screen
+ /// heights to weights so ONLY the split pane changes size
+ fn moveTerm(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) void {
+ const placement = p.movePlacement(id, cur_x, cur_y) orelse return;
+ const src = p.layoutFindTerm(id) orelse return;
+ const source_multi = p.col_n[src.col] > 1;
+ var heights: [MAX_PANES]u16 = @splat(0);
+ for (0..p.ncol) |c| {
+ for (0..p.col_n[c]) |k| {
+ const pid = p.col_terms[c][k];
+ heights[pid] = p.rects[pid].h;
+ }
+ }
+ p.layoutRemove(id);
+ if (source_multi and src.col < p.ncol and p.col_n[src.col] > 0) {
+ const sib = if (src.idx > 0) p.col_terms[src.col][src.idx - 1] else p.col_terms[src.col][src.idx];
+ heights[sib] +|= p.rects[id].h;
+ }
+ const af = p.layoutFindTerm(placement.above_id) orelse return;
+ const upper_h = @max(1, placement.row -| placement.above_y);
+ const lower_h = @max(1, placement.above_h -| upper_h);
+ heights[placement.above_id] = upper_h;
+ heights[id] = lower_h;
+ p.layoutInsert(af.col, af.idx + 1, id);
+ p.setColumnWeights(af.col, &heights);
+ if (source_multi and src.col < p.ncol and src.col != af.col) p.setColumnWeights(src.col, &heights);
+ }
+
+ fn setColumnWeights(p: *Pardes, col: usize, heights: *const [MAX_PANES]u16) void {
+ if (col >= p.ncol) return;
+ for (0..p.col_n[col]) |k| {
+ const pid = p.col_terms[col][k];
+ if (p.panes[pid]) |pane| pane.vweight = @floatFromInt(@max(1, heights[pid]));
+ }
+ }
+
+ // ---- helix-modal normal mode ----
+
+ const PaneLines = struct {
+ lines: []const []const u8,
+ row0: i32, // absolute row of lines[0]
+ };
+
+ /// The lines the cursor moves over, absolute rows. File: all content lines.
+ /// Terminal: the WHOLE history+active grid (prompt rows hidden, runs
+ /// spliced) so motions ride the scrollback. Scratch-arena backed.
+ fn paneCursorLines(p: *Pardes, pane: *Pane) !PaneLines {
+ const arena = p.scratch.allocator();
+ if (pane.file) |f| {
+ var ls: std.ArrayList([]const u8) = .empty;
+ var it = std.mem.splitScalar(u8, f.content, '\n');
+ while (it.next()) |ln| try ls.append(arena, ln);
+ return .{ .lines = ls.items, .row0 = 0 };
+ }
+ // ponytail: O(scrollback) dump+scan per keystroke — window it around the
+ // viewport if a multi-MB scrollback ever makes navigation lag.
+ const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} });
+ var out: std.ArrayList(u8) = .empty;
+ var it = std.mem.splitAny(u8, full, "\n");
+ var pit = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null);
+ var i: usize = 0;
+ while (it.next()) |raw| : (i += 1) {
+ if (i > 0) try out.append(arena, '\n');
+ const is_prompt = if (pit.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false;
+ try spliceRow(arena, &out, pane, @intCast(i), if (is_prompt) "" else raw);
+ }
+ var ls: std.ArrayList([]const u8) = .empty;
+ var lit = std.mem.splitScalar(u8, out.items, '\n');
+ while (lit.next()) |ln| try ls.append(arena, ln);
+ return .{ .lines = ls.items, .row0 = 0 };
+ }
+
+ fn toModalCursor(pane: *Pane, pl: PaneLines) modal.Cursor {
+ const r: i32 = pane.cur_row - pl.row0;
+ return .{ .row = @intCast(@max(0, r)), .col = @intCast(@max(0, pane.cur_col)) };
+ }
+
+ fn fromModalCursor(pane: *Pane, pl: PaneLines, c: modal.Cursor) void {
+ pane.cur_row = @as(i32, @intCast(c.row)) + pl.row0;
+ pane.cur_col = @intCast(c.col);
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ }
+
+ /// apply a motion: move the cursor, keep it visible. A line selection
+ /// collapses on motion; a char selection extends (its head is the cursor).
+ fn applyMove(pane: *Pane, pl: PaneLines, c: modal.Cursor) void {
+ fromModalCursor(pane, pl, c);
+ pane.msel.active = false;
+ pane.pending = 0;
+ pane.ensureCursorVisible();
+ }
+
+ fn handleNormal(p: *Pardes, pane: *Pane, key: Key) void {
+ pane.pinCursor();
+ const pl = p.paneCursorLines(pane) catch return;
+ const cur = toModalCursor(pane, pl);
+ const lines = pl.lines;
+ const half: usize = @max(1, pane.rows / 2);
+
+ // Esc collapses any selection / pending prefix; it does NOT drop to tty
+ if (is(key, Key.escape)) {
+ pane.vsel.active = false;
+ pane.msel.active = false;
+ pane.pending = 0;
+ return;
+ }
+ // prefix continuations
+ if (pane.pending == 'g') {
+ pane.pending = 0;
+ if (is(key, 'g')) return applyMove(pane, pl, modal.gotoFirst());
+ if (is(key, 'e')) return applyMove(pane, pl, modal.gotoLast(lines));
+ if (is(key, 'h')) return applyMove(pane, pl, modal.lineStart(cur));
+ if (is(key, 'l')) return applyMove(pane, pl, modal.lineEnd(lines, cur));
+ return;
+ }
+ if (pane.pending == 'z') {
+ pane.pending = 0;
+ if (is(key, 't')) return pane.scrollBy(pane.cur_row - pane.scroll());
+ if (is(key, 'z')) return pane.scrollBy(pane.cur_row - (pane.scroll() + @as(i32, pane.rows / 2)));
+ if (is(key, 'b')) return pane.scrollBy(pane.cur_row - (pane.scroll() + @as(i32, pane.rows) - 1));
+ return;
+ }
+ if (is(key, 'g')) {
+ pane.pending = 'g';
+ return;
+ }
+ if (is(key, 'z')) {
+ pane.pending = 'z';
+ return;
+ }
+ // movement
+ if (is(key, 'h') or is(key, Key.left)) return applyMove(pane, pl, modal.charLeft(cur));
+ if (is(key, 'l') or is(key, Key.right)) return applyMove(pane, pl, modal.charRight(lines, cur));
+ if (is(key, 'j') or is(key, Key.down)) return applyMove(pane, pl, modal.lineDown(lines, cur));
+ if (is(key, 'k') or is(key, Key.up)) return applyMove(pane, pl, modal.lineUp(lines, cur));
+ if (is(key, 'w')) return applyMove(pane, pl, modal.nextWordStart(lines, cur, false));
+ if (is(key, 'b')) return applyMove(pane, pl, modal.prevWordStart(lines, cur, false));
+ if (is(key, 'e')) return applyMove(pane, pl, modal.nextWordEnd(lines, cur, false));
+ if (is(key, 'W')) return applyMove(pane, pl, modal.nextWordStart(lines, cur, true));
+ if (is(key, 'B')) return applyMove(pane, pl, modal.prevWordStart(lines, cur, true));
+ if (is(key, 'E')) return applyMove(pane, pl, modal.nextWordEnd(lines, cur, true));
+ if (is(key, '0') or is(key, Key.home)) return applyMove(pane, pl, modal.lineStart(cur));
+ if (is(key, '$') or is(key, Key.end)) return applyMove(pane, pl, modal.lineEnd(lines, cur));
+ if (is(key, '^')) return applyMove(pane, pl, modal.firstNonWsOf(lines, cur));
+ if (is(key, 'G')) return applyMove(pane, pl, modal.gotoLast(lines));
+ if (isC(key, 'd') or is(key, Key.page_down))
+ return applyMove(pane, pl, modal.clampToChar(lines, modal.pageDown(cur, half)));
+ if (isC(key, 'u') or is(key, Key.page_up))
+ return applyMove(pane, pl, modal.clampToChar(lines, modal.pageUp(cur, half)));
+ if (isC(key, 'f'))
+ return applyMove(pane, pl, modal.clampToChar(lines, modal.pageDown(cur, @as(usize, pane.rows))));
+ if (isC(key, 'b'))
+ return applyMove(pane, pl, modal.clampToChar(lines, modal.pageUp(cur, @as(usize, pane.rows))));
+ // insert entry
+ if (is(key, 'i')) return p.enterInsert(pane, .at);
+ if (is(key, 'a')) return p.enterInsert(pane, .append);
+ if (is(key, 'I')) return p.enterInsert(pane, .line_start);
+ if (is(key, 'A')) return p.enterInsert(pane, .line_end);
+ if (is(key, 'o')) return p.enterInsert(pane, .open_below);
+ if (is(key, 'O')) return p.enterInsert(pane, .open_above);
+ // selection + edit
+ if (is(key, 'v')) return charSelect(pane);
+ if (is(key, 'x')) return lineSelect(pane);
+ if (is(key, 'd')) return p.normalDelete(pane);
+ if (is(key, 'c')) return p.normalChange(pane);
+ if (is(key, 'y')) return p.normalYank(pane);
+ if (is(key, 'u')) return p.doUndo(pane);
+ if (is(key, 'U')) return p.doRedo(pane);
+ }
+
+ const InsertAt = enum { at, append, line_start, line_end, open_below, open_above };
+
+ fn enterInsert(p: *Pardes, pane: *Pane, where: InsertAt) void {
+ pane.pinCursor();
+ // snapshot once per insert session so `u` undoes the whole session
+ p.pushUndo(pane);
+ const pl = p.paneCursorLines(pane) catch {
+ pane.mode = .insert;
+ pane.msel.active = false;
+ pane.vsel.active = false;
+ pane.pending = 0;
+ return;
+ };
+ const cur = toModalCursor(pane, pl);
+ const llen: usize = if (cur.row < pl.lines.len) pl.lines[cur.row].len else 0;
+ switch (where) {
+ .at => {},
+ .append => fromModalCursor(pane, pl, modal.charRight(pl.lines, cur)),
+ .line_start => fromModalCursor(pane, pl, modal.firstNonWsOf(pl.lines, cur)),
+ .line_end => {
+ pane.cur_col = @intCast(llen);
+ pane.cur_pinned = true;
+ },
+ .open_below => {
+ if (pane.file) |*f| {
+ const new = modal.insertAt(p.gpa, f.content, .{ .row = cur.row, .col = llen }, "\n") catch return;
+ p.setFileContent(f, new);
+ pane.cur_row = @as(i32, @intCast(cur.row + 1)) + pl.row0;
+ pane.cur_col = 0;
+ } else {
+ const bottom = pane.scroll() + @as(i32, pane.rows) - 1;
+ pane.cur_row = @min(bottom, pane.cur_row + 1);
+ pane.cur_col = 0;
+ }
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ },
+ .open_above => {
+ if (pane.file) |*f| {
+ const new = modal.insertAt(p.gpa, f.content, .{ .row = cur.row, .col = 0 }, "\n") catch return;
+ p.setFileContent(f, new);
+ pane.cur_col = 0;
+ } else {
+ const top = pane.scroll();
+ pane.cur_row = @max(top, pane.cur_row - 1);
+ pane.cur_col = 0;
+ }
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ },
+ }
+ // `i` inserts before the run at the cursor; the others append after it
+ pane.cur_off = if (where == .at) 0 else if (pane.runAt(pane.cur_row, pane.cur_col)) |i| pane.edits[i].len else 0;
+ pane.mode = .insert;
+ pane.msel.active = false;
+ pane.vsel.active = false;
+ pane.pending = 0;
+ pane.ensureCursorVisible();
+ }
+
+ /// insert mode: file panes get real content edits; terminals get run
+ /// splices — edits only ever touch YOUR runs, shell output is immutable.
+ fn handleInsert(p: *Pardes, pane: *Pane, key: Key) void {
+ if (pane.file) |*f| {
+ const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) };
+ if (key.text.len > 0) {
+ const new = modal.insertAt(p.gpa, f.content, c, key.text) catch return;
+ p.setFileContent(f, new);
+ for (key.text) |ch| {
+ if (ch == '\n') {
+ pane.cur_row += 1;
+ pane.cur_col = 0;
+ } else pane.cur_col += 1;
+ }
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ return;
+ }
+ switch (key.cp) {
+ Key.enter => {
+ const new = modal.insertAt(p.gpa, f.content, c, "\n") catch return;
+ p.setFileContent(f, new);
+ pane.cur_row += 1;
+ pane.cur_col = 0;
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ },
+ Key.backspace => {
+ if (pane.cur_col > 0) {
+ const new = modal.deleteChar(p.gpa, f.content, .{ .row = c.row, .col = c.col - 1 }) catch return;
+ p.setFileContent(f, new);
+ pane.cur_col -= 1;
+ } else if (pane.cur_row > 0) {
+ const prevlen = modal.lineSlice(f.content, c.row - 1).len;
+ const new = modal.deleteChar(p.gpa, f.content, .{ .row = c.row - 1, .col = prevlen }) catch return;
+ p.setFileContent(f, new);
+ pane.cur_row -= 1;
+ pane.cur_col = @intCast(prevlen);
+ }
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ },
+ Key.left, Key.right, Key.up, Key.down => {
+ const pl = p.paneCursorLines(pane) catch return;
+ const cur0 = toModalCursor(pane, pl);
+ const nc = switch (key.cp) {
+ Key.left => modal.charLeft(cur0),
+ Key.right => modal.charRight(pl.lines, cur0),
+ Key.up => modal.lineUp(pl.lines, cur0),
+ Key.down => modal.lineDown(pl.lines, cur0),
+ else => cur0,
+ };
+ fromModalCursor(pane, pl, nc);
+ pane.ensureCursorVisible();
+ },
+ else => {},
+ }
+ return;
+ }
+
+ if (!pane.cur_pinned) pane.pinCursor();
+ if (key.text.len > 0) {
+ insertRunText(pane, key.text);
+ return;
+ }
+ switch (key.cp) {
+ Key.backspace => {
+ const ei = pane.runAt(pane.cur_row, pane.cur_col) orelse return;
+ const e = &pane.edits[ei];
+ if (pane.cur_off > 0 and e.len > 0) {
+ var k: usize = pane.cur_off - 1;
+ while (k + 1 < e.len) : (k += 1) e.text[k] = e.text[k + 1];
+ e.len -= 1;
+ pane.cur_off -= 1;
+ }
+ },
+ else => {},
+ }
+ }
+
+ fn insertRunText(pane: *Pane, text: []const u8) void {
+ var idx: ?usize = pane.runAt(pane.cur_row, pane.cur_col);
+ if (idx == null and pane.nedits < MAX_EDITS) {
+ pane.edits[pane.nedits] = .{ .row = pane.cur_row, .col = pane.cur_col };
+ pane.nedits += 1;
+ idx = pane.nedits - 1;
+ }
+ const ei = idx orelse return;
+ const e = &pane.edits[ei];
+ for (text) |ch| {
+ if (e.len >= EDIT_CAP) break;
+ if (pane.cur_off > e.len) pane.cur_off = e.len;
+ var k: usize = e.len;
+ while (k > pane.cur_off) : (k -= 1) e.text[k] = e.text[k - 1];
+ e.text[pane.cur_off] = ch;
+ e.len += 1;
+ pane.cur_off += 1;
+ }
+ }
+
+ // ---- selections ----
+
+ /// helix `v`: toggle char-range select, anchored at the cursor
+ fn charSelect(pane: *Pane) void {
+ if (pane.vsel.active) {
+ pane.vsel.active = false;
+ } else {
+ pane.vsel = .{ .active = true, .row = pane.cur_row, .col = pane.cur_col };
+ }
+ pane.msel.active = false;
+ pane.cur_pinned = true;
+ pane.pending = 0;
+ }
+
+ /// helix `x`: extend the line selection down by one (or start it)
+ fn lineSelect(pane: *Pane) void {
+ if (!pane.msel.active) {
+ pane.msel = .{ .active = true, .r0 = pane.cur_row, .r1 = pane.cur_row };
+ } else {
+ const last = pane.lastNavRow();
+ pane.msel.r1 = @min(pane.msel.r1 + 1, last);
+ pane.cur_row = pane.msel.r1;
+ }
+ pane.vsel.active = false;
+ pane.cur_col = 0;
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.pending = 0;
+ }
+
+ const Bounds = struct { lo_row: i32, lo_col: i32, hi_row: i32, hi_col: i32 };
+
+ /// the char selection [anchor, cursor] normalized to document order
+ fn vselBounds(pane: *Pane) Bounds {
+ var ar = pane.vsel.row;
+ var ac = pane.vsel.col;
+ var br = pane.cur_row;
+ var bc = pane.cur_col;
+ if (br < ar or (br == ar and bc < ac)) {
+ const sr = ar;
+ const sc = ac;
+ ar = br;
+ ac = bc;
+ br = sr;
+ bc = sc;
+ }
+ return .{ .lo_row = ar, .lo_col = ac, .hi_row = br, .hi_col = bc };
+ }
+
+ fn vselText(p: *Pardes, pane: *Pane) []const u8 {
+ const arena = p.scratch.allocator();
+ const b = vselBounds(pane);
+ if (pane.file) |f| {
+ return modal.rangeText(arena, f.content, .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) }) catch "";
+ }
+ const body = p.bodyText(arena, pane) catch return "";
+ const off = pane.scroll();
+ var out: std.ArrayList(u8) = .empty;
+ var it = std.mem.splitAny(u8, body, "\n");
+ var i: i32 = 0;
+ while (it.next()) |ln| : (i += 1) {
+ const abs = i + off;
+ if (abs < b.lo_row or abs > b.hi_row) continue;
+ const lo: usize = if (abs == b.lo_row) @intCast(@max(0, b.lo_col)) else 0;
+ const hi_excl: usize = if (abs == b.hi_row) @as(usize, @intCast(@max(0, b.hi_col))) + 1 else ln.len;
+ if (abs > b.lo_row) out.append(arena, '\n') catch return "";
+ out.appendSlice(arena, ln[@min(lo, ln.len)..@min(hi_excl, ln.len)]) catch return "";
+ }
+ return out.items;
+ }
+
+ /// join the rendered body rows [r0, r1] (absolute) with '\n'; scratch-owned
+ fn yankRows(p: *Pardes, pane: *Pane, r0: i32, r1: i32) []const u8 {
+ const arena = p.scratch.allocator();
+ const body = p.bodyText(arena, pane) catch return "";
+ const off = pane.scroll();
+ var out: std.ArrayList(u8) = .empty;
+ var it = std.mem.splitAny(u8, body, "\n");
+ var i: i32 = 0;
+ while (it.next()) |ln| : (i += 1) {
+ const abs = i + off;
+ if (abs < r0 or abs > r1) continue;
+ if (abs > r0) out.append(arena, '\n') catch return "";
+ out.appendSlice(arena, ln) catch return "";
+ }
+ return out.items;
+ }
+
+ /// drop all insertion runs whose row is in [r0, r1]
+ fn clearEditsInRange(pane: *Pane, r0: i32, r1: i32) void {
+ var w: usize = 0;
+ for (pane.edits[0..pane.nedits]) |e| {
+ if (e.row >= r0 and e.row <= r1) continue;
+ pane.edits[w] = e;
+ w += 1;
+ }
+ pane.nedits = w;
+ }
+
+ // ---- edit operations (d / c / y / p) ----
+
+ fn normalDelete(p: *Pardes, pane: *Pane) void {
+ if (pane.vsel.active) {
+ const b = vselBounds(pane);
+ p.setYank(p.vselText(pane)); // read the range before the move
+ p.pushUndo(pane);
+ if (pane.file) |*f| {
+ const d = modal.deleteRange(p.gpa, f.content, .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) }) catch {
+ pane.vsel.active = false;
+ return;
+ };
+ p.setFileContent(f, d.content);
+ p.gpa.free(d.deleted);
+ pane.cur_row = b.lo_row;
+ pane.cur_col = b.lo_col;
+ clampCursorToFile(pane, f);
+ } else {
+ // terminal: can't delete shell output — only drop typed runs
+ clearEditsInRange(pane, b.lo_row, b.hi_row);
+ pane.cur_row = b.lo_row;
+ pane.cur_col = b.lo_col;
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.vsel.active = false;
+ pane.ensureCursorVisible();
+ }
+ return;
+ }
+ if (pane.msel.active) {
+ const r0 = @min(pane.msel.r0, pane.msel.r1);
+ const r1 = @max(pane.msel.r0, pane.msel.r1);
+ p.pushUndo(pane);
+ if (pane.file) |*f| {
+ const d = modal.deleteLines(p.gpa, f.content, @intCast(@max(0, r0)), @intCast(@max(0, r1))) catch {
+ pane.msel.active = false;
+ return;
+ };
+ p.setFileContent(f, d.content);
+ p.setYank(d.deleted);
+ p.gpa.free(d.deleted);
+ const n = modal.lineCount(f.content);
+ pane.cur_row = @intCast(@min(@as(usize, @intCast(r0)), if (n == 0) 0 else n - 1));
+ pane.cur_col = 0;
+ } else {
+ clearEditsInRange(pane, r0, r1);
+ p.setYank(p.yankRows(pane, r0, r1));
+ }
+ pane.msel.active = false;
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ } else if (pane.file) |*f| {
+ p.pushUndo(pane);
+ const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) };
+ const new = modal.deleteChar(p.gpa, f.content, c) catch return;
+ p.setFileContent(f, new);
+ } else {
+ p.deleteRunCharAtCursor(pane);
+ }
+ }
+
+ /// delete the inserted char under the (visual) cursor from its run, if any
+ fn deleteRunCharAtCursor(p: *Pardes, pane: *Pane) void {
+ for (pane.edits[0..pane.nedits]) |*e| {
+ if (e.row != pane.cur_row or e.len == 0) continue;
+ const vstart = pane.renderedCol(e.row, e.col, 0);
+ if (pane.cur_col < vstart or pane.cur_col >= vstart + @as(i32, e.len)) continue;
+ p.pushUndo(pane);
+ var k: usize = @intCast(pane.cur_col - vstart);
+ while (k + 1 < e.len) : (k += 1) e.text[k] = e.text[k + 1];
+ e.len -= 1;
+ pane.cur_pinned = true;
+ return;
+ }
+ }
+
+ fn normalYank(p: *Pardes, pane: *Pane) void {
+ if (pane.vsel.active) {
+ const b = vselBounds(pane);
+ p.setYank(p.vselText(pane));
+ pane.cur_row = b.lo_row;
+ pane.cur_col = b.lo_col;
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.vsel.active = false;
+ pane.ensureCursorVisible();
+ return;
+ }
+ if (pane.msel.active) {
+ const r0 = @min(pane.msel.r0, pane.msel.r1);
+ const r1 = @max(pane.msel.r0, pane.msel.r1);
+ if (pane.file) |f| {
+ var buf: std.ArrayList(u8) = .empty;
+ const arena = p.scratch.allocator();
+ var r: usize = @intCast(@max(0, r0));
+ const hi: usize = @intCast(@max(0, r1));
+ const lo: usize = @intCast(@max(0, r0));
+ while (r <= hi) : (r += 1) {
+ if (r > lo) buf.append(arena, '\n') catch break;
+ buf.appendSlice(arena, modal.lineSlice(f.content, r)) catch break;
+ }
+ p.setYank(buf.items);
+ } else {
+ p.setYank(p.yankRows(pane, r0, r1));
+ }
+ pane.cur_row = r0;
+ pane.cur_col = 0;
+ pane.msel.active = false;
+ } else if (pane.file) |f| {
+ p.setYank(modal.lineSlice(f.content, @intCast(@max(0, pane.cur_row))));
+ } else {
+ p.setYank(p.yankRows(pane, pane.cur_row, pane.cur_row));
+ }
+ }
+
+ fn normalPaste(p: *Pardes, pane: *Pane) void {
+ const y = p.yank orelse return;
+ p.pushUndo(pane);
+ if (pane.file) |*f| {
+ const new = modal.pasteLineBelow(p.gpa, f.content, @intCast(@max(0, pane.cur_row)), y) catch return;
+ p.setFileContent(f, new);
+ pane.cur_row += 1;
+ pane.cur_col = 0;
+ } else {
+ insertRunText(pane, y);
+ }
+ pane.cur_off = if (pane.runAt(pane.cur_row, pane.cur_col)) |i| pane.edits[i].len else 0;
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ }
+
+ fn normalChange(p: *Pardes, pane: *Pane) void {
+ if (pane.vsel.active) {
+ const b = vselBounds(pane);
+ p.setYank(p.vselText(pane));
+ p.pushUndo(pane);
+ if (pane.file) |*f| {
+ const d = modal.deleteRange(p.gpa, f.content, .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) }) catch {
+ pane.vsel.active = false;
+ return;
+ };
+ p.setFileContent(f, d.content);
+ p.gpa.free(d.deleted);
+ pane.cur_row = b.lo_row;
+ pane.cur_col = b.lo_col;
+ clampCursorToFile(pane, f);
+ } else {
+ clearEditsInRange(pane, b.lo_row, b.hi_row);
+ pane.cur_row = b.lo_row;
+ pane.cur_col = b.lo_col;
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.vsel.active = false;
+ pane.ensureCursorVisible();
+ }
+ pane.mode = .insert;
+ pane.pending = 0;
+ return;
+ }
+ if (pane.msel.active) {
+ const r0 = @min(pane.msel.r0, pane.msel.r1);
+ const r1 = @max(pane.msel.r0, pane.msel.r1);
+ p.pushUndo(pane);
+ if (pane.file) |*f| {
+ // keep one empty line at r0, drop the rest; yank the removed
+ var buf: std.ArrayList(u8) = .empty;
+ const arena = p.scratch.allocator();
+ var r: usize = @intCast(@max(0, r0));
+ const hi: usize = @intCast(@max(0, r1));
+ const lo: usize = @intCast(@max(0, r0));
+ while (r <= hi) : (r += 1) {
+ if (r > lo) buf.append(arena, '\n') catch break;
+ buf.appendSlice(arena, modal.lineSlice(f.content, r)) catch break;
+ }
+ p.setYank(buf.items);
+ if (r1 > r0) {
+ const d = modal.deleteLines(p.gpa, f.content, @intCast(@max(0, r0 + 1)), @intCast(@max(0, r1))) catch {
+ pane.msel.active = false;
+ return;
+ };
+ p.setFileContent(f, d.content);
+ p.gpa.free(d.deleted);
+ }
+ const cl = modal.clearLine(p.gpa, f.content, @intCast(@max(0, r0))) catch {
+ pane.msel.active = false;
+ return;
+ };
+ p.setFileContent(f, cl);
+ pane.cur_row = r0;
+ pane.cur_col = 0;
+ } else {
+ clearEditsInRange(pane, r0, r1);
+ p.setYank(p.yankRows(pane, r0, r1));
+ pane.cur_row = r0;
+ pane.cur_col = 0;
+ }
+ pane.msel.active = false;
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.mode = .insert;
+ pane.pending = 0;
+ pane.ensureCursorVisible();
+ } else if (pane.file) |*f| {
+ p.pushUndo(pane);
+ const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) };
+ const new = modal.deleteChar(p.gpa, f.content, c) catch return;
+ p.setFileContent(f, new);
+ pane.mode = .insert;
+ pane.cur_off = 0;
+ pane.pending = 0;
+ } else {
+ p.pushUndo(pane);
+ pane.mode = .insert;
+ pane.cur_off = 0;
+ pane.pending = 0;
+ }
+ }
+
+ // ---- dumb undo/redo: whole-state snapshots, one per edit op ----
+
+ fn setFileContent(p: *Pardes, f: *File, new: []u8) void {
+ p.gpa.free(f.content);
+ f.content = new;
+ f.syntax_dirty = true;
+ }
+
+ fn clampCursorToFile(pane: *Pane, f: *File) void {
+ const n = modal.lineCount(f.content);
+ const row: usize = @min(@as(usize, @intCast(@max(0, pane.cur_row))), if (n == 0) 0 else n - 1);
+ const llen = modal.lineSlice(f.content, row).len;
+ pane.cur_row = @intCast(row);
+ pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, pane.cur_col))), llen));
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.vsel.active = false;
+ pane.msel.active = false;
+ pane.ensureCursorVisible();
+ }
+
+ fn snapTerm(pane: *Pane) EditSnap {
+ return .{ .edits = pane.edits, .nedits = pane.nedits, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .cur_off = pane.cur_off };
+ }
+
+ fn editSnapEq(a: EditSnap, b: EditSnap) bool {
+ if (a.nedits != b.nedits) return false;
+ for (a.edits[0..a.nedits], b.edits[0..b.nedits]) |x, y| {
+ if (x.row != y.row or x.col != y.col or x.len != y.len) return false;
+ if (!std.mem.eql(u8, x.text[0..x.len], y.text[0..y.len])) return false;
+ }
+ return true;
+ }
+
+ fn pushUndo(p: *Pardes, pane: *Pane) void {
+ if (pane.file) |*f| {
+ if (f.undo.items.len > 0 and std.mem.eql(u8, f.undo.items[f.undo.items.len - 1], f.content)) return;
+ const snap = p.gpa.dupe(u8, f.content) catch return;
+ f.undo.append(p.gpa, snap) catch {
+ p.gpa.free(snap);
+ return;
+ };
+ if (f.undo.items.len > UNDO_MAX) p.gpa.free(f.undo.orderedRemove(0));
+ for (f.redo.items) |s| p.gpa.free(s);
+ f.redo.clearRetainingCapacity();
+ } else {
+ const snap = snapTerm(pane);
+ if (pane.ed_undo.items.len > 0 and editSnapEq(pane.ed_undo.items[pane.ed_undo.items.len - 1], snap)) return;
+ pane.ed_undo.append(p.gpa, snap) catch return;
+ if (pane.ed_undo.items.len > EDIT_UNDO_MAX) _ = pane.ed_undo.orderedRemove(0);
+ pane.ed_redo.clearRetainingCapacity();
+ }
+ }
+
+ fn doUndo(p: *Pardes, pane: *Pane) void {
+ if (pane.file) |*f| {
+ if (f.undo.items.len == 0) return;
+ const cur = p.gpa.dupe(u8, f.content) catch return;
+ f.redo.append(p.gpa, cur) catch {
+ p.gpa.free(cur);
+ return;
+ };
+ const prev = f.undo.pop() orelse return;
+ p.setFileContent(f, prev);
+ clampCursorToFile(pane, f);
+ return;
+ }
+ if (pane.ed_undo.items.len == 0) return;
+ pane.ed_redo.append(p.gpa, snapTerm(pane)) catch return;
+ restoreTerm(pane, pane.ed_undo.pop() orelse return);
+ }
+
+ fn doRedo(p: *Pardes, pane: *Pane) void {
+ if (pane.file) |*f| {
+ if (f.redo.items.len == 0) return;
+ const cur = p.gpa.dupe(u8, f.content) catch return;
+ f.undo.append(p.gpa, cur) catch {
+ p.gpa.free(cur);
+ return;
+ };
+ const next = f.redo.pop() orelse return;
+ p.setFileContent(f, next);
+ clampCursorToFile(pane, f);
+ return;
+ }
+ if (pane.ed_redo.items.len == 0) return;
+ pane.ed_undo.append(p.gpa, snapTerm(pane)) catch return;
+ restoreTerm(pane, pane.ed_redo.pop() orelse return);
+ }
+
+ fn restoreTerm(pane: *Pane, snap: EditSnap) void {
+ pane.edits = snap.edits;
+ pane.nedits = snap.nedits;
+ pane.cur_row = snap.cur_row;
+ pane.cur_col = snap.cur_col;
+ pane.cur_off = snap.cur_off;
+ pane.cur_pinned = true;
+ pane.vsel.active = false;
+ pane.msel.active = false;
+ pane.ensureCursorVisible();
+ }
+
+ fn handleMouse(p: *Pardes, m: Mouse) void {
+ const mcol = @min(m.col, p.screen_w -| 1);
+ const mrow = @min(m.row, p.screen_h -| 1);
+ p.hover_col = mcol;
+ p.hover_row = mrow;
+
+ const hovered: ?usize = for (p.panes, 0..) |slot, i| {
+ if (slot == null) continue;
+ const r = p.rects[i];
+ if (mcol >= r.x and mcol < r.x + r.w and mrow >= r.y and mrow < r.y + r.h) break i;
+ } else null;
+
+ // any press that is NOT the middle+left chord abandons a pending
+ // chord argument
+ if (m.kind == .press and
+ !(m.button == .left and p.drag == .select and p.drag.select.button == .middle))
+ {
+ if (p.chord_arg) |a| {
+ p.gpa.free(a);
+ p.chord_arg = null;
+ }
+ }
+
+ switch (m.button) {
+ .none => {}, // hover tracked above; motion has no other meaning
+ .wheel_up, .wheel_down => {
+ if (m.kind != .press) return;
+ const id = hovered orelse return;
+ p.panes[id].?.scrollBy(if (m.button == .wheel_up) -1 else 1);
+ },
+ .left => switch (m.kind) {
+ .press => {
+ // middle+left chord: a left press during a middle select-
+ // drag captures the KEPT left selection as the execute's
+ // argument; the middle drag keeps running to its release.
+ if (p.drag == .select and p.drag.select.button == .middle) {
+ if (p.panes[p.drag.select.id]) |t| {
+ const sl = t.sel[0];
+ if (sl.state == .done) {
+ if (p.chord_arg) |old| p.gpa.free(old);
+ const txt = p.selectionText(t, sl) catch null;
+ p.chord_arg = if (txt) |tx| (p.gpa.dupe(u8, tx) catch null) else null;
+ }
+ }
+ return;
+ }
+ if (mrow < TOPBAR_H) return; // topbar: left click deliberately inert
+ // resize handles first: a pane's own trailing edge (v: the
+ // left column's last col; h: the upper pane's last row)
+ for (0..p.ncol -| 1) |c| {
+ if (mcol == p.col_x[c] + p.col_w[c] -| 1) {
+ p.drag = .{ .border_v = .{ .left_col = c, .cur_x = mcol } };
+ return;
+ }
+ }
+ for (0..p.ncol) |cc| {
+ if (mcol < p.col_x[cc] or mcol >= p.col_x[cc] + p.col_w[cc]) continue;
+ for (0..p.col_n[cc] -| 1) |k| {
+ const r = p.rects[p.col_terms[cc][k]];
+ if (mrow == r.y + r.h -| 1) {
+ p.drag = .{ .border_h = .{ .col = cc, .top_idx = k, .cur_y = mrow } };
+ return;
+ }
+ }
+ }
+ const id = hovered orelse return;
+ const r = p.rects[id];
+ const pane = p.panes[id] orelse return;
+ if (mcol < r.x + GUTTER and mrow < r.y + BOX_H) {
+ p.drag = .{ .move = .{ .id = id, .cur_x = mcol, .cur_y = mrow } };
+ } else if (mcol < r.x + GUTTER) {
+ // gutter scrollbar: a left click scrolls UP to that row
+ p.active = id;
+ pane.scrollBy(-(@as(i32, mrow) - @as(i32, r.y + BOX_H)));
+ } else if (mrow < r.y + BOX_H and pane.image == null) {
+ // left click on the tag row: edit the tail in place
+ p.active = id;
+ p.enterTagEdit(pane, @as(i32, mcol) - @as(i32, r.x + GUTTER));
+ } else {
+ p.active = id;
+ exitTagEdit(pane); // clicking the body leaves tag editing
+ const sc: i32 = @as(i32, mcol) - @as(i32, r.x + GUTTER);
+ const v: i32 = @as(i32, mrow) - @as(i32, r.y);
+ pane.sel[0] = .{ .state = .dragging, .c0 = sc, .c1 = sc, .r0 = v, .r1 = v };
+ p.drag = .{ .select = .{ .id = id, .button = .left } };
+ }
+ },
+ .drag => p.dragUpdate(mcol, mrow),
+ .release => p.dragRelease(.left),
+ .motion => {},
+ },
+ .middle, .right => switch (m.kind) {
+ .press => {
+ if (mrow < TOPBAR_H) {
+ // global tagbar: MIDDLE runs the command under the click
+ // (a stray left click must never Kill)
+ if (m.button == .middle) {
+ const word = wordAtCol(topbar_str, mcol);
+ if (word.len > 0) p.actOnSelection(.middle, p.active, word, null);
+ }
+ return;
+ }
+ const id = hovered orelse return;
+ const r = p.rects[id];
+ const pane = p.panes[id] orelse return;
+ if (mcol < r.x + GUTTER and mrow >= r.y + BOX_H) {
+ // gutter: right scrolls DOWN to here, mirroring left's up;
+ // middle matches left. Right focuses (look lands you
+ // there); middle does not.
+ const local = @as(i32, mrow) - @as(i32, r.y + BOX_H);
+ if (m.button == .right) p.active = id;
+ pane.scrollBy(if (m.button == .right) local else -local);
+ } else if (mcol >= r.x + GUTTER) {
+ if (m.button == .right) p.active = id;
+ const c: i32 = @as(i32, mcol) - @as(i32, r.x + GUTTER);
+ const v: i32 = @as(i32, mrow) - @as(i32, r.y);
+ pane.sel[@intFromEnum(m.button)] = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v };
+ p.drag = .{ .select = .{ .id = id, .button = m.button } };
+ }
+ },
+ .drag => p.dragUpdate(mcol, mrow),
+ .release => p.dragRelease(m.button),
+ .motion => {},
+ },
+ }
+ }
+
+ fn dragUpdate(p: *Pardes, mcol: u16, mrow: u16) void {
+ switch (p.drag) {
+ .border_v => |*d| {
+ const c = d.left_col;
+ if (c + 1 < p.ncol) {
+ const lo = p.col_x[c] + MINW;
+ const hi = p.col_x[c] +| p.col_w[c] +| p.col_w[c + 1] -| MINW;
+ d.cur_x = if (lo <= hi) std.math.clamp(mcol, lo, hi) else mcol;
+ } else d.cur_x = mcol;
+ },
+ .border_h => |*d| {
+ const cc = d.col;
+ const k = d.top_idx;
+ if (k + 1 < p.col_n[cc]) {
+ const a = p.rects[p.col_terms[cc][k]];
+ const b = p.rects[p.col_terms[cc][k + 1]];
+ const lo = a.y + MINH;
+ const hi = a.y +| a.h +| b.h -| MINH;
+ d.cur_y = if (lo <= hi) std.math.clamp(mrow, lo, hi) else mrow;
+ } else d.cur_y = mrow;
+ },
+ .move => |*d| {
+ d.cur_x = mcol;
+ d.cur_y = mrow;
+ },
+ .select => |s| {
+ if (p.panes[s.id]) |pane| {
+ const r = p.rects[s.id];
+ const b = @intFromEnum(s.button);
+ pane.sel[b].c1 = @as(i32, mcol) - @as(i32, r.x + GUTTER);
+ pane.sel[b].r1 = @as(i32, mrow) - @as(i32, r.y);
+ }
+ },
+ .none => {},
+ }
+ }
+
+ fn dragRelease(p: *Pardes, button: Mouse.Button) void {
+ // a release of a button OTHER than the one driving a select drag is
+ // the middle+left chord's left release: the middle drag keeps going
+ if (p.drag == .select and button != p.drag.select.button) return;
+ defer p.drag = .none;
+ switch (p.drag) {
+ .border_v => |d| {
+ const c = d.left_col;
+ if (c + 1 < p.ncol) {
+ const combined: f32 = @floatFromInt(p.col_w[c] + p.col_w[c + 1]);
+ // the handle is the left pane's LAST column (inclusive), so
+ // the new width includes it — a no-drag click changes nothing
+ var nl: f32 = @floatFromInt((d.cur_x + 1) -| p.col_x[c]);
+ nl = std.math.clamp(nl, @as(f32, MINW), @max(@as(f32, MINW), combined - MINW));
+ const pair = p.col_weight[c] + p.col_weight[c + 1];
+ p.col_weight[c] = pair * (nl / combined);
+ p.col_weight[c + 1] = pair - p.col_weight[c];
+ }
+ },
+ .border_h => |d| {
+ const cc = d.col;
+ const k = d.top_idx;
+ if (k + 1 < p.col_n[cc]) {
+ const a = p.panes[p.col_terms[cc][k]] orelse return;
+ const b = p.panes[p.col_terms[cc][k + 1]] orelse return;
+ const ra = p.rects[p.col_terms[cc][k]];
+ const rb = p.rects[p.col_terms[cc][k + 1]];
+ const combined: f32 = @floatFromInt(ra.h + rb.h);
+ var nt: f32 = @floatFromInt((d.cur_y + 1) -| ra.y);
+ nt = std.math.clamp(nt, @as(f32, MINH), @max(@as(f32, MINH), combined - MINH));
+ const pair = a.vweight + b.vweight;
+ a.vweight = pair * (nt / combined);
+ b.vweight = pair - a.vweight;
+ }
+ },
+ .move => |d| {
+ p.moveTerm(d.id, d.cur_x, d.cur_y);
+ // a file moved into the left column evicts a lone unused shell
+ if (p.panes[d.id]) |mt| if (mt.file != null) {
+ if (p.layoutFindTerm(d.id)) |f| if (f.col == 0)
+ p.evictLonePristineTty(0, d.id);
+ };
+ },
+ .select => |s| {
+ const pane = p.panes[s.id] orelse return;
+ const b = @intFromEnum(s.button);
+ pane.sel[b].state = .done;
+ if (s.button == .left) {
+ // keep a dragged selection highlighted; a plain click clears
+ // it. Either way pin the cursor + enter normal mode on text
+ // panes (tty keeps the mouse usable without dropping modes).
+ const sl = pane.sel[0];
+ const dragged = sl.c0 != sl.c1 or sl.r0 != sl.r1;
+ if (!dragged) pane.sel[0].state = .none;
+ const body_vis = sl.r1 - @as(i32, BOX_H);
+ if (body_vis >= 0 and pane.mode != .tty) {
+ pane.cur_row = body_vis + pane.scroll();
+ pane.cur_col = if (pane.file != null) @max(0, sl.c1 - @as(i32, PREFIX_W)) else sl.c1;
+ pane.cur_off = if (pane.runAt(pane.cur_row, pane.cur_col)) |i| pane.edits[i].len else 0;
+ pane.cur_pinned = true;
+ pane.mode = .normal;
+ pane.msel.active = false;
+ pane.pending = 0;
+ }
+ } else {
+ // acme execute (middle) / look (right): a no-drag click
+ // expands to the file-ish word first; a captured chord
+ // argument rides along and is consumed here.
+ p.expandSel(pane, &pane.sel[b]);
+ const txt = p.selectionText(pane, pane.sel[b]) catch null;
+ const arg = p.chord_arg;
+ p.chord_arg = null;
+ defer if (arg) |a| p.gpa.free(a);
+ if (txt) |tx| p.actOnSelection(s.button, s.id, tx, arg);
+ }
+ },
+ .none => {},
+ }
+ }
+
+ /// tty mode: encode the key the way a terminal would and send it to the pty.
+ fn forwardKey(p: *Pardes, id: usize, key: Key) void {
+ var cbuf: [1]u8 = undefined;
+ const bytes: ?[]const u8 = blk: {
+ if (key.ctrl) {
+ if (key.cp >= 'a' and key.cp <= 'z') {
+ cbuf[0] = @intCast(key.cp - 0x60);
+ break :blk cbuf[0..1];
+ }
+ if (key.cp == '@') {
+ cbuf[0] = 0;
+ break :blk cbuf[0..1];
+ }
+ if (key.cp >= '[' and key.cp <= '_') {
+ cbuf[0] = @intCast(key.cp - 0x40);
+ break :blk cbuf[0..1];
+ }
+ if (key.cp >= 'A' and key.cp <= 'Z') {
+ cbuf[0] = @intCast(key.cp - 0x40);
+ break :blk cbuf[0..1];
+ }
+ }
+ if (key.text.len > 0) break :blk key.text;
+ break :blk switch (key.cp) {
+ Key.enter => "\r",
+ Key.backspace => "\x7f",
+ Key.tab => "\t",
+ Key.escape => "\x1b",
+ Key.up => "\x1b[A",
+ Key.down => "\x1b[B",
+ Key.right => "\x1b[C",
+ Key.left => "\x1b[D",
+ else => null,
+ };
+ };
+ if (bytes) |b| p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(b) } });
+ }
+
+ // ---- layout surgery ----
+
+ fn layoutFindTerm(p: *Pardes, id: usize) ?struct { col: usize, idx: usize } {
+ for (0..p.ncol) |c| {
+ for (0..p.col_n[c]) |k| {
+ if (p.col_terms[c][k] == id) return .{ .col = c, .idx = k };
+ }
+ }
+ return null;
+ }
+
+ fn layoutInsert(p: *Pardes, c: usize, idx: usize, id: usize) void {
+ var k = p.col_n[c];
+ while (k > idx) : (k -= 1) p.col_terms[c][k] = p.col_terms[c][k - 1];
+ p.col_terms[c][idx] = id;
+ p.col_n[c] += 1;
+ }
+
+ /// Remove from the layout. An emptied column hands its width to a neighbor
+ /// (else every surviving column reflows sideways) before shifting down.
+ fn layoutRemove(p: *Pardes, id: usize) void {
+ const f = p.layoutFindTerm(id) orelse return;
+ const c = f.col;
+ var k = f.idx;
+ while (k + 1 < p.col_n[c]) : (k += 1) p.col_terms[c][k] = p.col_terms[c][k + 1];
+ p.col_n[c] -= 1;
+ if (p.col_n[c] == 0) {
+ if (p.ncol > 1) p.col_weight[if (c > 0) c - 1 else c + 1] += p.col_weight[c];
+ var j = c;
+ while (j + 1 < p.ncol) : (j += 1) {
+ p.col_terms[j] = p.col_terms[j + 1];
+ p.col_n[j] = p.col_n[j + 1];
+ p.col_weight[j] = p.col_weight[j + 1];
+ }
+ p.ncol -= 1;
+ }
+ }
+
+ fn layoutAppendColumn(p: *Pardes, id: usize) void {
+ if (p.ncol >= MAX_COLS) return;
+ const c = p.ncol;
+ p.col_weight[c] = 1;
+ p.col_terms[c][0] = id;
+ p.col_n[c] = 1;
+ p.ncol += 1;
+ }
+
+ /// Hand a dying pane's vertical weight to ONE sibling (the pane above, or
+ /// below for the topmost) so the rest of the column keeps its sizes — the
+ /// deletion mirror of splitBelow. Call while `id` is still in the layout.
+ fn absorbVWeight(p: *Pardes, id: usize) void {
+ const f = p.layoutFindTerm(id) orelse return;
+ if (p.col_n[f.col] <= 1) return;
+ const gone = p.panes[id] orelse return;
+ const sib = if (f.idx > 0) p.col_terms[f.col][f.idx - 1] else p.col_terms[f.col][f.idx + 1];
+ if (p.panes[sib]) |s| s.vweight += gone.vweight;
+ }
+
+ /// Open a window BELOW `src` (acme-style) without rebalancing the column:
+ /// shrink ONLY src to its content height (cursor row kept visible) and hand
+ /// the freed rows to `nw` — together they fill src's old slot.
+ fn splitBelow(p: *Pardes, src_id: usize, nw: *Pane) void {
+ const src = p.panes[src_id] orelse return;
+ const src_h = p.rects[src_id].h;
+ const body: u16 = if (src_h > BOX_H) src_h - BOX_H else 1;
+ const cur: u16 = if (src.file != null) body / 2 else @as(u16, @intCast(src.vt.screens.active.cursor.y)) + 1;
+ const keep = std.math.clamp(cur, 1, @max(1, body -| 1));
+ const w = src.vweight;
+ src.vweight = w * @as(f32, @floatFromInt(BOX_H + keep)) / @as(f32, @floatFromInt(src_h));
+ nw.vweight = w - src.vweight;
+ }
+
+ /// a shell nobody has touched: no typing, cursor on the first prompt line,
+ /// no scrollback — a throwaway placeholder a file may replace
+ fn isPristineTty(pane: *Pane) bool {
+ if (!pane.isTerminal()) return false;
+ if (pane.nedits != 0) return false;
+ if (pane.vt.screens.active.cursor.y != 0) return false;
+ return pane.vt.screens.active.pages.scrollbar().total <= pane.rows;
+ }
+
+ /// when a doc lands in `col`, a lone pristine shell there is clutter — drop
+ /// it; absorbVWeight hands its space to the doc
+ fn evictLonePristineTty(p: *Pardes, col: usize, keep_id: usize) void {
+ var n_tty: usize = 0;
+ var tty_id: usize = 0;
+ for (0..p.col_n[col]) |k| {
+ const cid = p.col_terms[col][k];
+ if (p.panes[cid]) |ct| if (ct.file == null) {
+ n_tty += 1;
+ tty_id = cid;
+ };
+ }
+ if (n_tty != 1) return;
+ const tt = p.panes[tty_id] orelse return;
+ if (!isPristineTty(tt)) return;
+ p.absorbVWeight(tty_id);
+ p.layoutRemove(tty_id);
+ p.deinitPane(tt);
+ p.panes[tty_id] = null;
+ if (p.active == tty_id) p.active = keep_id;
+ }
+
+ /// a terminal already in `dir`, else a fresh shell there at the bottom of
+ /// the rightmost column. Backs middle-click send from a file pane. Does NOT
+ /// focus (execute keeps you where you were; look focuses).
+ fn ttyForDir(p: *Pardes, dir: []const u8) ?usize {
+ for (p.panes, 0..) |slot, i| if (slot) |tt| {
+ if (tt.file != null or tt.image != null) continue;
+ if (std.mem.eql(u8, tt.cwdSlice(), dir)) return i;
+ };
+ const free = p.freeSlot() orelse return null;
+ const nt = p.newShell(free, dir) catch return null;
+ nt.greet = false;
+ const rc = if (p.ncol > 0) p.ncol - 1 else 0;
+ p.layoutInsert(rc, p.col_n[rc], free);
+ return free;
+ }
+
+ /// executing at a prompt with typed runs below the prompt: pad the output
+ /// area with newlines so the command's output doesn't overwrite the runs
+ fn padOutputBelowEdits(p: *Pardes, id: usize) void {
+ const pane = p.panes[id] orelse return;
+ if (!pane.isTerminal() or pane.nedits == 0) return;
+ if (!pane.vt.cursorIsAtPrompt()) return;
+ var max_row: i32 = std.math.minInt(i32);
+ for (pane.edits[0..pane.nedits]) |e| {
+ if (e.len > 0 and e.row > max_row) max_row = e.row;
+ }
+ if (max_row == std.math.minInt(i32)) return;
+ const cursor_abs: i32 = pane.scroll() + @as(i32, @intCast(pane.vt.screens.active.cursor.y));
+ const pad = std.math.clamp(max_row - cursor_abs, 0, @as(i32, pane.rows));
+ var i: i32 = 0;
+ while (i < pad) : (i += 1) p.emitWrite(id, "\r");
+ }
+
+ // ---- doc panes ----
+
+ fn openFileView(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane {
+ const content = try look.readFile(p.gpa, path);
+ errdefer p.gpa.free(content);
+ const path_copy = try p.gpa.dupe(u8, path);
+ errdefer p.gpa.free(path_copy);
+ const pane = try p.newDocPane(id);
+ const total = fileLineCount(content);
+ const scroll: usize = if (line > 0 and line <= total) line - 1 else 0;
+ pane.file = .{ .path = path_copy, .content = content, .scroll = scroll };
+ pane.cur_pinned = true;
+ pane.cur_row = @intCast(scroll);
+ return pane;
+ }
+
+ fn openTutorView(p: *Pardes, id: usize) !*Pane {
+ const content = try p.gpa.dupe(u8, tutor_text);
+ errdefer p.gpa.free(content);
+ const path = try p.gpa.dupe(u8, "/Tutor");
+ errdefer p.gpa.free(path);
+ const pane = try p.newDocPane(id);
+ pane.file = .{ .path = path, .content = content };
+ pane.cur_pinned = true;
+ return pane;
+ }
+
+ fn openImageView(p: *Pardes, id: usize, path: []const u8, petscii: bool) !*Pane {
+ const path_copy = try p.gpa.dupe(u8, path);
+ errdefer p.gpa.free(path_copy);
+ const pane = try p.newDocPane(id);
+ pane.image = .{ .path = path_copy, .petscii = petscii };
+ return pane;
+ }
+
+ /// stack a fresh doc pane at the top of the LEFT column (acme convention:
+ /// files left, terminals right) with a fair share, then evict a leftover
+ /// pristine shell
+ fn stackDocLeft(p: *Pardes, free: usize, nt: *Pane) void {
+ const lc = 0;
+ const ln = p.col_n[lc];
+ if (ln > 0) {
+ var vsum: f32 = 0;
+ for (0..ln) |i| if (p.panes[p.col_terms[lc][i]]) |pp| {
+ vsum += pp.vweight;
+ };
+ nt.vweight = vsum / @as(f32, @floatFromInt(ln));
+ }
+ p.layoutInsert(lc, 0, free);
+ p.active = free;
+ p.evictLonePristineTty(lc, free);
+ }
+
+ // ---- the ONE dispatcher: look (right/Enter) and execute (middle/Tab) ----
+
+ fn actOnSelection(p: *Pardes, button: Mouse.Button, id: usize, txt: []const u8, arg: ?[]const u8) void {
+ const pane = p.panes[id] orelse return;
+ // image tagline toggles (execute), before the builtin/run dispatch
+ if (button == .middle) if (pane.image) |*iv| {
+ const w = std.mem.trim(u8, txt, " \t\r\n");
+ if (std.mem.eql(u8, w, "Petscii")) {
+ iv.petscii = !iv.petscii;
+ return;
+ }
+ if (std.mem.eql(u8, w, "C64") or std.mem.eql(u8, w, "Term")) {
+ iv.pmode = if (iv.pmode == .commodore) .terminal else .commodore;
+ return;
+ }
+ if (std.mem.eql(u8, w, "Ascii")) {
+ iv.ascii = !iv.ascii;
+ return;
+ }
+ };
+ if (button == .right) {
+ var realbuf: [4096]u8 = undefined;
+ var cwdbuf: [1024]u8 = undefined;
+ const cwd: []const u8 = if (pane.file) |f|
+ (std.fs.path.dirname(f.path) orelse "/")
+ else blk: {
+ const c = pane.cwdSlice();
+ @memcpy(cwdbuf[0..c.len], c);
+ break :blk cwdbuf[0..c.len];
+ };
+ switch (look.resolve(txt, cwd, &realbuf)) {
+ .none => {},
+ .url => |u| p.emit(.{ .open_link = .from(u) }),
+ .dir => |dir| {
+ // focus an existing terminal on this dir, else fork one below
+ for (p.panes, 0..) |slot, i| {
+ if (slot) |tt| if (tt.isTerminal() and std.mem.eql(u8, tt.cwdSlice(), dir)) {
+ p.active = i;
+ p.emitWrite(i, "ls\r");
+ return;
+ };
+ }
+ const free = p.freeSlot() orelse return;
+ const nt = p.newShell(free, dir) catch return;
+ nt.greet = true;
+ const f = p.layoutFindTerm(id).?;
+ p.layoutInsert(f.col, f.idx + 1, free);
+ p.splitBelow(id, nt);
+ p.active = free;
+ },
+ .file => |target| {
+ // focus an existing pane on this path (rescrolled), else open
+ for (p.panes, 0..) |slot, i| {
+ if (slot) |tt| if (tt.file) |*ff| if (std.mem.eql(u8, ff.path, target.path)) {
+ p.active = i;
+ if (target.line > 0 and target.line <= fileLineCount(ff.content)) {
+ const next = target.line - 1;
+ if (next != ff.scroll) {
+ ff.scroll = next;
+ ff.syntax_dirty = true;
+ }
+ }
+ return;
+ };
+ }
+ const free = p.freeSlot() orelse return;
+ const nt = p.openFileView(free, target.path, target.line) catch return;
+ p.placeDoc(id, free, nt);
+ },
+ .image => |target| {
+ for (p.panes, 0..) |slot, i| {
+ if (slot) |tt| if (tt.image) |iv| if (std.mem.eql(u8, iv.path, target.path)) {
+ p.active = i;
+ return;
+ };
+ }
+ const free = p.freeSlot() orelse return;
+ // petscii by default when the host has no kitty graphics
+ const nt = p.openImageView(free, target.path, !p.kitty_ok) catch return;
+ p.placeDoc(id, free, nt);
+ },
+ }
+ return;
+ }
+ switch (onSelect(button, txt)) {
+ .none => {},
+ .debug => p.show_debug = !p.show_debug,
+ .colors => p.colors_on = !p.colors_on,
+ .nextcolor => p.theme_idx = (p.theme_idx + 1) % themes.len,
+ .dump => p.dumpState() catch {},
+ .kill => {
+ p.quit = true;
+ p.emit(.quit);
+ },
+ .save => {
+ if (pane.file != null) p.emit(.{ .save_file = .{ .pane = @intCast(id) } });
+ },
+ .send => {
+ // terminal: run in itself. file pane: run in a shell in the
+ // file's directory (found by cwd, else freshly forked).
+ const cmd = std.mem.trim(u8, txt, " \t\r\n");
+ const dst: ?usize = if (pane.file) |f|
+ p.ttyForDir(std.fs.path.dirname(f.path) orelse "/")
+ else
+ id;
+ if (dst) |di| {
+ p.padOutputBelowEdits(di);
+ p.emitWrite(di, cmd);
+ // a chord argument (kept left selection) rides as a
+ // trailing CLI argument
+ if (arg) |a| {
+ const at = std.mem.trim(u8, a, " \t\r\n");
+ if (at.len > 0) {
+ p.emitWrite(di, " ");
+ p.emitWrite(di, at);
+ }
+ }
+ p.emitWrite(di, "\r");
+ }
+ },
+ .newcol => {
+ const free = p.freeSlot() orelse return;
+ if (p.ncol >= MAX_COLS) return;
+ const nt = p.newShell(free, "") catch return;
+ nt.greet = true;
+ p.layoutAppendColumn(free);
+ p.active = free;
+ },
+ .del => {
+ p.absorbVWeight(id);
+ p.layoutRemove(id);
+ p.deinitPane(pane);
+ p.panes[id] = null;
+ if (p.active == id) p.active = for (p.panes, 0..) |sl, i| {
+ if (sl != null) break i;
+ } else {
+ p.quit = true;
+ p.emit(.quit);
+ return;
+ };
+ },
+ .delcol => {
+ const f = p.layoutFindTerm(id) orelse return;
+ var ids: [MAX_PANES]usize = undefined;
+ const nids = p.col_n[f.col];
+ for (0..nids) |k| ids[k] = p.col_terms[f.col][k];
+ for (ids[0..nids]) |tid| {
+ if (p.panes[tid]) |tt| {
+ p.layoutRemove(tid);
+ p.deinitPane(tt);
+ p.panes[tid] = null;
+ }
+ }
+ if (p.panes[p.active] == null) p.active = for (p.panes, 0..) |sl, i| {
+ if (sl != null) break i;
+ } else {
+ p.quit = true;
+ p.emit(.quit);
+ return;
+ };
+ },
+ .tutor => {
+ const free = p.freeSlot() orelse return;
+ const nt = p.openTutorView(free) catch return;
+ p.stackDocLeft(free, nt);
+ },
+ }
+ }
+
+ /// place a fresh doc pane: split an existing doc (the source if it's one,
+ /// else the first found), or take the left column when no doc exists yet
+ fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void {
+ var src_id: ?usize = null;
+ if (p.panes[from_id]) |src| if (src.file != null or src.image != null) {
+ src_id = from_id;
+ };
+ if (src_id == null) for (p.panes, 0..) |sl, i| {
+ if (sl) |pp| if ((pp.file != null or pp.image != null) and i != free) {
+ src_id = i;
+ break;
+ };
+ };
+ if (src_id) |sid| {
+ const sf = p.layoutFindTerm(sid).?;
+ p.layoutInsert(sf.col, sf.idx + 1, free);
+ p.splitBelow(sid, nt);
+ p.active = free;
+ } else {
+ p.stackDocLeft(free, nt);
+ }
+ }
+
+ // ---- dump / load (acme-style: `pardes -l state.zon` restores a session) ----
+
+ fn dumpState(p: *Pardes) !void {
+ const arena = p.scratch.allocator();
+ var slot_to_pane: [MAX_PANES]?usize = @splat(null);
+ var panes: std.ArrayList(dump.Pane) = .empty;
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ slot_to_pane[id] = panes.items.len;
+ const pfx = try p.tagPrefix(pane);
+ const tag = try std.fmt.allocPrint(arena, "{s}{s}", .{ pfx, curTail(pane) });
+ const body = try p.bodyText(arena, pane);
+ const scroll: usize = @intCast(@max(0, pane.scroll()));
+ const dp: dump.Pane = if (pane.file) |f| .{
+ .kind = .file,
+ .tag = tag,
+ .body = body,
+ .scroll = scroll,
+ .cols = pane.cols,
+ .rows = pane.rows,
+ .vweight = pane.vweight,
+ .file = .{
+ .path = f.path,
+ .content = f.content,
+ .content_b64 = try dump.encodeBytes(arena, f.content),
+ },
+ } else if (pane.image) |iv| blk: {
+ const bytes = look.readFile(arena, iv.path) catch "";
+ break :blk .{
+ .kind = .image,
+ .tag = tag,
+ .body = body,
+ .scroll = scroll,
+ .cols = pane.cols,
+ .rows = pane.rows,
+ .vweight = pane.vweight,
+ .image = .{
+ .path = iv.path,
+ .bytes_b64 = if (bytes.len > 0) try dump.encodeBytes(arena, bytes) else "",
+ },
+ };
+ } else blk: {
+ // the stream text: whole history, prompts hidden, runs spliced
+ const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} });
+ var stream_text: std.ArrayList(u8) = .empty;
+ var lines = std.mem.splitAny(u8, full, "\n");
+ var prompts = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null);
+ var row: usize = 0;
+ while (lines.next()) |raw| : (row += 1) {
+ if (row > 0) try stream_text.append(arena, '\n');
+ const is_prompt = if (pane.mode != .tty)
+ if (prompts.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false
+ else
+ false;
+ const line = if (is_prompt) "" else raw;
+ if (pane.mode == .tty) {
+ try stream_text.appendSlice(arena, line);
+ } else {
+ try spliceRow(arena, &stream_text, pane, @intCast(row), line);
+ }
+ }
+ break :blk .{
+ .kind = .terminal,
+ .tag = tag,
+ .body = body,
+ .scroll = scroll,
+ .cols = pane.cols,
+ .rows = pane.rows,
+ .vweight = pane.vweight,
+ .terminal = .{
+ .cwd = try arena.dupe(u8, pane.cwdSlice()),
+ .stream = stream_text.items,
+ .stream_b64 = try dump.encodeBytes(arena, pane.tty_stream.items),
+ .cursor = .{
+ .col = pane.vt.screens.active.cursor.x,
+ .row = pane.vt.screens.active.cursor.y,
+ },
+ },
+ };
+ };
+ try panes.append(arena, dp);
+ }
+
+ var columns: std.ArrayList(dump.Column) = .empty;
+ for (0..p.ncol) |c| {
+ var ids: std.ArrayList(usize) = .empty;
+ for (0..p.col_n[c]) |k| {
+ if (slot_to_pane[p.col_terms[c][k]]) |compact| try ids.append(arena, compact);
+ }
+ if (ids.items.len > 0)
+ try columns.append(arena, .{ .weight = p.col_weight[c], .panes = ids.items });
+ }
+
+ const state: dump.State = .{
+ .screen = .{ .cols = p.screen_w, .rows = p.screen_h },
+ .active = slot_to_pane[p.active] orelse 0,
+ .topbar = topbar_str,
+ .theme = p.theme().name,
+ .columns = columns.items,
+ .panes = panes.items,
+ };
+ try dump.validate(state);
+ var out: std.Io.Writer.Allocating = .init(p.gpa);
+ errdefer out.deinit();
+ try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer);
+ if (p.dump_out) |d| p.gpa.free(d);
+ p.dump_out = try out.toOwnedSlice();
+ p.emit(.write_dump);
+ }
+
+ /// Initialize from another instance's dump: panes reconstructed (terminals
+ /// by replaying their raw VT streams into fresh emulators), no spawns —
+ /// loaded terminals are dead replays, scrollable and selectable.
+ pub fn initFromDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8) !*Pardes {
+ const p = try gpa.create(Pardes);
+ p.* = .{ .gpa = gpa, .opts = opts, .screen_w = opts.cols, .screen_h = opts.rows, .scratch = .init(gpa) };
+ errdefer p.deinit();
+ const st = try dump.readZon(gpa, zon_bytes, "load");
+ defer dump.free(gpa, st);
+
+ for (themes, 0..) |t, i| {
+ if (std.mem.eql(u8, t.name, st.theme)) p.theme_idx = i;
+ }
+ for (st.panes, 0..) |src, i| {
+ if (i >= MAX_PANES) break;
+ switch (src.kind) {
+ .terminal => {
+ const t = src.terminal.?;
+ const pane = try gpa.create(Pane);
+ pane.* = .{
+ .vt = try ghostty_vt.Terminal.init(gpa, .{
+ .cols = @max(1, src.cols),
+ .rows = @max(1, src.rows),
+ .max_scrollback = 16 * 1024 * 1024,
+ }),
+ .stream = undefined,
+ .cols = @max(1, src.cols),
+ .rows = @max(1, src.rows),
+ };
+ pane.stream = pane.vt.vtStream();
+ pane.stream.handler.effects.write_pty = ptyReport;
+ pane.stream.handler.effects.device_attributes = ptyDeviceAttrs;
+ p.panes[i] = pane;
+ if (t.stream_b64.len > 0) {
+ const bytes = try dump.decodeBytes(p.scratch.allocator(), t.stream_b64);
+ pane.tty_stream.appendSlice(gpa, bytes) catch {};
+ pane.stream.nextSlice(bytes);
+ pane.vt.screens.active.scroll(.active);
+ if (src.scroll > 0) pane.vt.screens.active.scroll(.{ .delta_row = -@as(isize, @intCast(src.scroll)) });
+ }
+ p.setCwd(i, t.cwd);
+ if (std.mem.startsWith(u8, src.tag, "sy ")) pane.mode = .tty;
+ p.restoreTail(pane, src.tag);
+ },
+ .file => {
+ const f = src.file.?;
+ const content: []u8 = if (f.content_b64.len > 0)
+ try dump.decodeBytes(gpa, f.content_b64)
+ else
+ try gpa.dupe(u8, f.content);
+ errdefer gpa.free(content);
+ const path = try gpa.dupe(u8, f.path);
+ errdefer gpa.free(path);
+ const pane = try p.newDocPane(i);
+ pane.file = .{ .path = path, .content = content, .scroll = src.scroll };
+ pane.cur_pinned = true;
+ pane.cur_row = @intCast(src.scroll);
+ pane.cols = @max(1, src.cols);
+ pane.rows = @max(1, src.rows);
+ p.restoreTail(pane, src.tag);
+ },
+ .image => {
+ const im = src.image.?;
+ const path = try gpa.dupe(u8, im.path);
+ errdefer gpa.free(path);
+ const pane = try p.newDocPane(i);
+ const raw: []u8 = if (im.bytes_b64.len > 0)
+ dump.decodeBytes(gpa, im.bytes_b64) catch &.{}
+ else
+ &.{};
+ pane.image = .{ .path = path, .petscii = true, .raw = raw };
+ pane.cols = @max(1, src.cols);
+ pane.rows = @max(1, src.rows);
+ },
+ }
+ p.panes[i].?.vweight = src.vweight;
+ }
+ p.ncol = @min(st.columns.len, MAX_COLS);
+ for (st.columns[0..p.ncol], 0..) |col, c| {
+ p.col_weight[c] = col.weight;
+ p.col_n[c] = @min(col.panes.len, MAX_PANES);
+ for (col.panes[0..p.col_n[c]], 0..) |pid, k| p.col_terms[c][k] = pid;
+ }
+ p.active = @min(st.active, MAX_PANES - 1);
+ p.sync();
+ return p;
+ }
+
+ /// restore a custom tag tail from a saved tag: whatever follows the live
+ /// prefix, unless it's just the defaults
+ fn restoreTail(p: *Pardes, pane: *Pane, saved_tag: []const u8) void {
+ const pfx = p.tagPrefix(pane) catch return;
+ if (!std.mem.startsWith(u8, saved_tag, pfx)) return;
+ const rest = saved_tag[pfx.len..];
+ const default_tail: []const u8 = if (pane.file != null) file_pane_tail else pane_tail;
+ if (std.mem.eql(u8, rest, default_tail)) return;
+ pane.tag_tail.appendSlice(p.gpa, rest) catch return;
+ pane.tag_init = true;
+ }
+
+ fn removePane(p: *Pardes, id: usize) void {
+ const pane = p.panes[id] orelse return;
+ p.absorbVWeight(id);
+ p.layoutRemove(id);
+ p.deinitPane(pane);
+ p.panes[id] = null;
+ if (p.active == id) p.active = for (p.panes, 0..) |slot, i| {
+ if (slot != null) break i;
+ } else {
+ p.quit = true;
+ p.emit(.quit);
+ return;
+ };
+ }
+
+ /// Recompute geometry, push grid-size changes to each emulator + pty, fire
+ /// deferred greetings. The mirror of the prototype's loop epilogue.
+ fn sync(p: *Pardes) void {
+ p.computeGeom();
+ for (&p.panes, 0..) |*slot, id| {
+ const pane = slot.* orelse continue;
+ if (pane.reply_len > 0) {
+ var off: u16 = 0;
+ while (off < pane.reply_len) {
+ const n = @min(pane.reply_len - off, 64);
+ p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(pane.reply[off .. off + n]) } });
+ off += n;
+ }
+ pane.reply_len = 0;
+ }
+ const r = p.rects[id];
+ const cols = @max(1, r.w -| GUTTER);
+ const rows = @max(1, r.h -| BOX_H); // the tag steals the top row
+ if (cols != pane.cols or rows != pane.rows) {
+ // doc panes have no pty/emulator grid to reflow; just record
+ // the size so bodyText renders the right number of rows
+ if (pane.isTerminal()) {
+ pane.vt.resize(p.gpa, cols, rows) catch {};
+ p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } });
+ }
+ pane.cols = cols;
+ pane.rows = rows;
+ }
+ // greet a fresh shell with `ls` once it has its real size (after
+ // the first winsize) AND has drawn its first prompt (the pty has
+ // produced output — bash sourced the rc and readline owns echo, so
+ // the greeting echoes on an OSC 133-marked row and hides cleanly;
+ // earlier, the kernel would echo it onto an unmarked row).
+ if (pane.greet and pane.isTerminal() and p.resize_count > 0 and pane.tty_stream.items.len > 0) {
+ p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = pane.cols, .rows = pane.rows } });
+ p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from("ls\r") } });
+ pane.greet = false;
+ }
+ }
+ }
+
+ fn computeGeom(p: *Pardes) void {
+ if (p.ncol == 0) return;
+ var wsum: f32 = 0;
+ for (0..p.ncol) |c| wsum += p.col_weight[c];
+ if (wsum <= 0) wsum = 1;
+
+ var x: u16 = 0;
+ for (0..p.ncol) |c| {
+ const last = c + 1 == p.ncol;
+ const fw = @as(f32, @floatFromInt(p.screen_w)) * p.col_weight[c] / wsum;
+ const cw: u16 = if (last) (p.screen_w -| x) else @max(1, @as(u16, @intFromFloat(@round(fw))));
+ p.col_x[c] = x;
+ p.col_w[c] = cw;
+
+ var vsum: f32 = 0;
+ for (0..p.col_n[c]) |k| {
+ if (p.panes[p.col_terms[c][k]]) |pane| vsum += pane.vweight;
+ }
+ if (vsum <= 0) vsum = 1;
+
+ var y: u16 = TOPBAR_H;
+ const avail_h = p.screen_h -| TOPBAR_H;
+ for (0..p.col_n[c]) |k| {
+ const id = p.col_terms[c][k];
+ const pane = p.panes[id] orelse continue;
+ const lastk = k + 1 == p.col_n[c];
+ const fh = @as(f32, @floatFromInt(avail_h)) * pane.vweight / vsum;
+ const ch: u16 = if (lastk) (p.screen_h -| y) else @max(1, @as(u16, @intFromFloat(@round(fh))));
+ p.rects[id] = .{ .x = x, .y = y, .w = cw, .h = ch };
+ y +|= ch;
+ }
+ x +|= cw;
+ }
+ }
+
+ fn theme(p: *const Pardes) *const Theme {
+ return &themes[p.theme_idx];
+ }
+
+ // ---- render: build the canonical surface ----
+
+ /// re-highlight the visible window of any file whose syntax went stale
+ /// (edit, scroll, load) — visible-range-first so big files stay snappy
+ fn refreshDirtyHighlights(p: *Pardes) void {
+ for (p.panes) |slot| {
+ const pane = slot orelse continue;
+ if (pane.file == null) continue;
+ const f = &pane.file.?;
+ if (!f.syntax_dirty) continue;
+ if (!p.colors_on) {
+ if (f.highlights.len > 0) p.gpa.free(f.highlights);
+ f.highlights = &.{};
+ f.highlight_start = 0;
+ f.syntax_dirty = false;
+ continue;
+ }
+ const start_row = f.scroll -| SYNTAX_CONTEXT_BEFORE_ROWS;
+ const start = modal.lineStartOffset(f.content, start_row);
+ const end_row = start_row + SYNTAX_CONTEXT_BEFORE_ROWS + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS;
+ const end = @max(start, modal.lineStartOffset(f.content, end_row));
+ const new_highlights = syntax.highlightFileRange(p.gpa, f.path, f.content, start, end) catch {
+ f.syntax_dirty = false;
+ continue;
+ };
+ if (f.highlights.len > 0) p.gpa.free(f.highlights);
+ f.highlights = new_highlights;
+ f.highlight_start = if (f.highlights.len > 0) start else 0;
+ f.syntax_dirty = false;
+ }
+ }
+
+ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
+ p.refreshDirtyHighlights();
+ const s = &p.surface;
+ const ncells = @as(usize, p.screen_w) * p.screen_h;
+ if (s.cells.len != ncells) {
+ p.gpa.free(s.cells);
+ s.cells = try p.gpa.alloc(Cell, ncells);
+ }
+ s.cols = p.screen_w;
+ s.rows = p.screen_h;
+ s.cursor = null;
+ s.images = @splat(null);
+ s.nimages = 0;
+ const th = p.theme();
+ // gaps between panes read as chrome (scrollbar track), not raw default
+ s.fill(0, 0, s.cols, s.rows, .{ .bg = .{ .rgb = th.scroll_track } });
+
+ for (&p.panes, 0..) |*slot, id| {
+ const pane = slot.* orelse continue;
+ try p.renderPane(arena, pane, p.rects[id], id == p.active);
+ }
+
+ // global tagbar: full width, top row
+ s.fill(0, 0, s.cols, TOPBAR_H, .{ .bg = .{ .rgb = th.tag_bg } });
+ _ = s.print(0, 0, s.cols, topbar_str, .{ .fg = .{ .rgb = th.tag_fg }, .bg = .{ .rgb = th.tag_bg } });
+
+ // resize-handle hint / drag previews: a dash overlay that keeps the
+ // underlying colors (border drags + hover), or the move indicator
+ switch (p.drag) {
+ .border_v => |d| {
+ var row: u16 = TOPBAR_H;
+ while (row < s.rows) : (row += 1) s.overlayDash(d.cur_x, row, "╎");
+ },
+ .border_h => |d| {
+ var col = p.col_x[d.col];
+ while (col < p.col_x[d.col] + p.col_w[d.col]) : (col += 1) s.overlayDash(col, d.cur_y, "╌");
+ },
+ .move => |d| {
+ if (p.movePlacement(d.id, d.cur_x, d.cur_y)) |placement| {
+ var col: u16 = p.col_x[placement.preview_col];
+ while (col < p.col_x[placement.preview_col] + p.col_w[placement.preview_col]) : (col += 1) {
+ s.set(col, placement.row, "╌", .{ .fg = .{ .rgb = gray.mid } });
+ }
+ s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = gray.mid } });
+ }
+ },
+ .none, .select => {
+ // hover hints on a pane's own trailing edge
+ var c: usize = 0;
+ while (c + 1 < p.ncol) : (c += 1) {
+ if (p.hover_col == p.col_x[c] + p.col_w[c] -| 1) {
+ var row: u16 = TOPBAR_H;
+ while (row < s.rows) : (row += 1) s.overlayDash(p.hover_col, row, "╎");
+ }
+ }
+ for (0..p.ncol) |cc| {
+ if (p.hover_col < p.col_x[cc] or p.hover_col >= p.col_x[cc] + p.col_w[cc]) continue;
+ for (0..p.col_n[cc] -| 1) |k| {
+ const r = p.rects[p.col_terms[cc][k]];
+ if (p.hover_row == r.y + r.h -| 1) {
+ var col = p.col_x[cc];
+ while (col < p.col_x[cc] + p.col_w[cc]) : (col += 1) s.overlayDash(col, p.hover_row, "╌");
+ }
+ }
+ }
+ },
+ }
+
+ // debug overlay: a small stats box drawn last, top-right ("Debug")
+ if (p.show_debug) if (p.panes[p.active]) |at| {
+ var nlive: usize = 0;
+ for (p.panes) |slot| {
+ if (slot != null) nlive += 1;
+ }
+ var sb_off: usize = undefined;
+ var sb_total: usize = undefined;
+ if (at.file) |f| {
+ sb_off = f.scroll;
+ sb_total = fileLineCount(f.content);
+ } else {
+ const sb = at.vt.screens.active.pages.scrollbar();
+ sb_off = sb.offset;
+ sb_total = sb.total;
+ }
+ var ov_buf: [192]u8 = undefined;
+ const text = std.fmt.bufPrint(
+ &ov_buf,
+ "pardes resizes={d}\nterms {d} cols {d}\nactive #{d} {d}x{d}\nscroll {d}/{d}\npinch {d:6.4}\ntscroll {d:7.4}",
+ .{ p.resize_count, nlive, p.ncol, p.active, at.cols, at.rows, sb_off, sb_total, p.ov_pinch_scale, p.ov_touch_scroll_delta },
+ ) catch "";
+ const ow: u16 = 30;
+ const oh: u16 = 8;
+ const ox: u16 = if (s.cols > ow) s.cols - ow else 0;
+ if (ox + ow <= s.cols and TOPBAR_H + oh <= s.rows) {
+ // vaxis single_rounded border ring, default style
+ s.set(ox, TOPBAR_H, "╭", .{});
+ s.set(ox + ow - 1, TOPBAR_H, "╮", .{});
+ s.set(ox, TOPBAR_H + oh - 1, "╰", .{});
+ s.set(ox + ow - 1, TOPBAR_H + oh - 1, "╯", .{});
+ var bx = ox + 1;
+ while (bx < ox + ow - 1) : (bx += 1) {
+ s.set(bx, TOPBAR_H, "─", .{});
+ s.set(bx, TOPBAR_H + oh - 1, "─", .{});
+ }
+ var by = TOPBAR_H + 1;
+ while (by < TOPBAR_H + oh - 1) : (by += 1) {
+ s.set(ox, by, "│", .{});
+ s.set(ox + ow - 1, by, "│", .{});
+ }
+ s.fill(ox + 1, TOPBAR_H + 1, ow - 2, oh - 2, .{ .bg = .{ .rgb = gray.bar } });
+ var lines = std.mem.splitScalar(u8, text, '\n');
+ var ly: u16 = TOPBAR_H + 1;
+ while (lines.next()) |line| : (ly += 1) {
+ if (ly >= TOPBAR_H + oh - 1) break;
+ _ = s.print(ox + 1, ly, ow - 2, line, .{ .fg = .{ .rgb = gray.txt } });
+ }
+ }
+ };
+ return s;
+ }
+
+ fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, active: bool) !void {
+ if (r.w <= GUTTER or r.h == 0) return;
+ const s = &p.surface;
+ const th = p.theme();
+ const tx = r.x + GUTTER; // text area (tag + body), right of the gutter
+ const tw = r.w - GUTTER;
+
+ // text area resets to terminal-default cells (vaxis clear semantics);
+ // light themes paint the page over it.
+ s.clearRect(tx, r.y, tw, r.h);
+ if (th.bg) |bg| s.fill(tx, r.y, tw, r.h, .{ .bg = .{ .rgb = bg } });
+
+ // tag: live prefix (mode + cwd/path) + editable tail, on a dim bar
+ const tag_style: CellStyle = .{ .fg = .{ .rgb = th.tag_fg }, .bg = .{ .rgb = th.tag_bg } };
+ s.fill(tx, r.y, tw, BOX_H, .{ .bg = .{ .rgb = th.tag_bg } });
+ const pfx = try p.tagPrefix(pane);
+ const tag = try std.fmt.allocPrint(arena, "{s}{s}", .{ pfx, curTail(pane) });
+ _ = s.print(tx, r.y, tw, tag, tag_style);
+ // tag-tail char selection highlight (helix v/x), inclusive [lo, hi]
+ if (pane.tag_edit and pane.tag_sel) {
+ const b = tagSelBounds(pane);
+ var col: usize = pfx.len + b.lo;
+ const end: usize = pfx.len + b.hi;
+ while (col <= end and col < tw) : (col += 1) {
+ const cell = s.at(tx + @as(u16, @intCast(col)), r.y);
+ cell.default = false;
+ cell.style.bg = .{ .rgb = msel_bg };
+ cell.style.fg = .{ .rgb = msel_fg };
+ }
+ }
+ // cursor while editing the tail: prefix length + column within it
+ if (active and pane.tag_edit) {
+ const cx: usize = pfx.len + pane.tag_col;
+ if (cx < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(cx)), .y = r.y };
+ }
+
+ // image pane: the picture fills the body — petscii glyph art into the
+ // cells, or a pixel attachment the shell places (kitty). Plain
+ // thumbless gutter so it reads like any other pane.
+ if (pane.image != null) {
+ p.drawImage(pane, r, tx, tw);
+ const box_bg2 = if (active) accent else accent_dim;
+ s.fill(r.x, r.y, GUTTER, BOX_H, .{ .bg = .{ .rgb = box_bg2 } });
+ if (r.h > BOX_H) s.fill(r.x, r.y + BOX_H, GUTTER, r.h - BOX_H, .{ .bg = .{ .rgb = th.scroll_track } });
+ return;
+ }
+
+ // body: emulator rows, prompt rows hidden outside tty mode
+ const body_h = if (r.h > BOX_H) r.h - BOX_H else 0;
+ const body_style: CellStyle = .{
+ .fg = if (th.fg) |c| .{ .rgb = c } else .default,
+ .bg = if (th.bg) |c| .{ .rgb = c } else .default,
+ };
+ const body = try p.bodyText(arena, pane);
+ var it = std.mem.splitScalar(u8, body, '\n');
+ var i: u16 = 0;
+ while (it.next()) |line| : (i += 1) {
+ if (i >= body_h) break;
+ _ = s.print(tx, r.y + BOX_H + i, tw, line, body_style);
+ }
+
+ // file line-number gutter: mute the first PREFIX_W columns. Cheap
+ // chrome, not gated on colors_on; selection/cursor passes still win.
+ if (pane.file != null) {
+ var vr: u16 = 0;
+ while (vr < body_h) : (vr += 1) {
+ var c: u16 = 0;
+ while (c < PREFIX_W and c < tw) : (c += 1) {
+ const cell = s.at(tx + c, r.y + BOX_H + vr);
+ cell.default = false; // paints blank gutter rows too
+ cell.style.fg = .{ .rgb = th.lineno };
+ }
+ }
+ }
+ // syntax colors (file panes): recolor each content cell from its
+ // tree-sitter style byte; content starts after the lineno gutter
+ if (p.colors_on) if (pane.file) |*f| {
+ if (f.highlights.len > 0) {
+ var flines = std.mem.splitScalar(u8, f.content, '\n');
+ var skip: usize = 0;
+ while (skip < f.scroll) : (skip += 1) {
+ if (flines.next() == null) break;
+ }
+ var vr: u16 = 0;
+ while (vr < body_h) : (vr += 1) {
+ const line = flines.next() orelse break;
+ const base = @intFromPtr(line.ptr) - @intFromPtr(f.content.ptr);
+ var c: usize = 0;
+ while (c < line.len and PREFIX_W + c < tw) : (c += 1) {
+ const idx = base + c;
+ if (idx < f.highlight_start) continue;
+ const hidx = idx - f.highlight_start;
+ if (hidx >= f.highlights.len) continue;
+ const ss = p.synStyle(@enumFromInt(f.highlights[hidx])) orelse continue;
+ const cell = s.at(tx + @as(u16, @intCast(PREFIX_W + c)), r.y + BOX_H + vr);
+ if (cell.default) continue;
+ cell.style.fg = .{ .rgb = ss.fg };
+ cell.style.bold = ss.bold;
+ }
+ }
+ }
+ };
+ // tty colors: recolor each body cell from the emulator's own style so
+ // shell output keeps its ansi colors (gated on the Colors toggle).
+ if (p.colors_on and pane.isTerminal()) {
+ var vr: u16 = 0;
+ while (vr < body_h and vr < pane.rows) : (vr += 1) {
+ var c: u16 = 0;
+ while (c < tw) : (c += 1) {
+ const ci = pane.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(c), .y = @intCast(vr) } }) orelse continue;
+ if (ci.cell.wide == .spacer_tail) continue;
+ const cell = s.at(tx + c, r.y + BOX_H + vr);
+ if (cell.default) continue;
+ cell.style = p.cellStyle(ci);
+ }
+ }
+ }
+
+ // repaint inserted cells with the plain body style so typed text reads
+ // exactly like normal text: the tty recolor above painted cell (c,vr)
+ // from emulator column c, but runs shifted the row right.
+ const off = pane.scroll();
+ if (pane.mode != .tty) for (pane.edits[0..pane.nedits]) |*e| {
+ if (e.len == 0) continue;
+ const prow = e.row - off + @as(i32, BOX_H);
+ if (prow < BOX_H or prow >= r.h) continue;
+ const start = pane.renderedCol(e.row, e.col, 0);
+ var k: i32 = 0;
+ while (k < e.len) : (k += 1) {
+ const cx = start + k;
+ if (cx < 0 or cx >= tw) continue;
+ const cell = s.at(tx + @as(u16, @intCast(cx)), r.y + @as(u16, @intCast(prow)));
+ if (!cell.default) cell.style = body_style;
+ }
+ };
+
+ // mouse selections (pane-local coords), one pass per button — later
+ // buttons win on overlap. A left .done stays highlighted after release;
+ // middle/right .done are transient (they fire their action on release).
+ for (pane.sel, 0..) |sl, b| {
+ if (sl.state == .none) continue;
+ if (sl.state == .done and b != 0) continue;
+ const r0 = @min(sl.r0, sl.r1);
+ const r1 = @max(sl.r0, sl.r1);
+ const c0 = @max(0, @min(sl.c0, sl.c1));
+ const c1 = @max(0, @max(sl.c0, sl.c1));
+ var row: u16 = 0;
+ while (row < r.h) : (row += 1) {
+ if (@as(i32, row) < r0 or @as(i32, row) > r1) continue;
+ var col: i32 = if (pane.file != null) @max(c0, @as(i32, PREFIX_W)) else c0;
+ while (col <= c1 and col < tw) : (col += 1) {
+ const cell = s.at(tx + @as(u16, @intCast(col)), r.y + row);
+ cell.default = false;
+ cell.style.bg = .{ .rgb = SEL_BG[b] };
+ cell.style.fg = .{ .rgb = sel_fg };
+ }
+ }
+ }
+ // modal line selection (helix `x`): whole body rows, normal mode only
+ if (pane.mode == .normal and pane.msel.active) {
+ const lo = @min(pane.msel.r0, pane.msel.r1) - off + @as(i32, BOX_H);
+ const hi = @max(pane.msel.r0, pane.msel.r1) - off + @as(i32, BOX_H);
+ var row: u16 = 0;
+ while (row < r.h) : (row += 1) {
+ if (@as(i32, row) < lo or @as(i32, row) > hi) continue;
+ var col: u16 = if (pane.file != null) PREFIX_W else 0;
+ while (col < tw) : (col += 1) {
+ const cell = s.at(tx + col, r.y + row);
+ cell.default = false;
+ cell.style.bg = .{ .rgb = msel_bg };
+ cell.style.fg = .{ .rgb = msel_fg };
+ }
+ }
+ }
+ // modal char selection (helix `v`): stream-shaped anchor..head highlight
+ if (pane.mode == .normal and pane.vsel.active) {
+ const bnd = vselBounds(pane);
+ const vpfx: i32 = if (pane.file != null) PREFIX_W else 0;
+ var ar: i32 = bnd.lo_row;
+ while (ar <= bnd.hi_row) : (ar += 1) {
+ const prow = ar - off + @as(i32, BOX_H);
+ if (prow < 0 or prow >= @as(i32, r.h)) continue;
+ const cstart: i32 = if (ar == bnd.lo_row) bnd.lo_col + vpfx else vpfx;
+ const cend: i32 = if (ar == bnd.hi_row) bnd.hi_col + vpfx else @as(i32, tw) - 1;
+ var col: i32 = @max(cstart, vpfx);
+ while (col <= cend and col < tw) : (col += 1) {
+ const cell = s.at(tx + @as(u16, @intCast(col)), r.y + @as(u16, @intCast(prow)));
+ cell.default = false;
+ cell.style.bg = .{ .rgb = msel_bg };
+ cell.style.fg = .{ .rgb = msel_fg };
+ }
+ }
+ }
+
+ // cursor: tracks the shell cursor until pinned by a click or a key
+ // (the tag cursor above wins while the tag is focused)
+ if (active and !pane.tag_edit) {
+ const cur = pane.vt.screens.active.cursor;
+ if (pane.mode != .tty) {
+ const crow = if (pane.cur_pinned) pane.cur_row else @as(i32, @intCast(cur.y)) + off;
+ const ccol = if (pane.cur_pinned) pane.cur_col else @as(i32, @intCast(cur.x));
+ const coff = if (pane.cur_pinned) pane.cur_off else 0;
+ const prow = crow - off + @as(i32, BOX_H);
+ // files: cur_col is content-relative, add the lineno prefix;
+ // insert mode maps the run anchor to its visual column; normal
+ // mode keeps cur_col already-visual.
+ const cx = if (pane.file != null)
+ @as(i32, PREFIX_W) + ccol + @as(i32, coff)
+ else if (pane.mode == .insert)
+ pane.renderedCol(crow, ccol, coff)
+ else
+ ccol;
+ if (prow >= 0 and cx >= 0 and prow < r.h and cx < tw)
+ s.cursor = .{ .x = tx + @as(u16, @intCast(cx)), .y = r.y + @as(u16, @intCast(prow)) };
+ } else if (cur.y + BOX_H < r.h and cur.x < tw) {
+ s.cursor = .{ .x = tx + cur.x, .y = r.y + cur.y + BOX_H };
+ }
+ }
+
+ // gutter: move box on top, scrollbar track + thumb below
+ const box_bg = if (active) accent else accent_dim;
+ s.fill(r.x, r.y, GUTTER, BOX_H, .{ .bg = .{ .rgb = box_bg } });
+ if (r.h > BOX_H) {
+ s.fill(r.x, r.y + BOX_H, GUTTER, r.h - BOX_H, .{ .bg = .{ .rgb = th.scroll_track } });
+ const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |f| .{
+ .total = fileLineCount(f.content),
+ .offset = f.scroll,
+ .len = pane.rows,
+ } else blk: {
+ const gsb = pane.vt.screens.active.pages.scrollbar();
+ break :blk .{ .total = gsb.total, .offset = gsb.offset, .len = gsb.len };
+ };
+ const track_h: usize = r.h - BOX_H;
+ 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.offset) / total;
+ var sy: usize = pos;
+ while (sy < track_h and sy < pos + len) : (sy += 1) {
+ s.fill(r.x, r.y + BOX_H + @as(u16, @intCast(sy)), GUTTER, 1, .{ .bg = .{ .rgb = th.scroll_thumb } });
+ }
+ }
+ }
+
+ /// Image pane body: decode once (path via look, or dump-loaded raw bytes),
+ /// rebuild the petscii grid when the size/toggles changed, then either
+ /// blit the glyph art into cells or attach the pixels for the shell.
+ fn drawImage(p: *Pardes, pane: *Pane, r: Rect, tx: u16, tw: u16) void {
+ const iv = &pane.image.?;
+ const s = &p.surface;
+ if (!iv.tried) {
+ iv.tried = true;
+ const bytes: []const u8 = if (iv.raw.len > 0) iv.raw else (look.readFile(p.scratch.allocator(), iv.path) catch "");
+ if (image.decode(p.gpa, bytes)) |d| {
+ iv.rgba = d.rgba;
+ iv.iw = d.w;
+ iv.ih = d.h;
+ }
+ }
+ if (iv.rgba.len == 0 or r.h <= BOX_H) return;
+ const body_cols = tw;
+ const body_rows = r.h - BOX_H;
+ const use_petscii = iv.petscii or !p.kitty_ok;
+ if (!use_petscii) {
+ s.images[s.nimages] = .{
+ .pane = @intCast(for (p.panes, 0..) |slot, i| {
+ if (slot == pane) break i;
+ } else 0),
+ .x = tx,
+ .y = r.y + BOX_H,
+ .w = body_cols,
+ .h = body_rows,
+ .rgba = iv.rgba,
+ .iw = iv.iw,
+ .ih = iv.ih,
+ };
+ s.nimages += 1;
+ return;
+ }
+ // (re)build the cached glyph grid if the size or toggles changed
+ if (iv.grid.len == 0 or iv.grid_w != body_cols or iv.grid_h != body_rows or
+ iv.grid_mode != iv.pmode or iv.grid_ascii != iv.ascii)
+ {
+ if (iv.grid.len > 0) p.gpa.free(iv.grid);
+ const pal = switch (iv.pmode) {
+ .commodore => image.petscii.commodore,
+ .terminal => image.ansiPalette(),
+ };
+ const g = image.petscii.render(p.gpa, iv.rgba, iv.iw, iv.ih, body_cols, body_rows, pal, iv.ascii) catch image.petscii.Grid{ .cells = &.{}, .gw = 0, .gh = 0 };
+ iv.grid = g.cells;
+ iv.gw = g.gw;
+ iv.gh = g.gh;
+ iv.grid_w = body_cols;
+ iv.grid_h = body_rows;
+ iv.grid_mode = iv.pmode;
+ iv.grid_ascii = iv.ascii;
+ }
+ if (iv.grid.len == 0) return;
+ const offx = if (body_cols > iv.gw) (@as(usize, body_cols) - iv.gw) / 2 else 0;
+ const offy = if (body_rows > iv.gh) (@as(usize, body_rows) - iv.gh) / 2 else 0;
+ var cy: usize = 0;
+ while (cy < iv.gh) : (cy += 1) {
+ var cx: usize = 0;
+ while (cx < iv.gw) : (cx += 1) {
+ const c = &iv.grid[cy * iv.gw + cx];
+ const fg: Color = switch (iv.pmode) {
+ .commodore => .{ .rgb = image.petscii.commodore[c.fg] },
+ .terminal => .{ .index = c.fg },
+ };
+ const bg: Color = switch (iv.pmode) {
+ .commodore => .{ .rgb = image.petscii.commodore[c.bg] },
+ .terminal => .{ .index = c.bg },
+ };
+ const sx = tx + @as(u16, @intCast(offx + cx));
+ const sy = r.y + BOX_H + @as(u16, @intCast(offy + cy));
+ if (sx < s.cols and sy < s.rows)
+ s.set(sx, sy, c.glyph[0..c.glen], .{ .fg = fg, .bg = bg });
+ }
+ }
+ }
+
+ /// The pane body as text. Image: blank rows (the picture draws over it).
+ /// File: line-numbered content from f.scroll, edits spliced like the tty.
+ /// Terminal: viewport rows, padded to the grid height, prompt rows blanked
+ /// outside tty mode (OSC 133), typed insertion runs spliced in.
+ fn bodyText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
+ _ = p;
+ if (pane.image != null) {
+ const buf = try arena.alloc(u8, pane.rows -| 1);
+ @memset(buf, '\n');
+ return buf;
+ }
+ if (pane.file) |f| {
+ var out: std.ArrayList(u8) = .empty;
+ var flines = std.mem.splitScalar(u8, f.content, '\n');
+ var skipped: usize = 0;
+ while (skipped < f.scroll) : (skipped += 1) {
+ if (flines.next() == null) break;
+ }
+ var i: usize = 0;
+ while (i < pane.rows) : (i += 1) {
+ if (i > 0) try out.append(arena, '\n');
+ const abs: i32 = @as(i32, @intCast(f.scroll)) + @as(i32, @intCast(i));
+ if (flines.next()) |text| {
+ var lbuf: [16]u8 = undefined;
+ // unsigned: {d} prints a leading '+' for signed ints
+ const lineno: usize = @intCast(abs + 1);
+ const prefix = std.fmt.bufPrint(&lbuf, "{d: >4} ", .{lineno}) catch " ";
+ var base: std.ArrayList(u8) = .empty;
+ try base.appendSlice(arena, prefix);
+ try base.appendSlice(arena, text);
+ try spliceRow(arena, &out, pane, abs, base.items);
+ } else try spliceRow(arena, &out, pane, abs, "");
+ }
+ return out.items;
+ }
+ const raw = try pane.vt.plainString(arena);
+ var flags: [512]bool = @splat(false);
+ const nrows: usize = @min(pane.rows, flags.len);
+ if (pane.mode != .tty) {
+ var it = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .viewport = .{} }, null);
+ var i: usize = 0;
+ while (it.next()) |pin| : (i += 1) {
+ if (i >= nrows) break;
+ flags[i] = pin.rowAndCell().row.semantic_prompt != .none;
+ }
+ }
+ const off = pane.scroll();
+ var out: std.ArrayList(u8) = .empty;
+ var lines = std.mem.splitScalar(u8, raw, '\n');
+ var i: usize = 0;
+ while (i < nrows) : (i += 1) {
+ if (i > 0) try out.append(arena, '\n');
+ const line = if (flags[i]) "" else lines.next() orelse "";
+ if (flags[i]) _ = lines.next();
+ if (pane.mode == .tty) {
+ try out.appendSlice(arena, line);
+ } else {
+ try spliceRow(arena, &out, pane, @as(i32, @intCast(i)) + off, line);
+ }
+ }
+ return out.items;
+ }
+
+ fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell) CellStyle {
+ const style = ci.style();
+ var cs: CellStyle = .{
+ .fg = p.ghostColor(style.fg_color, false),
+ .bg = p.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 = switch (style.flags.underline) {
+ .none => .off,
+ .single => .single,
+ .double => .double,
+ .curly => .curly,
+ .dotted => .dotted,
+ .dashed => .dashed,
+ },
+ };
+ switch (ci.cell.content_tag) {
+ .bg_color_palette => cs.bg = p.palColor(ci.cell.content.color_palette.data),
+ .bg_color_rgb => {
+ const rgb = ci.cell.content.color_rgb;
+ cs.bg = .{ .rgb = .{ rgb.r, rgb.g, rgb.b } };
+ },
+ else => {},
+ }
+ return cs;
+ }
+
+ const SynStyle = struct { fg: [3]u8, bold: bool };
+
+ fn synStyle(p: *Pardes, sy: syntax.Syn) ?SynStyle {
+ return switch (sy) {
+ .none => null,
+ .keyword => .{ .fg = p.theme().kw, .bold = true },
+ .string => .{ .fg = p.theme().str, .bold = false },
+ .number => .{ .fg = p.theme().num, .bold = false },
+ .comment => .{ .fg = p.theme().comment, .bold = true },
+ };
+ }
+
+ fn palColor(p: *Pardes, idx: u8) Color {
+ if (p.theme().palette) |pal| if (idx < 16) return .{ .rgb = pal[idx] };
+ return .{ .index = idx };
+ }
+
+ fn ghostColor(p: *Pardes, color: ghostty_vt.Style.Color, is_bg: bool) Color {
+ return switch (color) {
+ .none => blk: {
+ const t = if (is_bg) p.theme().bg else p.theme().fg;
+ break :blk if (t) |c| .{ .rgb = c } else .default;
+ },
+ .palette => |idx| p.palColor(idx),
+ .rgb => |rgb| .{ .rgb = .{ rgb.r, rgb.g, rgb.b } },
+ };
+ }
+};
+
+// ghostty calls this with a reply (cursor-position report, DA, ...) to send
+// back to the child as if it typed it. The handler's `terminal` is our Pane.vt
+// field; recover the Pane and stash the bytes — sync() drains them into write
+// effects (the callback has no path to the effect queue).
+fn ptyReport(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) void {
+ const pane: *Pane = @fieldParentPtr("vt", handler.terminal);
+ const room = pane.reply.len - pane.reply_len;
+ const n = @min(room, data.len);
+ @memcpy(pane.reply[pane.reply_len..][0..n], data[0..n]);
+ pane.reply_len += @intCast(n);
+}
+
+/// Splice this row's insertion runs into `line` (the raw tty text for absolute
+/// row `abs_row`), shifting the underlying text right rather than overwriting.
+fn spliceRow(arena: std.mem.Allocator, out: *std.ArrayList(u8), pane: *Pane, abs_row: i32, line: []const u8) !void {
+ var idxs: [MAX_EDITS]usize = undefined;
+ var n: usize = 0;
+ for (pane.edits[0..pane.nedits], 0..) |e, i| {
+ if (e.row == abs_row and e.len > 0) {
+ idxs[n] = i;
+ n += 1;
+ }
+ }
+ // insertion-sort the runs by column (n is tiny)
+ var a: usize = 1;
+ while (a < n) : (a += 1) {
+ const key = idxs[a];
+ var b = a;
+ while (b > 0 and pane.edits[idxs[b - 1]].col > pane.edits[key].col) : (b -= 1) idxs[b] = idxs[b - 1];
+ idxs[b] = key;
+ }
+ var vpos: usize = 0; // original-line columns consumed so far
+ for (idxs[0..n]) |i| {
+ const e = &pane.edits[i];
+ const c: usize = @intCast(@max(0, e.col));
+ if (c <= line.len) {
+ if (c > vpos) try out.appendSlice(arena, line[vpos..c]);
+ vpos = c;
+ } else {
+ // run anchored past the text: emit the rest, then pad with spaces
+ if (vpos < line.len) {
+ try out.appendSlice(arena, line[vpos..]);
+ vpos = line.len;
+ }
+ while (vpos < c) : (vpos += 1) try out.append(arena, ' ');
+ }
+ try out.appendSlice(arena, e.text[0..e.len]);
+ }
+ if (vpos < line.len) try out.appendSlice(arena, line[vpos..]);
+}
+
+const DeviceAttrs = @typeInfo(@typeInfo(@typeInfo(
+ @FieldType(ghostty_vt.TerminalStream.Handler.Effects, "device_attributes"),
+).optional.child).pointer.child).@"fn".return_type.?;
+fn ptyDeviceAttrs(_: *ghostty_vt.TerminalStream.Handler) DeviceAttrs {
+ return .{};
+}