summaryrefslogtreecommitdiff
path: root/src/ninep/ctl.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/ninep/ctl.zig')
-rw-r--r--src/ninep/ctl.zig142
1 files changed, 121 insertions, 21 deletions
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index b4144fd2..2fdc47cc 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -51,7 +51,7 @@ fn noteResult(p: *Pardes, serial: u32) void {
/// Runs one look or exec from the pane `id`, then records the panes it
/// created; failing that, a look records the pane it focused and an exec
/// the pane it acted on (even one it closed).
-fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void {
+pub fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void {
const context = p.panes[id].?.serial;
// A pane an event reader holds hears its clicks instead of acting on
// them (editors/acme/look.c:53 and exec.c:173 send them to winevent),
@@ -138,7 +138,14 @@ pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool) Reply {
if (!apply) continue;
const id = if (serial) |s| p.paneBySerial(s) orelse break else p.active;
if (p.panes[id] == null) return Reply.fail(req.tag, E.NOENT);
+ p.fs.no_pane_slot = false;
run(p, id, exec, text);
+ // A pane it would have opened had no slot: the write fails, and
+ // a look reads back nothing.
+ if (p.fs.no_pane_slot) {
+ p.fs.results_len = 0;
+ return tree.failText(req.tag, E.IO, p.fs.ename[0..p.fs.no_pane_slot_len]);
+ }
}
}
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
@@ -259,10 +266,23 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?
const sp = std.mem.indexOfAny(u8, line, " \t");
const word = line[0 .. sp orelse line.len];
const arg = if (sp) |s| std.mem.trim(u8, line[s + 1 ..], " \t") else "";
- const b = std.meta.stringToEnum(Builtin, word) orelse return refuse(p, req, "unknown control message", line);
+ const b = std.meta.stringToEnum(Builtin, word) orelse {
+ // A setting this build's frontend cannot show (Lift, GripWidth on a
+ // terminal) is known, and says so rather than sound misspelt.
+ if (config.Runtime.find(word) != null) {
+ var why: [64]u8 = undefined;
+ return refuse(p, req, std.fmt.bufPrint(&why, "{s} is GUI-only", .{word}) catch "GUI-only", line);
+ }
+ return refuse(p, req, "unknown control message", line);
+ };
// Edit is the one pane word the root takes too: at the active pane.
if (builtins.registry.scope(b) != scope and b != .Edit)
- return refuseTo(p, req, if (scope == .pane) "not a window control message" else "not a session control message", line, if (scope == .pane) "/ctl" else "pane/<n>/ctl");
+ return refuseTo(p, req, if (scope == .pane) "not a window control message" else "not a session control message", line, if (scope == .pane)
+ "/ctl"
+ else if (b == .Delcol or b == .Joincol)
+ "col/<serial>/ctl" // a column's own ctl, the one /layout names
+ else
+ "pane/<n>/ctl");
if (arg.len > 0 and !builtins.registry.takesArg(b)) return refuse(p, req, "wrong #args in control message", line);
if (arg.len == 0 and builtins.registry.requiresArg(b)) return refuse(p, req, "wrong #args in control message", line);
const setting = config.Runtime.find(word) orelse return null;
@@ -281,14 +301,19 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?
break :probe probe.apply(setting, if (arg.len > 0) arg else null);
},
};
- return if (takes) null else refuse(p, req, "bad value in control message", line);
+ if (takes) return null;
+ var why: [104]u8 = undefined;
+ return refuse(p, req, if (config.Runtime.choices(setting.action)) |values|
+ std.fmt.bufPrint(&why, "bad value in control message; takes {s}", .{values}) catch "bad value in control message"
+ else
+ "bad value in control message", line);
}
/// Runs a checked control message at pane `id`, as a click on its word
/// would. Answers the refusal when it would have opened a prompt for an
/// argument, which nobody writing to a file is there to type, or when it
/// reported an error: `Mount: AlreadyMounted "Mount peer /tmp/s"`.
-fn runBuiltin(p: *Pardes, req: Req, id: usize, line: []const u8) ?Reply {
+pub fn runBuiltin(p: *Pardes, req: Req, id: usize, line: []const u8) ?Reply {
p.fs.no_prompt = true;
p.fs.refused = false;
p.fs.failure_len = 0;
@@ -307,7 +332,10 @@ fn runBuiltin(p: *Pardes, req: Req, id: usize, line: []const u8) ?Reply {
if (!created) noteResult(p, context);
if (p.fs.refused) return refuse(p, req, e_prompt, line);
if (p.fs.failure_len == 0) return null;
- const refusal = refuse(p, req, p.fs.failure[0..p.fs.failure_len], line);
+ // An Edit's failure names what failed (the pattern that missed): its
+ // line, a program, is not quoted after it.
+ const edit = std.mem.startsWith(u8, line, "Edit ") or std.mem.eql(u8, line, "Edit");
+ const refusal = if (edit) tree.failText(req.tag, E.IO, p.fs.failure[0..p.fs.failure_len]) else refuse(p, req, p.fs.failure[0..p.fs.failure_len], line);
return .{ .tag = req.tag, .status = .err, .errno = E.IO, .ename = refusal.ename };
}
@@ -320,13 +348,22 @@ const e_prompt = "control message needs its argument";
/// What is not a setting -- platform, themes, where the config came from --
/// is /status's or the Config window's.
fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void {
- const s = &p.settings;
+ try writeSettingsOf(p, &p.settings, w);
+ try w.writeAll("LocationsConfig ");
+ try p.locations_config.write(w);
+ try w.writeByte('\n');
+ if (comptime builtins.Pet.enabled) try w.print("Pet {s}\n", .{@tagName(p.settings.pet)});
+}
+
+/// The setting words of `s`, one a line, as the root ctl reads them; a Dump
+/// keeps those that differ from a fresh session's (dump.zig).
+pub fn writeSettingsOf(p: *Pardes, s: *const config.Runtime, w: *std.Io.Writer) !void {
inline for (config.Runtime.settings) |setting| {
if (comptime !setting.enabled(builtins.capabilities)) continue;
const word = setting.word;
switch (setting.action) {
.toggle => |field| try w.print("{s} {s}\n", .{ word, if (@field(s, @tagName(field))) "on" else "off" }),
- .theme => try w.print("{s} {s}\n", .{ word, p.theme().name }),
+ .theme => try w.print("{s} {s}\n", .{ word, if (s == &p.settings) p.theme().name else pardes.themes[s.theme].name }),
.shell => {
const chosen = s.shell.requested.get();
try w.print("{s} {s}\n", .{ word, if (chosen.len > 0) chosen else config.default_shell });
@@ -347,8 +384,12 @@ fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void {
inline else => |field| try w.print("{s} {s}\n", .{ word, @tagName(@field(s, @tagName(field))) }),
},
.message_ms => |which| try w.print("{s} {d}\n", .{ word, @field(s, "message_" ++ @tagName(which) ++ "_ms") }),
- // Bare, it names the default directory.
- .dump_dir => if (s.dump_dir.len > 0) try w.print("{s} {s}\n", .{ word, s.dump_dir.get() }) else try w.print("{s}\n", .{word}),
+ // The directory in effect, as Config reports it; bare only when
+ // there is none (no home to put the default in).
+ .dump_dir => {
+ var buf: [900]u8 = undefined;
+ if (pardes.dump.directory(&buf, s.dump_dir.get())) |dir| try w.print("{s} {s}\n", .{ word, dir }) else try w.print("{s}\n", .{word});
+ },
.transition => |effect| try w.print("{s} {s}\n", .{ word, if (s.panel_transition == effect) "on" else "off" }),
.scene => |effect| if (s.post.level(effect) == 0) try w.print("{s} off\n", .{word}) else try w.print("{s} {d}\n", .{ word, s.post.level(effect) }),
.shader => for (s.post.list()) |entry| if (entry.scene == null) try w.print("{s} {s}\n", .{ word, entry.path.get() }),
@@ -359,10 +400,6 @@ fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void {
.grip_width => try w.print("{s} {d}\n", .{ word, s.grip_width }),
}
}
- try w.writeAll("LocationsConfig ");
- try p.locations_config.write(w);
- try w.writeByte('\n');
- if (comptime builtins.Pet.enabled) try w.print("Pet {s}\n", .{@tagName(s.pet)});
}
pub fn rootLen(p: *Pardes) u64 {
@@ -407,11 +444,20 @@ pub fn writeRoot(p: *Pardes, req: Req) Reply {
/// itself, so a new builtin is listed the moment it exists.
fn writeCommands(w: *std.Io.Writer) !void {
for (std.enums.values(Builtin)) |b| {
- try w.print("{s}{s} {s}\n", .{
+ try w.print("{s}{s} {s}", .{
@tagName(b),
if (builtins.registry.takesArg(b)) " arg" else "",
if (builtins.registry.scope(b) == .session) "root" else "pane",
});
+ // A setting that chooses among words: the words, comma-joined. Bare,
+ // it steps to the next of them (a two-valued one flips).
+ if (config.Runtime.find(@tagName(b))) |setting| if (config.Runtime.choices(setting.action)) |values| {
+ try w.writeByte(' ');
+ var parts = std.mem.splitSequence(u8, values, ", ");
+ var first = true;
+ while (parts.next()) |v| : (first = false) try w.print("{s}{s}", .{ if (first) "" else ",", v });
+ };
+ try w.writeByte('\n');
}
}
@@ -448,9 +494,12 @@ pub fn writeFocus(p: *Pardes, req: Req) Reply {
const text = std.mem.trim(u8, req.data, " \t\r\n");
const serial = std.fmt.parseInt(u32, text, 10) catch return tree.failText(req.tag, E.INVAL, tree.e_bad_ctl);
const id = p.paneBySerial(serial) orelse return tree.failText(req.tag, E.NOENT, e_no_pane);
- // The keyboard goes to the pane itself, off any header that had it.
+ // The keyboard goes to the pane itself, off any header that had it, and
+ // its column becomes the active one, as the user's click there would
+ // make it: pane/new and a look place there next, whatever Placement.
tagline.exitHeader(p);
p.active = id;
+ if (pardes.layout.findPane(p, id)) |at| p.setActiveColumn(at.col);
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
@@ -516,10 +565,15 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
} else if (std.mem.startsWith(u8, line, "answer ")) {
// The pane's question (its `ask` in the log), answered.
const choice = std.mem.trim(u8, line["answer ".len..], " ");
- if (choice.len != 1) return refuse(p, req, "answer takes one choice, or -", line);
const id = p.paneBySerial(serial).?;
- if (p.panes[id].?.prompt != .del_side and p.panes[id].?.prompt != .repl_choice)
- return refuse(p, req, "no question asked", line);
+ var offered: [64]u8 = undefined;
+ const choices = p.questionChoices(id, &offered) orelse return refuse(p, req, "no question asked", line);
+ // Only a choice the question offers answers it; anything
+ // else leaves it standing, and says what it takes.
+ if (!std.mem.eql(u8, choice, "-") and (choice.len != 1 or std.mem.indexOfScalar(u8, choices, choice[0]) == null)) {
+ var why: [96]u8 = undefined;
+ return refuse(p, req, std.fmt.bufPrint(&why, "answer takes {s} or -", .{choices}) catch "answer takes a choice or -", line);
+ }
if (apply) _ = p.answerQuestion(id, if (choice[0] == '-') null else choice[0]);
} else if (!apply) {
if (checkBuiltin(p, req, line, .pane)) |refusal| return refusal;
@@ -618,10 +672,11 @@ test "commands lists every builtin with its argument and the ctl that takes it"
const b = std.meta.stringToEnum(Builtin, words.next().?).?;
if (builtins.registry.takesArg(b)) try testing.expectEqualStrings("arg", words.next().?);
try testing.expectEqualStrings(if (builtins.registry.scope(b) == .session) "root" else "pane", words.next().?);
+ if (config.Runtime.find(@tagName(b))) |s| if (config.Runtime.choices(s.action) != null) try testing.expect(words.next() != null);
try testing.expect(words.next() == null);
}
try testing.expectEqual(std.enums.values(Builtin).len, lines);
- for ([_][]const u8{ "\nDel arg pane\n", "\nNewcol root\n", "\nSave arg pane\n", "\nVerbose arg root\n", "\nUndo pane\n", "\nRedo pane\n" }) |row|
+ for ([_][]const u8{ "\nDel arg pane\n", "\nNewcol root\n", "\nSave arg pane\n", "\nVerbose arg root on,off\n", "\nPlacement arg root acme,pardes\n", "\nUndo pane\n", "\nRedo pane\n" }) |row|
try testing.expect(std.mem.indexOf(u8, listed.bytes, row) != null);
}
@@ -747,7 +802,8 @@ test "the root ctl reads the settings as a write takes them, and takes the sessi
try testing.expectEqualStrings("not a session control message \"Del\": write it to pane/<n>/ctl", wr(p, root_ctl, "Del").reply.ename);
try testing.expectEqualStrings("unknown control message \"Nonsense 1\"", wr(p, root_ctl, "Nonsense 1").reply.ename);
- try testing.expectEqualStrings("bad value in control message \"Verbose maybe\"", wr(p, root_ctl, "Verbose maybe").reply.ename);
+ try testing.expectEqualStrings("bad value in control message; takes on, off \"Verbose maybe\"", wr(p, root_ctl, "Verbose maybe").reply.ename);
+ try testing.expectEqualStrings("bad value in control message; takes acme, pardes \"Placement east\"", wr(p, root_ctl, "Placement east").reply.ename);
const no_theme = wr(p, root_ctl, "Theme no-such-theme").reply.ename;
try testing.expectStringStartsWith(no_theme, "bad value in control message; like it: n");
try testing.expect(std.mem.endsWith(u8, no_theme, "; ThemeSel lists all \"Theme no-such-theme\""));
@@ -1216,3 +1272,47 @@ test "a look after Newcol fills the empty column the keyboard is on" {
try testing.expectEqual(@as(usize, 1), p.col_n[p.ncol - 1]);
try testing.expectEqual(p.active, p.col_panes[p.ncol - 1][0]);
}
+
+test "a Restore of a file that is no dump fails the write before any warning, and a failed Dump fails its write" {
+ if (comptime !pardes.hosted) return error.SkipZigTest;
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const f = pane_files.fileOf(p.panes[0].?).?;
+ f.content = try p.gpa.realloc(f.content, 3);
+ @memcpy(f.content, "yy\n");
+ f.revision +%= 1; // unsaved: a Restore would warn about it first
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "notes.txt", .data = "just notes\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ var line_buf: [4200]u8 = undefined;
+ const line = try std.fmt.bufPrint(&line_buf, "Restore {s}/notes.txt\n", .{dir});
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ const refused = wr(p, root_ctl, line);
+ try testing.expectEqual(E.IO, refused.errno());
+ try testing.expect(std.mem.indexOf(u8, refused.reply.ename, "not a pardes dump") != null);
+ try testing.expect(p.restore_req == null);
+ try testing.expect(p.panes[0].?.discard_warned == null);
+
+ p.fs.late_failure_len = 0;
+ p.dumpFailed("/nowhere/pardes.dump.zon", error.FileNotFound);
+ try testing.expectEqualStrings("Dump /nowhere/pardes.dump.zon: FileNotFound", p.fs.late_failure[0..p.fs.late_failure_len]);
+}
+
+test "a setting this frontend cannot show says GUI-only, and DumpDir reads back the directory in effect" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ if (!builtins.capabilities.lift) {
+ try testing.expectEqualStrings("Lift is GUI-only \"Lift on\"", wr(p, root_ctl, "Lift on\n").reply.ename);
+ try testing.expectEqualStrings("GripWidth is GUI-only \"GripWidth 120\"", wr(p, root_ctl, "GripWidth 120\n").reply.ename);
+ }
+ try testing.expectEqualStrings("unknown control message \"Liftt on\"", wr(p, root_ctl, "Liftt on\n").reply.ename);
+ var buf: [900]u8 = undefined;
+ if (pardes.dump.directory(&buf, "")) |dir| {
+ var want: [920]u8 = undefined;
+ const line = try std.fmt.bufPrint(&want, "\nDumpDir {s}\n", .{dir});
+ try testing.expect(std.mem.indexOf(u8, rd(p, root_ctl, 0, 1 << 16).bytes, line) != null);
+ }
+}