summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/9p_io.zig4
-rw-r--r--src/builtins.zig59
-rw-r--r--src/config.zig83
-rw-r--r--src/fs-help.txt4
-rw-r--r--src/fs.zig7
-rw-r--r--src/ninep/ctl.zig237
-rw-r--r--src/ninep/tree.zig8
-rw-r--r--src/pardes.zig6
8 files changed, 352 insertions, 56 deletions
diff --git a/src/9p_io.zig b/src/9p_io.zig
index 7b0976bc..02b9efb5 100644
--- a/src/9p_io.zig
+++ b/src/9p_io.zig
@@ -258,6 +258,10 @@ pub const Listener = struct {
// file written. A change carries no payload, so the reply is still
// whole after the wait.
l.kick();
+ // A refusal answers at once: its text may be in the core's one
+ // buffer for it, which a request run while this one waited would
+ // write over.
+ if (reply.status == .err) return conn.reply(&reply, "");
const restoring = core.restore_req != null;
if (core.effects_len != 0) pardes.turn.awaitSettled(epoch);
// Once a wait returns `core` may be gone: a Restore meanwhile put a
diff --git a/src/builtins.zig b/src/builtins.zig
index ffb00ff6..ed0a07dd 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -30,6 +30,14 @@ pub const capabilities: config.Runtime.Capabilities = .{
.ligatures = 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,
@@ -133,9 +141,14 @@ pub const registry = struct {
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;
- inline for (comptime settingList(), manualBuiltinCount()..) |setting, i|
- if (@intFromEnum(b) == i) return setting.takesArg();
- unreachable;
+ // Every setting takes its value, and a switch flips when bare.
+ return true;
+ }
+
+ 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 {
@@ -173,6 +186,17 @@ test "capabilities exactly gate setting and effect-source builtins" {
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 "Look does not announce itself on the message row; Del still does" {
const Builtin = registry.Builtin();
try std.testing.expect(!registry.announces(Builtin.Look));
@@ -233,6 +257,7 @@ pub const Exec = struct {
// ---- session ----
pub const Kill = struct {
+ pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
c.p.quit = true;
c.p.emit(.quit);
@@ -240,12 +265,14 @@ pub const Kill = struct {
};
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);
@@ -256,6 +283,7 @@ pub const Restore = struct {
};
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 {
@@ -270,6 +298,7 @@ pub const Attach = struct {
};
pub const Detach = struct {
+ pub const scope: Scope = .session;
pub const enabled = pardes.can_attach;
pub fn run(c: Ctx) void {
if (comptime enabled)
@@ -280,6 +309,7 @@ pub const Detach = struct {
};
pub const Mount = struct {
+ pub const scope: Scope = .session;
pub const takes_arg = true;
pub const enabled = pardes.hosted;
pub fn run(c: Ctx) void {
@@ -293,6 +323,7 @@ pub const Mount = struct {
};
pub const Unmount = struct {
+ pub const scope: Scope = .session;
pub const takes_arg = true;
pub const enabled = pardes.hosted;
pub fn run(c: Ctx) void {
@@ -318,12 +349,14 @@ pub const Msg = struct {
};
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);
@@ -331,6 +364,7 @@ pub const ThemeSel = struct {
};
pub const ThemeFile = struct {
+ pub const scope: Scope = .session;
pub const takes_arg = true;
pub const enabled = pardes.hosted;
pub fn run(c: Ctx) void {
@@ -342,6 +376,7 @@ pub const ThemeFile = struct {
};
pub const DumpThemes = struct {
+ pub const scope: Scope = .session;
pub const enabled = pardes.hosted;
pub fn run(c: Ctx) void {
if (comptime enabled) {
@@ -355,6 +390,7 @@ pub const DumpThemes = struct {
};
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 {
@@ -496,6 +532,7 @@ pub const New = struct {
/// The same empty scratch, opened in a fresh column beside the calling pane.
pub const Newcol = struct {
+ pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
c.p.newScratchColumn(c.id);
}
@@ -603,12 +640,14 @@ pub const Tty9p = struct {
/// Fold the active pane's column into the one on its right, keeping its panes.
/// The horizontal mirror of the vertical stacking `New` does.
pub const Joincol = struct {
+ pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
layout.joinCol(c.p);
}
};
pub const Tutor = struct {
+ pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
const free = c.p.freeSlot() orelse return;
const nt = c.p.openTutorView(free) catch return;
@@ -617,6 +656,7 @@ pub const Tutor = struct {
};
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);
@@ -624,6 +664,7 @@ pub const Help = struct {
};
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);
@@ -631,6 +672,7 @@ pub const Config = struct {
};
pub const LocationsConfig = struct {
+ pub const scope: Scope = .session;
pub const takes_arg = true;
pub const output: OutputTraits = .{ .name = "+LocationsConfig" };
@@ -660,6 +702,7 @@ pub const LocationsConfig = struct {
};
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);
@@ -669,6 +712,7 @@ pub const Messages = struct {
/// 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);
@@ -676,6 +720,7 @@ pub const Changelog = struct {
};
pub const EffectCode = struct {
+ pub const scope: Scope = .session;
pub const takes_arg = true;
pub const enabled = capabilities.panel_transitions or capabilities.scene_shaders;
pub const output: OutputTraits = .{ .name = config.effect_code_buffer };
@@ -690,6 +735,7 @@ pub const EffectCode = struct {
/// 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 enabled = pardes.platform == .gui;
pub fn run(c: Ctx) void {
@@ -793,6 +839,7 @@ pub const Right = struct {
/// 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);
}
@@ -800,6 +847,7 @@ pub const Back = struct {
/// 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);
}
@@ -821,6 +869,7 @@ pub const Last = struct {
};
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);
@@ -920,6 +969,7 @@ pub const Lspwhy = struct {
};
pub const Peek = struct {
+ pub const scope: Scope = .session;
pub const takes_arg = true;
pub const enabled = Board.enabled;
pub const output: OutputTraits = .{ .name = config.peek_buffer };
@@ -932,6 +982,7 @@ pub const Peek = struct {
};
pub const Poke = struct {
+ pub const scope: Scope = .session;
pub const takes_arg = true;
pub const enabled = Board.enabled;
pub fn run(c: Ctx) void {
@@ -943,6 +994,7 @@ pub const Poke = struct {
};
pub const Hexdump = struct {
+ pub const scope: Scope = .session;
pub const takes_arg = true;
pub const enabled = Board.enabled;
pub const output: OutputTraits = .{ .name = config.hexdump_buffer };
@@ -955,6 +1007,7 @@ pub const Hexdump = struct {
};
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 };
diff --git a/src/config.zig b/src/config.zig
index 8c37a7ce..f898df11 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -705,15 +705,6 @@ pub const Runtime = struct {
action: Action,
availability: ?Capability = null,
- pub fn takesArg(setting: Setting) bool {
- return switch (setting.action) {
- // A toggle flips when it is a bare tagline word and SETS when
- // it is given `on` or `off`, so the report is configuration.
- .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .dump_dir, .toggle => true,
- else => false,
- };
- }
-
pub fn enabled(setting: Setting, capabilities: Capabilities) bool {
const capability = setting.availability orelse return true;
return switch (capability) {
@@ -831,15 +822,7 @@ pub const Runtime = struct {
// it SETS, so the report `writeReport` prints (`Verbose on`) can
// be fed straight back as configuration and mean what it says.
.toggle => |field| {
- const text = std.mem.trim(u8, argument orelse "", " \t\r\n");
- const want: ?bool = if (text.len == 0)
- null
- else if (std.mem.eql(u8, text, "on"))
- true
- else if (std.mem.eql(u8, text, "off"))
- false
- else
- return false;
+ const want = switchedTo(argument) catch return false;
switch (field) {
inline else => |f| @field(state, @tagName(f)) = want orelse !@field(state, @tagName(f)),
}
@@ -881,18 +864,42 @@ pub const Runtime = struct {
inline else => |w| @field(state, "message_" ++ @tagName(w) ++ "_ms") = ms,
}
},
- .transition => |effect| state.toggleTransition(effect),
- .scene => |effect| switch (effect) {
- inline else => |field| {
- const value = &@field(state.scene_effects, @tagName(field));
- value.* = !value.*;
- },
+ // Panel effects are one choice, so `on` picks this one and `off`
+ // turns it off only if it is the one on.
+ .transition => |effect| {
+ const want = switchedTo(argument) catch return false;
+ if (want == null) {
+ state.toggleTransition(effect);
+ } else if (want.?) {
+ state.panel_transition = effect;
+ } else if (state.panel_transition == effect) {
+ state.panel_transition = .off;
+ }
+ },
+ .scene => |effect| {
+ const want = switchedTo(argument) catch return false;
+ switch (effect) {
+ inline else => |field| {
+ const value = &@field(state.scene_effects, @tagName(field));
+ value.* = want orelse !value.*;
+ },
+ }
},
.theme, .font => return false,
}
return true;
}
+ /// What `on`, `off` or nothing after a switch word asks for: null flips
+ /// it, as a bare tagline word does.
+ fn switchedTo(argument: ?[]const u8) error{NotOnOrOff}!?bool {
+ const text = std.mem.trim(u8, argument orelse "", " \t\r\n");
+ if (text.len == 0) return null;
+ if (std.mem.eql(u8, text, "on")) return true;
+ if (std.mem.eql(u8, text, "off")) return false;
+ return error.NotOnOrOff;
+ }
+
// Slices are borrowed for one writeReport call.
pub const ReportContext = struct {
startup_config_path: ?[]const u8,
@@ -1055,15 +1062,32 @@ pub const Runtime = struct {
try out.print("Pet: {s}\n", .{@tagName(state.pet)});
}
- test "setting names are unique and argument metadata follows actions" {
+ test "setting names are unique" {
for (settings, 0..) |setting, i| {
try std.testing.expect(setting.word.len > 0);
for (settings[i + 1 ..]) |later|
try std.testing.expect(!std.mem.eql(u8, setting.word, later.word));
- try std.testing.expectEqual(switch (setting.action) {
- .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .dump_dir, .toggle => true,
- else => false,
- }, setting.takesArg());
+ }
+ }
+
+ test "panel and scene effects flip bare and are set by on and off" {
+ var state: Runtime = .{};
+ const slide = findAction(.{ .transition = .slide }).?;
+ const zoom = findAction(.{ .transition = .zoom }).?;
+ try std.testing.expect(apply(&state, slide, "on"));
+ try std.testing.expectEqual(layout.Transition.slide, state.panel_transition);
+ try std.testing.expect(apply(&state, zoom, "off"));
+ try std.testing.expectEqual(layout.Transition.slide, state.panel_transition);
+ try std.testing.expect(apply(&state, slide, "on"));
+ try std.testing.expectEqual(layout.Transition.slide, state.panel_transition);
+ try std.testing.expect(apply(&state, slide, null));
+ try std.testing.expectEqual(layout.Transition.off, state.panel_transition);
+ try std.testing.expect(!apply(&state, slide, "yes"));
+ const scene = findAction(.{ .scene = std.enums.values(Scene)[0] }).?;
+ try std.testing.expect(apply(&state, scene, "on"));
+ try std.testing.expect(apply(&state, scene, "on"));
+ switch (std.enums.values(Scene)[0]) {
+ inline else => |f| try std.testing.expect(@field(state.scene_effects, @tagName(f))),
}
}
@@ -1125,7 +1149,6 @@ pub const Runtime = struct {
var state: Runtime = .{};
try std.testing.expectEqual(@as(u8, 100), state.window_opacity);
try std.testing.expect(state.window_opacity_pending);
- try std.testing.expect(setting.takesArg());
try std.testing.expectEqual(Capability.window_opacity, setting.availability.?);
for ([_][]const u8{ "0", " 85\n", "100" }, [_]u8{ 0, 85, 100 }) |argument, expected| {
diff --git a/src/fs-help.txt b/src/fs-help.txt
index 235ce072..f9e9c6bd 100644
--- a/src/fs-help.txt
+++ b/src/fs-help.txt
@@ -10,6 +10,7 @@ log recent events, one a line: new/del/rename/save <serial> <name>, and
screen rendered screen as JSON, frozen from open to close
listeners the session's dial addresses
focus the serial of the pane with the keyboard; write a serial to give it the keyboard
+ctl the settings, one a line as a write takes them; write one, or Newcol, Dump, Kill...
pane/new open it to make a pane; the read answers that pane's serial
pane/<n>/ name body tag ctl addr dot limit data xdata sel dirty mark scroll
errors event look exec, and pty/ for terminals; rmdir closes it
@@ -26,7 +27,6 @@ Below, $m is the mount point (PARDES_MOUNT in a Tty9p shell; 9ns and 9p work too
echo 'Msg hello' > $m/exec show text in the editor
echo '#0,#5' > $m/pane/$n/addr; printf NEW >> $m/pane/$n/data replace bytes 0..5
cp $m/pane/$n/addr $m/pane/$n/dot; cat $m/pane/$n/sel select the range, read it
- cat $m/pane/$n/dirty; echo 0 > $m/pane/$n/dirty is it modified? say it is not
exec 3<>$m/log; echo follow >&3; cat <&3 what happened lately, then wait for more
cat $m/pane/$n/event hold it open to own that pane's clicks
echo exec > $m/pane/$n/pty/ctl restart a shell; also winsize C R, sig INT
@@ -42,4 +42,4 @@ Pitfalls, one each:
tag reads path++text; writes append to the text (newlines too); truncating clears it, and dot or addr.
A terminal's body is a history snapshot frozen per open; pty/data is the live stream.
A failing command is reported in the editor and in log, not as a write error; a bad line fails the write.
- pane/<n>/ctl: acme's status line, then current or notcurrent; takes get (reload), lock/unlock on a held fd.
+ pane/<n>/ctl: acme's status line + current; takes get, lock/unlock (on a held fd) and pane builtins (Del).
diff --git a/src/fs.zig b/src/fs.zig
index 7e387958..339910a4 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -1256,6 +1256,13 @@ pub const Namespace = struct {
out: std.ArrayList(u8) = .empty,
listeners: u16 = 0,
origin: u8 = 'K',
+ /// A ctl write is running builtins: one that would open a prompt for
+ /// its argument refuses (`refused`), since nobody is at the prompt.
+ no_prompt: bool = false,
+ refused: bool = false,
+ /// A refusal that quotes the message it refuses, as Plan 9's cmderror
+ /// does (kernel/misc/parse.c:82); answered at once (src/9p_io.zig).
+ ename: [128]u8 = undefined,
/// Something a held read may be waiting on changed since they were last
/// answered: a record queued, a run answered, a pane gone.
news: bool = false,
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();
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index b0de446e..13dfb0cf 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -175,6 +175,7 @@ pub const TopFile = enum(u4) {
pane,
new,
focus,
+ ctl,
pub fn fileName(f: TopFile) []const u8 {
return if (f == .root) "/" else @tagName(f);
@@ -183,7 +184,7 @@ pub const TopFile = enum(u4) {
pub fn mode(f: TopFile) u16 {
return switch (f) {
.root, .pane => 0o755,
- .look, .exec, .log, .focus => 0o666,
+ .look, .exec, .log, .focus, .ctl => 0o666,
.README, .index, .status, .screen, .listeners, .new => 0o444,
};
}
@@ -445,6 +446,7 @@ fn topSize(p: *Pardes, f: TopFile) u64 {
.look, .exec => ctl.resultsLen(p),
.log => events.logLen(&p.fs.log),
.listeners => screen.listenersLen(p),
+ .ctl => ctl.rootLen(p),
.focus => focus: {
const pn = p.panes[p.active] orelse break :focus 0;
var digits: [16]u8 = undefined;
@@ -523,7 +525,7 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply {
}
if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, fs.os_root, true, "os");
// After the first ten, which older clients list by position.
- inline for (.{TopFile.focus}) |t| {
+ inline for (.{ TopFile.focus, TopFile.ctl }) |t| {
if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(t), t.dir(), t.fileName());
}
sources.stage(p, out, "", &skip);
@@ -716,6 +718,7 @@ fn read(p: *Pardes, req: Req, target: Target) Reply {
.screen => screen.readSnapshot(p, req, null),
.listeners => screen.readListeners(p, req),
.focus => ctl.readFocus(p, req),
+ .ctl => ctl.readRoot(p, req),
// The serial the open handed this fid, so that two reads of one
// fid answer the same pane: the read observes, the open acted.
.new => serial: {
@@ -749,6 +752,7 @@ fn write(p: *Pardes, req: Req, target: Target) Reply {
.look, .exec => ctl.command(p, req, null, f == .exec),
.log => events.writeLog(p, req),
.focus => ctl.writeFocus(p, req),
+ .ctl => ctl.writeRoot(p, req),
else => Reply.fail(req.tag, E.PERM),
},
.pane => |t| {
diff --git a/src/pardes.zig b/src/pardes.zig
index 321a6b3c..fbc3e119 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -5081,6 +5081,12 @@ pub const Pardes = struct {
pipe: modal.Normal.PipeBehavior,
save,
}) void {
+ // Nobody is at the keyboard to answer a command a script wrote to a
+ // ctl file: the write fails instead (src/ninep/ctl.zig).
+ if (p.fs.no_prompt) {
+ p.fs.refused = true;
+ return;
+ }
const marker = switch (request) {
.search => |marker| marker,
.save => config.save_marker,