diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-30 23:35:45 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | 19f7322100062b7c1adcde3376063ce6c1d8c72d (patch) | |
| tree | b76347dffe0347c29a38b4cad6031678683dba74 /src/pardes.zig | |
| parent | 753d5451ca7d09dbc5ff44f3c2dcf9a47fb87f96 (diff) | |
| download | pardes-19f7322100062b7c1adcde3376063ce6c1d8c72d.tar.gz pardes-19f7322100062b7c1adcde3376063ce6c1d8c72d.zip | |
structure: backends into src/{tty,gui,lsp}, pane kinds and builtins into their own files
The core now lies FLAT at src/ and every subdirectory is one backend, so a
file being in no directory at all is what says it is core. Pane-kind bodies
leave pardes.zig for term_pane.zig / file_pane.zig / output_pane.zig, leaving
it the layout, the event/effect machine and the generic render loop.
Builtins are one struct each in builtins.zig, and the enum is folded out of
the file's own declaration list at comptime — a zig file IS a struct, so the
list of builtins and the builtins themselves are the same text. Adding one is
writing a struct. Key paths deliberately stay one table for the config pass.
Pure refactor: no golden moved.
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 796 |
1 files changed, 94 insertions, 702 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index 07120954..f6168449 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -8,6 +8,10 @@ //! //! 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. const std = @import("std"); const ghostty_vt = @import("ghostty-vt"); const uucode = @import("uucode"); @@ -15,12 +19,13 @@ 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 image = @import("image.zig"); pub const dump = @import("dump.zig"); -pub const lsp = @import("lsp.zig"); - -const SYNTAX_CONTEXT_BEFORE_ROWS: usize = 0; -const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2; +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)); @@ -54,74 +59,15 @@ const file_pane_tail = " " ++ file_pane_builtins_str; // Builtins: executing the name (middle-click / Tab) runs it through the ONE // dispatcher (runBuiltin, reached from actOnSelection), no matter where the -// name appears. The enum FIELD NAME is the user-visible word — the one in the -// topbar, the one sitting in a tag, the one Help prints, the one you execute -// — so `std.meta.stringToEnum` IS the lookup and there is no name table to -// keep in sync. Everything else about a builtin (its leader key path, its -// Help line) is folded out of this enum at comptime, below. A name that is -// not a legal identifier would be spelled `@"..."`; zig fmt strips the -// quotes from the ones that are. -const Builtin = enum { - Kill, - Newcol, - Tutor, - Debug, - Colors, - NextColor, - Crt, - // the image pane's three renderer toggles. They used to be words the - // image tag printed and actOnSelection matched by hand; as builtins they - // are executable anywhere, pressable under SPC and listed by `SPC ?`, - // which is the whole reason the tag no longer carries them. Petscii picks - // glyph art over the host's pixels, Palette swaps the C64 palette for the - // terminal's own 16, Ascii adds the printable ASCII bitmaps to the - // matcher's glyph set. - Petscii, - Palette, - Ascii, - Dump, - Restore, - Save, - Find, - // Find's sibling: Find matches file NAMES under this pane's directory, - // Grep matches file CONTENTS under every pane's directory at once. - Grep, - Del, - Delcol, - Help, - // the window group. The DESTINATION is the name — a word, the way a tag - // holds Del or Save — because these names live in the same vocabulary as - // everything else here: `Wh` would be a leader key path leaking into the - // text you can middle-click. Plain English words are safe for exactly - // these five: focus is the cheapest thing to change by accident (nothing - // is edited, closed or written) and the way back is the opposite word. - Left, - Down, - Up, - Right, - Toggleterm, - // The language group, reached as `SPC l <helix's letter>` — see - // leader_path for why the prefix exists. They are builtins rather than - // bare keys for the same reason Save is one: the word is executable - // wherever it appears, so a middle-click on `Hover` in a tag does what - // `SPC l k` does. The five GOTOS are not here — helix binds them under `g` - // as motions, and a motion has no business being a word you can click. - Hover, - Rename, - CodeAction, - SelectRefs, - Symbols, - WsSymbols, - Diagnostics, - WsDiagnostics, - // Introspection. A language backend that answers nothing looks exactly - // like one that is broken — from the outside, `gd` doing nothing is both - // "there is no definition" and "the analyser threw and we swallowed it". - // These two are how you tell: Lspinfo says what the backend IS, Lspwhy - // says what it just DID and where it stopped. - Lspinfo, - Lspwhy, -}; +// name appears. 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 (leader_rows, the topbar check). +const Builtin = builtins.Builtin(); /// SPC leader: ONE key path per builtin, the whole remapping surface. An /// EnumArray, so a new enum field is a compile error until it has a path. @@ -192,7 +138,7 @@ const leader_path = std.EnumArray(Builtin, []const u8).init(.{ /// for thirteen two-character paths would be ceremony. `line` is the Help /// rendering, padded here so Help is a filter over an array of lines. const Leader = struct { path: []const u8, cmd: Builtin, line: []const u8 }; -const leader_rows: [@typeInfo(Builtin).@"enum".fields.len]Leader = blk: { +pub const leader_rows: [@typeInfo(Builtin).@"enum".fields.len]Leader = blk: { // the insertion sort below is O(n²) comptime branches, and n grew with the // language group @setEvalBranchQuota(8000); @@ -243,7 +189,7 @@ const leader_rows: [@typeInfo(Builtin).@"enum".fields.len]Leader = blk: { // reason it earns the width. Appended rather than inserted so every existing // word keeps its column and no golden's click coordinates move. comptime { - @setEvalBranchQuota(8000); // the tokenizer walks the string one branch per byte, stringToEnum builds a map per name + @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, topbar_str, ' '); while (it.next()) |w| { if (std.meta.stringToEnum(Builtin, w) == null) @compileError("topbar word is not a builtin: " ++ w); @@ -260,10 +206,6 @@ fn wordAtCol(str: []const u8, col: u16) []const u8 { return str[lo..hi]; } -fn fileLineCount(content: []const u8) usize { - return std.mem.count(u8, content, "\n") + 1; -} - /// `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 { @@ -854,7 +796,7 @@ pub const Pane = struct { return p.cwd[0..p.cwd_len]; } - fn isTerminal(pane: *const Pane) bool { + pub fn isTerminal(pane: *const Pane) bool { return pane.file == null and pane.image == null; } @@ -862,7 +804,7 @@ pub const Pane = struct { /// `rows` shell rows it covers, so everything below it slides by the /// difference — the identity on files and on terminals nobody has typed /// a newline into, which is why the rest of the row math can stay naive. - fn surfRow(pane: *const Pane, g: i32) i32 { + 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)); @@ -871,7 +813,7 @@ pub const Pane = struct { } /// the inverse; every surface row inside the edit buffer maps to its anchor - fn gridRow(pane: *const Pane, s: i32) i32 { + 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)); @@ -880,7 +822,7 @@ pub const Pane = struct { } /// current scroll offset: file top line, or the scrollback offset - fn scroll(pane: *Pane) i32 { + 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)); } @@ -901,7 +843,7 @@ pub const Pane = struct { } } - fn ensureCursorVisible(pane: *Pane) void { + pub fn ensureCursorVisible(pane: *Pane) void { // scrolloff margin, shrunk on short panes so the band stays non-empty const margin: i32 = @min(scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); const off = pane.scroll(); @@ -962,7 +904,7 @@ const Drag = union(enum) { select: struct { id: usize, button: Mouse.Button, chorded: bool = false, ctrl: bool = false }, }; -const Rect = struct { x: u16, y: u16, w: u16, h: u16 }; +pub const Rect = struct { x: u16, y: u16, w: u16, h: u16 }; pub const Options = struct { tty_only: bool = false, @@ -1080,7 +1022,7 @@ pub const Pardes = struct { _ = if (look.isImagePath(path)) try p.openImageView(0, path, !p.kitty_ok) else - try p.openFileView(0, path, opts.file_line); + try file_pane.open(p, 0, path, opts.file_line); const sh = try p.newPane(1); sh.greet = true; p.ncol = 2; @@ -1157,7 +1099,7 @@ pub const Pardes = struct { return topbar_str; } - fn deinitPane(p: *Pardes, pane: *Pane) void { + 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); @@ -1188,11 +1130,11 @@ pub const Pardes = struct { /// Allocate a pane slot with a live terminal emulator and queue the spawn /// effect (optionally in a directory); the shell answers by forking a pty /// and wiring reads back as Event.output for this pane id. - fn newPane(p: *Pardes, id: usize) !*Pane { + pub fn newPane(p: *Pardes, id: usize) !*Pane { return p.newShell(id, ""); } - fn newShell(p: *Pardes, id: usize, cwd: []const u8) !*Pane { + 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); @@ -1209,8 +1151,8 @@ pub const Pardes = struct { pane.stream = pane.vt.vtStream(); // answer terminal queries (DSR/DA/kitty) back into the pty, else // crossterm apps (nushell, helix, fzf) block on the reply forever. - pane.stream.handler.effects.write_pty = ptyReport; - pane.stream.handler.effects.device_attributes = ptyDeviceAttrs; + pane.stream.handler.effects.write_pty = term_pane.ptyReport; + pane.stream.handler.effects.device_attributes = term_pane.ptyDeviceAttrs; p.panes[id] = pane; p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(cwd) } }); return pane; @@ -1218,7 +1160,7 @@ pub const Pardes = struct { /// a doc pane (file/image): no pty, no spawn; a stub 1x1 emulator only /// because the shared machinery touches its allocator-owned bits. - fn newDocPane(p: *Pardes, id: usize) !*Pane { + pub fn newDocPane(p: *Pardes, id: usize) !*Pane { std.debug.assert(p.panes[id] == null); const pane = try p.gpa.create(Pane); pane.* = .{ @@ -1232,14 +1174,14 @@ pub const Pardes = struct { return pane; } - fn freeSlot(p: *Pardes) ?usize { + 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) - fn emitWrite(p: *Pardes, id: usize, bytes: []const u8) void { + 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); @@ -1257,7 +1199,7 @@ pub const Pardes = struct { pane.cwd_len = @intCast(n); } - fn emit(p: *Pardes, e: Effect) void { + pub fn emit(p: *Pardes, e: Effect) void { p.effects.append(p.gpa, e) catch {}; } @@ -1895,7 +1837,7 @@ pub const Pardes = struct { p.leader_on = false; // only "still a prefix" below re-arms it if (key.ctrl or key.alt or key.cp < 0x20 or key.cp > 0x7e) return; const c: u8 = @intCast(key.cp); - if (c == '?') return p.openHelp(p.active, p.leader_keys[0..p.leader_n]); + if (c == '?') 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; @@ -1951,7 +1893,7 @@ pub const Pardes = struct { /// move focus to the nearest pane in `dir` of `from` (overlap-aware /// nearest edge). `from` is the pane the builtin ran on, which is the /// active one for a key but the CLICKED one for a name executed in a tag. - fn focusDir(p: *Pardes, from: usize, dir: enum { left, right, up, down }) void { + 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; @@ -2101,32 +2043,6 @@ pub const Pardes = struct { row0: i32, // absolute row of lines[0] }; - /// A terminal's shell rows as the surface sees them: the WHOLE - /// history+active grid, prompt rows blanked (OSC 133), absolute grid rows - /// from 0. The raw material the motion surface is composed from — the - /// edit buffer is NOT applied here, so it is also what seeding the buffer - /// reads. Scratch-arena backed. - /// ghostty's dump trims the grid's trailing blank rows; ONE of them is - /// kept back, the row the cursor sits on below the last line of output. - /// That row is a file's final newline: without it the surface would have - /// one line fewer than the same text in a document, and every motion and - /// linewise edit at the bottom would diverge. - /// ponytail: O(scrollback) dump+scan per keystroke — window it around the - /// viewport if a multi-MB scrollback ever makes navigation lag. - fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 { - const arena = p.scratch.allocator(); - const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} }); - var pit = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); - var ls: std.ArrayList([]const u8) = .empty; - var it = std.mem.splitAny(u8, full, "\n"); - while (it.next()) |raw| { - const is_prompt = if (pit.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false; - try ls.append(arena, if (is_prompt) "" else raw); - } - try ls.append(arena, ""); - return ls.items; - } - /// The lines the cursor moves over, absolute rows. File: all content lines. /// Terminal: the whole history+active grid with the edit buffer's lines /// standing in for the rows it covers, so motions ride the scrollback and @@ -2141,7 +2057,7 @@ pub const Pardes = struct { while (it.next()) |ln| try ls.append(arena, ln); return .{ .lines = ls.items, .row0 = 0 }; } - const rows = try p.shellRows(pane); + 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; @@ -2741,18 +2657,18 @@ pub const Pardes = struct { /// of pardes carrying a second piece of pane state for two commands that /// differ in nothing but where the rows come from. const search_marker = " /"; - const find_marker = " Find /"; - const grep_marker = " Grep /"; + pub const find_marker = " Find /"; + pub const grep_marker = " Grep /"; // the two language queries that need a word typed: same tag input, same // Enter, and the marker is still what says which one is armed - const rename_marker = " Rename /"; - const symbol_marker = " WsSymbols /"; + pub const rename_marker = " Rename /"; + pub const symbol_marker = " WsSymbols /"; 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. - fn startSearch(p: *Pardes, pane: *Pane, marker: []const u8) void { + 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; @@ -2804,7 +2720,7 @@ pub const Pardes = struct { /// 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. - fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search) void { + 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; @@ -2872,7 +2788,7 @@ pub const Pardes = struct { p.gpa.free(content); return; }; - const np = p.openOutputView(free, dir, "+Search", content) catch { + const np = output_pane.open(p, free, dir, "+Search", content) catch { p.gpa.free(content); return; }; @@ -2925,7 +2841,7 @@ pub const Pardes = struct { /// 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. - fn lspRequest(p: *Pardes, id: usize, kind: lsp.Kind, arg: []const u8) void { + 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 @@ -2985,7 +2901,7 @@ pub const Pardes = struct { p.gpa.free(content); return; }; - const np = p.openOutputView(free, dir, w.kind.bufferName(), content) catch { + const np = output_pane.open(p, free, dir, w.kind.bufferName(), content) catch { p.gpa.free(content); return; }; @@ -3078,7 +2994,7 @@ pub const Pardes = struct { const up = old.row - want_lo; // shell rows to prepend const down = want_hi - (old.row + lines - 1); // surface rows past its end if (fresh or up > 0 or down > 0) { - const rows = p.shellRows(pane) catch return null; + 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); @@ -3128,7 +3044,7 @@ pub const Pardes = struct { /// install a rewritten editable text (frees the old one) fn setEditText(p: *Pardes, pane: *Pane, new: []u8) void { - if (pane.file) |*f| return p.setFileContent(f, new); + if (pane.file) |*f| return file_pane.setContent(p, f, new); if (pane.ovl) |*o| { p.gpa.free(o.text); o.text = new; @@ -4161,12 +4077,6 @@ pub const Pardes = struct { // ---- dumb undo/redo: whole-state snapshots, one per edit op ---- - fn setFileContent(p: *Pardes, f: *File, new: []u8) void { - p.gpa.free(f.content); - f.content = new; - f.syntax_dirty = true; - } - /// pull the cursor back inside `text` after a rewrite; `row0` is the /// absolute surface row of its first line (0 for a file) fn clampCursor(pane: *Pane, text: []const u8, row0: i32) void { @@ -4204,7 +4114,7 @@ pub const Pardes = struct { o.rows == cur.rows and std.mem.eql(u8, o.text, cur.text) else pane.ovl == null; if (same) return; } - const snap = p.snapTerm(pane) orelse return; + 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; @@ -4232,17 +4142,17 @@ pub const Pardes = struct { return; }; const prev = f.undo.pop() orelse return; - p.setFileContent(f, prev.content); - restoreFileSnap(pane, f, prev); + file_pane.setContent(p, f, prev.content); + file_pane.restoreSnap(pane, f, prev); return; } if (pane.ed_undo.items.len == 0) return; - const cur = p.snapTerm(pane) orelse 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; }; - p.restoreTerm(pane, pane.ed_undo.pop() orelse return); + term_pane.restoreSnap(p, pane, pane.ed_undo.pop() orelse return); } fn doRedo(p: *Pardes, pane: *Pane) void { @@ -4259,50 +4169,17 @@ pub const Pardes = struct { return; }; const next = f.redo.pop() orelse return; - p.setFileContent(f, next.content); - restoreFileSnap(pane, f, next); + file_pane.setContent(p, f, next.content); + file_pane.restoreSnap(pane, f, next); return; } if (pane.ed_redo.items.len == 0) return; - const cur = p.snapTerm(pane) orelse 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; }; - p.restoreTerm(pane, pane.ed_redo.pop() orelse return); - } - - /// undo/redo restores the selection recorded with the snapshot (helix - /// keeps selections in its history transactions) - fn restoreFileSnap(pane: *Pane, f: *File, snap: FileSnap) void { - const n = modal.lineCount(f.content); - const row: usize = @min(@as(usize, @intCast(@max(0, snap.cur_row))), if (n == 0) 0 else n - 1); - const llen = modal.lineSlice(f.content, row).len; - pane.cur_row = @intCast(row); - pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, snap.cur_col))), llen)); - pane.vsel = snap.vsel; - pane.msel.active = false; - pane.cur_pinned = true; - pane.sticky_col = -1; - pane.ensureCursorVisible(); - } - - /// the snapshot takes ownership of a COPY of the edit buffer's text - fn snapTerm(p: *Pardes, pane: *Pane) ?EditSnap { - var ovl: ?Ovl = null; - if (pane.ovl) |o| ovl = .{ .row = o.row, .rows = o.rows, .text = p.gpa.dupe(u8, o.text) catch return null }; - return .{ .ovl = ovl, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .vsel = pane.vsel }; - } - - fn restoreTerm(p: *Pardes, pane: *Pane, snap: EditSnap) void { - if (pane.ovl) |o| p.gpa.free(o.text); - pane.ovl = snap.ovl; - pane.cur_row = snap.cur_row; - pane.cur_col = snap.cur_col; - pane.cur_pinned = true; - pane.vsel = snap.vsel; - pane.msel.active = false; - pane.ensureCursorVisible(); + term_pane.restoreSnap(p, pane, pane.ed_redo.pop() orelse return); } fn handleMouse(p: *Pardes, m: Mouse) void { @@ -4715,7 +4592,7 @@ pub const Pardes = struct { const at = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }; p.pushUndo(pane); const new = modal.insertAt(p.gpa, f.content, at, y) catch return; - p.setFileContent(f, new); + 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) { @@ -4773,7 +4650,7 @@ pub const Pardes = struct { // ---- layout surgery ---- - fn layoutFindTerm(p: *Pardes, id: usize) ?struct { col: usize, idx: usize } { + 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 }; @@ -4791,7 +4668,7 @@ pub const Pardes = struct { /// Remove from the layout. An emptied column hands its width to a neighbor /// (else every surviving column reflows sideways) before shifting down. - fn layoutRemove(p: *Pardes, id: usize) void { + pub fn layoutRemove(p: *Pardes, id: usize) void { const f = p.layoutFindTerm(id) orelse return; const c = f.col; var k = f.idx; @@ -4809,7 +4686,7 @@ pub const Pardes = struct { } } - fn layoutAppendColumn(p: *Pardes, id: usize) void { + pub fn layoutAppendColumn(p: *Pardes, id: usize) void { p.layoutInsertColumn(p.ncol, id); } @@ -4845,7 +4722,7 @@ pub const Pardes = struct { /// the topmost) so the rest of the column keeps its sizes bit-identical — /// the deletion mirror of splitBelow. Call while `id` is still in the /// layout, with rects current. - fn absorbVWeight(p: *Pardes, id: usize) void { + 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); @@ -4907,7 +4784,7 @@ pub const Pardes = struct { if (nw.file) |f| if (f.output) { // 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 + fileLineCount(std.mem.trimEnd(u8, f.content, "\n"))); + 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; @@ -4968,52 +4845,9 @@ pub const Pardes = struct { return free; } - /// executing at a prompt with typed text below it: pad the output area - /// with newlines so the command's output doesn't overwrite the buffer - fn padOutputBelowEdits(p: *Pardes, id: usize) void { - const pane = p.panes[id] orelse return; - const o = pane.ovl orelse return; - if (!pane.isTerminal()) return; - if (!pane.vt.cursorIsAtPrompt()) return; - // the buffer's LAST surface row: its lines may outnumber the shell - // rows it covers, and it is the bottom one output must clear - const max_row = o.row + @as(i32, @intCast(modal.lineCount(o.text))) - 1; - const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); - const cursor_abs = pane.surfRow(goff + @as(i32, @intCast(pane.vt.screens.active.cursor.y))); - const pad = std.math.clamp(max_row - cursor_abs, 0, @as(i32, pane.rows)); - var i: i32 = 0; - while (i < pad) : (i += 1) p.emitWrite(id, "\r"); - } - // ---- doc panes ---- - fn openFileView(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane { - const content = try look.readFile(p.gpa, path); - errdefer p.gpa.free(content); - const path_copy = try p.gpa.dupe(u8, path); - errdefer p.gpa.free(path_copy); - const pane = try p.newDocPane(id); - const total = fileLineCount(content); - const scroll: usize = if (line > 0 and line <= total) line - 1 else 0; - pane.file = .{ .path = path_copy, .content = content, .scroll = scroll }; - pane.cur_pinned = true; - pane.cur_row = @intCast(scroll); - return pane; - } - - /// An output buffer (acme's +Errors): a file pane holding text the core - /// produced, with no file behind it. `content` is gpa-owned and adopted. - /// The name carries `dir` so looks inside it resolve like anywhere else. - fn openOutputView(p: *Pardes, id: usize, dir: []const u8, name: []const u8, content: []u8) !*Pane { - const path = try std.fmt.allocPrint(p.gpa, "{s}/{s}", .{ std.mem.trimEnd(u8, dir, "/"), name }); - errdefer p.gpa.free(path); - const pane = try p.newDocPane(id); - pane.file = .{ .path = path, .content = content, .output = true }; - pane.cur_pinned = true; - return pane; - } - - fn openTutorView(p: *Pardes, id: usize) !*Pane { + 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"); @@ -5026,7 +4860,7 @@ pub const Pardes = struct { /// TEST-ONLY, called by test/hxdiff.zig (the helix differential harness): /// swap the tty_only boot pane for a file pane holding `content` verbatim - /// — openFileView minus the disk read (cases carry their buffer inline). + /// — 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); @@ -5082,7 +4916,7 @@ pub const Pardes = struct { p.active = id; if (line == 0) return; if (pane.file) |*f| { - if (line > fileLineCount(f.content)) return; + 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; @@ -5200,7 +5034,7 @@ pub const Pardes = struct { // focus an existing pane on this path (rescrolled), else open if (p.focusPaneByPath(target.path, target.line, target.col)) return; const free = p.freeSlot() orelse return; - const nt = p.openFileView(free, target.path, target.line) catch return; + 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 @@ -5249,7 +5083,7 @@ pub const Pardes = struct { else id; if (dst) |di| { - p.padOutputBelowEdits(di); + term_pane.padOutputBelowEdits(p, di); p.emitWrite(di, cmd); // a chord argument (kept left selection) rides as a trailing CLI // argument @@ -5271,203 +5105,12 @@ pub const Pardes = struct { /// 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`. + /// 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; - switch (b) { - .Debug => p.show_debug = !p.show_debug, - .Colors => p.colors_on = !p.colors_on, - .NextColor => p.theme_idx = (p.theme_idx + 1) % themes.len, - .Crt => p.crt_on = !p.crt_on, - // the image toggles act on the pane they run in and are inert - // anywhere else, the way Save is on a terminal. Flipping the field - // is the whole toggle: drawImage re-matches the glyph grid when it - // sees grid_mode/grid_ascii disagree with the live ones. - .Petscii => if (pane.image) |*iv| { - iv.petscii = !iv.petscii; - }, - .Palette => if (pane.image) |*iv| { - iv.pmode = if (iv.pmode == .commodore) .terminal else .commodore; - }, - .Ascii => if (pane.image) |*iv| { - iv.ascii = !iv.ascii; - }, - .Dump => p.dumpState() catch {}, - .Help => p.openHelp(id, ""), - .Restore => { - var it = std.mem.tokenizeAny(u8, txt, " \t"); - _ = it.next(); // the word "Restore" - const path = it.next() orelse (p.last_dump orelse return); - if (path.len > p.restore_buf.len) return; - @memcpy(p.restore_buf[0..path.len], path); - p.restore_req = p.restore_buf[0..path.len]; - }, - .Kill => { - p.quit = true; - p.emit(.quit); - }, - // The language builtins are all one call: ask, and let the answer - // land in lspResponse. Nothing here blocks or knows what a backend - // is — swapping backends changes lsp.query and not one line below. - .Hover => p.lspRequest(id, .hover, ""), - .CodeAction => p.lspRequest(id, .code_action, ""), - .SelectRefs => p.lspRequest(id, .select_refs, ""), - .Symbols => p.lspRequest(id, .document_symbols, ""), - .Diagnostics => p.lspRequest(id, .diagnostics, ""), - .WsDiagnostics => p.lspRequest(id, .workspace_diagnostics, ""), - .Lspinfo => p.lspRequest(id, .status, ""), - .Lspwhy => p.lspRequest(id, .explain, ""), - // The two that need a word from the user, handled exactly the way - // Find and Grep are: an argument means run it now (a selection - // chorded onto the name), no argument arms the tag input and Enter - // submits (submitSearch). - .Rename, .WsSymbols => { - const kind: lsp.Kind = if (b == .Rename) .rename else .workspace_symbols; - const a = std.mem.trim(u8, arg orelse "", " \t\r\n"); - if (a.len > 0) return p.lspRequest(id, kind, a); - p.startSearch(pane, if (b == .Rename) rename_marker else symbol_marker); - }, - .Save => { - // an output buffer has no file behind it — nothing to write - if (pane.file) |f| if (!f.output) p.emit(.{ .save_file = .{ .pane = @intCast(id) } }); - }, - // The two builtins that ASK for something — Find walks file NAMES - // under this pane's directory, Grep file CONTENTS under every - // pane's. With an argument there is nothing to ask: it IS the - // pattern, so the walk runs now (this is what a `Grep` executed - // with a selection chorded to it means). Without one they arm the - // same tag input `/` does, and Enter runs it (submitSearch). - .Find, .Grep => { - const kind: Search = if (b == .Find) .find else .grep; - const pat = std.mem.trim(u8, arg orelse "", " \t\r\n"); - if (pat.len > 0) return p.runSearch(id, pat, kind); - p.startSearch(pane, if (b == .Find) find_marker else grep_marker); - }, - .Newcol => { - const free = p.freeSlot() orelse return; - if (p.ncol >= MAX_COLS) return; - const nt = p.newShell(free, "") catch return; - nt.greet = true; - p.layoutAppendColumn(free); - p.active = free; - }, - .Del => { - p.absorbVWeight(id); - p.layoutRemove(id); - p.deinitPane(pane); - p.panes[id] = null; - if (p.active == id) p.active = p.prevFocus(id) orelse { - p.quit = true; - p.emit(.quit); - return; - }; - }, - .Delcol => { - const f = p.layoutFindTerm(id) orelse return; - var ids: [MAX_PANES]usize = undefined; - const nids = p.col_n[f.col]; - for (0..nids) |k| ids[k] = p.col_terms[f.col][k]; - for (ids[0..nids]) |tid| { - if (p.panes[tid]) |tt| { - p.layoutRemove(tid); - p.deinitPane(tt); - p.panes[tid] = null; - } - } - if (p.panes[p.active] == null) p.active = p.prevFocus(p.active) orelse { - p.quit = true; - p.emit(.quit); - return; - }; - }, - .Tutor => { - const free = p.freeSlot() orelse return; - const nt = p.openTutorView(free) catch return; - p.placeDoc(id, free, nt); // a doc like any other - }, - .Left => p.focusDir(id, .left), - .Down => p.focusDir(id, .down), - .Up => p.focusDir(id, .up), - .Right => p.focusDir(id, .right), - // The file<->terminal hop. "Latest" is already recorded: focus_hist - // is the MRU sync() rebuilds every update (most recent last), the - // same list a closing pane hands focus back through — so this walks - // it instead of keeping a second one. Which side is which: only a - // shell is a terminal; a file, an image and an output buffer - // (+Search/+Help) are all DOCS you read, so isTerminal is the whole - // test. Landing pushes this pane to the top of that same history, - // which is why the hop back is the same key. - .Toggleterm => { - const want_term = !pane.isTerminal(); - var t: ?usize = null; - // any live pane of the other kind: a pane you have never - // focused (the file you started with) is in no history at all - for (p.panes, 0..) |slot, k| { - const op = slot orelse continue; - if (k != id and op.isTerminal() == want_term) t = k; - } - // ...but the most recently focused one wins - var i = p.nfocus; - while (i > 0) { - i -= 1; - const hid = p.focus_hist[i]; - const hp = p.panes[hid] orelse continue; - if (hid != id and hp.isTerminal() == want_term) { - t = hid; - break; - } - } - if (t) |target| { - p.active = target; - p.panes[target].?.pending = 0; - } - }, - } - } - - /// The Help builtin: every builtin reachable from `prefix` (empty = all - /// of them) with the keys that run it, written into an output buffer - /// (acme's +Errors) — ordinary text, so the names in it are LIVE: middle- - /// click `Tutor` there and the tutor opens. Reuses the open +Help buffer - /// instead of piling panes up, and focus follows: you asked to read it. - fn openHelp(p: *Pardes, id: usize, prefix: []const u8) void { - const pane = p.panes[id] orelse return; - const arena = p.scratch.allocator(); - var out: std.ArrayList(u8) = .empty; - if (prefix.len == 0) { - out.appendSlice(arena, "pardes builtins - SPC then the keys,\nor execute the name anywhere\n\n") catch return; - } else { - out.appendSlice(arena, "pardes builtins under SPC") catch return; - for (prefix) |c| out.appendSlice(arena, &[_]u8{ ' ', c }) catch return; - out.appendSlice(arena, "\n\n") catch return; - } - for (leader_rows) |row| { - if (!std.mem.startsWith(u8, row.path, prefix)) continue; - out.appendSlice(arena, row.line) catch return; - out.append(arena, '\n') catch return; - } - const content = p.gpa.dupe(u8, out.items) catch return; - for (p.panes, 0..) |slot, i| { - const hp = slot orelse continue; - const hf = if (hp.file) |*f| f else continue; - if (!hf.output or !std.mem.endsWith(u8, hf.path, "/+Help")) continue; - p.setFileContent(hf, content); - hf.scroll = 0; - hp.cur_row = 0; - hp.msel.active = false; - p.active = i; - return; - } - const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); - const free = p.freeSlot() orelse { - p.gpa.free(content); - return; - }; - const np = p.openOutputView(free, dir, "+Help", content) catch { - p.gpa.free(content); - return; - }; - p.placeDoc(id, free, np); + 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 @@ -5481,7 +5124,7 @@ pub const Pardes = struct { /// 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. - fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void { + pub fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void { const doc = if (nt.file) |f| !f.output else true; // an image is a doc var src_id: ?usize = null; if (p.panes[from_id]) |src| if (if (src.file) |f| !f.output else src.image != null) { @@ -5521,7 +5164,7 @@ pub const Pardes = struct { // ---- dump / load (acme-style: `pardes -l state.zon` restores a session) ---- - fn dumpState(p: *Pardes) !void { + 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; @@ -5665,8 +5308,8 @@ pub const Pardes = struct { .rows = @max(1, src.rows), }; pane.stream = pane.vt.vtStream(); - pane.stream.handler.effects.write_pty = ptyReport; - pane.stream.handler.effects.device_attributes = ptyDeviceAttrs; + 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); @@ -5749,7 +5392,7 @@ pub const Pardes = struct { /// where focus falls when the active pane closes: the most recently /// focused pane still alive (else any live one). Null = nothing left. - fn prevFocus(p: *Pardes, closing: usize) ?usize { + pub fn prevFocus(p: *Pardes, closing: usize) ?usize { var i = p.nfocus; while (i > 0) { i -= 1; @@ -5865,46 +5508,14 @@ pub const Pardes = struct { } } - fn theme(p: *const Pardes) *const Theme { + pub fn theme(p: *const Pardes) *const Theme { return &themes[p.theme_idx]; } // ---- render: build the canonical surface ---- - /// re-highlight the visible window of any file whose syntax went stale - /// (edit, scroll, load) — visible-range-first so big files stay snappy - fn refreshDirtyHighlights(p: *Pardes) void { - const tz = tracy.zone(@src(), "refreshDirtyHighlights"); - defer tz.end(); - for (p.panes) |slot| { - const pane = slot orelse continue; - if (pane.file == null) continue; - const f = &pane.file.?; - if (!f.syntax_dirty) continue; - if (!p.colors_on) { - if (f.highlights.len > 0) p.gpa.free(f.highlights); - f.highlights = &.{}; - f.highlight_start = 0; - f.syntax_dirty = false; - continue; - } - const start_row = f.scroll -| SYNTAX_CONTEXT_BEFORE_ROWS; - const start = modal.lineStartOffset(f.content, start_row); - const end_row = start_row + SYNTAX_CONTEXT_BEFORE_ROWS + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS; - const end = @max(start, modal.lineStartOffset(f.content, end_row)); - const new_highlights = syntax.highlightFileRange(p.gpa, f.path, f.content, start, end) catch { - f.syntax_dirty = false; - continue; - }; - if (f.highlights.len > 0) p.gpa.free(f.highlights); - f.highlights = new_highlights; - f.highlight_start = if (f.highlights.len > 0) start else 0; - f.syntax_dirty = false; - } - } - pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { - p.refreshDirtyHighlights(); + file_pane.refreshHighlights(p); const s = &p.surface; const ncells = @as(usize, p.screen_w) * p.screen_h; if (s.cells.len != ncells) { @@ -5991,7 +5602,7 @@ pub const Pardes = struct { var sb_total: usize = undefined; if (at.file) |f| { sb_off = f.scroll; - sb_total = fileLineCount(f.content); + sb_total = file_pane.lineCount(f.content); } else { const sb = at.vt.screens.active.pages.scrollbar(); sb_off = sb.offset; @@ -6116,85 +5727,17 @@ pub const Pardes = struct { _ = s.print(tx, r.y + BOX_H + i, tw, line, body_style); } - // file line-number gutter: mute the first PREFIX_W columns. Cheap - // chrome, not gated on colors_on; selection/cursor passes still win. - // The cursor row's number takes the tag style (same row math as the - // cursor pass below) so the eye finds the current line. - if (pane.file != null) { - const goff = pane.scroll(); - const gcur = pane.vt.screens.active.cursor; - const gcrow = if (pane.cur_pinned) pane.cur_row else @as(i32, @intCast(gcur.y)) + goff; - const cur_vr: i32 = if (active and !pane.tag_edit) gcrow - goff else -1; - var vr: u16 = 0; - while (vr < body_h) : (vr += 1) { - const on_cursor = @as(i32, vr) == cur_vr; - var c: u16 = 0; - while (c < PREFIX_W and c < tw) : (c += 1) { - const cell = s.at(tx + c, r.y + BOX_H + vr); - cell.default = false; // paints blank gutter rows too - if (on_cursor) { - cell.style.fg = .{ .rgb = th.tag_fg }; - cell.style.bg = .{ .rgb = th.tag_bg }; - } else cell.style.fg = .{ .rgb = th.lineno }; - } - } + // 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); } - // syntax colors (file panes): recolor each content cell from its - // tree-sitter style byte; content starts after the lineno gutter - if (p.colors_on) if (pane.file) |*f| { - if (f.highlights.len > 0) { - const tz_recolor = tracy.zone(@src(), "synRecolor"); - defer tz_recolor.end(); - var flines = std.mem.splitScalar(u8, f.content, '\n'); - var skip: usize = 0; - while (skip < f.scroll) : (skip += 1) { - if (flines.next() == null) break; - } - const hs: usize = @intCast(@max(0, pane.hscroll)); - var vr: u16 = 0; - while (vr < body_h) : (vr += 1) { - const line = flines.next() orelse break; - const base = @intFromPtr(line.ptr) - @intFromPtr(f.content.ptr); - var c: usize = 0; - while (hs + c < line.len and PREFIX_W + c < tw) : (c += 1) { - const idx = base + hs + c; - if (idx < f.highlight_start) continue; - const hidx = idx - f.highlight_start; - if (hidx >= f.highlights.len) continue; - const ss = p.synStyle(@enumFromInt(f.highlights[hidx])) orelse continue; - const cell = s.at(tx + @as(u16, @intCast(PREFIX_W + c)), r.y + BOX_H + vr); - if (cell.default) continue; - cell.style.fg = .{ .rgb = ss.fg }; - cell.style.bold = ss.bold; - } - } - } - }; - // tty colors: recolor each body cell from the emulator's own style so - // shell output keeps its ansi colors (gated on the Colors toggle). - // Rows the edit buffer stands in for are OURS, not the emulator's: - // they keep the plain body style, and the rows under them are read - // from the shell row the surface actually shows there. const off = pane.scroll(); - if (p.colors_on and pane.isTerminal()) { - const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); - var vr: u16 = 0; - while (vr < body_h and vr < pane.rows) : (vr += 1) { - const grid = pane.gridRow(off + @as(i32, vr)) - goff; - if (pane.mode != .tty) if (pane.ovl) |o| { - if (off + @as(i32, vr) >= o.row and off + @as(i32, vr) < o.row + @as(i32, @intCast(modal.lineCount(o.text)))) continue; - }; - if (grid < 0) continue; - var c: u16 = 0; - while (c < tw) : (c += 1) { - const ci = pane.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(c), .y = @intCast(grid) } }) orelse continue; - if (ci.cell.wide == .spacer_tail) continue; - const cell = s.at(tx + c, r.y + BOX_H + vr); - if (cell.default) continue; - cell.style = p.cellStyle(ci); - } - } - } + 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; @@ -6282,7 +5825,7 @@ pub const Pardes = struct { if (r.h > BOX_H) { s.fill(r.x, r.y + BOX_H, GUTTER, r.h - BOX_H, .{ .bg = .{ .rgb = th.scroll_track } }); const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |f| .{ - .total = fileLineCount(f.content), + .total = file_pane.lineCount(f.content), .offset = f.scroll, .len = pane.rows, } else blk: { @@ -6390,158 +5933,7 @@ pub const Pardes = struct { @memset(buf, '\n'); return buf; } - if (pane.file) |f| { - var out: std.ArrayList(u8) = .empty; - var flines = std.mem.splitScalar(u8, f.content, '\n'); - var skipped: usize = 0; - while (skipped < f.scroll) : (skipped += 1) { - if (flines.next() == null) break; - } - var i: usize = 0; - while (i < pane.rows) : (i += 1) { - if (i > 0) try out.append(arena, '\n'); - const abs: i32 = @as(i32, @intCast(f.scroll)) + @as(i32, @intCast(i)); - const row_start = out.items.len; - if (flines.next()) |text| { - var lbuf: [16]u8 = undefined; - // unsigned: {d} prints a leading '+' for signed ints - const lineno: usize = @intCast(abs + 1); - const prefix = std.fmt.bufPrint(&lbuf, "{d: >4} ", .{lineno}) catch " "; - var base: std.ArrayList(u8) = .empty; - try base.appendSlice(arena, prefix); - try base.appendSlice(arena, text); - try out.appendSlice(arena, base.items); - } - // horizontal scroll: cut hscroll columns after the gutter - if (pane.hscroll > 0) { - const cut_at = row_start + PREFIX_W; - if (out.items.len > cut_at) { - const cut = @min(@as(usize, @intCast(pane.hscroll)), out.items.len - cut_at); - out.replaceRangeAssumeCapacity(cut_at, cut, &.{}); - } - } - } - return out.items; - } - const raw = try pane.vt.plainString(arena); - // the viewport's shell rows, prompt rows blanked outside tty mode - var prompts = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .viewport = .{} }, null); - var vp: std.ArrayList([]const u8) = .empty; - var lines = std.mem.splitScalar(u8, raw, '\n'); - while (lines.next()) |ln| { - const is_prompt = if (pane.mode != .tty) - if (prompts.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false - else - false; - try vp.append(arena, if (is_prompt) "" else ln); - } - const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); - const off = pane.scroll(); - var g: i32 = pane.gridRow(off); - // the buffer can start above the viewport: drop the lines scrolled past - var skip: usize = if (pane.ovl) |o| @intCast(@max(0, off - pane.surfRow(o.row))) else 0; - var out: std.ArrayList(u8) = .empty; - var n: usize = 0; - while (n < pane.rows) { - if (pane.mode != .tty) if (pane.ovl) |o| if (g == o.row) { - var bit = std.mem.splitScalar(u8, o.text, '\n'); - var k: usize = 0; - while (bit.next()) |ln| : (k += 1) { - if (k < skip) continue; - if (n >= pane.rows) break; - if (n > 0) try out.append(arena, '\n'); - try out.appendSlice(arena, ln); - n += 1; - } - skip = 0; - g += o.rows; - continue; - }; - if (n > 0) try out.append(arena, '\n'); - const vi = g - goff; - if (vi >= 0 and @as(usize, @intCast(vi)) < vp.items.len) try out.appendSlice(arena, vp.items[@intCast(vi)]); - n += 1; - g += 1; - } - return out.items; - } - - fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell) CellStyle { - const style = ci.style(); - var cs: CellStyle = .{ - .fg = p.ghostColor(style.fg_color, false), - .bg = p.ghostColor(style.bg_color, true), - .bold = style.flags.bold, - .dim = style.flags.faint, - .italic = style.flags.italic, - .blink = style.flags.blink, - .reverse = style.flags.inverse, - .invisible = style.flags.invisible, - .strikethrough = style.flags.strikethrough, - .ul = switch (style.flags.underline) { - .none => .off, - .single => .single, - .double => .double, - .curly => .curly, - .dotted => .dotted, - .dashed => .dashed, - }, - }; - switch (ci.cell.content_tag) { - .bg_color_palette => cs.bg = p.palColor(ci.cell.content.color_palette.data), - .bg_color_rgb => { - const rgb = ci.cell.content.color_rgb; - cs.bg = .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }; - }, - else => {}, - } - return cs; - } - - const SynStyle = struct { fg: [3]u8, bold: bool }; - - fn synStyle(p: *Pardes, sy: syntax.Syn) ?SynStyle { - return switch (sy) { - .none => null, - .keyword => .{ .fg = p.theme().kw, .bold = true }, - .string => .{ .fg = p.theme().str, .bold = false }, - .number => .{ .fg = p.theme().num, .bold = false }, - .comment => .{ .fg = p.theme().comment, .bold = true }, - }; - } - - fn palColor(p: *Pardes, idx: u8) Color { - if (p.theme().palette) |pal| if (idx < 16) return .{ .rgb = pal[idx] }; - return .{ .index = idx }; - } - - fn ghostColor(p: *Pardes, color: ghostty_vt.Style.Color, is_bg: bool) Color { - return switch (color) { - .none => blk: { - const t = if (is_bg) p.theme().bg else p.theme().fg; - break :blk if (t) |c| .{ .rgb = c } else .default; - }, - .palette => |idx| p.palColor(idx), - .rgb => |rgb| .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }, - }; + if (pane.file) |*f| return file_pane.bodyText(arena, pane, f); + return term_pane.bodyText(arena, pane); } }; - -// ghostty calls this with a reply (cursor-position report, DA, ...) to send -// back to the child as if it typed it. The handler's `terminal` is our Pane.vt -// field; recover the Pane and stash the bytes — sync() drains them into write -// effects (the callback has no path to the effect queue). -fn ptyReport(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) void { - const pane: *Pane = @fieldParentPtr("vt", handler.terminal); - const room = pane.reply.len - pane.reply_len; - const n = @min(room, data.len); - @memcpy(pane.reply[pane.reply_len..][0..n], data[0..n]); - pane.reply_len += @intCast(n); -} - -const DeviceAttrs = @typeInfo(@typeInfo(@typeInfo( - @FieldType(ghostty_vt.TerminalStream.Handler.Effects, "device_attributes"), -).optional.child).pointer.child).@"fn".return_type.?; -fn ptyDeviceAttrs(_: *ghostty_vt.TerminalStream.Handler) DeviceAttrs { - return .{}; -} |
