//! 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"); const tracy = @import("tracy.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, restore }; 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 = "Restore", .action = .restore }, .{ .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; } // Restore takes the dump path as its first argument if (std.mem.startsWith(u8, cmd, "Restore ") or std.mem.startsWith(u8, cmd, "Restore\t")) return .restore; 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 = &.{}, /// bar: draw an insert-style thin cursor instead of the block cursor: ?struct { x: u16, y: u16, bar: bool = false } = 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, wheel_left, wheel_right, 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; pub const delete: u21 = 0xF0009; }; 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; } fn isA(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 the /// path dump.outPath resolves (acme-style: another instance loads it /// with -l, or the Restore builtin loads it into this one) 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: the anchor lives here, the head is the pane /// cursor. Since the helix motion model landed, EVERY motion leaves one of /// these — `explicit` separates user-intent selections (v / x / X / terminal /// n/N / file-search n/N) from bare motion residue: the acme Enter/Tab chords /// only act on explicit ones. Mutually exclusive with LineSel. pub const CharSel = struct { active: bool = false, row: i32 = 0, col: i32 = 0, explicit: bool = false, }; /// 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, }; /// Undo snapshot of a file pane: content + the selection at commit time — /// helix undo restores the selection along with the text. pub const FileSnap = struct { content: []u8, cur_row: i32, cur_col: i32, vsel: CharSel, }; /// A file pane's backing: content is gpa-owned; undo/redo are whole 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(FileSnap) = .empty, redo: std.ArrayList(FileSnap) = .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 = .{}, /// helix select/extend mode (`v`): motions extend the selection from its /// fixed anchor instead of replacing it. Reported as mode "select"; /// pane.mode stays .normal (insert/tty transitions drop it). select: bool = false, /// sticky goal column for j/k runs (helix old_visual_position): any /// non-vertical range write resets it to -1. sticky_col: i32 = -1, /// an `a` append session's original block-cursor cell: Esc backs the /// cursor up one grapheme and rebuilds the appended-over selection from /// here (helix doc.restore_cursor). Null outside `a` sessions. append_at: ?struct { row: i32, col: i32 } = null, /// match-mode second key in flight (`m` + i/a/s/r/d awaiting its char) pending2: u21 = 0, /// captured first char argument (`mr` holds here) pending_ch: u21 = 0, /// count prefix accumulator (0 = none). ponytail: capped at 0xffff — /// nobody types a bigger count, and it keeps the row math in i32 range. count: u32 = 0, /// last f/F/t/T motion, for Alt-. repeat find_op: u8 = 0, find_ch: u21 = 0, /// one pending prefix codepoint (0 = none): `g` `z` `m` `[` `]`, or an /// op waiting for its char argument (`f` `F` `t` `T` `r`) pending: u21 = 0, /// `/` file search: the pattern is typed into the tag tail (no popup); /// search_at is the tail length before " /" was appended (submit/cancel /// restore it). n/N walk the grep results in pane search_pane, starting /// just below search_row (the results shell's prompt row at submit). search_edit: bool = false, search_at: u16 = 0, search_pane: ?usize = null, search_row: i32 = 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, /// the body mode a tag edit hijacked (tags are always insert); terminals /// restore it on exit so clicking the tag never changes the pane's mode tag_mode: Mode = .normal, 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, /// horizontal scroll, file panes only (terminals wrap at pty width, they /// never have wider lines): content columns hidden left of the gutter. /// No scrollbar — the wheel and cursor movement (with scrolloff) drive it, /// the goal is just being able to read long lines. Byte columns, like the /// rest of the file-pane code. hscroll: 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 horizontal mirror, files only: keep scroll_off columns of // context around the cursor (wheel-driven hscroll is exempt — it // never moves the cursor, and a cursor move pulls the view back) if (pane.file != null) { const w: i32 = @max(1, @as(i32, pane.cols) - @as(i32, PREFIX_W)); const hmargin: i32 = @min(scroll_off, @divTrunc(w - 1, 2)); if (pane.cur_col < pane.hscroll + hmargin) pane.hscroll = @max(0, pane.cur_col - hmargin) else if (pane.cur_col > pane.hscroll + w - 1 - hmargin) pane.hscroll = pane.cur_col - (w - 1 - hmargin); } } /// 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 }, /// chorded: a 1-2/1-3 cut/paste chord fired during this left drag — /// the drag's own release is then inert select: struct { id: usize, button: Mouse.Button, chorded: bool = false }, }; const Rect = struct { x: u16, y: u16, w: u16, h: u16 }; pub const Options = struct { tty_only: bool = false, /// initial shell panes: 1 (default) or 3 for the classic two-column boot shells: u8 = 1, 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, /// pending effects, drained by the shell after each update. Growable: one /// update can emit unbounded writes (a big selection executed is chunked /// into 64-byte write effects) — a fixed ring overflows and panics. effects: std.ArrayList(Effect) = .empty, effects_head: usize = 0, /// modal yank register (gpa-owned); a yank also mirrors out to the system /// clipboard via the set_clipboard effect. yank: ?[]u8 = null, /// a normal-mode `P` is in flight: the next Event.paste pastes BEFORE /// (the get_clipboard round trip carries no argument) paste_before: bool = false, /// the last serialized dump (gpa-owned), read by the write_dump effect dump_out: ?[]u8 = null, /// where the shell wrote the last dump (shell reports back after /// write_dump); shown in the topbar as `Restore ` last_dump: ?[]const u8 = null, /// a Restore builtin wants this dump loaded into the live session; the /// shell consumes it via takeRestore each frame (core stays sans-IO) restore_req: ?[]const u8 = null, restore_buf: [1024]u8 = undefined, 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 if (opts.shells >= 3) { // classic layout: 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; } else { // ponytail: 1 and 3 are the only boot layouts; anything else acts as 1 _ = try p.newPane(0); p.panes[0].?.greet = true; p.ncol = 1; p.col_n[0] = 1; p.col_terms[0][0] = 0; } 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.last_dump) |d| gpa.free(d); if (p.chord_arg) |a| gpa.free(a); p.effects.deinit(gpa); p.scratch.deinit(); gpa.free(p.surface.cells); gpa.destroy(p); } /// shell feedback after write_dump: remember where the dump landed (the /// topbar grows a `Restore ` word pointing at it) pub fn setLastDump(p: *Pardes, path: []const u8) void { const copy = p.gpa.dupe(u8, path) catch return; if (p.last_dump) |old| p.gpa.free(old); p.last_dump = copy; } /// the shell polls this each frame: a pending Restore's dump path, or null pub fn takeRestore(p: *Pardes) ?[]const u8 { const r = p.restore_req; p.restore_req = null; return r; } /// the topbar line: the fixed builtins, plus `Restore ` once a dump /// exists — render and click dispatch must agree on this exact string fn topbar(p: *Pardes, buf: []u8) []const u8 { if (p.last_dump) |d| return std.fmt.bufPrint(buf, topbar_str ++ " Restore {s}", .{d}) catch topbar_str; return topbar_str; } 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.content); f.undo.deinit(p.gpa); for (f.redo.items) |s| p.gpa.free(s.content); 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); if (cwd.len > 256) return error.PathTooLong; // spawn effect cwd is a Buf(256) 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 { p.effects.append(p.gpa, e) catch {}; } pub fn nextEffect(p: *Pardes) ?Effect { if (p.effects_head == p.effects.items.len) { p.effects.clearRetainingCapacity(); p.effects_head = 0; return null; } const e = p.effects.items[p.effects_head]; 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 only in tty mode; in // normal/insert the user is reading scrollback and the view // holds still while output streams in. Tag edits hijack // pane.mode to insert — the body mode sits in tag_mode. const bmode = if (pane.tag_edit) pane.tag_mode else pane.mode; if (bmode == .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` p.setYank(bytes); if (p.panes[p.active]) |pane| p.normalPaste(pane, p.paste_before); p.paste_before = false; }, .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) "INS" else "NOR"; return std.fmt.allocPrint(arena, "{s} {s}", .{ fmode, f.path }); } const mode = switch (pane.mode) { .normal => "NOR", .insert => "INS", .tty => "TTY", }; 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; } if (!pane.tag_edit) pane.tag_mode = pane.mode; pane.tag_edit = true; pane.tag_sel = false; // a one-line tag has no use for normal mode: always insert pane.mode = .insert; 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 { // tags are always insert; leaving one restores the body mode: files // back to normal, terminals to whatever they had — a click (this runs // on every body press) must never change a shell pane's mode if (pane.isTerminal()) { if (pane.tag_edit) pane.mode = pane.tag_mode; } else pane.mode = .normal; pane.tag_edit = false; pane.tag_sel = false; pane.search_edit = false; // an abandoned `/` input stays as tag text } 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 tagInsertKey(p: *Pardes, pane: *Pane, key: Key) void { if (is(key, Key.escape)) { exitTagEdit(pane); // the tag is ALWAYS insert; Esc leaves it return; } // Enter looks / Tab executes the selection or the word at the cursor if (is(key, Key.enter) or is(key, Key.tab)) { if (p.tagChordText(pane)) |txt| { pane.tag_sel = false; p.actOnSelection(if (is(key, Key.tab)) .middle else .right, p.active, txt, null); } 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 /// 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 - pane.hscroll + @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; } // insert mode owns Ctrl-w (delete word backward, helix); the focus // prefix keeps normal/tty if (isC(key, 'w') and pane.mode != .insert) { 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.splitBelow(p.active, nt); 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.absorbVWeight(p.active); 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; } // `/` search input in flight: Enter greps, Esc abandons; both restore // the tag tail and hand focus back to the body. if (pane.search_edit and (is(key, Key.enter) or is(key, Key.escape))) { if (is(key, Key.enter)) p.submitSearch(pane); pane.tag_tail.shrinkRetainingCapacity(@min(pane.search_at, pane.tag_tail.items.len)); exitTagEdit(pane); pane.mode = .normal; pane.pending = 0; return; } // normal-mode Enter/Tab: the acme chords — look at (Enter) or execute // (Tab) the EXPLICIT modal selection (v/x/X, terminal n/N, search // n/N); implicit motion residue falls back to the file-ish word // under the cursor (on the tag tail when it's focused, 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; pane.pinCursor(); const explicit = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; if (explicit) { if (p.currentSelText(pane)) |txt| { pane.vsel.active = false; pane.msel.active = false; pane.select = false; p.actOnSelection(btn, p.active, txt, null); return; } } 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.tagInsertKey(pane, key); return; } // normal-mode `p`/`P` paste the SYSTEM clipboard: an async OSC 52 // round trip — request here, the reply arrives later as Event.paste. // Skipped while a prefix waits for its char argument (f/r/m and // friends take `p` literally). if (pane.mode == .normal and pane.pending == 0 and (is(key, 'p') or is(key, 'P'))) { p.paste_before = 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; // leaving an `a` append session: the cursor backs up one // grapheme and the appended-over span becomes the // implicit selection (helix doc.restore_cursor) if (pane.append_at) |aa| { pane.append_at = null; if (pane.file != null) { const pl = p.paneCursorLines(pane) catch return; const text = p.flatSurface(pane, pl) catch return; const gap = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }); const back = modal.prevGrapheme(text, gap); const bc = modal.hxPos(text, back); pane.cur_row = @intCast(bc.row); pane.cur_col = @intCast(bc.col); const a_off = modal.hxOff(text, .{ .row = @intCast(@max(0, aa.row)), .col = @intCast(@max(0, aa.col)) }); pane.vsel = .{ .active = a_off != back, .row = aa.row, .col = aa.col, .explicit = false }; pane.cur_pinned = true; pane.ensureCursorVisible(); } } 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.select = false; pane.append_at = null; pane.sticky_col = -1; 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. /// (pub only for test/hxdiff.zig — the helix differential harness dumps /// this surface as a tty case's final text.) pub 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; } // ---- helix range plumbing (see modal.zig "helix range engine") ---- // The pane's cursor + vsel cells render ONE helix gap range over the flat // motion surface. Every motion builds the current range, transforms it the // way the helix command would, and writes it back: normal mode REPLACES // the selection with the motion's range, select mode (v) extends it via // put_cursor. The differential suite (zig build hxdiff) pins all of this // against a real helix, key for key. /// the flat motion surface: file content as-is; terminals join the /// cursor-lines dump (scratch-arena backed, same lifetime as pl) fn flatSurface(p: *Pardes, pane: *Pane, pl: PaneLines) ![]const u8 { if (pane.file) |f| return f.content; const arena = p.scratch.allocator(); var buf: std.ArrayList(u8) = .empty; for (pl.lines, 0..) |ln, i| { if (i > 0) try buf.append(arena, '\n'); try buf.appendSlice(arena, ln); } return buf.items; } /// the current selection as a helix gap range over `text` fn paneRange(pane: *Pane, text: []const u8) modal.HxRange { const c = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }); if (pane.msel.active) { // legacy line selection (file-search results highlight): linewise const r0: usize = @intCast(@max(0, @min(pane.msel.r0, pane.msel.r1))); const r1: usize = @intCast(@max(0, @max(pane.msel.r0, pane.msel.r1))); const s = modal.lineStartOffset(text, r0); const e = if (r1 + 1 >= modal.hxLineCount(text)) text.len else modal.lineStartOffset(text, r1 + 1); return .{ .anchor = s, .head = @max(e, modal.nextGrapheme(text, c)) }; } if (pane.vsel.active) { const a = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row)), .col = @intCast(@max(0, pane.vsel.col)) }); if (a <= c) return .{ .anchor = a, .head = modal.nextGrapheme(text, c) }; return .{ .anchor = modal.nextGrapheme(text, a), .head = c }; } return .{ .anchor = c, .head = modal.nextGrapheme(text, c) }; } /// write a helix range back into pane state. `explicit` marks user-intent /// selections (v/x/X, terminal n/N, file-search n/N) — the acme chords /// act only on those; motion residue stays implicit. fn setPaneRange(pane: *Pane, pl: PaneLines, text: []const u8, r0: modal.HxRange, explicit: bool) void { var r = r0; if (r.anchor == r.head) r.head = modal.nextGrapheme(text, r.head); // min_width_1 var cur_off: usize = undefined; var anc_off: usize = undefined; if (r.head > r.anchor) { cur_off = modal.prevGrapheme(text, r.head); anc_off = r.anchor; } else if (r.head < r.anchor) { cur_off = r.head; anc_off = modal.prevGrapheme(text, r.anchor); } else { cur_off = r.head; anc_off = r.head; } const cc = modal.hxPos(text, cur_off); const ac = modal.hxPos(text, anc_off); pane.cur_row = @as(i32, @intCast(cc.row)) + pl.row0; pane.cur_col = @intCast(cc.col); pane.vsel = .{ .active = anc_off != cur_off or pane.select, .row = @as(i32, @intCast(ac.row)) + pl.row0, .col = @intCast(ac.col), .explicit = explicit or pane.select, }; pane.msel.active = false; pane.cur_off = 0; pane.cur_pinned = true; pane.sticky_col = -1; pane.pending = 0; pane.ensureCursorVisible(); } /// point-target motion: collapse there (extend in select mode) fn pointMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, target: usize) void { setPaneRange(pane, pl, text, modal.hxPutCursor(text, range, target, pane.select), false); } /// word motions select their traversed span (extend mode: head only) fn wordMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, cnt: usize, target: modal.WordTarget) void { const wr = modal.hxWordMove(text, range, cnt, target); const res = if (pane.select) modal.hxPutCursor(text, range, modal.hxCursor(text, wr), true) else wr; setPaneRange(pane, pl, text, res, false); } /// f/t/F/T: anchor at the old cursor cell, head on the hit (not found: no move) fn findMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, ch: u21, fwd: bool, till: bool, cnt: usize) void { if (ch > 0x7f) return; // ponytail: ASCII targets only (byte columns) const cur = modal.hxCursor(text, range); const t = modal.hxFindTarget(text, cur, @intCast(ch), fwd, till, cnt) orelse return; const res = if (pane.select) modal.hxPutCursor(text, range, t, true) else modal.hxPutCursor(text, .{ .anchor = cur, .head = cur }, t, true); setPaneRange(pane, pl, text, res, false); } /// j/k and friends: sticky goal column, clamped onto short lines' newline fn verticalMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, down: bool, cnt: usize) void { const cur = modal.hxCursor(text, range); const goal: usize = if (pane.sticky_col >= 0) @intCast(pane.sticky_col) else modal.hxPos(text, cur).col; const t = modal.hxVertTarget(text, cur, down, cnt, goal); // extend mode never walks onto the empty trailing line (helix) if (pane.select and t == text.len and text.len > 0 and text[text.len - 1] == '\n') return; setPaneRange(pane, pl, text, modal.hxPutCursor(text, range, t, pane.select), false); pane.sticky_col = @intCast(goal); } /// Ctrl-d/u: scroll half a page AND move the cursor by the same rows fn halfPageMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, down: bool) void { const half: i32 = @max(1, @divTrunc(@as(i32, pane.rows), 2)); pane.scrollBy(if (down) half else -half); verticalMove(pane, pl, text, range, down, @intCast(half)); } /// helix `scroll` without cursor sync (Ctrl-f/b, PgUp/PgDn, zj/zk): shift /// the view, then snap a fallen-out cursor to the near scrolloff edge, col 0 fn scrollViewMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, delta: i32) void { const margin: i32 = @min(scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); pane.scrollBy(delta); const top = pane.scroll(); const last_row: i32 = @intCast(modal.hxLineCount(text) - 1); const cur = modal.hxCursor(text, range); if (delta > 0) { const snap: i32 = @max(0, @min(top + margin, last_row)); const head = modal.lineStartOffset(text, @intCast(snap)); if (head <= cur) return; const anchor = if (pane.select) range.anchor else head; setPaneRange(pane, pl, text, .{ .anchor = anchor, .head = head }, false); } else { const snap: i32 = @max(0, @min(top + @as(i32, pane.rows) - margin - 1, last_row)); const head = modal.lineStartOffset(text, @intCast(snap)); if (head >= cur) return; const anchor = if (pane.select) range.anchor else head; setPaneRange(pane, pl, text, .{ .anchor = anchor, .head = head }, false); } } /// gt/gc/gb: view-relative rows, col 0, scrolloff clamped (helix goto_window) fn gotoWindow(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, which: u21, cnt: usize) void { const margin: i32 = @min(scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); const top = pane.scroll(); const last_row: i32 = @intCast(modal.hxLineCount(text) - 1); const last_vis: i32 = @min(@as(i32, pane.rows) - 1, last_row - top); const n: i32 = @intCast(cnt - 1); var vline: i32 = switch (which) { 't' => top + margin + n, 'c' => top + @divTrunc(last_vis, 2), else => top + last_vis - (margin + n), }; vline = @max(vline, top + margin); vline = @min(vline, top + last_vis - margin); const row: i32 = std.math.clamp(vline, 0, last_row); pointMove(pane, pl, text, range, modal.lineStartOffset(text, @intCast(row))); } /// helix Range::line_range — the inclusive line span a range covers fn rangeLineSpan(text: []const u8, r: modal.HxRange) struct { start: usize, end: usize } { const from = @min(r.anchor, r.head); const to = @max(r.anchor, r.head); const to_adj = if (from == to) to else @max(modal.prevGrapheme(text, to), from); return .{ .start = modal.hxLineOf(text, from), .end = modal.hxLineOf(text, to_adj) }; } fn lineStartOrEof(text: []const u8, line: usize) usize { if (line >= modal.hxLineCount(text)) return text.len; return modal.lineStartOffset(text, line); } /// helix `x` extend_line_below: full lines incl. the newline, cursor ON /// the last one's '\n'; an already-line-bounded selection grows downward fn lineSelect(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, cnt: usize) void { const span = rangeLineSpan(text, range); const start = modal.lineStartOffset(text, span.start); const end = lineStartOrEof(text, span.end + 1); const full = @min(range.anchor, range.head) == start and @max(range.anchor, range.head) == end; const head = lineStartOrEof(text, span.end + cnt + @intFromBool(full)); setPaneRange(pane, pl, text, .{ .anchor = start, .head = head }, true); } /// helix `X` extend_to_line_bounds (direction kept) fn lineBoundsSelect(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange) void { const span = rangeLineSpan(text, range); const start = modal.lineStartOffset(text, span.start); const end = lineStartOrEof(text, span.end + 1); const r: modal.HxRange = if (range.head < range.anchor) .{ .anchor = end, .head = start } else .{ .anchor = start, .head = end }; setPaneRange(pane, pl, text, r, true); } /// helix `Alt-x` shrink_to_line_bounds (single-line selections untouched) fn shrinkSelToLineBounds(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange) void { const span = rangeLineSpan(text, range); if (span.start == span.end) return; const from = @min(range.anchor, range.head); const to = @max(range.anchor, range.head); var start = modal.lineStartOffset(text, span.start); var end = lineStartOrEof(text, span.end + 1); if (start != from) start = lineStartOrEof(text, span.start + 1); if (end != to) end = modal.lineStartOffset(text, span.end); const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; const r: modal.HxRange = if (range.head < range.anchor) .{ .anchor = end, .head = start } else .{ .anchor = start, .head = end }; setPaneRange(pane, pl, text, r, expl); } fn handleNormal(p: *Pardes, pane: *Pane, key: Key) void { pane.pinCursor(); const pl = p.paneCursorLines(pane) catch return; const text = p.flatSurface(pane, pl) catch return; const lines = pl.lines; // Esc: exit select mode / drop pending prefixes + count. The // selection itself SURVIVES (helix; `;` collapses it) — motions // replace it anyway. if (is(key, Key.escape)) { pane.select = false; pane.pending = 0; pane.pending2 = 0; pane.pending_ch = 0; pane.count = 0; return; } // count prefix: digits accumulate while no prefix op is in flight // (`0` stays line-start until a count begins) if (pane.pending == 0 and !key.ctrl and !key.alt and key.cp >= '0' and key.cp <= '9' and !(key.cp == '0' and pane.count == 0)) { if (pane.count < 0xffff) pane.count = pane.count * 10 + (key.cp - '0'); return; } // any key but a prefix setter consumes the count (setters re-arm it) const cnt: usize = @max(1, pane.count); const has_count = pane.count != 0; const saved_count = pane.count; pane.count = 0; const range = paneRange(pane, text); const cur = modal.hxCursor(text, range); // block-cursor gap offset // the last line a goto may land on: helix skips the empty trailing line const max_line = blk: { const nl = modal.hxLineCount(text); break :blk if (modal.lineStartOffset(text, nl - 1) >= text.len) nl -| 2 else nl - 1; }; // prefix continuations if (pane.pending == 'g') { pane.pending = 0; if (is(key, 'g')) { const line = if (has_count) @min(cnt - 1, max_line) else 0; return pointMove(pane, pl, text, range, modal.lineStartOffset(text, line)); } if (is(key, 'e')) return pointMove(pane, pl, text, range, modal.lineStartOffset(text, max_line)); if (is(key, 'h')) return pointMove(pane, pl, text, range, modal.lineStartOffset(text, modal.hxLineOf(text, cur))); if (is(key, 'l')) { const line = modal.hxLineOf(text, cur); const ls = modal.lineStartOffset(text, line); return pointMove(pane, pl, text, range, @max(ls, modal.prevGrapheme(text, modal.hxLineEndIdx(text, line)))); } if (is(key, 's')) { const line = modal.hxLineOf(text, cur); const ls = modal.lineStartOffset(text, line); const slice = text[ls..modal.hxLineEndIdx(text, line)]; const nw = modal.firstNonWs(slice); if (nw == slice.len) return; // all-whitespace line: no move (helix) return pointMove(pane, pl, text, range, ls + nw); } if (is(key, 'j')) return verticalMove(pane, pl, text, range, true, cnt); if (is(key, 'k')) return verticalMove(pane, pl, text, range, false, cnt); if (is(key, '|')) { const line = modal.hxLineOf(text, cur); const ls = modal.lineStartOffset(text, line); return pointMove(pane, pl, text, range, @min(ls + (cnt - 1), modal.hxLineEndIdx(text, line))); } if (is(key, 't') or is(key, 'c') or is(key, 'b')) return gotoWindow(pane, pl, text, range, key.cp, cnt); return; } if (pane.pending == 'z') { pane.pending = 0; if (is(key, 't')) { pane.scrollBy(pane.cur_row - pane.scroll()); pane.ensureCursorVisible(); return; } if (is(key, 'z') or is(key, 'c')) { pane.scrollBy(pane.cur_row - (pane.scroll() + @divTrunc(@as(i32, pane.rows), 2) - 1)); pane.ensureCursorVisible(); return; } if (is(key, 'b')) { pane.scrollBy(pane.cur_row - (pane.scroll() + @as(i32, pane.rows) - 1)); pane.ensureCursorVisible(); return; } if (is(key, 'j') or is(key, Key.down)) return scrollViewMove(pane, pl, text, range, 1); if (is(key, 'k') or is(key, Key.up)) return scrollViewMove(pane, pl, text, range, -1); if (isC(key, 'd')) return halfPageMove(pane, pl, text, range, true); if (isC(key, 'u')) return halfPageMove(pane, pl, text, range, false); if (isC(key, 'f') or is(key, Key.page_down)) return scrollViewMove(pane, pl, text, range, @as(i32, pane.rows)); if (isC(key, 'b') or is(key, Key.page_up)) return scrollViewMove(pane, pl, text, range, -@as(i32, pane.rows)); return; } if (pane.pending == 'f' or pane.pending == 'F' or pane.pending == 't' or pane.pending == 'T') { const op: u8 = @intCast(pane.pending); pane.pending = 0; if (key.ctrl or key.alt or key.cp >= 0xF0000) return; // not a char pane.find_op = op; pane.find_ch = key.cp; return findMove(pane, pl, text, range, key.cp, op == 'f' or op == 't', op == 't' or op == 'T', cnt); } if (pane.pending == 'r') { pane.pending = 0; if (key.ctrl or key.alt or key.cp >= 0xF0000 or key.cp > 0x7f) return; return p.normalReplaceChar(pane, @intCast(key.cp)); } if (pane.pending == 'm') { if (pane.pending2 == 0) { if (is(key, 'm')) { pane.pending = 0; const mc = modal.matchBracket(lines, modal.hxPos(text, cur)) orelse return; return pointMove(pane, pl, text, range, modal.hxOff(text, mc)); } if (is(key, 'i') or is(key, 'a') or is(key, 's') or is(key, 'r') or is(key, 'd')) { pane.pending2 = key.cp; // pending stays 'm': the char arg is next return; } pane.pending = 0; return; } const sub = pane.pending2; if (key.ctrl or key.alt or key.cp >= 0xF0000) { pane.pending = 0; pane.pending2 = 0; pane.pending_ch = 0; return; } if (sub == 'r' and pane.pending_ch == 0) { pane.pending_ch = key.cp; // mr: hold , wait for return; } pane.pending = 0; pane.pending2 = 0; const from = pane.pending_ch; pane.pending_ch = 0; switch (sub) { 'i', 'a' => return p.textobjectSelect(pane, pl, key.cp, sub == 'a'), 's' => return p.surroundAdd(pane, key.cp), 'd' => return p.surroundDelete(pane, pl, key.cp), 'r' => return p.surroundReplace(pane, pl, from, key.cp), else => return, } } if (pane.pending == ']' or pane.pending == '[') { const fwd = pane.pending == ']'; pane.pending = 0; if (is(key, 'p')) { const r2 = modal.hxParaMove(text, range, cnt, fwd, pane.select); return setPaneRange(pane, pl, text, r2, false); } if (is(key, ' ')) return p.addNewline(pane, fwd, cnt); return; } // prefix setters (the count survives into the continuation) if (is(key, 'g') or is(key, 'z') or is(key, 'f') or is(key, 'F') or is(key, 't') or is(key, 'T') or is(key, 'r') or is(key, ']') or is(key, '[')) { pane.pending = key.cp; pane.count = saved_count; return; } if (is(key, 'm')) { pane.pending = 'm'; pane.pending2 = 0; pane.pending_ch = 0; return; } // movement if (is(key, 'h') or is(key, Key.left)) { var t = cur; for (0..cnt) |_| t = modal.prevGrapheme(text, t); return pointMove(pane, pl, text, range, t); } if (is(key, 'l') or is(key, Key.right)) { var t = cur; for (0..cnt) |_| t = modal.nextGrapheme(text, t); return pointMove(pane, pl, text, range, t); } if (is(key, 'j') or is(key, Key.down)) return verticalMove(pane, pl, text, range, true, cnt); if (is(key, 'k') or is(key, Key.up)) return verticalMove(pane, pl, text, range, false, cnt); if (is(key, 'w')) return wordMove(pane, pl, text, range, cnt, .next_word_start); if (is(key, 'b')) return wordMove(pane, pl, text, range, cnt, .prev_word_start); if (is(key, 'e')) return wordMove(pane, pl, text, range, cnt, .next_word_end); if (is(key, 'W')) return wordMove(pane, pl, text, range, cnt, .next_long_word_start); if (is(key, 'B')) return wordMove(pane, pl, text, range, cnt, .prev_long_word_start); if (is(key, 'E')) return wordMove(pane, pl, text, range, cnt, .next_long_word_end); if (isA(key, '.')) { // repeat the last f/F/t/T; a count repeats the motion N times if (pane.find_op == 0 or pane.find_ch > 0x7f) return; const fwd = pane.find_op == 'f' or pane.find_op == 't'; const till = pane.find_op == 't' or pane.find_op == 'T'; var r = range; var moved = false; for (0..cnt) |_| { const cc = modal.hxCursor(text, r); const t = modal.hxFindTarget(text, cc, @intCast(pane.find_ch), fwd, till, 1) orelse break; r = if (pane.select) modal.hxPutCursor(text, r, t, true) else modal.hxPutCursor(text, .{ .anchor = cc, .head = cc }, t, true); moved = true; } if (!moved) return; return setPaneRange(pane, pl, text, r, false); } if (is(key, '0') or is(key, Key.home)) return pointMove(pane, pl, text, range, modal.lineStartOffset(text, modal.hxLineOf(text, cur))); if (is(key, '$') or is(key, Key.end)) { const line = modal.hxLineOf(text, cur); const ls = modal.lineStartOffset(text, line); return pointMove(pane, pl, text, range, @max(ls, modal.prevGrapheme(text, modal.hxLineEndIdx(text, line)))); } if (is(key, '^')) { const line = modal.hxLineOf(text, cur); const ls = modal.lineStartOffset(text, line); const slice = text[ls..modal.hxLineEndIdx(text, line)]; const nw = modal.firstNonWs(slice); if (nw == slice.len) return; return pointMove(pane, pl, text, range, ls + nw); } if (is(key, 'G')) { // helix goto_line: only acts WITH a count (bare G is a no-op; // ge is goto-last-line) if (!has_count) return; const line = @min(cnt - 1, max_line); return pointMove(pane, pl, text, range, modal.lineStartOffset(text, line)); } if (isC(key, 'd')) return halfPageMove(pane, pl, text, range, true); if (isC(key, 'u')) return halfPageMove(pane, pl, text, range, false); if (isC(key, 'f') or is(key, Key.page_down)) return scrollViewMove(pane, pl, text, range, @as(i32, pane.rows)); if (isC(key, 'b') or is(key, Key.page_up)) return scrollViewMove(pane, pl, text, range, -@as(i32, pane.rows)); // insert entry if (is(key, 'i')) return p.enterInsert(pane, .at, cnt); if (is(key, 'a')) return p.enterInsert(pane, .append, cnt); if (is(key, 'I')) return p.enterInsert(pane, .line_start, cnt); if (is(key, 'A')) return p.enterInsert(pane, .line_end, cnt); if (is(key, 'o')) return p.enterInsert(pane, .open_below, cnt); if (is(key, 'O')) return p.enterInsert(pane, .open_above, cnt); // selections if (is(key, 'v')) { if (pane.select) { pane.select = false; // exit select mode, selection kept (helix) } else { pane.select = true; if (pane.msel.active) { // adopt the legacy line highlight as a char range setPaneRange(pane, pl, text, range, true); } else if (!pane.vsel.active) { pane.vsel = .{ .active = true, .row = pane.cur_row, .col = pane.cur_col, .explicit = true }; } else { pane.vsel.explicit = true; } } pane.cur_pinned = true; pane.pending = 0; return; } if (is(key, 'x')) return lineSelect(pane, pl, text, range, cnt); if (is(key, 'X')) return lineBoundsSelect(pane, pl, text, range); if (isA(key, 'x')) return shrinkSelToLineBounds(pane, pl, text, range); if (is(key, ';')) return setPaneRange(pane, pl, text, .{ .anchor = cur, .head = cur }, false); if (isA(key, ';')) { const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; return setPaneRange(pane, pl, text, .{ .anchor = range.head, .head = range.anchor }, expl); } if (is(key, '%')) return setPaneRange(pane, pl, text, .{ .anchor = 0, .head = text.len }, false); // edits if (is(key, 'd')) return p.normalDelete(pane, true); if (isA(key, 'd')) return p.normalDelete(pane, false); if (is(key, 'c')) return p.normalChange(pane); if (is(key, 'y')) return p.normalYank(pane); if (is(key, 'R')) return p.normalReplaceYank(pane); if (is(key, '~')) return p.normalCase(pane, .toggle); if (is(key, '`')) return p.normalCase(pane, .lower); if (isA(key, '`')) return p.normalCase(pane, .upper); if (is(key, 'J')) return p.normalJoin(pane); if (is(key, '>')) return p.normalIndent(pane, cnt, true); if (is(key, '<')) return p.normalIndent(pane, cnt, false); if (isC(key, 'a')) return p.normalAdjustNumber(pane, @intCast(cnt)); if (isC(key, 'x')) return p.normalAdjustNumber(pane, -@as(i64, @intCast(cnt))); if (is(key, 'u')) return p.doUndo(pane); if (is(key, 'U')) return p.doRedo(pane); // file search: `/` types a pattern into the tag, n/N walk the results if (pane.file != null and is(key, '/')) return p.startSearch(pane); if (pane.file != null and is(key, 'n')) return p.searchStep(pane, 1); if (pane.file != null and is(key, 'N')) return p.searchStep(pane, -1); // terminal n/N: look the next/previous output row that looks somewhere if (pane.isTerminal() and is(key, 'n')) return p.lookStep(pane, pl, 1); if (pane.isTerminal() and is(key, 'N')) return p.lookStep(pane, pl, -1); } // ---- file search (`/` + n/N): grep into a shell pane, look the results ---- /// `/` on a file pane: type the pattern into the tag tail after a " /" /// marker — the existing modal one-line editor, visible while typing, /// nothing that disappears. Enter/Esc are intercepted in handleKey. fn startSearch(p: *Pardes, pane: *Pane) void { if (!pane.tag_init) { pane.tag_tail.appendSlice(p.gpa, file_pane_tail) catch return; pane.tag_init = true; } pane.search_at = @intCast(pane.tag_tail.items.len); pane.tag_tail.appendSlice(p.gpa, " /") catch return; pane.search_edit = true; pane.tag_edit = true; pane.tag_sel = false; pane.mode = .insert; pane.pending = 0; pane.tag_col = @intCast(pane.tag_tail.items.len); } /// Enter on a `/` input: run `grep -nH '' ` in a shell in the /// file's directory (the same path middle-click send takes), so the /// results are ordinary persistent terminal text. Remember the results /// pane and start n/N below its current prompt row. fn submitSearch(p: *Pardes, pane: *Pane) void { const f = pane.file orelse return; const tail = pane.tag_tail.items; const pat = std.mem.trim(u8, tail[@min(@as(usize, pane.search_at) + 2, tail.len)..], " \t\r\n"); if (pat.len == 0) return; const arena = p.scratch.allocator(); var cmd: std.ArrayList(u8) = .empty; cmd.appendSlice(arena, "grep -nH '") catch return; for (pat) |ch| { if (ch == '\'') cmd.appendSlice(arena, "'\\''") catch return else cmd.append(arena, ch) catch return; } cmd.appendSlice(arena, "' '") catch return; cmd.appendSlice(arena, f.path) catch return; cmd.append(arena, '\'') catch return; const dst = p.ttyForDir(std.fs.path.dirname(f.path) orelse "/") orelse return; const rp = p.panes[dst] orelse return; p.padOutputBelowEdits(dst); p.emitWrite(dst, cmd.items); p.emitWrite(dst, "\r"); pane.search_pane = dst; // absolute row of the shell's cursor (the prompt echoing the grep); // results land below it, so n scans from there — older output and // stale results of a previous search stay behind const sb = rp.vt.screens.active.pages.scrollbar(); pane.search_row = @intCast(sb.total -| rp.rows + rp.vt.screens.active.cursor.y); } /// n/N: select the next/previous grep result row in the results pane and /// look it. Result rows read `path:NNN:...` and are found by their path /// prefix; prompt/echo rows are already blanked by paneCursorLines (the /// OSC 133 semantic row marks), so only real output is walked. fn searchStep(p: *Pardes, pane: *Pane, delta: i32) void { const f = pane.file orelse return; const rid = pane.search_pane orelse return; const rp = p.panes[rid] orelse return; if (!rp.isTerminal()) return; const pl = p.paneCursorLines(rp) catch return; const nrows: i32 = @intCast(pl.lines.len); var r = pane.search_row + delta; while (r >= 0 and r < nrows) : (r += delta) { const ln = pl.lines[@intCast(r)]; if (!std.mem.startsWith(u8, ln, f.path) or ln.len <= f.path.len or ln[f.path.len] != ':') continue; pane.search_row = r; // select the result line in the results pane and keep it in view rp.msel = .{ .active = true, .r0 = r, .r1 = r }; rp.vsel.active = false; rp.cur_row = r; rp.cur_col = 0; rp.cur_off = 0; rp.cur_pinned = true; rp.ensureCursorVisible(); // look the leading file-ish word (path:NNN:) — the ordinary look // path scrolls this file pane to the line var hi: usize = 0; while (hi < ln.len and look.isFileChar(ln[hi])) hi += 1; p.actOnSelection(.right, rid, ln[0..hi], null); return; } // no result that way (or none at all): nowhere to go, stay put } /// n/N on a terminal pane (normal mode): select the next/previous output /// row whose leading file-ish word looks somewhere — resolves to a /// file/dir/path:NNN exactly as a right click would — and look it. The /// scan only resolves (realpath, no pane opens, no scrolls); the one /// chosen row gets the real look. The modal cursor is the position, so /// the walk continues from wherever j/k left it. The look may focus what /// it opened; focus comes back here so the next n keeps stepping. /// n/N on a terminal pane: a MOTION over lookable tokens. Select the /// next/prev whitespace-separated token that look.resolve can turn into a /// file/dir (several per line: an ls row hops big.txt -> plain.txt), park /// the cursor at its start, open NOTHING — Enter's normal-mode handler /// looks the selection. Wraps around when nothing lies in the direction /// (fresh out of tty mode the cursor sits below the output, so the first /// n lands on the first token). fn lookStep(p: *Pardes, pane: *Pane, pl: PaneLines, delta: i32) void { _ = p; // a pure motion now: Enter's normal-mode handler does the look pane.pinCursor(); // fresh out of tty mode the cursor still tracks the shell const nrows: i32 = @intCast(pl.lines.len); if (nrows == 0) return; var realbuf: [4096]u8 = undefined; var k: i32 = 0; while (k < nrows) : (k += 1) { const r = @mod(pane.cur_row + delta * k, nrows); const ln = pl.lines[@intCast(r)]; var hit_t0: usize = 0; var hit_t1: usize = 0; var i: usize = 0; while (i < ln.len) { while (i < ln.len and (ln[i] == ' ' or ln[i] == '\t')) i += 1; const t0 = i; while (i < ln.len and ln[i] != ' ' and ln[i] != '\t') i += 1; if (i == t0) break; // on the cursor's own row (no wrap yet) only tokens strictly // past the cursor count, in the motion's direction if (k == 0) { if (delta > 0 and @as(i32, @intCast(t0)) <= pane.cur_col) continue; if (delta < 0 and @as(i32, @intCast(t0)) >= pane.cur_col) continue; } if (look.resolve(ln[t0..i], pane.cwdSlice(), &realbuf) == .none) continue; hit_t0 = t0; hit_t1 = i; if (delta > 0) break; // first token forward; keep the last one backward } if (hit_t1 == 0) continue; // anchor the selection at the token's end, cursor at its START; // EXPLICIT: Enter's look chord acts on it pane.vsel = .{ .active = true, .row = r, .col = @intCast(hit_t1 - 1), .explicit = true }; pane.msel.active = false; pane.cur_row = r; pane.cur_col = @intCast(hit_t0); pane.cur_off = 0; pane.cur_pinned = true; pane.ensureCursorVisible(); return; } // nothing lookable anywhere: stay put } const InsertAt = enum { at, append, line_start, line_end, open_below, open_above }; fn enterInsert(p: *Pardes, pane: *Pane, where: InsertAt, cnt: usize) void { pane.pinCursor(); // snapshot once per insert session (WITH the pre-insert selection) so // `u` undoes the whole session and restores what was selected p.pushUndo(pane); pane.select = false; pane.append_at = null; pane.sticky_col = -1; const pl = p.paneCursorLines(pane) catch { pane.mode = .insert; pane.msel.active = false; pane.vsel.active = false; pane.pending = 0; return; }; const text = p.flatSurface(pane, pl) catch return; const cur = toModalCursor(pane, pl); const llen: usize = if (cur.row < pl.lines.len) pl.lines[cur.row].len else 0; // helix selection-aware entry: `i` to the selection's START (the // selection flips and survives until the first edit); `a` one past // its END, remembering the origin cell for the Esc restore const b: ?Bounds = if (pane.vsel.active) vselBounds(pane) else null; switch (where) { .at => { if (b) |bb| { pane.cur_row = bb.lo_row; pane.cur_col = bb.lo_col; pane.vsel = .{ .active = true, .row = bb.hi_row, .col = bb.hi_col, .explicit = false }; pane.cur_pinned = true; } }, .append => { const lo_row = if (b) |bb| bb.lo_row else pane.cur_row; const lo_col = if (b) |bb| bb.lo_col else pane.cur_col; const hi_row = if (b) |bb| bb.hi_row else pane.cur_row; const hi_col = if (b) |bb| bb.hi_col else pane.cur_col; pane.append_at = .{ .row = lo_row, .col = lo_col }; const gap = modal.nextGrapheme(text, modal.hxOff(text, .{ .row = @intCast(@max(0, hi_row)), .col = @intCast(@max(0, hi_col)) })); const gc = modal.hxPos(text, gap); pane.cur_row = @intCast(gc.row); pane.cur_col = @intCast(gc.col); pane.vsel = .{ .active = b != null, .row = lo_row, .col = lo_col, .explicit = false }; pane.cur_pinned = true; }, .line_start => { fromModalCursor(pane, pl, modal.firstNonWsOf(pl.lines, cur)); pane.vsel.active = false; }, .line_end => { pane.cur_col = @intCast(llen); pane.cur_pinned = true; pane.vsel.active = false; }, .open_below, .open_above => { pane.vsel.active = false; const below = where == .open_below; if (pane.file) |*f| { // helix `open`: the new line copies the current line's // full indent LEVELS; o opens n lines, cursor on the // first const row: usize = if (b) |bb| @intCast(@max(0, if (below) bb.hi_row else bb.lo_row)) else cur.row; const ind = modal.hxIndentString(modal.lineSlice(f.content, row)); const arena = p.scratch.allocator(); var block: std.ArrayList(u8) = .empty; for (0..cnt) |_| { if (below) { block.append(arena, '\n') catch return; block.appendSlice(arena, ind) catch return; } else { block.appendSlice(arena, ind) catch return; block.append(arena, '\n') catch return; } } const at: modal.Cursor = if (below) .{ .row = row, .col = modal.lineSlice(f.content, row).len } else .{ .row = row, .col = 0 }; const new = modal.insertAt(p.gpa, f.content, at, block.items) catch return; p.setFileContent(f, new); pane.cur_row = @as(i32, @intCast(if (below) row + 1 else row)) + pl.row0; pane.cur_col = @intCast(ind.len); } else if (below) { const bottom = pane.scroll() + @as(i32, pane.rows) - 1; pane.cur_row = @min(bottom, pane.cur_row + 1); 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.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 { // helix aliases: normalize to the base key and re-dispatch if (isC(key, 'h')) return p.handleInsert(pane, .{ .cp = Key.backspace }); if (isC(key, 'j')) return p.handleInsert(pane, .{ .cp = Key.enter }); if (isC(key, 'd')) return p.handleInsert(pane, .{ .cp = Key.delete }); // a selection carried into insert (i/a) survives only until the next // key: helix maps it through every edit, pardes drops it instead — // its only pardes use (the acme chords) needs explicit selections // anyway, and those never enter insert mode pane.vsel.active = false; 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 and !key.ctrl and !key.alt) { 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; } // helix insert-mode kills (word/line; deleteSpan is exclusive) if (isC(key, 'w') or (key.alt and !key.ctrl and key.cp == Key.backspace)) { // helix delete_word_backward: to the previous word start — // crossing the newline at col 0 (the word before it goes too) const g = modal.hxOff(f.content, c); if (g == 0) return; const wr = modal.hxWordMove(f.content, .{ .anchor = g, .head = g }, 1, .prev_word_start); const from = @min(wr.anchor, wr.head); const fc = modal.hxPos(f.content, from); const new = modal.deleteSpan(p.gpa, f.content, fc, c) catch return; p.setFileContent(f, new); pane.cur_row = @intCast(fc.row); pane.cur_col = @intCast(fc.col); pane.cur_pinned = true; pane.ensureCursorVisible(); return; } if (key.alt and !key.ctrl and (key.cp == 'd' or key.cp == Key.delete)) { // helix delete_word_forward: to the next word END (trailing // whitespace survives), crossing newlines at line ends const g = modal.hxOff(f.content, c); const wr = modal.hxWordMove(f.content, .{ .anchor = g, .head = g }, 1, .next_word_end); const to = @max(wr.anchor, wr.head); if (to <= g) return; const new = modal.deleteSpan(p.gpa, f.content, c, modal.hxPos(f.content, to)) catch return; p.setFileContent(f, new); pane.cur_pinned = true; return; } if (isC(key, 'u')) { const new = modal.deleteSpan(p.gpa, f.content, .{ .row = c.row, .col = 0 }, c) catch return; p.setFileContent(f, new); pane.cur_col = 0; pane.cur_pinned = true; pane.ensureCursorVisible(); return; } if (isC(key, 'k')) { const llen = modal.lineSlice(f.content, c.row).len; const new = modal.deleteSpan(p.gpa, f.content, c, .{ .row = c.row, .col = llen }) catch return; p.setFileContent(f, new); pane.cur_pinned = true; return; } switch (key.cp) { Key.enter => { // helix insert_newline: the new line copies the current // line's full indent LEVELS (plain-text heuristic) const ind = modal.hxIndentString(modal.lineSlice(f.content, c.row)); const arena = p.scratch.allocator(); const block = std.fmt.allocPrint(arena, "\n{s}", .{ind}) catch return; const new = modal.insertAt(p.gpa, f.content, c, block) catch return; p.setFileContent(f, new); pane.cur_row += 1; pane.cur_col = @intCast(ind.len); 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) { // at col 0: eat the previous newline (join up) const prevlen = modal.lineSlice(f.content, c.row - 1).len; const new = modal.deleteSpan(p.gpa, f.content, .{ .row = c.row - 1, .col = prevlen }, .{ .row = c.row, .col = 0 }) 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(); }, Key.delete => { // delete forward; at line end eat the newline (join) const line = modal.lineSlice(f.content, c.row); if (c.col < line.len) { const new = modal.deleteChar(p.gpa, f.content, c) catch return; p.setFileContent(f, new); } else if (c.row + 1 < modal.lineCount(f.content)) { const new = modal.deleteSpan(p.gpa, f.content, c, .{ .row = c.row + 1, .col = 0 }) catch return; p.setFileContent(f, new); } pane.cur_pinned = true; }, Key.tab => { // helix insert_tab with a Spaces indent style: spaces to // the next tab stop (smart-tab machinery skipped) const pad = modal.INDENT_W - (c.col % modal.INDENT_W); const spaces = " "; const new = modal.insertAt(p.gpa, f.content, c, spaces[0..pad]) catch return; p.setFileContent(f, new); pane.cur_col += @intCast(pad); pane.cur_pinned = true; pane.ensureCursorVisible(); }, Key.home => { pane.cur_col = 0; pane.cur_pinned = true; pane.ensureCursorVisible(); }, Key.end => { // helix insert End: past the last char (goto_line_end_newline) pane.cur_col = @intCast(modal.lineSlice(f.content, c.row).len); pane.cur_pinned = true; pane.ensureCursorVisible(); }, Key.page_up, Key.page_down => { // helix binds insert pageup/pagedown to the same view // scroll + cursor snap as normal mode const pl = p.paneCursorLines(pane) catch return; const text = p.flatSurface(pane, pl) catch return; const range = paneRange(pane, text); const step: i32 = @intCast(@max(1, pane.rows)); scrollViewMove(pane, pl, text, range, if (key.cp == Key.page_down) step else -step); }, else => {}, } return; } if (!pane.cur_pinned) pane.pinCursor(); if (key.text.len > 0 and !key.ctrl and !key.alt) { insertRunText(pane, key.text); return; } // kills confined to the typed run at the cursor (output is immutable) if (isC(key, 'w') or (key.alt and !key.ctrl and key.cp == Key.backspace)) { const ei = pane.runAt(pane.cur_row, pane.cur_col) orelse return; const e = &pane.edits[ei]; var t: usize = @min(pane.cur_off, e.len); while (t > 0 and e.text[t - 1] == ' ') t -= 1; while (t > 0 and e.text[t - 1] != ' ') t -= 1; const cut = @min(pane.cur_off, e.len) - t; var k: usize = t; while (k + cut < e.len) : (k += 1) e.text[k] = e.text[k + cut]; e.len -= @intCast(cut); pane.cur_off = @intCast(t); return; } if (key.alt and !key.ctrl and (key.cp == 'd' or key.cp == Key.delete)) { const ei = pane.runAt(pane.cur_row, pane.cur_col) orelse return; const e = &pane.edits[ei]; var t: usize = @min(pane.cur_off, e.len); while (t < e.len and e.text[t] != ' ') t += 1; while (t < e.len and e.text[t] == ' ') t += 1; const cut = t - @min(pane.cur_off, e.len); var k: usize = @min(pane.cur_off, e.len); while (k + cut < e.len) : (k += 1) e.text[k] = e.text[k + cut]; e.len -= @intCast(cut); return; } if (isC(key, 'u')) { const ei = pane.runAt(pane.cur_row, pane.cur_col) orelse return; const e = &pane.edits[ei]; const cut = @min(pane.cur_off, e.len); var k: usize = 0; while (k + cut < e.len) : (k += 1) e.text[k] = e.text[k + cut]; e.len -= @intCast(cut); pane.cur_off = 0; return; } if (isC(key, 'k')) { const ei = pane.runAt(pane.cur_row, pane.cur_col) orelse return; const e = &pane.edits[ei]; e.len = @min(pane.cur_off, e.len); 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; } }, Key.delete => { const ei = pane.runAt(pane.cur_row, pane.cur_col) orelse return; const e = &pane.edits[ei]; if (pane.cur_off < e.len) { var k: usize = pane.cur_off; while (k + 1 < e.len) : (k += 1) e.text[k] = e.text[k + 1]; e.len -= 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; } } 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, yank: bool) void { pane.select = false; // helix delete exits select mode pane.sticky_col = -1; if (pane.vsel.active) { const b = vselBounds(pane); if (yank) 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); if (yank) 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); if (yank) 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)) }; if (yank) { const arena = p.scratch.allocator(); p.setYank(modal.rangeText(arena, f.content, c, c) catch return); } // deleteRange (not deleteChar): the cursor may sit ON a '\n' // cell, where a delete joins the lines (helix 1-wide selection) const d = modal.deleteRange(p.gpa, f.content, c, c) catch return; p.setFileContent(f, d.content); p.gpa.free(d.deleted); const llen = modal.lineSlice(f.content, c.row).len; pane.cur_col = @min(pane.cur_col, @as(i32, @intCast(llen))); } 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 { pane.select = false; // helix yank exits select mode, selection KEPT if (pane.vsel.active) { // helix: yank moves neither the cursor nor the selection p.setYank(p.vselText(pane)); 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)); } } else { // helix: a bare cursor IS a 1-wide selection — yank the char // under it, file and terminal alike (the line-yank vim-ism died // with the motion model). vselText already speaks both. const save = pane.vsel; pane.vsel = .{ .active = true, .row = pane.cur_row, .col = pane.cur_col, .explicit = false }; p.setYank(p.vselText(pane)); pane.vsel = save; } } /// helix p/P: a yank ending in '\n' pastes as whole lines below/above /// the SELECTION's line span; anything else splices inline at the /// selection's outer edge. The paste (repeated times) becomes /// the implicit selection, cursor on its last char (linewise: ON the /// last pasted line's newline). Terminals ignore `before` (runs splice /// at the cursor either way). fn normalPaste(p: *Pardes, pane: *Pane, before: bool) void { const y0 = p.yank orelse return; if (y0.len == 0) return; p.pushUndo(pane); pane.select = false; pane.sticky_col = -1; const cnt: usize = @max(1, pane.count); pane.count = 0; const arena = p.scratch.allocator(); var y: []const u8 = y0; if (cnt > 1) { var buf: std.ArrayList(u8) = .empty; for (0..cnt) |_| buf.appendSlice(arena, y0) catch return; y = buf.items; } if (pane.file) |*f| { const b: Bounds = if (pane.vsel.active) vselBounds(pane) else .{ .lo_row = pane.cur_row, .lo_col = pane.cur_col, .hi_row = pane.cur_row, .hi_col = pane.cur_col }; if (y[y.len - 1] == '\n') { const block_text = y[0 .. y.len - 1]; const n = modal.lineCount(block_text); if (before) { const row: usize = @intCast(@max(0, b.lo_row)); const block = std.fmt.allocPrint(arena, "{s}\n", .{block_text}) catch return; const new = modal.insertAt(p.gpa, f.content, .{ .row = row, .col = 0 }, block) catch return; p.setFileContent(f, new); pane.cur_row = @intCast(row + n - 1); } else { const row: usize = @intCast(@max(0, b.hi_row)); const new = modal.pasteLineBelow(p.gpa, f.content, row, block_text) catch return; p.setFileContent(f, new); pane.cur_row = @intCast(row + n); } pane.vsel = .{ .active = true, .row = pane.cur_row - @as(i32, @intCast(n - 1)), .col = 0, .explicit = false }; // cursor ON the last pasted line's '\n' (helix) const llen = modal.lineSlice(f.content, @intCast(pane.cur_row)).len; pane.cur_col = @intCast(llen); } else { const at: modal.Cursor = if (before) .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) } else blk: { const hrow: usize = @intCast(@max(0, b.hi_row)); const gcol = @as(usize, @intCast(@max(0, b.hi_col))) + 1; if (gcol > modal.lineSlice(f.content, hrow).len) break :blk .{ .row = hrow + 1, .col = 0 }; break :blk .{ .row = hrow, .col = gcol }; }; const new = modal.insertAt(p.gpa, f.content, at, y) catch return; p.setFileContent(f, new); pane.vsel = .{ .active = y.len > 1, .row = @intCast(at.row), .col = @intCast(at.col), .explicit = false }; const end = modal.advanceBy(at, y); if (end.col > 0) { pane.cur_row = @intCast(end.row); pane.cur_col = @intCast(end.col - 1); } else { pane.cur_row = @intCast(end.row -| 1); pane.cur_col = @intCast(modal.lineSlice(f.content, end.row -| 1).len); } } pane.msel.active = false; pane.cur_off = 0; pane.cur_pinned = true; pane.ensureCursorVisible(); return; } 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 { pane.select = false; pane.sticky_col = -1; if (pane.vsel.active) { const b = vselBounds(pane); // helix: a LINEWISE selection (whole lines incl. their final // newline) deletes the lines and opens a fresh one above, // copying the indent of the line that moves up if (pane.file) |*f| { const hi_len = modal.lineSlice(f.content, @intCast(@max(0, b.hi_row))).len; const linewise = b.lo_col == 0 and @as(usize, @intCast(@max(0, b.hi_col))) >= hi_len; if (linewise) { p.setYank(p.vselText(pane)); p.pushUndo(pane); const d = modal.deleteLines(p.gpa, f.content, @intCast(@max(0, b.lo_row)), @intCast(@max(0, b.hi_row))) catch return; p.setFileContent(f, d.content); p.gpa.free(d.deleted); const n = modal.lineCount(f.content); const row: usize = @min(@as(usize, @intCast(@max(0, b.lo_row))), if (n == 0) 0 else n - 1); const ind = modal.hxIndentString(modal.lineSlice(f.content, row)); const arena = p.scratch.allocator(); const block = std.fmt.allocPrint(arena, "{s}\n", .{ind}) catch return; const new = modal.insertAt(p.gpa, f.content, .{ .row = row, .col = 0 }, block) catch return; p.setFileContent(f, new); pane.cur_row = @intCast(row); pane.cur_col = @intCast(ind.len); pane.vsel.active = false; pane.msel.active = false; pane.cur_off = 0; pane.cur_pinned = true; pane.mode = .insert; pane.pending = 0; pane.ensureCursorVisible(); return; } } 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 arena = p.scratch.allocator(); p.setYank(modal.rangeText(arena, f.content, c, c) catch return); const d = modal.deleteRange(p.gpa, f.content, c, c) catch return; p.setFileContent(f, d.content); p.gpa.free(d.deleted); const llen = modal.lineSlice(f.content, c.row).len; pane.cur_col = @min(pane.cur_col, @as(i32, @intCast(llen))); pane.mode = .insert; pane.cur_off = 0; pane.pending = 0; } else { p.pushUndo(pane); pane.mode = .insert; pane.cur_off = 0; pane.pending = 0; } } // ---- helix change ops (r R ~ ` J > < Ctrl-a m-mode ]space) ---- // Terminals follow the immutable-output rule: r/~/`/Alt-` touch the typed // run byte under the cursor if there is one; the rest are clean no-ops. /// the typed-run byte under the (visual) cursor, if any fn runByteAt(pane: *Pane, row: i32, col: i32) ?*u8 { for (pane.edits[0..pane.nedits]) |*e| { if (e.row != row or e.len == 0) continue; const vstart = pane.renderedCol(e.row, e.col, 0); if (col < vstart or col >= vstart + @as(i32, e.len)) continue; return &e.text[@intCast(col - vstart)]; } return null; } /// the selection as an inclusive cursor range: vsel span, msel line span, /// else the char under the cursor (helix's implicit 1-wide selection) fn selRange(pane: *Pane, content: []const u8) modal.Range { if (pane.vsel.active) { const b = vselBounds(pane); return .{ .a = .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) }, .b = .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) }, }; } if (pane.msel.active) { const r0: usize = @intCast(@max(0, @min(pane.msel.r0, pane.msel.r1))); const r1: usize = @intCast(@max(0, @max(pane.msel.r0, pane.msel.r1))); const llen = modal.lineSlice(content, r1).len; return .{ .a = .{ .row = r0, .col = 0 }, .b = .{ .row = r1, .col = llen -| 1 } }; } const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }; return .{ .a = c, .b = c }; } /// `r`: overwrite the selection (or the cursor char) with ch — /// newlines included (helix), so `xrz` joins the selected lines fn normalReplaceChar(p: *Pardes, pane: *Pane, ch: u8) void { pane.select = false; if (pane.file) |*f| { const r = selRange(pane, f.content); p.pushUndo(pane); const new = modal.replaceChars(p.gpa, f.content, r.a, r.b, ch) catch return; p.setFileContent(f, new); } else if (runByteAt(pane, pane.cur_row, pane.cur_col)) |b| { p.pushUndo(pane); b.* = ch; } } /// `R`: replace the selection (or the cursor char) with the yank register; /// the pasted text becomes the selection, head on its last char (which /// for a trailing-newline yank is the '\n' cell of the last full line) fn normalReplaceYank(p: *Pardes, pane: *Pane) void { const y = p.yank orelse return; if (y.len == 0) return; if (pane.file == null) return; // shell output is immutable pane.select = false; const f = &pane.file.?; const r = selRange(pane, f.content); p.pushUndo(pane); const new = modal.replaceRange(p.gpa, f.content, r.a, r.b, y) catch return; p.setFileContent(f, new); pane.msel.active = false; pane.vsel = .{ .active = y.len > 1, .row = @intCast(r.a.row), .col = @intCast(r.a.col), .explicit = false }; const end = modal.advanceBy(r.a, y); if (end.col > 0) { pane.cur_row = @intCast(end.row); pane.cur_col = @intCast(end.col - 1); } else { // the yank ended in '\n': the cursor lands ON that newline pane.cur_row = @intCast(end.row -| 1); pane.cur_col = @intCast(modal.lineSlice(f.content, end.row -| 1).len); } pane.cur_off = 0; pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); } /// `~` / `` ` `` / ``Alt-` ``: case-map the selection (or the cursor char), /// keeping the selection (helix) fn normalCase(p: *Pardes, pane: *Pane, op: modal.CaseOp) void { pane.select = false; if (pane.file) |*f| { const r = selRange(pane, f.content); p.pushUndo(pane); const new = modal.changeCase(p.gpa, f.content, r.a, r.b, op) catch return; p.setFileContent(f, new); } else if (runByteAt(pane, pane.cur_row, pane.cur_col)) |b| { p.pushUndo(pane); b.* = switch (op) { .toggle => if (std.ascii.isUpper(b.*)) std.ascii.toLower(b.*) else std.ascii.toUpper(b.*), .lower => std.ascii.toLower(b.*), .upper => std.ascii.toUpper(b.*), }; } } /// a change to apply to file content: delete [from, to), insert `ins` /// spaces at `from`. Ascending and disjoint. const TextChange = struct { from: usize, to: usize, ins: usize }; /// map an original-text offset through a change list (insertions AT a /// position push it right — helix Assoc::After; positions inside a /// deleted span collapse to its start) fn mapThroughChanges(chs: []const TextChange, pos: usize) usize { var delta: i64 = 0; for (chs) |ch| { if (pos < ch.from) break; if (ch.from == ch.to) { delta += @intCast(ch.ins); continue; } if (pos == ch.from) break; if (pos < ch.to) return @intCast(@as(i64, @intCast(ch.from)) + delta); delta += @as(i64, @intCast(ch.ins)) - @as(i64, @intCast(ch.to - ch.from)); } return @intCast(@as(i64, @intCast(pos)) + delta); } /// apply a change list; the insertions are spaces (join/indent only ever /// insert those). gpa-owned result. fn applyChanges(p: *Pardes, text: []const u8, chs: []const TextChange) ![]u8 { var out: std.ArrayList(u8) = .empty; errdefer out.deinit(p.gpa); var copied: usize = 0; for (chs) |ch| { try out.appendSlice(p.gpa, text[copied..ch.from]); for (0..ch.ins) |_| try out.append(p.gpa, ' '); copied = ch.to; } try out.appendSlice(p.gpa, text[copied..]); return out.toOwnedSlice(p.gpa); } /// `J`: helix join_selections — join the selection's line span (a bare /// cursor joins with the next line): each '\n' + following indent become /// one space, EXCEPT before content-less lines (no space) — and on the /// buffer's last line the trailing newline is deleted. The selection and /// cursor map through the edit; the count is ignored (helix). fn normalJoin(p: *Pardes, pane: *Pane) void { if (pane.file == null) return; // joining shell output would edit it const f = &pane.file.?; const text = f.content; const range = paneRange(pane, text); const span = rangeLineSpan(text, range); const nlines = modal.hxLineCount(text); var end = span.end; if (span.start == end) end = @min(end + 1, nlines - 1); if (end <= span.start) return; const arena = p.scratch.allocator(); var chs: std.ArrayList(TextChange) = .empty; var l = span.start; while (l < end) : (l += 1) { const from = modal.hxLineEndIdx(text, l); var to = if (l + 1 >= nlines) text.len else modal.lineStartOffset(text, l + 1); while (to < text.len and (text[to] == ' ' or text[to] == '\t')) to += 1; const sep: usize = if (to == modal.hxLineEndIdx(text, @min(l + 1, nlines - 1))) 0 else 1; chs.append(arena, .{ .from = from, .to = to, .ins = sep }) catch return; } if (chs.items.len == 0) return; p.pushUndo(pane); const cur_off = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }); const anc_off = if (pane.vsel.active) modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row)), .col = @intCast(@max(0, pane.vsel.col)) }) else cur_off; const new = p.applyChanges(text, chs.items) catch return; const nc = mapThroughChanges(chs.items, cur_off); const na = mapThroughChanges(chs.items, anc_off); p.setFileContent(f, new); const cc = modal.hxPos(f.content, nc); const ac = modal.hxPos(f.content, na); pane.cur_row = @intCast(cc.row); pane.cur_col = @intCast(cc.col); if (pane.vsel.active) { pane.vsel.row = @intCast(ac.row); pane.vsel.col = @intCast(ac.col); } pane.msel.active = false; pane.cur_off = 0; pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); } /// `>` / `<`: helix indent/unindent over the selection's line span. /// Blank (all-whitespace) lines are skipped; `>` inserts count levels /// realigned to the next INDENT_W stop; `<` removes up to count levels of /// leading whitespace (a tab advances to the next stop). Cursor and /// selection map through the edit. fn normalIndent(p: *Pardes, pane: *Pane, cnt: usize, add: bool) void { if (pane.file == null) return; // shell output is immutable pane.select = false; const f = &pane.file.?; const text = f.content; const range = paneRange(pane, text); const span = rangeLineSpan(text, range); const arena = p.scratch.allocator(); var chs: std.ArrayList(TextChange) = .empty; var l = span.start; while (l <= span.end) : (l += 1) { const ls = modal.lineStartOffset(text, l); const le = modal.hxLineEndIdx(text, l); const line = text[ls..le]; const nw = modal.firstNonWs(line); if (nw == line.len) continue; // blank lines stay blank (helix) if (add) { const ins = modal.INDENT_W * cnt - (nw % modal.INDENT_W); chs.append(arena, .{ .from = ls, .to = ls, .ins = ins }) catch return; } else { const want = modal.INDENT_W * cnt; var w: usize = 0; var pos: usize = 0; while (pos < line.len) { if (line[pos] == ' ') { w += 1; } else if (line[pos] == '\t') { w = (w / modal.INDENT_W + 1) * modal.INDENT_W; } else break; pos += 1; if (w >= want) break; } if (pos > 0) chs.append(arena, .{ .from = ls, .to = ls + pos, .ins = 0 }) catch return; } } if (chs.items.len == 0) return; p.pushUndo(pane); const cur_off = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }); const anc_off = if (pane.vsel.active) modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row)), .col = @intCast(@max(0, pane.vsel.col)) }) else cur_off; const new = p.applyChanges(text, chs.items) catch return; const nc = mapThroughChanges(chs.items, cur_off); const na = mapThroughChanges(chs.items, anc_off); p.setFileContent(f, new); const cc = modal.hxPos(f.content, nc); const ac = modal.hxPos(f.content, na); pane.cur_row = @intCast(cc.row); pane.cur_col = @intCast(cc.col); if (pane.vsel.active) { pane.vsel.row = @intCast(ac.row); pane.vsel.col = @intCast(ac.col); } pane.msel.active = false; pane.cur_off = 0; pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); } /// `Ctrl-a` / `Ctrl-x`: increment/decrement the SELECTION as a decimal /// integer (helix: the selected fragment itself, no number scan around /// the cursor); a fragment that isn't an integer is a no-op. The new /// number becomes the selection, cursor on its last char. fn normalAdjustNumber(p: *Pardes, pane: *Pane, delta: i64) void { if (pane.file == null) return; // shell output is immutable const f = &pane.file.?; const r = selRange(pane, f.content); const arena = p.scratch.allocator(); const frag = modal.rangeText(arena, f.content, r.a, r.b) catch return; const rep = (modal.hxIncrement(arena, frag, delta) catch null) orelse return; p.pushUndo(pane); pane.select = false; const new = modal.replaceRange(p.gpa, f.content, r.a, r.b, rep) catch return; p.setFileContent(f, new); const start = modal.lineStartOffset(f.content, r.a.row) + r.a.col; const cc = modal.hxPos(f.content, start + rep.len - 1); pane.vsel = .{ .active = rep.len > 1, .row = @intCast(r.a.row), .col = @intCast(r.a.col), .explicit = false }; pane.msel.active = false; pane.cur_row = @intCast(cc.row); pane.cur_col = @intCast(cc.col); pane.cur_off = 0; pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); } /// `]Space` / `[Space`: add blank lines below/above, the cursor staying /// on its text line (the selection rides along) fn addNewline(p: *Pardes, pane: *Pane, below: bool, cnt: usize) void { if (pane.file == null) return; // shell output is immutable const f = &pane.file.?; p.pushUndo(pane); const row: usize = @intCast(@max(0, pane.cur_row)); const arena = p.scratch.allocator(); const nl = arena.alloc(u8, cnt) catch return; @memset(nl, '\n'); const llen = modal.lineSlice(f.content, row).len; const new = modal.insertAt(p.gpa, f.content, .{ .row = row, .col = if (below) llen else 0 }, nl) catch return; p.setFileContent(f, new); if (!below) { pane.cur_row += @intCast(cnt); // the line moved down; stay on it if (pane.vsel.active and pane.vsel.row >= @as(i32, @intCast(row))) pane.vsel.row += @intCast(cnt); } else if (pane.vsel.active and pane.vsel.row > @as(i32, @intCast(row))) { pane.vsel.row += @intCast(cnt); } pane.cur_pinned = true; pane.ensureCursorVisible(); } /// pair mapping for m-mode: either bracket names the pair; anything else /// surrounds with itself (quotes) fn pairFor(ch: u21) ?struct { o: u8, c: u8 } { return switch (ch) { '(', ')' => .{ .o = '(', .c = ')' }, '[', ']' => .{ .o = '[', .c = ']' }, '{', '}' => .{ .o = '{', .c = '}' }, '<', '>' => .{ .o = '<', .c = '>' }, else => if (ch <= 0x7f) .{ .o = @intCast(ch), .c = @intCast(ch) } else null, }; } /// `mi` / `ma`: select inside/around a textobject (pure range /// math over the motion surface — works on terminals too). Word and /// paragraph objects are helix textobject.rs ports; pairs/quotes are the /// plain-text scans (quotes line-scoped — ponytail). fn textobjectSelect(p: *Pardes, pane: *Pane, pl: PaneLines, obj: u21, around: bool) void { const text = p.flatSurface(pane, pl) catch return; const range = paneRange(pane, text); switch (obj) { 'w', 'W' => { const r = modal.hxTextobjectWord(text, range, around, obj == 'W'); return setPaneRange(pane, pl, text, r, false); }, 'p' => { const r = modal.hxTextobjectParagraph(text, range, around, 1); return setPaneRange(pane, pl, text, r, false); }, else => {}, } const cur = modal.hxPos(text, modal.hxCursor(text, range)); const pair: ?modal.Range = switch (obj) { '\'', '"', '`' => modal.enclosingQuote(pl.lines, cur, @intCast(obj)), '(', ')' => modal.enclosingPair(pl.lines, cur, '(', ')'), '[', ']' => modal.enclosingPair(pl.lines, cur, '[', ']'), '{', '}' => modal.enclosingPair(pl.lines, cur, '{', '}'), '<', '>' => modal.enclosingPair(pl.lines, cur, '<', '>'), else => null, }; const pr = pair orelse return; var a = modal.hxOff(text, pr.a); var head = modal.nextGrapheme(text, modal.hxOff(text, pr.b)); if (!around) { // inside: shrink off the delimiters; an EMPTY pair collapses to // a 1-wide cursor on the closing char (helix) a = modal.nextGrapheme(text, a); head = modal.prevGrapheme(text, head); } setPaneRange(pane, pl, text, .{ .anchor = a, .head = head }, false); } /// `ms`: wrap the selection (or the cursor char) in a pair; the wrap /// including the pair becomes the selection fn surroundAdd(p: *Pardes, pane: *Pane, ch: u21) void { if (pane.file == null) return; // shell output is immutable const pr = pairFor(ch) orelse return; pane.select = false; const f = &pane.file.?; const r = selRange(pane, f.content); p.pushUndo(pane); var new = modal.insertAt(p.gpa, f.content, .{ .row = r.b.row, .col = r.b.col + 1 }, &[1]u8{pr.c}) catch return; p.setFileContent(f, new); new = modal.insertAt(p.gpa, f.content, .{ .row = r.a.row, .col = r.a.col }, &[1]u8{pr.o}) catch return; p.setFileContent(f, new); pane.msel.active = false; pane.vsel = .{ .active = true, .row = @intCast(r.a.row), .col = @intCast(r.a.col), .explicit = false }; pane.cur_row = @intCast(r.b.row); pane.cur_col = @intCast(r.b.col + 1 + @as(usize, if (r.a.row == r.b.row) 1 else 0)); pane.cur_off = 0; pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); } /// `md`: delete the enclosing pair's chars; the cursor maps through fn surroundDelete(p: *Pardes, pane: *Pane, pl: PaneLines, ch: u21) void { if (pane.file == null) return; // shell output is immutable const pr = pairFor(ch) orelse return; const f = &pane.file.?; const text = p.flatSurface(pane, pl) catch return; const cur = toModalCursor(pane, pl); const r = (if (pr.o == pr.c) modal.enclosingQuote(pl.lines, cur, pr.o) else modal.enclosingPair(pl.lines, cur, pr.o, pr.c)) orelse return; p.pushUndo(pane); const a_off = modal.hxOff(text, r.a); const b_off = modal.hxOff(text, r.b); const cur_off = modal.hxOff(text, cur); // the close first, so the open's position stays valid var new = modal.deleteChar(p.gpa, f.content, r.b) catch return; p.setFileContent(f, new); new = modal.deleteChar(p.gpa, f.content, r.a) catch return; p.setFileContent(f, new); var nc = cur_off; if (nc > b_off) nc -= 1; if (nc > a_off) nc -= 1; const cc = modal.hxPos(f.content, nc); pane.cur_row = @intCast(cc.row); pane.cur_col = @intCast(cc.col); pane.vsel.active = false; pane.msel.active = false; pane.cur_off = 0; pane.cur_pinned = true; pane.ensureCursorVisible(); } /// `mr`: swap the enclosing pair's chars for 's fn surroundReplace(p: *Pardes, pane: *Pane, pl: PaneLines, from: u21, to: u21) void { if (pane.file == null) return; // shell output is immutable const fp = pairFor(from) orelse return; const tp = pairFor(to) orelse return; const f = &pane.file.?; const cur = toModalCursor(pane, pl); const r = (if (fp.o == fp.c) modal.enclosingQuote(pl.lines, cur, fp.o) else modal.enclosingPair(pl.lines, cur, fp.o, fp.c)) orelse return; p.pushUndo(pane); const out = p.gpa.dupe(u8, f.content) catch return; out[modal.lineStartOffset(out, r.a.row) + r.a.col] = tp.o; out[modal.lineStartOffset(out, r.b.row) + r.b.col] = tp.c; p.setFileContent(f, out); } // ---- 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].content, f.content)) return; const snap: FileSnap = .{ .content = p.gpa.dupe(u8, f.content) catch return, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .vsel = pane.vsel, }; f.undo.append(p.gpa, snap) catch { p.gpa.free(snap.content); return; }; if (f.undo.items.len > UNDO_MAX) p.gpa.free(f.undo.orderedRemove(0).content); for (f.redo.items) |s| p.gpa.free(s.content); 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: FileSnap = .{ .content = p.gpa.dupe(u8, f.content) catch return, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .vsel = pane.vsel, }; f.redo.append(p.gpa, cur) catch { p.gpa.free(cur.content); return; }; const prev = f.undo.pop() orelse return; p.setFileContent(f, prev.content); restoreFileSnap(pane, f, prev); 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: FileSnap = .{ .content = p.gpa.dupe(u8, f.content) catch return, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .vsel = pane.vsel, }; f.undo.append(p.gpa, cur) catch { p.gpa.free(cur.content); return; }; const next = f.redo.pop() orelse return; p.setFileContent(f, next.content); restoreFileSnap(pane, f, next); 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); } /// undo/redo restores the selection recorded with the snapshot (helix /// keeps selections in its history transactions) fn restoreFileSnap(pane: *Pane, f: *File, snap: FileSnap) void { const n = modal.lineCount(f.content); const row: usize = @min(@as(usize, @intCast(@max(0, snap.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, snap.cur_col))), llen)); pane.vsel = snap.vsel; pane.msel.active = false; pane.cur_off = 0; pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); } 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); }, .wheel_left, .wheel_right => { if (m.kind != .press) return; const id = hovered orelse return; const pane = p.panes[id].?; // ponytail: no right clamp — overscroll shows blank and the // next cursor move or left wheel pulls it back if (pane.file != null) pane.hscroll = @max(0, pane.hscroll + @as(i32, if (m.button == .wheel_right) 4 else -4)); }, .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; } // acme 3-1: a left tap during a right (look) drag cancels // the look; the rest of the gesture is inert if (p.drag == .select and p.drag.select.button == .right) { if (p.panes[p.drag.select.id]) |t| t.sel[@intFromEnum(Mouse.Button.right)].state = .none; p.drag = .none; 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 => { // acme 1-2 / 1-3: middle (Cut) or right (Paste) tapped // while the left button holds a selection drag if (p.drag == .select and p.drag.select.button == .left) { p.chordCutPaste(m.button == .middle); return; } // acme 2-3 / 3-2: tapping the OTHER of middle/right during // an execute or look drag cancels it (textselect23's mask) if (p.drag == .select) { if (p.panes[p.drag.select.id]) |t| t.sel[@intFromEnum(p.drag.select.button)].state = .none; p.drag = .none; return; } if (mrow < TOPBAR_H) { // global tagbar: MIDDLE runs the command under the click // (a stray left click must never Kill) if (m.button == .middle) { var tb_buf: [1200]u8 = undefined; const word = wordAtCol(p.topbar(&tb_buf), 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; if (s.chorded) { // a cut/paste chord consumed this drag; the release is inert pane.sel[@intFromEnum(s.button)].state = .none; 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; files also enter normal // mode, terminals keep the mode they had (tty keeps the // mouse usable, insert keeps typing where you clicked). 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)) + pane.hscroll 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; if (!pane.isTerminal()) 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 => {}, } } /// acme 1-2 (Cut) / 1-3 (Paste): middle or right tapped while the left /// button holds a selection drag. The first tap converts the mouse /// selection into the modal one (vsel anchored at the drag start, cursor /// at its end; a plain click is a bare cursor). Cut yanks + deletes the /// selection; Paste replaces it with the yank register, or splices the /// register in literally at a bare cursor — so 1-2 then 1-3 in one hold /// nets out to Snarf (copy: the text returns, the register keeps it). fn chordCutPaste(p: *Pardes, cut: bool) void { const s = &p.drag.select; const pane = p.panes[s.id] orelse return; if (!s.chorded) { s.chorded = true; const sl = pane.sel[0]; pane.sel[0].state = .none; if (pane.mode == .tty) return; // the pty owns the cursor there const pfx: i32 = if (pane.file != null) PREFIX_W else 0; const hs: i32 = if (pane.file != null) pane.hscroll else 0; const row0 = @max(0, sl.r0 - @as(i32, BOX_H)) + pane.scroll(); const row1 = @max(0, sl.r1 - @as(i32, BOX_H)) + pane.scroll(); const col0 = @max(0, sl.c0 - pfx) + hs; const col1 = @max(0, sl.c1 - pfx) + hs; pane.cur_row = row1; pane.cur_col = col1; pane.cur_off = 0; pane.cur_pinned = true; pane.msel.active = false; pane.pending = 0; if (!pane.isTerminal()) pane.mode = .normal; pane.vsel = .{ .active = row0 != row1 or col0 != col1, .row = row0, .col = col0, .explicit = false }; } if (pane.mode == .tty) return; if (cut) { if (pane.vsel.active) p.normalDelete(pane, true); } else if (pane.vsel.active) { p.normalReplaceYank(pane); } else if (pane.file) |*f| { // bare cursor: splice the register in literally (no linewise // magic) so a paste right after a cut restores the text exactly const y = p.yank orelse return; if (y.len == 0) return; const at = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }; p.pushUndo(pane); const new = modal.insertAt(p.gpa, f.content, at, y) catch return; p.setFileContent(f, new); pane.vsel = .{ .active = y.len > 1, .row = @intCast(at.row), .col = @intCast(at.col), .explicit = false }; const end = modal.advanceBy(at, y); if (end.col > 0) { pane.cur_row = @intCast(end.row); pane.cur_col = @intCast(end.col - 1); } else { // the register ended in '\n': the cursor lands ON that newline pane.cur_row = @intCast(end.row -| 1); pane.cur_col = @intCast(modal.lineSlice(f.content, end.row -| 1).len); } pane.cur_off = 0; pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); } else p.normalPaste(pane, true); // terminal: splice run text at the cursor } /// 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", Key.delete => "\x1b[3~", 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; } /// Snap every pane in column `c` to its on-screen row count so later /// weight edits move ONLY the panes they name: computeGeom rounds /// round(avail*w/vsum) per pane, and with fractional weights a split or /// absorb elsewhere in the column can jiggle a bystander by a row. fn snapColWeights(p: *Pardes, c: usize) void { for (0..p.col_n[c]) |k| { const pid = p.col_terms[c][k]; if (p.panes[pid]) |pp| pp.vweight = @floatFromInt(@max(1, p.rects[pid].h)); } } /// Hand a dying pane's rows to ONE sibling (the pane above, or below for /// the topmost) so the rest of the column keeps its sizes bit-identical — /// the deletion mirror of splitBelow. Call while `id` is still in the /// layout, with rects current. fn absorbVWeight(p: *Pardes, id: usize) void { const f = p.layoutFindTerm(id) orelse return; if (p.col_n[f.col] <= 1) return; p.snapColWeights(f.col); 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 += @as(f32, @floatFromInt(@max(1, p.rects[id].h))); } /// 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 and the other /// panes keep their sizes bit-identical (weights snap to row counts). 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; // cap keep so a content-full source still leaves the new pane a tag + // a few body rows (an Alt-n from a full shell was born 0 rows tall) const keep = std.math.clamp(cur, 1, @max(1, body -| (BOX_H + 3))); if (p.layoutFindTerm(src_id)) |f| for (0..p.col_n[f.col]) |k| { const pid = p.col_terms[f.col][k]; if (p.panes[pid]) |pp| if (pp != nw) { pp.vweight = @floatFromInt(@max(1, p.rects[pid].h)); }; }; src.vweight = @floatFromInt(BOX_H + keep); nw.vweight = @floatFromInt(@max(1, src_h -| (BOX_H + keep))); } /// 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.computeGeom(); // a just-stacked doc has no rect yet; absorb snaps to rows 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; if (p.col_n[rc] > 0) p.splitBelow(p.col_terms[rc][p.col_n[rc] - 1], nt); 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; } /// TEST-ONLY, called by test/hxdiff.zig (the helix differential harness): /// swap the tty_only boot pane for a file pane holding `content` verbatim /// — openFileView minus the disk read (cases carry their buffer inline). /// Unreachable from any shell; keep it dumb. pub fn hxOpenFileContent(p: *Pardes, content: []const u8) !*Pane { const copy = try p.gpa.dupe(u8, content); errdefer p.gpa.free(copy); const path = try p.gpa.dupe(u8, "/hxcase.txt"); errdefer p.gpa.free(path); p.deinitPane(p.panes[0].?); p.panes[0] = null; const pane = try p.newDocPane(0); pane.file = .{ .path = path, .content = copy }; pane.cur_pinned = true; p.active = 0; p.sync(); 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), halving ONLY the old top pane's slot so /// the rest of the column keeps its sizes; then evict a leftover pristine /// shell fn stackDocLeft(p: *Pardes, free: usize, nt: *Pane) void { const lc = 0; if (p.col_n[lc] > 0) if (p.panes[p.col_terms[lc][0]]) |top| { p.snapColWeights(lc); const h = p.rects[p.col_terms[lc][0]].h; nt.vweight = @floatFromInt(@max(1, h / 2)); top.vweight = @floatFromInt(@max(1, h -| h / 2)); }; 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| if (u.len <= 256) 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) -| tt.rows / 2; // center, clamp at top if (next != ff.scroll) { ff.scroll = next; ff.syntax_dirty = true; } // land the modal cursor on the target line (and keep // ensureCursorVisible agreeing with the recenter — // a stale cursor would yank the view right back) tt.cur_row = @intCast(target.line - 1); tt.cur_col = 0; tt.cur_pinned = true; } return; }; } const free = p.freeSlot() orelse return; const nt = p.openFileView(free, target.path, target.line) catch return; p.placeDoc(id, free, nt); // center the target line: the pane's real body height only // exists after placement, so lay out now and pull the // scroll up by half a body (line 0 opens stay at the top) p.computeGeom(); nt.file.?.scroll -|= @max(1, p.rects[free].h -| BOX_H) / 2; }, .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 {}, .restore => { var it = std.mem.tokenizeAny(u8, std.mem.trim(u8, txt, " \t\r\n"), " \t"); _ = it.next(); // the word "Restore" const path = it.next() orelse (p.last_dump orelse return); if (path.len > p.restore_buf.len) return; @memcpy(p.restore_buf[0..path.len], path); p.restore_req = p.restore_buf[0..path.len]; }, .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, "TTY ")) 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 { const tz = tracy.zone(@src(), "refreshDirtyHighlights"); defer tz.end(); 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 } }); var tb_buf: [1200]u8 = undefined; _ = s.print(0, 0, s.cols, p.topbar(&tb_buf), .{ .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 { const tz = tracy.zone(@src(), "renderPane"); defer tz.end(); 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, .bar = true }; // tags are always insert } // 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 tz_body = tracy.zone(@src(), "bodyText"); const body = try p.bodyText(arena, pane); tz_body.end(); 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. // The cursor row's number takes the tag style (same row math as the // cursor pass below) so the eye finds the current line. if (pane.file != null) { const goff = pane.scroll(); const gcur = pane.vt.screens.active.cursor; const gcrow = if (pane.cur_pinned) pane.cur_row else @as(i32, @intCast(gcur.y)) + goff; const cur_vr: i32 = if (active and !pane.tag_edit) gcrow - goff else -1; var vr: u16 = 0; while (vr < body_h) : (vr += 1) { const on_cursor = @as(i32, vr) == cur_vr; 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 if (on_cursor) { cell.style.fg = .{ .rgb = th.tag_fg }; cell.style.bg = .{ .rgb = th.tag_bg }; } else 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) { const tz_recolor = tracy.zone(@src(), "synRecolor"); defer tz_recolor.end(); var flines = std.mem.splitScalar(u8, f.content, '\n'); var skip: usize = 0; while (skip < f.scroll) : (skip += 1) { if (flines.next() == null) break; } const hs: usize = @intCast(@max(0, pane.hscroll)); 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 (hs + c < line.len and PREFIX_W + c < tw) : (c += 1) { const idx = base + hs + 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; const vhs: i32 = if (pane.file != null) pane.hscroll 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 - vhs + vpfx else vpfx; const cend: i32 = if (ar == bnd.hi_row) bnd.hi_col - vhs + 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) - pane.hscroll 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)), .bar = pane.mode == .insert }; } 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, .bar = pane.mode == .insert }; } } // 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)); const row_start = out.items.len; 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, ""); // horizontal scroll: cut hscroll columns after the gutter, // from the fully composed row (edit splices shift with it) if (pane.hscroll > 0) { const cut_at = row_start + PREFIX_W; if (out.items.len > cut_at) { const cut = @min(@as(usize, @intCast(pane.hscroll)), out.items.len - cut_at); out.replaceRangeAssumeCapacity(cut_at, cut, &.{}); } } } 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 .{}; }