diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-28 09:41:55 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | f5eaddb2cf28a9b72d5dd5b3b10502a316d4de85 (patch) | |
| tree | 3d043946453c8da589ef17eef1ebc66f2d2c1b24 /src/ninep/ctl.zig | |
| parent | 0ee8d748c028ad5257f95e15db38646aecd2851b (diff) | |
| download | pardes-f5eaddb2cf28a9b72d5dd5b3b10502a316d4de85.tar.gz pardes-f5eaddb2cf28a9b72d5dd5b3b10502a316d4de85.zip | |
Split control messages by scope: the root ctl takes the session's builtins and reads the settings, a pane's ctl its own
Every builtin could only be clicked, or written to exec, and the settings
could be read only as the Config window's prose. acme keeps window verbs on
a window's ctl, and webfs and upas/fs keep session settings on a root ctl.
Each builtin now declares its scope (scope = .session; settings are all
session, the rest pane), read by the registry. The root /ctl takes session
builtins and reads every setting in the words a write takes, so its read
written back changes nothing (panel and scene effects now take on/off like
the toggles, to make that true); a pane's ctl takes the pane's builtins
beside get, lock and unlock. Writes are checked whole and refused in Plan
9's ctl words (unknown control message "X", wrong #args ...), which 9ns
now maps to EINVAL (cloud9 re-pinned at a8c7a715). A builtin that would
prompt for its argument fails the write instead, and a refusal is answered
at once, not after the frame.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src/ninep/ctl.zig')
| -rw-r--r-- | src/ninep/ctl.zig | 237 |
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(); |
