//! 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; /// which key runs which acme chord — everywhere it exists: normal mode, the /// tag, and the `:` command line. false (the default) is the original binding, /// Enter looks / Tab executes (so a command runs with `:w`); true swaps /// them for the vim reading. pub const swap_enter_tab = false; pub const topbar_str = "Kill Newcol Tutor Debug 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 it through the ONE // dispatcher (runBuiltin, reached from actOnSelection), no matter where the // name appears. The enum FIELD NAME is the user-visible word — the one in the // topbar, the one sitting in a tag, the one Help prints, the one you execute // — so `std.meta.stringToEnum` IS the lookup and there is no name table to // keep in sync. Everything else about a builtin (its leader key path, its // Help line) is folded out of this enum at comptime, below. A name that is // not a legal identifier would be spelled `@"..."`; zig fmt strips the // quotes from the ones that are. const Builtin = enum { Kill, Newcol, Tutor, Debug, Colors, NextColor, Crt, // the image pane's three renderer toggles. They used to be words the // image tag printed and actOnSelection matched by hand; as builtins they // are executable anywhere, pressable under SPC and listed by `SPC ?`, // which is the whole reason the tag no longer carries them. Petscii picks // glyph art over the host's pixels, Palette swaps the C64 palette for the // terminal's own 16, Ascii adds the printable ASCII bitmaps to the // matcher's glyph set. Petscii, Palette, Ascii, Dump, Restore, Save, Find, Del, Delcol, Help, // the window group. The DESTINATION is the name — a word, the way a tag // holds Del or Save — because these names live in the same vocabulary as // everything else here: `Wh` would be a leader key path leaking into the // text you can middle-click. Plain English words are safe for exactly // these five: focus is the cheapest thing to change by accident (nothing // is edited, closed or written) and the way back is the opposite word. Left, Down, Up, Right, Toggleterm, }; /// SPC leader: ONE key path per builtin, the whole remapping surface. An /// EnumArray, so a new enum field is a compile error until it has a path. /// Groups are just shared first letters (f files, h docs, c columns, t /// toggles, s session); `?` is Help's path and is honored at ANY depth — it /// lists what the prefix typed so far can still reach. const leader_path = std.EnumArray(Builtin, []const u8).init(.{ .Help = "?", .Del = "d", .Kill = "k", // the `f` file group (spacemacs): Save left vim's `w` to join Find here, // which frees `w` for the window group (SPC w h/j/k/l) to move into. .Save = "fs", .Find = "ff", .Tutor = "ht", .Newcol = "cn", .Delcol = "cd", .Debug = "td", .Colors = "tc", .NextColor = "tn", .Crt = "tr", // the image toggles join the same `t` group; Palette takes `l` because // `p` is Petscii's and `c` is Colors'. .Petscii = "tp", .Palette = "tl", .Ascii = "ta", .Dump = "sd", .Restore = "sr", // the `w` window group `Save` vacated: the four directional focus moves // the Ctrl-w prefix does, spelled h/j/k/l because focus IS a motion, plus // `t` for the file<->terminal hop. .Left = "wh", .Down = "wj", .Up = "wk", .Right = "wl", .Toggleterm = "wt", }); /// The leader tree, flattened: one row per builtin SORTED BY PATH, so every /// prefix's subtree is a contiguous run — which is all the two runtime /// readers need. The matcher asks "exact hit? still a prefix of something?" /// and Help filters the same rows by the same prefix. A node-and-pointer trie /// for thirteen two-character paths would be ceremony. `line` is the Help /// rendering, padded here so Help is a filter over an array of lines. const Leader = struct { path: []const u8, cmd: Builtin, line: []const u8 }; const leader_rows: [@typeInfo(Builtin).@"enum".fields.len]Leader = blk: { var rows: [@typeInfo(Builtin).@"enum".fields.len]Leader = undefined; // the widest spaced-out path ("h t" = 3), so the names line up var keyw: usize = 0; for (std.enums.values(Builtin)) |b| keyw = @max(keyw, 2 * leader_path.get(b).len - 1); for (std.enums.values(Builtin), 0..) |b, i| { const path = leader_path.get(b); var keys: []const u8 = ""; for (path, 0..) |c, k| { if (k > 0) keys = keys ++ " "; keys = keys ++ &[_]u8{c}; } rows[i] = .{ .path = path, .cmd = b, .line = "SPC " ++ keys ++ (" " ** (keyw - keys.len)) ++ " " ++ @tagName(b), }; } // insertion sort by path: a group sorts right before what extends it for (1..rows.len) |i| { var j = i; while (j > 0 and std.mem.lessThan(u8, rows[j].path, rows[j - 1].path)) : (j -= 1) { const t = rows[j]; rows[j] = rows[j - 1]; rows[j - 1] = t; } } break :blk rows; }; // The topbar is a HAND-PICKED subset in a fixed order, not a derivation: row 0 // is where topbar clicks land, so its exact bytes are load-bearing (every // snapshot golden records the column each word starts at). Comptime-checked // against the enum so a rename cannot silently rot it. // Colors and Crt are NOT here: both are set-once display switches you flip and // forget, and a bar you read every frame should not spend width on them now // that `SPC t c` / `SPC t r` press them. NextColor stays — it is the one you // cycle repeatedly, so a click beats a three-key path. comptime { @setEvalBranchQuota(8000); // the tokenizer walks the string one branch per byte, stringToEnum builds a map per name var it = std.mem.tokenizeScalar(u8, topbar_str, ' '); while (it.next()) |w| { if (std.meta.stringToEnum(Builtin, w) == null) @compileError("topbar word is not a builtin: " ++ w); } } /// 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 copy the edit buffer; 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, /// Only consulted for keys whose codepoint does NOT already carry the /// shift (Escape and friends) — `A` is `A`, not shift-`a`, so `is()` /// ignores this field on purpose. shift: 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, 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 }; /// A terminal's edit buffer: real gpa-owned text standing in for the `rows` /// shell rows starting at ABSOLUTE body row `row`. It is edited by exactly /// the machinery File.content is — that is the whole point, a terminal in /// insert mode has to behave like a text file — and it rides the scrollback /// the way the char-mode insertion runs it replaced did. The vt grid itself /// is never written, so output keeps streaming underneath. /// /// Seeded from the rows it stands in for the first time you type, and grown /// whenever an edit reaches past either end (absorbing the shell rows it /// swallows). When it ends up holding MORE lines than the rows it covers — /// which is what pressing Enter does — the surface below simply slides down /// by the difference: see Pane.surfRow. pub const Ovl = struct { row: i32 = 0, rows: i32 = 1, text: []u8 = &.{}, }; /// 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 buffer (whole-state, dumb on /// purpose). `ovl` is null for a pane nothing has been typed into yet, and /// owns its text exactly like FileSnap does. pub const EditSnap = struct { ovl: ?Ovl, cur_row: i32, cur_col: i32, vsel: CharSel, }; /// 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, /// an OUTPUT buffer (acme's +Errors): a file pane with no file behind it, /// where the core shows text it produced itself (search results). The ONLY /// difference is that it cannot be saved — every mode, motion, chord and /// look works because it IS a file pane. `path` still names it (and gives /// its directory) so looks inside it resolve like anywhere else. output: bool = false, 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, /// `/` search and the Find builtin: the pattern is typed into the tag tail /// (no popup); search_at is the tail length before the marker was appended /// (submit/cancel restore it, and the marker itself says WHICH search this /// is). Enter fills the output buffer in pane search_pane with one result /// per row; n/N walk it, search_row being the row they last landed on /// (null = fresh results, nothing stepped to yet). search_edit: bool = false, search_at: u16 = 0, search_pane: ?usize = null, search_row: ?usize = null, /// 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, at ABSOLUTE body rows of the pane's SURFACE (file lines, /// or the terminal's shell rows with its edit buffer standing in). Tracks /// the shell cursor until pinned by a click or a key. 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, sel: [3]Sel = @splat(.{}), /// terminals only: the typed-text buffer standing in for shell rows ovl: ?Ovl = null, /// 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; } /// Surface row of shell row `g`. The edit buffer's lines stand in for the /// `rows` shell rows it covers, so everything below it slides by the /// difference — the identity on files and on terminals nobody has typed /// a newline into, which is why the rest of the row math can stay naive. fn surfRow(pane: *const Pane, g: i32) i32 { const o = pane.ovl orelse return g; if (g <= o.row) return g; const lines: i32 = @intCast(modal.lineCount(o.text)); if (g >= o.row + o.rows) return g + lines - o.rows; return @min(g, o.row + lines - 1); // inside the buffer: its own rows } /// the inverse; every surface row inside the edit buffer maps to its anchor fn gridRow(pane: *const Pane, s: i32) i32 { const o = pane.ovl orelse return s; if (s <= o.row) return s; const lines: i32 = @intCast(modal.lineCount(o.text)); if (s < o.row + lines) return o.row; return s - lines + o.rows; } /// current scroll offset: file top line, or the scrollback offset fn scroll(pane: *Pane) i32 { if (pane.file) |f| return @intCast(f.scroll); return pane.surfRow(@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 { // the vt scrolls in SHELL rows; convert through the edit buffer const off: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); pane.vt.screens.active.scroll(.{ .delta_row = pane.gridRow(pane.surfRow(off) + delta) - off }); } } 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; } fn pinCursor(pane: *Pane) void { if (pane.cur_pinned) return; if (pane.file != null) { pane.cur_row = pane.scroll(); pane.cur_col = 0; } else { const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); pane.cur_row = pane.surfRow(@as(i32, @intCast(pane.vt.screens.active.cursor.y)) + goff); pane.cur_col = @intCast(pane.vt.screens.active.cursor.x); } pane.cur_pinned = true; } }; 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, /// argv FILE (resolved absolute): boot with it focused as a doc pane in /// the left column, a terminal on the launch directory in the right file: ?[]const u8 = null, file_line: usize = 0, 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, /// keyboard focus history, least-recent first, `active` last: rebuilt each /// sync (dead slots pruned, ids are reused). A closing pane hands focus /// back to the last live one instead of whatever slot comes first. focus_hist: [MAX_PANES]usize = undefined, nfocus: usize = 0, theme_idx: usize = 0, colors_on: bool = true, show_debug: bool = false, /// the Crt builtin's CRT post-process, rendered by the gui shell only /// (the tty and web shells never read it) crt_on: 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, /// SPC leader in flight, holding the key path typed so far (empty = just /// SPC). Global like ctrl_w_pending — there is one leader and it acts on /// the active pane, whose tag shows the pending path while it waits. leader_on: bool = false, leader_keys: [4]u8 = undefined, leader_n: u8 = 0, 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, /// 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.file) |path| { // FILE argv boot: the doc focused in the left column, a terminal // on the launch directory in the right (files left, shells right) _ = if (look.isImagePath(path)) try p.openImageView(0, path, !p.kitty_ok) else try p.openFileView(0, path, opts.file_line); const sh = try p.newPane(1); sh.greet = true; p.ncol = 2; p.col_n[0] = 1; p.col_terms[0][0] = 0; p.col_n[1] = 1; p.col_terms[1][0] = 1; } else 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); } if (pane.ovl) |o| p.gpa.free(o.text); pane.tag_tail.deinit(p.gpa); for (pane.ed_undo.items) |sn| if (sn.ovl) |o| p.gpa.free(o.text); pane.ed_undo.deinit(p.gpa); for (pane.ed_redo.items) |sn| if (sn.ovl) |o| p.gpa.free(o.text); 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| { // a shell-level paste (bracketed/SDL): load the register, paste p.setYank(bytes); if (p.panes[p.active]) |pane| p.normalPaste(pane, 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 (an image has only its path — the /// renderer toggles it used to spell out are builtins now, under SPC t) fn tagPrefix(p: *Pardes, pane: *Pane) ![]u8 { const arena = p.scratch.allocator(); if (pane.image) |iv| return std.fmt.allocPrint(arena, "img {s}", .{iv.path}); 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 /// (an output buffer has no file to Save, so it gets the plain tail) fn curTail(pane: *Pane) []const u8 { if (pane.tag_init) return pane.tag_tail.items; if (pane.file) |f| if (!f.output) return file_pane_tail; return 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 { const pfx = p.tagPrefix(pane) catch return; if (!pane.tag_init) { pane.tag_tail.appendSlice(p.gpa, curTail(pane)) 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 search 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; } // the acme chords on 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; const btn: Mouse.Button = if (is(key, Key.enter) == swap_enter_tab) .middle else .right; p.actOnSelection(btn, 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 => {}, } } /// write a helix range back onto the tag cursor + selection: the tail's /// one-line mirror of setPaneRange. fn setTagRange(pane: *Pane, r: modal.HxRange) void { const text = pane.tag_tail.items; const lo = @min(r.anchor, r.head); const hi = @max(r.anchor, r.head); pane.tag_col = @intCast(modal.hxCursor(text, r)); pane.tag_sel = hi > lo + 1; // a 1-wide range IS the block cursor if (pane.tag_sel) pane.tag_anchor = @intCast(if (r.head > r.anchor) lo else hi - 1); } /// normal mode ON the tag — where `:` lands. The body's own helix motions /// with the tail as a one-line document, plus insert entry and the acme /// chords; editing keys stay in insert (`i` then type, like the mouse path). fn tagNormalKey(p: *Pardes, pane: *Pane, key: Key) void { const text = pane.tag_tail.items; const cur: usize = @min(@as(usize, pane.tag_col), text.len); // Esc abandons the command line: back to the body, tail kept as text if (is(key, Key.escape)) return exitTagEdit(pane); // the chord: run the selection (or the word under the cursor) and drop // back into the body — the whole point of `:`. Same keys as everywhere // else (swap_enter_tab), so the command line is `:w` by default. // Nothing under the cursor means nothing ran: the tag keeps focus. if (is(key, Key.enter) or is(key, Key.tab)) { const btn: Mouse.Button = if (is(key, Key.enter) == swap_enter_tab) .middle else .right; const txt = p.tagChordText(pane) orelse return; const id = p.active; exitTagEdit(pane); // leave the tag FIRST: Del frees the pane p.actOnSelection(btn, id, txt, null); return; } // insert entry (one line, so I/A are the tail's ends) if (is(key, 'i') or is(key, 'a') or is(key, 'I') or is(key, 'A')) { pane.tag_col = @intCast(switch (key.cp) { 'a' => modal.nextGrapheme(text, cur), 'I' => 0, 'A' => text.len, else => cur, }); pane.tag_sel = false; pane.mode = .insert; return; } const target: ?usize = if (is(key, 'h') or is(key, Key.left)) modal.prevGrapheme(text, cur) else if (is(key, 'l') or is(key, Key.right)) modal.nextGrapheme(text, cur) else if (is(key, '0') or is(key, Key.home)) 0 else if (is(key, '$') or is(key, Key.end)) modal.prevGrapheme(text, text.len) else if (is(key, '^')) modal.firstNonWs(text) else null; if (target) |t| return setTagRange(pane, .{ .anchor = t, .head = t }); // word motions select their traversed span, exactly like the body's const wt: ?modal.WordTarget = if (is(key, 'w')) .next_word_start else if (is(key, 'b')) .prev_word_start else if (is(key, 'e')) .next_word_end else if (is(key, 'W')) .next_long_word_start else if (is(key, 'B')) .prev_long_word_start else if (is(key, 'E')) .next_long_word_end else null; if (wt) |t| { const range: modal.HxRange = .{ .anchor = cur, .head = modal.nextGrapheme(text, cur) }; return setTagRange(pane, modal.hxWordMove(text, range, 1, t)); } } /// 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 text and shell 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; return p.yankRows(pane, @min(pane.msel.r0, pane.msel.r1), @max(pane.msel.r0, pane.msel.r1)); } fn handleKey(p: *Pardes, key: Key) void { const pane = p.panes[p.active] orelse return; // a SPC leader in flight swallows the next key, whatever it is — // before Ctrl-w, so a modified key abandons the sequence instead of // arming a second prefix on top of it if (p.leader_on) return p.leaderKey(key); // Ctrl-w prefix: helix-style directional pane focus (h/j/k/l), running // the SAME builtins `SPC w h/j/k/l` runs. It stays despite the leader // covering it because it reaches one place the leader cannot: a pane // in raw tty mode never sees SPC (the shell owns every printable key), // so this is the only keyboard way out of one. if (p.ctrl_w_pending) { p.ctrl_w_pending = false; if (is(key, 'h') or is(key, Key.left)) p.runBuiltin(.Left, p.active, "") else if (is(key, 'l') or is(key, Key.right)) p.runBuiltin(.Right, p.active, "") else if (is(key, 'k') or is(key, Key.up)) p.runBuiltin(.Up, p.active, "") else if (is(key, 'j') or is(key, Key.down)) p.runBuiltin(.Down, p.active, ""); 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 src = p.splitParent(p.active); const f = p.layoutFindTerm(src).?; p.layoutInsert(f.col, f.idx + 1, free); p.splitBelow(src, 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, or Shift-Esc, toggles raw tty mode in and // out (terminals only); tty is deliberately off the normal editing // path. Shift-Esc needs a host that reports modifiers on Escape (the // kitty keyboard protocol); where it doesn't it arrives as a plain // Escape and still means what Escape always means. const tty_toggle = (key.ctrl and key.cp == p.opts.tty_toggle) or (key.shift and !key.ctrl and !key.alt and key.cp == Key.escape); if (pane.isTerminal() and tty_toggle) { if (pane.mode == .tty) { pane.mode = .normal; pane.pending = 0; } else p.enterTty(p.active); return; } // a search input in flight (`/` or Find): Enter searches, 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(p.active); pane.tag_tail.shrinkRetainingCapacity(@min(pane.search_at, pane.tag_tail.items.len)); exitTagEdit(pane); pane.mode = .normal; pane.pending = 0; return; } // tag editing intercepts every other key: a modal one-line editor over // the tail, sharing the pane's mode. Above the body chords — a focused // tag owns Enter/Tab too (that IS the `:` command line). if (pane.tag_edit) { if (pane.mode == .normal) p.tagNormalKey(pane, key) else p.tagInsertKey(pane, key); return; } // normal-mode Enter/Tab: the acme chords — look at / execute (which // key is which is swap_enter_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. if (pane.mode == .normal and (is(key, Key.enter) or is(key, Key.tab))) { const btn: Mouse.Button = if (is(key, Key.enter) == swap_enter_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; } // normal-mode `p`/`P` paste the core's yank register directly (helix: // the default register, NOT the system clipboard — most terminals // refuse the OSC 52 read, so a round trip would never come back). // 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.normalPaste(pane, is(key, 'P')); 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; 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), } } /// A key after SPC: walk the leader tree (leader_rows, the comptime table). /// `?` at any depth opens Help scoped to the path typed so far; an exact /// path runs its builtin with no arguments; a key that only extends a /// group keeps waiting. ANYTHING else abandons the sequence — a typo must /// not leave the next keystroke armed at a builtin that closes panes, and /// the indicator vanishing is the receipt (vim and helix drop unmapped /// leader keys the same way). Esc lands here as one of those. fn leaderKey(p: *Pardes, key: Key) void { p.leader_on = false; // only "still a prefix" below re-arms it if (key.ctrl or key.alt or key.cp < 0x20 or key.cp > 0x7e) return; const c: u8 = @intCast(key.cp); if (c == '?') return p.openHelp(p.active, p.leader_keys[0..p.leader_n]); if (p.leader_n >= p.leader_keys.len) return; p.leader_keys[p.leader_n] = c; p.leader_n += 1; const pfx = p.leader_keys[0..p.leader_n]; for (leader_rows) |row| { if (std.mem.eql(u8, row.path, pfx)) return p.runBuiltin(row.cmd, p.active, ""); } for (leader_rows) |row| { if (std.mem.startsWith(u8, row.path, pfx)) { p.leader_on = true; return; } } } /// 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()) { // the pin is a GRID position: convert the modal cursor's surface // row back through the edit buffer const goff: i32 = @intCast(screen.pages.scrollbar().offset); const vp_row: i32 = pane.gridRow(pane.cur_row) - goff; 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` of `from` (overlap-aware /// nearest edge). `from` is the pane the builtin ran on, which is the /// active one for a key but the CLICKED one for a name executed in a tag. fn focusDir(p: *Pardes, from: usize, dir: enum { left, right, up, down }) void { const a = p.rects[from]; var best: ?usize = null; var best_d: i32 = 0; for (p.panes, 0..) |slot, i| { if (slot == null or i == from) 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; // a count typed before the hop was meant for the pane you left p.panes[b].?.pending = 0; } } // ---- 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] }; /// A terminal's shell rows as the surface sees them: the WHOLE /// history+active grid, prompt rows blanked (OSC 133), absolute grid rows /// from 0. The raw material the motion surface is composed from — the /// edit buffer is NOT applied here, so it is also what seeding the buffer /// reads. Scratch-arena backed. /// ghostty's dump trims the grid's trailing blank rows; ONE of them is /// kept back, the row the cursor sits on below the last line of output. /// That row is a file's final newline: without it the surface would have /// one line fewer than the same text in a document, and every motion and /// linewise edit at the bottom would diverge. /// ponytail: O(scrollback) dump+scan per keystroke — window it around the /// viewport if a multi-MB scrollback ever makes navigation lag. fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 { const arena = p.scratch.allocator(); const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} }); var pit = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); var ls: std.ArrayList([]const u8) = .empty; var it = std.mem.splitAny(u8, full, "\n"); while (it.next()) |raw| { const is_prompt = if (pit.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false; try ls.append(arena, if (is_prompt) "" else raw); } try ls.append(arena, ""); return ls.items; } /// The lines the cursor moves over, absolute rows. File: all content lines. /// Terminal: the whole history+active grid with the edit buffer's lines /// standing in for the rows it covers, so motions ride the scrollback and /// the typed text alike. 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 }; } const rows = try p.shellRows(pane); // the buffer may sit below the dump's last row (the grid's trailing // blanks are trimmed off), so run the loop to whichever ends later var last = rows.len; if (pane.ovl) |o| last = @max(last, @as(usize, @intCast(@max(0, o.row + o.rows)))); var ls: std.ArrayList([]const u8) = .empty; var g: usize = 0; while (g < last) : (g += 1) { if (pane.ovl) |o| if (o.row >= 0 and g == @as(usize, @intCast(o.row))) { var bit = std.mem.splitScalar(u8, o.text, '\n'); while (bit.next()) |ln| try ls.append(arena, ln); g += @intCast(o.rows - 1); continue; }; try ls.append(arena, if (g < rows.len) rows[g] else ""); } 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_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`, whose first /// line is absolute row `row0` (0 for the motion surface and for file /// content; a terminal's edit buffer starts wherever it was anchored) fn paneRange(pane: *Pane, text: []const u8, row0: i32) modal.HxRange { const c = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row - row0)), .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) - row0)); const r1: usize = @intCast(@max(0, @max(pane.msel.r0, pane.msel.r1) - row0)); 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 - row0)), .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_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. On an OUTPUT BUFFER (+Search, +Help) there is no // file behind the pane and nothing worth keeping, so "never mind" // means dismiss it: Esc IS Del, the same builtin its tag offers. if (is(key, Key.escape)) { if (pane.file) |f| if (f.output) return p.runBuiltin(.Del, p.active, ""); 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, pl.row0); 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); // SPC — the leader: a key path from here runs a BUILTIN with no // arguments, the same builtins the topbar and the tags hold (`?` at // any depth lists what the prefix can still reach). Helix spends Space // on pickers, which pardes does not have; acme's builtins are exactly // what a leader is for. Body normal mode only — a tag is always // insert, and a tty pane's keys belong to the program. if (is(key, ' ')) { p.leader_on = true; p.leader_n = 0; return; } // `:` — vim's command line with acme's vocabulary: focus the pane's own // tag in normal mode, parked at the tail's start. Motions walk the // words and the execute chord runs the one under the cursor, then // hands the body back (`:w` = Save). Always the start, not the // remembered tag column, so `:w` is the same keystrokes every time. if (is(key, ':')) { p.enterTagEdit(pane, 0); if (pane.tag_edit) pane.mode = .normal; return; } // search: `/` types a pattern into the tag, n/N walk the results. Same // keys on every kind of pane; a terminal with no search armed falls // back to n/N as a motion over the lookable tokens in its output. if (is(key, '/')) return p.startSearch(pane, search_marker); if (is(key, 'n') or is(key, 'N')) { const delta: i32 = if (is(key, 'n')) 1 else -1; if (p.searchStep(p.active, delta)) return; if (pane.isTerminal()) return p.lookStep(pane, pl, delta); return; } } // ---- search (`/`, Find, n/N): results into an output buffer, look the rows ---- /// What an armed search writes into the tag tail — and the ONLY record of /// which search it is: Enter reads the marker back (submitSearch) instead /// of pardes carrying a second piece of pane state for two commands that /// differ in nothing but where the rows come from. const search_marker = " /"; const find_marker = " Find /"; /// `/` (and the Find builtin) on any pane: type the pattern into the tag /// tail after `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, marker: []const u8) void { if (!pane.tag_init) { pane.tag_tail.appendSlice(p.gpa, curTail(pane)) catch return; pane.tag_init = true; } pane.search_at = @intCast(pane.tag_tail.items.len); pane.tag_tail.appendSlice(p.gpa, marker) 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 an armed input: the pattern is everything past the marker's /// `/` (so a pattern may itself contain slashes), and the marker names the /// search — " Find /" walks the filesystem, " /" reads the pane's text. /// No `/` left means the editor ate the marker: nothing to run. fn submitSearch(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; const tail = pane.tag_tail.items; const armed = tail[@min(pane.search_at, tail.len)..]; const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return; p.runSearch(id, armed[slash + 1 ..], std.mem.startsWith(u8, armed, find_marker)); } /// Fill this pane's results buffer with everything matching `pat_raw` /// (plain substring, case-insensitive) — ONE function for both searches, /// because they differ only in where the rows come from and agree on every /// row being a LOOK TARGET, which is what makes n/N work: /// text (`/`) — the pane's own flat text, the same view Look and Execute /// read, so a terminal searches its scrollback exactly as a file /// searches its content. Rows are `location text`, the location ended /// by a SPACE (a trailing `:` would read as part of it): the pane's /// path when it has a real file, else `@pN`. /// find — the pane's DIRECTORY, walked like fd. Rows are bare paths. /// No matches = an empty buffer. fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, find: bool) void { const pane = p.panes[id] orelse return; const pat = std.mem.trim(u8, pat_raw, " \t\r\n"); if (pat.len == 0) return; const arena = p.scratch.allocator(); // where the pane lives: a file's directory, a shell's cwd — the walk // root, and the directory the results buffer is named in. const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); var out: std.ArrayList(u8) = .empty; if (find) look.find(arena, dir, pat, &out) else { const pl = p.paneCursorLines(pane) catch return; // a real file names itself; a terminal or an output buffer has no path const has_path = if (pane.file) |f| !f.output else false; var idbuf: [16]u8 = undefined; const loc: []const u8 = if (has_path) pane.file.?.path else std.fmt.bufPrint(&idbuf, "@p{d}", .{id}) catch return; for (pl.lines, 0..) |ln, i| { const at = std.ascii.indexOfIgnoreCase(ln, pat) orelse continue; const row = std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{ loc, i + 1, at + 1, std.mem.trimEnd(u8, ln, " \t"), }) catch return; out.appendSlice(arena, row) catch return; } } const content = p.gpa.dupe(u8, out.items) catch return; // reuse this pane's own results buffer while it lives, else open one // next to the docs; focus stays here so n/N keeps stepping. search_pane // is a raw slot index and a deleted buffer frees its slot, so the NAME // is the proof the pane found there is still the one we made. if (pane.search_pane) |sid| if (p.panes[sid]) |sp| if (sp.file) |*sf| if (sf.output and std.mem.endsWith(u8, sf.path, "/+Search")) { p.setFileContent(sf, content); sf.scroll = 0; sp.cur_row = 0; sp.msel.active = false; pane.search_row = null; return; }; const free = p.freeSlot() orelse { p.gpa.free(content); return; }; const np = p.openOutputView(free, dir, "+Search", content) catch { p.gpa.free(content); return; }; p.placeDoc(id, free, np); p.active = id; pane.search_pane = free; pane.search_row = null; } /// n/N: step to the next/previous row of this pane's results buffer and /// look it — every row there IS a result, so the leading file-ish word /// (`path:LINE:COL` or `@pN:LINE:COL`) does the rest through the ordinary /// look path. False = no live search: a terminal's n/N falls back to /// lookStep, anything else stays put. fn searchStep(p: *Pardes, id: usize, delta: i32) bool { const pane = p.panes[id] orelse return false; const rid = pane.search_pane orelse return false; const rp = p.panes[rid] orelse return false; const rf = if (rp.file) |*f| f else return false; // a freed slot can be reused by an unrelated pane: the name says ours if (!rf.output or !std.mem.endsWith(u8, rf.path, "/+Search")) return false; // fresh results: n starts at the first row, N has nothing behind it const nrows: i64 = @intCast(std.mem.count(u8, rf.content, "\n")); const step: i64 = if (pane.search_row) |c| @as(i64, @intCast(c)) + delta else if (delta > 0) 0 else -1; if (step < 0 or step >= nrows) return true; // armed, nowhere left to go const r: i32 = @intCast(step); pane.search_row = @intCast(step); // select the result row 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_pinned = true; rp.ensureCursorVisible(); const ln = modal.lineSlice(rf.content, @intCast(step)); var hi: usize = 0; while (hi < ln.len and look.isFileChar(ln[hi])) hi += 1; p.actOnSelection(.right, rid, ln[0..hi], null); // the look may focus what it opened — a Find row opens a whole new // file pane every time — so focus comes back to the pane that owns the // search and the next n keeps stepping. A `/` row looks at the // searching pane itself, so this is what already happened there. p.active = id; return true; } /// 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_pinned = true; pane.ensureCursorVisible(); return; } // nothing lookable anywhere: stay put } const EditText = struct { text: []u8, row0: i32 }; /// The pane's editable text and the absolute surface row its first line /// sits at: a file's whole content at row 0, or a terminal's edit buffer /// at its anchor. Terminals materialize the buffer here on first use and /// grow it whenever an edit reaches past an end, seeding from — and from /// then on standing in for — the shell rows it swallows. /// `col` is the caller's cursor column: row `lo` is padded out to it with /// spaces, because a terminal cursor may sit past the end of a row (acme: /// you click anywhere and type there, which is what the insertion runs /// did by padding at render time). Insert-mode callers pass their column; /// everything else passes -1 and leaves the text alone. /// Null if the pane holds no editable text (an image). fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText { if (pane.file) |f| return .{ .text = f.content, .row0 = 0 }; if (pane.image != null) return null; const want_lo = @max(0, @min(lo, hi)); const want_hi = @max(want_lo, @max(lo, hi)); const fresh = pane.ovl == null; const old: Ovl = pane.ovl orelse .{ .row = want_lo, .rows = 1, .text = &.{} }; const lines: i32 = if (fresh) 1 else @intCast(modal.lineCount(old.text)); const up = old.row - want_lo; // shell rows to prepend const down = want_hi - (old.row + lines - 1); // surface rows past its end if (fresh or up > 0 or down > 0) { const rows = p.shellRows(pane) catch return null; var buf: std.ArrayList(u8) = .empty; defer buf.deinit(p.gpa); const row0 = old.row - @max(0, up); var g: i32 = row0; while (g < old.row) : (g += 1) { buf.appendSlice(p.gpa, if (@as(usize, @intCast(g)) < rows.len) rows[@intCast(g)] else "") catch return null; buf.append(p.gpa, '\n') catch return null; } if (fresh) buf.appendSlice(p.gpa, if (@as(usize, @intCast(old.row)) < rows.len) rows[@intCast(old.row)] else "") catch return null else buf.appendSlice(p.gpa, old.text) catch return null; var k: i32 = 0; while (k < down) : (k += 1) { const src = old.row + old.rows + k; buf.append(p.gpa, '\n') catch return null; buf.appendSlice(p.gpa, if (@as(usize, @intCast(src)) < rows.len) rows[@intCast(src)] else "") catch return null; } const text = buf.toOwnedSlice(p.gpa) catch return null; if (pane.ovl) |o| p.gpa.free(o.text); pane.ovl = .{ .row = row0, .rows = old.rows + @max(0, up) + @max(0, down), .text = text }; } const o = &pane.ovl.?; const r: usize = @intCast(@max(0, want_lo - o.row)); const llen: i32 = @intCast(modal.lineSlice(o.text, r).len); if (col > llen) { const spaces = p.gpa.alloc(u8, @intCast(col - llen)) catch return null; defer p.gpa.free(spaces); @memset(spaces, ' '); const text = modal.insertAt(p.gpa, o.text, .{ .row = r, .col = @intCast(llen) }, spaces) catch return null; p.gpa.free(o.text); o.text = text; } return .{ .text = o.text, .row0 = o.row }; } /// editText for an op whose selection can END on a line's newline cell: /// eating that newline joins with the line BELOW, so a terminal's buffer /// has to cover that row too (a file's content always already does). fn editTextEol(p: *Pardes, pane: *Pane, b: Bounds) ?EditText { const eb = p.editText(pane, b.lo_row, b.hi_row, -1) orelse return null; const r: usize = @intCast(@max(0, b.hi_row - eb.row0)); if (r + 1 < modal.lineCount(eb.text)) return eb; if (b.hi_col < @as(i32, @intCast(modal.lineSlice(eb.text, r).len))) return eb; return p.editText(pane, b.lo_row, b.hi_row + 1, -1); } /// install a rewritten editable text (frees the old one) fn setEditText(p: *Pardes, pane: *Pane, new: []u8) void { if (pane.file) |*f| return p.setFileContent(f, new); if (pane.ovl) |*o| { p.gpa.free(o.text); o.text = new; } else p.gpa.free(new); } 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; // helix `open`: the new line copies the current line's full // indent LEVELS; o opens n lines, cursor on the first const abs: i32 = if (b) |bb| (if (below) bb.hi_row else bb.lo_row) else pane.cur_row; const eb = p.editText(pane, abs, abs, -1) orelse return; const row: usize = @intCast(@max(0, abs - eb.row0)); const ind = modal.hxIndentString(modal.lineSlice(eb.text, 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(eb.text, row).len } else .{ .row = row, .col = 0 }; const new = modal.insertAt(p.gpa, eb.text, at, block.items) catch return; p.setEditText(pane, new); pane.cur_row = @as(i32, @intCast(if (below) row + 1 else row)) + eb.row0; pane.cur_col = @intCast(ind.len); pane.cur_pinned = true; }, } pane.mode = .insert; pane.msel.active = false; pane.pending = 0; pane.ensureCursorVisible(); } /// insert mode. ONE path for both pane kinds: a file edits its content, a /// terminal edits the buffer standing in for its shell rows (editText /// materializes and grows it), so typing, Enter, joins and the kill runs /// mean exactly the same thing in a shell pane as in a document. 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.cur_pinned) pane.pinCursor(); // arrows and paging are pure motion over the WHOLE surface, so they // run before editText — a terminal must not freeze shell rows into an // edit buffer just because you walked across them switch (key.cp) { 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(); return; }, 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 flat = p.flatSurface(pane, pl) catch return; const range = paneRange(pane, flat, pl.row0); const step: i32 = @intCast(@max(1, pane.rows)); scrollViewMove(pane, pl, flat, range, if (key.cp == Key.page_down) step else -step); return; }, Key.home => { pane.cur_col = 0; pane.cur_pinned = true; pane.ensureCursorVisible(); return; }, Key.end => { // helix insert End: past the last char (goto_line_end_newline) const pl = p.paneCursorLines(pane) catch return; const cur0 = toModalCursor(pane, pl); pane.cur_col = @intCast(if (cur0.row < pl.lines.len) pl.lines[cur0.row].len else 0); pane.cur_pinned = true; pane.ensureCursorVisible(); return; }, else => {}, } const eb = p.editText(pane, pane.cur_row, pane.cur_row, pane.cur_col) orelse return; const text = eb.text; const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }; if (key.text.len > 0 and !key.ctrl and !key.alt) { const new = modal.insertAt(p.gpa, text, c, key.text) catch return; p.setEditText(pane, 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, which takes the word before it // too, so on the buffer's first line a terminal grows up one row const e2 = if (c.row == 0 and c.col == 0) (p.editText(pane, pane.cur_row - 1, pane.cur_row, 0) orelse return) else eb; const c2 = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - e2.row0)), .col = c.col }; const g = modal.hxOff(e2.text, c2); if (g == 0) return; const wr = modal.hxWordMove(e2.text, .{ .anchor = g, .head = g }, 1, .prev_word_start); const from = @min(wr.anchor, wr.head); const fc = modal.hxPos(e2.text, from); const new = modal.deleteSpan(p.gpa, e2.text, fc, c2) catch return; p.setEditText(pane, new); pane.cur_row = @as(i32, @intCast(fc.row)) + e2.row0; 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 — at the // buffer's last line a terminal grows down one row to allow it const e2 = if (c.col >= modal.lineSlice(text, c.row).len and c.row + 1 >= modal.lineCount(text)) (p.editText(pane, pane.cur_row, pane.cur_row + 1, pane.cur_col) orelse return) else eb; const c2 = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - e2.row0)), .col = c.col }; const g = modal.hxOff(e2.text, c2); const wr = modal.hxWordMove(e2.text, .{ .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, e2.text, c2, modal.hxPos(e2.text, to)) catch return; p.setEditText(pane, new); pane.cur_pinned = true; return; } if (isC(key, 'u')) { const new = modal.deleteSpan(p.gpa, text, .{ .row = c.row, .col = 0 }, c) catch return; p.setEditText(pane, new); pane.cur_col = 0; pane.cur_pinned = true; pane.ensureCursorVisible(); return; } if (isC(key, 'k')) { const llen = modal.lineSlice(text, c.row).len; const new = modal.deleteSpan(p.gpa, text, c, .{ .row = c.row, .col = llen }) catch return; p.setEditText(pane, 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(text, c.row)); const arena = p.scratch.allocator(); const block = std.fmt.allocPrint(arena, "\n{s}", .{ind}) catch return; const new = modal.insertAt(p.gpa, text, c, block) catch return; p.setEditText(pane, 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, text, .{ .row = c.row, .col = c.col - 1 }) catch return; p.setEditText(pane, new); pane.cur_col -= 1; pane.cur_pinned = true; pane.ensureCursorVisible(); return; } // at col 0: eat the newline above (join up) — a terminal grows // its buffer over the row it is joining with if (pane.cur_row <= 0) return; const e2 = p.editText(pane, pane.cur_row - 1, pane.cur_row, 0) orelse return; const row: usize = @intCast(@max(0, pane.cur_row - e2.row0)); if (row == 0) return; const prevlen = modal.lineSlice(e2.text, row - 1).len; const new = modal.deleteSpan(p.gpa, e2.text, .{ .row = row - 1, .col = prevlen }, .{ .row = row, .col = 0 }) catch return; p.setEditText(pane, new); pane.cur_row -= 1; pane.cur_col = @intCast(prevlen); pane.cur_pinned = true; pane.ensureCursorVisible(); }, Key.delete => { // delete forward; at line end eat the newline (join), which on // a terminal means the buffer swallowing the row below const line = modal.lineSlice(text, c.row); if (c.col < line.len) { const new = modal.deleteChar(p.gpa, text, c) catch return; p.setEditText(pane, new); } else { const e2 = p.editText(pane, pane.cur_row, pane.cur_row + 1, pane.cur_col) orelse return; const row: usize = @intCast(@max(0, pane.cur_row - e2.row0)); if (row + 1 >= modal.lineCount(e2.text)) return; const new = modal.deleteSpan(p.gpa, e2.text, .{ .row = row, .col = c.col }, .{ .row = row + 1, .col = 0 }) catch return; p.setEditText(pane, 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, text, c, spaces[0..pad]) catch return; p.setEditText(pane, new); pane.cur_col += @intCast(pad); pane.cur_pinned = true; pane.ensureCursorVisible(); }, else => {}, } } 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 }; } /// the char selection as text. Read off the pane's SURFACE (file content / /// terminal shell rows + edit buffer), not the rendered body: a yank of a /// whole line has to carry its newline, the way a file's does, or p/P /// paste it charwise. Scratch-owned. fn vselText(p: *Pardes, pane: *Pane) []const u8 { const arena = p.scratch.allocator(); const b = vselBounds(pane); const text = p.surfaceText(pane) orelse return ""; return modal.rangeText(arena, text, .{ .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 ""; } /// join surface 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 pl = p.paneCursorLines(pane) catch return ""; var out: std.ArrayList(u8) = .empty; var i = r0; while (i <= r1) : (i += 1) { if (i > r0) out.append(arena, '\n') catch return ""; if (i >= 0 and @as(usize, @intCast(i)) < pl.lines.len) out.appendSlice(arena, pl.lines[@intCast(i)]) catch return ""; } return out.items; } /// the whole pane surface as flat text, absolute rows from 0 (read-only: /// unlike editText it never materializes a terminal's edit buffer) fn surfaceText(p: *Pardes, pane: *Pane) ?[]const u8 { if (pane.file) |f| return f.content; const pl = p.paneCursorLines(pane) catch return null; return p.flatSurface(pane, pl) catch null; } // ---- 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); const eb = p.editTextEol(pane, b) orelse { pane.vsel.active = false; return; }; const d = modal.deleteRange(p.gpa, eb.text, .{ .row = @intCast(@max(0, b.lo_row - eb.row0)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row - eb.row0)), .col = @intCast(@max(0, b.hi_col)) }) catch { pane.vsel.active = false; return; }; p.setEditText(pane, d.content); p.gpa.free(d.deleted); pane.cur_row = b.lo_row; pane.cur_col = b.lo_col; clampCursor(pane, d.content, eb.row0); 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); // linewise: a line's newline lives on the row below it const eb = p.editText(pane, r0, r1 + 1, -1) orelse { pane.msel.active = false; return; }; const d = modal.deleteLines(p.gpa, eb.text, @intCast(@max(0, r0 - eb.row0)), @intCast(@max(0, r1 - eb.row0))) catch { pane.msel.active = false; return; }; p.setEditText(pane, d.content); if (yank) p.setYank(d.deleted); p.gpa.free(d.deleted); const n = modal.lineCount(d.content); pane.cur_row = @as(i32, @intCast(@min(@as(usize, @intCast(@max(0, r0 - eb.row0))), if (n == 0) 0 else n - 1))) + eb.row0; pane.cur_col = 0; pane.msel.active = false; pane.cur_pinned = true; pane.ensureCursorVisible(); } else { p.pushUndo(pane); const eb = p.editTextEol(pane, .{ .lo_row = pane.cur_row, .lo_col = pane.cur_col, .hi_row = pane.cur_row, .hi_col = pane.cur_col }) orelse return; const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }; if (yank) { const arena = p.scratch.allocator(); p.setYank(modal.rangeText(arena, eb.text, 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, eb.text, c, c) catch return; p.setEditText(pane, d.content); p.gpa.free(d.deleted); const llen = modal.lineSlice(d.content, c.row).len; pane.cur_col = @min(pane.cur_col, @as(i32, @intCast(llen))); } } 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) { p.setYank(p.yankRows(pane, @min(pane.msel.r0, pane.msel.r1), @max(pane.msel.r0, pane.msel.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). 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; } 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 }; const eb = p.editText(pane, b.lo_row, b.hi_row, -1) orelse return; const row0 = eb.row0; if (y[y.len - 1] == '\n') { const block_text = y[0 .. y.len - 1]; const n = modal.lineCount(block_text); var out: []u8 = undefined; if (before) { const row: usize = @intCast(@max(0, b.lo_row - row0)); const block = std.fmt.allocPrint(arena, "{s}\n", .{block_text}) catch return; out = modal.insertAt(p.gpa, eb.text, .{ .row = row, .col = 0 }, block) catch return; pane.cur_row = @as(i32, @intCast(row + n - 1)) + row0; } else { const row: usize = @intCast(@max(0, b.hi_row - row0)); out = modal.pasteLineBelow(p.gpa, eb.text, row, block_text) catch return; pane.cur_row = @as(i32, @intCast(row + n)) + row0; } p.setEditText(pane, out); 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(out, @intCast(@max(0, pane.cur_row - row0))).len; pane.cur_col = @intCast(llen); } else { const at: modal.Cursor = if (before) .{ .row = @intCast(@max(0, b.lo_row - row0)), .col = @intCast(@max(0, b.lo_col)) } else blk: { const hrow: usize = @intCast(@max(0, b.hi_row - row0)); const gcol = @as(usize, @intCast(@max(0, b.hi_col))) + 1; if (gcol > modal.lineSlice(eb.text, hrow).len) break :blk .{ .row = hrow + 1, .col = 0 }; break :blk .{ .row = hrow, .col = gcol }; }; const out = modal.insertAt(p.gpa, eb.text, at, y) catch return; p.setEditText(pane, out); pane.vsel = .{ .active = y.len > 1, .row = @as(i32, @intCast(at.row)) + row0, .col = @intCast(at.col), .explicit = false }; const end = modal.advanceBy(at, y); if (end.col > 0) { pane.cur_row = @as(i32, @intCast(end.row)) + row0; pane.cur_col = @intCast(end.col - 1); } else { pane.cur_row = @as(i32, @intCast(end.row -| 1)) + row0; pane.cur_col = @intCast(modal.lineSlice(out, end.row -| 1).len); } } pane.msel.active = false; 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); const eb = p.editTextEol(pane, b) orelse return; // 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 const hi_len = modal.lineSlice(eb.text, @intCast(@max(0, b.hi_row - eb.row0))).len; if (b.lo_col == 0 and @as(usize, @intCast(@max(0, b.hi_col))) >= hi_len) { p.setYank(p.vselText(pane)); p.pushUndo(pane); const d = modal.deleteLines(p.gpa, eb.text, @intCast(@max(0, b.lo_row - eb.row0)), @intCast(@max(0, b.hi_row - eb.row0))) catch return; p.setEditText(pane, d.content); p.gpa.free(d.deleted); const n = modal.lineCount(d.content); const row: usize = @min(@as(usize, @intCast(@max(0, b.lo_row - eb.row0))), if (n == 0) 0 else n - 1); const ind = modal.hxIndentString(modal.lineSlice(d.content, row)); const arena = p.scratch.allocator(); const block = std.fmt.allocPrint(arena, "{s}\n", .{ind}) catch return; const new = modal.insertAt(p.gpa, d.content, .{ .row = row, .col = 0 }, block) catch return; p.setEditText(pane, new); pane.cur_row = @as(i32, @intCast(row)) + eb.row0; pane.cur_col = @intCast(ind.len); pane.vsel.active = false; pane.msel.active = false; pane.cur_pinned = true; pane.mode = .insert; pane.pending = 0; pane.ensureCursorVisible(); return; } p.setYank(p.vselText(pane)); p.pushUndo(pane); const d = modal.deleteRange(p.gpa, eb.text, .{ .row = @intCast(@max(0, b.lo_row - eb.row0)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row - eb.row0)), .col = @intCast(@max(0, b.hi_col)) }) catch { pane.vsel.active = false; return; }; p.setEditText(pane, d.content); p.gpa.free(d.deleted); pane.cur_row = b.lo_row; pane.cur_col = b.lo_col; clampCursor(pane, d.content, eb.row0); 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); const eb = p.editText(pane, r0, r1 + 1, -1) orelse return; // keep one empty line at r0, drop the rest; yank the removed var buf: std.ArrayList(u8) = .empty; const arena = p.scratch.allocator(); const lo: usize = @intCast(@max(0, r0 - eb.row0)); const hi: usize = @intCast(@max(0, r1 - eb.row0)); var r: usize = lo; while (r <= hi) : (r += 1) { if (r > lo) buf.append(arena, '\n') catch break; buf.appendSlice(arena, modal.lineSlice(eb.text, r)) catch break; } p.setYank(buf.items); var body: []u8 = eb.text; if (hi > lo) { const d = modal.deleteLines(p.gpa, body, lo + 1, hi) catch { pane.msel.active = false; return; }; p.setEditText(pane, d.content); p.gpa.free(d.deleted); body = d.content; } const cl = modal.clearLine(p.gpa, body, lo) catch { pane.msel.active = false; return; }; p.setEditText(pane, cl); pane.cur_row = r0; pane.cur_col = 0; pane.msel.active = false; pane.cur_pinned = true; pane.mode = .insert; pane.pending = 0; pane.ensureCursorVisible(); } else { p.pushUndo(pane); const eb = p.editTextEol(pane, .{ .lo_row = pane.cur_row, .lo_col = pane.cur_col, .hi_row = pane.cur_row, .hi_col = pane.cur_col }) orelse return; const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }; const arena = p.scratch.allocator(); p.setYank(modal.rangeText(arena, eb.text, c, c) catch return); const d = modal.deleteRange(p.gpa, eb.text, c, c) catch return; p.setEditText(pane, d.content); p.gpa.free(d.deleted); const llen = modal.lineSlice(d.content, c.row).len; pane.cur_col = @min(pane.cur_col, @as(i32, @intCast(llen))); pane.mode = .insert; pane.pending = 0; } } // ---- helix change ops (r R ~ ` J > < Ctrl-a m-mode ]space) ---- // Terminals go through the same edit buffer as files: since editText // materializes one over whatever rows the op names, every one of these // works the same in a shell pane as in a document. /// the selection as an inclusive cursor range in `content`, whose first /// line is absolute row `row0`: vsel span, msel line span, else the char /// under the cursor (helix's implicit 1-wide selection) fn selRange(pane: *Pane, content: []const u8, row0: i32) modal.Range { if (pane.vsel.active) { const b = vselBounds(pane); return .{ .a = .{ .row = @intCast(@max(0, b.lo_row - row0)), .col = @intCast(@max(0, b.lo_col)) }, .b = .{ .row = @intCast(@max(0, b.hi_row - row0)), .col = @intCast(@max(0, b.hi_col)) }, }; } if (pane.msel.active) { const r0: usize = @intCast(@max(0, @min(pane.msel.r0, pane.msel.r1) - row0)); const r1: usize = @intCast(@max(0, @max(pane.msel.r0, pane.msel.r1) - row0)); 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 - row0)), .col = @intCast(@max(0, pane.cur_col)) }; return .{ .a = c, .b = c }; } /// the rows an op's selection spans, absolute (what editText must cover) fn selRows(pane: *Pane) Bounds { if (pane.vsel.active) return vselBounds(pane); if (pane.msel.active) return .{ .lo_row = @min(pane.msel.r0, pane.msel.r1), .lo_col = 0, .hi_row = @max(pane.msel.r0, pane.msel.r1), .hi_col = 0, }; return .{ .lo_row = pane.cur_row, .lo_col = pane.cur_col, .hi_row = pane.cur_row, .hi_col = pane.cur_col }; } /// `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; const eb = p.editTextEol(pane, selRows(pane)) orelse return; const r = selRange(pane, eb.text, eb.row0); p.pushUndo(pane); const new = modal.replaceChars(p.gpa, eb.text, r.a, r.b, ch) catch return; p.setEditText(pane, new); } /// `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; pane.select = false; const eb = p.editTextEol(pane, selRows(pane)) orelse return; const r = selRange(pane, eb.text, eb.row0); p.pushUndo(pane); const new = modal.replaceRange(p.gpa, eb.text, r.a, r.b, y) catch return; p.setEditText(pane, new); pane.msel.active = false; pane.vsel = .{ .active = y.len > 1, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false }; const end = modal.advanceBy(r.a, y); if (end.col > 0) { pane.cur_row = @as(i32, @intCast(end.row)) + eb.row0; pane.cur_col = @intCast(end.col - 1); } else { // the yank ended in '\n': the cursor lands ON that newline pane.cur_row = @as(i32, @intCast(end.row -| 1)) + eb.row0; pane.cur_col = @intCast(modal.lineSlice(new, end.row -| 1).len); } 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; const eb = p.editTextEol(pane, selRows(pane)) orelse return; const r = selRange(pane, eb.text, eb.row0); p.pushUndo(pane); const new = modal.changeCase(p.gpa, eb.text, r.a, r.b, op) catch return; p.setEditText(pane, new); } /// 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 { // a join always eats the newline of its last line, so the buffer has // to reach one row PAST the selection const sr = selRows(pane); const eb = p.editText(pane, sr.lo_row, sr.hi_row + 1, -1) orelse return; const text = eb.text; const range = paneRange(pane, text, eb.row0); 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 - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }); const anc_off = if (pane.vsel.active) modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row - eb.row0)), .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.setEditText(pane, new); const cc = modal.hxPos(new, nc); const ac = modal.hxPos(new, na); pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; pane.cur_col = @intCast(cc.col); if (pane.vsel.active) { pane.vsel.row = @as(i32, @intCast(ac.row)) + eb.row0; pane.vsel.col = @intCast(ac.col); } pane.msel.active = false; 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 { pane.select = false; const sr = selRows(pane); const eb = p.editText(pane, sr.lo_row, sr.hi_row, -1) orelse return; const text = eb.text; const range = paneRange(pane, text, eb.row0); 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 - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }); const anc_off = if (pane.vsel.active) modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row - eb.row0)), .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.setEditText(pane, new); const cc = modal.hxPos(new, nc); const ac = modal.hxPos(new, na); pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; pane.cur_col = @intCast(cc.col); if (pane.vsel.active) { pane.vsel.row = @as(i32, @intCast(ac.row)) + eb.row0; pane.vsel.col = @intCast(ac.col); } pane.msel.active = false; 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 { const eb = p.editTextEol(pane, selRows(pane)) orelse return; const r = selRange(pane, eb.text, eb.row0); const arena = p.scratch.allocator(); const frag = modal.rangeText(arena, eb.text, 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, eb.text, r.a, r.b, rep) catch return; p.setEditText(pane, new); const start = modal.lineStartOffset(new, r.a.row) + r.a.col; const cc = modal.hxPos(new, start + rep.len - 1); pane.vsel = .{ .active = rep.len > 1, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false }; pane.msel.active = false; pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; pane.cur_col = @intCast(cc.col); 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 { const eb = p.editText(pane, pane.cur_row, pane.cur_row, -1) orelse return; p.pushUndo(pane); const row: usize = @intCast(@max(0, pane.cur_row - eb.row0)); const arena = p.scratch.allocator(); const nl = arena.alloc(u8, cnt) catch return; @memset(nl, '\n'); const llen = modal.lineSlice(eb.text, row).len; const new = modal.insertAt(p.gpa, eb.text, .{ .row = row, .col = if (below) llen else 0 }, nl) catch return; p.setEditText(pane, 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, pl.row0); 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 { const pr = pairFor(ch) orelse return; pane.select = false; const eb = p.editTextEol(pane, selRows(pane)) orelse return; const r = selRange(pane, eb.text, eb.row0); p.pushUndo(pane); var new = modal.insertAt(p.gpa, eb.text, .{ .row = r.b.row, .col = r.b.col + 1 }, &[1]u8{pr.c}) catch return; p.setEditText(pane, new); new = modal.insertAt(p.gpa, new, .{ .row = r.a.row, .col = r.a.col }, &[1]u8{pr.o}) catch return; p.setEditText(pane, new); pane.msel.active = false; pane.vsel = .{ .active = true, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false }; pane.cur_row = @as(i32, @intCast(r.b.row)) + eb.row0; pane.cur_col = @intCast(r.b.col + 1 + @as(usize, if (r.a.row == r.b.row) 1 else 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 { const pr = pairFor(ch) orelse return; // the pair is found over the motion surface, whose rows are absolute; // the edit runs in the buffer covering those rows, and every offset // below is taken in THAT text so the cursor maps through it 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; const lo: i32 = @intCast(@min(r.a.row, cur.row)); const hi: i32 = @intCast(@max(r.b.row, cur.row)); const eb = p.editText(pane, lo + pl.row0, hi + pl.row0, -1) orelse return; const text = eb.text; const drow = pl.row0 - eb.row0; // surface row -> buffer row const ra: modal.Cursor = .{ .row = @intCast(@as(i32, @intCast(r.a.row)) + drow), .col = r.a.col }; const rb: modal.Cursor = .{ .row = @intCast(@as(i32, @intCast(r.b.row)) + drow), .col = r.b.col }; p.pushUndo(pane); const a_off = modal.hxOff(text, ra); const b_off = modal.hxOff(text, rb); const cur_off = modal.hxOff(text, .{ .row = @intCast(@as(i32, @intCast(cur.row)) + drow), .col = cur.col }); // the close first, so the open's position stays valid var new = modal.deleteChar(p.gpa, text, rb) catch return; p.setEditText(pane, new); new = modal.deleteChar(p.gpa, new, ra) catch return; p.setEditText(pane, new); var nc = cur_off; if (nc > b_off) nc -= 1; if (nc > a_off) nc -= 1; const cc = modal.hxPos(new, nc); pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; pane.cur_col = @intCast(cc.col); pane.vsel.active = false; pane.msel.active = false; 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 { const fp = pairFor(from) orelse return; const tp = pairFor(to) orelse return; 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; const eb = p.editText(pane, @as(i32, @intCast(r.a.row)) + pl.row0, @as(i32, @intCast(r.b.row)) + pl.row0, -1) orelse return; const drow = pl.row0 - eb.row0; // surface row -> buffer row const ar: usize = @intCast(@as(i32, @intCast(r.a.row)) + drow); const br: usize = @intCast(@as(i32, @intCast(r.b.row)) + drow); p.pushUndo(pane); const out = p.gpa.dupe(u8, eb.text) catch return; out[modal.lineStartOffset(out, ar) + r.a.col] = tp.o; out[modal.lineStartOffset(out, br) + r.b.col] = tp.c; p.setEditText(pane, 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; } /// pull the cursor back inside `text` after a rewrite; `row0` is the /// absolute surface row of its first line (0 for a file) fn clampCursor(pane: *Pane, text: []const u8, row0: i32) void { const n = modal.lineCount(text); const row: usize = @min(@as(usize, @intCast(@max(0, pane.cur_row - row0))), if (n == 0) 0 else n - 1); const llen = modal.lineSlice(text, row).len; pane.cur_row = @as(i32, @intCast(row)) + row0; pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, pane.cur_col))), llen)); pane.cur_pinned = true; pane.vsel.active = false; pane.msel.active = false; pane.ensureCursorVisible(); } 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 cur = pane.ovl orelse Ovl{ .rows = 0 }; if (pane.ed_undo.getLastOrNull()) |top| { const same = if (top.ovl) |o| pane.ovl != null and o.row == cur.row and o.rows == cur.rows and std.mem.eql(u8, o.text, cur.text) else pane.ovl == null; if (same) return; } const snap = p.snapTerm(pane) orelse return; pane.ed_undo.append(p.gpa, snap) catch { if (snap.ovl) |o| p.gpa.free(o.text); return; }; if (pane.ed_undo.items.len > EDIT_UNDO_MAX) { const drop = pane.ed_undo.orderedRemove(0); if (drop.ovl) |o| p.gpa.free(o.text); } for (pane.ed_redo.items) |sn| if (sn.ovl) |o| p.gpa.free(o.text); 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; const cur = p.snapTerm(pane) orelse return; pane.ed_redo.append(p.gpa, cur) catch { if (cur.ovl) |o| p.gpa.free(o.text); return; }; p.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; const cur = p.snapTerm(pane) orelse return; pane.ed_undo.append(p.gpa, cur) catch { if (cur.ovl) |o| p.gpa.free(o.text); return; }; p.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_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); } /// the snapshot takes ownership of a COPY of the edit buffer's text fn snapTerm(p: *Pardes, pane: *Pane) ?EditSnap { var ovl: ?Ovl = null; if (pane.ovl) |o| ovl = .{ .row = o.row, .rows = o.rows, .text = p.gpa.dupe(u8, o.text) catch return null }; return .{ .ovl = ovl, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .vsel = pane.vsel }; } fn restoreTerm(p: *Pardes, pane: *Pane, snap: EditSnap) void { if (pane.ovl) |o| p.gpa.free(o.text); pane.ovl = snap.ovl; pane.cur_row = snap.cur_row; pane.cur_col = snap.cur_col; pane.cur_pinned = true; pane.vsel = snap.vsel; 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 a selection as the execute's argument; the // middle drag keeps running to its release. The drag pane's // own selection wins; a pane with none borrows from the // OTHER panes (acme: the chord argument is whatever text is // selected, in any window) — the ACTIVE pane first (making // a selection focuses its pane, so it holds the most // recently made one), then slot order. Per pane a kept left // selection wins, else an explicit modal (v/x, n/N) one. if (p.drag == .select and p.drag.select.button == .middle) { const did = p.drag.select.id; var txt: ?[]const u8 = null; var k: usize = 0; while (txt == null and k < p.panes.len + 2) : (k += 1) { const i = switch (k) { 0 => did, 1 => p.active, else => k - 2, }; if (k > 0 and (i == did or (k > 1 and i == p.active))) continue; const t = p.panes[i] orelse continue; if (t.sel[0].state == .done) txt = p.selectionText(t, t.sel[0]) catch null else if ((t.vsel.active and t.vsel.explicit) or t.msel.active) txt = p.currentSelText(t); } if (txt) |tx| { if (p.chord_arg) |old| p.gpa.free(old); p.chord_arg = p.gpa.dupe(u8, tx) catch 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 // the layout box (gutter cells of the tag row) wins over // the resize handles: a tag-only pane's single row IS its // pair's h-handle, and the box must stay grabbable to move if (hovered) |mid| { const mr = p.rects[mid]; if (mcol < mr.x + GUTTER and mrow < mr.y + BOX_H) { p.drag = .{ .move = .{ .id = mid, .cur_x = mcol, .cur_y = mrow } }; return; } } // resize handles next: 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) { // 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) { // 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]]; // either pane may shrink to just its tag row (BOX_H) const lo = a.y + BOX_H - 1; const hi = a.y +| a.h +| b.h -| (BOX_H + 1); 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, BOX_H), @max(@as(f32, BOX_H), combined - BOX_H)); 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_pinned = true; if (!pane.isTerminal()) pane.mode = .normal; pane.msel.active = false; pane.pending = 0; // a chord/motion selection is residue: a fresh click // dismisses it rather than dragging the anchored span // to the click (explicit v/x keeps vim's click-extend) if (!pane.vsel.explicit) pane.vsel.active = false; } } 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). /// In a tty-mode shell 1-3 instead pastes into the pty (forwarded click /// + bracketed paste) and 1-2 is inert. fn chordCutPaste(p: *Pardes, cut: bool) void { const s = &p.drag.select; const pane = p.panes[s.id] orelse return; if (pane.mode == .tty) { // tty: nothing can be cut — 1-2 stays inert; the pty owns the // screen. 1-3 pastes like a terminal emulator: the click is // forwarded first (only when the app listens for mouse) so // mouse-aware programs put their cursor under it, then the // register is typed — bracketed when the app set mode 2004 // (readline/vim/helix strip the markers), else with \n turned // to \r like any unbracketed paste. if (!s.chorded) { s.chorded = true; pane.sel[0].state = .none; // drop the sweep highlight } if (cut) return; const y = p.yank orelse return; if (y.len == 0) return; const m = &pane.vt.modes; if (m.get(.mouse_event_normal) or m.get(.mouse_event_button) or m.get(.mouse_event_any)) { // dragUpdate keeps sel[0] tracking the held left button, so // the click lands where the mouse is at this tap; 1-based, // body-relative (the tag row is ours, not the app's) const col: u16 = @intCast(std.math.clamp(pane.sel[0].c1 + 1, 1, 9999)); const row: u16 = @intCast(std.math.clamp(pane.sel[0].r1 - @as(i32, BOX_H) + 1, 1, 9999)); var mb: [32]u8 = undefined; if (m.get(.mouse_format_sgr)) { p.emitWrite(s.id, std.fmt.bufPrint(&mb, "\x1b[<0;{d};{d}M\x1b[<0;{d};{d}m", .{ col, row, col, row }) catch return); } else { // ponytail: legacy X10 bytes; add utf8/urxvt formats if an app ever wants them const cb: u8 = @intCast(32 + @min(col, 222)); const rb: u8 = @intCast(32 + @min(row, 222)); p.emitWrite(s.id, &[_]u8{ 0x1b, '[', 'M', 32, cb, rb, 0x1b, '[', 'M', 35, cb, rb }); } } if (m.get(.bracketed_paste)) { p.emitWrite(s.id, "\x1b[200~"); p.emitWrite(s.id, y); p.emitWrite(s.id, "\x1b[201~"); } else { const cp = p.scratch.allocator().dupe(u8, y) catch return; for (cp) |*c| { if (c.* == '\n') c.* = '\r'; } p.emitWrite(s.id, cp); } return; } if (!s.chorded) { s.chorded = true; const sl = pane.sel[0]; pane.sel[0].state = .none; 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_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 (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_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 { p.layoutInsertColumn(p.ncol, id); } /// a fresh single-pane column at index `at` (the columns from there on /// shift right); an equal weight, so the others narrow proportionally fn layoutInsertColumn(p: *Pardes, at: usize, id: usize) void { if (p.ncol >= MAX_COLS) return; const c = @min(at, p.ncol); var j = p.ncol; while (j > c) : (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.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))); } /// The parent a new pane splits from must be tall enough that splitBelow /// leaves the NEW pane at least 2 body rows (the parent keeps tag+1 row, /// the new pane needs tag+2). A too-short choice is swapped for a /// qualifying pane (same column first, keeping the split local), else the /// tallest pane anywhere. fn splitParent(p: *Pardes, want: usize) usize { const need = 2 * BOX_H + 3; if (p.rects[want].h >= need) return want; if (p.layoutFindTerm(want)) |f| for (0..p.col_n[f.col]) |k| { if (p.rects[p.col_terms[f.col][k]].h >= need) return p.col_terms[f.col][k]; }; var tallest = want; for (0..p.ncol) |c| for (0..p.col_n[c]) |k| { const pid = p.col_terms[c][k]; if (p.rects[pid].h >= need) return pid; if (p.rects[pid].h > p.rects[tallest].h) tallest = pid; }; return tallest; } /// 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.ovl != null) 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) { const src = p.splitParent(p.col_terms[rc][p.col_n[rc] - 1]); const f = p.layoutFindTerm(src).?; p.splitBelow(src, nt); p.layoutInsert(f.col, f.idx + 1, free); } else p.layoutInsert(rc, p.col_n[rc], free); return free; } /// executing at a prompt with typed text below it: pad the output area /// with newlines so the command's output doesn't overwrite the buffer fn padOutputBelowEdits(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; const o = pane.ovl orelse return; if (!pane.isTerminal()) return; if (!pane.vt.cursorIsAtPrompt()) return; // the buffer's LAST surface row: its lines may outnumber the shell // rows it covers, and it is the bottom one output must clear const max_row = o.row + @as(i32, @intCast(modal.lineCount(o.text))) - 1; const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); const cursor_abs = pane.surfRow(goff + @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; } /// An output buffer (acme's +Errors): a file pane holding text the core /// produced, with no file behind it. `content` is gpa-owned and adopted. /// The name carries `dir` so looks inside it resolve like anywhere else. fn openOutputView(p: *Pardes, id: usize, dir: []const u8, name: []const u8, content: []u8) !*Pane { const path = try std.fmt.allocPrint(p.gpa, "{s}/{s}", .{ std.mem.trimEnd(u8, dir, "/"), name }); errdefer p.gpa.free(path); const pane = try p.newDocPane(id); pane.file = .{ .path = path, .content = content, .output = true }; pane.cur_pinned = true; 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. The placement of last resort — a first doc normally takes a /// column of its own (placeDoc), and this only runs when the column bar /// is full. 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) ---- /// Focus pane `id` and, for a nonzero 1-based `line`, put its modal cursor /// there (`col` likewise, 0 = line start): files recenter the view on it, /// terminals ride their scrollback to it. Both look targets that name a /// live pane land here — a path a pane already holds, and `@pN:LINE:COL`. fn focusPaneLine(p: *Pardes, id: usize, line: usize, col: usize) void { if (id >= MAX_PANES) return; const pane = p.panes[id] orelse return; p.active = id; if (line == 0) return; if (pane.file) |*f| { if (line > fileLineCount(f.content)) return; const next = (line - 1) -| pane.rows / 2; // center, clamp at top if (next != f.scroll) { f.scroll = next; f.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) pane.cur_row = @intCast(line - 1); pane.cur_col = if (col > 0) @intCast(col - 1) else 0; pane.cur_pinned = true; pane.ensureCursorVisible(); } /// focus the pane already loaded on `path` (exact match), if any: file /// panes recenter on a :NN line like the look dedup always has, image /// panes just focus. Returns false when no pane holds that path. fn focusPaneByPath(p: *Pardes, path: []const u8, line: usize, col: usize) bool { for (p.panes, 0..) |slot, i| { const tt = slot orelse continue; if (tt.image) |iv| if (std.mem.eql(u8, iv.path, path)) { p.active = i; return true; }; const ff = if (tt.file) |*f| f else continue; if (!std.mem.eql(u8, ff.path, path)) continue; p.focusPaneLine(i, line, col); return true; } return false; } fn actOnSelection(p: *Pardes, button: Mouse.Button, id: usize, txt: []const u8, arg: ?[]const u8) void { const pane = p.panes[id] orelse 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]; }; // an already-loaded pane wins BEFORE any filesystem resolve: the // web build has no fs (a look would otherwise be inert even for // panes sitting in the session), and native gets the same dedup // it always did, just without touching disk. Pane paths are // canonical (realpath'd or dump-given), so match the word as-is // and cwd-joined; anything fancier falls through to resolve. { const pl = look.parsePathLine(std.mem.trim(u8, txt, " \t\r\n")); if (pl.path.len > 0) { if (p.focusPaneByPath(pl.path, pl.line, pl.col)) return; if (pl.path[0] != '/') { var joinbuf: [2048]u8 = undefined; if (std.fmt.bufPrint(&joinbuf, "{s}/{s}", .{ cwd, pl.path }) catch null) |j| if (p.focusPaneByPath(j, pl.line, pl.col)) return; } } } switch (look.resolve(txt, cwd, &realbuf)) { // acme button-3: a word that names no file/dir is a search of // the pane it was clicked in — exactly what `/` runs (n/N then // walk the results). Paths (src/a/b.rs:100) still resolve above // and open; only the non-file case falls through here. A shell // searches its scrollback like anything else, EXCEPT in tty // mode, where the click belongs to the program on the other // end; an image pane has no text to search either way. .none => { const bmode = if (pane.tag_edit) pane.tag_mode else pane.mode; if (pane.image == null and bmode != .tty) p.runSearch(id, txt, false); }, // `@p7:10:5`: pane 7, line 10, column 5 — how a search result // points at a terminal or an output buffer, neither of which // has a path. .pane => |t| p.focusPaneLine(t.id, t.line, t.col), .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 src = p.splitParent(id); const f = p.layoutFindTerm(src).?; p.layoutInsert(f.col, f.idx + 1, free); p.splitBelow(src, nt); p.active = free; }, .file => |target| { // focus an existing pane on this path (rescrolled), else open if (p.focusPaneByPath(target.path, target.line, target.col)) return; const free = p.freeSlot() orelse return; const nt = p.openFileView(free, target.path, target.line) catch return; if (target.col > 0) nt.cur_col = @intCast(target.col - 1); 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| { if (p.focusPaneByPath(target.path, 0, 0)) 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; } // execute (middle / Tab): a builtin's name runs the builtin, anything // else is a command line for a shell. if (button != .middle) return; const cmd = std.mem.trim(u8, txt, " \t\r\n"); if (cmd.len == 0) return; const bi: ?Builtin = std.meta.stringToEnum(Builtin, cmd) orelse // Restore is the ONE builtin that takes an argument (`Restore // `); every other name must match whole, so `Kill foo` is a // shell command and not the Kill builtin. if (std.mem.startsWith(u8, cmd, "Restore ") or std.mem.startsWith(u8, cmd, "Restore\t")) .Restore else null; if (bi) |b| return p.runBuiltin(b, id, cmd); // terminal: run in itself. file pane: run in a shell in the file's // directory (found by cwd, else freshly forked). 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"); } } /// Run a builtin on pane `id`. `txt` is the executed text — only Restore /// reads it (for its optional path argument), so the leader passes "" and /// every builtin runs with zero arguments. The topbar ones are global; the /// pane-scoped ones (Save, Del, Delcol, the image toggles, and the window /// group, which moves focus relative to `id`) act on `id`. fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8) void { const pane = p.panes[id] orelse return; switch (b) { .Debug => p.show_debug = !p.show_debug, .Colors => p.colors_on = !p.colors_on, .NextColor => p.theme_idx = (p.theme_idx + 1) % themes.len, .Crt => p.crt_on = !p.crt_on, // the image toggles act on the pane they run in and are inert // anywhere else, the way Save is on a terminal. Flipping the field // is the whole toggle: drawImage re-matches the glyph grid when it // sees grid_mode/grid_ascii disagree with the live ones. .Petscii => if (pane.image) |*iv| { iv.petscii = !iv.petscii; }, .Palette => if (pane.image) |*iv| { iv.pmode = if (iv.pmode == .commodore) .terminal else .commodore; }, .Ascii => if (pane.image) |*iv| { iv.ascii = !iv.ascii; }, .Dump => p.dumpState() catch {}, .Help => p.openHelp(id, ""), .Restore => { var it = std.mem.tokenizeAny(u8, txt, " \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 => { // an output buffer has no file behind it — nothing to write if (pane.file) |f| if (!f.output) p.emit(.{ .save_file = .{ .pane = @intCast(id) } }); }, // the one builtin that ASKS for something: it arms the same tag // input `/` does, and Enter there runs the walk (submitSearch). .Find => p.startSearch(pane, find_marker), .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 = p.prevFocus(id) orelse { 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 = p.prevFocus(p.active) orelse { p.quit = true; p.emit(.quit); return; }; }, .Tutor => { const free = p.freeSlot() orelse return; const nt = p.openTutorView(free) catch return; p.placeDoc(id, free, nt); // a doc like any other }, .Left => p.focusDir(id, .left), .Down => p.focusDir(id, .down), .Up => p.focusDir(id, .up), .Right => p.focusDir(id, .right), // The file<->terminal hop. "Latest" is already recorded: focus_hist // is the MRU sync() rebuilds every update (most recent last), the // same list a closing pane hands focus back through — so this walks // it instead of keeping a second one. Which side is which: only a // shell is a terminal; a file, an image and an output buffer // (+Search/+Help) are all DOCS you read, so isTerminal is the whole // test. Landing pushes this pane to the top of that same history, // which is why the hop back is the same key. .Toggleterm => { const want_term = !pane.isTerminal(); var t: ?usize = null; // any live pane of the other kind: a pane you have never // focused (the file you started with) is in no history at all for (p.panes, 0..) |slot, k| { const op = slot orelse continue; if (k != id and op.isTerminal() == want_term) t = k; } // ...but the most recently focused one wins var i = p.nfocus; while (i > 0) { i -= 1; const hid = p.focus_hist[i]; const hp = p.panes[hid] orelse continue; if (hid != id and hp.isTerminal() == want_term) { t = hid; break; } } if (t) |target| { p.active = target; p.panes[target].?.pending = 0; } }, } } /// The Help builtin: every builtin reachable from `prefix` (empty = all /// of them) with the keys that run it, written into an output buffer /// (acme's +Errors) — ordinary text, so the names in it are LIVE: middle- /// click `Tutor` there and the tutor opens. Reuses the open +Help buffer /// instead of piling panes up, and focus follows: you asked to read it. fn openHelp(p: *Pardes, id: usize, prefix: []const u8) void { const pane = p.panes[id] orelse return; const arena = p.scratch.allocator(); var out: std.ArrayList(u8) = .empty; if (prefix.len == 0) { out.appendSlice(arena, "pardes builtins - SPC then the keys,\nor execute the name anywhere\n\n") catch return; } else { out.appendSlice(arena, "pardes builtins under SPC") catch return; for (prefix) |c| out.appendSlice(arena, &[_]u8{ ' ', c }) catch return; out.appendSlice(arena, "\n\n") catch return; } for (leader_rows) |row| { if (!std.mem.startsWith(u8, row.path, prefix)) continue; out.appendSlice(arena, row.line) catch return; out.append(arena, '\n') catch return; } const content = p.gpa.dupe(u8, out.items) catch return; for (p.panes, 0..) |slot, i| { const hp = slot orelse continue; const hf = if (hp.file) |*f| f else continue; if (!hf.output or !std.mem.endsWith(u8, hf.path, "/+Help")) continue; p.setFileContent(hf, content); hf.scroll = 0; hp.cur_row = 0; hp.msel.active = false; p.active = i; return; } const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); const free = p.freeSlot() orelse { p.gpa.free(content); return; }; const np = p.openOutputView(free, dir, "+Help", content) catch { p.gpa.free(content); return; }; p.placeDoc(id, free, np); } /// place a fresh doc pane. Any doc already open is the split parent (the /// source if it IS one, else the first found) and the newcomer lands right /// below it, so all docs share one column. The FIRST doc of the session /// instead gets a column of its own on the left (acme: files left, shells /// right) — nothing is displaced, the other columns just narrow. Only a /// full column bar falls back to stacking into the leftmost one. 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 src = p.splitParent(sid); const sf = p.layoutFindTerm(src).?; p.layoutInsert(sf.col, sf.idx + 1, free); p.splitBelow(src, nt); p.active = free; return; } if (p.ncol < MAX_COLS) { p.layoutInsertColumn(0, free); p.active = free; return; } 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), .output = f.output, }, } 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 outside tty // mode, the edit buffer standing in for the rows it covers 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: i32 = 0; var skip: i32 = 0; while (lines.next()) |raw| : (row += 1) { const is_prompt = if (pane.mode != .tty) if (prompts.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false else false; if (skip > 0) { skip -= 1; continue; } if (row > 0) try stream_text.append(arena, '\n'); if (pane.mode != .tty) if (pane.ovl) |o| if (row == o.row) { try stream_text.appendSlice(arena, o.text); skip = o.rows - 1; continue; }; try stream_text.appendSlice(arena, if (is_prompt) "" else raw); } 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, .output = f.output, .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..]; if (std.mem.eql(u8, rest, curTail(pane))) 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 = p.prevFocus(id) orelse { p.quit = true; p.emit(.quit); return; }; } /// where focus falls when the active pane closes: the most recently /// focused pane still alive (else any live one). Null = nothing left. fn prevFocus(p: *Pardes, closing: usize) ?usize { var i = p.nfocus; while (i > 0) { i -= 1; const id = p.focus_hist[i]; if (id != closing and p.panes[id] != null) return id; } for (p.panes, 0..) |slot, k| { if (slot != null and k != closing) return k; } return null; } /// 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(); // focus history, rebuilt in one pass: drop dead slots and the current // active, then push active back on top. Every focus change (mouse, // look, Ctrl-w, Alt-n) lands here without touching its call site. { var w: usize = 0; for (p.focus_hist[0..p.nfocus]) |id| { if (p.panes[id] == null or id == p.active) continue; p.focus_hist[w] = id; w += 1; } p.nfocus = w; if (p.panes[p.active] != null) { p.focus_hist[p.nfocus] = p.active; p.nfocus += 1; } } 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 // a pane shrunk to just its tag keeps its last real grid: no // pty/vt reflow while the body is hidden, so re-enlarging brings // it back exactly as it was if ((cols != pane.cols or rows != pane.rows) and r.h > BOX_H) { // 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); // pending SPC leader path, on the ACTIVE pane's tag at its RIGHT edge: // the same rule as the `/` input — state you can see, nothing that // disappears — but right-aligned, so it never collides with the // builtins in the tail. Printed AFTER the tag, so a tag long enough to // reach it loses its last columns instead: what you are about to press // beats the path, which is still readable in the prefix. if (active and p.leader_on) { var ibuf: [16]u8 = @splat(' '); @memcpy(ibuf[1..4], "SPC"); var iw: usize = 4; for (p.leader_keys[0..p.leader_n]) |ch| { ibuf[iw + 1] = ch; iw += 2; } const w: u16 = @intCast(iw); if (w < tw) _ = s.print(tx + tw - w, r.y, w, ibuf[0..iw], 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; // bar while typing, block for `:` normal mode (same rule as a body) if (cx < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(cx)), .y = r.y, .bar = pane.mode == .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). // Rows the edit buffer stands in for are OURS, not the emulator's: // they keep the plain body style, and the rows under them are read // from the shell row the surface actually shows there. const off = pane.scroll(); if (p.colors_on and pane.isTerminal()) { const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); var vr: u16 = 0; while (vr < body_h and vr < pane.rows) : (vr += 1) { const grid = pane.gridRow(off + @as(i32, vr)) - goff; if (pane.mode != .tty) if (pane.ovl) |o| { if (off + @as(i32, vr) >= o.row and off + @as(i32, vr) < o.row + @as(i32, @intCast(modal.lineCount(o.text)))) continue; }; if (grid < 0) continue; var c: u16 = 0; while (c < tw) : (c += 1) { const ci = pane.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(c), .y = @intCast(grid) } }) 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); } } } // 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 = BOX_H; // never paint the tag row 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 < BOX_H 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 goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); const crow = if (pane.cur_pinned) pane.cur_row else pane.surfRow(@as(i32, @intCast(cur.y)) + goff); const ccol = if (pane.cur_pinned) pane.cur_col else @as(i32, @intCast(cur.x)); const prow = crow - off + @as(i32, BOX_H); // files: cur_col is content-relative, add the lineno prefix const cx = if (pane.file != null) @as(i32, PREFIX_W) + ccol - pane.hscroll else ccol; if (prow >= BOX_H 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. /// Terminal: viewport rows, padded to the grid height, prompt rows blanked /// outside tty mode (OSC 133), the edit buffer standing in for the shell /// rows it covers — which is where a buffer holding more lines than those /// rows pushes the output below it down the screen. 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 out.appendSlice(arena, base.items); } // horizontal scroll: cut hscroll columns after the gutter 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); // the viewport's shell rows, prompt rows blanked outside tty mode var prompts = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .viewport = .{} }, null); var vp: std.ArrayList([]const u8) = .empty; var lines = std.mem.splitScalar(u8, raw, '\n'); while (lines.next()) |ln| { const is_prompt = if (pane.mode != .tty) if (prompts.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false else false; try vp.append(arena, if (is_prompt) "" else ln); } const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); const off = pane.scroll(); var g: i32 = pane.gridRow(off); // the buffer can start above the viewport: drop the lines scrolled past var skip: usize = if (pane.ovl) |o| @intCast(@max(0, off - pane.surfRow(o.row))) else 0; var out: std.ArrayList(u8) = .empty; var n: usize = 0; while (n < pane.rows) { if (pane.mode != .tty) if (pane.ovl) |o| if (g == o.row) { var bit = std.mem.splitScalar(u8, o.text, '\n'); var k: usize = 0; while (bit.next()) |ln| : (k += 1) { if (k < skip) continue; if (n >= pane.rows) break; if (n > 0) try out.append(arena, '\n'); try out.appendSlice(arena, ln); n += 1; } skip = 0; g += o.rows; continue; }; if (n > 0) try out.append(arena, '\n'); const vi = g - goff; if (vi >= 0 and @as(usize, @intCast(vi)) < vp.items.len) try out.appendSlice(arena, vp.items[@intCast(vi)]); n += 1; g += 1; } 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); } 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 .{}; }