summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-30 23:35:45 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commit19f7322100062b7c1adcde3376063ce6c1d8c72d (patch)
treeb76347dffe0347c29a38b4cad6031678683dba74
parent753d5451ca7d09dbc5ff44f3c2dcf9a47fb87f96 (diff)
downloadpardes-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.
-rw-r--r--build.zig6
-rw-r--r--docs/lsp-evaluation.md4
-rw-r--r--docs/lsp.md4
-rw-r--r--next-steps.txt2
-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
-rw-r--r--test/deck/replay.zig2
-rw-r--r--test/lspbench.zig24
18 files changed, 1058 insertions, 733 deletions
diff --git a/build.zig b/build.zig
index 87df6727..c11ccccd 100644
--- a/build.zig
+++ b/build.zig
@@ -122,7 +122,7 @@ pub fn build(b: *std.Build) void {
if (target.result.os.tag == .freebsd or target.result.os.tag == .netbsd or target.result.os.tag == .openbsd)
root_mod.linkSystemLibrary("util", .{});
- // The language backend (src/lsp_zls.zig) links ZLS as a MODULE — no zls
+ // The language backend (src/lsp/lsp_zls.zig) links ZLS as a MODULE — no zls
// binary, no JSON-RPC, no protocol. Everything except the browser gets it;
// emscripten does not, because the web shell has no threads and no-ops the
// lsp effect anyway, so paying to compile an analyser it can never call
@@ -639,7 +639,7 @@ pub fn build(b: *std.Build) void {
const crt_test = b.addTest(.{ .root_module = b.createModule(.{
.target = target,
.optimize = optimize,
- .root_source_file = b.path("src/crt.zig"),
+ .root_source_file = b.path("src/gui/crt.zig"),
}) });
unit_step.dependOn(&b.addRunArtifact(crt_test).step);
// deck.zig is pure std too; replay.zig drives it with slices of real
@@ -652,7 +652,7 @@ pub fn build(b: *std.Build) void {
deck_test.root_module.addImport("deck", b.createModule(.{
.target = target,
.optimize = optimize,
- .root_source_file = b.path("src/deck.zig"),
+ .root_source_file = b.path("src/gui/deck.zig"),
}));
unit_step.dependOn(&b.addRunArtifact(deck_test).step);
}
diff --git a/docs/lsp-evaluation.md b/docs/lsp-evaluation.md
index 5f660640..1756ec94 100644
--- a/docs/lsp-evaluation.md
+++ b/docs/lsp-evaluation.md
@@ -1,6 +1,6 @@
# Three language backends, measured
-Same core, same seam (`src/lsp.zig`), same 17-probe harness (`zig build lspbench`)
+Same core, same seam (`src/lsp/lsp.zig`), same 17-probe harness (`zig build lspbench`)
over the same corpus. Three jj workspaces, three independent implementations,
one function each. C landed; see **Decision** below.
@@ -84,7 +84,7 @@ real binary through the helix keymap, and pass `hxdiff` (360) and `hxparity`
## Decision
-**C landed.** It is what `src/lsp_zls.zig` is, and ZLS is now a fetched
+**C landed.** It is what `src/lsp/lsp_zls.zig` is, and ZLS is now a fetched
dependency (`build.zig.zon`, pinned to commit `3e0d0820` on the 0.16.x branch)
so it sits in `zig-pkg/` like everything else and the build is reproducible
from the .zon alone — no path dependency on a local checkout.
diff --git a/docs/lsp.md b/docs/lsp.md
index 2a4c8784..91d07231 100644
--- a/docs/lsp.md
+++ b/docs/lsp.md
@@ -5,7 +5,7 @@ Three things landed together, and only the first two are permanent:
1. **An async execution model.** The core stays a state machine; slow work goes
to a worker and comes back as an event.
2. **A helix-exact keymap** for every LSP command.
-3. **A seam** (`src/lsp.zig`) with exactly one function behind it, so competing
+3. **A seam** (`src/lsp/lsp.zig`) with exactly one function behind it, so competing
backends can be swapped, measured, and thrown away.
## The async model
@@ -126,7 +126,7 @@ document — which matters precisely when the pane you are in is the problem.
## Writing a backend
-`src/lsp.zig` is the seam. An implementation supplies three things and touches
+`src/lsp/lsp.zig` is the seam. An implementation supplies three things and touches
nothing else:
```zig
diff --git a/next-steps.txt b/next-steps.txt
index 3026bdb5..747dfd34 100644
--- a/next-steps.txt
+++ b/next-steps.txt
@@ -10,4 +10,4 @@
- There's some bad performance when dealing with specially large files or long lines; write a perf harness for this and optimize the performance.
- The Look and Exec should be regular semantic entities, i.e. their only special feature should be the keys/shortcuts they're assigned so we could execute Look main.zig and it would call the regular look on it, but also we could Look some string like @`ls -la` (you can change the syntax, but this also depends on that syntax change so it's centralized and easy to change later). This basically will allow you to make some big refactorings and cut a lot of code (hopefully) and also allow some funny things like @`Look .` (basically allowing nested things like this for no good reason other than, it's elegant and it's fun!)
- We'll make the focus history a meaningful and strong semantic entity: instead of being a stack of Panes, it'll be a stack of locations. This will allow us to implement vim's ctrl-i and ctrl-o that will just move the current focus up or down on that stack. there'll be also a new builtin that will toggle the focus of the latest panes, basically moving the current focus by changing the current pointer to the location on the stack. The Jumplist (new builtin) will be an Output pane with each line being a location that can be Looked, n/N should work.
-- The main pardes.zig file should hold the logic that applies to all kinds of panes and backends. Maybe right now the pane specifiticies are too intermingled. Maintain the panes logics into their own files, right now tty.zig exists but I don't think output panes live by themselves nor file panes on their own files. Also, each builtin command should be implemented as a struct, the current logic on switching on the enum should use some comptime logic to build the tagged union/enum from those structs now. Also I think it's time to create a few directories on @src now since some files are specific to some backends and others are part of the core, it should be clear just by looking at the directory structure (don't add more dir layers, just new dirs under src).
++ The main pardes.zig file should hold the logic that applies to all kinds of panes and backends. Maybe right now the pane specifiticies are too intermingled. Maintain the panes logics into their own files, right now tty.zig exists but I don't think output panes live by themselves nor file panes on their own files. Also, each builtin command should be implemented as a struct, the current logic on switching on the enum should use some comptime logic to build the tagged union/enum from those structs now. Also I think it's time to create a few directories on @src now since some files are specific to some backends and others are part of the core, it should be clear just by looking at the directory structure (don't add more dir layers, just new dirs under src).
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;
diff --git a/test/deck/replay.zig b/test/deck/replay.zig
index 9cf829d6..475f7476 100644
--- a/test/deck/replay.zig
+++ b/test/deck/replay.zig
@@ -1,5 +1,5 @@
//! Replays slices of real Steam Deck recordings (deckcap .zon captures)
-//! through src/deck.zig and checks the aggregate actions, so the deck
+//! through src/gui/deck.zig and checks the aggregate actions, so the deck
//! control mapping is exercised without the device.
const std = @import("std");
diff --git a/test/lspbench.zig b/test/lspbench.zig
index 9eddf2f0..ffea0e20 100644
--- a/test/lspbench.zig
+++ b/test/lspbench.zig
@@ -61,17 +61,17 @@ const anchors = [_]Anchor{
// a call to a function defined in the same file
.{ .file = "src/pardes.zig", .needle = "modal.hxLineCount(text)", .at = 6, .kind = .definition, .expect = "modal.zig" },
// a plain local/decl reference within one file
- .{ .file = "src/lsp.zig", .needle = "lineCol(source", .at = 0, .kind = .definition, .expect = "lsp.zig" },
+ .{ .file = "src/lsp/lsp.zig", .needle = "lineCol(source", .at = 0, .kind = .definition, .expect = "lsp.zig" },
// a std reference: needs the zig lib dir, which is the first thing a
// single-file backend cannot do
- .{ .file = "src/lsp.zig", .needle = "std.mem.count(u8", .at = 8, .kind = .definition, .expect = "mem.zig" },
+ .{ .file = "src/lsp/lsp.zig", .needle = "std.mem.count(u8", .at = 8, .kind = .definition, .expect = "mem.zig" },
// hover over the same symbol
- .{ .file = "src/lsp.zig", .needle = "lineCol(source", .at = 0, .kind = .hover, .expect = "" },
+ .{ .file = "src/lsp/lsp.zig", .needle = "lineCol(source", .at = 0, .kind = .hover, .expect = "" },
// the file's own symbols
- .{ .file = "src/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .document_symbols, .expect = "Kind" },
+ .{ .file = "src/lsp/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .document_symbols, .expect = "Kind" },
.{ .file = "src/modal.zig", .needle = "pub fn ", .at = 7, .kind = .document_symbols, .expect = "" },
// references to a symbol used in several places
- .{ .file = "src/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .references, .expect = "" },
+ .{ .file = "src/lsp/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .references, .expect = "" },
// Diagnostics and format probe a DELIBERATELY BROKEN fixture, never the
// real source. On a clean corpus the correct answer is nothing, which is
// byte-identical to "this backend has no diagnostics" — a blind spot that
@@ -80,16 +80,16 @@ const anchors = [_]Anchor{
// and a misformatted fn (needs a formatter), so both probes have real work.
.{ .file = "test/lspfixture/broken.zig", .needle = "unused_local", .at = 0, .kind = .diagnostics, .expect = "broken.zig" },
// the remaining kinds, probed once each so the matrix is complete
- .{ .file = "src/lsp.zig", .needle = "lineCol(source", .at = 0, .kind = .declaration, .expect = "" },
+ .{ .file = "src/lsp/lsp.zig", .needle = "lineCol(source", .at = 0, .kind = .declaration, .expect = "" },
// cursor on `out`, whose type is `*std.Io.Writer` — a type_definition probe
// wants a NAME whose type must be resolved, not the type spelled out
- .{ .file = "src/lsp.zig", .needle = "out: *std.Io.Writer", .at = 0, .kind = .type_definition, .expect = "" },
- .{ .file = "src/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .implementation, .expect = "" },
- .{ .file = "src/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .select_refs, .expect = "" },
+ .{ .file = "src/lsp/lsp.zig", .needle = "out: *std.Io.Writer", .at = 0, .kind = .type_definition, .expect = "" },
+ .{ .file = "src/lsp/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .implementation, .expect = "" },
+ .{ .file = "src/lsp/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .select_refs, .expect = "" },
.{ .file = "test/lspfixture/broken.zig", .needle = "badly_spaced", .at = 0, .kind = .format, .expect = "" },
- .{ .file = "src/lsp.zig", .needle = "pub fn query", .at = 7, .kind = .code_action, .expect = "" },
- .{ .file = "src/lsp.zig", .needle = "pub fn query", .at = 7, .kind = .rename, .expect = "" },
- .{ .file = "src/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .workspace_symbols, .expect = "" },
+ .{ .file = "src/lsp/lsp.zig", .needle = "pub fn query", .at = 7, .kind = .code_action, .expect = "" },
+ .{ .file = "src/lsp/lsp.zig", .needle = "pub fn query", .at = 7, .kind = .rename, .expect = "" },
+ .{ .file = "src/lsp/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .workspace_symbols, .expect = "" },
.{ .file = "test/lspfixture/broken.zig", .needle = "unused_local", .at = 0, .kind = .workspace_diagnostics, .expect = "broken.zig" },
};