//! 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. //! //! src/ layout: the core lies flat at src/, and every SUBDIRECTORY is one //! backend (tty/ gui/ lsp/) — so a file being in no directory at all is what //! says it is core, and nothing needs a header to claim it. //! //! Not one key, button or piece of Look syntax is spelled in this file: every //! one of them is a named binding in config.zig, and `hit`/`isPrefix` below are //! the only two matchers. That is so retargeting anything is an edit in one //! file, and so a later builtin can enumerate the bindings the way Help already //! enumerates the leader. 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"); const term_pane = @import("term_pane.zig"); const file_pane = @import("file_pane.zig"); const output_pane = @import("output_pane.zig"); const builtins = @import("builtins.zig"); pub const config = @import("config.zig"); pub const image = @import("image.zig"); pub const dump = @import("dump.zig"); pub const lsp = @import("lsp/lsp.zig"); pub const Platform = enum { tty, gui, web }; pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config").platform)); // The capacities and the two heights that are STRUCTURE, not taste: the // fixed-size pane/column arrays, and the fact that the topbar and a tag are // one row each (nothing here works at any other value). The layout numbers // that ARE taste — the gutter, the line-number prefix, scrolloff, the pane // minimums — live in config.zig with everything else a user retargets. pub const MAX_PANES = 16; pub const MAX_COLS = 6; /// how far back the jump stack remembers. Vim keeps 100; this is a session of /// at most sixteen panes, so the depth that matters is "more visits than you /// can hold in your head" and the oldest entry falls off the bottom. pub const MAX_JUMPS = 64; pub const TOPBAR_H: u16 = 1; pub const BOX_H: u16 = 1; /// A place the keyboard has been: a pane AND a spot in it, which is the whole /// upgrade over the stack of bare pane ids this replaces — Ctrl-o can now /// rewind WITHIN a pane, and a Jumplist row can name a line. /// /// It is SAFE against the pane it names dying: `serial` is the pane's own /// identity, so an entry whose slot has since been handed to a different pane /// reads as dead rather than silently retargeting itself at the newcomer, and /// sync() drops it. What it does not do is outlive the pane — ponytail: a /// location is a place in the SESSION, not on disk, so closing a file forgets /// the entries pointing into it. To make Ctrl-o RE-OPEN a closed file, this /// grows a path field and jumpBy looks it when the pane is gone. pub const Loc = struct { pane: u16, serial: u32, /// 1-based, both — this is the `path:LINE:COL` a look word spells, and /// focusPaneLine takes exactly these. 0 = no spot, just the pane (see /// trackJump: a shell whose cursor is still the program's). line: u32, col: u32, }; const pane_tail = " " ++ config.pane_builtins_str; const file_pane_tail = " " ++ config.file_pane_builtins_str; // Builtins: executing the name (middle-click / Tab) runs it through the ONE // dispatcher (runBuiltin, reached from execute), no matter where the name // appears — and Look and Exec are two of them, so the click itself is a // builtin. One STRUCT per builtin in builtins.zig — name, comment and // body in one place — and this enum is folded out of THAT FILE'S declarations // at comptime, so the enum FIELD NAME is still the user-visible word (the one // in the topbar, the one sitting in a tag, the one Help prints, the one you // execute) and `std.meta.stringToEnum` is still the lookup with no name table // to keep in sync. It lands here rather than in builtins.zig because a // container cannot hold a decl folded out of its own decl list, and here it // sits with the other two comptime folds (builtin_rows, the topbar check). const Builtin = builtins.Builtin(); /// How a `Chord` is SPELLED in the index. The named keys come from Key's OWN /// declarations rather than a table beside them — a new special key names /// itself here, and a wrong name is impossible because there is only one. The /// rest is the printable character; anything else is a compile error, because /// a private-use codepoint cast to a byte would render as silent garbage in a /// listing nobody diffs. fn chordName(comptime c: config.Chord) []const u8 { comptime { const mods = (if (c.ctrl) "C-" else "") ++ (if (c.alt) "A-" else "") ++ (if (c.shift) "S-" else ""); for (@typeInfo(Key).@"struct".decls) |d| { if (@TypeOf(@field(Key, d.name)) != u21) continue; if (@field(Key, d.name) == c.cp) return mods ++ d.name; } if (c.cp < 0x20 or c.cp > 0x7e) @compileError("chord has no name for the index"); return mods ++ &[_]u8{@as(u8, @intCast(c.cp))}; } } /// Every way to run `b` that is NOT its leader path: the chords and buttons /// config binds to it, plus the topbar if it has a word up there (row 0 is a /// click target, and the only shortcut that works in tty mode where SPC /// belongs to the shell). Walked from config's own tables — window_keys' and /// jump_keys' `cmd` columns, look_cmd/exec_cmd, topbar_str — so retargeting a /// binding there re-renders here with nothing to keep in step. /// /// What is NOT here, and cannot be: a chord that reaches no builtin. Alt-n, /// Alt-c, the tty toggle and the 1-2/1-3 cut/paste chords are inline handlers /// with no word to index by. The day one of them becomes a builtin — a struct /// in builtins.zig and a `cmd` column beside its binding — it appears here for /// free, which is the upgrade path rather than a special case here. fn shortcuts(comptime b: Builtin) []const u8 { comptime { @setEvalBranchQuota(20000); var parts: []const []const u8 = &.{}; // both spellings of the four directional moves: the arrow is a real // key someone presses, and the table carries it precisely so it is // discoverable from the builtin as well as the other way round for (config.window_keys) |wk| { if (wk.cmd != b) continue; // the prefix has one spelling and this is it const pfx = chordName(config.window_prefix[0]) ++ " "; parts = parts ++ &[_][]const u8{ pfx ++ chordName(wk.letter), pfx ++ chordName(wk.arrow) }; } for (config.jump_keys) |jk| { if (jk.cmd == b) parts = parts ++ &[_][]const u8{chordName(jk.chord)}; } // the two acme verbs, a key and a mouse button each. Asked of the // BINDING (look_cmd/exec_cmd point at a builtin) rather than of Look // and Exec by name, so pointing look_cmd at Grep moves the row. if (config.look_cmd == b) { for (config.look_key) |k| parts = parts ++ &[_][]const u8{chordName(k)}; parts = parts ++ &[_][]const u8{@tagName(config.look_button) ++ "-click"}; } if (config.exec_cmd == b) { for (config.exec_key) |k| parts = parts ++ &[_][]const u8{chordName(k)}; parts = parts ++ &[_][]const u8{@tagName(config.exec_button) ++ "-click"}; } var it = std.mem.tokenizeScalar(u8, config.topbar_str, ' '); while (it.next()) |w| { if (std.mem.eql(u8, w, @tagName(b))) parts = parts ++ &[_][]const u8{"topbar"}; } var out: []const u8 = ""; for (parts, 0..) |s, i| out = out ++ (if (i > 0) ", " else "") ++ s; return out; } } /// THE BUILTIN INDEX, flattened: one row per builtin — the leader path that /// runs it (null: SPC does not reach it), every other way to run it, and the /// Help line those are rendered into. 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 forty /// two-character paths would be ceremony. /// /// A row per BUILTIN and no longer a row per leader PATH, which is what makes /// Help the complete index instead of a second builtin listing a superset of /// what Help lists. A path-less builtin (Look, Exec, Theme) is a row like any /// other and its empty key column is the information: SPC does not reach this /// one, the last column does — or nothing does, which is also worth knowing. /// The mid-chord filter is the SAME array read with a non-empty prefix, and it /// drops those rows by itself because an empty path starts with nothing. const Row = struct { path: ?[]const u8, cmd: Builtin, line: []const u8 }; pub const builtin_rows: [std.enums.values(Builtin).len]Row = blk: { // one pass per builtin per config table, and the insertion sort below is // O(n²) comptime branches on top of it @setEvalBranchQuota(200000); var rows: [std.enums.values(Builtin).len]Row = undefined; // the widest spaced-out path ("l D" = 3) and the widest name, so the three // columns line up var keyw: usize = 0; var namew: usize = 0; for (std.enums.values(Builtin)) |b| { if (config.leader_path.get(b)) |p| keyw = @max(keyw, 2 * p.len - 1); namew = @max(namew, @tagName(b).len); } for (std.enums.values(Builtin), 0..) |b, i| { var keys: []const u8 = ""; if (config.leader_path.get(b)) |path| { keys = "SPC "; for (path, 0..) |c, k| { if (k > 0) keys = keys ++ " "; keys = keys ++ &[_]u8{c}; } } // the name column is only padded when something follows it: a row // whose builtin has no other shortcut ends at the name, so the listing // carries no trailing whitespace const rest = shortcuts(b); const named = @tagName(b) ++ (if (rest.len == 0) "" else (" " ** (namew - @tagName(b).len)) ++ " " ++ rest); rows[i] = .{ .path = config.leader_path.get(b), .cmd = b, .line = keys ++ (" " ** (4 + keyw - keys.len)) ++ " " ++ named, }; } // insertion sort by path: a group sorts right before what extends it, and // a path-less builtin sorts after every path — DEL is not a path because // leaderKey only ever stores a printable key const last = "\x7f"; for (1..rows.len) |i| { var j = i; while (j > 0 and std.mem.lessThan(u8, rows[j].path orelse last, rows[j - 1].path orelse last)) : (j -= 1) { const t = rows[j]; rows[j] = rows[j - 1]; rows[j - 1] = t; } } break :blk rows; }; // config.topbar_str is a HAND-PICKED subset of the builtins in a fixed order, // not a derivation — see it there for why each word is in or out. This is the // check that a rename cannot silently rot it. comptime { @setEvalBranchQuota(20000); // the tokenizer walks the string one branch per byte, stringToEnum builds a map per name, and both sit behind the decl walk that folds Builtin var it = std.mem.tokenizeScalar(u8, config.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]; } /// `a` IS `b` or lives under it — how Grep drops a pane's directory when /// another pane's directory already covers it fn pathUnder(a: []const u8, b: []const u8) bool { const root = std.mem.trimEnd(u8, b, "/"); if (!std.mem.startsWith(u8, a, root)) return false; return a.len == root.len or a[root.len] == '/'; } 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 ---- // 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 /// Everything a theme repaints. `bg`/`fg` null = leave the host terminal's own /// default cell showing (the native-dark shape); `palette` null = let a child's /// ANSI indices reach the host untranslated. The gutter's move box lives here /// too: it used to be a pair of module constants, but a theme that wants to be /// restrained has to be able to turn the accent DOWN, and the box is the one /// piece of loud chrome on screen. The overlay/drag greys that were also /// hardcoded turned out to be the dark theme's own scroll_track/lineno/tag_fg /// spelled a second time, so they read those fields now instead. pub const Theme = struct { name: []const u8, bg: ?[3]u8, fg: ?[3]u8, tag_bg: [3]u8, tag_fg: [3]u8, box: [3]u8, // move box, focused pane box_dim: [3]u8, // move box, everything else 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, }; /// The three themes pardes ships with, in RING ORDER, which is the one thing /// the files themselves cannot say: `helix` is index 0 and so what boots, and /// NextColor walks from here out into the generated ones. Written here rather /// than in a fourth file because the order IS the information — and it is /// load-bearing, since test/snapshots/theme.snap captures the first three /// steps of the ring by their colors. const curated = struct { pub const helix = @import("themes/helix.zig"); pub const dark = @import("themes/dark.zig"); pub const acme = @import("themes/acme.zig"); }; /// A zig file IS a struct, so the FILES are the list: this walks a container's /// declarations — each one an imported theme file — and copies its `theme` /// value into a real Theme. FIELD BY FIELD rather than by plain coercion, /// because a theme file deliberately imports nothing (it is data, not code) and /// zig will not coerce a whole anonymous struct into a named one; assigning one /// field at a time puts each value in a result location that knows the type, /// which is also what makes a missing field a compile error naming it. /// /// A FUNCTION and not a const for the same reason builtins.all is one: it is /// only ever a signature to whoever walks it, never a value in its own way. fn fold(comptime C: type) []const Theme { comptime { @setEvalBranchQuota(20000); // themes x fields, and each field a @field lookup var out: []const Theme = &.{}; for (@typeInfo(C).@"struct".decls) |d| { var t: Theme = undefined; for (@typeInfo(Theme).@"struct".fields) |f| @field(t, f.name) = @field(@field(C, d.name).theme, f.name); out = out ++ &[_]Theme{t}; } return out; } } /// The ring: ours, then every theme tools/gen_themes.zig exported out of the /// helix and zed sources in vendor/themes (build.zig runs it and hands the /// result over as a module). Adding one is dropping a file in there — there is /// no list here to append to, which is the whole point of folding the files. pub const themes = fold(curated) ++ fold(@import("generated_themes")); // ---- 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. /// /// The text is NOT trusted to be valid UTF-8 — a file pane holds whatever /// bytes are on disk (latin-1 source, an ELF opened by mistake), a path can /// be any bytes at all, and a search row splices both. std's unchecked /// iterator panics on a bad start byte, so decode by hand and paint one /// U+FFFD per undecodable byte (what a terminal does). fn print(s: *Surface, x: u16, y: u16, w: u16, text: []const u8, style: CellStyle) u16 { var col = x; const end = x + w; var i: usize = 0; while (i < text.len) { if (col >= end) break; // n == 0: not a start byte at all. A short tail or a bad // continuation decodes to null the same way — one U+FFFD, one byte. const n = std.unicode.utf8ByteSequenceLength(text[i]) catch 0; const decoded: ?u21 = if (n > 0 and i + n <= text.len) (std.unicode.utf8Decode(text[i .. i + n]) catch null) else null; const cp_slice = if (decoded == null) "\u{FFFD}" else text[i .. i + n]; i += if (decoded == null) 1 else n; const cp = decoded orelse 0xFFFD; 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, /// Ctrl held during the click. Only the left press reads it (ctrl-click = /// goto-definition, the one chord every editor with an LSP has); every /// other button ignores it, because acme's button semantics are already /// the vocabulary here and modifiers are not part of it. ctrl: bool = false, }; 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 `hit()` /// ignores this field unless a binding explicitly asks for it. 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; }; /// Does this press match a binding? Every keymap test in the core goes through /// here, so the modifier rules are written once instead of once per modifier /// (the three is/isC/isA predicates this replaces each spelled out the same /// comparison with a different pair of negations). /// /// A binding is a LIST: most have two spellings that must reach the same arm — /// `h` and Left, `Ctrl-f` and PageDown — and the `or` chain that used to do /// that at every call site is now one loop here. fn hit(key: Key, binding: []const config.Chord) bool { for (binding) |c| { if (key.cp != c.cp or key.ctrl != c.ctrl or key.alt != c.alt) continue; // shift is carried in the codepoint for anything printable (`A` is // `A`, not shift-`a`), so it is consulted ONLY when a binding asks for // it — otherwise every letter binding would newly demand shift be up if (c.shift and !key.shift) continue; return true; } return false; } /// `Pane.sel` is indexed by @intFromEnum(button), so this is the SELECT /// button's slot: where a sweep lands and where the left half of every acme /// chord reads its selection from. Named rather than the bare 0 it used to be /// so that config.select_button is genuinely the only place the choice is /// made. const sel_slot = @intFromEnum(config.select_button); /// The same test for a MODAL PREFIX (`g`, `z`, `m`, `mi`, `]`...). config /// spells those as bare codepoints rather than Chords because pardes stores /// the codepoint itself in pane.pending until the next key completes the /// sequence — so this is the one place the two shapes meet, and it is the same /// comparison the stored byte gets a few lines later. fn isPrefix(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 }, /// a language query the shell ran on a worker has finished. `rows` is /// `+Search`-format text (see lsp.zig) and is borrowed for this call only, /// exactly like `output` bytes. An id the core no longer recognises is a /// stale answer (the pane was closed, or a newer query superseded it) and /// is dropped. lsp_resp: struct { id: u32, rows: []const 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, /// answer a language query OFF the event loop and post the rows back as an /// `lsp_resp` Event. The shell reads the file's path and content off the /// core (like save_file) and must SNAPSHOT them before the worker starts — /// the core keeps editing while this is in flight. lsp: struct { id: u32, kind: lsp.Kind, pane: u8, offset: u32, arg: Buf(128) }, 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, /// set = this is an OUTPUT buffer (acme's +Errors): a file pane with no /// file behind it, showing text the core produced itself. It records the /// COMMAND that opened it, and output_pane.zig's one table turns that into /// every way it differs from a file — ask it, never this field's shape. /// `path` still names it (and gives its directory) so looks inside it /// resolve like anywhere else. output: ?output_pane.Output = null, 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, /// WHICH pane this is, for anything that outlives the pane: slots are /// REUSED (freeSlot hands back the lowest free one), so a remembered id /// alone can silently come to mean an unrelated pane. Handed out by /// Pardes.next_serial and never reused. The one reader is the jump stack. serial: u32 = 0, 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). WHICH command filled /// that buffer is the BUFFER's own record, never a second field here — /// output_pane.resultsFrom is how `]d` asks. 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, /// THE tag coordinate space: byte columns into the WHOLE rendered tag, /// prefix ++ tail (tagText). One space, no conversions — the renderer, the /// mouse and every motion speak it directly. The prefix is live chrome, so /// it is selectable/yankable/executable but READ-ONLY: every edit op /// measures from `edit0` (= tagPrefix().len, the first editable column) and /// does nothing left of it. 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, pub fn cwdSlice(p: *const Pane) []const u8 { return p.cwd[0..p.cwd_len]; } pub 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. pub 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 pub 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 pub 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 }); } } pub fn ensureCursorVisible(pane: *Pane) void { // scrolloff margin, shrunk on short panes so the band stays non-empty const margin: i32 = @min(config.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, config.PREFIX_W)); const hmargin: i32 = @min(config.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 }, /// a left sweep along a pane's TAG row: it drives the tag's own cursor and /// selection (rendered-tag columns) rather than the body's block selection, /// which is what makes a one-line tag select like a line of text tag: struct { id: usize }, /// 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, ctrl: bool = false }, }; pub 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 = config.tty_toggle_default, /// 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, /// THE focus history: where the keyboard has BEEN, oldest first, and `jcur` /// is the entry it is at NOW (so `jumps[jcur]` is always the live spot and /// the entries past it are the ones Ctrl-i walks forward into). Maintained /// in exactly one place — sync() — and read by everything that asks "where /// was I": Back/Forward, Last, the Jumplist buffer, prevFocus when a pane /// closes, Toggleterm, and the directory order a look resolves in. /// /// One list, not two. A jumplist kept beside a focus history is two things /// to keep agreeing, and they would disagree the first time one of them /// forgot a pane the other still names. jumps: [MAX_JUMPS]Loc = undefined, njumps: usize = 0, jcur: usize = 0, /// hands out Pane.serial; monotonic, never reused next_serial: u32 = 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, /// the TOPBAR holds the keyboard, parked at this column of the rendered /// row-0 line. Global like leader_on for the same reason: row 0 is not a /// pane and never will be, so its one piece of focus state cannot live on /// one. `null` = the panes have the keyboard, which is every other frame. topbar_col: ?u16 = null, 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, /// how many `execute` calls are on the stack — see max_exec_depth exec_depth: u8 = 0, /// The one language query in flight. ONE, deliberately: every one of these /// is a keystroke the user is waiting on, so a second press means "I meant /// this one" — the id bump makes the older answer stale and lspResponse /// drops it. A queue would only buy the right to render an answer nobody /// is waiting for any more. /// `arg` rides along only so the buffer the answer opens can record what /// was asked (a rename's new name, a symbol query) — the query itself has /// it in the effect already. lsp_seq: u32 = 0, lsp_wait: ?struct { id: u32, kind: lsp.Kind, pane: usize, arg: Effect.Buf(128) } = 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 { 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 file_pane.open(p, 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, config.topbar_str ++ " Restore {s}", .{d}) catch config.topbar_str; return config.topbar_str; } pub 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. pub fn newPane(p: *Pardes, id: usize) !*Pane { return p.newShell(id, ""); } pub 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 = term_pane.ptyReport; pane.stream.handler.effects.device_attributes = term_pane.ptyDeviceAttrs; p.next_serial += 1; pane.serial = p.next_serial; 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. pub 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.next_serial += 1; pane.serial = p.next_serial; p.panes[id] = pane; return pane; } pub 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) pub 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); } pub 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), .lsp_resp => |r| p.lspResponse(r.id, r.rows), .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, config.tag_image ++ " {s}", .{iv.path}); if (pane.file) |f| { const fmode = if (pane.mode == .insert) config.tag_insert else config.tag_normal; return std.fmt.allocPrint(arena, "{s} {s}", .{ fmode, f.path }); } const mode = switch (pane.mode) { .normal => config.tag_normal, .insert => config.tag_insert, .tty => config.tag_tty, }; return std.fmt.allocPrint(arena, "{s} {s}", .{ mode, pane.cwdSlice() }); } /// the editable tail: the user's edited buffer once touched, else defaults /// (a buffer with nothing to Save gets the plain tail — the table decides) fn curTail(pane: *Pane) []const u8 { if (pane.tag_init) return pane.tag_tail.items; if (pane.file) |f| if (output_pane.fileTraits(f.output).saves) return file_pane_tail; return pane_tail; } /// the tag exactly as it is rendered: prefix ++ tail. THE text tag_col and /// tag_anchor index, so the renderer, the mouse, the motions and the chord /// all read the same bytes at the same columns. fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 { return std.fmt.allocPrint(arena, "{s}{s}", .{ try p.tagPrefix(pane), curTail(pane) }); } /// focus the tag for editing, seeding the tail on first touch and parking /// the cursor at `col` — a column of the RENDERED tag (see tag_col). /// NEGATIVE means the first EDITABLE column, which is where `:` and a /// tagline hop land: a place no click can name, so it needs no sentinel of /// its own and the callers need no prefix length. fn enterTagEdit(p: *Pardes, pane: *Pane, col: i32) void { const edit0: i32 = @intCast((p.tagPrefix(pane) catch return).len); 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 end = edit0 + @as(i32, @intCast(pane.tag_tail.items.len)); pane.tag_col = @intCast(if (col < 0) edit0 else std.math.clamp(col, 0, end)); } 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 (and `y`) acts on: the char selection, /// else the file-ish word under the cursor. Over the WHOLE rendered tag, so /// the path is a word like any other — Enter on it looks it. fn tagChordText(p: *Pardes, pane: *Pane) ?[]const u8 { const text = p.tagText(p.scratch.allocator(), pane) catch return null; if (pane.tag_sel) { const b = tagSelBounds(pane); const hi = @min(b.hi + 1, text.len); return if (hi > b.lo) text[b.lo..hi] else null; } const b = config.wordBounds(text, @min(@as(usize, pane.tag_col), text.len)); return if (b.hi > b.lo) text[b.lo..b.hi] else null; } /// tag-edit key dispatch: a modal one-line editor over the rendered tag, /// sharing the pane's mode — the cursor moves over all of it, edits reach /// only the tail. Newlines are always dropped. fn tagInsertKey(p: *Pardes, pane: *Pane, key: Key) void { if (hit(key, config.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 (hit(key, config.look_key) or hit(key, config.exec_key)) { if (p.tagChordText(pane)) |txt| { pane.tag_sel = false; p.runBuiltin(if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd, p.active, "", txt); } return; } // the prefix is live chrome, not text you own: the cursor may sit in it // (that is how the path selects), but every edit below is measured from // the first EDITABLE column and simply does nothing to the left of it. const edit0: u16 = @intCast((p.tagPrefix(pane) catch return).len); const end: u16 = edit0 + @as(u16, @intCast(pane.tag_tail.items.len)); if (key.text.len > 0) { if (pane.tag_col < edit0) return; // typing inside the path: inert for (key.text) |ch| { if (ch == '\n' or ch == '\r') continue; // one-line tag pane.tag_tail.insert(p.gpa, pane.tag_col - edit0, ch) catch return; pane.tag_col += 1; } return; } switch (key.cp) { Key.backspace => if (pane.tag_col > edit0) { _ = pane.tag_tail.orderedRemove(pane.tag_col - edit0 - 1); pane.tag_col -= 1; }, Key.left => if (pane.tag_col > 0) { pane.tag_col -= 1; }, Key.right => if (pane.tag_col < end) { pane.tag_col += 1; }, Key.home => pane.tag_col = 0, Key.end => pane.tag_col = end, else => {}, } } /// write a helix range back onto the tag cursor + selection: the rendered /// tag's one-line mirror of setPaneRange. fn setTagRange(pane: *Pane, text: []const u8, r: modal.HxRange) void { 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 WHOLE rendered tag as a one-line document (so the path selects /// like any other text, and Enter on it looks it), plus insert entry and /// the acme chords; editing keys stay in insert (`i` then type, like the /// mouse path) and never reach left of `edit0`. fn tagNormalKey(p: *Pardes, pane: *Pane, key: Key) void { const text = p.tagText(p.scratch.allocator(), pane) catch return; 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 (hit(key, config.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 `:`. The same two bindings as // everywhere else (config.look_key / exec_key), so the command line is // `:w` by default. // Nothing under the cursor means nothing ran: the tag keeps focus. if (hit(key, config.look_key) or hit(key, config.exec_key)) { const cmd = if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd; const txt = p.tagChordText(pane) orelse return; const id = p.active; exitTagEdit(pane); // leave the tag FIRST: Del frees the pane p.runBuiltin(cmd, id, "", txt); return; } // y — yank what the chord would run: the selection, else the word under // the cursor. The path is selectable, so this is how you copy it out. if (hit(key, config.tag_yank)) { if (p.tagChordText(pane)) |txt| p.setYank(txt); return; } // insert entry (one line, so I/A are the tail's ends). The prefix is // read-only, so entering insert inside it parks at the first editable // column instead — you can never be typing into the path. if (hit(key, config.insert) or hit(key, config.append) or hit(key, config.insert_line_start) or hit(key, config.insert_line_end)) { const edit0: usize = (p.tagPrefix(pane) catch return).len; pane.tag_col = @intCast(@max(edit0, if (hit(key, config.append)) modal.nextGrapheme(text, cur) else if (hit(key, config.insert_line_start)) 0 else if (hit(key, config.insert_line_end)) text.len else cur)); pane.tag_sel = false; pane.mode = .insert; return; } // h/j/k/l — a tagline is a place in the LAYOUT, so the four letters walk // it: focus the neighbour and land on ITS tagline, still in normal mode, // so tag-to-tag navigation never drops through a body. Runs the SAME // Left/Down/Up/Right builtins `SPC w h/j/k/l` and `Ctrl-w` run, off the // SAME table (config.window_keys) — one focusDir, one binding. Only the // LETTER column: the arrows keep the in-tag grapheme motion below, so // the letters cost nothing. Nothing in that direction = stay put, tag // and all: focusDir left `active` alone, so there is nothing to undo — // EXCEPT upwards, where "nothing" is still something (see below). const dir: ?Builtin = for (config.window_keys) |wk| { if (hit(key, &.{wk.letter})) break wk.cmd; } else null; if (dir) |d| { const from = p.active; p.runBuiltin(d, from, "", null); if (p.active == from) { // above the topmost tagline is the TOPBAR — row 0, the global // one. It is not a pane, so focusDir can never reach it; this // one fallback is what makes `k` walk off the top of the // layout instead of dying there. Only from a tagline: a body's // `SPC w k`/`Ctrl-w k` keeps its pane-to-pane meaning. if (d == .Up) { exitTagEdit(pane); p.topbar_col = 0; } return; } const dest = p.panes[p.active] orelse return; exitTagEdit(pane); // the pane we left is fully out of its tag... p.enterTagEdit(dest, -1); // ...and the tail's start is the new park if (dest.tag_edit) dest.mode = .normal; return; } if (lineMotion(text, cur, key)) |r| setTagRange(pane, text, r); } /// the one-line normal-mode motion vocabulary as a helix range over `text`, /// or null when `key` is not one of them: arrows by grapheme, `0`/`$`/`^` /// (Home/End) to the ends, and the word motions, which select the span they /// traverse exactly like the body's. A pane's tag and the TOPBAR are the /// same one-line normal mode over different bytes, so the keys are read in /// one place; only `h`/`l` differ — a tagline spends them on the layout, /// the topbar has no layout — and each caller answers those itself. fn lineMotion(text: []const u8, cur: usize, key: Key) ?modal.HxRange { const target: ?usize = if (hit(key, config.line_move_left)) modal.prevGrapheme(text, cur) else if (hit(key, config.line_move_right)) modal.nextGrapheme(text, cur) else if (hit(key, config.line_start)) 0 else if (hit(key, config.line_end)) modal.prevGrapheme(text, text.len) else if (hit(key, config.line_first_nonws)) modal.firstNonWs(text) else null; if (target) |t| return .{ .anchor = t, .head = t }; const wt: ?modal.WordTarget = if (hit(key, config.next_word_start)) .next_word_start else if (hit(key, config.prev_word_start)) .prev_word_start else if (hit(key, config.next_word_end)) .next_word_end else if (hit(key, config.next_long_word_start)) .next_long_word_start else if (hit(key, config.prev_long_word_start)) .prev_long_word_start else if (hit(key, config.next_long_word_end)) .next_long_word_end else null; const t = wt orelse return null; return modal.hxWordMove(text, .{ .anchor = cur, .head = modal.nextGrapheme(text, cur) }, 1, t); } /// normal mode ON the topbar — row 0, where `k` off a top-row tagline /// lands. The same one-line vocabulary a tag has (lineMotion), plus `h`/`l` /// as plain grapheme motion: up here they have no neighbouring window to /// walk to, so they cost nothing. Enter/Tab runs the word under the cursor /// through the very dispatch a middle click on it uses, and `j` drops back /// onto a tagline. The bar is chrome with no tail of its own, so there is /// deliberately no insert mode and no selection: focus, move, run, leave. fn topbarKey(p: *Pardes, key: Key) void { var tb_buf: [1200]u8 = undefined; const bar = p.topbar(&tb_buf); // the line GROWS a `Restore ` word the moment a dump lands, so // the column is clamped against the live line on every use const cur: usize = @min(@as(usize, p.topbar_col orelse return), bar.len); if (hit(key, config.escape)) { p.topbar_col = null; // the active pane still has its body focus return; } // the chord: run the word under the cursor, exactly as a middle click // on it does. Leave the bar FIRST — `Kill` lives up here and tears the // session down, the same hazard the pane-tag chord has with `Del`. if (hit(key, config.look_key) or hit(key, config.exec_key)) { const word = wordAtCol(bar, @intCast(cur)); p.topbar_col = null; if (word.len > 0) _ = p.execute(p.active, word); return; } // j — back down onto a tagline, the mirror of the k that got you here: // the pane you came from if it still holds the top row, else the // leftmost pane that does. Recomputed, never remembered, so a pane // deleted while the bar had focus strands nobody. if (hit(key, config.topbar_down)) { var dest: ?usize = null; for (p.panes, 0..) |slot, i| { if (slot == null or p.rects[i].y != TOPBAR_H) continue; if (i == p.active) { dest = i; break; } if (dest == null or p.rects[i].x < p.rects[dest.?].x) dest = i; } const d = dest orelse return; const pane = p.panes[d].?; p.topbar_col = null; p.active = d; p.enterTagEdit(pane, -1); if (pane.tag_edit) pane.mode = .normal; return; } const col: ?usize = if (hit(key, config.topbar_left)) modal.prevGrapheme(bar, cur) else if (hit(key, config.topbar_right)) modal.nextGrapheme(bar, cur) else if (lineMotion(bar, cur, key)) |r| modal.hxCursor(bar, r) else null; // never past the last cell: there is nothing to append up here, so the // block cursor stays ON a character (which is where `$` already lands) if (col) |c| p.topbar_col = @intCast(@min(c, modal.prevGrapheme(bar, bar.len))); } /// tag + '\n' + body: the full selectable pane text (row 0 = the tag) fn paneText(p: *Pardes, pane: *Pane) ![]u8 { const arena = p.scratch.allocator(); return std.fmt.allocPrint(arena, "{s}\n{s}", .{ try p.tagText(arena, pane), try p.bodyText(arena, pane) }); } /// 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 word under it — /// file-ish, or a whole `` @`...` `` run (config.wordBounds is the spelling) 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; const b = config.wordBounds(line, col); if (b.hi > b.lo) { sl.c0 = @intCast(b.lo); sl.c1 = @intCast(b.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, config.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)); } /// What an execute takes as its ARGUMENT: text selected ANYWHERE (acme — /// the chord argument is whatever is selected, in any window), searched /// `first` (the pane the execute happened in), then the active pane (making /// a selection focuses its pane, so it holds the most recent one), then /// slot order. Per pane a kept left selection wins, else an explicit modal /// (v/x, n/N) one. Scratch-owned: dead at the next arena reset, so a caller /// that keeps it (the 2-1 chord) copies. fn heldSelection(p: *Pardes, first: usize) ?[]const u8 { var k: usize = 0; while (k < p.panes.len + 2) : (k += 1) { const i = switch (k) { 0 => first, 1 => p.active, else => k - 2, }; if (k > 0 and (i == first or (k > 1 and i == p.active))) continue; const t = p.panes[i] orelse continue; if (t.sel[sel_slot].state == .done) { if (p.selectionText(t, t.sel[sel_slot]) catch null) |tx| return tx; } else if ((t.vsel.active and t.vsel.explicit) or t.msel.active) { if (p.currentSelText(t)) |tx| return tx; } } return null; } /// Splice a chord argument onto what the gesture pointed at. acme's 2-1 /// chord means "run this WITH that", and that is a command LINE: `Grep` /// plus a held `foo` is the same string `Grep foo` you could have typed, /// so it goes down the one path that already knows how to split a name /// from its tail. The alternative — a second argument threaded past the /// dispatcher — is what used to be here, and it could not survive Exec /// becoming an ordinary builtin with one argument slot like every other. /// Scratch-owned; `txt` itself when there is nothing to splice. fn withArg(p: *Pardes, txt: []const u8, arg: ?[]const u8) []const u8 { const a = std.mem.trim(u8, arg orelse return txt, " \t\r\n"); if (a.len == 0) return txt; const t = std.mem.trim(u8, txt, " \t\r\n"); return std.fmt.allocPrint(p.scratch.allocator(), "{s} {s}", .{ t, a }) catch txt; } fn handleKey(p: *Pardes, key: Key) void { // the topbar holds the keyboard (`k` off the topmost tagline): row 0 is // its own one-line normal mode and owns every key until Esc or a chord. // Before the pane lookup because it needs no pane — that is the point. if (p.topbar_col != null) return p.topbarKey(key); 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 or the // arrows), running the SAME builtins `SPC w h/j/k/l` runs off the SAME // table a tagline's own h/j/k/l reads. 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; for (config.window_keys) |wk| { if (hit(key, &.{ wk.letter, wk.arrow })) return p.runBuiltin(wk.cmd, p.active, "", null); } return; } // insert mode owns Ctrl-w (delete word backward, helix); the focus // prefix keeps normal/tty if (hit(key, config.window_prefix) 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 (hit(key, config.new_shell_below)) { 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; } // the jump chords, global for the same reason: Ctrl-o has to get you // out of wherever you are, including a pane in raw tty mode. Above the // acme chords below, which is what makes Ctrl-i reachable at all — on // a kitty-protocol host it arrives as its own key, and where it does // not it IS Tab (0x09) and falls through to Exec, see config.jump_keys. for (config.jump_keys) |jk| { if (hit(key, &.{jk.chord})) return p.runBuiltin(jk.cmd, p.active, "", null); } if (hit(key, config.pane_to_new_column)) { 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 hit(key, config.tty_toggle_alt); 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 (hit(key, config.search_submit) or hit(key, config.escape))) { if (hit(key, config.search_submit)) 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; } // the acme chords in the body — look at / execute (config.look_key and // exec_key, Enter and Tab by default) 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 (hit(key, config.look_key) or hit(key, config.exec_key))) { const cmd = if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd; 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.runBuiltin(cmd, p.active, "", txt); return; } } var sel = cursorWordSel(pane); p.expandSel(pane, &sel); const word = p.selectionText(pane, sel) catch return; p.runBuiltin(cmd, p.active, "", word); 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 (hit(key, config.paste_after) or hit(key, config.paste_before))) { p.normalPaste(pane, hit(key, config.paste_before)); return; } switch (pane.mode) { .normal => p.handleNormal(pane, key), .insert => { if (hit(key, config.escape)) { 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 == config.leader_help) return output_pane.openHelp(p, 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]; // a path-less builtin is a row here too (it is the same index Help // reads), and SPC cannot reach one — so both scans skip it for (builtin_rows) |row| { if (std.mem.eql(u8, row.path orelse continue, pfx)) return p.runBuiltin(row.cmd, p.active, "", null); } for (builtin_rows) |row| { if (std.mem.startsWith(u8, row.path orelse continue, 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. pub 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 >= config.MINH * 2) config.MINH else 1; const lo = y +| min_each; const hi = y + h - min_each; if (lo > hi) return y + h / 2; return std.math.clamp(cur_y, lo, hi); } fn movePlacement(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) ?MovePlacement { const src = p.layoutFindTerm(id) orelse return null; const tc = p.targetColumn(cur_x); if (tc == src.col and p.col_n[src.col] == 1) return null; if (tc == src.col) { const sr = p.rects[id]; if (cur_y >= sr.y and cur_y < sr.y + sr.h) return null; } var heights: [MAX_PANES]u16 = @splat(0); for (0..p.ncol) |c| { for (0..p.col_n[c]) |k| { const pid = p.col_terms[c][k]; heights[pid] = p.rects[pid].h; } } if (p.col_n[src.col] > 1) { const sib = if (src.idx > 0) p.col_terms[src.col][src.idx - 1] else p.col_terms[src.col][src.idx + 1]; heights[sib] +|= p.rects[id].h; } var y: u16 = TOPBAR_H; var last: ?MovePlacement = null; for (0..p.col_n[tc]) |k| { const pid = p.col_terms[tc][k]; if (pid == id) continue; const h = heights[pid]; const row = splitRowForExtent(y, h, cur_y) orelse { y +|= h; continue; }; const placement: MovePlacement = .{ .preview_col = tc, .above_id = pid, .row = row, .above_y = y, .above_h = h, }; last = placement; if (cur_y < y + h) return placement; y +|= h; } return last; } /// drop pane `id` below the pane under the cursor, converting on-screen /// heights to weights so ONLY the split pane changes size fn moveTerm(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) void { const placement = p.movePlacement(id, cur_x, cur_y) orelse return; const src = p.layoutFindTerm(id) orelse return; const source_multi = p.col_n[src.col] > 1; var heights: [MAX_PANES]u16 = @splat(0); for (0..p.ncol) |c| { for (0..p.col_n[c]) |k| { const pid = p.col_terms[c][k]; heights[pid] = p.rects[pid].h; } } p.layoutRemove(id); if (source_multi and src.col < p.ncol and p.col_n[src.col] > 0) { const sib = if (src.idx > 0) p.col_terms[src.col][src.idx - 1] else p.col_terms[src.col][src.idx]; heights[sib] +|= p.rects[id].h; } const af = p.layoutFindTerm(placement.above_id) orelse return; const upper_h = @max(1, placement.row -| placement.above_y); const lower_h = @max(1, placement.above_h -| upper_h); heights[placement.above_id] = upper_h; heights[id] = lower_h; p.layoutInsert(af.col, af.idx + 1, id); p.setColumnWeights(af.col, &heights); if (source_multi and src.col < p.ncol and src.col != af.col) p.setColumnWeights(src.col, &heights); } fn setColumnWeights(p: *Pardes, col: usize, heights: *const [MAX_PANES]u16) void { if (col >= p.ncol) return; for (0..p.col_n[col]) |k| { const pid = p.col_terms[col][k]; if (p.panes[pid]) |pane| pane.vweight = @floatFromInt(@max(1, heights[pid])); } } // ---- helix-modal normal mode ---- const PaneLines = struct { lines: []const []const u8, row0: i32, // absolute row of lines[0] }; /// The lines the cursor moves over, absolute rows. File: all content lines. /// Terminal: the whole history+active grid 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 term_pane.shellRows(p, 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(config.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: enum { top, center, bottom }, cnt: usize) void { const margin: i32 = @min(config.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) { .top => top + margin + n, .center => top + @divTrunc(last_vis, 2), .bottom => 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. A buffer the table marks `dismiss` (every output // buffer today) instead takes Esc as "never mind" and closes: Esc IS // Del, the same builtin its tag offers. if (hit(key, config.escape)) { if (pane.file) |f| if (output_pane.fileTraits(f.output).dismiss) return p.runBuiltin(.Del, p.active, "", null); 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 == config.goto_prefix) { pane.pending = 0; if (hit(key, config.goto_file_start)) { const line = if (has_count) @min(cnt - 1, max_line) else 0; return pointMove(pane, pl, text, range, modal.lineStartOffset(text, line)); } if (hit(key, config.goto_last_line)) return pointMove(pane, pl, text, range, modal.lineStartOffset(text, max_line)); if (hit(key, config.goto_line_start)) return pointMove(pane, pl, text, range, modal.lineStartOffset(text, modal.hxLineOf(text, cur))); if (hit(key, config.goto_line_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 (hit(key, config.goto_first_nonws)) { 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 (hit(key, config.goto_line_down)) return verticalMove(pane, pl, text, range, true, cnt); if (hit(key, config.goto_line_up)) return verticalMove(pane, pl, text, range, false, cnt); if (hit(key, config.goto_column)) { 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 (hit(key, config.goto_view_top)) return gotoWindow(pane, pl, text, range, .top, cnt); if (hit(key, config.goto_view_center)) return gotoWindow(pane, pl, text, range, .center, cnt); if (hit(key, config.goto_view_bottom)) return gotoWindow(pane, pl, text, range, .bottom, cnt); // helix's five LSP gotos, all under `g` and nowhere else. They ask // and return; the answer arrives later as an lsp_resp Event and // either jumps or opens a list (lspResponse). if (hit(key, config.goto_definition)) return p.lspRequest(p.active, .definition, ""); if (hit(key, config.goto_declaration)) return p.lspRequest(p.active, .declaration, ""); if (hit(key, config.goto_type_definition)) return p.lspRequest(p.active, .type_definition, ""); if (hit(key, config.goto_implementation)) return p.lspRequest(p.active, .implementation, ""); if (hit(key, config.goto_references)) return p.lspRequest(p.active, .references, ""); return; } if (pane.pending == config.view_prefix) { pane.pending = 0; if (hit(key, config.view_top)) { pane.scrollBy(pane.cur_row - pane.scroll()); pane.ensureCursorVisible(); return; } if (hit(key, config.view_center)) { pane.scrollBy(pane.cur_row - (pane.scroll() + @divTrunc(@as(i32, pane.rows), 2) - 1)); pane.ensureCursorVisible(); return; } if (hit(key, config.view_bottom)) { pane.scrollBy(pane.cur_row - (pane.scroll() + @as(i32, pane.rows) - 1)); pane.ensureCursorVisible(); return; } if (hit(key, config.view_scroll_down)) return scrollViewMove(pane, pl, text, range, 1); if (hit(key, config.view_scroll_up)) return scrollViewMove(pane, pl, text, range, -1); if (hit(key, config.half_page_down)) return halfPageMove(pane, pl, text, range, true); if (hit(key, config.half_page_up)) return halfPageMove(pane, pl, text, range, false); if (hit(key, config.page_down)) return scrollViewMove(pane, pl, text, range, @as(i32, pane.rows)); if (hit(key, config.page_up)) return scrollViewMove(pane, pl, text, range, -@as(i32, pane.rows)); return; } if (pane.pending == config.find_char_fwd or pane.pending == config.find_char_back or pane.pending == config.till_char_fwd or pane.pending == config.till_char_back) { 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 == config.find_char_fwd or op == config.till_char_fwd, op == config.till_char_fwd or op == config.till_char_back, cnt); } if (pane.pending == config.replace_prefix) { 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 == config.match_prefix) { if (pane.pending2 == 0) { if (hit(key, config.match_bracket)) { 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 (isPrefix(key, config.match_inside) or isPrefix(key, config.match_around) or isPrefix(key, config.surround_add) or isPrefix(key, config.surround_replace) or isPrefix(key, config.surround_delete)) { 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 == config.surround_replace 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) { config.match_inside, config.match_around => return p.textobjectSelect(pane, pl, key.cp, sub == config.match_around), config.surround_add => return p.surroundAdd(pane, key.cp), config.surround_delete => return p.surroundDelete(pane, pl, key.cp), config.surround_replace => return p.surroundReplace(pane, pl, from, key.cp), else => return, } } if (pane.pending == config.next_prefix or pane.pending == config.prev_prefix) { const fwd = pane.pending == config.next_prefix; pane.pending = 0; if (hit(key, config.goto_paragraph)) { const r2 = modal.hxParaMove(text, range, cnt, fwd, pane.select); return setPaneRange(pane, pl, text, r2, false); } if (hit(key, config.add_newline)) return p.addNewline(pane, fwd, cnt); // ]d / [d: step the diagnostics list, asking for one if it is not // up yet. The list is an ordinary results buffer, so "step" is the // n/N stepper — `d` differs from `n` only in what it guarantees is // showing. ]D / [D jump to the last/first, helix's pair. if (hit(key, config.goto_diagnostic)) { if (output_pane.resultsFrom(p, pane, .{ .query = .diagnostics }) and p.searchStep(p.active, if (fwd) 1 else -1)) return; return p.lspRequest(p.active, .diagnostics, ""); } if (hit(key, config.goto_diagnostic_end)) { if (!output_pane.resultsFrom(p, pane, .{ .query = .diagnostics })) return p.lspRequest(p.active, .diagnostics, ""); if (!fwd) { // [D = first pane.search_row = null; _ = p.searchStep(p.active, 1); return; } const rp = p.panes[pane.search_pane orelse return] orelse return; const rf = rp.file orelse return; pane.search_row = std.mem.count(u8, rf.content, "\n"); // one past the last _ = p.searchStep(p.active, -1); return; } return; } // prefix setters (the count survives into the continuation) if (isPrefix(key, config.goto_prefix) or isPrefix(key, config.view_prefix) or isPrefix(key, config.find_char_fwd) or isPrefix(key, config.find_char_back) or isPrefix(key, config.till_char_fwd) or isPrefix(key, config.till_char_back) or isPrefix(key, config.replace_prefix) or isPrefix(key, config.next_prefix) or isPrefix(key, config.prev_prefix)) { pane.pending = key.cp; pane.count = saved_count; return; } if (isPrefix(key, config.match_prefix)) { pane.pending = config.match_prefix; pane.pending2 = 0; pane.pending_ch = 0; return; } // movement if (hit(key, config.move_left)) { var t = cur; for (0..cnt) |_| t = modal.prevGrapheme(text, t); return pointMove(pane, pl, text, range, t); } if (hit(key, config.move_right)) { var t = cur; for (0..cnt) |_| t = modal.nextGrapheme(text, t); return pointMove(pane, pl, text, range, t); } if (hit(key, config.move_down)) return verticalMove(pane, pl, text, range, true, cnt); if (hit(key, config.move_up)) return verticalMove(pane, pl, text, range, false, cnt); if (hit(key, config.next_word_start)) return wordMove(pane, pl, text, range, cnt, .next_word_start); if (hit(key, config.prev_word_start)) return wordMove(pane, pl, text, range, cnt, .prev_word_start); if (hit(key, config.next_word_end)) return wordMove(pane, pl, text, range, cnt, .next_word_end); if (hit(key, config.next_long_word_start)) return wordMove(pane, pl, text, range, cnt, .next_long_word_start); if (hit(key, config.prev_long_word_start)) return wordMove(pane, pl, text, range, cnt, .prev_long_word_start); if (hit(key, config.next_long_word_end)) return wordMove(pane, pl, text, range, cnt, .next_long_word_end); if (hit(key, config.repeat_find)) { // 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 == config.find_char_fwd or pane.find_op == config.till_char_fwd; const till = pane.find_op == config.till_char_fwd or pane.find_op == config.till_char_back; 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 (hit(key, config.line_start)) return pointMove(pane, pl, text, range, modal.lineStartOffset(text, modal.hxLineOf(text, cur))); if (hit(key, config.line_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 (hit(key, config.line_first_nonws)) { 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 (hit(key, config.goto_line)) { // 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 (hit(key, config.half_page_down)) return halfPageMove(pane, pl, text, range, true); if (hit(key, config.half_page_up)) return halfPageMove(pane, pl, text, range, false); if (hit(key, config.page_down)) return scrollViewMove(pane, pl, text, range, @as(i32, pane.rows)); if (hit(key, config.page_up)) return scrollViewMove(pane, pl, text, range, -@as(i32, pane.rows)); // insert entry if (hit(key, config.insert)) return p.enterInsert(pane, .at, cnt); if (hit(key, config.append)) return p.enterInsert(pane, .append, cnt); if (hit(key, config.insert_line_start)) return p.enterInsert(pane, .line_start, cnt); if (hit(key, config.insert_line_end)) return p.enterInsert(pane, .line_end, cnt); if (hit(key, config.open_below)) return p.enterInsert(pane, .open_below, cnt); if (hit(key, config.open_above)) return p.enterInsert(pane, .open_above, cnt); // selections if (hit(key, config.select_mode)) { 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 (hit(key, config.select_line)) return lineSelect(pane, pl, text, range, cnt); if (hit(key, config.select_line_bounds)) return lineBoundsSelect(pane, pl, text, range); if (hit(key, config.shrink_to_line_bounds)) return shrinkSelToLineBounds(pane, pl, text, range); if (hit(key, config.collapse_selection)) return setPaneRange(pane, pl, text, .{ .anchor = cur, .head = cur }, false); if (hit(key, config.flip_selection)) { 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 (hit(key, config.select_all)) return setPaneRange(pane, pl, text, .{ .anchor = 0, .head = text.len }, false); // edits if (hit(key, config.delete)) return p.normalDelete(pane, true); if (hit(key, config.delete_noyank)) return p.normalDelete(pane, false); if (hit(key, config.change)) return p.normalChange(pane); if (hit(key, config.yank)) return p.normalYank(pane); if (hit(key, config.replace_with_yank)) return p.normalReplaceYank(pane); if (hit(key, config.switch_case)) return p.normalCase(pane, .toggle); if (hit(key, config.to_lowercase)) return p.normalCase(pane, .lower); if (hit(key, config.to_uppercase)) return p.normalCase(pane, .upper); if (hit(key, config.join_lines)) return p.normalJoin(pane); if (hit(key, config.indent)) return p.normalIndent(pane, cnt, true); if (hit(key, config.unindent)) return p.normalIndent(pane, cnt, false); // helix's `=` (format_selections) — its neighbour on the keyboard and // in the keymap. Unbound in pardes until now. if (hit(key, config.format)) return p.lspRequest(p.active, .format, ""); if (hit(key, config.increment)) return p.normalAdjustNumber(pane, @intCast(cnt)); if (hit(key, config.decrement)) return p.normalAdjustNumber(pane, -@as(i64, @intCast(cnt))); if (hit(key, config.undo)) return p.doUndo(pane); if (hit(key, config.redo)) 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 (hit(key, config.leader)) { 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 (hit(key, config.command_line)) { p.enterTagEdit(pane, -1); 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 (hit(key, config.search)) return p.startSearch(pane, config.search_marker); if (hit(key, config.search_next) or hit(key, config.search_prev)) { const delta: i32 = if (hit(key, config.search_next)) 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 ---- const Search = enum { text, find, grep }; /// `/` (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. pub 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; // tag_col is a RENDERED-tag column, so the end of the tail is past the // prefix (search_at stays a tail offset — it slices tag_tail) pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + 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 for NAMES, " Grep /" for /// CONTENTS, " /" reads the pane's own 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; if (std.mem.startsWith(u8, armed, config.rename_marker)) return p.lspRequest(id, .rename, armed[slash + 1 ..]); if (std.mem.startsWith(u8, armed, config.symbol_marker)) return p.lspRequest(id, .workspace_symbols, armed[slash + 1 ..]); const kind: Search = if (std.mem.startsWith(u8, armed, config.find_marker)) .find else if (std.mem.startsWith(u8, armed, config.grep_marker)) .grep else .text; p.runSearch(id, armed[slash + 1 ..], kind); } /// 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. /// grep — the CONTENTS of every file under every pane's directory, /// walked like `grep -R`. Rows are `path:LINE:COL text`, the path /// relative to THIS pane's directory (absolute for a hit outside it). /// No matches = an empty buffer. pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search) 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 (kind == .grep) { // One walk per PLACE the session is open on: every pane's // directory, minus the ones another pane's already contains, so a // tree two panes sit in is greped once and a pane deep inside // another's tree adds nothing. Slot order, so the same session // gives the same buffer twice running. var roots: [MAX_PANES][]const u8 = undefined; var nroots: usize = 0; for (p.panes) |slot| { const pp = slot orelse continue; const d = if (pp.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pp.cwdSlice(); var covered = false; var k: usize = 0; while (k < nroots) { if (pathUnder(d, roots[k])) { covered = true; break; } if (pathUnder(roots[k], d)) { // this one swallows a root already kept nroots -= 1; roots[k] = roots[nroots]; continue; } k += 1; } if (covered) continue; roots[nroots] = d; nroots += 1; } for (roots[0..nroots]) |r| look.grep(arena, p.gpa, r, dir, pat, &out); } else if (kind == .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 == null else false; var idbuf: [16]u8 = undefined; // relative to `dir`, like Find's rows — and `dir` IS this file's // own directory, so that is exactly its basename const loc: []const u8 = if (has_path) std.fs.path.basename(pane.file.?.path) else std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{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; // every search opens its OWN buffer, even from the same pane: the // earlier lists stay open at their sizes and the new one stacks // directly below this pane, taking its rows from here (placeDoc). // search_pane is the newest, so n/N step the search just run; focus // stays here. const free = p.freeSlot() orelse { p.gpa.free(content); return; }; // the buffer records WHICH search filled it, pattern and all: Find and // Grep are builtins (words you can execute), the bare `/` is a key const from: output_pane.Origin = switch (kind) { .text => .search, .find => .{ .cmd = .Find }, .grep => .{ .cmd = .Grep }, }; const np = output_pane.open(p, free, dir, from, pat, 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 /// ACT on it — which of the two acme verbs that is comes from the buffer's /// own traits, so this is one motion over "a list of things you can run", /// not two kinds of stepping. A location list (every search, the jumplist, /// every language answer) Looks the leading `path:LINE:COL` word; a command /// list (ThemeSel) Execs the whole row. 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; // one question covers both hazards: a freed slot can be reused by an // unrelated pane, and a buffer of PROSE has nowhere to step to const tr = output_pane.fileTraits(rf.output); if (!tr.steps) 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)); // Both arms are the BUILTIN, run on the results pane — the same call a // middle or right click on that row would make, so a stepped row and a // clicked row can never drift apart. A command row goes whole (its // argument is the tail after the name); a location row is cut to the // leading file-ish word, since the rest of it is the matched text. if (tr.executes) { p.runBuiltin(config.exec_cmd, rid, "", std.mem.trim(u8, ln, " \t\r")); } else { var hi: usize = 0; while (hi < ln.len and config.isFileChar(ln[hi])) hi += 1; p.runBuiltin(config.look_cmd, rid, "", ln[0..hi]); } // 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; } /// Ask the backend something about the symbol under the cursor. Only a /// real file can be asked: a terminal's rows are a program's output and an /// output buffer is our own text, neither of which has a language behind /// it. Unsupported kinds never get here (the keymap drops them), so a /// backend that answers nothing simply never opens a buffer. pub fn lspRequest(p: *Pardes, id: usize, kind: lsp.Kind, arg: []const u8) void { if (!lsp.supports.contains(kind)) return; const pane = p.panes[id] orelse return; // `status` is about the BACKEND, not about a document, so it answers // from ANY pane — a terminal, a +Search, anywhere. That matters // precisely when the pane you are sitting in is the thing going wrong. // Every other kind needs a real file: a terminal's rows are a // program's output and an output buffer is our own text. if (kind != .status) { const f = pane.file orelse return; if (f.output != null) return; } if (arg.len > 128) return; // the effect's arg is a Buf(128) const off = if (pane.file) |f| modal.hxOff(f.content, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)), }) else 0; p.lsp_seq +%= 1; p.lsp_wait = .{ .id = p.lsp_seq, .kind = kind, .pane = id, .arg = .from(arg) }; p.emit(.{ .lsp = .{ .id = p.lsp_seq, .kind = kind, .pane = @intCast(id), .offset = @intCast(off), .arg = .from(arg), } }); } /// A worker answered. Rows are `+Search` format with ABSOLUTE paths, so /// both dispositions below are the ordinary look path: /// one row, a goto -> jump straight there (helix jumps on a single /// location and shows a picker on several) /// anything else -> an output buffer, which n/N already steps. That /// buffer IS the picker; there was never one to write. pub fn lspResponse(p: *Pardes, id: u32, rows: []const u8) void { const w = p.lsp_wait orelse return; if (w.id != id) return; // superseded by a newer press, or the pane died p.lsp_wait = null; const pane = p.panes[w.pane] orelse return; if (rows.len == 0) return; const from: output_pane.Origin = .{ .query = w.kind }; const nrows = std.mem.count(u8, rows, "\n"); if (output_pane.traits(from).jumps and nrows == 1) { const ln = std.mem.trimEnd(u8, rows, "\n"); var hi: usize = 0; while (hi < ln.len and config.isFileChar(ln[hi])) hi += 1; // exactly what a `/` result row does when n steps onto it: look the // `path:LINE:COL` token from the pane that asked, so placement, // dedup-onto-an-open-pane and centering are the ONE look path. // (helix would also push its jumplist here; pardes has none, so // there is nothing to push — do not read this as one.) return p.lookAt(w.pane, ln[0..hi]); } const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); const content = p.gpa.dupe(u8, rows) catch return; const free = p.freeSlot() orelse { p.gpa.free(content); return; }; const np = output_pane.open(p, free, dir, from, w.arg.slice(), content) catch { p.gpa.free(content); return; }; p.placeDoc(w.pane, free, np); p.active = w.pane; // prose is not a list of locations: n/N over a hover blurb would step // to nowhere, so only stepping buffers arm the stepper — and WHICH // query filled it is now the buffer's own record, not a field here. if (output_pane.traits(from).steps) { pane.search_pane = free; pane.search_row = null; } } /// 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). Deliberately only THIS pane's directory, /// where the look itself walks every pane's: the motion resolves every /// token it steps over, so the other directories would cost tokens × /// panes realpaths on a single keystroke — and a token the motion skips /// is still openable by looking it, which is all n/N is a shortcut for. 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 = term_pane.shellRows(p, 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 file_pane.setContent(p, 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 (hit(key, config.insert_backspace_alias)) return p.handleInsert(pane, .{ .cp = Key.backspace }); if (hit(key, config.insert_enter_alias)) return p.handleInsert(pane, .{ .cp = Key.enter }); if (hit(key, config.insert_delete_alias)) 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 (hit(key, config.delete_word_backward)) { // 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 (hit(key, config.delete_word_forward)) { // 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 (hit(key, config.kill_to_line_start)) { 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 (hit(key, config.kill_to_line_end)) { 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]; // a yanked BLANK line is "\n": the block is empty and lineCount // says 0 lines, but it still pastes as one (empty) line — without // the floor every `n - 1` below underflows and panics. const n = @max(1, 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 ---- /// 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 = term_pane.snap(p, 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; file_pane.setContent(p, f, prev.content); file_pane.restoreSnap(pane, f, prev); return; } if (pane.ed_undo.items.len == 0) return; const cur = term_pane.snap(p, pane) orelse return; pane.ed_redo.append(p.gpa, cur) catch { if (cur.ovl) |o| p.gpa.free(o.text); return; }; term_pane.restoreSnap(p, 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; file_pane.setContent(p, f, next.content); file_pane.restoreSnap(pane, f, next); return; } if (pane.ed_redo.items.len == 0) return; const cur = term_pane.snap(p, pane) orelse return; pane.ed_undo.append(p.gpa, cur) catch { if (cur.ovl) |o| p.gpa.free(o.text); return; }; term_pane.restoreSnap(p, pane, pane.ed_redo.pop() orelse return); } 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 execute+select chord abandons a pending // chord argument if (m.kind == .press and !(m.button == config.select_button and p.drag == .select and p.drag.select.button == config.exec_button)) { if (p.chord_arg) |a| { p.gpa.free(a); p.chord_arg = null; } } // ...and any press at all takes the keyboard back off the topbar: the // mouse names where focus goes, so leaving a cursor parked on row 0 // while you click into a pane would just be a lie if (m.kind == .press) p.topbar_col = 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) -config.wheel_rows else config.wheel_rows); }, .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 + (if (m.button == .wheel_right) config.wheel_cols else -config.wheel_cols)); }, config.select_button => switch (m.kind) { .press => { // acme 2-1: a select press during an EXECUTE drag captures // a selection (heldSelection) as the execute's argument; // the execute drag keeps running to its release, which // consumes it. if (p.drag == .select and p.drag.select.button == config.exec_button) { if (p.heldSelection(p.drag.select.id)) |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 select tap during a LOOK drag cancels the // look; the rest of the gesture is inert if (p.drag == .select and p.drag.select.button == config.look_button) { if (p.panes[p.drag.select.id]) |t| t.sel[@intFromEnum(config.look_button)].state = .none; p.drag = .none; return; } if (mrow < TOPBAR_H) return; // topbar: a select click is 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 + config.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 + config.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 press on the tag row: focus the tag AT that // column (a rendered-tag column — the mouse and the tag // agree, no clamp into the tail) and anchor a sweep, so // dragging selects any span of it, path included p.active = id; p.enterTagEdit(pane, @as(i32, mcol) - @as(i32, r.x + config.GUTTER)); pane.tag_anchor = pane.tag_col; p.drag = .{ .tag = .{ .id = id } }; } else { p.active = id; exitTagEdit(pane); // clicking the body leaves tag editing const sc: i32 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER); const v: i32 = @as(i32, mrow) - @as(i32, r.y); pane.sel[sel_slot] = .{ .state = .dragging, .c0 = sc, .c1 = sc, .r0 = v, .r1 = v }; // Ctrl rides on the drag rather than firing here: the // click does not place the modal cursor until RELEASE // (dragRelease), and a goto asked at press time would // answer about wherever the cursor happened to be. p.drag = .{ .select = .{ .id = id, .button = config.select_button, .ctrl = m.ctrl } }; } }, .drag => p.dragUpdate(mcol, mrow), .release => p.dragRelease(config.select_button), .motion => {}, }, config.exec_button, config.look_button => switch (m.kind) { .press => { // acme 1-2 / 1-3: the execute (Cut) or look (Paste) button // tapped while the select button holds a selection drag if (p.drag == .select and p.drag.select.button == config.select_button) { p.chordCutPaste(m.button == config.exec_button); return; } // acme 2-3 / 3-2: tapping the OTHER of execute/look 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: EXECUTE runs the command under the // click (a stray select click must never Kill) if (m.button == config.exec_button) { var tb_buf: [1200]u8 = undefined; const word = wordAtCol(p.topbar(&tb_buf), mcol); // a topbar word runs on the PRESS — there is no // drag to chord into, so the argument is simply // whatever is selected right now: select a word, // then click Grep, and it greps that word. if (word.len > 0) _ = p.execute(p.active, p.withArg(word, p.heldSelection(p.active))); } return; } const id = hovered orelse return; const r = p.rects[id]; const pane = p.panes[id] orelse return; if (mcol < r.x + config.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 == config.look_button) p.active = id; pane.scrollBy(if (m.button == config.look_button) local else -local); } else if (mcol >= r.x + config.GUTTER) { if (m.button == config.look_button) p.active = id; const c: i32 = @as(i32, mcol) - @as(i32, r.x + config.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] + config.MINW; const hi = p.col_x[c] +| p.col_w[c] +| p.col_w[c + 1] -| config.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 + config.GUTTER); pane.sel[b].r1 = @as(i32, mrow) - @as(i32, r.y); } }, .tag => |d| { // the tag cursor follows the mouse across the rendered tag; the // row is ignored (a tag is one line) and the anchor stays where // the press put it, so this is the mouse's `v` const pane = p.panes[d.id] orelse return; const end: i32 = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail.items.len); const c = @as(i32, mcol) - @as(i32, p.rects[d.id].x + config.GUTTER); pane.tag_col = @intCast(std.math.clamp(c, 0, end)); pane.tag_sel = pane.tag_col != pane.tag_anchor; }, .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, config.MINW), @max(@as(f32, config.MINW), combined - config.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 == config.select_button) { // 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[sel_slot]; const dragged = sl.c0 != sl.c1 or sl.r0 != sl.r1; if (!dragged) pane.sel[sel_slot].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, config.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; // Ctrl-click IS `gd`, asked now that the cursor has // landed — the mouse spelling of the keyboard motion, // through the identical request. A ctrl-DRAG still // selects and still asks, about where it started, // which is the same thing `gd` would answer. if (s.ctrl) p.lspRequest(s.id, .definition, ""); } } else { // acme execute (middle) / look (right): a no-drag click // expands to the word under it 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); const cmd = if (s.button == config.look_button) config.look_cmd else config.exec_cmd; if (txt) |tx| p.runBuiltin(cmd, s.id, "", p.withArg(tx, arg)); } }, .tag => {}, // dragUpdate already left the tag cursor + selection set .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[sel_slot].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[sel_slot] tracking the held select 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[sel_slot].c1 + 1, 1, 9999)); const row: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].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[sel_slot]; pane.sel[sel_slot].state = .none; const pfx: i32 = if (pane.file != null) config.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; file_pane.setContent(p, 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 ---- pub 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. pub 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; } } pub 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. pub 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); // the pane above, but never a result list: walking past a stack of them // lands on the pane that spawned it, so those keep their heights and // only the spawner grows. The topmost pane has only what is below it. var sib = if (f.idx > 0) p.col_terms[f.col][f.idx - 1] else p.col_terms[f.col][f.idx + 1]; var k = f.idx; while (k > 0) : (k -= 1) { sib = p.col_terms[f.col][k - 1]; if (p.panes[sib]) |pp| if (if (pp.file) |ff| output_pane.fileTraits(ff.output).doc else true) break; } 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 non-doc buffer is worth exactly its own text: a three-hit +Search // is four rows, not half the source. Nothing else can want the rows, // so they go straight back to the pane they were taken from. if (nw.file) |f| if (!output_pane.fileTraits(f.output).doc) { // trimmed: every row ends in a newline, and the empty line after // the last one is not a result const want: f32 = @floatFromInt(BOX_H + file_pane.lineCount(std.mem.trimEnd(u8, f.content, "\n"))); if (want < nw.vweight) { src.vweight += nw.vweight - want; nw.vweight = want; } }; } /// 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; } // ---- doc panes ---- pub 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 /// — file_pane.open 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`. pub 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 > file_pane.lineCount(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; } /// LOOK — the Look builtin's body, and so what a right click, an Enter and /// the word `Look` all end at. Resolve `txt` against the panes' directories /// and open (or focus) whatever it names; a word that names nothing is a /// search of the pane it came from, which is acme's button-3. pub fn lookAt(p: *Pardes, id: usize, txt: []const u8) void { const pane = p.panes[id] orelse return; const trimmed = std.mem.trim(u8, txt, " \t\r\n"); // `` @`ls -la` `` names a COMMAND, not a path: run it, and land in the // pane that answers — looking at a thing means being SHOWN it, and a // command's output is what there is to be shown. Looking at a // DIRECTORY has always been exactly this (below: focus a shell there // and make it `ls`); this is that rule spelled generally. if (config.commandWord(trimmed)) |cmd| { if (p.execute(id, cmd)) |dst| p.active = dst; return; } var realbuf: [4096]u8 = undefined; // 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 // here and joined onto each directory below. const pl = look.parsePathLine(trimmed); if (pl.path.len > 0 and p.focusPaneByPath(pl.path, pl.line, pl.col)) return; // The word is resolved against the pane DIRECTORIES in access // order: the pane the click came from FIRST — its answer is the // one taken, so nothing that resolves today moves — then every // other live pane, most recently focused first (the jump stack runs // least-recent -> active, so it is that array backwards; the // clicked pane is not always `active`, a right click does not // focus). Only when ALL of them fail does the word fall through to // the search below: a name you can read in one window is openable // from any of them. Each attempt is a realpath + an open and // shells share cwds constantly, so a seen-list holds every // directory to one try; an absolute word — and `@pN`, which reads // no directory at all — answers the same everywhere and stops // after the first pass. var found: look.Target = .none; var seen: [MAX_PANES][]const u8 = undefined; var nseen: usize = 0; var n: usize = 0; cand: while (n <= p.njumps) : (n += 1) { const k: usize = if (n == 0) id else p.jumps[p.njumps - n].pane; if (n > 0 and k == id) continue; // tried first, skip the rerun const op = p.panes[k] orelse continue; const dir = if (op.file) |f| (std.fs.path.dirname(f.path) orelse "/") else op.cwdSlice(); for (seen[0..nseen]) |s| if (std.mem.eql(u8, s, dir)) continue :cand; seen[nseen] = dir; nseen += 1; if (pl.path.len > 0 and pl.path[0] != '/') { var joinbuf: [2048]u8 = undefined; if (std.fmt.bufPrint(&joinbuf, "{s}/{s}", .{ dir, pl.path }) catch null) |j| if (p.focusPaneByPath(j, pl.line, pl.col)) return; } found = look.resolve(txt, dir, &realbuf); if (found != .none or pl.path.len == 0 or pl.path[0] == '/') break; } switch (found) { // 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, .text); }, // `@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 = file_pane.open(p, 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); }, } } /// how deep `execute` may re-enter itself. Nothing can reach this today: /// every door back in strips at least one word (`Exec X` -> `X`) or one /// pair of delimiters (`` @`X` `` -> `X`), so the command line strictly /// shrinks and a cycle cannot close — executing the bare word `Exec` runs /// out of argument immediately. The counter is here so that a syntax added /// later which does NOT shrink (an alias, a macro) stops instead of hanging /// the editor, and the ceiling is small because a human nesting eight deep /// has made a different mistake. const max_exec_depth = 8; /// EXECUTE — the Exec builtin's body, and so what a middle click, a Tab /// and the word `Exec` all end at. A builtin's NAME runs the builtin; /// anything else is a command line typed at a shell. Returns the pane it /// was typed into, which is what Look focuses and an execute deliberately /// does not. pub fn execute(p: *Pardes, id: usize, txt: []const u8) ?usize { const pane = p.panes[id] orelse return null; var cmd = std.mem.trim(u8, txt, " \t\r\n"); // `` @`ls -la` `` is the same command line wearing the syntax that // makes it clickable inside ordinary prose: unwrap and carry on. The // loop (rather than one peel) costs nothing and is honest about the // form nesting. while (config.commandWord(cmd)) |inner| cmd = std.mem.trim(u8, inner, " \t\r\n"); if (cmd.len == 0) return null; if (p.exec_depth >= max_exec_depth) return null; p.exec_depth += 1; defer p.exec_depth -= 1; // The builtins that take an ARGUMENT match their name with a TAIL: // `Restore `, `Find `, `Grep `, `Rename `, // `WsSymbols `, `Theme `, and the two verbs themselves — // `Look `, `Exec `, which is what makes `` @`Look .` `` nest // (the tail goes straight back through here). Every other name must // match WHOLE, so `Kill foo` is a shell command and not Kill. const sp = std.mem.indexOfAny(u8, cmd, " \t"); const bi: ?Builtin = std.meta.stringToEnum(Builtin, cmd) orelse blk: { const head = std.meta.stringToEnum(Builtin, cmd[0 .. sp orelse break :blk null]) orelse break :blk null; break :blk switch (head) { .Restore, .Find, .Grep, .Rename, .WsSymbols, .Theme, .Look, .Exec => head, else => null, }; }; if (bi) |b| { const typed = if (sp) |s| std.mem.trim(u8, cmd[s + 1 ..], " \t") else ""; p.runBuiltin(b, id, cmd, if (typed.len > 0) typed else null); return null; } // terminal: run in itself. file pane: run in a shell in the file's // directory (found by cwd, else freshly forked). const dst = (if (pane.file) |f| p.ttyForDir(std.fs.path.dirname(f.path) orelse "/") else id) orelse return null; term_pane.padOutputBelowEdits(p, dst); p.emitWrite(dst, cmd); p.emitWrite(dst, "\r"); return dst; } /// Run a builtin on pane `id`. `txt` is the executed text (Restore reads /// its path back out of it) and `arg` the builtin's ARGUMENT — the tail /// after the name, which is why Grep and Find run straight away when there /// is one instead of asking, and which for Look and Exec is the whole /// operand. A gesture that points at a word (a click, an Enter) passes it /// as `arg` with no `txt`: it named no builtin, config.look_cmd did. The /// leader passes "" and null: a key path names a builtin, never an /// argument. The topbar builtins 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`. The null-pane check is the one guard /// every builtin used to share, so it stays here rather than in each. fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8) void { const pane = p.panes[id] orelse return; const c: builtins.Ctx = .{ .p = p, .pane = pane, .id = id, .txt = txt, .arg = arg }; inline for (comptime builtins.all(), 0..) |B, i| if (@intFromEnum(b) == i) return B.run(c); } /// place a fresh doc pane. An OUTPUT buffer (+Search/+Help) is NOT a /// document: it is the result list belonging to the pane that asked for it, /// so it never claims a column and is never anyone else's split parent — it /// lands right below `from_id`, be that a shell, a file or another list, /// and `from_id` alone pays the rows (several lists just stack there). /// A real doc joins the docs: 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. A full /// column bar, or a result list whose source died, stacks into the leftmost. pub fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void { const doc = if (nt.file) |f| output_pane.fileTraits(f.output).doc else true; // an image is a doc var src_id: ?usize = null; if (p.panes[from_id]) |src| if (if (src.file) |f| output_pane.fileTraits(f.output).doc else src.image != null) { src_id = from_id; }; if (doc and src_id == null) for (p.panes, 0..) |sl, i| { if (sl) |pp| if (i != free and (if (pp.file) |f| output_pane.fileTraits(f.output).doc else pp.image != null)) { src_id = i; break; }; }; // A result list belongs to its spawner: it lands directly BELOW it — // on top of the lists already there — and its rows come out of the // SPAWNER, never a bystander, so opening another list (or deleting // one, see absorbVWeight) leaves every other pane's height untouched. if (!doc) if (p.layoutFindTerm(from_id)) |sf| { p.layoutInsert(sf.col, sf.idx + 1, free); p.splitBelow(from_id, nt); // NOT splitParent: no bystander pays p.active = free; return; }; 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 (doc and 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) ---- pub 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 tag = try p.tagText(arena, pane); const body = try p.bodyText(arena, pane); const scroll: usize = @intCast(@max(0, pane.scroll())); // BY POINTER: `origin_arg` is a slice into the File's own inline // buffer, and a by-value capture would leave it pointing at a // stack copy that dies before the ZON is written. 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), .origin = if (f.output) |o| output_pane.word(o.from) else "", .origin_arg = if (f.output) |*o| o.arg() else "", }, } 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 = config.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 = term_pane.ptyReport; pane.stream.handler.effects.device_attributes = term_pane.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); // an unknown origin word is a dump from another build, not // a reason to refuse the load: it comes back as a file const out: ?output_pane.Output = if (output_pane.fromWord(f.origin)) |o| blk: { var ov: output_pane.Output = .{ .from = o }; output_pane.setArg(&ov, f.origin_arg); break :blk ov; } else null; pane.file = .{ .path = path, .content = content, .output = out, .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. /// Walks the jump stack newest-first, so it answers exactly what it always /// did — a pane's newest entry sits where the old MRU put the pane. pub fn prevFocus(p: *Pardes, closing: usize) ?usize { var i = p.njumps; while (i > 0) { i -= 1; const id = p.jumps[i].pane; 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; } /// THE PUSH RULE, and the only place it is written down. /// /// A location is worth remembering when you cannot see it any more: focus /// ended this update in a DIFFERENT pane, or more than a bodyful of rows /// away in the same one. Anything closer is the cursor strolling, and the /// current entry just follows it — so h/j/k/w/b never grow the list, while /// a goto-line, a search hit, a goto-definition and every focus change do. /// (Vim's rule is a hand-kept list of "jump commands"; this one asks the /// question those commands are a proxy for, and needs no list.) /// /// It is read HERE, once per update, and nowhere else: what a call site /// does transiently is invisible, which is what keeps n/N over a results /// buffer — which focuses each hit and comes straight back — from pushing /// two entries per keystroke. That transparency is the whole reason the /// rule lives in sync() rather than at the sites that move focus, which is /// where the heuristics used to be scattered. fn trackJump(p: *Pardes) void { // dead entries first, in one compacting pass. A slot is reused, so the // test is the SERIAL: an entry whose pane has been replaced names a // pane that no longer exists, not the newcomer sitting in its slot. var w: usize = 0; var cur: usize = 0; for (p.jumps[0..p.njumps], 0..) |j, i| { if (i == p.jcur) cur = w; // survives -> lands at w; dies -> the next one does const pane = p.panes[j.pane] orelse continue; if (pane.serial != j.serial) continue; p.jumps[w] = j; w += 1; } p.njumps = w; p.jcur = @min(cur, w -| 1); const pane = p.panes[p.active] orelse return; // An UNPINNED cursor belongs to the program on the other end of the // pty, not to you, so such a pane is remembered as a place and not as // a spot: line 0 is the "no line" focusPaneLine already understands, // and going back there focuses the shell without dragging its view up // to scrollback row 0. It also has no line to be FAR from, which is // what keeps the first keypress in a shell (which pins the cursor // wherever the prompt is) from reading as a jump. const now: Loc = .{ .pane = @intCast(p.active), .serial = pane.serial, .line = if (pane.cur_pinned) @intCast(@max(0, pane.cur_row) + 1) else 0, .col = if (pane.cur_pinned) @intCast(@max(0, pane.cur_col) + 1) else 0, }; if (p.njumps > 0) { const at = &p.jumps[p.jcur]; const body = @max(1, p.rects[p.active].h -| BOX_H); const far = at.line != 0 and now.line != 0 and @max(now.line, at.line) - @min(now.line, at.line) > body; if (at.pane == now.pane and !far) { at.* = now; // same visit; the entry IS where you are return; } } // a new jump made from the middle of the list drops everything ahead of // it, the way vim's does: the future you did not take is not history. if (p.njumps > 0) p.njumps = p.jcur + 1; if (p.njumps == MAX_JUMPS) { std.mem.copyForwards(Loc, p.jumps[0 .. MAX_JUMPS - 1], p.jumps[1..]); p.njumps -= 1; } p.jumps[p.njumps] = now; p.jcur = p.njumps; p.njumps += 1; } /// Ctrl-o / Ctrl-i (the Back and Forward builtins): move the CURSOR into /// the stack and go to what it names. Nothing is pushed and nothing is /// dropped — walking history is not making it — and trackJump agrees, /// because after the move the live spot IS `jumps[jcur]` again. pub fn jumpBy(p: *Pardes, delta: i32) void { const next = @as(i64, @intCast(p.jcur)) + delta; if (p.njumps == 0 or next < 0 or next >= p.njumps) return; p.jcur = @intCast(next); const j = p.jumps[p.jcur]; p.focusPaneLine(j.pane, j.line, j.col); } /// 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(); p.trackJump(); 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 -| config.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 wroom = p.screen_w -| x; // same clamp as the rows below const cw: u16 = if (last) wroom else @min(wroom, @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; // every pane wants at least one row, so a column with more // panes than the window has rows would walk `y` off the bottom // and hand renderPane a rect outside the surface (assert, then // panic — shrink a window with a few stacked panes). Clamp to // what is left: the panes past the edge get h = 0 and render // nothing until the window grows back. const room = p.screen_h -| y; const ch: u16 = if (lastk) room else @min(room, @max(1, @as(u16, @intFromFloat(@round(fh))))); p.rects[id] = .{ .x = x, .y = y, .w = cw, .h = ch }; y +|= ch; } x +|= cw; } } pub fn theme(p: *const Pardes) *const Theme { return &themes[p.theme_idx]; } // ---- render: build the canonical surface ---- pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { file_pane.refreshHighlights(p); 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 } }); // the topbar's cursor, if it has the keyboard. AFTER the pane loop on // purpose: there is exactly one Surface cursor and the bar's must beat // the active pane's. Always a block — the bar has no insert mode. if (p.topbar_col) |c| if (c < s.cols) { s.cursor = .{ .x = c, .y = 0, .bar = false }; }; // resize-handle hint / drag previews: a dash overlay that keeps the // underlying colors (border drags + hover), or the move indicator. A // drag holds the coordinates of the last mouse event, so a resize // mid-drag (tiling WM, font-size change) can leave them off the new // surface — every arm below checks before it draws. switch (p.drag) { .border_v => |d| if (d.cur_x < s.cols) { var row: u16 = TOPBAR_H; while (row < s.rows) : (row += 1) s.overlayDash(d.cur_x, row, "╎"); }, .border_h => |d| if (d.cur_y < s.rows) { 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 (d.cur_x < s.cols) { 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 = th.lineno } }); } s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = th.lineno } }); } }, .none, .select, .tag => { // 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 = file_pane.lineCount(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 = th.scroll_track } }); 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 = th.tag_fg } }); } } }; 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 <= config.GUTTER or r.h == 0) return; const s = &p.surface; const th = p.theme(); const tx = r.x + config.GUTTER; // text area (tag + body), right of the gutter const tw = r.w - config.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 tag = try p.tagText(arena, 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 char selection highlight (helix v/x, or a tagline sweep), // inclusive [lo, hi] — rendered-tag columns, so no conversion if (pane.tag_edit and pane.tag_sel) { const b = tagSelBounds(pane); var col: usize = b.lo; const end: usize = 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 tag: a rendered-tag column, verbatim if (active and pane.tag_edit) { // bar while typing, block for `:` normal mode (same rule as a body) if (pane.tag_col < tw) s.cursor = .{ .x = tx + pane.tag_col, .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) th.box else th.box_dim; s.fill(r.x, r.y, config.GUTTER, BOX_H, .{ .bg = .{ .rgb = box_bg2 } }); if (r.h > BOX_H) s.fill(r.x, r.y + BOX_H, config.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); } // the two file-only passes and the terminal-only one: each is the // whole reason its pane kind renders differently at all, so each lives // with its kind. Order is load-bearing — gutter, then syntax over the // content, then the emulator's own colors — and the selection/cursor // passes below still win over all three. if (pane.file) |*f| { file_pane.drawGutter(p, pane, r, tx, tw, body_h, active); if (p.colors_on) file_pane.recolorSyntax(p, pane, f, r, tx, tw, body_h); } const off = pane.scroll(); if (p.colors_on and pane.isTerminal()) term_pane.recolorAnsi(p, pane, r, tx, tw, body_h, off); // 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, config.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) config.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) config.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, config.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) th.box else th.box_dim; s.fill(r.x, r.y, config.GUTTER, BOX_H, .{ .bg = .{ .rgb = box_bg } }); if (r.h > BOX_H) { s.fill(r.x, r.y + BOX_H, config.GUTTER, r.h - BOX_H, .{ .bg = .{ .rgb = th.scroll_track } }); const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |f| .{ .total = file_pane.lineCount(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)), config.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| return file_pane.bodyText(arena, pane, f); return term_pane.bodyText(arena, pane); } };