diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/Output.zig | 782 | ||||
| -rw-r--r-- | src/fs.zig | 1 | ||||
| -rw-r--r-- | src/panes.zig | 766 |
3 files changed, 784 insertions, 765 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; +} @@ -36,6 +36,7 @@ else const source_files = [_]Source{ .{ .path = "src/pardes.zig", .contents = @embedFile("pardes.zig") }, .{ .path = "src/panes.zig", .contents = @embedFile("panes.zig") }, + .{ .path = "src/Output.zig", .contents = @embedFile("Output.zig") }, .{ .path = "src/Mini.zig", .contents = @embedFile("Mini.zig") }, .{ .path = "src/image.zig", .contents = @embedFile("image.zig") }, .{ .path = "src/layout.zig", .contents = @embedFile("layout.zig") }, diff --git a/src/panes.zig b/src/panes.zig index bc579a92..6ee7d70b 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -2408,771 +2408,7 @@ pub const File = struct { } }; -pub const Output = struct { - 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, - - 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); - } - - 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; - } -}; +pub const Output = @import("Output.zig"); pub const Mini = @import("Mini.zig"); |
