//! 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). It IS a file pane — every mode, motion, chord and look works for //! free — and that reuse is the point. //! //! What it is NOT any more is a file pane with a bool on it. An output buffer //! remembers THE COMMAND THAT OPENED IT (`Origin`), and every special case it //! gets is one `traits` lookup on that field. Before this, a dozen places //! re-derived what a pane was from the outside, each asking a different wrong //! question: `endsWith(path, "+Search")` (the NAME decided what a pane WAS, //! which is backwards — a name is a consequence), a `search_kind` field on the //! pane that ran the search rather than on the buffer that answered it, and //! `f.output` booleans sprinkled through kind-agnostic layout code. Now the //! buffer knows, and the table below is the whole answer: one screen you read //! top to bottom to see every way an output pane differs from a file, and one //! row to add to introduce another kind. const std = @import("std"); const pardes = @import("pardes.zig"); const Pardes = pardes.Pardes; const Pane = pardes.Pane; const File = pardes.File; const file_pane = @import("file_pane.zig"); const modal = @import("modal.zig"); const builtins = @import("builtins.zig"); const Builtin = builtins.registry.Builtin(); const config = @import("config.zig"); const lsp = @import("lsp/lsp.zig"); /// the installed fonts, for openFonts. GUI only, behind the same comptime /// branch builtins.zig imports it through — see the note there. const fonts = if (pardes.font_picker) @import("fonts.zig") else struct {}; /// What opened this buffer — THE field, and the only input to `traits`. /// /// Two vocabularies because the core has exactly two: a `Builtin` is a word /// you can execute anywhere, and that covers Find, Grep, Help and every /// language query that has a name (Hover, Diagnostics...). The rest are KEYS — /// helix binds the five gotos and `=` as motions and `/` as a search input, /// and a motion has no word to click. Recording the key's `lsp.Kind` (or /// `.search` for the bare `/`) is not a parallel tag enum: both are the values /// the caller already holds when it opens the buffer. pub const Origin = union(enum) { cmd: Builtin, query: lsp.Kind, /// the bare `/` — the pane's own text, searched in core search, }; /// The cap on a recorded argument, matching the one lspRequest already puts on /// an effect payload. ponytail: a longer pattern is TRUNCATED here, because /// `arg` is a record of what was asked and never the text anything re-runs; if /// something ever re-runs it, this becomes gpa-owned like `content`. pub const max_arg = 128; /// An output buffer's own state, hung off `File.output`. pub const Output = struct { from: Origin, /// the command's ARGUMENT: the pattern a Grep matched, the new name a /// Rename took, the SPC prefix a Help lists. Inline rather than allocated /// — it is a fixed-size record, and an owned slice would buy a free in /// deinitPane and an errdefer at every open site for nothing. arg_buf: [max_arg]u8 = undefined, arg_len: u8 = 0, pub fn arg(o: *const Output) []const u8 { return o.arg_buf[0..o.arg_len]; } }; pub fn setArg(o: *Output, text: []const u8) void { o.arg_len = @intCast(@min(text.len, max_arg)); @memcpy(o.arg_buf[0..o.arg_len], text[0..o.arg_len]); } /// Every way an output pane differs from a file pane. One struct so the /// QUESTIONS are visible even where today every buffer answers alike: a column /// that never varies still says "this is decided here", which is what makes /// the next kind of buffer a row rather than a hunt. pub const Traits = struct { /// the buffer's name (`+Search`...). DERIVED from the command, never the /// thing that identifies it — that inversion is what this file undoes. name: []const u8, /// n/N walk the rows: each is a `path:LINE:COL text` location the ordinary /// look path resolves, so the buffer IS helix's picker. Prose (a hover /// blurb, a formatting diff) has nowhere to step to. /// /// It is also what makes a step ROW-GRAINED (Grain below): one stop per /// row, on the location at its head. A pane whose lines are free text — /// a terminal, a file, a PDF, and the prose buffers here — steps every /// look-able word instead, several to a line. steps: bool = false, /// ...and WHAT THE ROWS ARE. Off, each is a LOCATION with a look-able /// `path:LINE:COL` word inside it. On, each is a COMMAND LINE — the whole /// row, exactly as you would have typed it (ThemeSel's `Theme gruvbox`) — /// with no path in it to pick out. /// /// TWO readers, one fact, which is why the column is named for the fact: /// n/N (lookWalk) select the location at the head of a location row, /// and THE WHOLE LINE of a command row, since the line /// is the unit there. Either way they only select; Enter /// looks what they left, Tab runs it. /// searchStep Looks a location row's leading word and Execs a /// command row whole — still how `]d`/`[d` and acme's /// button-3 arrive somewhere in one gesture. /// Both go through the ordinary builtins (config.look_cmd / exec_cmd), so /// a row does exactly what the matching mouse button on it would. /// /// Nothing about this is output-pane specific, which is why it is a column /// here and not a branch in either reader: `file_row` answers it too, so a /// file pane whose lines happen to be commands is one word away from /// behaving the same. commands: bool = false, /// an answer of exactly ONE row jumps straight there instead of opening /// this buffer at all — helix: the gotos jump on a single location and /// show a picker on several, a symbol list is always a picker. jumps: bool = false, /// a DOCUMENT for layout purposes: claims a column of its own, is a split /// parent, pays for a split. A result list is not — it belongs to the pane /// that asked for it, lands directly below it and takes its rows from /// there, so opening or closing one never resizes a bystander. doc: bool = false, /// there is a file behind it to write. Also what its tag says: no Save to /// offer means the plain pane tail rather than the file one. saves: bool = false, }; /// A REAL file pane, as a row of the same table — so kind-agnostic code asks /// one question and gets one answer whichever it is holding. `name` is unused: /// a file already has a path. const file_row: Traits = .{ .name = "", .doc = true, .saves = true }; /// THE TABLE. Everything above, answered from the command that opened the /// buffer. Exhaustive on purpose: a new `lsp.Kind` or a new output-opening /// builtin should not compile until someone has said what its buffer does. pub fn traits(o: Origin) Traits { return switch (o) { // rows are `location text`, so n/N walk them .search => .{ .name = config.search_buffer, .steps = true }, .cmd => |b| blk: { const meta = builtins.registry.outputTraits(b) orelse break :blk .{ .name = config.search_buffer, .steps = true }; break :blk .{ .name = meta.name, .steps = meta.steps, .jumps = meta.jumps, .commands = meta.commands, }; }, .query => |k| switch (k) { .hover => .{ .name = config.hover_buffer }, // prose: an action list, a diff, a report about the backend .code_action, .format, .status, .explain => .{ .name = config.lsp_buffer }, // Rename responses are edits consumed before an output can open; // the exhaustive table still records the otherwise-unused trait. .rename => .{ .name = config.lsp_buffer }, .definition, .declaration, .type_definition, .implementation, .references => .{ .name = config.search_buffer, .steps = true, .jumps = true, }, // completion lists WHAT COULD GO HERE, one row per candidate's // definition. It does not jump on a single row where the gotos do: // a goto answers a question whose answer is a place, so landing // there IS the answer, whereas the question here is "what can I // write", and being teleported into the one candidate's // declaration instead of being shown it is not that. .document_symbols, .workspace_symbols, .diagnostics, .workspace_diagnostics, .select_refs, .completion => .{ .name = config.search_buffer, .steps = true, }, }, }; } /// The same table asked of a file pane's `output` field, null (a real file) /// included. This is what the kind-agnostic code in pardes.zig calls. pub fn fileTraits(out: ?Output) Traits { return traits((out orelse return file_row).from); } /// How much of a row ONE n/N step selects (Pardes.lookSpanIn). Derived from /// the two columns above rather than a third one, because it is not a fact /// about a buffer — it is what those facts MEAN to the walk. pub const Grain = enum { /// every look-able word, several to a line, in document order. Free text: /// a terminal's scrollback, a file, a PDF, and an output buffer of PROSE, /// where the place you want may be mid-sentence. word, /// the location at the head of the row, and one stop per row. A results /// buffer is a LIST: the words after a row's location are the matched /// text, and stepping onto them was stepping onto the same hit twice. line, /// the whole row: a command list, where the line is the word. whole, }; /// The grain of a pane's rows, off its `output` field — null (a real file) /// included, which is why a file pane is unaffected by any of this. pub fn grain(out: ?Output) Grain { const tr = fileTraits(out); if (tr.commands) return .whole; return if (tr.steps) .line else .word; } /// How the dump spells an origin. A WORD, never an integer, for the reason the /// dump already stores tag words: reordering builtins.zig stays free. Nothing /// collides — a builtin is CamelCase, an lsp.Kind is snake_case, and `/` is /// neither. pub fn word(o: Origin) []const u8 { return switch (o) { .cmd => |b| @tagName(b), .query => |k| @tagName(k), .search => "/", }; } /// the inverse; null for "" (a real file) and for a word this build no longer /// has, which is a dump from another version and not a reason to fail a load pub fn fromWord(w: []const u8) ?Origin { if (w.len == 0) return null; if (std.mem.eql(u8, w, "/")) return .search; if (std.meta.stringToEnum(Builtin, w)) |b| return .{ .cmd = b }; if (std.meta.stringToEnum(lsp.Kind, w)) |k| return .{ .query = k }; return null; } /// Is the results buffer `pane`'s n/N is armed on the one `from` filled? /// `]d`/`[d` are the only keys that care WHICH search is showing — they step /// the diagnostics list when it is up and ask for one when it is not — and /// this is how they ask now that the buffer remembers: `search_pane` is a /// SLOT, so this doubles as the check that the slot is still ours. pub fn resultsFrom(p: *Pardes, pane: *Pane, from: Origin) bool { const rp = p.panes[pane.search_pane orelse return false] orelse return false; const f = rp.file orelse return false; const o = f.output orelse return false; return std.meta.eql(o.from, from); } /// Open one. `content` is gpa-owned and adopted; the NAME comes from the table /// (the caller says what ran, not what to call it) and carries `dir` so looks /// inside the buffer resolve like anywhere else. pub fn open(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u8, content: []u8) !*Pane { const path = try std.fmt.allocPrint(p.gpa, "{s}/{s}", .{ std.mem.trimEnd(u8, dir, "/"), traits(from).name, }); errdefer p.gpa.free(path); const pane = try p.newDocPane(id); var out: Output = .{ .from = from }; setArg(&out, arg); pane.file = .{ .path = path, .content = content, .output = out }; pane.kind = .file; pane.cur_pinned = true; return pane; } /// Land a freshly produced list of rows in the buffer it belongs in — the one /// rule every results buffer follows, whichever side of the core made them. /// /// The SAME command asked again REFILLS the list it already opened rather than /// stacking a byte-identical twin under the pane. That was runSearch's rule /// from the start (right-clicking a word in four places is one +Search walked /// four times) and language answers turned out to need it far more urgently: /// Tab after a dot makes a query an ordinary typing keystroke, and without the /// refill twenty of them fill every slot and the key is eaten for the rest of /// the session — see docs/lsp.md. /// /// What "the same command" means comes off the ORIGIN. A search is identified /// by its PATTERN, so `foo`, `bar`, `foo` re-arms foo's own buffer and leaves /// bar's open; a language query is asked about a different symbol every time /// with the same (usually empty) arg, so the arg cannot tell two apart and the /// KIND is the natural unit — a second `gr` replaces the first list. Same /// directory only, because the rows are written relative to it, and never the /// asking pane itself (a `/` inside a +Search writes its own rows). /// /// `content` is gpa-owned: adopted by the buffer, or freed here when there is /// nowhere to put it. `anchor` is the row n/N step from, null for the top. pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u8, content: []u8, anchor: ?usize) !void { errdefer p.gpa.free(content); const pane = p.panes[id] orelse return error.MissingPane; const by_arg = std.meta.activeTag(from) != .query; for (p.panes, 0..) |slot, i| { if (i == id) continue; const rp = slot orelse continue; const rf = if (rp.file) |*f| f else continue; const o = if (rf.output) |*x| x else continue; if (!std.meta.eql(o.from, from)) continue; if (by_arg and !std.mem.eql(u8, o.arg(), arg)) continue; if (!std.mem.eql(u8, std.fs.path.dirname(rf.path) orelse "", dir)) continue; setArg(o, arg); // a refill that changes NOTHING keeps its place: a right click on an // already-armed word is an `n`, and throwing the list back to the top // only to scroll down to the stepped row is a jump with no information // in it. const same = std.mem.eql(u8, rf.content, content); file_pane.setContent(p, rf, content); if (!same) rf.scroll = 0; p.active = id; if (traits(from).steps) { pane.search_pane = i; pane.search_row = anchor; } return; } const free = p.freeSlot() orelse return error.NoPaneSlots; const np = try open(p, free, dir, from, arg, content); p.placeDoc(id, free, np); p.active = id; // prose is not a list of locations: n/N over a hover blurb would step to // nowhere, so only stepping buffers arm the stepper — and WHICH command // filled it is the buffer's own record, not a field on the asking pane. if (traits(from).steps) { pane.search_pane = free; pane.search_row = anchor; } } fn putPrint(dst: []u8, at: *usize, comptime fmt: []const u8, args: anytype) void { const text = std.fmt.bufPrint(dst[at.*..], fmt, args) catch unreachable; at.* += text.len; } /// The Jumplist builtin: the focus history (Pardes.jumps) written out as text, /// one row per location, oldest first — the same `location text` shape every /// results buffer here has, which is what buys n/N stepping and Look-on-a-row /// for nothing: the leading word is an ordinary look target, and the ordinary /// look path is what goes there. /// /// A RENDERING, never a second list. The rows are spelled from the stack at /// the moment you ask and go stale the moment you jump, exactly like a search /// result — which is also why this opens a fresh buffer per press instead of /// refreshing one the way Help does: Help is a document, this is a snapshot. /// /// How a location is spelled is the rule runSearch already follows: a REAL /// file names itself (its path is absolute, so the row resolves from any /// pane's directory), and everything else — a terminal, an output buffer, an /// image — has no file to point at and gets `@pN`. The trailing text is the /// content line for anything holding text, else the pane's directory: enough /// to recognise the place without opening it. pub fn openJumps(p: *Pardes, id: usize) !void { const arena = p.scratch.allocator(); var len: usize = 0; for (p.jumps[0..p.njumps]) |j| { const jp = p.panes[j.pane] orelse continue; var idbuf: [16]u8 = undefined; const pdf_path: ?[]const u8 = if (comptime pardes.pdf_enabled) jp.pdfPath() else null; const has_path = if (jp.file) |f| f.output == null else pdf_path != null; const loc: []const u8 = if (has_path) (if (jp.file) |f| f.path else pdf_path.?) else std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{j.pane}) catch unreachable; const what: []const u8 = if (jp.file) |f| std.mem.trim(u8, modal.lineSlice(f.content, j.line -| 1), " \t\r") else if (jp.image) |iv| iv.path else if (pdf_path) |path| path else jp.cwdSlice(); var cut = @min(what.len, 120); while (cut > 0 and cut < what.len and what[cut] & 0xc0 == 0x80) cut -= 1; len += if (j.line == 0) std.fmt.count("{s} {s}\n", .{ loc, what[0..cut] }) else std.fmt.count("{s}:{d}:{d} {s}\n", .{ loc, j.line, j.col, what[0..cut] }); } const out = try arena.alloc(u8, len); var at: usize = 0; for (p.jumps[0..p.njumps]) |j| { const jp = p.panes[j.pane] orelse continue; var idbuf: [16]u8 = undefined; const pdf_path: ?[]const u8 = if (comptime pardes.pdf_enabled) jp.pdfPath() else null; const has_path = if (jp.file) |f| f.output == null else pdf_path != null; const loc: []const u8 = if (has_path) (if (jp.file) |f| f.path else pdf_path.?) else std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{j.pane}) catch unreachable; const what: []const u8 = if (jp.file) |f| std.mem.trim(u8, modal.lineSlice(f.content, j.line -| 1), " \t\r") else if (jp.image) |iv| iv.path else if (pdf_path) |path| path else jp.cwdSlice(); var cut = @min(what.len, 120); while (cut > 0 and cut < what.len and what[cut] & 0xc0 == 0x80) cut -= 1; if (j.line == 0) putPrint(out, &at, "{s} {s}\n", .{ loc, what[0..cut] }) else putPrint(out, &at, "{s}:{d}:{d} {s}\n", .{ loc, j.line, j.col, what[0..cut] }); } std.debug.assert(at == out.len); try openStepped(p, id, .{ .cmd = .Jumplist }, out); } /// The ThemeSel builtin: the theme ring written out as one `Theme ` row /// per theme — the ordinary builtin with its argument, exactly the line you /// would type — into a buffer whose `commands` trait says the rows are words /// and not places. n/N therefore select each row WHOLE and Tab runs it, so /// walking the list is trying the themes on and stopping on one is choosing /// it: no picker mode, no preview state, nothing to commit or cancel. /// /// The command's own name comes from the builtin rather than a literal: the /// word is derived from the struct in exactly one place (builtins.word), and a /// rename there must not leave rows here that name something that is gone. pub fn openThemes(p: *Pardes, id: usize) !void { const arena = p.scratch.allocator(); var len: usize = 0; for (pardes.themes) |t| len += std.fmt.count(comptime builtins.word(builtins.Theme) ++ " {s}\n", .{t.name}); const out = try arena.alloc(u8, len); var at: usize = 0; for (pardes.themes) |t| putPrint(out, &at, comptime builtins.word(builtins.Theme) ++ " {s}\n", .{t.name}); std.debug.assert(at == out.len); try openStepped(p, id, .{ .cmd = .ThemeSel }, out); } /// The FontSel builtin: openThemes over the fonts installed on the machine /// instead of the themes compiled into the binary — one `Font ` row /// each, in a buffer whose rows n/N RUN, so walking it wears the fonts and /// stopping picks one. Everything that makes that work is already above; this /// is the same eight lines pointed at a different list. /// /// Only where the shell draws its own text, and the body says so rather than /// the signature: fonts.list and the FontSel origin both exist only there, and /// a comptime-false `if` is what keeps the tty build from analysing either. /// The dead parameters on that build are the honest shape of "this platform /// cannot open one". pub fn openFonts(p: *Pardes, id: usize) !void { if (pardes.font_picker) { const arena = p.scratch.allocator(); const font_list = fonts.list(arena, null); var len: usize = 0; for (font_list) |f| len += std.fmt.count(comptime builtins.word(builtins.Font) ++ " {s}\n", .{f.name}); const out = try arena.alloc(u8, len); var at: usize = 0; for (font_list) |f| putPrint(out, &at, comptime builtins.word(builtins.Font) ++ " {s}\n", .{f.name}); std.debug.assert(at == out.len); try openStepped(p, id, .{ .cmd = .FontSel }, out); } } /// Open a buffer n/N will walk, and arm them on it: the shared tail of every /// builtin that answers with a list. `text` is borrowed (the callers build it /// in the scratch arena) and copied into a gpa buffer the pane adopts. /// /// Focus stays with the pane that ASKED, exactly as it does after a search: /// n/N are read there, and they step the buffer they just armed. fn openStepped(p: *Pardes, id: usize, from: Origin, text: []const u8) !void { const pane = p.panes[id] orelse return error.MissingPane; const content = try p.gpa.dupe(u8, text); errdefer p.gpa.free(content); const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); const free = p.freeSlot() orelse return error.NoPaneSlots; const np = try open(p, free, dir, from, "", content); p.placeDoc(id, free, np); p.active = id; pane.search_pane = free; pane.search_row = null; } /// The Help builtin: THE INDEX of builtins — every one of them, and every way /// to run it — filtered to what `prefix` can still reach, 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. /// /// ONE builtin and not two. The complete index and the mid-chord "what can /// `SPC h` still reach" are the same array (pardes.builtin_rows) read with a /// different prefix — the empty one matches every row, including the builtins /// SPC cannot reach at all, so the reference page IS the filter's degenerate /// case. A second builtin would have been a second renderer over a superset of /// these rows, and the two would have drifted the first time a column moved. pub fn openHelp(p: *Pardes, id: usize, prefix: []const u8) !void { const full_header = "pardes builtins, and how to run each:\nSPC and its keys, a chord, a button, the\ntopbar - or the name, executed anywhere.\n\n"; const group_header = "pardes builtins under SPC"; var len: usize = if (prefix.len == 0) full_header.len else group_header.len + prefix.len * 2 + 2; for (pardes.builtin_rows) |row| { // a path-less builtin filters as the empty path: in the full listing // (which starts with nothing) and out of every group if (!std.mem.startsWith(u8, row.path orelse "", prefix)) continue; len += row.line.len + 1; } const content = try p.gpa.alloc(u8, len); var at: usize = 0; if (prefix.len == 0) { @memcpy(content[0..full_header.len], full_header); at = full_header.len; } else { @memcpy(content[0..group_header.len], group_header); at = group_header.len; for (prefix) |c| { content[at] = ' '; content[at + 1] = c; at += 2; } content[at] = '\n'; content[at + 1] = '\n'; at += 2; } for (pardes.builtin_rows) |row| { if (!std.mem.startsWith(u8, row.path orelse "", prefix)) continue; @memcpy(content[at..][0..row.line.len], row.line); at += row.line.len; content[at] = '\n'; at += 1; } std.debug.assert(at == content.len); // content is handed off unfreed on purpose: openRead adopts it or frees // it, and nothing between the alloc above and this line can fail. return openRead(p, id, .{ .cmd = .Help }, prefix, content); } /// The Config builtin: WHERE the startup config file is, as one line of text. /// /// The PATH and not the file. `Look` on the line opens it when it exists, and /// when it does not the path is still the entire answer — "put your Theme and /// Font lines HERE" is the question this is asked, and a builtin that opened /// an empty buffer instead would have said nothing. The core never resolved /// it: the launcher did, before init (Options.startup_config_path), so this /// prints what was actually consulted rather than recomputing a guess that /// could differ from it. pub fn openConfig(p: *Pardes, id: usize) !void { const content = if (p.opts.startup_config_path) |path| try std.fmt.allocPrint(p.gpa, "{s}\n", .{path}) else // the browser, and a native launch with no HOME to build one from try p.gpa.dupe(u8, "no per-user config path\n"); return openRead(p, id, .{ .cmd = .Config }, "", content); } /// Open a buffer you READ, and go there: the shared tail of every builtin /// whose answer is a document rather than a list. Asking again REFRESHES the /// one already open instead of stacking a twin beside it — found by its /// ORIGIN, never by matching its name, for the reason the whole file exists. /// /// The mirror of `openStepped`, and the difference is the two lines at the /// ends: focus comes HERE (you asked to read it) where a results buffer /// leaves you in the pane that asked, and n/N are not armed, because prose /// has nowhere to step to. /// /// `content` is gpa-owned: adopted by the buffer, or freed here when there is /// nowhere to put it. fn openRead(p: *Pardes, id: usize, from: Origin, arg: []const u8, content: []u8) !void { errdefer p.gpa.free(content); const pane = p.panes[id] orelse return error.MissingPane; for (p.panes, 0..) |slot, i| { const hp = slot orelse continue; const hf = if (hp.file) |*f| f else continue; const ho = if (hf.output) |*o| o else continue; if (!std.meta.eql(ho.from, from)) continue; file_pane.setContent(p, hf, content); setArg(ho, arg); 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 return error.NoPaneSlots; const np = try open(p, free, dir, from, arg, content); p.placeDoc(id, free, np); p.active = free; }