summaryrefslogtreecommitdiff
path: root/src/Output.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-27 21:19:08 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commit2f3f8776613ebb357ff6458267c3d79c79944968 (patch)
tree5582cd51cc9b586cb68f05d67f1bed14efa1e80e /src/Output.zig
parent2352f0079404f31d458745e687d195d8f9c5b39f (diff)
downloadpardes-2f3f8776613ebb357ff6458267c3d79c79944968.tar.gz
pardes-2f3f8776613ebb357ff6458267c3d79c79944968.zip
Move the Output namespace out of panes.zig into Output.zig
Pure move: the body of `pub const Output = struct { ... }` is now the file Output.zig, dedented, with the imports it uses; panes.zig keeps `pub const Output = @import("Output.zig");`, so `panes.Output.x` callers are unchanged. decorateRow and RowDecoration.styleAt become pub because File calls them from another file now. Output.zig joins the embedded source list. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src/Output.zig')
-rw-r--r--src/Output.zig782
1 files changed, 782 insertions, 0 deletions
diff --git a/src/Output.zig b/src/Output.zig
new file mode 100644
index 00000000..00122b16
--- /dev/null
+++ b/src/Output.zig
@@ -0,0 +1,782 @@
+//! Output buffers (+Errors, search and jump results, help, messages, the
+//! changelog): their rows, traits and look targets.
+const panes = @import("panes.zig");
+const std = @import("std");
+const pardes = @import("pardes.zig");
+const config = @import("config.zig");
+const Pardes = pardes.Pardes;
+const modal = @import("modal.zig");
+const dump = @import("dump.zig");
+const limits = @import("memory.zig").limits;
+const builtins = @import("builtins.zig");
+const effect_sources = @import("effect_sources.zig");
+const build_options = @import("pardes_config");
+const lsp = @import("lsp/lsp.zig");
+const look = @import("look.zig");
+const locations = @import("locations.zig");
+const Pane = panes.Pane;
+const File = panes.File;
+
+const Builtin = builtins.registry.Builtin();
+const gui_shader_source_mode = effect_sources.guiShaderSourceMode();
+
+const fonts = if (builtins.capabilities.font_picker) @import("fonts.zig") else struct {};
+
+pub const Origin = union(enum) {
+ cmd: Builtin,
+ query: lsp.Kind,
+ search,
+ errors,
+ pdf_links,
+};
+
+pub const max_arg = dump.max_origin_arg;
+
+pub const State = struct {
+ from: Origin,
+ arg_buf: [max_arg]u8 = undefined,
+ arg_len: u16 = 0,
+
+ pub fn arg(o: *const State) []const u8 {
+ return o.arg_buf[0..o.arg_len];
+ }
+};
+
+const RowDecoration = struct {
+ location_end: usize = 0,
+ prefix_end: usize = 0,
+ declaration: bool = false,
+ mark_start: usize = 0,
+ mark_end: usize = 0,
+ severity: enum { none, err, warning, info, hint } = .none,
+
+ pub fn styleAt(row: RowDecoration, p: *const Pardes, byte: usize) ?pardes.CellStyle {
+ // Body paper changes immediately, so its ink must change with it
+ // even while the surrounding tags are still fading.
+ const chrome = pardes.ChromeTheme.fromTheme(p.theme());
+ if (row.declaration or byte < @max(row.location_end, row.prefix_end))
+ return .{ .fg = .{ .rgb = chrome.lineno } };
+ if (byte < row.mark_start or byte >= row.mark_end) return null;
+ if (row.severity == .none) return .{
+ .fg = .{ .rgb = chrome.search_fg },
+ .bg = .{ .rgb = chrome.search_bg },
+ };
+ return .{ .fg = .{ .rgb = switch (row.severity) {
+ .err => chrome.diagnostic_error,
+ .warning => chrome.diagnostic_warning,
+ .info => chrome.diagnostic_info,
+ .hint => chrome.diagnostic_hint,
+ .none => unreachable,
+ } }, .bold = true };
+ }
+};
+
+fn rowDecoration(output: ?State, line: []const u8) RowDecoration {
+ return decorateRow(output, line, null);
+}
+
+pub fn decorateRow(output: ?State, line: []const u8, metadata: ?locations.Row) RowDecoration {
+ const origin = (output orelse return .{}).from;
+ if (!traits(origin).locations) return .{};
+ const target = locations.parse(line);
+ const spot = if (metadata) |m| m.at else target.at;
+ if (spot.line == 0) return .{};
+ var row: RowDecoration = .{
+ .location_end = if (metadata) |m| m.location_end else target.end,
+ .prefix_end = if (metadata) |m| m.code_start else target.end,
+ .declaration = if (metadata) |m| m.declaration else false,
+ };
+ const source_start = if (metadata) |m| m.code_start else target.code_start;
+ if (source_start >= line.len) return row;
+ if (metadata) |m| if (m.kind == .context) return row;
+ const mark_occurrence = switch (origin) {
+ .search => true,
+ .cmd => |cmd| cmd == .Grep,
+ .query => |kind| switch (kind) {
+ .definition, .declaration, .type_definition, .implementation, .references => true,
+ else => false,
+ },
+ else => false,
+ };
+ if (mark_occurrence and spot.col > 0 and spot.end_line == spot.line and
+ spot.end_col >= spot.col)
+ {
+ row.mark_start = source_start +| (spot.col - 1);
+ row.mark_end = @min(line.len, source_start +| spot.end_col);
+ }
+ const is_diagnostic = switch (origin) {
+ .query => |kind| kind == .diagnostics or kind == .workspace_diagnostics,
+ else => false,
+ };
+ if (is_diagnostic) {
+ const text = line[source_start..];
+ inline for (.{ .{ "error:", .err }, .{ "warning:", .warning }, .{ "info:", .info }, .{ "hint:", .hint } }) |label| {
+ if (std.mem.startsWith(u8, text, label[0])) {
+ row.mark_start = source_start;
+ row.mark_end = source_start + label[0].len;
+ row.severity = label[1];
+ }
+ }
+ }
+ return row;
+}
+
+test "result decoration follows source byte ranges and diagnostic provenance" {
+ const row = rowDecoration(.{ .from = .search }, "src/a.rs:3:5-7 let abc = 1;");
+ try std.testing.expectEqualStrings("abc", "src/a.rs:3:5-7 let abc = 1;"[row.mark_start..row.mark_end]);
+ const prose = rowDecoration(.{ .from = .{ .query = .hover } }, "src/a.rs:3:5-7 error: prose");
+ try std.testing.expectEqual(@as(usize, 0), prose.mark_end);
+ const diagnostic = rowDecoration(.{ .from = .{ .query = .diagnostics } }, "src/a.rs:3:5-7 warning: unused");
+ try std.testing.expectEqual(.warning, diagnostic.severity);
+ try std.testing.expectEqualStrings("warning:", "src/a.rs:3:5-7 warning: unused"[diagnostic.mark_start..diagnostic.mark_end]);
+ const malformed = rowDecoration(.{ .from = .search }, "src/a.rs:3:99999999999999999999-99999999999999999999 text");
+ try std.testing.expect(malformed.mark_end <= "src/a.rs:3:99999999999999999999-99999999999999999999 text".len);
+ const snippet = "src/a.rs:3:14-16 \t\tfoo + 界 + foo";
+ for ([_]lsp.Kind{ .definition, .declaration, .type_definition, .implementation, .references }) |kind| {
+ const occurrence = rowDecoration(.{ .from = .{ .query = kind } }, snippet);
+ try std.testing.expectEqualStrings("foo", snippet[occurrence.mark_start..occurrence.mark_end]);
+ }
+ for ([_]lsp.Kind{ .document_symbols, .workspace_symbols, .completion, .incoming_calls, .outgoing_calls, .supertypes, .subtypes, .rename }) |kind| {
+ const label = rowDecoration(.{ .from = .{ .query = kind } }, snippet);
+ try std.testing.expectEqual(@as(usize, 0), label.mark_end);
+ }
+}
+
+test "stacked empty previews distinguish separators from trailing newlines" {
+ var path = [_]u8{ 'x', '.', 'c' };
+ const rows = [_]locations.Row{
+ .{ .kind = .match, .path = &path, .at = .{ .line = 1, .col = 1 }, .location_end = 7, .code_start = 7 },
+ .{ .kind = .preview, .path = &path, .at = .{ .line = 1, .col = 1 }, .location_end = 0, .code_start = 0 },
+ };
+ try std.testing.expect(locations.sameInput("x.c:1:1 ", "x.c:1:1\n", &rows, .stacked));
+ try std.testing.expect(locations.sameInput("x.c:1:1 \n", "x.c:1:1\n\n", &rows, .stacked));
+ try std.testing.expect(!locations.sameInput("x.c:1:1 ", "x.c:1:1\n\n", &rows, .stacked));
+ try std.testing.expect(!locations.sameInput("x.c:1:1 \n", "x.c:1:1\n", &rows, .stacked));
+}
+
+test "plain output does not opt into location decoration" {
+ const report = rowDecoration(.{ .from = .{ .cmd = .LocationsConfig } }, "LocationsConfig context:5 tscontext:on");
+ try std.testing.expectEqualDeep(RowDecoration{}, report);
+ const prose = rowDecoration(.{ .from = .{ .query = .hover } }, "src/a.rs:3:5-7 error: prose");
+ try std.testing.expectEqualDeep(RowDecoration{}, prose);
+ const errors = rowDecoration(.{ .from = .errors }, "src/a.rs:3:5-7 error: prose");
+ try std.testing.expectEqualDeep(RowDecoration{}, errors);
+ try std.testing.expect(!traits(.{ .cmd = .Find }).locations);
+ try std.testing.expect(traits(.{ .cmd = .Grep }).locations);
+ try std.testing.expect(traits(.{ .query = .completion }).locations);
+}
+
+pub fn setArg(o: *State, 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);
+}
+
+pub const Traits = builtins.OutputTraits;
+
+const file_row: Traits = .{ .name = "", .doc = true, .saves = true };
+
+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, .locations = true },
+ .errors => .{ .name = config.errors_buffer, .doc = true },
+ .pdf_links => .{ .name = config.pdf_links_buffer, .steps = true, .locations = 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 => .{ .name = config.search_buffer, .steps = true, .locations = true },
+ .definition, .declaration, .type_definition, .implementation, .references => .{
+ .name = config.search_buffer,
+ .steps = true,
+ .locations = 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,
+ .locations = true,
+ .jumps = true,
+ },
+ .document_symbols, .workspace_symbols, .diagnostics, .workspace_diagnostics, .select_refs, .completion => .{
+ .name = config.search_buffer,
+ .steps = true,
+ .locations = true,
+ },
+ },
+ };
+}
+
+pub fn usesLocationsConfig(from: Origin) bool {
+ const tr = traits(from);
+ return tr.steps and !tr.commands and from != .pdf_links and
+ !std.meta.eql(from, Origin{ .cmd = .Find }) and
+ !std.meta.eql(from, Origin{ .query = .completion });
+}
+
+pub fn fileTraits(out: ?State) Traits {
+ return traits((out orelse return file_row).from);
+}
+
+pub const Grain = enum {
+ word,
+ line,
+ whole,
+};
+
+pub fn grain(out: ?State) Grain {
+ const tr = fileTraits(out);
+ if (tr.commands) return .whole;
+ return if (tr.steps) .line else .word;
+}
+
+pub fn word(o: Origin) []const u8 {
+ return switch (o) {
+ .cmd => |b| @tagName(b),
+ .query => |k| @tagName(k),
+ .search => "/",
+ .errors => config.errors_buffer,
+ .pdf_links => config.pdf_links_buffer,
+ };
+}
+
+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.mem.eql(u8, w, config.pdf_links_buffer)) return .pdf_links;
+ 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;
+}
+
+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);
+}
+
+pub const Location = struct {
+ path: []const u8,
+ at: look.Spot,
+ end: usize,
+
+ fn order(a: Location, b: Location) std.math.Order {
+ const path = std.mem.order(u8, a.path, b.path);
+ if (path != .eq) return path;
+ if (a.at.line != b.at.line) return std.math.order(a.at.line, b.at.line);
+ return std.math.order(a.at.col, b.at.col);
+ }
+};
+
+pub fn location(line: []const u8) Location {
+ const target = locations.parse(std.mem.trimEnd(u8, line, " \t\r\n"));
+ return .{ .path = target.path, .at = target.at, .end = target.end };
+}
+
+pub fn isResultRow(file: *const File.State, row: usize) bool {
+ if (row < file.location_rows.len) return file.location_rows[row].kind == .match;
+ return !locations.isContextLine(modal.lineSlice(file.content, row));
+}
+
+fn sortResults(arena: std.mem.Allocator, from: Origin, content: []u8, anchor: ?usize) !?usize {
+ switch (from) {
+ .search => {},
+ .cmd => |cmd| switch (cmd) {
+ .Find, .Grep => {},
+ else => return anchor,
+ },
+ .query => |kind| switch (kind) {
+ .definition,
+ .declaration,
+ .type_definition,
+ .implementation,
+ .references,
+ .incoming_calls,
+ .outgoing_calls,
+ .supertypes,
+ .subtypes,
+ .document_symbols,
+ .workspace_symbols,
+ .diagnostics,
+ .workspace_diagnostics,
+ .select_refs,
+ .rename,
+ => {},
+ else => return anchor,
+ },
+ .errors, .pdf_links => return anchor,
+ }
+ if (content.len == 0) return anchor;
+ const path_only = std.meta.eql(from, Origin{ .cmd = .Find });
+ const trailing_newline = content[content.len - 1] == '\n';
+ const body = content[0 .. content.len - @intFromBool(trailing_newline)];
+ var lines = std.mem.splitScalar(u8, body, '\n');
+ var previous: ?Location = null;
+ var sorted = true;
+ var count: usize = 0;
+ while (lines.next()) |line| {
+ const current: Location = if (path_only) .{ .path = line, .at = .{}, .end = line.len } else location(line);
+ if (!path_only and current.at.line == 0) return anchor;
+ if (previous) |last| if (last.order(current) == .gt) {
+ sorted = false;
+ };
+ previous = current;
+ count += 1;
+ }
+ if (sorted) return anchor;
+
+ const Row = struct {
+ text: []const u8,
+ target: Location,
+ original: usize,
+
+ fn lessThan(_: void, a: @This(), b: @This()) bool {
+ return switch (a.target.order(b.target)) {
+ .lt => true,
+ .eq => a.original < b.original,
+ .gt => false,
+ };
+ }
+ };
+ const rows = try arena.alloc(Row, count);
+ const copy = try arena.dupe(u8, body);
+ lines = std.mem.splitScalar(u8, copy, '\n');
+ for (rows, 0..) |*row, original| {
+ const text = lines.next().?;
+ const target: Location = if (path_only) .{ .path = text, .at = .{}, .end = text.len } else location(text);
+ row.* = .{
+ .text = text,
+ .target = target,
+ .original = original,
+ };
+ }
+ std.mem.sort(Row, rows, {}, Row.lessThan);
+ var mapped = anchor;
+ var offset: usize = 0;
+ for (rows, 0..) |row, i| {
+ if (anchor == row.original) mapped = i;
+ @memcpy(content[offset..][0..row.text.len], row.text);
+ offset += row.text.len;
+ if (i + 1 < rows.len or trailing_newline) {
+ content[offset] = '\n';
+ offset += 1;
+ }
+ }
+ std.debug.assert(offset == content.len);
+ return mapped;
+}
+
+pub fn nextResult(content: []const u8, rows: []const locations.Row, path: []const u8, at: look.Spot) usize {
+ const current: Location = .{ .path = path, .at = at, .end = 0 };
+ var lines = std.mem.splitScalar(u8, content, '\n');
+ var first: ?usize = null;
+ var row: usize = 0;
+ while (lines.next()) |line| : (row += 1) {
+ if (row < rows.len) {
+ if (rows[row].kind != .match) continue;
+ } else if (locations.isContextLine(line)) continue;
+ const target = location(line);
+ if (target.at.line == 0) continue;
+ if (first == null) first = row;
+ if (target.order(current) == .gt) return row;
+ }
+ return first orelse 0;
+}
+
+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: State = .{ .from = from };
+ try setArg(&out, arg);
+ const history = try File.History.create(p.gpa);
+ errdefer p.gpa.destroy(history);
+ const pane = try p.newDocPane(id);
+ pane.file = .{ .path = path, .content = content, .output = out, .history = history };
+ pane.cur_pinned = true;
+ return pane;
+}
+
+pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u8, content: []u8, initial_anchor: ?usize) !void {
+ var result_content = content;
+ errdefer p.gpa.free(result_content);
+ var result_rows: []locations.Row = &.{};
+ errdefer locations.freeRows(p.gpa, result_rows);
+ const pane = p.panes[id] orelse return error.MissingPane;
+ if (arg.len > max_arg) return error.ArgumentTooLong;
+ var anchor = try sortResults(p.scratch.allocator(), from, content, initial_anchor);
+ const by_arg = std.meta.activeTag(from) != .query;
+ if (p.locations_config.context == 0 and !p.locations_config.tscontext) {
+ 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 output = if (rf.output) |*o| o else continue;
+ if (!std.meta.eql(output.from, from)) continue;
+ if (by_arg and !std.mem.eql(u8, output.arg(), arg)) continue;
+ if (!std.mem.eql(u8, std.fs.path.dirname(rf.path) orelse "", dir)) continue;
+ if (!locations.sameInput(content, rf.content, rf.location_rows, p.locations_config.layout)) continue;
+ try setArg(output, arg);
+ p.gpa.free(content);
+ p.active = id;
+ if (traits(from).steps) {
+ pane.search_pane = i;
+ pane.search_row = anchor;
+ p.armLookWalk(i);
+ }
+ return;
+ }
+ }
+ if (usesLocationsConfig(from)) {
+ const formatted = try locations.format(p, dir, content, anchor, true);
+ p.gpa.free(result_content);
+ result_content = formatted.content;
+ result_rows = formatted.rows;
+ anchor = formatted.anchor;
+ }
+ 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);
+ if (std.mem.eql(u8, rf.content, result_content)) {
+ p.gpa.free(result_content);
+ } else {
+ File.setContent(p, rf, result_content);
+ resetBody(p, rp);
+ }
+ if (locations.equalRows(rf.location_rows, result_rows)) {
+ locations.freeRows(p.gpa, result_rows);
+ } else {
+ locations.freeRows(p.gpa, rf.location_rows);
+ rf.location_rows = result_rows;
+ if (rf.highlights.len > 0) p.tree_sitter_gpa.free(rf.highlights);
+ rf.highlights = &.{};
+ rf.highlight_start = 0;
+ rf.syntax_dirty = true;
+ }
+ 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, result_content);
+ np.file.?.location_rows = result_rows;
+ p.placeDoc(id, free, np);
+ p.active = id;
+ if (traits(from).steps) {
+ pane.search_pane = free;
+ pane.search_row = anchor;
+ p.armLookWalk(free);
+ }
+}
+
+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);
+}
+
+pub fn openThemes(p: *Pardes, id: usize) !void {
+ var out: std.Io.Writer.Allocating = .init(p.gpa);
+ errdefer out.deinit();
+ try out.writer.writeAll("# Pardes themes\n");
+ for (pardes.themes, 0..) |t, i| {
+ if (i == pardes.native_theme_count)
+ try out.writer.writeAll("\n# Legacy and imported themes\n");
+ try out.writer.print(comptime config.Runtime.findAction(.theme).?.word ++ " {s}\n", .{t.name});
+ }
+ const content = try out.toOwnedSlice();
+ try openStepped(p, id, .{ .cmd = .ThemeSel }, content);
+}
+
+pub fn isWalkRow(file: *const File.State, row: usize, line: []const u8) bool {
+ const state = file.output orelse return true;
+ if (usesLocationsConfig(state.from)) return isResultRow(file, row);
+ if (!std.meta.eql(state.from, Origin{ .cmd = .ThemeSel })) return true;
+ // Section labels are for reading, not stops in the command walk.
+ return std.mem.startsWith(u8, std.mem.trimStart(u8, line, " \t"), comptime config.Runtime.findAction(.theme).?.word ++ " ");
+}
+
+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 config.Runtime.findAction(.font).?.word ++ " {s}\n", .{f.name});
+ const content = try out.toOwnedSlice();
+ try openStepped(p, id, .{ .cmd = .FontSel }, content);
+ }
+}
+
+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);
+}
+
+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";
+ 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);
+}
+
+pub fn openConfig(p: *Pardes, id: usize) !void {
+ var out: std.Io.Writer.Allocating = .init(p.gpa);
+ errdefer out.deinit();
+ var dir_buf: [1024]u8 = undefined;
+ const host_fs = comptime (pardes.platform != .web and pardes.platform != .esp32p4);
+ try config.Runtime.writeReport(&out.writer, .{
+ .default_dump_dir = if (host_fs) (pardes.dump.defaultDirectory(&dir_buf) orelse "") else "",
+ .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.serial != 0) try out.writer.print("{d}: ", .{m.serial});
+ 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);
+}
+
+pub fn openEffectCode(p: *Pardes, id: usize, argument: []const u8) !void {
+ const name = std.mem.trim(u8, argument, " \t\r\n");
+ const setting = config.Runtime.find(name) orelse return error.UnknownEffect;
+ switch (setting.action) {
+ .transition, .scene => {},
+ else => return error.NotAnEffect,
+ }
+ if (!setting.enabled(builtins.capabilities)) return error.EffectUnavailable;
+ // Without -Dembed-sources the paths would name nothing this build can open.
+ if (!limits.embedded_sources) return error.EffectUnavailable;
+ const paths = 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 (paths) |path| try out.writer.print("/virtual/{s}\n", .{path});
+ const content = try out.toOwnedSlice();
+ return openRead(p, id, .{ .cmd = .EffectCode }, setting.word, content);
+}
+
+pub fn openErrors(p: *Pardes, id: usize, content: []u8) !void {
+ return openRead(p, id, .errors, "", content);
+}
+
+pub fn resetBody(p: *Pardes, pane: *Pane) void {
+ pane.file.?.scroll = 0;
+ pane.cur_row = 0;
+ pane.cur_col = 0;
+ pane.cur_pinned = true;
+ pane.hscroll = 0;
+ pane.wrap_n = 0;
+ pane.msel = .{};
+ pane.vsel = .{};
+ pane.nsel = 0;
+ pane.nsel_snap = 0;
+ pane.select = false;
+ pane.sel = @splat(.{});
+ pane.sticky_col = -1;
+ pane.append_at = null;
+ pane.normal.clear();
+ pane.look_at = null;
+ if (p.look_hover_wait) |wait| if (wait.serial == pane.serial) {
+ p.look_hover_wait = null;
+ };
+ if (p.look_hover_preview) |preview| if (preview.serial == pane.serial) {
+ p.look_hover_preview = null;
+ };
+ if (p.drag == .select and p.panes[p.drag.select.id] == pane) p.drag = .none;
+}
+
+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.setContent(p, hf, content);
+ resetBody(p, hp);
+ 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;
+}