//! 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.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.platform == .gui) @import("gui/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 rename diff) has nowhere to step to. steps: bool = false, /// ...and what a step DOES with the row it lands on. Off, the row is a /// LOCATION and its leading word is LOOKED. On, the row is a COMMAND LINE /// and the whole of it is EXECUTED — so walking the list runs each row in /// turn, which is what makes a picker over things that take effect /// immediately (ThemeSel) a plain list of the words you would have typed. /// Both go through the ordinary builtin (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 searchStep: `file_row` answers it too, so a /// file pane whose lines happen to be commands is one word away from /// behaving the same. executes: 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| switch (b) { // The builtin index. Neither steppable nor executable, and that is // a decision rather than an omission: its rows are not locations // (a look on `SPC` would go looking for a file called SPC), and // `executes` — which is exactly what makes ThemeSel a picker — // would mean walking the list runs Kill, Del and Delcol in turn. // Wearing a theme is free; wearing Kill ends the session. The // names in it are still live text, so middle-clicking the ONE you // want does the picking, which is all a picker was for. .Help => .{ .name = config.help_buffer }, // the focus history, one location per row: not a search, but the // same kind of list, so n/N walk it and a row is a look target .Jumplist => .{ .name = config.jumps_buffer, .steps = true }, // the theme ring, one `Theme ` per row. The only buffer whose // rows are COMMANDS rather than locations, so n/N execute them: // walking the list wears each theme, and stopping is picking one. .ThemeSel => .{ .name = config.themes_buffer, .steps = true, .executes = true }, // the font picker, the theme picker one layer down: same rows, // same verb, its own name. It is a BRANCH and not a prong because // FontSel only exists in a gui build (see builtins.zig) — a prong // naming an enum field the tty fold does not have is a compile // error, and a comptime-false `if` is the one form that is not // even analysed there. // // Find (file names) and Grep (file contents) both list locations; // no other builtin opens a buffer, and the day one does it lands // here rather than in a call site. else => blk: { if (pardes.platform == .gui) if (b == .FontSel) break :blk .{ .name = config.fonts_buffer, .steps = true, .executes = true }; break :blk .{ .name = config.search_buffer, .steps = true }; }, }, .query => |k| switch (k) { .hover => .{ .name = config.hover_buffer }, // prose: an action list, a diff, a report about the backend .code_action, .format, .rename, .status, .explain => .{ .name = config.lsp_buffer }, .definition, .declaration, .type_definition, .implementation, .references => .{ .name = config.search_buffer, .steps = true, .jumps = true, }, .document_symbols, .workspace_symbols, .diagnostics, .workspace_diagnostics, .select_refs => .{ .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 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.cur_pinned = true; return pane; } /// 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 out: std.ArrayList(u8) = .empty; 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 continue; 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(); // one line of a file can be the whole file: cut it, but never mid // codepoint — a partial UTF-8 sequence reaches the renderer as a row // and there is nothing sane for it to draw (grepText cuts the same way) var cut = @min(what.len, 120); while (cut > 0 and cut < what.len and what[cut] & 0xc0 == 0x80) cut -= 1; // line 0 is a place with no spot in it (a shell whose cursor is still // the program's): it is written WITHOUT the suffix, which is the same // thing a bare path has always meant to a look. const row = if (j.line == 0) std.fmt.allocPrint(arena, "{s} {s}\n", .{ loc, what[0..cut] }) catch return else std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{ loc, j.line, j.col, what[0..cut] }) catch return; out.appendSlice(arena, row) catch return; } openStepped(p, id, .{ .cmd = .Jumplist }, out.items); } /// 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 `executes` trait makes n/N RUN each row /// rather than look it. So walking the list is trying the themes on, and /// stopping on one is choosing it; there is no picker mode, no preview state /// and nothing to commit or cancel, because every step already did the thing. /// /// 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 out: std.ArrayList(u8) = .empty; for (pardes.themes) |t| { out.print(arena, comptime builtins.word(builtins.Theme) ++ " {s}\n", .{t.name}) catch return; } openStepped(p, id, .{ .cmd = .ThemeSel }, out.items); } /// 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. /// /// GUI only, 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.platform == .gui) { const arena = p.scratch.allocator(); var out: std.ArrayList(u8) = .empty; for (fonts.list(arena, null)) |f| { out.print(arena, comptime builtins.word(builtins.Font) ++ " {s}\n", .{f.name}) catch return; } openStepped(p, id, .{ .cmd = .FontSel }, out.items); } } /// 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; const content = p.gpa.dupe(u8, text) catch 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, from, "", content) catch { p.gpa.free(content); return; }; 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 pane = p.panes[id] orelse return; const arena = p.scratch.allocator(); var out: std.ArrayList(u8) = .empty; if (prefix.len == 0) { // the header names the four KINDS of shortcut a row's columns can // hold, because a blank column is only readable once you know what // would have been in it out.appendSlice(arena, "pardes builtins, and how to run each:\nSPC and its keys, a chord, a button, the\ntopbar - or the name, executed 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.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; out.appendSlice(arena, row.line) catch return; out.append(arena, '\n') catch return; } const content = p.gpa.dupe(u8, out.items) catch return; // the buffer says what made it, so finding the open one is asking that and // not matching its name 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, Origin{ .cmd = .Help })) continue; file_pane.setContent(p, hf, content); setArg(ho, prefix); 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, .{ .cmd = .Help }, prefix, content) catch { p.gpa.free(content); return; }; p.placeDoc(id, free, np); }