From 2f3f8776613ebb357ff6458267c3d79c79944968 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 27 Sep 2026 21:19:08 -0300 Subject: 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 --- src/Output.zig | 782 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 782 insertions(+) create mode 100644 src/Output.zig (limited to 'src/Output.zig') 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; +} -- cgit v1.3