//! 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; /// vim 'scrolloff': keyboard cursor moves keep this many context rows visible /// above/below the cursor (clamped at file boundaries and short panes) const scroll_off = 3; 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 { // scrolloff margin, shrunk on short panes so the band stays non-empty const margin: i32 = @min(scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); const off = pane.scroll(); const last = off + @as(i32, pane.rows) - 1; if (pane.cur_row < off + margin) { pane.scrollBy(pane.cur_row - margin - off); // scrollBy clamps at line 0 } else if (pane.cur_row > last - margin) { // don't scroll a file past EOF-at-bottom-row (vim's bottom clamp); // terminals overshoot harmlessly — the vt clamps at the live bottom var to = pane.cur_row + margin; if (pane.file) |f| to = @min(to, @as(i32, @intCast(modal.lineCount(f.content) -| 1))); pane.scrollBy(@max(0, to - 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//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 .{}; }