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.zig237
1 files changed, 218 insertions, 19 deletions
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 5cc238a7..b43d7b20 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -2,8 +2,9 @@
//! a right click on it and writing one to `exec` a middle click, at the
//! active pane from the root and at that pane from /pane/<n>/; reading either
//! answers the serials the last command made or touched. /status reports the
-//! editor, and a pane's ctl its acme status line and the verbs no file of its
-//! own would say any better: `get`, `lock` and `unlock`.
+//! editor. The root ctl reads the settings and takes the session's builtins;
+//! a pane's ctl reads its acme status line and takes acme's verbs `get`,
+//! `lock` and `unlock` and the builtins that act on a pane.
const std = @import("std");
const pardes = @import("../pardes.zig");
const look = @import("../look.zig");
@@ -11,6 +12,8 @@ const panes = @import("../panes.zig");
const config = @import("../config.zig");
const fs = @import("../fs.zig");
const tagline = @import("../tagline.zig");
+const builtins = @import("../builtins.zig");
+const exec_line = @import("../exec.zig");
const tree = @import("tree.zig");
const pane_files = @import("pane.zig");
@@ -177,6 +180,134 @@ pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 {
return w.buffered();
}
+// ---- control messages: the builtins, by scope ----
+
+const Builtin = builtins.registry.Builtin();
+
+/// Refuses a control message, quoting it the way Plan 9's cmderror does
+/// (kernel/misc/parse.c:82): `unknown control message "Bogus 3"`.
+fn refuse(p: *Pardes, req: Req, why: []const u8, line: []const u8) Reply {
+ const room = p.fs.ename.len - why.len - 3;
+ const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\"", .{ why, line[0..@min(line.len, room)] }) catch why;
+ return tree.failText(req.tag, E.INVAL, text);
+}
+
+/// Checks one line written to a ctl as a builtin of `scope` before any line
+/// of the write runs: a word the registry knows, of this ctl's scope, given
+/// an argument only if it takes one, and for a setting a value it takes.
+/// Answers the refusal, or null. Words are the builtins' own, capitalised
+/// as on a tag; acme's lowercase verbs are the pane ctl's and alias none.
+fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?Reply {
+ 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);
+ if (builtins.registry.scope(b) != scope)
+ return refuse(p, req, if (scope == .pane) "not a window control message" else "not a session control message", line);
+ if (arg.len > 0 and !builtins.registry.takesArg(b)) return refuse(p, req, "wrong #args in control message", line);
+ const setting = config.Runtime.find(word) orelse return null;
+ const takes = switch (setting.action) {
+ .theme => for (pardes.themes) |t| {
+ if (std.mem.eql(u8, t.name, arg)) break true;
+ } else false,
+ .font => config.Runtime.FontSpec.parse(arg) != null,
+ else => probe: {
+ var probe = p.settings;
+ 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);
+}
+
+/// Runs a checked control message at pane `id`, as a click on its word
+/// would; false when it would have opened a prompt for an argument, which
+/// nobody writing to a file is there to type.
+fn runBuiltin(p: *Pardes, id: usize, line: []const u8) bool {
+ p.fs.no_prompt = true;
+ p.fs.refused = false;
+ defer p.fs.no_prompt = false;
+ _ = exec_line.executeBuiltinLine(p, id, line);
+ return !p.fs.refused;
+}
+
+const e_prompt = "control message needs its argument";
+
+// ---- the root ctl ----
+
+/// Every setting, one a line, in the words a write of it takes, so that the
+/// file read back is the configuration, as webfs's ctl reads (webfs(4)).
+/// 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;
+ 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 }),
+ .shell => {
+ const chosen = s.shell.requested.get();
+ try w.print("{s} {s}\n", .{ word, if (chosen.len > 0) chosen else config.default_shell });
+ },
+ .font => {
+ const requested = s.font.requested_name.get();
+ const name = if (requested.len > 0) requested else s.font.effective_name.get();
+ const size = if (s.font.requested_size_hundredths != 0) s.font.requested_size_hundredths else s.font.effective_size_hundredths;
+ if (name.len > 0 and size != 0)
+ try w.print("{s} {s}:{d}.{d:0>2}\n", .{ word, name, size / 100, size % 100 })
+ else if (name.len > 0)
+ try w.print("{s} {s}\n", .{ word, name });
+ },
+ .tagline_size => try w.print("{s} {d}\n", .{ word, s.font.tagline_percent }),
+ .window_opacity => try w.print("{s} {d}\n", .{ word, s.window_opacity }),
+ .window_blur => try w.print("{s} {d}\n", .{ word, s.window_blur }),
+ .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}),
+ .transition => |effect| try w.print("{s} {s}\n", .{ word, if (s.panel_transition == effect) "on" else "off" }),
+ .scene => |effect| try w.print("{s} {s}\n", .{ word, if (@field(s.scene_effects, @tagName(effect))) "on" else "off" }),
+ }
+ }
+ 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 {
+ var buf: [256]u8 = undefined;
+ var counting: std.Io.Writer.Discarding = .init(&buf);
+ writeSettings(p, &counting.writer) catch return 0;
+ return counting.fullCount();
+}
+
+pub fn readRoot(p: *Pardes, req: Req) Reply {
+ var text: std.Io.Writer.Allocating = .init(p.scratch.allocator());
+ writeSettings(p, &text.writer) catch return Reply.fail(req.tag, E.NOMEM);
+ p.fs.stage(p.gpa).appendSlice(p.gpa, text.written()) catch return Reply.fail(req.tag, E.NOMEM);
+ return tree.stagedReply(p, req);
+}
+
+/// One session builtin a line, at whichever pane has the keyboard as each
+/// runs, as the root's look and exec click there; all are checked first.
+pub fn writeRoot(p: *Pardes, req: Req) Reply {
+ for ([2]bool{ false, true }) |apply| {
+ var it = std.mem.splitScalar(u8, req.data, '\n');
+ while (it.next()) |raw| {
+ const line = std.mem.trim(u8, raw, " \t\r");
+ if (line.len == 0) continue;
+ if (!apply) {
+ if (checkBuiltin(p, req, line, .session)) |refusal| return refusal;
+ continue;
+ }
+ if (p.panes[p.active] == null) return Reply.fail(req.tag, E.NOENT);
+ if (!runBuiltin(p, p.active, line)) return tree.failText(req.tag, E.INVAL, e_prompt);
+ }
+ }
+ return .{ .tag = req.tag, .written = @intCast(req.data.len) };
+}
+
// ---- /focus ----
/// The pane with the keyboard, which a write of a serial moves, the way
@@ -207,8 +338,8 @@ pub fn readPane(p: *Pardes, req: Req, pane: *Pane) Reply {
}
/// `get` reloads the buffer from the name it carries, wherever that name
-/// resolves. Repeating it in one write would only reload the same bytes, so
-/// it runs once.
+/// resolves. Any other line is a builtin that acts on a pane (`Del`,
+/// `Save f`), run at this one; a session's builtin is the root ctl's.
///
/// `lock` and `unlock` are acme's (editors/acme/xfid.c:603-611), so that a
/// client can make an edit of several writes to addr and data without
@@ -229,7 +360,7 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
if (o == null or o.?.what != .ctl) pane.fs.lock = null;
}
const other = pane.fs.lock != null and pane.fs.lock != mine;
- var asked = false;
+ const serial = pane.serial;
// Checked whole before anything applies, so a write that must wait for
// the lock has done nothing yet when it goes again.
for ([2]bool{ false, true }) |apply| {
@@ -238,8 +369,12 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
while (it.next()) |raw| {
const line = std.mem.trim(u8, raw, " \t\r");
if (line.len == 0) continue;
+ // A line before this one may have closed the pane (`Del`).
+ if (apply and p.paneBySerial(serial) == null) return Reply.fail(req.tag, E.NOENT);
if (std.mem.eql(u8, line, "get")) {
- asked = true;
+ if (!apply) continue;
+ const errno = get(p, pane);
+ if (errno != 0) return Reply.fail(req.tag, errno);
} else if (std.mem.eql(u8, line, "lock")) {
if (mine == null) return tree.failText(req.tag, E.INVAL, tree.e_bad_ctl);
if (other) return .{ .tag = req.tag, .status = .again };
@@ -252,13 +387,13 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
pane.fs.lock = null;
pardes.turn.parked = true; // a `lock` waiting goes again
}
- } else return tree.failText(req.tag, E.INVAL, tree.e_bad_ctl);
+ } else if (!apply) {
+ if (checkBuiltin(p, req, line, .pane)) |refusal| return refusal;
+ } else if (!runBuiltin(p, p.paneBySerial(serial).?, line)) {
+ return tree.failText(req.tag, E.INVAL, e_prompt);
+ }
}
}
- if (asked) {
- const errno = get(p, pane);
- if (errno != 0) return Reply.fail(req.tag, errno);
- }
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
@@ -342,20 +477,84 @@ test "focus reads the pane with the keyboard, and a serial written to it moves t
try testing.expectEqual(p.paneBySerial(first).?, p.active);
}
-test "the pane ctl takes get, lock and unlock, and nothing that a file of its own now answers" {
+test "the pane ctl takes acme's verbs and the pane's builtins, and refuses the rest in Plan 9's words" {
const gpa = testing.allocator;
const p = try withFile(gpa, "one\ntwo\n");
defer p.deinit();
- const ctl_node = Node.of(serialOf(p), .ctl);
+ const serial = serialOf(p);
+ const ctl_node = Node.of(serial, .ctl);
+ // acme's other ctl words have files of their own here, and a builtin is
+ // its own capitalised word: `Get`, `DEL` and `del` are none of them.
for ([_][]const u8{
- "menu", "nomenu", "dump echo hi", "font Go Mono", "lock x", "bogus", "DEL",
- "name x.txt", "put", "del", "delete", "Look x", "Exec Save", "clean",
- "dirty", "cleartag", "dot=addr", "addr=dot", "show", "mark", "nomark",
- "scroll", "limit=addr", "get x", "look /tmp", "exec Del",
- }) |bad| try testing.expectEqual(E.INVAL, wr(p, ctl_node, bad).errno());
- try testing.expect(p.paneBySerial(serialOf(p)) != null);
+ "menu", "nomenu", "dump echo hi", "font Go Mono", "lock x", "bogus", "DEL",
+ "put", "del", "delete", "clean", "dirty", "show", "mark",
+ "Get", "limit=addr", "get x", "look /tmp", "exec Del",
+ }) |bad| {
+ const refused = wr(p, ctl_node, bad);
+ try testing.expectEqual(E.INVAL, refused.errno());
+ var want: [64]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "unknown control message \"{s}\"", .{bad}), refused.reply.ename);
+ }
+ try testing.expectEqualStrings("not a window control message \"Newcol\"", wr(p, ctl_node, "Newcol").reply.ename);
+ try testing.expectEqualStrings("not a window control message \"Verbose off\"", wr(p, ctl_node, "Verbose off").reply.ename);
+ try testing.expectEqualStrings("wrong #args in control message \"Collapse now\"", wr(p, ctl_node, "Collapse now").reply.ename);
+ // Checked whole first: a bad line anywhere and nothing ran.
+ try testing.expectEqual(E.INVAL, wr(p, ctl_node, "Msg first\nbogus").errno());
+ try testing.expect(p.panes[0].?.msg_len == 0 or !std.mem.eql(u8, p.panes[0].?.msg[0..p.panes[0].?.msg_len], "first"));
+
+ // A pane's builtin runs at that pane.
+ try testing.expectEqual(Status.ok, wr(p, ctl_node, "Msg from ctl\n").reply.status);
+ const pane = p.panes[p.paneBySerial(serial).?].?;
+ try testing.expectEqualStrings("from ctl", pane.msg[0..pane.msg_len]);
+ // One that would ask at a prompt for its argument fails instead.
+ try testing.expectEqualStrings(e_prompt, wr(p, ctl_node, "Find").reply.ename);
+ try testing.expect(pane.prompt == .none);
+ try testing.expect(!p.fs.no_prompt);
+ // Save with no name on a scratch would ask for one: refused, not asked.
+ const other = try th.newPane(p);
+ try testing.expectEqualStrings(e_prompt, wr(p, Node.of(other, .ctl), "Save").reply.ename);
+ try testing.expect(p.panes[p.paneBySerial(other).?].?.prompt == .none);
+ // A line after the one that closed the pane has nowhere to run.
+ try testing.expectEqual(E.NOENT, wr(p, Node.of(other, .ctl), "Del\nMsg after").errno());
+ try testing.expect(p.paneBySerial(other) == null);
}
+test "the root ctl reads the settings as a write takes them, and takes the session's builtins" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "x\n");
+ defer p.deinit();
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ const first = rd(p, root_ctl, 0, 1 << 16);
+ try testing.expectEqual(Status.ok, first.reply.status);
+ const settings = try gpa.dupe(u8, first.bytes);
+ defer gpa.free(settings);
+ try testing.expectEqual(@as(u64, settings.len), call(p, .{ .tag = 1, .op = .getattr, .node = root_ctl }).reply.attr.size);
+ try testing.expect(std.mem.indexOf(u8, settings, "Verbose on\n") != null);
+ try testing.expect(std.mem.indexOf(u8, settings, "LocationsConfig ") != null);
+ // Settings only: nothing /status or the Config window says.
+ try testing.expect(std.mem.indexOf(u8, settings, "Platform") == null);
+ try testing.expect(std.mem.indexOf(u8, settings, "requested") == null);
+
+ // Every line written back is taken, and changes nothing.
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, settings).reply.status);
+ try testing.expectEqualStrings(settings, rd(p, root_ctl, 0, 1 << 16).bytes);
+
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "Verbose off\n").reply.status);
+ try testing.expect(!p.settings.verbose);
+ try testing.expect(std.mem.indexOf(u8, rd(p, root_ctl, 0, 1 << 16).bytes, "Verbose off\n") != null);
+ const columns = p.ncol;
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "Newcol\n").reply.status);
+ try testing.expectEqual(columns + 1, p.ncol);
+
+ try testing.expectEqualStrings("not a session control message \"Del\"", 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 \"Theme no-such-theme\"", wr(p, root_ctl, "Theme no-such-theme").reply.ename);
+ try testing.expectEqualStrings("wrong #args in control message \"Newcol 2\"", wr(p, root_ctl, "Newcol 2").reply.ename);
+ try testing.expect(!p.settings.verbose);
+}
+
+
test "a second lock waits until the holder unlocks or closes, and binds nobody else" {
const p = try withFile(testing.allocator, "one\ntwo\n");
defer p.deinit();