//! Command structs and runtime settings form the builtin registry; bindings live in config.zig. const std = @import("std"); const pardes = @import("pardes.zig"); const tagline = @import("tagline.zig"); const dump = @import("dump.zig"); const exec = @import("exec.zig"); const look = @import("look.zig"); const edit = @import("edit.zig"); const colors = @import("colors.zig"); const Pardes = pardes.Pardes; const Pane = pardes.Pane; const panes = @import("panes.zig"); const layout = @import("layout.zig"); const config = @import("config.zig"); const builtin = @import("builtin"); const Header = @import("esp32p4_gpio.zig").Header; const limits = @import("memory.zig").limits; pub const capabilities: config.Runtime.Capabilities = .{ .font_picker = pardes.font_picker, .panel_transitions = pardes.hosted, .scene_shaders = pardes.platform == .gui or pardes.platform == .macos, // The tty's font belongs to its emulator, and the P4 firmware's belongs to // whatever terminal is on the other end of the serial line. .tagline_font_size = pardes.platform != .tty and pardes.platform != .esp32p4, .window_opacity = pardes.platform == .gui or pardes.platform == .macos, .window_blur = pardes.platform == .macos, // Only the SDL shell shapes with HarfBuzz and honours the setting. macOS // draws CoreText ligatures of its own, but nothing there reads it yet. .ligatures = pardes.platform == .gui, // Soft shadows are the SDL shell's decor. .lift = pardes.platform == .gui, }; /// Where a builtin acts: at the pane it runs from, or on the editor as a /// whole. acme keeps its window verbs on a window's ctl and its session /// commands elsewhere, as webfs and upas/fs keep session settings on a root /// ctl; pardes's root `ctl` takes the session builtins and a pane's `ctl` /// the pane ones. A builtin says `pub const scope: Scope = .session;`, /// and every setting is one; the rest act on their pane. pub const Scope = enum { pane, session }; pub const Ctx = struct { p: *Pardes, pane: *Pane, id: usize, txt: []const u8, arg: ?[]const u8, /// Made by a gesture on the pane's body -- a middle click, the execute /// key -- as acme's execute() asks of its Text (exec.c:122,169): an /// Exec from a body may be a bound REPL's (exec.zig routeToRepl). from_body: bool = false, }; fn isEnabled(comptime T: type) bool { return !@hasDecl(T, "enabled") or T.enabled; } fn manualBuiltinCount() comptime_int { comptime { var count = 0; for (@typeInfo(@This()).@"struct".decls) |d| { if (@TypeOf(@field(@This(), d.name)) != type) continue; const T = @field(@This(), d.name); if (@typeInfo(T) != .@"struct" or !@hasDecl(T, "run") or !isEnabled(T)) continue; if (@TypeOf(T.run) != fn (Ctx) void) @compileError(d.name ++ ".run must have signature fn (Ctx) void"); count += 1; } return count; } } fn manualBuiltinList() [manualBuiltinCount()]type { comptime { @setEvalBranchQuota(4000); var list: [manualBuiltinCount()]type = undefined; var count = 0; for (@typeInfo(@This()).@"struct".decls) |d| { if (@TypeOf(@field(@This(), d.name)) != type) continue; const T = @field(@This(), d.name); if (@typeInfo(T) != .@"struct" or !@hasDecl(T, "run") or !isEnabled(T)) continue; list[count] = T; count += 1; } return list; } } fn settingCount() comptime_int { comptime { var count = 0; for (config.Runtime.settings) |setting| if (setting.enabled(capabilities)) { count += 1; }; return count; } } fn settingList() [settingCount()]config.Runtime.Setting { comptime { var list: [settingCount()]config.Runtime.Setting = undefined; var count = 0; for (config.Runtime.settings) |setting| if (setting.enabled(capabilities)) { list[count] = setting; count += 1; }; return list; } } pub fn word(comptime T: type) []const u8 { const n = @typeName(T); const dot = std.mem.lastIndexOfScalar(u8, n, '.') orelse return n; return n[dot + 1 ..]; } pub const OutputTraits = struct { name: []const u8, steps: bool = false, jumps: bool = false, // This producer emits location rows whose labels and source excerpts may be styled. locations: bool = false, commands: bool = false, doc: bool = false, // Saving promotes this scratch buffer into an ordinary file. saves: bool = false, }; // Keep enum-dependent signatures out of the outer declaration walk. pub const registry = struct { pub fn Builtin() type { // Duplicate-name validation compares every pair. @setEvalBranchQuota(200_000); const manual = manualBuiltinList(); const generated = settingList(); const count = manual.len + generated.len; const Tag = std.math.IntFittingRange(0, count - 1); var names: [count][]const u8 = undefined; for (manual, 0..) |T, i| names[i] = word(T); for (generated, manual.len..) |setting, i| names[i] = setting.word; for (names, 0..) |name, i| for (names[i + 1 ..]) |later| if (std.mem.eql(u8, name, later)) @compileError("duplicate builtin name: " ++ name); return @Enum(Tag, .exhaustive, &names, &std.simd.iota(Tag, count)); } pub fn takesArg(b: Builtin()) bool { inline for (manualBuiltinList(), 0..) |T, i| if (@intFromEnum(b) == i) return @hasDecl(T, "takes_arg") and T.takes_arg; // Every setting takes its value, and a switch flips when bare. return true; } /// Whether the word takes its argument joined by a `+` as one word a /// tag can hold and a click take whole (`Tty+fish`). Only a builtin /// that says `pub const plus_arg = true;`: others have arguments a `+` /// may be in (`Dump+2024.zon` is a name, not Dump's argument). pub fn plusArg(b: Builtin()) bool { inline for (manualBuiltinList(), 0..) |T, i| if (@intFromEnum(b) == i) return @hasDecl(T, "plus_arg") and T.plus_arg; return false; } /// Whether the word means nothing without its argument (`Mount`, /// `Msg`, a setting's value), or would ask for it at a prompt (`Find`): /// a ctl refuses it bare before any line of the write runs. A builtin /// says `pub const requires_arg = true;` beside `takes_arg`. pub fn requiresArg(b: Builtin()) bool { inline for (manualBuiltinList(), 0..) |T, i| if (@intFromEnum(b) == i) return @hasDecl(T, "requires_arg") and T.requires_arg; inline for (comptime settingList(), manualBuiltinCount()..) |setting, i| if (@intFromEnum(b) == i) return switch (setting.action) { // a switch flips bare, a choice steps, and DumpDir and Shell bare // are the default .toggle, .transition, .scene, .dump_dir, .choice, .lift, .shader_animation, .motion, .shell => false, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .inactive_dim, .grip_width => true, }; unreachable; } pub fn scope(b: Builtin()) Scope { inline for (manualBuiltinList(), 0..) |T, i| if (@intFromEnum(b) == i) return if (@hasDecl(T, "scope")) T.scope else .pane; return .session; // a setting } pub fn outputTraits(b: Builtin()) ?OutputTraits { inline for (manualBuiltinList(), 0..) |T, i| if (@intFromEnum(b) == i) return if (@hasDecl(T, "output")) T.output else null; inline for (comptime settingList(), manualBuiltinCount()..) |_, i| if (@intFromEnum(b) == i) return null; unreachable; } /// Does this builtin say its own name on the message row before it runs? /// The ones that own that row themselves declare `quiet` and do not. pub fn announces(b: Builtin()) bool { inline for (manualBuiltinList(), 0..) |T, i| if (@intFromEnum(b) == i) return !(@hasDecl(T, "quiet") and T.quiet); return true; // a setting builtin changes something invisible; say so } pub fn dispatch(b: Builtin(), c: Ctx) void { inline for (manualBuiltinList(), 0..) |T, i| if (@intFromEnum(b) == i) return T.run(c); inline for (comptime settingList(), manualBuiltinCount()..) |setting, i| if (@intFromEnum(b) == i) return exec.applySettingBuiltin(c.p, setting, c.arg); unreachable; } }; test "capabilities exactly gate setting and effect-source builtins" { const Builtin = registry.Builtin(); for (config.Runtime.settings) |setting| { const registered = std.meta.stringToEnum(Builtin, setting.word) != null; try std.testing.expectEqual(setting.enabled(capabilities), registered); } const effect_code_registered = std.meta.stringToEnum(Builtin, "EffectCode") != null; try std.testing.expectEqual(EffectCode.enabled, effect_code_registered); } test "a builtin's scope is the pane unless it says session, and every setting is the session's" { const Builtin = registry.Builtin(); try std.testing.expectEqual(Scope.pane, registry.scope(Builtin.Del)); try std.testing.expectEqual(Scope.pane, registry.scope(Builtin.Save)); try std.testing.expectEqual(Scope.session, registry.scope(Builtin.Newcol)); try std.testing.expectEqual(Scope.session, registry.scope(Builtin.Kill)); try std.testing.expectEqual(Scope.session, registry.scope(Builtin.Verbose)); for (comptime settingList()) |setting| try std.testing.expectEqual(Scope.session, registry.scope(std.meta.stringToEnum(Builtin, setting.word).?)); } test "Exit asks once about unsaved text, and quits when asked again" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("saved\n"); pane.file.?.saved_revision = pane.file.?.revision -% 1; // modified pane.file.?.saved_hash = 0; // edited: what it had is not what it has try std.testing.expect(p.executeBuiltinLine(p.active, "Exit")); try std.testing.expect(!p.quit); try std.testing.expect(std.mem.endsWith(u8, pane.msg[0..pane.msg_len], ": Modified (Exit again to discard)")); // Told once, and nothing edited since: the second Exit quits. try std.testing.expect(p.executeBuiltinLine(p.active, "Exit")); try std.testing.expect(p.quit); } test "Exit over a ctl write names every pane it asks about, and the next Exit discards them all" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const first = try p.setTestFile("saved\n"); first.file.?.saved_revision = first.file.?.revision -% 1; first.file.?.saved_hash = 0; // edited: what it had is not what it has p.newScratchBelow(p.active); const second = p.panes[p.active].?; panes.File.setContent(p, &second.file.?, try std.testing.allocator.dupe(u8, "x" ** 120)); try std.testing.expect(second.file.?.revision != second.file.?.saved_revision); p.fs.no_prompt = true; defer p.fs.no_prompt = false; try std.testing.expect(p.executeBuiltinLine(p.active, "Exit")); try std.testing.expect(!p.quit); const said = p.fs.failure[0..p.fs.failure_len]; try std.testing.expect(std.mem.indexOf(u8, said, first.file.?.path) != null); try std.testing.expect(std.mem.indexOf(u8, said, second.file.?.path) != null); try std.testing.expect(std.mem.endsWith(u8, said, ": Modified (Exit again to discard)")); p.fs.failure_len = 0; try std.testing.expect(p.executeBuiltinLine(p.active, "Exit")); try std.testing.expect(p.quit); } /// A dump file that exists, for a Restore to get past reading it. fn testDump(buf: []u8) ![]const u8 { var tmp = std.testing.tmpDir(.{}); // A real dump: a Restore reads it through before it asks anything. const source = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer source.deinit(); try pardes.dump.dumpState(source); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "d.zon", .data = source.dump_out.? }); var dir: [4096]u8 = undefined; const at = dir[0..try tmp.dir.realPath(std.testing.io, &dir)]; return std.fmt.bufPrint(buf, "Restore {s}/d.zon", .{at}); } test "Restore asks about unsaved text as Exit does, and restores when asked again" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("saved\n"); pane.file.?.saved_revision = pane.file.?.revision -% 1; // modified pane.file.?.saved_hash = 0; // edited: what it had is not what it has var line_buf: [4200]u8 = undefined; const line = try testDump(&line_buf); try std.testing.expect(p.executeBuiltinLine(p.active, line)); try std.testing.expect(p.restore_req == null); try std.testing.expect(std.mem.endsWith(u8, pane.msg[0..pane.msg_len], ": Modified (Restore again to discard)")); try std.testing.expect(p.executeBuiltinLine(p.active, line)); try std.testing.expectEqualStrings(line["Restore ".len..], p.restore_req.?); } test "a Restore of a dump that cannot be read says so, before the unsaved-text warning" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("saved\n"); pane.file.?.saved_revision = pane.file.?.revision -% 1; // modified pane.file.?.saved_hash = 0; // edited: what it had is not what it has p.fs.no_prompt = true; defer p.fs.no_prompt = false; try std.testing.expect(p.executeBuiltinLine(p.active, "Restore /nope/missing.zon")); try std.testing.expect(p.restore_req == null); try std.testing.expectEqualStrings("Restore: /nope/missing.zon: no such file", p.fs.failure[0..p.fs.failure_len]); // No warning spent: the Exit after it still asks. try std.testing.expect(pane.discard_warned == null); } test "a Restore's warning is not an Exit's: each word is warned on its own" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("saved\n"); pane.file.?.saved_revision = pane.file.?.revision -% 1; // modified pane.file.?.saved_hash = 0; // edited: what it had is not what it has var line_buf: [4200]u8 = undefined; try std.testing.expect(p.executeBuiltinLine(p.active, try testDump(&line_buf))); try std.testing.expect(p.restore_req == null); // The Restore warned; an Exit after it has not been, and asks. try std.testing.expect(p.executeBuiltinLine(p.active, "Exit")); try std.testing.expect(!p.quit); try std.testing.expect(std.mem.endsWith(u8, pane.msg[0..pane.msg_len], ": Modified (Exit again to discard)")); try std.testing.expect(p.executeBuiltinLine(p.active, "Exit")); try std.testing.expect(p.quit); } test "Kill stops the commands pardes typed, bare all and by name those that match, and never quits" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); while (p.nextEffect()) |_| {} const term = p.active; try std.testing.expect(p.panes[term].?.isTerminal()); const prompt = "\x1b]133;A;cl=line;aid=pardes\x07$ \x1b]133;B;aid=pardes\x07"; p.update(.{ .output = .{ .pane = @intCast(term), .bytes = prompt } }); while (p.nextEffect()) |_| {} // The host the core asks: how many it was asked to stop, and whether // their shells run jobs in groups of their own. const killed = struct { var asked: usize = 0; var job_control = true; const vtable: pardes.Host.VTable = .{ .kill_job = answer }; fn answer(_: ?*anyopaque, _: u8) bool { asked += 1; return job_control; } fn count(pp: *Pardes, line: []const u8) usize { asked = 0; _ = pp.executeBuiltinLine(pp.active, line); return asked; } }; p.host = .{ .vtable = &killed.vtable }; // Nothing running: nothing to stop, and never the shell; said. try std.testing.expectEqual(@as(usize, 0), killed.count(p, "Kill")); try std.testing.expectEqualStrings("Kill: nothing running", p.panes[term].?.msg[0..p.panes[term].?.msg_len]); _ = exec.execute(p, term, "make -j8"); while (p.nextEffect()) |_| {} // Typed but not started yet (no C mark): not running. try std.testing.expectEqual(@as(usize, 0), killed.count(p, "Kill")); p.update(.{ .output = .{ .pane = @intCast(term), .bytes = "\x1b]133;C;aid=pardes\x07" } }); while (p.nextEffect()) |_| {} try std.testing.expectEqual(@as(usize, 0), killed.count(p, "Kill ls")); try std.testing.expectEqual(@as(usize, 1), killed.count(p, "Kill ls make")); try std.testing.expectEqual(@as(usize, 1), killed.count(p, "Kill")); try std.testing.expect(!p.quit); // With job control off there is no job apart from the shell: said, and // over ctl the write fails with it. killed.job_control = false; p.fs.no_prompt = true; try std.testing.expectEqual(@as(usize, 1), killed.count(p, "Kill")); try std.testing.expectStringStartsWith(p.fs.failure[0..p.fs.failure_len], "Kill: no job to signal"); p.fs.no_prompt = false; p.fs.failure_len = 0; killed.job_control = true; // Ended (a D mark): nothing to stop again. p.update(.{ .output = .{ .pane = @intCast(term), .bytes = "\x1b]133;D;143;aid=pardes\x07" ++ prompt } }); while (p.nextEffect()) |_| {} try std.testing.expectEqual(@as(usize, 0), killed.count(p, "Kill")); } test "Look does not announce itself on the message row; Del still does" { const Builtin = registry.Builtin(); try std.testing.expect(!registry.announces(Builtin.Look)); try std.testing.expect(registry.announces(Builtin.Del)); } test "Ligatures is a setting only in the shell that shapes text" { const exists = @hasField(registry.Builtin(), "Ligatures"); try std.testing.expectEqual(pardes.platform == .gui, exists); const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); try std.testing.expectEqual(exists, p.executeBuiltinLine(p.active, "Ligatures off")); try std.testing.expectEqual(!exists, p.settings.ligatures); var storage: [8192]u8 = undefined; var out: std.Io.Writer = .fixed(&storage); try config.Runtime.writeReport(&out, .{ .startup_config_path = null, .platform = @tagName(pardes.platform), .theme_name = p.theme().name, .compiled_default_shell = config.defaultShell(), .hover_delay_frames = null, .native_images = false, .capabilities = capabilities, .state = &p.settings, }); try std.testing.expectEqual(exists, std.mem.indexOf(u8, storage[0..out.end], "Ligatures off\n") != null); try std.testing.expectEqual(exists, std.mem.indexOf(u8, storage[0..out.end], "Ligatures") != null); } comptime { for ([_][]const u8{ "Peek", "Poke", "Hexdump" }) |name| if (@hasField(registry.Builtin(), name) != Board.enabled) @compileError( "bare-metal memory word gating leaked: " ++ name, ); } // ---- the two acme verbs ---- pub const Look = struct { pub const takes_arg = true; pub const requires_arg = true; // a look's answer is the pane it opens or the place it jumps to; its own // name on the message row would only be noise over that pub const quiet = true; pub fn run(c: Ctx) void { look.lookAt(c.p, c.id, c.arg orelse return); } }; pub const Exec = struct { pub const takes_arg = true; pub const requires_arg = true; pub fn run(c: Ctx) void { // the destination pane is Look's business (it focuses what answered); // an execute deliberately leaves you where you were _ = exec.executeFrom(c.p, c.id, c.arg orelse return, c.from_body); } }; // ---- session ---- /// acme's rowclean (editors/acme/wind.c:511-529, winclean), asked before /// what would throw unsaved text away: each pane holding some nobody has /// been told about says so, `: Modified (Exit again to discard)`, /// naming the word asking, since repeating it throws the text away, and is /// then clean for the asking, as winclean clears `dirty`; so the same word /// again, with nothing edited since, goes ahead. Only the same word: a /// Restore a script was refused does not spend the warning an Exit owes /// the person at the keyboard. A scratch of under 100 bytes is no loss /// worth the question, as winclean does not ask for one. fn warnModified(c: Ctx, asking: Pane.Discarding) bool { return warnModifiedIn(c, asking, .all); } /// Which panes a refusal asks about: every one (Exit, Restore), one (Del), /// or a column's (Delcol). const Asked = union(enum) { all, pane: usize, column: usize }; fn warnModifiedIn(c: Ctx, asking: Pane.Discarding, which: Asked) bool { var modified = false; // Every pane asked about is named in the one refusal a ctl write gets, // as acme's rowclean names them all (wind.c:511). var all_buf: [1024]u8 = undefined; var all = std.Io.Writer.fixed(&all_buf); for (c.p.panes, 0..) |slot, id| { const pane = slot orelse continue; switch (which) { .all => {}, .pane => |only| if (id != only) continue, .column => |col| if ((layout.findPane(c.p, id) orelse continue).col != col) continue, } const f = pane.file orelse continue; // A +New scratch saves nowhere, but what is typed in it is as lost; // under 100 bytes it is not asked about, as acme's winclean does not // whine about a small unnamed window, nor about a command's output. const scratch = if (f.output) |o| o.from == .cmd and o.from.cmd == .New else false; if (f.revision == f.saved_revision or !(panes.Output.fileTraits(f.output).saves or scratch)) continue; if (f.output != null and f.content.len < 100) continue; // Dirty by a rename alone is no text to lose, as get says (ctl.zig). // Nor is a buffer never edited at all: its first edit is what keeps // the text it had (File.zig), so with none there is nothing to lose. const saved = f.saved_hash orelse continue; if (std.hash.Wyhash.hash(0, f.content) == saved) continue; if (pane.discard_warned) |w| if (w.revision == f.revision and w.by == asking) continue; pane.discard_warned = .{ .revision = f.revision, .by = asking }; // Scratches share a name: each is named with its pane's serial too, // `/dir/+New (pane 12)`, the same in every refusal (Exit, Restore, // Del, Delcol). var name_buf: [limits.host_path_cap + 24]u8 = undefined; const name = if (scratch) std.fmt.bufPrint(&name_buf, "{s} (pane {d})", .{ f.path, pane.serial }) catch f.path else f.path; var buf: [limits.host_path_cap + 72]u8 = undefined; c.p.reportFailure(id, std.fmt.bufPrint(&buf, "{s}: Modified ({s} again to discard)", .{ name, @tagName(asking) }) catch name); all.print("{s}{s}", .{ if (modified) ", " else "", name }) catch {}; modified = true; } if (modified and c.p.fs.no_prompt) { all.print(": Modified ({s} again to discard)", .{@tagName(asking)}) catch {}; const said = @import("Messages.zig").clip(all.buffered(), c.p.fs.failure.len); @memcpy(c.p.fs.failure[0..said.len], said); c.p.fs.failure_len = @intCast(said.len); } return modified; } /// acme's Exit (editors/acme/exec.c:892): quit, once warnModified has /// nothing to say. pub const Exit = struct { pub const scope: Scope = .session; pub fn run(c: Ctx) void { if (warnModified(c, .Exit)) return; c.p.quit = true; c.p.emit(.quit); } }; /// acme's Kill (exec.c, `kill`; acme.c's WKill): stop the commands pardes /// started -- the lines it typed into a terminal, from an exec, a middle /// click or a pty/run, while their shell says they run (C mark to D). /// Bare, every one; `Kill make ls`, those whose line starts with one of /// the words. acme posts the "kill" note, which ends a process; here that /// is SIGTERM to the terminal's foreground job, and never to the shell, /// which is its own job and stays. pub const Kill = struct { pub const scope: Scope = .session; pub const takes_arg = true; pub fn run(c: Ctx) void { const names = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); var stopped = false; var jobless = false; for (c.p.panes, 0..) |slot, id| { const pane = slot orelse continue; // A command pane's child is the command: the whole of its // process group goes, as acme's Kill ends the process. if (pane.command) |line| if (!pane.command_done) { const said = std.mem.trim(u8, line, " \t"); const first = said[0 .. std.mem.indexOfAny(u8, said, " \t") orelse said.len]; if (names.len > 0) { var words = std.mem.tokenizeAny(u8, names, " \t"); while (words.next()) |w| { if (std.mem.eql(u8, w, first)) break; } else continue; } c.p.emit(.{ .signal_pty = .{ .pane = @intCast(id), .serial = pane.serial, .sig = .term } }); stopped = true; continue; }; if (!panes.Terminal.commandRunning(pane)) continue; const sent = pane.sent_command.?; if (names.len > 0) { var words = std.mem.tokenizeAny(u8, names, " \t"); while (words.next()) |w| { if (std.mem.eql(u8, w, sent.word[0..sent.len])) break; } else continue; } // Asked of the host now, not queued: the job the marks say is // running is the one signalled, not whatever runs by then. if (c.p.hostKillJob(id)) stopped = true else jobless = true; } if (stopped) return; if (jobless) return c.p.reportFailure(c.id, "Kill: no job to signal (its shell runs it without job control)"); if (names.len > 0) c.p.reportFailure(c.id, "Kill: no running command has that first word") else // said, but no failure: a script's Kill has nothing left to do c.p.setMessage(c.id, "Kill: nothing running"); } }; pub const Dump = struct { pub const scope: Scope = .session; pub fn run(c: Ctx) void { dump.dumpState(c.p) catch {}; } }; pub const Restore = struct { pub const scope: Scope = .session; pub const takes_arg = true; pub fn run(c: Ctx) void { const path = c.arg orelse (c.p.last_dump orelse return c.p.reportFailure(c.id, "Restore: no dump yet; name one")); if (path.len > c.p.restore_buf.len) return c.p.reportFailure(c.id, "Restore: that name is too long"); // A dump that cannot be read is said now, and fails a ctl write: the // host reads it only after this step, where nobody hears. First, // so a Restore that could never happen spends no warning. if (comptime pardes.hosted) { const bytes = @import("fs.zig").readRestore(c.p.gpa, path, c.p.settings.dump_dir.get()) catch |err| { var buf: [limits.host_path_cap + 64]u8 = undefined; const why = switch (err) { error.FileNotFound => "no such file", error.PermissionDenied => "permission denied", error.IsDirectory => "a directory, not a dump", else => @errorName(err), }; return c.p.reportFailure(c.id, std.fmt.bufPrint(&buf, "Restore: {s}: {s}", .{ path, why }) catch "Restore: cannot read the dump"); }; defer c.p.gpa.free(bytes); // Nor does a file that is not a dump, before any warning. pardes.dump.check(c.p.gpa, bytes) catch |err| { var buf: [limits.host_path_cap + 64]u8 = undefined; return c.p.reportFailure(c.id, std.fmt.bufPrint(&buf, "Restore: {s}: not a pardes dump ({s})", .{ path, @errorName(err) }) catch "Restore: not a pardes dump"); }; } // acme's Load adds a dump's windows to the ones there; a Restore // replaces them all, so it asks what Exit asks first. if (warnModified(c, .Restore)) return; @memcpy(c.p.restore_buf[0..path.len], path); c.p.restore_req = c.p.restore_buf[0..path.len]; } }; pub const Attach = struct { pub const scope: Scope = .session; pub const takes_arg = true; pub const enabled = pardes.can_attach; pub fn run(c: Ctx) void { if (comptime enabled) ask(c) else unreachable; } fn ask(c: Ctx) void { const name = c.arg orelse ""; if (name.len > pardes.attach_name_max) return c.p.reportError(c.id, comptime word(@This()), error.NameTooLong); c.p.emit(.{ .attach = .{ .pane = @intCast(c.id), .name = .from(name) } }); } }; pub const Detach = struct { pub const scope: Scope = .session; pub const enabled = pardes.can_attach; pub fn run(c: Ctx) void { if (comptime enabled) c.p.emit(.{ .detach = .{ .pane = @intCast(c.id) } }) else unreachable; } }; pub const Mount = struct { pub const scope: Scope = .session; pub const takes_arg = true; pub const requires_arg = true; pub const enabled = pardes.hosted; pub fn run(c: Ctx) void { if (comptime !enabled) unreachable; var args = std.mem.tokenizeAny(u8, c.arg orelse "", " \t"); const name = args.next() orelse return c.p.reportError(c.id, "Mount name dial", error.MissingArgument); const dial = std.mem.trim(u8, args.rest(), " \t"); if (dial.len == 0) return c.p.reportError(c.id, "Mount name dial", error.MissingArgument); c.p.fs.mount(c.p.gpa, name, dial) catch |err| return c.p.reportError(c.id, "Mount", err); } }; pub const Unmount = struct { pub const scope: Scope = .session; pub const takes_arg = true; pub const requires_arg = true; pub const enabled = pardes.hosted; pub fn run(c: Ctx) void { if (comptime !enabled) unreachable; var args = std.mem.tokenizeAny(u8, c.arg orelse "", " \t"); const name = args.next() orelse return c.p.reportError(c.id, "Unmount name", error.MissingArgument); if (args.next() != null) return c.p.reportError(c.id, "Unmount name", error.TooManyArguments); pardes.filesystem.unmount(c.p, name) catch |err| return c.p.reportError(c.id, "Unmount", err); } }; // ---- the message row ---- pub const Msg = struct { pub const takes_arg = true; pub const requires_arg = true; pub const quiet = true; // it IS the message row pub fn run(c: Ctx) void { if (c.arg) |text| c.p.setMessage(c.id, text) else c.p.reportError(c.id, comptime word(@This()), error.NoMessage); } }; pub const NextColor = struct { pub const scope: Scope = .session; pub fn run(c: Ctx) void { colors.setThemeIndex(c.p, (@as(usize, c.p.settings.theme) + 1) % pardes.themes.len); } }; pub const ThemeSel = struct { pub const scope: Scope = .session; pub const output: OutputTraits = .{ .name = config.themes_buffer, .steps = true, .commands = true }; pub fn run(c: Ctx) void { panes.Output.openThemes(c.p, c.id) catch |err| c.p.reportError(c.id, "themes", err); } }; pub const ThemeFile = struct { pub const scope: Scope = .session; pub const takes_arg = true; pub const requires_arg = true; pub const enabled = pardes.hosted; pub fn run(c: Ctx) void { if (comptime enabled) colors.requestThemeFile(c.p, c.id, c.arg orelse return) else unreachable; } }; pub const DumpThemes = struct { pub const scope: Scope = .session; pub const enabled = pardes.hosted; pub fn run(c: Ctx) void { if (comptime enabled) { if (c.p.opts.config_dir == null) { c.p.reportError(c.id, "dump themes", error.NoConfigDirectory); return; } c.p.emit(.{ .dump_themes = .{ .pane = @intCast(c.id) } }); } else unreachable; } }; pub const FontSel = struct { pub const scope: Scope = .session; pub const output: OutputTraits = .{ .name = config.fonts_buffer, .steps = true, .commands = true }; pub const enabled = capabilities.font_picker; pub fn run(c: Ctx) void { if (comptime enabled) apply(c) else unreachable; } fn apply(c: Ctx) void { panes.Output.openFonts(c.p, c.id) catch |err| c.p.reportError(c.id, "fonts", err); } }; pub const TreeContext = struct { pub const enabled = pardes.syntax.enabled; pub const takes_arg = true; pub fn run(c: Ctx) void { if (comptime !enabled) unreachable; if (!panes.File.supportsContext(c.pane)) return; const file = &c.pane.file.?; const arg = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); const on = if (arg.len == 0) !file.tree_context else if (std.mem.eql(u8, arg, "on")) true else if (std.mem.eql(u8, arg, "off")) false else { c.p.reportError(c.id, "TreeContext", error.ExpectedOnOrOff); return; }; file.tree_context = on; file.syntax_dirty = true; c.pane.wrap_n = 0; c.pane.context_rows = 0; c.pane.body_rows = 0; c.pane.body_visible_rows = 0; c.pane.context_row_limit = null; c.p.surface.body_layers[c.id].rows = 0; } }; pub const PdfFit = struct { pub const enabled = pardes.pdf_enabled; pub fn run(c: Ctx) void { if (comptime enabled) apply(c) else unreachable; } fn apply(c: Ctx) void { panes.Pdf.toggleFit(c.pane); } }; pub const PdfTint = struct { pub const enabled = pardes.pdf_enabled; pub fn run(c: Ctx) void { if (comptime enabled) apply(c) else unreachable; } fn apply(c: Ctx) void { panes.Pdf.toggleTint(c.pane); } }; pub const PdfSections = struct { pub const output: OutputTraits = .{ .name = config.pdf_sections_buffer, .steps = true, .locations = true }; pub const enabled = pardes.pdf_enabled; pub fn run(c: Ctx) void { if (comptime enabled) apply(c) else unreachable; } fn apply(c: Ctx) void { panes.Pdf.openSections(c.p, c.id); } }; /// glyph art over the host's pixels pub const Petscii = struct { pub fn run(c: Ctx) void { if (c.pane.image) |*state| panes.Image.toggleGlyphArt(state); } }; /// the C64 palette or the terminal's own 16 pub const Palette = struct { pub fn run(c: Ctx) void { if (c.pane.image) |*state| panes.Image.togglePalette(state); } }; /// add the printable ASCII bitmaps to the matcher's glyph set pub const Ascii = struct { pub fn run(c: Ctx) void { if (c.pane.image) |*state| panes.Image.toggleAscii(state); } }; // ---- the system clipboard ---- pub const ClipYank = struct { pub fn run(c: Ctx) void { edit.clipYank(c.p, c.pane, false); } }; /// helix `Y`: the PRIMARY selection alone, where `SPC y` joins every /// cursor's. One cursor makes them the same word. pub const ClipYankMain = struct { pub fn run(c: Ctx) void { edit.clipYank(c.p, c.pane, true); } }; pub const ClipPaste = struct { pub fn run(c: Ctx) void { edit.clipRequest(c.p, c.id, .after); } }; pub const ClipPasteBefore = struct { pub fn run(c: Ctx) void { edit.clipRequest(c.p, c.id, .before); } }; pub const ClipReplace = struct { pub fn run(c: Ctx) void { edit.clipRequest(c.p, c.id, .replace); } }; // ---- panes and columns ---- pub const Save = struct { pub const takes_arg = true; pub fn run(c: Ctx) void { const path = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); if (path.len > 0) return exec.saveTo(c.p, c.id, path); if (c.pane.file) |file| if (file.output == null) return exec.saveFile(c.p, c.id); if (c.pane.file != null or c.pane.isTerminal()) c.p.startPrompt(c.pane, &c.pane.body, .save); } }; pub const New = struct { pub const output: OutputTraits = .{ .name = config.scratch_buffer, .doc = true, .saves = true }; pub fn run(c: Ctx) void { c.p.newScratchBelow(c.id); } }; /// An empty column right of the keyboard's, its tag taking the keyboard. pub const Newcol = struct { pub const scope: Scope = .session; pub fn run(c: Ctx) void { c.p.newColumn(c.id); } }; /// Close the pane. `Del k` gives its rows to the open pane above it and /// `Del j` to the one below, each falling back to the other side. Focus on /// it goes with its rows. pub const Del = struct { pub const takes_arg = true; pub fn run(c: Ctx) void { const side = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); // acme's Del asks winclean first (exec.c del): unsaved text is warned // about once, and the same Del again, nothing edited since, closes. if (warnModifiedIn(c, .Del, .{ .pane = c.id })) return; const at = layout.findPane(c.p, c.id); const above = if (at) |f| layout.expandedNeighbor(c.p, f.col, f.idx, .above) else null; const below = if (at) |f| layout.expandedNeighbor(c.p, f.col, f.idx, .below) else null; const to: ?usize = if (std.mem.eql(u8, side, "k")) above orelse below else if (std.mem.eql(u8, side, "j")) below orelse above else if (side.len > 0) return c.p.reportFailure(c.id, "Del: takes k, j or nothing") else null; // Someone at the keyboard with a real choice to make is asked, on the // pane's notice band; a click, a 9P write or a script closes it the // way it always has. A folded pane gives back only its tag row, and a // pane with a prompt open closes as it did rather than trade the // prompt for a question. if (side.len == 0 and c.p.can_ask and above != null and below != null and !c.pane.collapsed and c.pane.prompt == .none) { c.pane.prompt = .del_side; c.p.active = c.id; var rec: [32]u8 = undefined; pardes.ctlfs.events.notePath(c.p, "ask", std.fmt.bufPrint(&rec, "{d} del k j", .{c.pane.serial}) catch ""); // The question says Del already. if (c.pane.msg_announcement) c.pane.msg_len = 0; return; } c.p.removePane(c.id, to) catch |err| return c.p.reportError(c.id, "close", err); } }; pub const Filter = struct { pub fn run(c: Ctx) void { if (!c.pane.isTerminal()) return; c.pane.tty_filter = !c.pane.tty_filter; } }; /// Cycle the pane's input modes: terminals add raw tty mode to normal/insert. pub const Mode = struct { pub fn run(c: Ctx) void { c.p.cycleMode(c.id); } }; /// Switch this terminal between raw program input and normal editor mode. pub const Togglettymode = struct { pub fn run(c: Ctx) void { c.p.toggleTty(c.id); } }; /// acme's Edit (exec.c `edit`, edit.c): sam's command language over the /// pane's body, from a tag, a pane ctl or exec. One undo step; a failure /// changes nothing and says why. src/sam_edit.zig has what is left out. pub const Edit = struct { pub const takes_arg = true; pub const requires_arg = true; pub fn run(c: Ctx) void { const sam = @import("sam_edit.zig"); const ninep_pane = @import("ninep/pane.zig"); const f = ninep_pane.fileOf(c.pane) orelse return c.p.reportFailure(c.id, "Edit: only a text pane is edited"); const arena = c.p.scratch.allocator(); var why: sam.Why = .{}; var said: [260]u8 = undefined; const res = sam.run(arena, f.content, ninep_pane.dotOf(c.pane), f.path, c.arg orelse "", &why) catch |err| switch (err) { error.Edit => return c.p.reportFailure(c.id, std.fmt.bufPrint(&said, "Edit: {s}", .{why.text()}) catch "Edit: failed"), error.OutOfMemory => return c.p.reportError(c.id, "Edit", err), }; if (res.undo != 0) { for (0..@abs(res.undo)) |_| if (res.undo > 0) panes.File.undo(c.p, c.pane) else panes.File.redo(c.p, c.pane); return; } if (res.ops.len > 0) { const new = sam.apply(c.p.gpa, f.content, res.ops) catch |err| return c.p.reportError(c.id, "Edit", err); panes.File.pushUndo(c.p, c.pane); panes.File.setContent(c.p, f, new); } ninep_pane.setDot(c.pane, sam.moveDot(res.dot, res.ops)); if (res.printed.len > 0) _ = ninep_pane.appendErrors(c.p, c.id, res.printed); } }; /// acme's Undo and Redo (exec.c `undo`): step the pane's body back or /// forward through its edits, from a tag, a pane ctl or exec. /// With no step left, Undo and Redo say so and change nothing; a ctl write /// of them still succeeds, as acme's Undo is silent there. A file keeps /// limits.undo_max steps (256). pub const Undo = struct { pub fn run(c: Ctx) void { if (c.pane.file) |f| if (f.history.undo_len == 0) return c.p.setMessage(c.id, "Undo: nothing to undo"); edit.doUndo(c.p, &c.pane.body); } }; pub const Redo = struct { pub fn run(c: Ctx) void { if (c.pane.file) |f| if (f.history.redo_len == 0) return c.p.setMessage(c.id, "Redo: nothing to redo"); edit.doRedo(c.p, &c.pane.body); } }; /// Fold a pane to its tag, or restore its share of the column. pub const Collapse = struct { pub fn run(c: Ctx) void { layout.toggleCollapse(c.p, c.id); } }; pub const Delcol = struct { pub fn run(c: Ctx) void { // The column of the tag it came from, else the pane's: a pane ctl's // Delcol closes that pane's column wherever the keyboard is. const col = c.p.exec_column orelse (layout.findPane(c.p, c.id) orelse return).col; // acme's Delcol refuses a column with unsaved text (exec.c delcol, // cols.c colclean), once: the next Delcol closes it. if (warnModifiedIn(c, .Delcol, .{ .column = col })) return; c.p.removeColumn(col) catch |err| c.p.reportError(c.id, "close column", err); } }; pub const DelAbove = struct { pub fn run(c: Ctx) void { Del.run(.{ .p = c.p, .pane = c.pane, .id = c.id, .txt = c.txt, .arg = "k" }); } }; pub const DelBelow = struct { pub fn run(c: Ctx) void { Del.run(.{ .p = c.p, .pane = c.pane, .id = c.id, .txt = c.txt, .arg = "j" }); } }; /// A shell in the calling pane's directory, raw from the first frame. On every /// pane's tagline: the fast path from wherever you are to a prompt there. /// A terminal below the caller in its directory: the configured shell, or /// the one named, `Tty fish`, as a terminal's own tag word `Tty+fish` says. pub const Tty = struct { pub const takes_arg = true; pub const plus_arg = true; pub fn run(c: Ctx) void { const arg = std.mem.trim(u8, c.arg orelse "", " \t"); // A shell that is not there is said, where the host would quietly // start its fallback in its place. if (comptime pardes.hosted) if (arg.len > 0) { var why: [320]u8 = undefined; if (@import("host_io.zig").Shell.refusal(arg[0..@min(arg.len, 200)], &why)) |refused| { var said: [340]u8 = undefined; return c.p.reportFailure(c.id, std.fmt.bufPrint(&said, "Tty: {s}", .{refused}) catch "Tty: no such shell"); } }; const pane = exec.spawnTty(c.p, c.id) orelse return; if (arg.len > 0) pane.shell = c.p.gpa.dupe(u8, arg) catch null; } }; /// A Linux terminal with the current session mounted through kernel v9fs. pub const Tty9p = struct { pub const enabled = pardes.hosted and @import("builtin").os.tag == .linux; pub fn run(c: Ctx) void { exec.spawnV9fsTty(c.p, c.id); } }; /// Fold the active pane's column into the one on its right, keeping its panes. /// Binds this terminal as a language's REPL, by the language's name as the /// syntax table has it: an exec made on the body of a file in that /// language -- a middle click, the execute key, a single word like `ls` /// as much as a selection -- and one made on this terminal's own body, is /// typed into it (bracketed paste if its program asked for that, else line /// by line, then Enter) instead of run. `Repl python` binds (again, it is /// already); `Repl -` unbinds; `Repl` bare says what it is bound as. With /// several bound for a language, the exec asks which. /// Still commands, whatever is bound: a word in a tag, `Exec ` run by /// name (typed, a 2-1 chord onto Exec, a ctl line), and a command word /// @`cmd` in the text, looked at or clicked. pub const Repl = struct { pub const takes_arg = true; pub fn run(c: Ctx) void { const pane = c.pane; // A terminal with its emulator -- not a document pane -- and not // a command pane whose command, the REPL it would be, is done. if (!pane.isTerminal() or pane.terminal == null) return c.p.reportFailure(c.id, "Repl: only a terminal is a REPL"); if (pane.command != null and pane.command_done) return c.p.reportFailure(c.id, "Repl: its command is done; a REPL is a running program"); const arg = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); var buf: [320]u8 = undefined; var idbuf: [48]u8 = undefined; if (arg.len == 0) { if (pane.repl == null) return c.p.setMessage(c.id, "Repl: not bound"); return c.p.setMessage(c.id, std.fmt.bufPrint(&buf, "Repl: {s}", .{exec.replId(&idbuf, pane)}) catch "Repl"); } if (std.mem.eql(u8, arg, "-")) { pane.repl = null; return c.p.setMessage(c.id, "Repl: unbound"); } const manifest = @import("grammar_manifest.zig"); const grammars = manifest.all; // Named as a code fence names it: `py`, `Python`, `sh`. const named: ?u8 = if (manifest.byName(arg)) |i| if (grammars[i].exts.len > 0) @intCast(i) else null else null; const lang: u8 = named orelse { // A few, whole, and where the rest are: a list cut to fit the // row would end mid-word. var w = std.Io.Writer.fixed(&buf); w.print("Repl: no language \"{s}\"; - or one like", .{@import("Messages.zig").clip(arg, 24)}) catch {}; // Those that start as the word does first, a misspelling's // likeliest meaning (pithon: python). var shown: usize = 0; var known: usize = 0; for ([2]bool{ true, false }) |alike| for (grammars) |g| if (g.exts.len > 0) { const starts = arg.len > 0 and g.name[0] == std.ascii.toLower(arg[0]); if (starts != alike) continue; known += 1; if (shown < 6) { w.print(" {s}", .{g.name}) catch {}; shown += 1; } }; w.print(" ({d} more: docs/tags.md, Repl)", .{known - shown}) catch {}; return c.p.reportFailure(c.id, w.buffered()); }; if (pane.repl) |r| if (r.lang == lang) return c.p.setMessage(c.id, std.fmt.bufPrint(&buf, "Repl: {s}", .{exec.replId(&idbuf, pane)}) catch "Repl"); // The lowest letter no other REPL of the language has: freed ones // come back. var letter: u8 = 'a'; while (letter <= 'z') : (letter += 1) { const taken = for (c.p.panes) |slot| { const other = slot orelse continue; if (other != pane) if (other.repl) |r| if (r.lang == lang and r.letter == letter) break true; } else false; if (!taken) break; } if (letter > 'z') return c.p.reportFailure(c.id, "Repl: every letter of the language is taken"); pane.repl = .{ .lang = lang, .letter = letter }; pane.repl_warned = false; c.p.setMessage(c.id, std.fmt.bufPrint(&buf, "Repl: {s}", .{exec.replId(&idbuf, pane)}) catch "Repl"); } }; /// The horizontal mirror of the vertical stacking `New` does. pub const Joincol = struct { pub const scope: Scope = .session; pub fn run(c: Ctx) void { // The keyboard's column joins the one to its right; the last has none. const col = c.p.keyboardColumn(c.p.active) orelse return; if (col + 1 >= c.p.ncol) return c.p.reportFailure(c.id, "Joincol: no column to the right"); layout.joinCol(c.p, col); } }; pub const Tutor = struct { pub const scope: Scope = .session; pub fn run(c: Ctx) void { const free = c.p.freeSlot() orelse return c.p.reportError(c.id, "Tutor", error.NoPaneSlots); _ = c.p.openTutorView(free) catch return; exec.placeNew(c.p, c.id, c.id, free, .doc); // a doc like any other } }; pub const Help = struct { pub const scope: Scope = .session; pub const output: OutputTraits = .{ .name = config.help_buffer }; pub fn run(c: Ctx) void { panes.Output.openHelp(c.p, c.id, "") catch |err| c.p.reportError(c.id, "help", err); } }; pub const Config = struct { pub const scope: Scope = .session; pub const output: OutputTraits = .{ .name = config.config_buffer }; pub fn run(c: Ctx) void { panes.Output.openConfig(c.p, c.id) catch |err| c.p.reportError(c.id, "config", err); } }; pub const LocationsConfig = struct { pub const scope: Scope = .session; pub const takes_arg = true; pub const output: OutputTraits = .{ .name = "+LocationsConfig" }; pub fn run(c: Ctx) void { const argument = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); const next = c.p.locations_config.parse(argument) catch |err| { c.p.reportError(c.id, "LocationsConfig", err); return; }; c.p.locations_config = next; // Bare, it is a question, and the window is the answer. With arguments // it is a setting, and a setting that opens a window is noise. if (argument.len != 0) return; report(c) catch |err| c.p.reportError(c.id, "LocationsConfig", err); } fn report(c: Ctx) !void { var out: std.Io.Writer.Allocating = .init(c.p.gpa); errdefer out.deinit(); try out.writer.writeAll("LocationsConfig "); try c.p.locations_config.write(&out.writer); try out.writer.writeByte('\n'); const content = try out.toOwnedSlice(); const dir = if (c.pane.file) |file| std.fs.path.dirname(file.path) orelse "/" else c.pane.cwdSlice(); try panes.Output.fillResults(c.p, c.id, dir, .{ .cmd = .LocationsConfig }, "", content, null); } }; pub const Messages = struct { pub const scope: Scope = .session; pub const output: OutputTraits = .{ .name = config.messages_buffer }; pub fn run(c: Ctx) void { panes.Output.openMessages(c.p, c.id) catch |err| c.p.reportError(c.id, "messages", err); } }; /// This build's version and what changed to reach it, printed into an output /// buffer the same way Config prints the live settings. pub const Changelog = struct { pub const scope: Scope = .session; pub const output: OutputTraits = .{ .name = config.changelog_buffer }; pub fn run(c: Ctx) void { panes.Output.openChangelog(c.p, c.id) catch |err| c.p.reportError(c.id, "changelog", err); } }; pub const EffectCode = struct { pub const scope: Scope = .session; pub const takes_arg = true; pub const requires_arg = true; pub const enabled = capabilities.panel_transitions or capabilities.scene_shaders; pub const output: OutputTraits = .{ .name = config.effect_code_buffer }; pub fn run(c: Ctx) void { if (comptime enabled) panes.Output.openEffectCode(c.p, c.id, c.arg orelse return) catch |err| c.p.reportError(c.id, "effect code", err) else unreachable; } }; /// An optional SDL workspace-tag companion; never consumes input or text cells. pub const Pet = struct { pub const scope: Scope = .session; pub const takes_arg = true; pub const requires_arg = true; pub const enabled = pardes.platform == .gui; pub fn run(c: Ctx) void { const name = std.mem.trim(u8, c.arg orelse return, " \t\r\n"); c.p.settings.pet = std.meta.stringToEnum(@TypeOf(c.p.settings.pet), name) orelse return; } }; pub const Mini = struct { pub const takes_arg = true; pub const output: OutputTraits = .{ .name = "Mini", .doc = true }; pub fn run(c: Ctx) void { panes.Mini.open(c.p, c.id, c.arg orelse "") catch |err| c.p.reportError(c.id, "mini", err); } }; // ---- search ---- pub const Find = struct { pub const takes_arg = true; pub const requires_arg = true; pub const output: OutputTraits = .{ .name = config.search_buffer, .steps = true }; pub fn run(c: Ctx) void { const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); if (pat.len > 0) { _ = look.runSearch(c.p, c.id, pat, .find, .top, true) catch |err| c.p.reportError(c.id, "find", err); return; } c.p.startPrompt(c.pane, &c.pane.body, .{ .search = config.find_marker }); } }; /// Find's sibling: Find matches file NAMES under this pane's directory, Grep /// matches file CONTENTS under every pane's directory at once. pub const Grep = struct { pub const takes_arg = true; pub const requires_arg = true; pub const output: OutputTraits = .{ .name = config.search_buffer, .steps = true, .locations = true }; pub fn run(c: Ctx) void { const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); if (pat.len > 0) { _ = look.runSearch(c.p, c.id, pat, .grep, .top, true) catch |err| c.p.reportError(c.id, "grep", err); return; } c.p.startPrompt(c.pane, &c.pane.body, .{ .search = config.grep_marker }); } }; // ---- the window group ---- // The headers are above the panes in the same layout: Up from a pane with // nothing above it reaches its column's tag, then the workspace's, and Down // comes back the same way. Left and Right walk the column tags. pub const Left = struct { pub fn run(c: Ctx) void { if (c.p.header_focus) { if (c.p.header_column) |column| if (column > 0) tagline.enterHeader(c.p, column - 1); return; } _ = layout.focusDir(c.p, c.id, .left); } }; pub const Down = struct { pub fn run(c: Ctx) void { if (c.p.header_focus) { const column = layout.findPane(c.p, c.id); if (c.p.header_column == null and c.p.columnBarHeight() > 0) return tagline.enterHeader(c.p, if (column) |at| at.col else 0); // Onto the top pane of the column the header stands over. const col = c.p.header_column orelse if (column) |at| at.col else return; // An empty column's tag has nothing below it. if (col < c.p.ncol and c.p.col_n[col] == 0) return; tagline.exitHeader(c.p); if (col < c.p.ncol) c.p.active = c.p.col_panes[col][0]; return; } _ = layout.focusDir(c.p, c.id, .down); } }; pub const Up = struct { pub fn run(c: Ctx) void { if (c.p.header_focus) { if (c.p.header_column != null and c.p.topBarHeight() > 0) tagline.enterHeader(c.p, null); return; } if (layout.focusDir(c.p, c.id, .up)) return; if (c.p.columnBarHeight() > 0) { if (layout.findPane(c.p, c.id)) |at| tagline.enterHeader(c.p, at.col); } else if (c.p.topBarHeight() > 0) tagline.enterHeader(c.p, null); } }; pub const Right = struct { pub fn run(c: Ctx) void { if (c.p.header_focus) { if (c.p.header_column) |column| if (column + 1 < c.p.ncol) tagline.enterHeader(c.p, column + 1); return; } _ = layout.focusDir(c.p, c.id, .right); } }; // ---- the jump group ---- /// Ctrl-o: one step back into the history. pub const Back = struct { pub const scope: Scope = .session; pub fn run(c: Ctx) void { c.p.jumpBy(-1); } }; /// Ctrl-i: one step forward again, up to wherever Back started. pub const Forward = struct { pub const scope: Scope = .session; pub fn run(c: Ctx) void { c.p.jumpBy(1); } }; pub const Last = struct { pub fn run(c: Ctx) void { var i = c.p.njumps; while (i > 0) { i -= 1; const j = c.p.jumps[i]; // Restore the cursor without recentering the pane's retained view. if (j.pane != c.id) return look.focusPaneLine(c.p, j.pane, .{ .line = j.line, .col = j.col }, .keep); } // Nothing on the jumplist to go back to, which is the ordinary case // for a pane that opened beside this one and was never focused. if (c.p.neighbourPane(c.id)) |next| look.focusPaneLine(c.p, next, .{ .line = 0, .col = 0 }, .keep); } }; pub const Jumplist = struct { pub const scope: Scope = .session; pub const output: OutputTraits = .{ .name = config.jumps_buffer, .steps = true, .locations = true }; pub fn run(c: Ctx) void { panes.Output.openJumps(c.p, c.id) catch |err| c.p.reportError(c.id, "jumplist", err); } }; // ---- the language group ---- pub const Hover = struct { pub fn run(c: Ctx) void { c.p.lspRequest(c.id, .hover, ""); } }; pub const CodeAction = struct { pub fn run(c: Ctx) void { c.p.lspRequest(c.id, .code_action, ""); } }; pub const SelectRefs = struct { pub fn run(c: Ctx) void { c.p.lspRequest(c.id, .select_refs, ""); } }; pub const Symbols = struct { pub fn run(c: Ctx) void { c.p.lspRequest(c.id, .document_symbols, ""); } }; pub const Diagnostics = struct { pub fn run(c: Ctx) void { c.p.lspRequest(c.id, .diagnostics, ""); } }; pub const WsDiagnostics = struct { pub fn run(c: Ctx) void { c.p.lspRequest(c.id, .workspace_diagnostics, ""); } }; pub const Callers = struct { pub fn run(c: Ctx) void { c.p.lspRequest(c.id, .incoming_calls, ""); } }; pub const Callees = struct { pub fn run(c: Ctx) void { c.p.lspRequest(c.id, .outgoing_calls, ""); } }; pub const Supertypes = struct { pub fn run(c: Ctx) void { c.p.lspRequest(c.id, .supertypes, ""); } }; pub const Subtypes = struct { pub fn run(c: Ctx) void { c.p.lspRequest(c.id, .subtypes, ""); } }; pub const Rename = struct { pub const takes_arg = true; pub const requires_arg = true; pub fn run(c: Ctx) void { const a = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); if (a.len > 0) return c.p.lspRequest(c.id, .rename, a); c.p.startPrompt(c.pane, &c.pane.body, .{ .search = config.rename_marker }); } }; pub const WsSymbols = struct { pub const takes_arg = true; pub const requires_arg = true; pub fn run(c: Ctx) void { const a = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); if (a.len > 0) return c.p.lspRequest(c.id, .workspace_symbols, a); c.p.startPrompt(c.pane, &c.pane.body, .{ .search = config.symbol_marker }); } }; pub const Lspinfo = struct { pub fn run(c: Ctx) void { c.p.lspRequest(c.id, .status, ""); } }; pub const Lspwhy = struct { pub fn run(c: Ctx) void { c.p.lspRequest(c.id, .explain, ""); } }; pub const Peek = struct { pub const scope: Scope = .session; pub const takes_arg = true; pub const requires_arg = true; pub const enabled = Board.enabled; pub const output: OutputTraits = .{ .name = config.peek_buffer }; pub fn run(c: Ctx) void { if (comptime enabled) { Board.peek(c.p, c.id, c.arg orelse "") catch |err| c.p.reportError(c.id, "peek", err); } else unreachable; } }; pub const Poke = struct { pub const scope: Scope = .session; pub const takes_arg = true; pub const requires_arg = true; pub const enabled = Board.enabled; pub fn run(c: Ctx) void { if (comptime enabled) { Board.poke(c.p, c.id, c.arg orelse "") catch |err| c.p.reportError(c.id, "poke", err); } else unreachable; } }; pub const Hexdump = struct { pub const scope: Scope = .session; pub const takes_arg = true; pub const requires_arg = true; pub const enabled = Board.enabled; pub const output: OutputTraits = .{ .name = config.hexdump_buffer }; pub fn run(c: Ctx) void { if (comptime enabled) { Board.hexdump(c.p, c.id, c.arg orelse "") catch |err| c.p.reportError(c.id, "hexdump", err); } else unreachable; } }; pub const Gpio = struct { pub const scope: Scope = .session; pub const takes_arg = true; pub const enabled = Board.enabled; pub const output: OutputTraits = .{ .name = config.gpio_buffer }; pub fn run(c: Ctx) void { if (comptime enabled) { Board.gpio(c.p, c.id, c.arg orelse "") catch |err| c.p.reportError(c.id, "gpio", err); } else unreachable; } }; const Board = struct { const enabled = pardes.platform == .esp32p4; comptime { if (enabled and pardes.hosted) @compileError("an OS is not bare metal"); if (enabled and builtin.os.tag != .freestanding) @compileError("the P4 firmware is freestanding"); if (enabled and builtin.target.cpu.arch.isWasm()) @compileError("wasm addresses are not a bus"); } // Bound output sent over the board's 115200-baud console. const max_bytes: u32 = 4096; const max_words: u32 = max_bytes / 4; const address_space_end: u64 = 1 << 32; const Error = error{ MissingAddress, BadAddress, BadCount, MissingValue, BadValue, MisalignedAddress, ExtraArgument, BadPin, NoPads, }; fn parseHex(comptime T: type, tok: []const u8, bad: Error) Error!T { const body = if (tok.len > 2 and tok[0] == '0' and (tok[1] | 0x20) == 'x') tok[2..] else tok; return std.fmt.parseInt(T, body, 16) catch bad; } fn parseAddr(tok: []const u8) Error!u32 { return parseHex(u32, tok, Error.BadAddress); } fn parseCount(tok: []const u8) Error!u64 { return parseHex(u64, tok, Error.BadCount); } fn parseValue(tok: []const u8) Error!u32 { return parseHex(u32, tok, Error.BadValue); } // Callers reject unaligned words; volatile accesses must reach the device. fn readWord(addr: u32) u32 { const cell: *allowzero const volatile u32 = @ptrFromInt(@as(usize, addr)); return cell.*; } fn writeWord(addr: u32, value: u32) void { const cell: *allowzero volatile u32 = @ptrFromInt(@as(usize, addr)); cell.* = value; } fn readByte(addr: u32) u8 { const cell: *allowzero const volatile u8 = @ptrFromInt(@as(usize, addr)); return cell.*; } const Limit = enum { console, space, }; const Extent = struct { count: u32, limit: ?Limit, }; fn extent(addr: u32, requested: u64, unit: u32, cap: u32) Extent { var count = requested; var limit: ?Limit = null; if (count > cap) { count = cap; limit = .console; } const fits = (address_space_end - addr) / unit; if (count > fits) { count = fits; limit = .space; } return .{ .count = @intCast(count), .limit = limit }; } fn writeNote(w: *std.Io.Writer, e: Extent, requested: u64, unit_name: []const u8) !void { switch (e.limit orelse return) { .console => try w.print( "clamped: 0x{x} {s} requested, 0x{x} shown (0x{x}-byte cap, one 115200-baud console)\n", .{ requested, unit_name, e.count, max_bytes }, ), .space => try w.print( "clamped: 0x{x} {s} requested, 0x{x} shown (the 32-bit address space ends at 0x100000000)\n", .{ requested, unit_name, e.count }, ), } } fn peek(p: *Pardes, id: usize, argument: []const u8) !void { var it = std.mem.tokenizeAny(u8, argument, " \t\r\n"); const addr = try parseAddr(it.next() orelse return Error.MissingAddress); const requested = if (it.next()) |tok| try parseCount(tok) else 0x1; if (it.next() != null) return Error.ExtraArgument; if (addr % 4 != 0) return Error.MisalignedAddress; const e = extent(addr, requested, 4, max_words); var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); try writeNote(&out.writer, e, requested, "words"); for (0..e.count) |i| { const at = addr + @as(u32, @intCast(i * 4)); try out.writer.print("{x:0>8}: {x:0>8}\n", .{ at, readWord(at) }); } const content = try out.toOwnedSlice(); try fill(p, id, .{ .cmd = .Peek }, content); } fn poke(p: *Pardes, id: usize, argument: []const u8) !void { var it = std.mem.tokenizeAny(u8, argument, " \t\r\n"); const addr = try parseAddr(it.next() orelse return Error.MissingAddress); const value = try parseValue(it.next() orelse return Error.MissingValue); if (it.next() != null) return Error.ExtraArgument; if (addr % 4 != 0) return Error.MisalignedAddress; writeWord(addr, value); const back = readWord(addr); var buf: [96]u8 = undefined; p.setMessage(id, std.fmt.bufPrint( &buf, "{x:0>8}: wrote {x:0>8}, reads {x:0>8}", .{ addr, value, back }, ) catch unreachable); } fn gpio(p: *Pardes, id: usize, argument: []const u8) !void { var it = std.mem.tokenizeAny(u8, argument, " \t\r\n"); const tok = it.next() orelse { const content = try p.gpa.dupe(u8, Header.text); return fill(p, id, .{ .cmd = .Gpio }, content); }; if (it.next() != null) return Error.ExtraArgument; const pin = std.fmt.parseInt(u16, tok, 10) catch return Error.BadPin; const toggle = p.host.vtable.gpio_toggle orelse return Error.NoPads; var was: u8 = 0; var now: u8 = 0; if (!toggle(p.host.ctx, pin, &was, &now)) return Error.BadPin; var buf: [48]u8 = undefined; p.setMessage(id, std.fmt.bufPrint(&buf, "GPIO {d}: {d}->{d}", .{ pin, was, now }) catch unreachable); } const row_bytes: u32 = limits.hexdump_row_bytes; fn hexdump(p: *Pardes, id: usize, argument: []const u8) !void { var it = std.mem.tokenizeAny(u8, argument, " \t\r\n"); const addr = try parseAddr(it.next() orelse return Error.MissingAddress); const requested = if (it.next()) |tok| try parseCount(tok) else 0x100; if (it.next() != null) return Error.ExtraArgument; const e = extent(addr, requested, 1, max_bytes); var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); try writeNote(&out.writer, e, requested, "bytes"); var row: u32 = 0; while (row < e.count) : (row += row_bytes) { const n = @min(row_bytes, e.count - row); var bytes: [row_bytes]u8 = undefined; for (0..n) |i| bytes[i] = readByte(addr + row + @as(u32, @intCast(i))); try out.writer.print("{x:0>8} ", .{addr + row}); for (0..row_bytes) |i| { if (i == row_bytes / 2) try out.writer.writeByte(' '); if (i < n) try out.writer.print(" {x:0>2}", .{bytes[i]}) else try out.writer.writeAll(" "); } try out.writer.writeAll(" |"); for (0..n) |i| try out.writer.writeByte( if (bytes[i] >= 0x20 and bytes[i] < 0x7f) bytes[i] else '.', ); try out.writer.writeAll("|\n"); } const content = try out.toOwnedSlice(); try fill(p, id, .{ .cmd = .Hexdump }, content); } fn fill(p: *Pardes, id: usize, from: panes.Output.Origin, content: []u8) !void { const pane = p.panes[id] orelse { p.gpa.free(content); return error.MissingPane; }; const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); try panes.Output.fillResults(p, id, dir, from, "", content, null); } test "the clamp reports the tighter bound and never wraps the address space" { const eq = std.testing.expectEqual; try eq(Extent{ .count = 3, .limit = null }, extent(0x4ff40000, 3, 4, max_words)); try eq(Extent{ .count = max_words, .limit = .console }, extent(0x4ff40000, 99_999, 4, max_words)); try eq(Extent{ .count = max_bytes, .limit = .console }, extent(0, 100_000, 1, max_bytes)); try eq(Extent{ .count = 16, .limit = .space }, extent(0xfffffff0, 64, 1, max_bytes)); try eq(Extent{ .count = 4, .limit = .space }, extent(0xfffffff0, 64, 4, max_words)); try eq(Extent{ .count = 0, .limit = .space }, extent(0xffffffff, 1, 4, max_words)); try eq(Extent{ .count = max_bytes, .limit = .console }, extent(0xffff0000, 1 << 20, 1, max_bytes)); } test "a clamp note is written exactly when something was clamped" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try writeNote(&w, extent(0x4ff40000, 3, 4, max_words), 3, "words"); try std.testing.expectEqualStrings("", w.buffered()); try writeNote(&w, extent(0x4ff40000, 99_999, 4, max_words), 99_999, "words"); try std.testing.expectEqualStrings( "clamped: 0x1869f words requested, 0x400 shown (0x1000-byte cap, one 115200-baud console)\n", w.buffered(), ); w = .fixed(&buf); try writeNote(&w, extent(0xfffffff0, 64, 1, max_bytes), 64, "bytes"); try std.testing.expectEqualStrings( "clamped: 0x40 bytes requested, 0x10 shown (the 32-bit address space ends at 0x100000000)\n", w.buffered(), ); } test "every literal is hex, with or without the prefix" { const eq = std.testing.expectEqual; try eq(0x4ff40000, parseAddr("0x4ff40000")); try eq(0x4ff40000, parseAddr("4ff40000")); try eq(0x4ff40000, parseAddr("0X4FF40000")); try eq(0x4ff40000, parseAddr("4FF40000")); try eq(0x100, parseCount("100")); try eq(0x256, parseCount("256")); try eq(0xdeadbeef, parseValue("deadbeef")); try std.testing.expectError(Error.BadAddress, parseAddr("0x100000000")); try std.testing.expectError(Error.BadAddress, parseAddr("0x")); try std.testing.expectError(Error.BadAddress, parseAddr("nope")); try std.testing.expectError(Error.BadAddress, parseAddr("12g4")); try std.testing.expectError(Error.BadCount, parseCount("-1")); try std.testing.expectError(Error.BadValue, parseValue("0x1_0000_0000")); } test "the pinout fits the board's own grid, in two aligned columns" { const cols: usize = @import("pardes_config").esp32p4_cols; const usable = cols - 7; var rows: usize = 0; var pins: usize = 0; var first_bar: ?usize = null; var it = std.mem.splitScalar(u8, Header.text, '\n'); while (it.next()) |line| { try std.testing.expect(line.len <= usable); rows += 1; const bar = std.mem.indexOfScalar(u8, line, '|') orelse continue; if (line[line.len - 1] == '+') continue; pins += 1; if (first_bar) |b| try std.testing.expectEqual(b, bar) else first_bar = bar; } try std.testing.expectEqual(@as(usize, 13), pins); try std.testing.expect(rows > 15); try std.testing.expect(std.mem.indexOf(u8, Header.text, "GPIO 20 | 17 |") != null); try std.testing.expect(std.mem.indexOf(u8, Header.text, "| 8 | --") != null); } test "board addresses are hexadecimal and GPIO numbers are decimal" { try std.testing.expectEqual(@as(u16, 20), try std.fmt.parseInt(u16, "20", 10)); try std.testing.expectEqual(@as(u32, 0x20), try parseAddr("20")); try std.testing.expect(20 != 0x20); } }; test "Exit and Restore name a modified +New scratch as well as files" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const file = try p.setTestFile("saved\n"); file.file.?.saved_revision = file.file.?.revision -% 1; // modified file.file.?.saved_hash = 0; // edited: what it had is not what it has p.newScratchBelow(p.active); const scratch = &p.panes[p.active].?.file.?; scratch.content = try p.gpa.realloc(scratch.content, 120); @memset(scratch.content, 'n'); // past acme's 100 bytes, where it whines scratch.revision +%= 1; // typed into, never saved scratch.saved_hash = 0; // edited: what it had is not what it has p.fs.no_prompt = true; defer p.fs.no_prompt = false; try std.testing.expect(p.executeBuiltinLine(p.active, "Exit")); try std.testing.expect(!p.quit); const said = p.fs.failure[0..p.fs.failure_len]; try std.testing.expect(std.mem.indexOf(u8, said, "test.txt") != null); var named: [64]u8 = undefined; try std.testing.expect(std.mem.indexOf(u8, said, try std.fmt.bufPrint(&named, "{s} (pane {d})", .{ config.scratch_buffer, p.panes[p.active].?.serial })) != null); // Delcol, Restore and Exit name it the same way. p.fs.failure_len = 0; try std.testing.expect(p.executeBuiltinLine(p.active, "Delcol")); try std.testing.expect(std.mem.indexOf(u8, p.fs.failure[0..p.fs.failure_len], named[0 .. config.scratch_buffer.len + 7]) != null); } test "Del and Delcol refuse a pane with unsaved text once, as acme's do, and close on the second" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("saved\n"); pane.file.?.saved_revision = pane.file.?.revision -% 1; // modified pane.file.?.saved_hash = 0; // edited: what it had is not what it has const serial = pane.serial; try std.testing.expect(p.executeBuiltinLine(p.active, "Delcol")); try std.testing.expect(p.paneBySerial(serial) != null); try std.testing.expect(std.mem.endsWith(u8, pane.msg[0..pane.msg_len], ": Modified (Delcol again to discard)")); // Each word is warned on its own: Del asks too, then closes. try std.testing.expect(p.executeBuiltinLine(p.active, "Del")); try std.testing.expect(p.paneBySerial(serial) != null); try std.testing.expect(std.mem.endsWith(u8, pane.msg[0..pane.msg_len], ": Modified (Del again to discard)")); try std.testing.expect(p.executeBuiltinLine(p.active, "Del")); try std.testing.expect(p.paneBySerial(serial) == null); } test "a pane renamed but never edited has no text to lose: Del closes it at once" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); p.newScratchBelow(p.active); const pane = try p.setTestFile("loaded\n"); pane.file.?.saved_revision = pane.file.?.revision -% 1; // dirty by the rename alone const serial = pane.serial; try std.testing.expect(p.executeBuiltinLine(0, "Del")); try std.testing.expect(p.paneBySerial(serial) == null); }