summaryrefslogtreecommitdiff
path: root/src/output_pane.zig
blob: 9021e47905dd41fce2a565a50495c9745c5a4189 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
//! 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 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 => "/",
        .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";
    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 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);
    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 enabled builtin row" {
    const content = try helpContent(std.testing.allocator, "");
    defer std.testing.allocator.free(content);

    const body = content[(std.mem.lastIndexOf(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);
    // The renderer terminates every row with a newline, so only split's empty
    // trailing field may remain. Any extra non-empty field is an unregistered
    // Help row and any missing row already failed in the loop above.
    try std.testing.expectEqualStrings("", lines.next() orelse
        return error.MissingHelpTerminator);
    try std.testing.expect(lines.next() == 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 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.
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());
}