diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 3728 |
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 .{}; +} |
