diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-06 18:11:36 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-07 13:59:12 -0300 |
| commit | 60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch) | |
| tree | 310fc734173cf771881f4691c71909135fadde97 /src/output_pane.zig | |
| parent | fa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff) | |
| download | pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.tar.gz pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.zip | |
Refactor panes and filesystem; replace FUSE with 9P
Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples.
Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill.
Diffstat (limited to 'src/output_pane.zig')
| -rw-r--r-- | src/output_pane.zig | 695 |
1 files changed, 0 insertions, 695 deletions
diff --git a/src/output_pane.zig b/src/output_pane.zig deleted file mode 100644 index f4721136..00000000 --- a/src/output_pane.zig +++ /dev/null @@ -1,695 +0,0 @@ -//! 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_pane = @import("file_pane.zig"); -const modal = @import("modal.zig"); -const builtins = @import("builtins.zig"); -const runtime_config = @import("runtime_config.zig"); -const effect_sources = @import("effect_sources.zig"); -const Builtin = builtins.registry.Builtin(); -const config = @import("config.zig"); -const build_options = @import("pardes_config"); -const dump = @import("dump.zig"); -const lsp = @import("lsp/lsp.zig"); -const gui_shader_source_mode = effect_sources.guiShaderSourceMode(); - -/// the installed fonts, for openFonts. GUI only, behind the same comptime -/// branch builtins.zig imports it through — see the note there. -const fonts = if (builtins.capabilities.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, - /// acme's `+Errors`: whatever a script wrote to a pane's `errors` file or - /// to the top-level `cons`. Not a command at all — the third vocabulary - /// is "somebody else's output", and it has no word to click because the - /// writer is a process, not a keystroke. - errors, -}; - -/// The exact command identity. A search prompt is bounded by the same one-line -/// cap as a tag, so retaining that whole bound keeps refill and dump/restore -/// identity exact without adding a per-output allocation. -pub const max_arg = dump.max_origin_arg; - -/// An output buffer's own state, hung off `file_pane.State.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: - /// tag input already enforces this exact cap. - arg_buf: [max_arg]u8 = undefined, - arg_len: u16 = 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) error{ArgumentTooLong}!void { - if (text.len > max_arg) return error.ArgumentTooLong; - o.arg_len = @intCast(text.len); - @memcpy(o.arg_buf[0..o.arg_len], text); -} - -/// Every way an output pane differs from a file pane. Manual builtins already -/// declare this exact row beside their implementation; use that schema here -/// too instead of copying it into a parallel struct. -pub const Traits = builtins.OutputTraits; - -/// 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 }, - // A transcript, not a list: rows are whatever a program printed, so - // n/N walks its words like any prose buffer, and there is nothing to - // Save — acme's +Errors is not a file either. - .errors => .{ .name = config.errors_buffer, .doc = true }, - .cmd => |b| builtins.registry.outputTraits(b) orelse unreachable, - .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 usually resolves to edit records the core consumes before - // any buffer opens; what RENDERS is the multi-file PREVIEW — one - // location row per would-be edit — which n/N step like any list. - .rename => .{ .name = config.search_buffer, .steps = true }, - .definition, .declaration, .type_definition, .implementation, .references => .{ - .name = config.search_buffer, - .steps = true, - .jumps = true, - }, - // The hierarchy kinds behave like references: a list of places, - // and a lone answer (one caller, one subtype) is a jump. - .incoming_calls, .outgoing_calls, .supertypes, .subtypes => .{ - .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 => "/", - .errors => config.errors_buffer, - }; -} - -/// 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.mem.eql(u8, w, config.errors_buffer)) return .errors; - if (std.meta.stringToEnum(Builtin, w)) |b| - if (builtins.registry.outputTraits(b) != null) 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); - var out: Output = .{ .from = from }; - try setArg(&out, arg); - const pane = try p.newDocPane(id); - pane.file = .{ .path = path, .content = content, .output = out }; - 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; - try 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; - p.armLookWalk(i); - } - 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; - p.armLookWalk(free); - } -} - -/// 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 { - var out: std.Io.Writer.Allocating = .init(p.gpa); - errdefer out.deinit(); - 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) - try out.writer.print("{s} {s}\n", .{ loc, what[0..cut] }) - else - try out.writer.print("{s}:{d}:{d} {s}\n", .{ loc, j.line, j.col, what[0..cut] }); - } - const content = try out.toOwnedSlice(); - try openStepped(p, id, .{ .cmd = .Jumplist }, content); -} - -/// The ThemeSel builtin: the theme ring written out as one `Theme <name>` 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 runtime setting descriptor rather -/// than a second literal. The descriptor generates the builtin too, so a -/// rename cannot leave picker rows naming a command that is gone. -pub fn openThemes(p: *Pardes, id: usize) !void { - var out: std.Io.Writer.Allocating = .init(p.gpa); - errdefer out.deinit(); - for (pardes.themes) |t| - try out.writer.print(comptime runtime_config.findAction(.theme).?.word ++ " {s}\n", .{t.name}); - const content = try out.toOwnedSlice(); - try openStepped(p, id, .{ .cmd = .ThemeSel }, content); -} - -/// The FontSel builtin: openThemes over the fonts installed on the machine -/// instead of the themes compiled into the binary — one `Font <name>` 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 one-pass writer pointed at a different list. -/// -/// Only where the shell draws its own text. The same `font_picker` availability -/// bit that generates the Font/FontSel builtins keeps non-GUI builds from -/// analysing font discovery here. -pub fn openFonts(p: *Pardes, id: usize) !void { - if (builtins.capabilities.font_picker) { - const arena = p.scratch.allocator(); - const font_list = fonts.list(arena, null); - var out: std.Io.Writer.Allocating = .init(p.gpa); - errdefer out.deinit(); - for (font_list) |f| - try out.writer.print(comptime runtime_config.findAction(.font).?.word ++ " {s}\n", .{f.name}); - const content = try out.toOwnedSlice(); - try openStepped(p, id, .{ .cmd = .FontSel }, content); - } -} - -/// Open a buffer n/N will walk, and arm them on it: the shared tail of every -/// builtin that answers with a list. `content` is gpa-owned and adopted by the -/// new pane, or freed here if opening it fails. -/// -/// 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, content: []u8) !void { - errdefer p.gpa.free(content); - const pane = p.panes[id] orelse return error.MissingPane; - 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; - p.armLookWalk(free); -} - -/// 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. -fn helpContent(gpa: std.mem.Allocator, prefix: []const u8) ![]u8 { - 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"; - // The LANGUAGE KEYS are the one part of the keymap Help would otherwise - // never show: they are motions and modes, not words, so no builtin row - // carries them — yet they are the keys a reader comes looking for. Full - // listing only; a mid-chord `SPC l` view stays a pure filter. - const language_footer = - "\nlanguage keys (motions, not words):\n" ++ - "gd gD gy gi gr goto: definition,\n" ++ - " declaration, type-def,\n" ++ - " implementation, refs\n" ++ - "]d [d ]D [D diagnostics: next,\n" ++ - " prev, last, first\n" ++ - "= format (applies, one\n" ++ - " undo step)\n" ++ - "Tab after a . completion, in insert\n" ++ - "C-left-click definition, by mouse\n" ++ - "SPC l ... hover, rename, symbols,\n" ++ - " calls, types: above\n"; - var len: usize = if (prefix.len == 0) - full_header.len + language_footer.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 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; - } - if (prefix.len == 0) { - @memcpy(content[at..][0..language_footer.len], language_footer); - at += language_footer.len; - } - std.debug.assert(at == content.len); - return content; -} - -pub fn openHelp(p: *Pardes, id: usize, prefix: []const u8) !void { - const content = try helpContent(p.gpa, prefix); - // 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); -} - -test "full Help renders every builtin row, then the language keys" { - const content = try helpContent(std.testing.allocator, ""); - defer std.testing.allocator.free(content); - - // FIRST blank line: the end of the header (the footer opens with one too) - const body = content[(std.mem.indexOf(u8, content, "\n\n") orelse - return error.MissingHelpHeader) + 2 ..]; - var lines = std.mem.splitScalar(u8, body, '\n'); - for (pardes.builtin_rows) |row| - try std.testing.expectEqualStrings(row.line, lines.next() orelse - return error.MissingBuiltinHelpRow); - // ...and after the last row, the language-keys section: the one part of - // the keymap no builtin row can carry, closing the page. - try std.testing.expectEqualStrings("", lines.next() orelse - return error.MissingLanguageKeys); - try std.testing.expectEqualStrings("language keys (motions, not words):", lines.next() orelse - return error.MissingLanguageKeys); - try std.testing.expect(std.mem.indexOf(u8, body, "\ngd gD gy gi gr goto: definition,\n") != null); - // the group view stays a pure filter: no footer under a prefix - const group = try helpContent(std.testing.allocator, "l"); - defer std.testing.allocator.free(group); - try std.testing.expect(std.mem.indexOf(u8, group, "language keys") == null); -} - -/// The complete live Config report: the generated settings and the host facts -/// needed to interpret them. The startup path remains ordinary selectable text -/// in the report, so Look still opens the exact file the launcher consulted. -pub fn openConfig(p: *Pardes, id: usize) !void { - var out: std.Io.Writer.Allocating = .init(p.gpa); - errdefer out.deinit(); - try runtime_config.writeReport(&out.writer, .{ - .startup_config_path = p.opts.startup_config_path, - .platform = @tagName(pardes.platform), - .theme_name = p.theme().name, - .compiled_default_shell = config.default_shell, - .gui_shader_source_mode = if (gui_shader_source_mode) |mode| - mode.label() - else - null, - .hover_delay_frames = config.look_preview_delay_frames, - .native_images = p.native_images, - .capabilities = builtins.capabilities, - .state = &p.settings, - }); - const content = try out.toOwnedSlice(); - return openRead(p, id, .{ .cmd = .Config }, "", content); -} - -/// The message-row log, oldest first — the lines that were said in passing and -/// then cleared by the next keystroke. -pub fn openMessages(p: *Pardes, id: usize) !void { - var out: std.Io.Writer.Allocating = .init(p.gpa); - errdefer out.deinit(); - var i: usize = 0; - while (p.messageLog(i)) |m| : (i += 1) { - if (m.pane != 0xff) try out.writer.print("{d}: ", .{m.pane}); - try out.writer.writeAll(m.slice()); - if (m.repeats > 1) try out.writer.print(" (x{d})", .{m.repeats}); - try out.writer.writeByte('\n'); - } - if (i == 0) try out.writer.writeAll("nothing has been said yet\n"); - const content = try out.toOwnedSlice(); - return openRead(p, id, .{ .cmd = .Messages }, "", content); -} - -/// The version banner plus the embedded CHANGELOG, so an installed binary can -/// say what it is and what changed without a repository beside it. -pub fn openChangelog(p: *Pardes, id: usize) !void { - var out: std.Io.Writer.Allocating = .init(p.gpa); - errdefer out.deinit(); - try out.writer.print("pardes {s}\n\n", .{build_options.version}); - try out.writer.writeAll(@embedFile("CHANGELOG.md")); - const content = try out.toOwnedSlice(); - return openRead(p, id, .{ .cmd = .Changelog }, "", content); -} - -/// Print the implementation that this build actually uses for one effect. -/// Sources are build inputs embedded as bytes, so this stays useful from an -/// installed binary with no repository beside it. -pub fn openEffectCode(p: *Pardes, id: usize, argument: []const u8) !void { - const name = std.mem.trim(u8, argument, " \t\r\n"); - const setting = runtime_config.find(name) orelse return error.UnknownEffect; - switch (setting.action) { - .transition, .scene => {}, - else => return error.NotAnEffect, - } - if (!setting.enabled(builtins.capabilities)) return error.EffectUnavailable; - const segments = effect_sources.forSetting(setting) orelse - return error.EffectUnavailable; - - var out: std.Io.Writer.Allocating = .init(p.gpa); - errdefer out.deinit(); - try out.writer.print("EffectCode {s} ({s})\n", .{ setting.word, @tagName(effect_sources.backend) }); - if (gui_shader_source_mode) |mode| - try out.writer.print("GUI shader source: {s}\n", .{mode.label()}); - try out.writer.writeByte('\n'); - for (segments) |segment| { - try out.writer.print("--- {s} ---\n", .{segment.path}); - try out.writer.writeAll(segment.source); - if (!std.mem.endsWith(u8, segment.source, "\n")) try out.writer.writeByte('\n'); - try out.writer.writeByte('\n'); - } - const content = try out.toOwnedSlice(); - return openRead(p, id, .{ .cmd = .EffectCode }, setting.word, 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. -/// Put a report in this directory's `+Errors` buffer — acme's own name for -/// output that came from the PROGRAM rather than from a word somebody clicked. -/// Refills the one already open rather than stacking a twin, which is what a -/// second failed filter wants: the newest reason is the one being read. -pub fn openErrors(p: *Pardes, id: usize, content: []u8) !void { - return openRead(p, id, .errors, "", content); -} - -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; - try setArg(ho, arg); - 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 return error.NoPaneSlots; - const np = try open(p, free, dir, from, arg, content); - p.placeDoc(id, free, np); - p.active = free; -} - -test "dump origins accept only builtins that actually own output panes" { - try std.testing.expectEqual(Origin{ .cmd = .Help }, fromWord("Help").?); - try std.testing.expect(fromWord("Kill") == null); - try std.testing.expect(fromWord("Theme") == null); -} - -test "result refill identity retains the full bounded argument" { - const p = try Pardes.init(std.testing.allocator, .{ - .tty_only = true, - .cols = 80, - .rows = 24, - }); - defer p.deinit(); - - var first: [max_arg]u8 = @splat('a'); - var second = first; - first[200] = 'x'; - second[200] = 'y'; - - try fillResults( - p, - 0, - "/tmp", - .search, - &first, - try p.gpa.dupe(u8, "first\n"), - null, - ); - const first_id = p.panes[0].?.search_pane orelse return error.MissingResults; - const next_slot = p.freeSlot(); - - try fillResults( - p, - 0, - "/tmp", - .search, - &first, - try p.gpa.dupe(u8, "refilled\n"), - null, - ); - try std.testing.expectEqual(first_id, p.panes[0].?.search_pane.?); - try std.testing.expectEqual(next_slot, p.freeSlot()); - try std.testing.expectEqualStrings("refilled\n", p.panes[first_id].?.file.?.content); - - try fillResults( - p, - 0, - "/tmp", - .search, - &second, - try p.gpa.dupe(u8, "second\n"), - null, - ); - try std.testing.expect(p.panes[0].?.search_pane.? != first_id); - - var output: Output = .{ .from = .search }; - var oversized: [max_arg + 1]u8 = @splat('z'); - try std.testing.expectError(error.ArgumentTooLong, setArg(&output, &oversized)); - const slot_before_error = p.freeSlot(); - try std.testing.expectError( - error.ArgumentTooLong, - fillResults( - p, - 0, - "/tmp", - .search, - &oversized, - try p.gpa.dupe(u8, "must be freed\n"), - null, - ), - ); - try std.testing.expectEqual(slot_before_error, p.freeSlot()); -} |
