summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/builtins.zig443
-rw-r--r--src/file_pane.zig196
-rw-r--r--src/gui/crt.zig (renamed from src/crt.zig)0
-rw-r--r--src/gui/deck.zig (renamed from src/deck.zig)0
-rw-r--r--src/gui/gui.zig (renamed from src/gui.zig)8
-rw-r--r--src/lsp/lsp.zig (renamed from src/lsp.zig)0
-rw-r--r--src/lsp/lsp_zls.zig (renamed from src/lsp_zls.zig)0
-rw-r--r--src/main.zig6
-rw-r--r--src/output_pane.zig72
-rw-r--r--src/pardes.zig796
-rw-r--r--src/term_pane.zig222
-rw-r--r--src/tty/tty.zig (renamed from src/tty.zig)6
12 files changed, 1037 insertions, 712 deletions
diff --git a/src/builtins.zig b/src/builtins.zig
new file mode 100644
index 00000000..7143a860
--- /dev/null
+++ b/src/builtins.zig
@@ -0,0 +1,443 @@
+//! The builtins: one struct each, and nothing hand-maintained about them.
+//!
+//! Executing a builtin's NAME (middle-click / Tab) runs it through the one
+//! dispatcher (Pardes.runBuiltin), no matter where the name appears. The
+//! struct's DECL 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 sync.
+//!
+//! A zig file IS a struct, so THIS FILE'S declarations are the list: `all`
+//! walks them at comptime and `Builtin` folds an enum out of that, in source
+//! order. Adding a builtin is writing one struct here — there is no list to
+//! append to and no switch prong to add, so there is nothing to forget. The
+//! hand-written enum and the four-hundred-line-away switch this replaces were
+//! two lists that had to agree; now the code IS the data.
+//!
+//! What makes a declaration a builtin is its SHAPE: a struct declaring
+//! `pub fn run(Ctx) void`. That is strict enough that Ctx and the two folds
+//! below fall out by construction rather than by a blocklist — a helper can
+//! never accidentally become a builtin, and a builtin whose run has the wrong
+//! signature silently vanishes instead of half-working, which the leader
+//! table catches immediately (see leader_path: a missing key path is a
+//! compile error).
+//!
+//! What is NOT here: the key bindings. `leader_path` stays ONE table in
+//! pardes.zig next to `topbar_str` — the whole remapping surface belongs in
+//! one place a user can read top to bottom, not scattered a line at a time
+//! across thirty-three structs.
+const std = @import("std");
+const pardes = @import("pardes.zig");
+const Pardes = pardes.Pardes;
+const Pane = pardes.Pane;
+const output_pane = @import("output_pane.zig");
+
+/// What a builtin gets to act on. One bundle rather than five parameters
+/// because most builtins want two of them and zig rejects the unused rest.
+/// `txt` is the executed text (Restore reads its path back out of it) and
+/// `arg` the execute's ARGUMENT — text typed after the name, or the selection
+/// a mouse chord kept, which is why Grep and Find run straight away when there
+/// is one instead of asking. The leader passes "" and null: a key path names a
+/// builtin, never an argument.
+pub const Ctx = struct {
+ p: *Pardes,
+ /// pane `id`, already resolved — the dispatcher's null check is the one
+ /// guard every builtin used to share.
+ pane: *Pane,
+ id: usize,
+ txt: []const u8,
+ arg: ?[]const u8,
+};
+
+/// Every builtin, in the order they are written below — which is the enum's
+/// numeric order. Nothing reads that order: the topbar picks its own subset by
+/// name and Help sorts by key path, and nothing persists a builtin's integer
+/// (the dump stores tag WORDS), so reordering this file is free.
+///
+/// A FUNCTION and not a const, and so is Builtin below, for one reason: both
+/// walk this file's own declaration list, and a const doing that is a
+/// declaration whose value depends on itself — zig rejects it outright. As
+/// functions they are only ever a signature to the walk, never a value, so
+/// they are not in their own way.
+///
+/// The walk only ever sees `pub` decls, so the imports above are invisible to
+/// it; what it does see and turn away is `all`/`word`/`Builtin` (not types)
+/// and `Ctx` (a type, but with no `run`).
+pub fn all() []const type {
+ // the whole body is comptime: a []const type only exists there, and it is
+ // what makes the decl walk's `d` a compile-time name rather than a value
+ comptime {
+ @setEvalBranchQuota(4000); // one pass per decl, and @hasDecl builds a map each
+ var list: []const type = &.{};
+ for (@typeInfo(@This()).@"struct".decls) |d| {
+ // @TypeOf never evaluates its operand, so this turns away `all`
+ // and `Builtin` by their SIGNATURES — asking for either one's
+ // VALUE here would be a declaration that depends on itself
+ if (@TypeOf(@field(@This(), d.name)) != type) continue;
+ const T = @field(@This(), d.name);
+ if (@typeInfo(T) != .@"struct") continue;
+ if (!@hasDecl(T, "run")) continue;
+ if (@TypeOf(T.run) != fn (Ctx) void) continue;
+ list = list ++ &[_]type{T};
+ }
+ return list;
+ }
+}
+
+/// A builtin's user-visible word: the LAST dotted segment of `@typeName`,
+/// because @typeName spells a file-scope struct fully qualified
+/// ("builtins.Kill"). Deriving it beats a `pub const name` field per struct,
+/// which would be the same word written twice with nothing keeping the two
+/// honest. A name that is not a legal identifier would be spelled `@"..."`.
+pub fn word(comptime T: type) []const u8 {
+ const n = @typeName(T);
+ const dot = std.mem.lastIndexOfScalar(u8, n, '.') orelse return n;
+ return n[dot + 1 ..];
+}
+
+/// The enum: field name = struct name, value = index into `all()`. Everything
+/// downstream (leader_path's EnumArray, leader_rows, the topbar's comptime
+/// check, stringToEnum) speaks it exactly as it did when it was hand-written.
+pub fn Builtin() type {
+ const cmds = all();
+ const Tag = std.math.IntFittingRange(0, cmds.len - 1);
+ var names: [cmds.len][]const u8 = undefined;
+ for (cmds, 0..) |T, i| names[i] = word(T);
+ return @Enum(Tag, .exhaustive, &names, &std.simd.iota(Tag, cmds.len));
+}
+
+// ---- session ----
+
+pub const Kill = struct {
+ pub fn run(c: Ctx) void {
+ c.p.quit = true;
+ c.p.emit(.quit);
+ }
+};
+
+pub const Dump = struct {
+ pub fn run(c: Ctx) void {
+ c.p.dumpState() catch {};
+ }
+};
+
+pub const Restore = struct {
+ pub fn run(c: Ctx) void {
+ var it = std.mem.tokenizeAny(u8, c.txt, " \t");
+ _ = it.next(); // the word "Restore"
+ const path = it.next() orelse (c.p.last_dump orelse return);
+ if (path.len > c.p.restore_buf.len) return;
+ @memcpy(c.p.restore_buf[0..path.len], path);
+ c.p.restore_req = c.p.restore_buf[0..path.len];
+ }
+};
+
+// ---- display toggles ----
+
+pub const Debug = struct {
+ pub fn run(c: Ctx) void {
+ c.p.show_debug = !c.p.show_debug;
+ }
+};
+
+pub const Colors = struct {
+ pub fn run(c: Ctx) void {
+ c.p.colors_on = !c.p.colors_on;
+ }
+};
+
+pub const NextColor = struct {
+ pub fn run(c: Ctx) void {
+ c.p.theme_idx = (c.p.theme_idx + 1) % pardes.themes.len;
+ }
+};
+
+pub const Crt = struct {
+ pub fn run(c: Ctx) void {
+ c.p.crt_on = !c.p.crt_on;
+ }
+};
+
+// 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. Each acts on the pane it runs in and
+// is 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.
+
+/// glyph art over the host's pixels
+pub const Petscii = struct {
+ pub fn run(c: Ctx) void {
+ if (c.pane.image) |*iv| {
+ iv.petscii = !iv.petscii;
+ }
+ }
+};
+
+/// the C64 palette or the terminal's own 16
+pub const Palette = struct {
+ pub fn run(c: Ctx) void {
+ if (c.pane.image) |*iv| {
+ iv.pmode = if (iv.pmode == .commodore) .terminal else .commodore;
+ }
+ }
+};
+
+/// add the printable ASCII bitmaps to the matcher's glyph set
+pub const Ascii = struct {
+ pub fn run(c: Ctx) void {
+ if (c.pane.image) |*iv| {
+ iv.ascii = !iv.ascii;
+ }
+ }
+};
+
+// ---- panes and columns ----
+
+pub const Save = struct {
+ pub fn run(c: Ctx) void {
+ // an output buffer has no file behind it — nothing to write
+ if (c.pane.file) |f| if (!f.output) c.p.emit(.{ .save_file = .{ .pane = @intCast(c.id) } });
+ }
+};
+
+pub const Newcol = struct {
+ pub fn run(c: Ctx) void {
+ const free = c.p.freeSlot() orelse return;
+ if (c.p.ncol >= pardes.MAX_COLS) return;
+ const nt = c.p.newShell(free, "") catch return;
+ nt.greet = true;
+ c.p.layoutAppendColumn(free);
+ c.p.active = free;
+ }
+};
+
+pub const Del = struct {
+ pub fn run(c: Ctx) void {
+ c.p.absorbVWeight(c.id);
+ c.p.layoutRemove(c.id);
+ c.p.deinitPane(c.pane);
+ c.p.panes[c.id] = null;
+ if (c.p.active == c.id) c.p.active = c.p.prevFocus(c.id) orelse {
+ c.p.quit = true;
+ c.p.emit(.quit);
+ return;
+ };
+ }
+};
+
+pub const Delcol = struct {
+ pub fn run(c: Ctx) void {
+ const f = c.p.layoutFindTerm(c.id) orelse return;
+ var ids: [pardes.MAX_PANES]usize = undefined;
+ const nids = c.p.col_n[f.col];
+ for (0..nids) |k| ids[k] = c.p.col_terms[f.col][k];
+ for (ids[0..nids]) |tid| {
+ if (c.p.panes[tid]) |tt| {
+ c.p.layoutRemove(tid);
+ c.p.deinitPane(tt);
+ c.p.panes[tid] = null;
+ }
+ }
+ if (c.p.panes[c.p.active] == null) c.p.active = c.p.prevFocus(c.p.active) orelse {
+ c.p.quit = true;
+ c.p.emit(.quit);
+ return;
+ };
+ }
+};
+
+pub const Tutor = struct {
+ pub fn run(c: Ctx) void {
+ const free = c.p.freeSlot() orelse return;
+ const nt = c.p.openTutorView(free) catch return;
+ c.p.placeDoc(c.id, free, nt); // a doc like any other
+ }
+};
+
+pub const Help = struct {
+ pub fn run(c: Ctx) void {
+ output_pane.openHelp(c.p, c.id, "");
+ }
+};
+
+// ---- search ----
+
+// 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).
+
+pub const Find = struct {
+ pub fn run(c: Ctx) void {
+ const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
+ if (pat.len > 0) return c.p.runSearch(c.id, pat, .find);
+ c.p.startSearch(c.pane, Pardes.find_marker);
+ }
+};
+
+/// Find's sibling: Find matches file NAMES under this pane's directory, Grep
+/// matches file CONTENTS under every pane's directory at once.
+pub const Grep = struct {
+ pub fn run(c: Ctx) void {
+ const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
+ if (pat.len > 0) return c.p.runSearch(c.id, pat, .grep);
+ c.p.startSearch(c.pane, Pardes.grep_marker);
+ }
+};
+
+// ---- 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.
+
+pub const Left = struct {
+ pub fn run(c: Ctx) void {
+ c.p.focusDir(c.id, .left);
+ }
+};
+
+pub const Down = struct {
+ pub fn run(c: Ctx) void {
+ c.p.focusDir(c.id, .down);
+ }
+};
+
+pub const Up = struct {
+ pub fn run(c: Ctx) void {
+ c.p.focusDir(c.id, .up);
+ }
+};
+
+pub const Right = struct {
+ pub fn run(c: Ctx) void {
+ c.p.focusDir(c.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.
+pub const Toggleterm = struct {
+ pub fn run(c: Ctx) void {
+ const want_term = !c.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 (c.p.panes, 0..) |slot, k| {
+ const op = slot orelse continue;
+ if (k != c.id and op.isTerminal() == want_term) t = k;
+ }
+ // ...but the most recently focused one wins
+ var i = c.p.nfocus;
+ while (i > 0) {
+ i -= 1;
+ const hid = c.p.focus_hist[i];
+ const hp = c.p.panes[hid] orelse continue;
+ if (hid != c.id and hp.isTerminal() == want_term) {
+ t = hid;
+ break;
+ }
+ }
+ if (t) |target| {
+ c.p.active = target;
+ c.p.panes[target].?.pending = 0;
+ }
+ }
+};
+
+// ---- 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.
+//
+// Most of them are 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.
+
+pub const Hover = struct {
+ pub fn run(c: Ctx) void {
+ c.p.lspRequest(c.id, .hover, "");
+ }
+};
+
+pub const CodeAction = struct {
+ pub fn run(c: Ctx) void {
+ c.p.lspRequest(c.id, .code_action, "");
+ }
+};
+
+pub const SelectRefs = struct {
+ pub fn run(c: Ctx) void {
+ c.p.lspRequest(c.id, .select_refs, "");
+ }
+};
+
+pub const Symbols = struct {
+ pub fn run(c: Ctx) void {
+ c.p.lspRequest(c.id, .document_symbols, "");
+ }
+};
+
+pub const Diagnostics = struct {
+ pub fn run(c: Ctx) void {
+ c.p.lspRequest(c.id, .diagnostics, "");
+ }
+};
+
+pub const WsDiagnostics = struct {
+ pub fn run(c: Ctx) void {
+ c.p.lspRequest(c.id, .workspace_diagnostics, "");
+ }
+};
+
+// 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).
+
+pub const Rename = struct {
+ pub fn run(c: Ctx) void {
+ const a = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
+ if (a.len > 0) return c.p.lspRequest(c.id, .rename, a);
+ c.p.startSearch(c.pane, Pardes.rename_marker);
+ }
+};
+
+pub const WsSymbols = struct {
+ pub fn run(c: Ctx) void {
+ const a = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
+ if (a.len > 0) return c.p.lspRequest(c.id, .workspace_symbols, a);
+ c.p.startSearch(c.pane, Pardes.symbol_marker);
+ }
+};
+
+// 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.
+
+pub const Lspinfo = struct {
+ pub fn run(c: Ctx) void {
+ c.p.lspRequest(c.id, .status, "");
+ }
+};
+
+pub const Lspwhy = struct {
+ pub fn run(c: Ctx) void {
+ c.p.lspRequest(c.id, .explain, "");
+ }
+};
diff --git a/src/file_pane.zig b/src/file_pane.zig
new file mode 100644
index 00000000..4c02e983
--- /dev/null
+++ b/src/file_pane.zig
@@ -0,0 +1,196 @@
+//! File panes: everything a Pane does BECAUSE it has `file: ?File` set — the
+//! disk read, the content swap undo/redo commits through, the tree-sitter
+//! highlight window, and the two render passes only a file has (the line
+//! number gutter and the syntax recolor). The rest of a file pane's behaviour
+//! is the pane machinery in pardes.zig, which does not care what kind it is.
+const std = @import("std");
+const pardes = @import("pardes.zig");
+const Pardes = pardes.Pardes;
+const Pane = pardes.Pane;
+const File = pardes.File;
+const FileSnap = pardes.FileSnap;
+const modal = @import("modal.zig");
+const look = @import("look.zig");
+const syntax = @import("syntax.zig");
+const tracy = @import("tracy.zig");
+
+const SYNTAX_CONTEXT_BEFORE_ROWS: usize = 0;
+const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2;
+
+pub fn lineCount(content: []const u8) usize {
+ return std.mem.count(u8, content, "\n") + 1;
+}
+
+pub fn open(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 = lineCount(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;
+}
+
+pub fn setContent(p: *Pardes, f: *File, new: []u8) void {
+ p.gpa.free(f.content);
+ f.content = new;
+ f.syntax_dirty = true;
+}
+
+/// undo/redo restores the selection recorded with the snapshot (helix keeps
+/// selections in its history transactions), clamped: the content it was taken
+/// against may be shorter than the one it is being restored onto.
+pub fn restoreSnap(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();
+}
+
+/// re-highlight the visible window of any file whose syntax went stale
+/// (edit, scroll, load) — visible-range-first so big files stay snappy
+pub fn refreshHighlights(p: *Pardes) void {
+ const tz = tracy.zone(@src(), "refreshHighlights");
+ 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;
+ }
+}
+
+/// the body a file pane renders: `pane.rows` content lines from the scroll
+/// offset, each behind its right-aligned line number, then cut by hscroll.
+pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *File) ![]const u8 {
+ 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 + pardes.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;
+}
+
+/// 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 renderPane's cursor pass) so
+/// the eye finds the current line.
+pub fn drawGutter(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, active: bool) void {
+ const s = &p.surface;
+ const th = p.theme();
+ 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 < pardes.PREFIX_W and c < tw) : (c += 1) {
+ const cell = s.at(tx + c, r.y + pardes.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 };
+ }
+ }
+}
+
+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 },
+ };
+}
+
+/// syntax colors: recolor each content cell from its tree-sitter style byte;
+/// content starts after the lineno gutter
+pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *File, r: pardes.Rect, tx: u16, tw: u16, body_h: u16) void {
+ if (f.highlights.len == 0) return;
+ const s = &p.surface;
+ 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 pardes.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 = synStyle(p, @enumFromInt(f.highlights[hidx])) orelse continue;
+ const cell = s.at(tx + @as(u16, @intCast(pardes.PREFIX_W + c)), r.y + pardes.BOX_H + vr);
+ if (cell.default) continue;
+ cell.style.fg = .{ .rgb = ss.fg };
+ cell.style.bold = ss.bold;
+ }
+ }
+}
diff --git a/src/crt.zig b/src/gui/crt.zig
index 9cf81593..9cf81593 100644
--- a/src/crt.zig
+++ b/src/gui/crt.zig
diff --git a/src/deck.zig b/src/gui/deck.zig
index 1859bae8..1859bae8 100644
--- a/src/deck.zig
+++ b/src/gui/deck.zig
diff --git a/src/gui.zig b/src/gui/gui.zig
index 4fa5d20e..bca0ebb9 100644
--- a/src/gui.zig
+++ b/src/gui/gui.zig
@@ -16,8 +16,8 @@ const posix = std.posix;
const libc = std.c;
const vaxis = @import("vaxis"); // test modes only: the stdin escape-seq parser
const ghostty_vt = @import("ghostty-vt"); // 256-color palette for .index cells
-const pardes = @import("pardes.zig");
-const look = @import("look.zig");
+const pardes = @import("../pardes.zig");
+const look = @import("../look.zig");
const deck = @import("deck.zig");
const crt = @import("crt.zig");
@@ -948,7 +948,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
opts.rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h)));
}
var core = if (opts.load_path) |lp| blk: {
- const bytes = try @import("look.zig").readFile(gpa, lp);
+ const bytes = try @import("../look.zig").readFile(gpa, lp);
defer gpa.free(bytes);
break :blk try pardes.Pardes.initFromDump(gpa, opts, bytes);
} else try pardes.Pardes.init(gpa, opts);
@@ -1370,7 +1370,7 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
opts.rows = grid_rows;
}
const core = if (opts.load_path) |lp| blk: {
- const bytes = try @import("look.zig").readFile(gpa, lp);
+ const bytes = try @import("../look.zig").readFile(gpa, lp);
defer gpa.free(bytes);
break :blk try pardes.Pardes.initFromDump(gpa, opts, bytes);
} else try pardes.Pardes.init(gpa, opts);
diff --git a/src/lsp.zig b/src/lsp/lsp.zig
index 536047a3..536047a3 100644
--- a/src/lsp.zig
+++ b/src/lsp/lsp.zig
diff --git a/src/lsp_zls.zig b/src/lsp/lsp_zls.zig
index 6f5bbcdd..6f5bbcdd 100644
--- a/src/lsp_zls.zig
+++ b/src/lsp/lsp_zls.zig
diff --git a/src/main.zig b/src/main.zig
index 61c0049e..436cf938 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -100,7 +100,7 @@ fn emscriptenMain(argc: c_int, argv: [*]?[*:0]u8) callconv(.c) c_int {
// no argv in the browser: options stay default, state comes from the dump
fn webMain() !void {
- try @import("gui.zig").run(.{});
+ try @import("gui/gui.zig").run(.{});
}
fn nativeMain(init: std.process.Init) !void {
@@ -153,8 +153,8 @@ fn nativeMain(init: std.process.Init) !void {
}
}
switch (pardes.platform) {
- .tty => try @import("tty.zig").run(init, opts),
- .gui => try @import("gui.zig").run(init, opts),
+ .tty => try @import("tty/tty.zig").run(init, opts),
+ .gui => try @import("gui/gui.zig").run(init, opts),
.web => unreachable, // web enters through webMain, never here
}
}
diff --git a/src/output_pane.zig b/src/output_pane.zig
new file mode 100644
index 00000000..4535495c
--- /dev/null
+++ b/src/output_pane.zig
@@ -0,0 +1,72 @@
+//! Output panes: acme's +Errors, a file pane with no file behind it, holding
+//! text the core produced itself (+Search results, +Help, the LSP answer
+//! buffers). The ONLY thing that makes one different from a file pane is that
+//! it cannot be saved — every mode, motion, chord and look works because it IS
+//! a file pane, and that reuse is the point.
+//!
+//! Thin on purpose, for now. The `f.output` tests still scattered through
+//! pardes.zig each ask a one-line question inside kind-agnostic code (does
+//! this pane claim a column? can it be saved? is it a doc?) and pulling them
+//! here would trade one line for a call. What belongs here is what an output
+//! pane KNOWS about itself, and today that is just how one is opened.
+const std = @import("std");
+const pardes = @import("pardes.zig");
+const Pardes = pardes.Pardes;
+const Pane = pardes.Pane;
+const file_pane = @import("file_pane.zig");
+
+/// `content` is gpa-owned and adopted. The name carries `dir` so looks inside
+/// the buffer resolve like anywhere else.
+pub fn open(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;
+}
+
+/// 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.
+pub 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 (pardes.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;
+ file_pane.setContent(p, 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 = open(p, free, dir, "+Help", content) catch {
+ p.gpa.free(content);
+ return;
+ };
+ p.placeDoc(id, free, np);
+}
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 .{};
-}
diff --git a/src/term_pane.zig b/src/term_pane.zig
new file mode 100644
index 00000000..1c03afa7
--- /dev/null
+++ b/src/term_pane.zig
@@ -0,0 +1,222 @@
+//! Terminal panes: everything a Pane does BECAUSE it owns a ghostty-vt
+//! emulator — reading the grid back out as text (for motions and for the
+//! body), translating the emulator's own cell styles into ours, the pty
+//! replies ghostty hands back through a callback, and the edit-buffer undo
+//! snapshots that only exist because a terminal's "content" is a live grid
+//! rather than a []u8.
+//!
+//! What is NOT here: the edit buffer's SEMANTICS (Pane.ovl, surfRow/gridRow)
+//! live on Pane in pardes.zig, because the motion, insert and undo machinery
+//! is shared with file panes verbatim — a terminal in insert mode behaving
+//! exactly like a text file is the whole design, and splitting the row math
+//! away from the code that uses it would only hide that.
+const std = @import("std");
+const ghostty_vt = @import("ghostty-vt");
+const pardes = @import("pardes.zig");
+const Pardes = pardes.Pardes;
+const Pane = pardes.Pane;
+const EditSnap = pardes.EditSnap;
+const Ovl = pardes.Ovl;
+const modal = @import("modal.zig");
+
+/// 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.
+pub 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 body a terminal renders: the viewport's shell rows (prompt rows blanked
+/// outside tty mode) with the edit buffer's lines standing in for the rows it
+/// covers, so what you see is what the motions move over.
+pub fn bodyText(arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
+ const raw = try pane.vt.plainString(arena);
+ 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;
+}
+
+/// tty colors: recolor each body cell from the emulator's own style so shell
+/// output keeps its ansi colors (gated on the Colors toggle by the caller).
+/// 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.
+pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, off: i32) void {
+ const s = &p.surface;
+ 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 + pardes.BOX_H + vr);
+ if (cell.default) continue;
+ cell.style = cellStyle(p, ci);
+ }
+ }
+}
+
+pub fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell) pardes.CellStyle {
+ const style = ci.style();
+ var cs: pardes.CellStyle = .{
+ .fg = ghostColor(p, style.fg_color, false),
+ .bg = ghostColor(p, 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 = palColor(p, 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;
+}
+
+pub fn palColor(p: *Pardes, idx: u8) pardes.Color {
+ if (p.theme().palette) |pal| if (idx < 16) return .{ .rgb = pal[idx] };
+ return .{ .index = idx };
+}
+
+pub fn ghostColor(p: *Pardes, color: ghostty_vt.Style.Color, is_bg: bool) pardes.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| palColor(p, idx),
+ .rgb => |rgb| .{ .rgb = .{ rgb.r, rgb.g, rgb.b } },
+ };
+}
+
+/// 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
+pub 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");
+}
+
+/// the snapshot takes ownership of a COPY of the edit buffer's text
+pub fn snap(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 };
+}
+
+/// undo/redo restores the selection recorded with the snapshot (helix
+/// keeps selections in its history transactions)
+pub fn restoreSnap(p: *Pardes, pane: *Pane, s: EditSnap) void {
+ if (pane.ovl) |o| p.gpa.free(o.text);
+ pane.ovl = s.ovl;
+ pane.cur_row = s.cur_row;
+ pane.cur_col = s.cur_col;
+ pane.cur_pinned = true;
+ pane.vsel = s.vsel;
+ pane.msel.active = false;
+ pane.ensureCursorVisible();
+}
+
+// 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).
+pub 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.?;
+pub fn ptyDeviceAttrs(_: *ghostty_vt.TerminalStream.Handler) DeviceAttrs {
+ return .{};
+}
diff --git a/src/tty.zig b/src/tty/tty.zig
index 0bb7ef63..007abd49 100644
--- a/src/tty.zig
+++ b/src/tty/tty.zig
@@ -7,9 +7,9 @@ const builtin = @import("builtin");
const posix = std.posix;
const libc = std.c;
const vaxis = @import("vaxis");
-const pardes = @import("pardes.zig");
-const tracy = @import("tracy.zig");
-const look = @import("look.zig");
+const pardes = @import("../pardes.zig");
+const tracy = @import("../tracy.zig");
+const look = @import("../look.zig");
extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int;
extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;