summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/File.zig8
-rw-r--r--src/Messages.zig12
-rw-r--r--src/Terminal.zig62
-rw-r--r--src/animation.zig201
-rw-r--r--src/builtins.zig91
-rw-r--r--src/config.zig63
-rw-r--r--src/detached/server.zig4
-rw-r--r--src/detached/wire.zig22
-rw-r--r--src/draw.zig182
-rw-r--r--src/dump.zig127
-rw-r--r--src/exec.zig29
-rw-r--r--src/fs-help.txt16
-rw-r--r--src/fs.zig24
-rw-r--r--src/gui/gui.zig97
-rw-r--r--src/layout.zig29
-rw-r--r--src/look.zig1
-rw-r--r--src/macos.zig4
-rw-r--r--src/mouse.zig59
-rw-r--r--src/ninep/cols.zig284
-rw-r--r--src/ninep/ctl.zig142
-rw-r--r--src/ninep/events.zig8
-rw-r--r--src/ninep/pane.zig61
-rw-r--r--src/ninep/screen.zig22
-rw-r--r--src/ninep/tree.zig197
-rw-r--r--src/panes.zig6
-rw-r--r--src/pardes.zig94
-rw-r--r--src/regexp.zig23
-rw-r--r--src/sam_edit.zig14
-rw-r--r--src/surface.zig10
-rw-r--r--src/tagline.zig8
-rw-r--r--src/tty/tty.zig4
-rw-r--r--src/tutor.txt9
32 files changed, 1746 insertions, 167 deletions
diff --git a/src/File.zig b/src/File.zig
index 929e33bf..6a1ac319 100644
--- a/src/File.zig
+++ b/src/File.zig
@@ -814,6 +814,14 @@ pub fn restore(p: *Pardes, id: usize, src: dump.Pane) !*Pane {
pane.file.?.tree_context = saved.tree_context and supportsContext(pane);
pane.body.cur_pinned = true;
pane.body.cur_row = @intCast(src.scroll);
+ // Its dot as it was, where the view the dump kept shows it.
+ if (saved.dot) |dot| if (dot[0] <= dot[1] and dot[1] <= content.len) {
+ const a = modal.positionAt(content, dot[0]);
+ const h = modal.positionAt(content, if (dot[1] > dot[0]) modal.prevGrapheme(content, dot[1]) else dot[0]);
+ pane.body.vsel = .{ .active = dot[1] > dot[0], .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true };
+ pane.body.cur_row = @intCast(h.row);
+ pane.body.cur_col = @intCast(h.col);
+ };
pane.cols = @max(1, src.cols);
pane.rows = @max(1, src.rows);
if (output == null and filesystem.localPath(path) != null) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true, .mode = .baseline_disk } });
diff --git a/src/Messages.zig b/src/Messages.zig
index 8b6c795d..7d284e6b 100644
--- a/src/Messages.zig
+++ b/src/Messages.zig
@@ -445,10 +445,22 @@ pub fn messageLog(m: *const Messages, i: usize) ?*const LoggedMessage {
}
/// `<operation>: <the error in words>`: FileNotFound reads "file not found".
+/// What a route that would open a pane says when every slot is taken.
+pub const no_pane_slots = "no pane slots: {d} max";
+
pub fn reportError(p: *Pardes, id: usize, operation: []const u8, err: anyerror) void {
var buf: [256]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
w.print("{s}: ", .{operation}) catch {};
+ // Every route that opens a pane says the cap the same way.
+ if (err == error.NoPaneSlots) {
+ w.print(no_pane_slots, .{pardes.MAX_PANES}) catch {};
+ // A 9P look or exec that would open the pane fails with it too.
+ p.fs.no_pane_slot = true;
+ @memcpy(p.fs.ename[0..w.buffered().len], w.buffered());
+ p.fs.no_pane_slot_len = @intCast(w.buffered().len);
+ return reportFailure(p, id, w.buffered());
+ }
for (@errorName(err), 0..) |c, i| {
if (std.ascii.isUpper(c) and i > 0) w.writeByte(' ') catch break;
w.writeByte(std.ascii.toLower(c)) catch break;
diff --git a/src/Terminal.zig b/src/Terminal.zig
index 76be29d0..4808298e 100644
--- a/src/Terminal.zig
+++ b/src/Terminal.zig
@@ -385,26 +385,66 @@ pub fn mouseFormatSgr(pane: *const Pane) bool {
return state.vt.modes.get(.mouse_format_sgr);
}
-/// The whole scrollback as plain text, `gpa`-owned: what `Save` writes out.
+/// The whole scrollback as plain text, `gpa`-owned: what a terminal's body
+/// reads and `Save` writes out. Logical lines: rows the terminal wrapped
+/// are joined again (ghostty's wrap flags, as pty/run's output is), and
+/// the last line ends with a newline.
pub fn screenTextAlloc(pane: *Pane, gpa: std.mem.Allocator) ![]const u8 {
if (comptime !enabled) return &.{};
- const state = pane.terminal orelse return &.{};
- return state.vt.screens.active.dumpStringAlloc(gpa, .{ .screen = .{} });
+ var out: std.Io.Writer.Allocating = .init(gpa);
+ errdefer out.deinit();
+ try writeScreenText(pane, &out.writer);
+ return out.toOwnedSlice();
}
+fn writeScreenText(pane: *Pane, w: *std.Io.Writer) !void {
+ if (comptime !enabled) return;
+ const state = pane.terminal orelse return;
+ const screen = state.vt.screens.active;
+ const br = screen.pages.getBottomRight(.screen) orelse return;
+ var both: TeeWriter = .{ .a = w };
+ try screen.dumpString(&both.writer, .{ .tl = screen.pages.getTopLeft(.screen), .br = br, .unwrap = true });
+ if (both.count > 0 and both.final != '\n') try w.writeByte('\n');
+}
+
+/// Passes bytes through, remembering how many and the last one.
+const TeeWriter = struct {
+ a: *std.Io.Writer,
+ count: usize = 0,
+ final: u8 = 0,
+ writer: std.Io.Writer = .{ .vtable = &.{ .drain = drain }, .buffer = &.{} },
+
+ fn drain(w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize {
+ const t: *TeeWriter = @alignCast(@fieldParentPtr("writer", w));
+ var n: usize = 0;
+ for (data[0 .. data.len - 1]) |bytes| {
+ try t.a.writeAll(bytes);
+ t.note(bytes);
+ n += bytes.len;
+ }
+ const pattern = data[data.len - 1];
+ for (0..splat) |_| {
+ try t.a.writeAll(pattern);
+ t.note(pattern);
+ n += pattern.len;
+ }
+ return n;
+ }
+
+ fn note(t: *TeeWriter, bytes: []const u8) void {
+ if (bytes.len == 0) return;
+ t.count += bytes.len;
+ t.final = bytes[bytes.len - 1];
+ }
+};
+
/// How long `screenTextAlloc` would be, counted without keeping it: the
/// length a terminal's body reads as, which its ctl line reports.
pub fn screenTextLen(pane: *Pane) usize {
if (comptime !enabled) return 0;
- const state = pane.terminal orelse return 0;
- const screen = state.vt.screens.active;
var buf: [256]u8 = undefined;
var counting: std.Io.Writer.Discarding = .init(&buf);
- screen.dumpString(&counting.writer, .{
- .tl = screen.pages.getTopLeft(.screen),
- .br = screen.pages.getBottomRight(.screen) orelse return 0,
- .unwrap = false,
- }) catch return 0;
+ writeScreenText(pane, &counting.writer) catch return 0;
return @intCast(counting.fullCount());
}
@@ -2505,6 +2545,8 @@ test "a prompt row hidden end to end paints nothing at all" {
defer p.deinit();
while (p.nextEffect()) |_| {}
const pane = p.panes[0].?;
+ pane.tag_expanded = false; // a 12x8 screen: the body, not the tag
+ p.sync();
pane.tty_filter = false;
pane.body.mode = .normal;
p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32maaaaaaaaa\x1b]133;B\x1b\\\x1b[41;36m\u{754C}\x1b[0m\r\n" } });
diff --git a/src/animation.zig b/src/animation.zig
index 9b43644e..9002a2c6 100644
--- a/src/animation.zig
+++ b/src/animation.zig
@@ -870,6 +870,207 @@ test "a flavour's stretched pane lands exactly on its box, and a click finds the
}
}
+/// The focused cursor's quad (docs/render-pipeline.md §5.4), in grid cells:
+/// four corners, each on its own springs, so a jump smears along its path
+/// (the leading corners stiffer than the trailing ones, Motion.follow) and
+/// collapses at rest; a long jump bows (Motion.arc). A move of a cell or
+/// less, or any the caller calls a snap, lands at once.
+pub const CursorGlide = struct {
+ /// Top left, top right, bottom right, bottom left; x then y.
+ corners: [4][2]Spring = @splat(@splat(.{})),
+ target: Box = .{},
+ from_centre: [2]f32 = .{ 0, 0 },
+ placed: bool = false,
+
+ /// How much faster than the flavour's own pace a cursor moves: it
+ /// follows input (§8.1: settles in 90-150 ms on the default).
+ pub const pace: f32 = 2.4;
+ /// A jump longer than this, in cells, bows.
+ pub const arc_from: f32 = 8;
+
+ fn cornersOf(box: Box) [4][2]f32 {
+ return .{ .{ box.x, box.y }, .{ box.x + box.w, box.y }, .{ box.x + box.w, box.y + box.h }, .{ box.x, box.y + box.h } };
+ }
+
+ fn centre(box: Box) [2]f32 {
+ return .{ box.x + box.w / 2, box.y + box.h / 2 };
+ }
+
+ /// Heads for `box` at `now_ns`.
+ pub fn aim(glide: *CursorGlide, box: Box, now_ns: u64, motion: Motion, snap: bool) void {
+ // The same mark: nothing to do, unless a snap lands a glide under way.
+ if (glide.placed and std.meta.eql(box, glide.target) and !(snap and glide.moving())) return;
+ const to = cornersOf(box);
+ const from_c = if (glide.placed) glide.centreAt(now_ns) else centre(box);
+ const to_c = centre(box);
+ const dx = to_c[0] - from_c[0];
+ const dy = to_c[1] - from_c[1];
+ const jump = @abs(dx) > 1.01 or @abs(dy) > 1.01;
+ const land = !glide.placed or snap or motion.instant or !jump;
+ glide.from_centre = from_c;
+ glide.target = box;
+ glide.placed = true;
+ for (&glide.corners, to) |*corner, point| {
+ // Leading: the corner ahead of the move, on the side it goes.
+ const ahead = (point[0] - to_c[0]) * dx + (point[1] - to_c[1]) * dy;
+ var own = motion;
+ own.omega *= pace * (if (ahead > 0) 1 else motion.follow);
+ if (land) {
+ for (corner, point) |*axis, value| axis.* = .{ .omega = own.omega, .zeta = own.zeta, .target = value, .from = value };
+ continue;
+ }
+ for (corner, point) |*axis, value| axis.retarget(value, now_ns, own);
+ }
+ }
+
+ fn centreAt(glide: *const CursorGlide, now_ns: u64) [2]f32 {
+ var c: [2]f32 = .{ 0, 0 };
+ for (glide.corners) |corner| for (&c, corner) |*sum, axis| {
+ sum.* += axis.value(now_ns) / 4;
+ };
+ return c;
+ }
+
+ /// Text under the cursor moved by (dx, dy) cells (a scroll): the quad
+ /// moves with it, rather than gliding against text that jumped.
+ pub fn shift(glide: *CursorGlide, dx: f32, dy: f32) void {
+ for (&glide.corners) |*corner| for (corner, [2]f32{ dx, dy }) |*axis, d| {
+ axis.from += d;
+ axis.target += d;
+ };
+ glide.from_centre = .{ glide.from_centre[0] + dx, glide.from_centre[1] + dy };
+ glide.target.x += dx;
+ glide.target.y += dy;
+ }
+
+ /// The quad at `now_ns`, bowed on a long jump by the flavour's arc.
+ pub fn sample(glide: *const CursorGlide, now_ns: u64, motion: Motion) [4][2]f32 {
+ var out: [4][2]f32 = undefined;
+ for (&out, glide.corners) |*point, corner| for (point, corner) |*value, axis| {
+ value.* = axis.value(now_ns);
+ };
+ const to_c = centre(glide.target);
+ const dx = to_c[0] - glide.from_centre[0];
+ const dy = to_c[1] - glide.from_centre[1];
+ const length = @sqrt(dx * dx + dy * dy);
+ if (motion.arc == 0 or length < arc_from) return out;
+ const now_c = glide.centreAt(now_ns);
+ const left = @sqrt((to_c[0] - now_c[0]) * (to_c[0] - now_c[0]) + (to_c[1] - now_c[1]) * (to_c[1] - now_c[1]));
+ const travelled = std.math.clamp(1 - left / length, 0, 1);
+ const bow = motion.arc * length * 4 * travelled * (1 - travelled);
+ // Perpendicular to the path, to its left.
+ for (&out) |*point| {
+ point[0] += -dy / length * bow;
+ point[1] += dx / length * bow;
+ }
+ return out;
+ }
+
+ /// Steps the springs to `now_ns`; true while the quad still moves.
+ pub fn step(glide: *CursorGlide, now_ns: u64) bool {
+ var any = false;
+ for (&glide.corners) |*corner| for (corner) |*axis| {
+ if (axis.step(now_ns)) any = true;
+ };
+ return any;
+ }
+
+ /// How far, in cells, the furthest corner still is from its mark.
+ pub fn remaining(glide: *const CursorGlide, now_ns: u64) f32 {
+ var most: f32 = 0;
+ for (glide.corners) |corner| for (corner) |axis| {
+ most = @max(most, @abs(axis.value(now_ns) - axis.target));
+ };
+ return most;
+ }
+
+ pub fn moving(glide: *const CursorGlide) bool {
+ for (glide.corners) |corner| for (corner) |axis| {
+ if (!axis.settled) return true;
+ };
+ return false;
+ }
+};
+
+/// The cursor's opacity under blink (§5.4): solid while typing and for
+/// `hold_ns` after, then on and off with an eased edge, solid again after
+/// `idle_ns` of no input. `edge` says whether it is mid-edge (a frame is
+/// owed), `next_ns` when the next edge starts (a wake).
+pub const Blink = struct {
+ pub const hold_ns: u64 = 500 * std.time.ns_per_ms;
+ pub const idle_ns: u64 = 10 * std.time.ns_per_s;
+ pub const half_ns: u64 = 530 * std.time.ns_per_ms;
+ pub const edge_ns: u64 = 80 * std.time.ns_per_ms;
+
+ alpha: f32,
+ edge: bool,
+ next_ns: ?u64,
+
+ pub fn at(now_ns: u64, input_ns: u64) Blink {
+ const since = now_ns -| input_ns;
+ if (since < hold_ns) return .{ .alpha = 1, .edge = false, .next_ns = input_ns + hold_ns + half_ns - edge_ns };
+ if (since >= hold_ns + idle_ns) return .{ .alpha = 1, .edge = false, .next_ns = null };
+ const t = (since - hold_ns) % (2 * half_ns);
+ const cycle_start = now_ns - t;
+ // On for a half, easing out over its last `edge_ns`; off for a half,
+ // easing back in over its last.
+ if (t < half_ns - edge_ns) return .{ .alpha = 1, .edge = false, .next_ns = cycle_start + half_ns - edge_ns };
+ if (t < half_ns) return .{ .alpha = 1 - smooth(@as(f32, @floatFromInt(t - (half_ns - edge_ns))) / @as(f32, @floatFromInt(edge_ns))), .edge = true, .next_ns = null };
+ if (t < 2 * half_ns - edge_ns) return .{ .alpha = 0, .edge = false, .next_ns = cycle_start + 2 * half_ns - edge_ns };
+ return .{ .alpha = smooth(@as(f32, @floatFromInt(t - (2 * half_ns - edge_ns))) / @as(f32, @floatFromInt(edge_ns))), .edge = true, .next_ns = null };
+ }
+
+ fn smooth(x: f32) f32 {
+ const u = std.math.clamp(x, 0, 1);
+ return u * u * (3 - 2 * u);
+ }
+};
+
+test "a cursor snaps a cell at a time and glides a jump, smearing, bowing and landing exactly" {
+ const ms = std.time.ns_per_ms;
+ const smooth_motion = Motion.of(.smooth);
+ var glide: CursorGlide = .{};
+ const cell: Box = .{ .x = 3, .y = 4, .w = 1, .h = 1 };
+ glide.aim(cell, 0, smooth_motion, false);
+ try std.testing.expect(!glide.moving());
+ // One cell over: lands at once.
+ glide.aim(.{ .x = 4, .y = 4, .w = 1, .h = 1 }, ms, smooth_motion, false);
+ try std.testing.expect(!glide.moving());
+ // A jump of 30 cells: glides, the leading edge ahead of the trailing one.
+ const far: Box = .{ .x = 34, .y = 4, .w = 1, .h = 1 };
+ glide.aim(far, 2 * ms, smooth_motion, false);
+ try std.testing.expect(glide.moving());
+ const mid = glide.sample(40 * ms, smooth_motion);
+ const width = mid[1][0] - mid[0][0];
+ try std.testing.expect(width > 1.5);
+ // Bowed off the straight line on a long jump.
+ try std.testing.expect(@abs(mid[0][1] - 4) > 0.05);
+ var t: u64 = 2 * ms;
+ while (glide.step(t)) t += ms;
+ // Arrives within §8.1's 90-150 ms on the default, within a twentieth of
+ // a cell (under a pixel); settles to rest just after, landing exactly.
+ const arrived = glide.sample(152 * ms, smooth_motion);
+ try std.testing.expect(@abs(arrived[3][0] - 34) < 0.05 and @abs(arrived[1][0] - 35) < 0.05);
+ try std.testing.expect(t <= 220 * ms);
+ const rest = glide.sample(t, smooth_motion);
+ try std.testing.expectEqual([2]f32{ 34, 4 }, rest[0]);
+ try std.testing.expectEqual([2]f32{ 35, 5 }, rest[2]);
+ // A scroll carries it with the text.
+ glide.shift(0, -3);
+ try std.testing.expectEqual([2]f32{ 34, 1 }, glide.sample(t, smooth_motion)[0]);
+}
+
+test "a blink holds while typing, eases at its edges and stops when idle" {
+ const ms = std.time.ns_per_ms;
+ try std.testing.expectEqual(@as(f32, 1), Blink.at(100 * ms, 0).alpha);
+ const out = Blink.at(Blink.hold_ns + Blink.half_ns + 100 * ms, 0);
+ try std.testing.expectEqual(@as(f32, 0), out.alpha);
+ const edge = Blink.at(Blink.hold_ns + Blink.half_ns - Blink.edge_ns / 2, 0);
+ try std.testing.expect(edge.edge and edge.alpha > 0.2 and edge.alpha < 0.8);
+ try std.testing.expectEqual(@as(?u64, null), Blink.at(Blink.hold_ns + Blink.idle_ns + ms, 0).next_ns);
+ try std.testing.expectEqual(@as(f32, 1), Blink.at(Blink.hold_ns + Blink.idle_ns + ms, 0).alpha);
+}
+
/// A displayed value that fades from one target to the next over
/// `transition_steps` frames; the chrome colours are one.
pub fn Fade(comptime Value: type) type {
diff --git a/src/builtins.zig b/src/builtins.zig
index 657f49d0..32e3cd84 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -170,9 +170,9 @@ pub const registry = struct {
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, and DumpDir bare is the default
- .toggle, .transition, .scene, .dump_dir, .choice, .lift => false,
- .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .shader_animation, .motion, .inactive_dim, .grip_width => true,
+ // a switch flips bare, a choice steps, and DumpDir bare is the default
+ .toggle, .transition, .scene, .dump_dir, .choice, .lift, .shader_animation, .motion => false,
+ .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .inactive_dim, .grip_width => true,
};
unreachable;
}
@@ -258,7 +258,7 @@ test "Exit over a ctl write names every pane it asks about, and the next Exit di
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 them all)"));
+ 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);
@@ -267,7 +267,11 @@ test "Exit over a ctl write names every pane it asks about, and the next Exit di
/// A dump file that exists, for a Restore to get past reading it.
fn testDump(buf: []u8) ![]const u8 {
var tmp = std.testing.tmpDir(.{});
- try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "d.zon", .data = ".{}" });
+ // 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});
@@ -442,6 +446,14 @@ pub const Exec = struct {
/// 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).
@@ -449,9 +461,23 @@ fn warnModified(c: Ctx, asking: Pane.Discarding) bool {
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;
- if (f.revision == f.saved_revision or !panes.Output.fileTraits(f.output).saves) 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 an empty buffer that never held a file.
+ if (f.saved_hash) |saved| {
+ if (std.hash.Wyhash.hash(0, f.content) == saved) continue;
+ } else if (f.content.len == 0) continue;
if (pane.discard_warned) |w| if (w.revision == f.revision and w.by == asking) continue;
pane.discard_warned = .{ .revision = f.revision, .by = asking };
var buf: [limits.host_path_cap + 48]u8 = undefined;
@@ -460,7 +486,7 @@ fn warnModified(c: Ctx, asking: Pane.Discarding) bool {
modified = true;
}
if (modified and c.p.fs.no_prompt) {
- all.print(": Modified ({s} again to discard them all)", .{@tagName(asking)}) catch {};
+ 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);
@@ -558,7 +584,12 @@ pub const Restore = struct {
};
return c.p.reportFailure(c.id, std.fmt.bufPrint(&buf, "Restore: {s}: {s}", .{ path, why }) catch "Restore: cannot read the dump");
};
- c.p.gpa.free(bytes);
+ 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.
@@ -835,6 +866,9 @@ 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;
@@ -944,6 +978,9 @@ pub const Delcol = struct {
// 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);
}
};
@@ -1065,7 +1102,7 @@ pub const Joincol = struct {
pub const Tutor = struct {
pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
- const free = c.p.freeSlot() orelse return;
+ 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
}
@@ -1724,3 +1761,39 @@ const Board = struct {
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
+ 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
+ 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);
+ try std.testing.expect(std.mem.indexOf(u8, said, config.scratch_buffer) != 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
+ 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);
+}
diff --git a/src/config.zig b/src/config.zig
index 24c48770..fb38e4dd 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -119,6 +119,7 @@ pub const leader_path = paths: {
if (builtins.capabilities.window_opacity) table.set(.WindowOpacity, null);
if (builtins.capabilities.window_blur) table.set(.WindowBlur, null);
if (builtins.capabilities.ligatures) table.set(.Ligatures, null);
+ if (builtins.capabilities.lift) table.set(.CursorBlink, null);
if (builtins.capabilities.lift) table.set(.Lift, null);
table.set(.Motion, null);
table.set(.InactiveDim, null);
@@ -647,6 +648,10 @@ pub const Runtime = struct {
verbose: bool = true,
/// A message falls into its row and dissolves out of it.
message_animation: bool = true,
+ /// The cursor blinks, a pixel shell's (docs/render-pipeline.md §5.4):
+ /// solid while typing and for half a second after, eased at each edge,
+ /// solid again after ten idle seconds.
+ cursor_blink: bool = true,
/// How long a message stays after the key or click that dismisses it.
message_linger_ms: u16 = 800,
/// How long its ease-in fall into the row takes.
@@ -863,7 +868,7 @@ pub const Runtime = struct {
/// The message timings a setting word sets, each in milliseconds.
pub const MessageTime = enum { linger, fall, dissolve };
- pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, syntax_bold, verbose, message_animation, ligatures };
+ pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, syntax_bold, verbose, message_animation, ligatures, cursor_blink };
/// The bundled post pass, a Shadertoy file in shaders/post/.
pub const Scene = enum { crt };
@@ -918,6 +923,7 @@ pub const Runtime = struct {
.{ .word = "Verbose", .action = .{ .toggle = .verbose } },
.{ .word = "MessageAnimation", .action = .{ .toggle = .message_animation } },
.{ .word = "Ligatures", .action = .{ .toggle = .ligatures }, .availability = .ligatures },
+ .{ .word = "CursorBlink", .action = .{ .toggle = .cursor_blink }, .availability = .lift },
.{ .word = "Lift", .action = .lift, .availability = .lift },
.{ .word = "Motion", .action = .motion },
.{ .word = "InactiveDim", .action = .inactive_dim },
@@ -990,6 +996,9 @@ pub const Runtime = struct {
.toggle => |field| if (field == .ligatures) {
if (setting.availability != .ligatures)
@compileError("the ligatures toggle must use the ligatures capability");
+ } else if (field == .cursor_blink) {
+ if (setting.availability != .lift)
+ @compileError("CursorBlink must use the lift capability (a pixel shell's)");
} else if (setting.availability != null)
@compileError("unconditional toggles cannot carry a backend capability"),
else => if (setting.availability != null)
@@ -1104,20 +1113,12 @@ pub const Runtime = struct {
}
return state.post.set(null, path, null);
},
- .shader_animation => {
- const text = std.mem.trim(u8, argument orelse return false, " \t\r\n");
- state.shader_animation = std.meta.stringToEnum(ShaderAnimation, text) orelse return false;
- },
+ .shader_animation => state.shader_animation = stepped(ShaderAnimation, state.shader_animation, argument) orelse return false,
// Bare, it steps to the next word, as a tag word should.
.choice => |which| switch (which) {
inline else => |field| {
const now = &@field(state, @tagName(field));
- const T = @TypeOf(now.*);
- const text = std.mem.trim(u8, argument orelse "", " \t\r\n");
- now.* = if (text.len == 0)
- @enumFromInt((@as(usize, @intFromEnum(now.*)) + 1) % std.enums.values(T).len)
- else
- std.meta.stringToEnum(T, text) orelse return false;
+ now.* = stepped(@TypeOf(now.*), now.*, argument) orelse return false;
},
},
.lift => {
@@ -1129,10 +1130,7 @@ pub const Runtime = struct {
else
std.meta.stringToEnum(LiftStyle, text) orelse return false;
},
- .motion => {
- const text = std.mem.trim(u8, argument orelse return false, " \t\r\n");
- state.motion = std.meta.stringToEnum(animation.Motion.Flavour, text) orelse return false;
- },
+ .motion => state.motion = stepped(animation.Motion.Flavour, state.motion, argument) orelse return false,
.inactive_dim => {
const text = std.mem.trim(u8, argument orelse return false, " \t\r\n%");
const percent = std.fmt.parseInt(u8, text, 10) catch return false;
@@ -1150,6 +1148,40 @@ pub const Runtime = struct {
return true;
}
+ /// The values a setting that chooses among words takes, for a refusal to
+ /// name; null for one that takes a number, a path or a name. Bare, each
+ /// of these flips (a two-valued one) or steps to its next value.
+ pub fn choices(action: Action) ?[]const u8 {
+ const words = struct {
+ fn of(comptime T: type) []const u8 {
+ comptime var out: []const u8 = "";
+ inline for (std.meta.fieldNames(T), 0..) |name, i| out = out ++ (if (i > 0) ", " else "") ++ name;
+ return out;
+ }
+ };
+ return switch (action) {
+ .toggle, .transition => "on, off",
+ .choice => |which| switch (which) {
+ inline else => |field| comptime words.of(@FieldType(Runtime, @tagName(field))),
+ },
+ .lift => "on, " ++ comptime words.of(LiftStyle),
+ .motion => comptime words.of(animation.Motion.Flavour),
+ .scene => std.fmt.comptimePrint("on, off, 0-{d}", .{Post.max_level}),
+ .shader_animation => comptime words.of(ShaderAnimation),
+ else => null,
+ };
+ }
+
+ /// The value a choice word names, or bare, the one after `now`; null for
+ /// a word it does not take.
+ fn stepped(comptime T: type, now: T, argument: ?[]const u8) ?T {
+ const text = std.mem.trim(u8, argument orelse "", " \t\r\n");
+ if (text.len > 0) return std.meta.stringToEnum(T, text);
+ const all = std.enums.values(T);
+ const at = std.mem.indexOfScalar(T, all, now) orelse 0;
+ return all[(at + 1) % all.len];
+ }
+
/// 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 {
@@ -1618,6 +1650,7 @@ pub const Runtime = struct {
.verbose => "Verbose off\n",
.message_animation => "MessageAnimation off\n",
.ligatures => "Ligatures off\n",
+ .cursor_blink => "CursorBlink off\n",
},
.shell => "Shell requested (new panes) fish\n",
.tagline_size => "TaglineSize 73\n",
diff --git a/src/detached/server.zig b/src/detached/server.zig
index 857591d4..8f45135a 100644
--- a/src/detached/server.zig
+++ b/src/detached/server.zig
@@ -569,8 +569,8 @@ pub const Session = struct {
fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void {
const s = of(ctx);
var pbuf: [1024:0]u8 = undefined;
- const path = pardes.dump.outPath(&pbuf, s.core.settings.dump_dir.get()) orelse return;
- filesystem.write(s.core, path, bytes) catch |err| return s.core.reportError(0, "dump", err);
+ const path = pardes.dump.outPath(&pbuf, s.core.settings.dump_dir.get()) orelse return s.core.dumpFailed(s.core.settings.dump_dir.get(), error.NoDumpDirectory);
+ filesystem.write(s.core, path, bytes) catch |err| return s.core.dumpFailed(path, err);
s.core.setLastDump(path);
}
diff --git a/src/detached/wire.zig b/src/detached/wire.zig
index c0e17054..53a1a70d 100644
--- a/src/detached/wire.zig
+++ b/src/detached/wire.zig
@@ -13,7 +13,7 @@ pub const version: u16 = 8;
/// The Chrome palette is carried field by field as its declaration lists
/// them, so any change to it changes the wire: bump `version`, then this.
-const chrome_layout: u64 = 0x7e3d0952317bfee;
+const chrome_layout: u64 = 0xc91196fcdef144e2;
comptime {
@setEvalBranchQuota(10_000);
var hash = std.hash.Fnv1a_64.init();
@@ -286,6 +286,16 @@ pub const Frame = struct {
}
} else if (field.type == u8) {
rgb.* = try r.getByte();
+ } else if (field.type == u32) {
+ rgb.* = try r.getU32();
+ } else if (field.type == f32) {
+ rgb.* = @bitCast(try r.getU32());
+ if (!std.math.isFinite(rgb.*)) return error.BadValue;
+ } else if (field.type == [8]f32) {
+ for (rgb) |*value| {
+ value.* = @bitCast(try r.getU32());
+ if (!std.math.isFinite(value.*)) return error.BadValue;
+ }
} else for (rgb) |*channel| channel.* = try r.getByte();
}
try r.end();
@@ -747,7 +757,7 @@ pub fn layersBound(layers: []const pardes.Layer, tags: []const pardes.Layer, reg
const region_len = 1 + 1 + 4 + 8 + 1 + 2 + 2 + 1 + 2 + 4;
/// The palette: each colour three bytes, a theme's optional ones a flag
/// before them.
-const chrome_len = @typeInfo(pardes.Chrome).@"struct".fields.len * 4;
+const chrome_len = @typeInfo(pardes.Chrome).@"struct".fields.len * 4 + 8 * 4;
pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, pointer_shape: pardes.Surface.PointerShape, cells: []const pardes.Cell, prev: []const pardes.Cell, layers: []const pardes.Layer, tags: []const pardes.Layer, regions: []const pardes.Region, chrome: pardes.Chrome) Error![]const u8 {
// The protocol's ceiling, enforced by the ENCODER too, and BEFORE the
@@ -837,6 +847,12 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point
if (rgb) |value| for (value) |channel| try w.putByte(channel);
} else if (field.type == u8) {
try w.putByte(rgb);
+ } else if (field.type == u32) {
+ try w.putU32(rgb);
+ } else if (field.type == f32) {
+ try w.putU32(@bitCast(rgb));
+ } else if (field.type == [8]f32) {
+ for (rgb) |value| try w.putU32(@bitCast(value));
} else for (rgb) |channel| try w.putByte(channel);
}
}
@@ -1917,7 +1933,7 @@ test "detached wire carries compact body rows and exact pointer metrics" {
const placed = [_]pardes.Region{.{ .kind = .rail, .owner = 3, .serial = 42, .rect = .{ .x = 0, .y = 1, .w = 2, .h = 3 }, .active = true, .dirty = true, .thumb_y = 1, .thumb_h = 2, .clip = 1, .lift = 0.5 }};
const out = try gpa.alloc(u8, frameBound(2, 4) + layersBound(&.{layer}, &.{tag}, &placed));
defer gpa.free(out);
- const palette: pardes.Chrome = .{ .page = .{ 1, 2, 3 }, .border = .{ 4, 5, 6 }, .tag_focus_bg = .{ 7, 8, 9 }, .scroll_thumb = .{ 10, 11, 12 }, .rule_px = 3, .tag_rule = .{ 13, 14, 15 }, .rail_px = 12, .grip_border = .{ 16, 17, 18 }, .grip_focus_ring = .{ 19, 20, 21 }, .grip_dirty = .{ 22, 23, 24 }, .lift_style = 2 };
+ const palette: pardes.Chrome = .{ .page = .{ 1, 2, 3 }, .border = .{ 4, 5, 6 }, .tag_focus_bg = .{ 7, 8, 9 }, .scroll_thumb = .{ 10, 11, 12 }, .rule_px = 3, .tag_rule = .{ 13, 14, 15 }, .rail_px = 12, .grip_border = .{ 16, 17, 18 }, .grip_focus_ring = .{ 19, 20, 21 }, .grip_dirty = .{ 22, 23, 24 }, .lift_style = 2, .cursor_glide = 3, .cursor_alpha = 0.5, .cursor_idle_ms = 1234, .cursor_quad = .{ 1, 2, 3.5, 2, 3.5, 3, 1, 3 } };
const encoded = try encodeFrameLayers(out, 2, 4, null, .link, &cells, &cells, &.{layer}, &.{tag}, &placed, palette);
const packet = (try framed(encoded)).?;
const frame = (try decodeServer(packet.tag, packet.payload)).frame;
diff --git a/src/draw.zig b/src/draw.zig
index 963d61c1..a2005e59 100644
--- a/src/draw.zig
+++ b/src/draw.zig
@@ -184,6 +184,76 @@ fn inkOn(bg: [3]u8) [3]u8 {
return if (lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff };
}
+/// The focused cursor's box in grid cells, at the pitch it is drawn at: a
+/// tag's at the tagline's (Layer.Metrics), else a grid cell.
+fn focusedCursorBox(p: *const Pardes, s: *const Surface) ?animation.Box {
+ const cursor = s.cursor orelse return null;
+ const m = p.row_metrics;
+ const bw: f32 = @floatFromInt(@max(1, m.body_w));
+ const bh: f32 = @floatFromInt(@max(1, m.body_h));
+ const tw: f32 = @floatFromInt(@max(1, m.tagline_w));
+ const th: f32 = @floatFromInt(@max(1, @min(m.tagline_h, m.body_h)));
+ for (s.tagLayers()) |*layer| {
+ const caret = layer.cursor orelse continue;
+ if (layer.rows == 0 or layer.viewport.y + caret.y != cursor.y) continue;
+ if (cursor.x < layer.viewport.x or cursor.x >= layer.viewport.x + layer.viewport.w) continue;
+ const x = @as(f32, @floatFromInt(layer.viewport.x)) + @as(f32, @floatFromInt(caret.x)) * tw / bw;
+ const w = if (caret.bar) tw / bw / 8 else tw / bw;
+ return .{ .x = x, .y = @as(f32, @floatFromInt(cursor.y)) + (bh - th) / 2 / bh, .w = w, .h = th / bh };
+ }
+ return .{ .x = @floatFromInt(cursor.x), .y = @floatFromInt(cursor.y), .w = if (cursor.bar) 1.0 / 8.0 else 1, .h = 1 };
+}
+
+/// G3 (docs/render-pipeline.md §5.4): the focused cursor on its springs,
+/// at the Motion flavour's pace, a jump gliding and a step or a keystroke in
+/// insert mode landing at once; blinking unless typing. A pixel shell's: a
+/// grid's cursor is the terminal's own.
+fn glideCursor(p: *Pardes, s: *Surface) void {
+ s.chrome.cursor_glide = 0;
+ s.chrome.cursor_alpha = 1;
+ s.chrome.cursor_idle_ms = std.math.maxInt(u32);
+ if (!builtins.capabilities.lift) return;
+ const box = focusedCursorBox(p, s) orelse return;
+ const motion = animation.Motion.of(p.settings.motion);
+ var snap = if (s.cursor) |cursor| cursor.bar else false;
+ if (p.panes[p.active]) |pane| {
+ // A focus change lands at once: a glide between panes would be
+ // squashed against the new one's edge.
+ if (p.cursor_view.serial != pane.serial) snap = true;
+ // Typing never trails: insert mode lands every move.
+ if (pane.body.mode == .insert) snap = true;
+ // Scrolled (a file's view, a terminal's output): the quad moves with
+ // the text and lands, never gliding against text that jumped (every
+ // Enter at a prompt scrolls one and moves the other).
+ const line: i64 = if (pane.file) |f| @intCast(f.scroll) else if (pane.isTerminal()) panes.Terminal.gridOffset(pane) else 0;
+ if (p.cursor_view.serial == pane.serial and line != p.cursor_view.line) {
+ p.cursor_glide.shift(0, @floatFromInt(p.cursor_view.line - line));
+ snap = true;
+ }
+ p.cursor_view = .{ .serial = pane.serial, .line = line };
+ }
+ p.cursor_glide.aim(box, p.now_ns, motion, snap);
+ _ = p.cursor_glide.step(p.now_ns);
+ const moving = p.cursor_glide.moving();
+ s.chrome.cursor_glide = 2 | @as(u8, @intFromBool(moving));
+ // The shell blinks it, from how long ago the last key or click was.
+ if (p.settings.cursor_blink)
+ s.chrome.cursor_idle_ms = @intCast(@min(std.math.maxInt(u32) - 1, (p.now_ns -| p.input_ns) / std.time.ns_per_ms));
+ // A little less than solid while it glides, so the glyphs it passes
+ // stay legible (less on the long flavours), solid again as it lands.
+ const remaining = p.cursor_glide.remaining(p.now_ns);
+ const see_through: f32 = if (motion.zeta < 1) 0.7 else 0.85;
+ s.chrome.cursor_alpha = if (moving) see_through + (1 - see_through) * std.math.clamp(1 - remaining / 0.5, 0, 1) else 1;
+ const quad = p.cursor_glide.sample(p.now_ns, motion);
+ // Never outside the focused pane, however far a flavour overshoots or
+ // a scroll carried it: it squashes against the pane's edges.
+ const r = p.rects[p.active];
+ for (quad, 0..) |point, i| {
+ s.chrome.cursor_quad[i * 2] = std.math.clamp(point[0], @as(f32, @floatFromInt(r.x)), @as(f32, @floatFromInt(r.x + r.w)));
+ s.chrome.cursor_quad[i * 2 + 1] = std.math.clamp(point[1], @as(f32, @floatFromInt(r.y)), @as(f32, @floatFromInt(r.y + r.h)));
+ }
+}
+
/// The Lift style as drawn: `auto` is a shadow on a light page, and on a
/// dark one, where a shadow has nothing to darken, no lift at all (the other
/// panes recede instead, `inactiveDim`).
@@ -504,15 +574,8 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.hover_col, .y = p.topBarHeight(), .w = 1, .h = s.rows -| p.topBarHeight() } });
}
}
- for (0..p.ncol) |cc| {
- if (p.hover_col < p.col_x[cc] or p.hover_col >= p.col_x[cc] + p.col_w[cc]) continue;
- // the same seam the h-handle hit test picks (see there)
- if (mouse.seamIdxAt(p, cc, p.hover_row) != null) {
- var col = p.col_x[cc];
- while (col < p.col_x[cc] + p.col_w[cc]) : (col += 1) s.overlayDash(col, p.hover_row, "╌");
- s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[cc], .y = p.hover_row, .w = p.col_w[cc], .h = 1 } });
- }
- }
+ // No hint over a pane's last row: a row of text is no handle; two
+ // panes resize by the rule between them or by the grip.
for (0..p.ncol -| 1) |cn| {
if (p.hover_col != p.col_x[cn] + p.col_w[cn] -| 1) continue;
if (mouse.seamIdxAt(p, cn, p.hover_row) != null) continue;
@@ -582,6 +645,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
}
}
};
+ glideCursor(p, s);
try p.presentation.submit(p, s);
return composeAsciiTransitions(p, arena, s);
}
@@ -987,9 +1051,11 @@ fn joinPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r
// The grid's row: the gap cell and the text, as far as the pane.
joinCells(s, region.rect.x, region.rect.y + @as(u16, @intCast(line)), storage[line * pitch ..][0..pitch], config.GUTTER, region.rect.w, tag_fg);
}
- if (temporary.cursor) |cursor| if (cursor.x - TAG_TEXT_INSET < r.w - TAG_TEXT_INSET) {
- s.cursor = .{ .x = r.x + cursor.x, .y = region.rect.y + cursor.y, .bar = cursor.bar };
- };
+ // Past a full row's end the layer's caret is at the tag's edge, as
+ // acme's tick is; the grid has no cell there, so it takes the last one.
+ if (temporary.cursor) |cursor| {
+ s.cursor = .{ .x = r.x + @min(cursor.x, r.w - 1), .y = region.rect.y + cursor.y, .bar = cursor.bar };
+ }
}
/// A notice chip, painted once as a tag layer at the tagline's pitch, and the
@@ -1653,6 +1719,98 @@ test "each Motion flavour reaches the lift as its own trajectory" {
try std.testing.expect(peaks[2] <= 1.001 and peaks[3] > 1.25 and peaks[4] > peaks[3]);
}
+test "the focused cursor glides a jump, lands a step at once, and is exact at rest" {
+ if (!builtins.capabilities.lift) return error.SkipZigTest;
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 30 });
+ defer p.deinit();
+ _ = try p.setTestFile("some text on a line\n" ** 20);
+ p.settings.cursor_blink = false;
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const ms = std.time.ns_per_ms;
+ var now: u64 = std.time.ns_per_s;
+ p.advance(now);
+ _ = try p.render(arena.allocator());
+ // A step down: at once.
+ p.update(.{ .key = .{ .cp = 'j' } });
+ now += 16 * ms;
+ p.advance(now);
+ _ = arena.reset(.retain_capacity);
+ var s = try p.render(arena.allocator());
+ try std.testing.expectEqual(@as(u8, 2), s.chrome.cursor_glide);
+ const before = s.cursor.?;
+ // To the last line: it glides, a quad partway, and asks for frames.
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'e' } });
+ now += 16 * ms;
+ p.advance(now);
+ _ = arena.reset(.retain_capacity);
+ s = try p.render(arena.allocator());
+ const target = s.cursor.?;
+ try std.testing.expect(target.y > before.y + 5);
+ try std.testing.expectEqual(@as(u8, 3), s.chrome.cursor_glide);
+ try std.testing.expect(s.chrome.cursor_quad[1] < @as(f32, @floatFromInt(target.y)) - 1);
+ try std.testing.expect(p.continuous());
+ // Frame by frame to rest: gliding exactly while it moves (the shell then
+ // draws the quad, never the cells' cursor too), at under full alpha
+ // until it lands, solid as it does.
+ var frames: usize = 0;
+ while (p.continuous()) : (frames += 1) {
+ now += 7 * ms;
+ p.advance(now);
+ _ = arena.reset(.retain_capacity);
+ s = try p.render(arena.allocator());
+ try std.testing.expect(s.chrome.cursor_glide & 2 != 0);
+ try std.testing.expectEqual(p.cursor_glide.moving(), s.chrome.cursor_glide & 1 != 0);
+ if (s.chrome.cursor_glide & 1 != 0) try std.testing.expect(s.chrome.cursor_alpha <= 1 and s.chrome.cursor_alpha >= 0.7);
+ for (s.chrome.cursor_quad) |value| try std.testing.expect(std.math.isFinite(value));
+ }
+ try std.testing.expect(frames > 3);
+ try std.testing.expectEqual(@as(u8, 2), s.chrome.cursor_glide);
+ try std.testing.expectEqual(@as(f32, 1), s.chrome.cursor_alpha);
+ // At rest the quad is the cursor's own cell.
+ try std.testing.expectEqual(@as(f32, @floatFromInt(target.x)), s.chrome.cursor_quad[0]);
+ try std.testing.expectEqual(@as(f32, @floatFromInt(target.y)), s.chrome.cursor_quad[1]);
+ // A mode change mid-glide lands at once: insert mode never trails.
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'g' } });
+ now += 7 * ms;
+ p.advance(now);
+ _ = arena.reset(.retain_capacity);
+ s = try p.render(arena.allocator());
+ try std.testing.expectEqual(@as(u8, 3), s.chrome.cursor_glide);
+ p.update(.{ .key = .{ .cp = 'i' } });
+ now += 7 * ms;
+ p.advance(now);
+ _ = arena.reset(.retain_capacity);
+ s = try p.render(arena.allocator());
+ try std.testing.expectEqual(@as(u8, 2), s.chrome.cursor_glide);
+}
+
+test "a terminal's cursor lands with scrolling output, never gliding against it" {
+ if (!builtins.capabilities.lift or !pardes.terminal_panes) return error.SkipZigTest;
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const term = p.active;
+ try std.testing.expect(p.panes[term].?.isTerminal());
+ p.settings.cursor_blink = false;
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const ms = std.time.ns_per_ms;
+ var now: u64 = std.time.ns_per_s;
+ p.update(.{ .output = .{ .pane = @intCast(term), .bytes = "line\r\n" ** 20 ++ "$ some command typed" } });
+ p.advance(now);
+ _ = try p.render(arena.allocator());
+ // Enter: three lines of output scroll up, the cursor to a new prompt.
+ p.update(.{ .output = .{ .pane = @intCast(term), .bytes = "\r\nout\r\nout\r\nout\r\n$ " } });
+ now += 16 * ms;
+ p.advance(now);
+ _ = arena.reset(.retain_capacity);
+ const s = try p.render(arena.allocator());
+ try std.testing.expectEqual(@as(u8, 2), s.chrome.cursor_glide);
+}
+
test "a monkey over notices, prompts, tags and tiny panes at a gui's metrics never panics" {
// The snapshot harness runs the tty, whose tagline pitch is the body's,
// so it never reaches what a narrower tagline does to a notice's band:
diff --git a/src/dump.zig b/src/dump.zig
index ef442ddb..88a95994 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -53,27 +53,34 @@ pub fn outPath(buf: *[1024:0]u8, dir_setting: []const u8) ?[:0]const u8 {
ds.getSecondsIntoMinute(),
}, 0) catch null;
}
+/// The host's environment; a freestanding core (web, the board) has none
+/// and no libc to ask, so only a DumpDir with no `~/` names a directory there.
+fn hostEnv(name: [*:0]const u8) ?[*:0]u8 {
+ if (builtin.os.tag == .freestanding) return null;
+ return std.c.getenv(name);
+}
+
/// Where dumps go and relative Restore names are looked up: the `DumpDir`
/// setting when set (a leading `~/` is the home directory), else the default.
pub fn directory(buf: []u8, dir_setting: []const u8) ?[]const u8 {
if (dir_setting.len == 0) return defaultDirectory(buf);
- if (std.mem.startsWith(u8, dir_setting, "~/")) if (std.c.getenv("HOME")) |h|
+ if (std.mem.startsWith(u8, dir_setting, "~/")) if (hostEnv("HOME")) |h|
return std.fmt.bufPrint(buf, "{s}/{s}", .{ std.mem.span(h), dir_setting[2..] }) catch null;
return std.fmt.bufPrint(buf, "{s}", .{dir_setting}) catch null;
}
/// The standard dump directory, without creating it or generating a filename.
pub fn defaultDirectory(buf: []u8) ?[]const u8 {
- if (std.c.getenv("XDG_DATA_HOME")) |x|
+ if (hostEnv("XDG_DATA_HOME")) |x|
return std.fmt.bufPrint(buf, "{s}/pardes", .{std.mem.span(x)}) catch null;
- if (std.c.getenv("HOME")) |h|
+ if (hostEnv("HOME")) |h|
return std.fmt.bufPrint(buf, "{s}/.local/share/pardes", .{std.mem.span(h)}) catch null;
return null;
}
pub const magic = "pardes-dump";
pub const version: u32 = 1;
-pub const max_panes: usize = 16;
+pub const max_panes: usize = MAX_PANES;
pub const max_cols: usize = 6;
/// Output arguments are typed in the same bounded one-line tag storage. Keep
/// the schema limit named independently so a dump reader can validate it
@@ -142,6 +149,9 @@ pub const File = struct {
origin_arg: []const u8 = "",
mini_source: []const u8 = "",
mini_colors_b64: []const u8 = "",
+ /// Its dot, q0 and q1 in bytes, as acme's dump keeps a window's; null
+ /// in older dumps.
+ dot: ?[2]u32 = null,
};
pub const ImagePalette = enum {
@@ -180,6 +190,9 @@ pub const Pane = struct {
};
pub const Column = struct {
+ /// Its serial when dumped (0 in older dumps): a Restore logs
+ /// `restoredcol <old> <new>` for it.
+ serial: u32 = 0,
tag: ?[]const u8 = null,
weight: f32 = 1,
panes: []const usize = &.{},
@@ -200,6 +213,11 @@ pub const State = struct {
columns: []const Column = &.{},
panes: []const Pane = &.{},
mounts: []const Mount = &.{},
+ /// The settings that differ from a fresh session's, each a line as the
+ /// root ctl takes it (`Placement pardes`); the theme and the locations
+ /// config are kept on their own, and the font is the frontend's. Absent
+ /// in older dumps.
+ settings: []const []const u8 = &.{},
};
/// A header is a text like any other: it may hold newlines, but not a
@@ -305,6 +323,15 @@ pub const Parsed = struct {
}
};
+/// Whether `bytes` are a dump a Restore could load, saying nothing: a
+/// Restore asks before it warns about unsaved text or replaces anything.
+pub fn check(gpa: std.mem.Allocator, bytes: []const u8) !void {
+ var arena: std.heap.ArenaAllocator = .init(gpa);
+ defer arena.deinit();
+ const source = try arena.allocator().dupeZ(u8, bytes);
+ try validate(try std.zon.parse.fromSliceAlloc(State, arena.allocator(), source, null, .{ .free_on_error = false }));
+}
+
pub fn readZon(gpa: std.mem.Allocator, bytes: []const u8, label: []const u8) !Parsed {
var parsed: Parsed = .{ .value = undefined, .arena = .init(gpa) };
errdefer parsed.deinit();
@@ -636,6 +663,10 @@ pub fn dumpState(p: *Pardes) !void {
else
try pardes.panes.Terminal.dumpPane(pane, arena, tag, body, scroll);
dp.tag_tail = pane.tag.own;
+ if (dp.file) |*df| {
+ const dot = ctlfs.pane.dotOf(pane);
+ df.dot = .{ dot.q0, dot.q1 };
+ }
dp.serial = pane.serial;
dump_panes[panes_len] = dp;
panes_len += 1;
@@ -651,6 +682,7 @@ pub fn dumpState(p: *Pardes) !void {
ids_len += 1;
};
columns[columns_len] = .{
+ .serial = layout.columnSerial(p, c),
.tag = p.col_tags[c].own,
.weight = @as(f32, @floatFromInt(p.col_weight[c])) / @as(f32, @floatFromInt(column_weight_unit)),
.panes = column_ids[c][0..ids_len],
@@ -671,6 +703,7 @@ pub fn dumpState(p: *Pardes) !void {
.columns = columns[0..columns_len],
.panes = dump_panes[0..panes_len],
.mounts = mounts[0..p.fs.mounts.items.len],
+ .settings = try changedSettings(p, arena),
};
try validate(state);
var out: std.Io.Writer.Allocating = .init(p.gpa);
@@ -681,20 +714,55 @@ pub fn dumpState(p: *Pardes) !void {
p.emit(.write_dump);
}
+/// The setting lines that differ from a fresh session's, as the root ctl
+/// reads them, less the ones a dump keeps apart.
+fn changedSettings(p: *Pardes, arena: std.mem.Allocator) ![]const []const u8 {
+ var now: std.Io.Writer.Allocating = .init(arena);
+ var fresh: std.Io.Writer.Allocating = .init(arena);
+ const defaults: config.Runtime = .{};
+ try pardes.ctlfs.ctl.writeSettingsOf(p, &p.settings, &now.writer);
+ try pardes.ctlfs.ctl.writeSettingsOf(p, &defaults, &fresh.writer);
+ var out: std.ArrayList([]const u8) = .empty;
+ var lines = std.mem.splitScalar(u8, now.written(), '\n');
+ while (lines.next()) |line| {
+ if (line.len == 0) continue;
+ const word = line[0 .. std.mem.indexOfScalar(u8, line, ' ') orelse line.len];
+ if (std.mem.eql(u8, word, "Theme") or std.mem.eql(u8, word, "Font")) continue;
+ var same = std.mem.splitScalar(u8, fresh.written(), '\n');
+ while (same.next()) |other| {
+ if (std.mem.eql(u8, other, line)) break;
+ } else try out.append(arena, line);
+ }
+ return out.items;
+}
+
+/// Applies a dump's setting lines, each as the root ctl would.
+fn applySettings(p: *Pardes, lines: []const []const u8) void {
+ for (lines) |line| {
+ const sp = std.mem.indexOfScalar(u8, line, ' ');
+ const setting = config.Runtime.find(line[0 .. sp orelse line.len]) orelse continue;
+ _ = p.settings.apply(setting, if (sp) |s| line[s + 1 ..] else null);
+ }
+}
+
/// The replacement core for a Restore of the dump at `from`, whose log says
/// so after its panes' `new`s, for a client that reconnects to read.
pub fn restore(p: *Pardes, zon_bytes: []const u8, from: []const u8) !*Pardes {
var opts = p.opts;
opts.cols = p.screen_w;
opts.rows = p.screen_h;
- var old: [MAX_PANES]u32 = @splat(0);
+ var old: OldSerials = .{};
const replacement = try initDump(p.gpa, opts, zon_bytes, p, &old);
pardes.ctlfs.events.notePath(replacement, "restore", from);
- // Which serial each pane had, for a script that held the old ones.
- for (replacement.panes, old) |slot, was| if (slot) |pane| if (was != 0) {
+ // Which serial each pane and column had, for a script that held them.
+ for (replacement.panes, old.panes) |slot, was| if (slot) |pane| if (was != 0) {
var rec: [32]u8 = undefined;
pardes.ctlfs.events.notePath(replacement, "restored", std.fmt.bufPrint(&rec, "{d} {d}", .{ was, pane.serial }) catch continue);
};
+ for (old.cols[0..replacement.ncol], 0..) |was, c| if (was != 0) {
+ var rec: [32]u8 = undefined;
+ pardes.ctlfs.events.notePath(replacement, "restoredcol", std.fmt.bufPrint(&rec, "{d} {d}", .{ was, layout.columnSerial(replacement, c) }) catch continue);
+ };
return replacement;
}
@@ -702,7 +770,13 @@ pub fn initFromDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8
return initDump(gpa, opts, zon_bytes, null, null);
}
-fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previous: ?*const Pardes, old_serials: ?*[MAX_PANES]u32) !*Pardes {
+/// The serials a dump's panes and columns had, read as it is restored.
+const OldSerials = struct {
+ panes: [MAX_PANES]u32 = @splat(0),
+ cols: [MAX_COLS]u32 = @splat(0),
+};
+
+fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previous: ?*const Pardes, old_serials: ?*OldSerials) !*Pardes {
const image_gpa = opts.image_allocator orelse gpa;
const pdf_gpa = opts.pdf_allocator orelse gpa;
const tree_sitter_gpa = opts.tree_sitter_allocator orelse gpa;
@@ -736,6 +810,7 @@ fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previo
const st = parsed.value;
p.locations_config = st.locations_config;
p.settings.tree_context_tag_style = st.tree_context_tag_style;
+ applySettings(p, st.settings);
for (st.mounts) |mount| {
var already_mounted = false;
for (p.fs.mounts.items) |existing| {
@@ -752,7 +827,7 @@ fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previo
}
for (st.panes, 0..) |src, i| {
if (i >= MAX_PANES) break;
- if (old_serials) |o| o[i] = src.serial;
+ if (old_serials) |o| o.panes[i] = src.serial;
const pane: *pardes.panes.Pane = switch (src.kind) {
.terminal => terminal: {
const t = src.terminal.?;
@@ -822,6 +897,7 @@ fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previo
p.col_serial = @splat(0);
if (st.topbar_custom) |bar| p.global_tag.own = try p.gpa.dupe(u8, bar);
for (st.columns[0..p.ncol], 0..) |col, c| {
+ if (old_serials) |o| o.cols[c] = col.serial;
if (col.tag) |bar| p.col_tags[c].own = try p.gpa.dupe(u8, bar);
const scaled = @as(f64, @floatCast(col.weight)) * @as(f64, @floatFromInt(column_weight_unit));
const bounded = @min(scaled, @as(f64, @floatFromInt(max_column_weight)));
@@ -840,3 +916,36 @@ fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previo
ctlfs.events.announce(p);
return p;
}
+
+test "a dump keeps the settings that differ from a fresh session's, and a restore applies them" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Placement pardes"));
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Verbose off"));
+ try dumpState(p);
+ try std.testing.expect(std.mem.indexOf(u8, p.dump_out.?, "\"Placement pardes\"") != null);
+ // What is left at its default is not written.
+ try std.testing.expect(std.mem.indexOf(u8, p.dump_out.?, "\"BootShell keep\"") == null);
+ const restored = try initFromDump(gpa, .{ .tty_only = true }, p.dump_out.?);
+ defer restored.deinit();
+ try std.testing.expectEqual(config.Runtime.Placement.pardes, restored.settings.placement);
+ try std.testing.expect(!restored.settings.verbose);
+}
+
+test "a restored pane keeps its dot, as acme's dump keeps a window's" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ const pane = try p.setTestFile("one two three\nfour\n");
+ ctlfs.pane.setDot(pane, .{ .q0 = 4, .q1 = 7 });
+ try dumpState(p);
+ const restored = try initFromDump(gpa, .{ .tty_only = true }, p.dump_out.?);
+ defer restored.deinit();
+ const again = for (restored.panes) |slot| {
+ if (slot) |candidate| if (candidate.file != null) break candidate;
+ } else return error.FileLost;
+ const dot = ctlfs.pane.dotOf(again);
+ try std.testing.expectEqual(@as(u32, 4), dot.q0);
+ try std.testing.expectEqual(@as(u32, 7), dot.q1);
+}
diff --git a/src/exec.zig b/src/exec.zig
index 56e6a2a9..d4480e00 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -312,7 +312,7 @@ pub fn spawnV9fsTty(p: *Pardes, from: usize) void {
fn spawnTtyWithMount(p: *Pardes, from: usize, v9fs: bool) ?*Pane {
const src = p.panes[from] orelse return null;
const free = p.freeSlot() orelse {
- p.reportFailure(from, "Tty: no pane free");
+ p.reportError(from, "Tty", error.NoPaneSlots);
return null;
};
const nt = p.newShell(free, Pardes.paneDir(src)) catch |err| {
@@ -443,6 +443,18 @@ fn routeToRepl(p: *Pardes, from: usize, text: []const u8) Routed {
return .asked;
}
+const repl_enter_wait_ns: u64 = 250 * std.time.ns_per_ms;
+
+/// Sends the Enters a paste into pane `id`'s REPL is owed: when the REPL
+/// has answered it (`answered`), or once the wait is up.
+pub fn flushReplEnter(p: *Pardes, id: usize, answered: bool) void {
+ const pane = p.panes[id] orelse return;
+ if (pane.repl_enter == 0) return;
+ if (!answered and p.stepped_ns < pane.repl_enter_due_ns) return;
+ for (0..pane.repl_enter) |_| p.emitWrite(id, "\r");
+ pane.repl_enter = 0;
+}
+
fn pythonBlockOpen(text: []const u8) bool {
const trimmed = std.mem.trimEnd(u8, text, " \t\n");
const start = if (std.mem.lastIndexOfScalar(u8, trimmed, '\n')) |nl| nl + 1 else 0;
@@ -475,13 +487,20 @@ pub fn sendToRepl(p: *Pardes, from: usize, to: usize, text: []const u8) void {
var said: [128]u8 = undefined;
return p.setMessage(from, std.fmt.bufPrint(&said, "Repl: {d} bytes is more than can be sent at once; send less", .{n}) catch "Repl: too long to send");
}
+ const pasted = panes.Terminal.bracketedPaste(dst);
edit.typeToTty(p, to, dst, clean[0..n]);
- p.emitWrite(to, "\r");
// A Python block whose last line is indented, or a bare header, is not
// over at one Enter, pasted into 3.13's REPL or typed into the old one:
// the blank line a second Enter gives ends it.
- if ((if (dst.repl) |r| r.lang == python_lang else false) and pythonBlockOpen(clean[0..n]))
- p.emitWrite(to, "\r");
+ const enters: u8 = if ((if (dst.repl) |r| r.lang == python_lang else false) and pythonBlockOpen(clean[0..n])) 2 else 1;
+ if (pasted) {
+ // An Enter read with the paste is part of it: 3.13's REPL takes it
+ // as a newline in the pasted text, even a one-line one, and waits.
+ // It goes once the REPL has answered the paste (its next output),
+ // or after a moment if nothing comes.
+ dst.repl_enter = enters;
+ dst.repl_enter_due_ns = p.stepped_ns + repl_enter_wait_ns;
+ } else for (0..enters) |_| p.emitWrite(to, "\r");
// Line by line, a blank line inside a Python block ends the block.
if (!panes.Terminal.bracketedPaste(dst) and !dst.repl_warned and (if (dst.repl) |r| r.lang == python_lang else false) and
std.mem.indexOfScalar(u8, text, '\n') != null)
@@ -590,7 +609,7 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize {
return id;
}
const free = p.freeSlot() orelse {
- p.reportFailure(from, "exec: no pane free for the command");
+ p.reportError(from, "exec", error.NoPaneSlots);
return null;
};
const pane = p.newCommand(free, dir, line) catch |err| {
diff --git a/src/fs-help.txt b/src/fs-help.txt
index 0121a831..73a49e9b 100644
--- a/src/fs-help.txt
+++ b/src/fs-help.txt
@@ -1,22 +1,28 @@
Pardes control filesystem
=========================
README this guide
-index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name (+New if unnamed)
+index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name (+New if unnamed), column serial
status pid, version and pane count
look write a line: a right click on it at the active pane; read: the serials it touched
exec write a line: a middle click, an editor command word or a shell line; read the same
-log events: new/del/rename/save <serial> <name>, run/exit/send <serial> ..., dump/restore <path>, msg, err
+log events: new/del/rename/save <serial> <name>, newcol/delcol <column serial>, run/exit/send <serial> ...,
+ ask <serial> <what> <choices>, dump/restore <path>, restored/restoredcol <old> <new>, msg, err
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 (empty on a column tag); write a serial to give it the keyboard
ctl settings, one a line; or Newcol (an empty column), Dump, Exit (quits, asks once), Kill [w] (stops what pardes ran, w: by first word)
commands every builtin: its word, arg if it takes one, and root or pane: which ctl takes it
+layout one line per column: serial index x width current|notcurrent empty|full pane-serials...; then active <serial>
+tag the workspace tag: read it, > replaces it, >> appends (one line)
+col/<n>/tag the tag of the column with serial n, the same way (a serial stays while the column lives)
+col/<n>/ctl Delcol, Joincol, New or Tty on that column; col/<n>/exec a word as a click in its tag; rmdir col/<n> closes an empty one
pane/new open it to make a pane (named +New) in the active column, filling it if empty; the read answers its serial
pane/<n>/ name body tag ctl addr dot limit data xdata sel dirty mark scroll errors (write-only:
appended to its directory's +Errors pane) event look exec, pty/ on terminals; rmdir closes
-os/, src/ the host filesystem; the editor's own sources (-Dembed-sources=true builds only)
+os/, src/ the host filesystem; the editor's own sources
-Below, $m is the mount: $NINE_MOUNT/pardes/<pid, or NAME of --detach=NAME>; PARDES_MOUNT in a Tty9p shell.
+Below, $m is the mount: $NINE_MOUNT/pardes/<pid, or NAME of --detach=NAME>; PARDES_MOUNT in a Tty9p shell;
+$NINE_MOUNT itself under `9ns --unix SOCKET -- cmd`, which mounts one session as its root.
cat $m/index which panes exist
n=$(cat $m/pane/new) make a pane, take its serial
@@ -38,7 +44,7 @@ Pitfalls, one each:
addr: #n,#m, 12:5 or /re/ from the current address (^ $ per line); > never resets it, 0 does; a miss refuses data.
data: > replaces the addr range, : > deletes it, a 2nd > inserts (addr moved); body: > empties all.
tag reads the path, then its own text; > replaces that text (default words too), >> appends.
- A terminal's body is a history snapshot frozen per open; pty/data is the live stream.
+ A terminal's body is a history snapshot frozen per open, wrapped rows joined into lines; pty/data is the live stream.
exec: a non-builtin line runs as a command pane (ctl's Shell -c; exit N shown, logged), a shell's typed in.
Through a mount a malformed write is EINVAL, a well-formed one that fails EIO: log's err says why.
lock/unlock need a held ctl fd (exec 3>ctl), a held lock fails: retry. A column's last pane leaves it empty; the last pane of all quits.
diff --git a/src/fs.zig b/src/fs.zig
index 0a4473d6..8be728a6 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -19,7 +19,14 @@ const Reply = tree.Reply;
const E = tree.E;
/// The served README; tree.zig answers it.
-pub const help = @embedFile("fs-help.txt");
+/// The tree's README; a build that embeds no sources lists no src/.
+pub const help = help: {
+ const raw = @embedFile("fs-help.txt");
+ if (limits.embedded_sources) break :help raw;
+ const both = "os/, src/ the host filesystem; the editor's own sources";
+ const at = std.mem.indexOf(u8, raw, both) orelse @compileError("fs-help.txt no longer lists os/, src/");
+ break :help raw[0..at] ++ "os/ the host filesystem" ++ raw[at + both.len ..];
+};
/// Host filesystem nodes: the root, and one hashed node per path a client walked to.
pub const os_root: u64 = (1 << 63) + 2;
@@ -491,7 +498,7 @@ test "bounded reads release terminal snapshots after success and size refusal" {
defer gpa.free(empty);
try std.testing.expectEqual(@as(usize, 0), empty.len);
p.update(.{ .output = .{ .pane = 0, .bytes = "limited terminal output" } });
- const expected = "limited terminal output";
+ const expected = "limited terminal output\n"; // a line ends with its newline
const bytes = try readLimit(p, path, expected.len);
defer gpa.free(bytes);
try std.testing.expectEqualStrings(expected, bytes);
@@ -1270,6 +1277,15 @@ pub const Namespace = struct {
/// What went wrong performing a write's effects after it was answered
/// by the core (a save the host could not do): the 9P write, which
/// waits for them, fails with it (src/9p_io.zig).
+ /// A header tag truncated by an open not yet released (ninep/cols.zig):
+ /// 0 the workspace's, a column's its index + 1; and whether the newline
+ /// that ended its last write is held back.
+ header_rewrite: ?u32 = null,
+ /// A look or exec a 9P write made found every pane slot taken; the
+ /// write fails with what was said, kept at the head of `ename`.
+ no_pane_slot: bool = false,
+ no_pane_slot_len: u16 = 0,
+ header_held: bool = false,
late_failure: [256]u8 = undefined,
late_failure_len: u16 = 0,
/// A refusal that quotes the message it refuses, as Plan 9's cmderror
@@ -1355,3 +1371,7 @@ pub fn unmount(p: *Pardes, name: []const u8) !void {
test {
_ = tree;
}
+
+test "the tree's README lists src/ only when the build serves it" {
+ try std.testing.expectEqual(limits.embedded_sources, std.mem.indexOf(u8, help, "src/") != null);
+}
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 5dfacee9..491373a8 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -1857,6 +1857,12 @@ const Gui = struct {
scroll_edge: []pardes.Cell = &.{}, // the row that just left the pane
scroll_edge_len: u16 = 0,
+ /// The cursor's blink (blinkNow): when the frame drawn last arrived,
+ /// a redraw owed for an edge, and this frame's cursor mode and alpha.
+ frame_arrival_ns: u64 = 0,
+ blink_due: bool = false,
+ no_blink: bool = false,
+ cursor_quad_alpha: f32 = 0,
scene_tex: ?*c.SDL_GPUTexture = null,
scene_tex_w: u32 = 0,
scene_tex_h: u32 = 0,
@@ -1972,7 +1978,11 @@ fn glyphForCodepoint(g: *const Gui, cp: u32) FaceGlyph {
}
const CellLayout = struct { w: f32, h: f32, x_off: f32, y_off: f32 };
-const MouseCell = struct { col: u16, row: u16, body_hit: ?pardes.Mouse.BodyHit = null, tag_hit: ?pardes.TagHit = null };
+const MouseCell = struct { col: u16, row: u16, body_hit: ?pardes.Mouse.BodyHit = null, tag_hit: ?pardes.TagHit = null, rule_above: bool = false };
+
+/// How near a row's top edge a press is on the rule drawn there between two
+/// panes: the rule's 2 px and a pixel either side.
+const rule_grab_px: f32 = 3;
const WindowGeometry = struct {
window_w: f32,
window_h: f32,
@@ -2648,6 +2658,8 @@ fn localSession(
.test_hz = if (std.c.getenv("PARDES_TEST_HZ")) |hz| @max(1, std.fmt.parseInt(u64, std.mem.span(hz), 10) catch 60) else 60,
};
defer shell.shutdownPtys();
+ // A virtual clock never blinks: goldens and feel series hold still.
+ g.no_blink = shell.test_clock != null;
const host = shell.host();
core.host = host;
@@ -3420,6 +3432,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
.row = mc.row,
.tag_hit = mc.tag_hit,
.body_hit = mc.body_hit,
+ .rule_above = mc.rule_above,
.ctrl = (c.SDL_GetModState() & c.SDL_KMOD_CTRL) != 0,
},
});
@@ -3720,6 +3733,8 @@ fn gridCellAtDimensions(
) MouseCell {
const row: u16 = @intFromFloat(@min(@floor(@max(y, 0) / @max(body_h, 1)), 10_000));
var point: MouseCell = .{ .col = @intFromFloat(@min(@floor(@max(x, 0) / @max(body_w, 1)), 10_000)), .row = row };
+ // On the rule over this row, which is where two panes meet if they do.
+ point.rule_above = row > 0 and @max(y, 0) - @as(f32, @floatFromInt(row)) * @max(body_h, 1) < rule_grab_px;
if (core) |p| if (p.presentation.pointerFractional(p.screen_w, p.screen_h, x / @max(body_w, 1), y / @max(body_h, 1))) |mapped| {
for (p.surface.tagLayers()) |*layer| {
if (layer.tagHit(mapped.x * body_w, mapped.y * body_h, body_w, body_h, tagline_w, @floatFromInt(p.row_metrics.tagline_h))) |hit| {
@@ -4179,7 +4194,14 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void {
@intCast(@max(1, refreshNs(g) / std.time.ns_per_ms))
else
16;
- const ms: c_int = if (latency_fd >= 0) 0 else if (minimized) -1 else if (timeout_ms != 0) @min(@as(c_int, @intCast(@min(timeout_ms, std.math.maxInt(c_int)))), if (polls) poll_ms else std.math.maxInt(c_int)) else if (polls) poll_ms else -1;
+ const waited: c_int = if (latency_fd >= 0) 0 else if (minimized) -1 else if (timeout_ms != 0) @min(@as(c_int, @intCast(@min(timeout_ms, std.math.maxInt(c_int)))), if (polls) poll_ms else std.math.maxInt(c_int)) else if (polls) poll_ms else -1;
+ // The cursor's blink is the shell's to time (Chrome.cursor_idle_ms):
+ // a frame a loop through an edge, else a wake at the next one; none
+ // while the window is minimized or unfocused. A virtual clock never
+ // blinks, so goldens hold still.
+ const blink = if (s.test_clock != null or minimized) null else blinkNow(g, if (s.surface) |frame| &frame.chrome else null);
+ const blink_ms: ?c_int = if (blink) |b| (if (b.edge) 1 else if (b.next_ns) |next| @as(c_int, @intCast(@min(std.math.maxInt(c_int), next / std.time.ns_per_ms + 1))) else null) else null;
+ const ms: c_int = if (blink_ms) |b| (if (waited < 0) b else @min(waited, b)) else waited;
// The wait is the 9P connections' turn with the core.
pardes.turn.rest();
const got = c.SDL_WaitEventTimeout(&sev, ms);
@@ -4189,6 +4211,9 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void {
// (PARDES_TEST_HZ), events or not, as a real display's present
// would; otherwise a wait that ran out moves it exactly to the
// core's wake.
+ // Through an edge, or woken for the next: the shell redraws the last
+ // frame with the new alpha, no core render (§5.5 level B).
+ if (blink_ms != null and (blink.?.edge or !got)) g.blink_due = true;
if (s.test_clock) |*virtual| {
if (core.continuous()) {
const display = virtual.* + std.time.ns_per_s / s.test_hz;
@@ -4273,6 +4298,27 @@ fn pollFrame(ctx: ?*anyopaque) void {
// early is on time: the wait that paces it is in whole milliseconds).
if (g.post.animating(core.settings.shader_animation) and !core.needs_frame and s.presented and
c.SDL_GetTicksNS() -| g.post.last_ns + std.time.ns_per_ms >= refreshNs(g)) redrawPost(g, s.gpa);
+ // A blink edge: the last frame again, the cursor's alpha moved on.
+ if (g.blink_due and !core.needs_frame and s.presented) if (s.surface) |frame| {
+ g.blink_due = false;
+ blink_redraws += 1;
+ s.presented = renderFrame(g, s.gpa, core, frame, core.settings.debug) catch false;
+ };
+}
+
+/// Frames drawn for a blink alone, with no core render (tests, tracing).
+var blink_redraws: u64 = 0;
+
+/// The blink now, from the frame's time since input and how long ago it
+/// arrived; null where nothing blinks (off, idle, an unfocused window).
+fn blinkNow(g: *const Gui, chrome: ?*const pardes.Chrome) ?pardes.animation.Blink {
+ const frame = chrome orelse return null;
+ if (g.no_blink or frame.cursor_glide & 2 == 0 or frame.cursor_idle_ms == std.math.maxInt(u32)) return null;
+ if (c.SDL_GetWindowFlags(g.window) & c.SDL_WINDOW_INPUT_FOCUS == 0) return null;
+ const since = @as(u64, frame.cursor_idle_ms) * std.time.ns_per_ms + (monoNs() -| g.frame_arrival_ns);
+ // Relative to now: `next_ns` is how long until the next edge.
+ const b = pardes.animation.Blink.at(since, 0);
+ return .{ .alpha = b.alpha, .edge = b.edge, .next_ns = if (b.next_ns) |next| next -| since else null };
}
/// One refresh of the display the window is on, in ns; a frame of the
@@ -4340,6 +4386,7 @@ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void {
frame_started_ns = monoNs();
const frame = @constCast(surface);
s.surface = frame;
+ g.frame_arrival_ns = monoNs();
const scene_requested = g.post.ready();
if (!scene_requested) g.scene_failures = 0;
s.presented = renderFrame(
@@ -4485,8 +4532,8 @@ fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) vo
fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void {
const s = shellOf(ctx);
var pbuf: [1024:0]u8 = undefined;
- const path = pardes.dump.outPath(&pbuf, s.core.settings.dump_dir.get()) orelse return;
- filesystem.write(s.core, path, bytes) catch |err| return s.core.reportError(0, "dump", err);
+ const path = pardes.dump.outPath(&pbuf, s.core.settings.dump_dir.get()) orelse return s.core.dumpFailed(s.core.settings.dump_dir.get(), error.NoDumpDirectory);
+ filesystem.write(s.core, path, bytes) catch |err| return s.core.dumpFailed(path, err);
s.core.setLastDump(path);
}
@@ -5359,8 +5406,13 @@ fn renderFrame(
const layout = fixedCellLayout(g);
const chrome = &surface.chrome;
var groups = makeGroups(surface.panelTracks(), surface.hasPanelDiff());
- // Cursors stand still while panes move, as they always have.
- const cursors_shown = groups.tracks == 0;
+ // Cursors stand still while panes move, as they always have; while the
+ // focused one glides or blinks, it is the overlay's quad instead. One
+ // or the other every frame (none only at a blink's off half).
+ const blink_alpha: f32 = if (blinkNow(g, chrome)) |b| b.alpha else 1;
+ const quad_mode = chrome.cursor_glide & 2 != 0 and (chrome.cursor_glide & 1 != 0 or blink_alpha < 1);
+ g.cursor_quad_alpha = if (quad_mode) chrome.cursor_alpha * blink_alpha else 0;
+ const cursors_shown = groups.tracks == 0 and !quad_mode;
var overlay_count = buildOverlay(g, core, surface, sw, sh, debug_on);
if (overlay_count != 0 and !uploadOverlayGpu(g, cmd, overlay_count))
overlay_count = 0;
@@ -8498,10 +8550,43 @@ fn buildOverlay(
if (g.overlay_vertices.len == 0 or sw == 0 or sh == 0) return 0;
var builder = OverlayBuilder{ .vertices = g.overlay_vertices, .win_w = @floatFromInt(sw), .win_h = @floatFromInt(sh) };
appendWorkspacePet(g, core, surface, &builder);
+ appendCursorGlide(g, surface, &builder);
appendTouchOverlay(g, &builder, debug_on);
return @intCast(builder.len);
}
+/// G3: the focused cursor while it glides or blinks, its quad from the core
+/// (Chrome.cursor_quad, grid cells) in its cursor colour; a little less than
+/// solid, so the glyphs it passes over stay legible.
+fn appendCursorGlide(g: *Gui, surface: *const pardes.Surface, builder: *OverlayBuilder) void {
+ const chrome = &surface.chrome;
+ if (g.cursor_quad_alpha <= 0.001) return;
+ if (surface.panelTracks().len != 0) return;
+ const layout = fixedCellLayout(g);
+ var px: [4][2]f32 = undefined;
+ var centre: [2]f32 = .{ 0, 0 };
+ for (&px, 0..) |*point, i| {
+ point.* = .{ chrome.cursor_quad[i * 2] * layout.w, chrome.cursor_quad[i * 2 + 1] * layout.h };
+ centre[0] += point[0] / 4;
+ centre[1] += point[1] / 4;
+ }
+ // Its colour: the cursor the cells would draw nearest the quad.
+ var boxes: [max_cursors]CursorBox = undefined;
+ var rgb: [3]u8 = chrome.fg orelse fg_default;
+ var nearest: f32 = std.math.inf(f32);
+ for (boxes[0..cursorBoxes(g, surface, layout, builder.win_h, &boxes)]) |box| {
+ const dx = (box.x0 + box.x1) / 2 - centre[0];
+ const dy = (box.y0 + box.y1) / 2 - centre[1];
+ if (dx * dx + dy * dy < nearest) {
+ nearest = dx * dx + dy * dy;
+ rgb = box.rgb;
+ }
+ }
+ const color: OverlayColor = .{ .r = @as(f32, @floatFromInt(rgb[0])) / 255, .g = @as(f32, @floatFromInt(rgb[1])) / 255, .b = @as(f32, @floatFromInt(rgb[2])) / 255, .a = g.cursor_quad_alpha };
+ builder.addTri(px[0][0], px[0][1], px[1][0], px[1][1], px[2][0], px[2][1], color);
+ builder.addTri(px[0][0], px[0][1], px[2][0], px[2][1], px[3][0], px[3][1], color);
+}
+
fn appendWorkspacePet(g: *Gui, core: ?*const pardes.Pardes, surface: *const pardes.Surface, builder: *OverlayBuilder) void {
const p = core orelse return;
const kind: pet.Kind = @enumFromInt(@intFromEnum(p.settings.pet));
diff --git a/src/layout.zig b/src/layout.zig
index c58f3bcf..b9b24e79 100644
--- a/src/layout.zig
+++ b/src/layout.zig
@@ -162,8 +162,15 @@ pub fn targetColumn(p: *Pardes, cur_x: u16) usize {
}
pub fn splitRowForExtent(y: u16, h: u16, cur_y: u16) ?u16 {
+ return splitRowFor(y, h, cur_y, null);
+}
+
+/// As `splitRowForExtent`, each pane keeping at least `least` rows when
+/// given: a pane dragged by its grip within its own column is how a
+/// terminal resizes, and acme lets either window keep only its tag.
+fn splitRowFor(y: u16, h: u16, cur_y: u16, least: ?u16) ?u16 {
if (h < 2) return null;
- const min_each: u16 = if (h >= config.MINH * 2) config.MINH else 1;
+ const min_each: u16 = least orelse if (h >= config.MINH * 2) config.MINH else 1;
const lo = y +| min_each;
const hi = y + h - min_each;
if (lo > hi) return y + h / 2;
@@ -176,7 +183,11 @@ pub fn movePlacement(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) ?MovePlaceme
if (tc == src.col and p.col_n[src.col] == 1) return null;
if (tc == src.col) {
const sr = p.rects[id];
- if (cur_y >= sr.y and cur_y < sr.y + sr.h) return null;
+ // Dropped on itself it stays, unless dragged down from its top with
+ // a pane above: its top moves down there, the pane above taking the
+ // rows, as acme's window dragged by its button does. With no rule
+ // between panes, that is how a terminal resizes them.
+ if (cur_y >= sr.y and cur_y < sr.y + sr.h and (src.idx == 0 or cur_y <= sr.y)) return null;
}
var heights: [MAX_PANES]u16 = @splat(0);
for (0..p.ncol) |c| {
@@ -196,7 +207,7 @@ pub fn movePlacement(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) ?MovePlaceme
const pid = p.col_panes[tc][k];
if (pid == id) continue;
const h = heights[pid];
- const row = splitRowForExtent(y, h, cur_y) orelse {
+ const row = splitRowFor(y, h, cur_y, if (tc == src.col) BOX_H else null) orelse {
y +|= h;
continue;
};
@@ -280,7 +291,7 @@ pub fn applyRowSplit(p: *Pardes, cc: usize, k: usize, cur_y: u16) void {
b.vweight = pair - a.vweight;
}
-pub fn findPane(p: *Pardes, id: usize) ?struct { col: usize, idx: usize } {
+pub fn findPane(p: *const Pardes, id: usize) ?struct { col: usize, idx: usize } {
for (0..p.ncol) |c| {
for (0..p.col_n[c]) |k| {
if (p.col_panes[c][k] == id) return .{ .col = c, .idx = k };
@@ -311,6 +322,7 @@ pub fn removePane(p: *Pardes, id: usize) void {
/// it (the first column's to its right), as acme's rowclose gives it.
pub fn dropColumn(p: *Pardes, c: usize) void {
std.debug.assert(p.col_n[c] == 0);
+ const serial = columnSerial(p, c);
tagline.exitHeader(p);
if (p.ncol > 1) p.col_weight[if (c > 0) c - 1 else c + 1] +|= p.col_weight[c];
p.col_tags[c].deinit(p.gpa);
@@ -325,6 +337,14 @@ pub fn dropColumn(p: *Pardes, c: usize) void {
p.ncol -= 1;
p.col_tags[p.ncol] = .{ .what = .column, .gpa = p.gpa };
p.col_serial[p.ncol] = 0;
+ noteColumn(p, "delcol", serial);
+}
+
+/// `newcol <serial>` or `delcol <serial>` in the log: the column's serial,
+/// as /layout and /col name it.
+fn noteColumn(p: *Pardes, what: []const u8, serial: u32) void {
+ var buf: [16]u8 = undefined;
+ pardes.ctlfs.events.notePath(p, what, std.fmt.bufPrint(&buf, "{d}", .{serial}) catch return);
}
/// Fold column `c` into the one on its right, its panes below theirs.
@@ -480,6 +500,7 @@ pub fn insertColumn(p: *Pardes, col: usize, before: bool) ?usize {
p.col_serial[c] = 0;
p.col_n[c] = 0;
p.ncol += 1;
+ noteColumn(p, "newcol", columnSerial(p, c));
return c;
}
diff --git a/src/look.zig b/src/look.zig
index d90d27b8..17746cd1 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -1378,6 +1378,7 @@ test "Look hover waits without mutating the pane and input cancels it" {
while (p.nextEffect()) |_| {}
const other_id = p.freeSlot() orelse return error.NoSparePaneForHoverTest;
_ = try p.newShell(other_id, "");
+ p.panes[other_id].?.tag_expanded = false; // the blank spot right of the file is its body, not its tag
try std.testing.expect(layout.splitColumn(p, 0, other_id, false));
p.sync();
while (p.nextEffect()) |_| {}
diff --git a/src/macos.zig b/src/macos.zig
index 678b155d..d6daf944 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -2496,8 +2496,8 @@ fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) vo
fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void {
const st = hostState(ctx);
var pbuf: [1024:0]u8 = undefined;
- const path = pardes.dump.outPath(&pbuf, st.core.settings.dump_dir.get()) orelse return;
- filesystem.write(st.core, path, bytes) catch |err| return st.core.reportError(0, "dump", err);
+ const path = pardes.dump.outPath(&pbuf, st.core.settings.dump_dir.get()) orelse return st.core.dumpFailed(st.core.settings.dump_dir.get(), error.NoDumpDirectory);
+ filesystem.write(st.core, path, bytes) catch |err| return st.core.dumpFailed(path, err);
st.core.setLastDump(path);
}
diff --git a/src/mouse.zig b/src/mouse.zig
index 36786612..1ca53017 100644
--- a/src/mouse.zig
+++ b/src/mouse.zig
@@ -97,13 +97,50 @@ fn cornerFixture(gpa: std.mem.Allocator) !*Pardes {
return p;
}
+test "a pane's last row of text is no resize handle; the rule over the next pane is" {
+ if (platform == .web) return;
+ const p = try cornerFixture(std.testing.allocator);
+ defer p.deinit();
+ const seam = seamRowOf(p, 1, 0);
+ const inside = p.col_x[1] + p.col_w[1] / 2;
+ p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = inside, .row = seam } });
+ try std.testing.expect(p.drag != .border_h);
+ p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = inside, .row = seam } });
+ try std.testing.expect(nudgeRightSeam(p, 2) == seam + 2);
+}
+
+test "a pane's grip dragged up or down its own column moves its top, down to a tag alone above" {
+ if (platform == .web) return;
+ const p = try cornerFixture(std.testing.allocator);
+ defer p.deinit();
+ const upper = p.col_panes[1][0];
+ const lower = p.col_panes[1][1];
+ const grip_x = p.rects[lower].x;
+ // Up, past the pane above's first row: it keeps one row, its tag.
+ const top = p.rects[upper].y;
+ p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = grip_x, .row = p.rects[lower].y } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = grip_x, .row = top } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = grip_x, .row = top } });
+ p.sync();
+ try std.testing.expectEqual(@as(u16, 1), p.rects[upper].h);
+ try std.testing.expectEqual(lower, p.col_panes[1][1]);
+ // Down, inside itself: its top follows, the pane above taking the rows.
+ const to = p.rects[lower].y + 6;
+ p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = grip_x, .row = p.rects[lower].y } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = grip_x, .row = to } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = grip_x, .row = to } });
+ p.sync();
+ try std.testing.expectEqual(to, p.rects[lower].y);
+}
+
fn nudgeRightSeam(p: *Pardes, delta: i32) u16 {
const from = seamRowOf(p, 1, 0);
const inside = p.col_x[1] + p.col_w[1] / 2;
const to: u16 = @intCast(@as(i32, from) + delta);
- p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = inside, .row = from } });
- p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = inside, .row = to } });
- p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = inside, .row = to } });
+ // On the rule a pixel frontend draws over the lower pane's first row.
+ p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = inside, .row = from + 1, .rule_above = true } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = inside, .row = to + 1 } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = inside, .row = to + 1 } });
p.sync();
return seamRowOf(p, 1, 0);
}
@@ -437,13 +474,17 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
return;
}
}
- for (0..p.ncol) |cc| {
+ // Two panes are resized on the rule between them, never on a
+ // row of text; with no rule, by the grip, as acme moves a
+ // window by its button.
+ if (m.rule_above and mrow > 0) for (0..p.ncol) |cc| {
if (mcol < p.col_x[cc] or mcol >= p.col_x[cc] + p.col_w[cc]) continue;
- if (seamIdxAt(p, cc, mrow)) |k| {
- p.drag = .{ .border_h = .{ .col = cc, .top_idx = k, .cur_y = mrow } };
+ const seam = if (p.settings.tag_bottom) mrow else mrow - 1;
+ if (seamIdxAt(p, cc, seam)) |k| {
+ p.drag = .{ .border_h = .{ .col = cc, .top_idx = k, .cur_y = seam } };
return;
}
- }
+ };
const id = hovered orelse return;
const r = p.rects[id];
const pane = p.panes[id] orelse return;
@@ -597,7 +638,9 @@ pub fn dragUpdate(p: *Pardes, mcol: u16, mrow: u16, body_hit: ?Mouse.BodyHit) vo
if (k + 1 < p.col_n[cc]) {
const a = p.rects[p.col_panes[cc][k]];
const b = p.rects[p.col_panes[cc][k + 1]];
- d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow, p.settings.tag_bottom);
+ // Held by the rule over the lower pane: the seam is the row above.
+ const seam = if (p.settings.tag_bottom) mrow else mrow -| 1;
+ d.cur_y = clampBorderRow(a.y, a.h, b.h, seam, p.settings.tag_bottom);
} else d.cur_y = mrow;
},
.column_move => |*d| d.cur_x = mcol,
diff --git a/src/ninep/cols.zig b/src/ninep/cols.zig
new file mode 100644
index 00000000..b6dc7d26
--- /dev/null
+++ b/src/ninep/cols.zig
@@ -0,0 +1,284 @@
+//! The layout and the header tags: /layout, /tag (the workspace's) and
+//! /col/<serial>/tag (each column's; a column's serial stays while it lives,
+//! as a pane's does). acme serves no column files -- its columns
+//! are only a window's place -- so these go past it: a script can see where
+//! the panes are and edit the tags a person clicks in.
+const std = @import("std");
+const pardes = @import("../pardes.zig");
+const Pardes = pardes.Pardes;
+const tree = @import("tree.zig");
+const tagline = pardes.tagline;
+const layout = pardes.layout;
+const Text = pardes.panes.Text;
+const Req = tree.Req;
+const Reply = tree.Reply;
+const E = tree.E;
+
+/// The column with the keyboard: the header's, when a column tag has it,
+/// else the active pane's. Null when the workspace tag has it and no pane
+/// is active.
+pub fn activeColumn(p: *const Pardes) ?usize {
+ if (p.header_focus) if (p.header_column) |c| return if (c < p.ncol) c else null;
+ const at = layout.findPane(p, p.active) orelse return null;
+ return at.col;
+}
+
+/// One line a column: its serial (the name /col/<serial> and the log give
+/// it, which stays while it lives), its index left to right, x and width in
+/// cells, `current` or `notcurrent` (rio's words, for the column with the
+/// keyboard), `empty` or `full`, and its panes' serials, top to bottom;
+/// then `active <serial>`: the active column, where pane/new and a look
+/// place a pane next (acme's activecol), not always the keyboard's.
+pub fn writeLayout(p: *Pardes, w: *std.Io.Writer) !void {
+ const active = activeColumn(p);
+ for (0..p.ncol) |c| {
+ try w.print("{d} {d} {d} {d} {s} {s}", .{
+ layout.columnSerial(p, c), c, p.col_x[c], p.col_w[c],
+ if (active == c) "current" else "notcurrent", if (p.col_n[c] == 0) "empty" else "full",
+ });
+ for (p.col_panes[c][0..p.col_n[c]]) |id| if (p.panes[id]) |pane| try w.print(" {d}", .{pane.serial});
+ try w.writeByte('\n');
+ }
+ if (p.activeColumn() orelse active) |c| try w.print("active {d}\n", .{layout.columnSerial(p, c)}) else try w.writeAll("active -\n");
+}
+
+pub fn layoutLen(p: *Pardes) u64 {
+ var buf: [256]u8 = undefined;
+ var counting: std.Io.Writer.Discarding = .init(&buf);
+ writeLayout(p, &counting.writer) catch return 0;
+ return counting.fullCount();
+}
+
+/// A header by its node: the workspace's (`serial` null) or the column's
+/// with that serial; null when that column has closed.
+const Header = struct { text: *Text, col: ?usize };
+
+fn header(p: *Pardes, serial: ?u32) ?Header {
+ const s = serial orelse return .{ .text = &p.global_tag, .col = null };
+ const c = layout.columnBySerial(p, s) orelse return null;
+ return .{ .text = &p.col_tags[c], .col = c };
+}
+
+pub fn headerText(p: *Pardes, serial: ?u32) ?[]const u8 {
+ const h = header(p, serial) orelse return null;
+ return if (h.col) |c| tagline.columnTag(p, c) else tagline.topbar(p);
+}
+
+pub fn read(p: *Pardes, req: Req, serial: ?u32) Reply {
+ const text = headerText(p, serial) orelse return Reply.fail(req.tag, E.NOENT);
+ const out = p.fs.stage(p.gpa);
+ out.appendSlice(p.gpa, text) catch return Reply.fail(req.tag, E.NOMEM);
+ out.append(p.gpa, '\n') catch return Reply.fail(req.tag, E.NOMEM);
+ return tree.stagedReply(p, req);
+}
+
+/// A write appends, as a pane tag's does; the tag is one line, so a newline
+/// in it becomes a space (tagline.flattenHeaders), but after a truncation
+/// the newline that ends the write is dropped, as a pane tag drops it.
+pub fn write(p: *Pardes, req: Req, serial: ?u32) Reply {
+ const t = (header(p, serial) orelse return Reply.fail(req.tag, E.NOENT)).text;
+ if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 };
+ const was = headerText(p, serial).?;
+ var data = req.data;
+ const rewriting = p.fs.header_rewrite == rewriteKey(serial);
+ // A newline held from the write before goes in once more text follows.
+ const held: []const u8 = if (rewriting and p.fs.header_held) " " else "";
+ p.fs.header_held = false;
+ if (rewriting and data[data.len - 1] == '\n') {
+ data = data[0 .. data.len - 1];
+ p.fs.header_held = true;
+ }
+ const next = std.mem.concat(p.gpa, u8, &.{ was, held, data }) catch return Reply.fail(req.tag, E.NOMEM);
+ std.mem.replaceScalar(u8, next, '\n', ' ');
+ if (t.own) |own| p.gpa.free(own);
+ t.own = next;
+ return .{ .tag = req.tag, .written = @intCast(req.data.len) };
+}
+
+/// Truncating clears it, as a pane tag's `cleartag`: its default words go
+/// too, and `u` in it brings them back.
+pub fn truncate(p: *Pardes, serial: ?u32) tree.Status {
+ const t = (header(p, serial) orelse return .err).text;
+ const empty = p.gpa.alloc(u8, 0) catch return .err;
+ t.remember(p.gpa, if (t.own) |own| .{ .text = own } else null);
+ if (t.own) |own| p.gpa.free(own);
+ t.own = empty;
+ t.cur_row = 0;
+ t.cur_col = 0;
+ p.fs.header_rewrite = rewriteKey(serial);
+ p.fs.header_held = false;
+ return .ok;
+}
+
+/// Which header a truncation was of: the workspace's is 0 (no column's
+/// serial is), a column's its serial.
+fn rewriteKey(serial: ?u32) u32 {
+ return serial orelse 0;
+}
+
+pub fn released(p: *Pardes, serial: ?u32) void {
+ if (p.fs.header_rewrite != rewriteKey(serial)) return;
+ p.fs.header_rewrite = null;
+ p.fs.header_held = false;
+}
+
+/// A column's ctl: the words that act on a column, as its tag's clicks do --
+/// Delcol, Joincol, New, Tty -- one a line, each run as from that tag.
+pub fn writeCtl(p: *Pardes, req: Req, serial: u32) Reply {
+ var lines = std.mem.splitScalar(u8, req.data, '\n');
+ while (lines.next()) |raw| {
+ const line = std.mem.trim(u8, raw, " \t\r");
+ if (line.len == 0) continue;
+ const word = line[0 .. std.mem.indexOfAny(u8, line, " \t") orelse line.len];
+ const takes = for ([_][]const u8{ "Delcol", "Joincol", "New", "Tty" }) |w| {
+ if (std.mem.eql(u8, w, word)) break true;
+ } else false;
+ if (!takes) return tree.failText(req.tag, E.INVAL, "a column's ctl takes Delcol, Joincol, New or Tty");
+ const col = layout.columnBySerial(p, serial) orelse return Reply.fail(req.tag, E.NOENT);
+ p.exec_column = col;
+ defer p.exec_column = null;
+ if (pardes.ctlfs.ctl.runBuiltin(p, req, p.active, line)) |refusal| return refusal;
+ }
+ return .{ .tag = req.tag, .written = @intCast(req.data.len) };
+}
+
+/// A column's exec: each line run as a middle click on it in that column's
+/// tag, acting on that column.
+pub fn writeExec(p: *Pardes, req: Req, serial: u32) Reply {
+ var lines = std.mem.splitScalar(u8, req.data, '\n');
+ while (lines.next()) |raw| {
+ const line = std.mem.trim(u8, raw, " \t\r");
+ if (line.len == 0) continue;
+ for (line) |c| if (c < ' ' and c != '\t') return Reply.fail(req.tag, E.INVAL);
+ const col = layout.columnBySerial(p, serial) orelse return Reply.fail(req.tag, E.NOENT);
+ p.exec_column = col;
+ defer p.exec_column = null;
+ pardes.ctlfs.ctl.run(p, p.active, true, line);
+ }
+ return .{ .tag = req.tag, .written = @intCast(req.data.len) };
+}
+
+/// rmdir of a column closes it when it is empty; one with panes is
+/// refused, saying so (close them, or Delcol on its ctl, which asks first).
+pub fn remove(p: *Pardes, req: Req, serial: u32, file: tree.ColFile) Reply {
+ if (file != .dir) return Reply.fail(req.tag, E.PERM);
+ const col = layout.columnBySerial(p, serial) orelse return Reply.fail(req.tag, E.NOENT);
+ if (p.col_n[col] > 0) return tree.failText(req.tag, E.NOTEMPTY, "column not empty: close its panes, or write Delcol to its ctl");
+ p.removeColumn(col) catch return Reply.fail(req.tag, E.IO);
+ return .{ .tag = req.tag };
+}
+
+// ---- tests ----
+
+const testing = std.testing;
+const th = @import("testing.zig");
+const Node = tree.Node;
+
+test "layout lists each column's place, panes and state, and the active one" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ p.newColumn(p.active);
+ p.sync();
+ const first_serial = layout.columnSerial(p, 0);
+ const new_serial = layout.columnSerial(p, 1);
+ try testing.expect(first_serial != new_serial);
+ const layout_node = @intFromEnum(tree.TopFile.layout);
+ const text = th.rd(p, layout_node, 0, 4096).bytes;
+ var want: [256]u8 = undefined;
+ const first = try std.fmt.bufPrint(&want, "{d} 0 {d} {d} notcurrent full {d}\n", .{ first_serial, p.col_x[0], p.col_w[0], p.panes[0].?.serial });
+ try testing.expect(std.mem.startsWith(u8, text, first));
+ // Newcol gives the new, empty column's tag the keyboard.
+ try testing.expect(std.mem.indexOf(u8, text, "current empty\n") != null);
+ try testing.expect(std.mem.endsWith(u8, text, try std.fmt.bufPrint(&want, "active {d}\n", .{new_serial})));
+ try testing.expectEqual(@as(u64, text.len), th.call(p, .{ .tag = 1, .op = .getattr, .node = layout_node }).reply.attr.size);
+ // /index says which column each pane is in, by its serial, last.
+ try testing.expect(std.mem.endsWith(u8, th.rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes, try std.fmt.bufPrint(&want, " {d}\n", .{first_serial})));
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "newcol {d}\n", .{new_serial})));
+ // A column that closes takes its name with it; the others keep theirs.
+ try p.removeColumn(1);
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "delcol {d}\n", .{new_serial})));
+ try testing.expectEqual(E.NOENT, th.look_up(p, @intFromEnum(tree.TopFile.col), try std.fmt.bufPrint(&want, "{d}", .{new_serial})).errno());
+ try testing.expectEqual(first_serial, layout.columnSerial(p, 0));
+}
+
+test "a column's tag and the workspace's read, replace and append as a pane tag does, on one line" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const col_dir = @intFromEnum(tree.TopFile.col);
+ const serial = layout.columnSerial(p, 0);
+ var name: [16]u8 = undefined;
+ try testing.expectEqual(tree.Status.ok, th.look_up(p, col_dir, try std.fmt.bufPrint(&name, "{d}", .{serial})).reply.status);
+ try testing.expectEqual(E.NOENT, th.look_up(p, col_dir, "0").errno());
+ try testing.expectEqual(E.NOENT, th.look_up(p, col_dir, "777").errno());
+ const tag = Node.ofCol(serial, .tag);
+ try testing.expectEqual(tree.Status.ok, th.look_up(p, Node.ofCol(serial, .dir), "tag").reply.status);
+ try testing.expectEqualStrings("New Tty Find Grep Joincol Delcol\n", th.rd(p, tag, 0, 4096).bytes);
+ // `echo Make > tag`: cleared, then the newline ending the write dropped.
+ _ = th.call(p, .{ .tag = 1, .op = .setattr, .node = tag, .truncate = true });
+ _ = th.wr(p, tag, "Make\n");
+ try testing.expectEqualStrings("Make\n", th.rd(p, tag, 0, 4096).bytes);
+ _ = th.call(p, .{ .tag = 2, .op = .release, .node = tag });
+ // `>>` appends; a newline in a one-line tag is a space.
+ _ = th.wr(p, tag, " Lint\nNow");
+ try testing.expectEqualStrings("Make Lint Now\n", th.rd(p, tag, 0, 4096).bytes);
+ const top = @intFromEnum(tree.TopFile.tag);
+ _ = th.call(p, .{ .tag = 3, .op = .setattr, .node = top, .truncate = true });
+ _ = th.wr(p, top, "Newcol Exit\n");
+ try testing.expectEqualStrings("Newcol Exit", tagline.topbar(p));
+}
+
+test "a focus write makes its pane's column the active one: layout says so and pane/new goes there" {
+ for ([_][]const u8{ "Placement acme", "Placement pardes" }) |placement| {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ try testing.expect(p.executeBuiltinLine(p.active, placement));
+ const left = p.panes[0].?.serial;
+ p.newColumn(p.active);
+ p.newScratchFor(p.active, .fs); // fills the new, right column
+ p.sync();
+ try testing.expectEqual(@as(usize, 1), layout.findPane(p, p.active).?.col);
+ var digits: [16]u8 = undefined;
+ _ = th.wr(p, @intFromEnum(tree.TopFile.focus), try std.fmt.bufPrint(&digits, "{d}\n", .{left}));
+ var want: [32]u8 = undefined;
+ const text = th.rd(p, @intFromEnum(tree.TopFile.layout), 0, 4096).bytes;
+ try testing.expect(std.mem.endsWith(u8, text, try std.fmt.bufPrint(&want, "active {d}\n", .{layout.columnSerial(p, 0)})));
+ const made = th.call(p, .{ .tag = 1, .op = .open, .node = @intFromEnum(tree.TopFile.new) }).reply.handle;
+ try testing.expectEqual(@as(usize, 0), layout.findPane(p, p.paneBySerial(made).?).?.col);
+ }
+}
+
+test "a column's ctl and exec act on it as its tag would, and rmdir closes it only empty" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const first = layout.columnSerial(p, 0);
+ p.newColumn(p.active);
+ const empty = layout.columnSerial(p, 1);
+ // New on the empty column's ctl fills it, wherever the keyboard is.
+ try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(empty, .ctl), "New\n").reply.status);
+ try testing.expectEqual(@as(usize, 1), p.col_n[1]);
+ try testing.expectEqual(E.INVAL, th.wr(p, Node.ofCol(empty, .ctl), "Exit\n").errno());
+ // rmdir refuses a column with a pane, and says why.
+ const full = th.call(p, .{ .tag = 1, .op = .release, .node = Node.ofCol(empty, .dir), .remove = true });
+ try testing.expectEqual(E.NOTEMPTY, full.errno());
+ try testing.expect(th.logHas(p, "column not empty"));
+ // exec runs a word as a click in that column's tag: Delcol closes it.
+ try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(empty, .exec), "Delcol\n").reply.status);
+ try testing.expectEqual(@as(?usize, null), layout.columnBySerial(p, empty));
+ // An empty column goes on rmdir.
+ p.newColumn(p.active);
+ const made = layout.columnSerial(p, 1);
+ try testing.expectEqual(tree.Status.ok, th.call(p, .{ .tag = 2, .op = .release, .node = Node.ofCol(made, .dir), .remove = true }).reply.status);
+ try testing.expectEqual(@as(?usize, null), layout.columnBySerial(p, made));
+ try testing.expectEqual(first, layout.columnSerial(p, 0));
+ // Joincol folds a column into the one on its right, which keeps its own
+ // serial and tag.
+ p.newColumn(p.active);
+ const right = layout.columnSerial(p, 1);
+ _ = th.wr(p, Node.ofCol(right, .tag), " Mine");
+ try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(first, .ctl), "Joincol\n").reply.status);
+ try testing.expectEqual(@as(?usize, null), layout.columnBySerial(p, first));
+ try testing.expectEqual(@as(?usize, 0), layout.columnBySerial(p, right));
+ try testing.expect(std.mem.endsWith(u8, headerText(p, right).?, " Mine"));
+ // The root's ctl points a column word at the column's own.
+ try testing.expect(std.mem.endsWith(u8, th.wr(p, @intFromEnum(tree.TopFile.ctl), "Delcol\n").reply.ename, "write it to col/<serial>/ctl"));
+}
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);
+ }
+}
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index 0e489be7..ccc61352 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -172,6 +172,10 @@ pub fn noteError(p: *Pardes, req: Req, reply: Reply) void {
if (req.node != pardes.filesystem.os_root and req.node & pardes.filesystem.os_node == 0 and req.node & tree.sources.archive_node == 0) {
if (tree.Node.target(req.node)) |t| switch (t) {
.top => |f| file = f.fileName(),
+ .col => |c| {
+ serial = "-";
+ file = std.fmt.bufPrint(&name, "col/{d}/tag", .{c.serial}) catch "col";
+ },
.pane => |pt| {
serial = std.fmt.bufPrint(&name, "{d}", .{pt.serial}) catch "-";
file = switch (pt.file) {
@@ -940,7 +944,9 @@ test "a Dump written and a Restore made are in the log, with their files" {
const log = try freezeLog(restored);
defer restored.gpa.free(log.bytes);
var want: [64]u8 = undefined;
- try testing.expect(std.mem.endsWith(u8, log.bytes, try std.fmt.bufPrint(&want, "restore /tmp/pardes.dump.zon\nrestored {d} {d}\n", .{ p.panes[0].?.serial, restored.panes[0].?.serial })));
+ try testing.expect(std.mem.indexOf(u8, log.bytes, try std.fmt.bufPrint(&want, "restore /tmp/pardes.dump.zon\nrestored {d} {d}\n", .{ p.panes[0].?.serial, restored.panes[0].?.serial })) != null);
+ // ...and each column's: restoredcol <old> <new>.
+ try testing.expect(std.mem.endsWith(u8, log.bytes, try std.fmt.bufPrint(&want, "restoredcol {d} {d}\n", .{ pardes.layout.columnSerial(p, 0), pardes.layout.columnSerial(restored, 0) })));
}
test "opens of the log share the open records, and a closed one frees its record" {
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index 13381066..c594c7a3 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -63,6 +63,11 @@ pub const State = struct {
lock: ?u32 = null,
/// The host started a shell it could not teach to mark its prompts.
unmarked: bool = false,
+ /// `tag` was truncated (`echo X > tag`): until that open is released,
+ /// the newline that ends a write is held back, so the one that ends the
+ /// whole of it is dropped and draws no empty row.
+ tag_rewrite: bool = false,
+ tag_held_newline: bool = false,
/// The pty's size as pty/ctl's winsize last set it, until the pane's own
/// grid resizes and gives the pty its size again.
winsize: ?[2]u16 = null,
@@ -260,6 +265,13 @@ pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 {
// ---- the index ----
+/// The serial of the column a pane is in, as /layout and /col name it; the
+/// last word of its /index line.
+pub fn columnOf(p: *Pardes, id: usize) u32 {
+ const at = pardes.layout.findPane(p, id) orelse return 0;
+ return pardes.layout.columnSerial(p, at.col);
+}
+
pub fn indexLen(p: *Pardes) u64 {
var n: u64 = 0;
var last: u32 = 0;
@@ -269,6 +281,7 @@ pub fn indexLen(p: *Pardes) u64 {
var digits: [16]u8 = undefined;
n += (std.fmt.bufPrint(&digits, "{d}", .{serial}) catch unreachable).len;
n += 1 + kindOf(pane).len + 3 + nameOf(pane).len + 1;
+ n += 1 + (std.fmt.bufPrint(&digits, "{d}", .{columnOf(p, p.paneBySerial(serial).?)}) catch unreachable).len;
}
return n;
}
@@ -427,11 +440,23 @@ fn writeTag(req: Req, pane: *Pane) Reply {
if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 };
// The tag's own text grows by what is written, newlines and all: a tag
// is a text like any other (acme's tag file appends the same way).
+ const pf = &pane.fs;
const had = tagline.curTail(pane).len;
- const room = limits.max_tag_tail -| had;
+ const room = limits.max_tag_tail -| had -| @intFromBool(pf.tag_held_newline);
if (room == 0) return Reply.fail(req.tag, E.NOSPC);
const take = wholeUtf8(req.data[0..@min(req.data.len, room)]);
- pane.appendTag(req.data[0..take]) catch return Reply.fail(req.tag, E.NOMEM);
+ // A truncating write drops ONE trailing newline, its whole text's: a
+ // newline held from a write before goes in once more text follows it.
+ if (pf.tag_held_newline) {
+ pane.appendTag("\n") catch return Reply.fail(req.tag, E.NOMEM);
+ pf.tag_held_newline = false;
+ }
+ var text = req.data[0..take];
+ if (pf.tag_rewrite and text.len > 0 and text[text.len - 1] == '\n') {
+ text = text[0 .. text.len - 1];
+ pf.tag_held_newline = true;
+ }
+ pane.appendTag(text) catch return Reply.fail(req.tag, E.NOMEM);
return .{ .tag = req.tag, .written = @intCast(take) };
}
@@ -544,7 +569,10 @@ fn writeName(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
const name = std.mem.trim(u8, req.data, " \t\r\n");
if (name.len == 0) return Reply.fail(req.tag, E.INVAL);
for (name) |c| if (c < ' ') return Reply.fail(req.tag, E.INVAL);
- if (fileOf(pane) == null) return Reply.fail(req.tag, E.PERM);
+ if (fileOf(pane) == null) return tree.failText(req.tag, E.PERM, if (pane.isTerminal())
+ "rename not allowed: a terminal is named by its shell's directory; cd there, or Tty in another"
+ else
+ "rename not allowed: an image or PDF is named by the file it shows");
const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ pardes.Pardes.paneDir(pane), name }) catch
return Reply.fail(req.tag, E.NOMEM);
if (!std.fs.path.isAbsolute(full) or full.len >= 4096) return Reply.fail(req.tag, E.INVAL);
@@ -648,6 +676,8 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status {
pane.tag.cur_col = if (tagline.pathPrefix(p.scratch.allocator(), pane)) |prefix| @intCast(prefix.len) else |_| 0;
pane.tag.vsel.active = false;
pane.tag.nsel = 0;
+ pf.tag_rewrite = true;
+ pf.tag_held_newline = false;
},
// A shell's `>` truncates before it writes: the address written
// is evaluated from where the last one left off, as with `>>`.
@@ -681,7 +711,7 @@ test "index lists serial kind dirty flag and name and stat reports its length" {
const a = rd(p, index, 0, 4096);
try testing.expectEqual(Status.ok, a.reply.status);
var want: [64]u8 = undefined;
- const clean_row = try std.fmt.bufPrint(&want, "{d} text 0 /test.txt\n", .{pane.serial});
+ const clean_row = try std.fmt.bufPrint(&want, "{d} text 0 /test.txt {d}\n", .{ pane.serial, columnOf(p, 0) });
try testing.expect(std.mem.indexOf(u8, a.bytes, clean_row) != null);
const at = std.mem.indexOf(u8, a.bytes, clean_row).?;
const stat = call(p, .{ .tag = 1, .op = .getattr, .node = index });
@@ -690,7 +720,7 @@ test "index lists serial kind dirty flag and name and stat reports its length" {
pane.file.?.saved_revision = pane.file.?.revision -% 1;
var dirty_want: [64]u8 = undefined;
- const dirty_row = try std.fmt.bufPrint(&dirty_want, "{d} text 1 /test.txt\n", .{pane.serial});
+ const dirty_row = try std.fmt.bufPrint(&dirty_want, "{d} text 1 /test.txt {d}\n", .{ pane.serial, columnOf(p, 0) });
try testing.expect(std.mem.indexOf(u8, rd(p, index, 0, 4096).bytes, dirty_row) != null);
const kind = at + std.mem.indexOfScalar(u8, clean_row, ' ').? + 1;
try testing.expectEqualStrings("text", rd(p, index, kind, 4).bytes);
@@ -700,7 +730,7 @@ test "index lists serial kind dirty flag and name and stat reports its length" {
try testing.expectEqual(@as(u64, both.bytes.len), indexLen(p));
var expected: [4200]u8 = undefined;
const made = p.panes[p.paneBySerial(serial).?].?;
- try testing.expect(std.mem.endsWith(u8, both.bytes, try std.fmt.bufPrint(&expected, "\n{d} text 0 {s}\n", .{ serial, nameOf(made) })));
+ try testing.expect(std.mem.endsWith(u8, both.bytes, try std.fmt.bufPrint(&expected, "\n{d} text 0 {s} {d}\n", .{ serial, nameOf(made), columnOf(p, p.paneBySerial(serial).?) })));
}
test "a terminal is listed as term with its directory as name" {
@@ -712,10 +742,12 @@ test "a terminal is listed as term with its directory as name" {
try testing.expect(std.mem.indexOf(
u8,
rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes,
- try std.fmt.bufPrint(&want, "{d} term 0 /work/dir\n", .{pane.serial}),
+ try std.fmt.bufPrint(&want, "{d} term 0 /work/dir {d}\n", .{ pane.serial, columnOf(p, 0) }),
) != null);
try testing.expectEqualStrings("/work/dir\n", rd(p, Node.of(pane.serial, .name), 0, 4096).bytes);
- try testing.expectEqual(E.PERM, wr(p, Node.of(pane.serial, .name), "/elsewhere\n").errno());
+ const refused = wr(p, Node.of(pane.serial, .name), "/elsewhere\n");
+ try testing.expectEqual(E.PERM, refused.errno());
+ try testing.expect(std.mem.startsWith(u8, refused.reply.ename, "rename not allowed: a terminal is named"));
}
test "body reads at any offset and writes append" {
@@ -977,6 +1009,19 @@ test "truncating the tag clears its editable tail" {
// and a click on the G puts the tag's cursor on it
const at = tagline.tagOffsetAt(p, pane, 0, "/test.txt ".len).?;
try testing.expectEqualStrings("Get", whole[at.col..]);
+
+ // `echo Make > tag`: the newline ending the truncating write's text is
+ // dropped, one only; a newline between its writes stays.
+ _ = call(p, .{ .tag = 3, .op = .release, .node = tag });
+ _ = call(p, .{ .tag = 4, .op = .setattr, .node = tag, .truncate = true });
+ _ = wr(p, tag, "Make\n");
+ try testing.expectEqualStrings("Make", pane.tag.own.?);
+ _ = wr(p, tag, "More\n\n");
+ try testing.expectEqualStrings("Make\nMore\n", pane.tag.own.?);
+ _ = call(p, .{ .tag = 5, .op = .release, .node = tag });
+ // `>>` keeps what it is given.
+ _ = wr(p, tag, " Tail\n");
+ try testing.expectEqualStrings("Make\nMore\n Tail\n", pane.tag.own.?);
}
test "name reads the file name and writing it promotes a scratch without touching its body" {
diff --git a/src/ninep/screen.zig b/src/ninep/screen.zig
index 462ea40f..f496da03 100644
--- a/src/ninep/screen.zig
+++ b/src/ninep/screen.zig
@@ -146,7 +146,7 @@ test "terminal body handles keep one history snapshot across fragmented reads" {
try testing.expectEqualStrings("", call(p, .{ .tag = 4, .op = .read, .node = node, .handle = opened.reply.handle, .off = original.len, .size = 3 }).bytes);
_ = call(p, .{ .tag = 5, .op = .release, .node = node, .handle = opened.reply.handle });
const newer = call(p, .{ .tag = 6, .op = .open, .node = node });
- try testing.expectEqualStrings("new output", call(p, .{ .tag = 7, .op = .read, .node = node, .handle = newer.reply.handle, .size = 32 }).bytes);
+ try testing.expectEqualStrings("new output\n", call(p, .{ .tag = 7, .op = .read, .node = node, .handle = newer.reply.handle, .size = 32 }).bytes);
_ = call(p, .{ .tag = 8, .op = .release, .node = node, .handle = newer.reply.handle });
}
@@ -162,7 +162,7 @@ test "an empty terminal body snapshot stays empty while output continues" {
try testing.expectEqualStrings("", call(p, .{ .tag = 3, .op = .read, .node = node, .handle = opened.reply.handle, .size = 32 }).bytes);
_ = call(p, .{ .tag = 4, .op = .release, .node = node, .handle = opened.reply.handle });
const newer = call(p, .{ .tag = 5, .op = .open, .node = node });
- try testing.expectEqualStrings("new output", call(p, .{ .tag = 6, .op = .read, .node = node, .handle = newer.reply.handle, .size = 32 }).bytes);
+ try testing.expectEqualStrings("new output\n", call(p, .{ .tag = 6, .op = .read, .node = node, .handle = newer.reply.handle, .size = 32 }).bytes);
_ = call(p, .{ .tag = 7, .op = .release, .node = node, .handle = newer.reply.handle });
}
@@ -194,13 +194,13 @@ test "terminal body snapshots are lazy bounded and released after the pane close
try testing.expectEqual(E.NOMEM, failed.errno());
try testing.expect(p.fs.opens[handles[0] - 1].what.snapshot == null);
const first = call(p, .{ .tag = 4, .op = .read, .node = node, .handle = handles[0], .size = 32 });
- try testing.expectEqualStrings("latest output", first.bytes);
+ try testing.expectEqualStrings("latest output\n", first.bytes);
const saved = p.fs.opens[handles[0] - 1].what.snapshot.?;
failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 });
p.gpa = failing.allocator();
const second = call(p, .{ .tag = 5, .op = .read, .node = node, .handle = handles[0], .off = 7, .size = 32 });
p.gpa = gpa;
- try testing.expectEqualStrings("output", second.bytes);
+ try testing.expectEqualStrings("output\n", second.bytes);
try testing.expect(!failing.has_induced_failure);
try testing.expectEqual(saved.ptr, p.fs.opens[handles[0] - 1].what.snapshot.?.ptr);
try testing.expectEqual(E.INVAL, call(p, .{ .tag = 6, .op = .read, .node = screen_node, .handle = handles[0], .size = 32 }).errno());
@@ -328,3 +328,17 @@ test "screen inspection preserves acknowledged presentation and the next real fr
}
}
}
+
+test "a terminal's body is its logical lines: a row the terminal wrapped is joined again" {
+ const p = try th.withTerm(testing.allocator);
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const long = "w" ** 50 ++ "x" ** 150; // wider than the pane: it wraps
+ try testing.expect(long.len > pane.cols);
+ p.update(.{ .output = .{ .pane = 0, .bytes = long ++ "\r\nnext" } });
+ while (p.nextEffect()) |_| {}
+ const node = Node.of(pane.serial, .body);
+ const body = th.rd(p, node, 0, 4096).bytes;
+ try testing.expectEqualStrings(long ++ "\nnext\n", body);
+ try testing.expectEqual(@as(u64, body.len), th.call(p, .{ .tag = 1, .op = .getattr, .node = node }).reply.attr.size);
+}
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index ad95f6bf..baf8c772 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -18,6 +18,8 @@ pub const events = @import("events.zig");
pub const pane = @import("pane.zig");
pub const addr = @import("addr.zig");
pub const ctl = @import("ctl.zig");
+pub const cols = @import("cols.zig");
+const layout = pardes.layout;
pub const pty = @import("pty.zig");
pub const sources = @import("sources.zig");
pub const screen = @import("screen.zig");
@@ -68,9 +70,11 @@ pub const e_shut_down = "window shut down";
/// Tremove closes a pane; nothing else in the tree is created or destroyed by
/// the protocol, and wstat stays a truncation. Making a pane is an open of
-/// /pane/new, which needs no feature of the engine's, so Tcreate is refused
-/// everywhere, as it is in acme (editors/acme/fsys.c, fsyscreate).
-pub const features: cloud9.fs.Features = .{ .remove = true };
+/// /pane/new, so Tcreate is refused everywhere, as it is in acme
+/// (editors/acme/fsys.c, fsyscreate) -- here rather than by the engine, so
+/// a create in a pane that has closed says the pane is gone (a shell's `>`
+/// to a closed pane's file creates when its lookup fails).
+pub const features: cloud9.fs.Features = .{ .remove = true, .create = true };
/// Whether a request must wait until no step is out in a syscall
/// (`pardes.turn`): what would change a pane such a step still points into.
@@ -169,7 +173,7 @@ pub fn openOf(p: *Pardes, req: Req) ?*Open {
// ---- nodes ----
-pub const TopFile = enum(u4) {
+pub const TopFile = enum(u5) {
root = 1,
README,
index,
@@ -184,6 +188,9 @@ pub const TopFile = enum(u4) {
focus,
ctl,
commands,
+ layout,
+ tag,
+ col,
pub fn fileName(f: TopFile) []const u8 {
return if (f == .root) "/" else @tagName(f);
@@ -191,14 +198,14 @@ pub const TopFile = enum(u4) {
pub fn mode(f: TopFile) u16 {
return switch (f) {
- .root, .pane => 0o755,
- .look, .exec, .log, .focus, .ctl => 0o666,
- .README, .index, .status, .screen, .listeners, .new, .commands => 0o444,
+ .root, .pane, .col => 0o755,
+ .look, .exec, .log, .focus, .ctl, .tag => 0o666,
+ .README, .index, .status, .screen, .listeners, .new, .commands, .layout => 0o444,
};
}
pub fn dir(f: TopFile) bool {
- return f == .root or f == .pane;
+ return f == .root or f == .pane or f == .col;
}
};
@@ -262,6 +269,21 @@ pub const PaneFile = enum(u5) {
}
};
+/// /col/<serial> and its tag: a column is named by its serial, which stays
+/// while it lives as a pane's does; this bit, past a pane serial's 32, keeps
+/// the two apart.
+pub const ColFile = enum(u5) {
+ dir = 0,
+ tag = 1,
+ ctl = 2,
+ exec = 3,
+
+ pub fn fileName(f: ColFile) []const u8 {
+ return @tagName(f);
+ }
+};
+const col_bit: u59 = 1 << 58;
+
pub const Node = packed struct(u64) {
file: u5 = 0,
serial: u59 = 0,
@@ -271,11 +293,19 @@ pub const Node = packed struct(u64) {
return @bitCast(Node{ .file = @intFromEnum(file), .serial = serial });
}
+ pub fn ofCol(serial: u32, file: ColFile) u64 {
+ return @bitCast(Node{ .file = @intFromEnum(file), .serial = col_bit | @as(u59, serial) });
+ }
+
pub fn target(node: u64) ?Target {
const n: Node = @bitCast(node);
if (n.serial == 0) {
return .{ .top = std.enums.fromInt(TopFile, n.file) orelse return null };
}
+ if (n.serial & col_bit != 0) return .{ .col = .{
+ .serial = std.math.cast(u32, n.serial & ~col_bit) orelse return null,
+ .file = std.enums.fromInt(ColFile, n.file) orelse return null,
+ } };
return .{ .pane = .{
.serial = std.math.cast(u32, n.serial) orelse return null,
.file = std.enums.fromInt(PaneFile, n.file) orelse return null,
@@ -286,6 +316,7 @@ pub const Node = packed struct(u64) {
pub const Target = union(enum) {
top: TopFile,
pane: struct { serial: u32, file: PaneFile },
+ col: struct { serial: u32, file: ColFile },
};
/// Opening /pane/new makes a pane and reading the open fid answers its
@@ -371,6 +402,11 @@ pub fn stagedReply(p: *Pardes, req: Req) Reply {
// ---- dispatch ----
pub fn handle(p: *Pardes, req: Req) Reply {
+ if (req.op == .open and req.create) {
+ if (Node.target(req.node)) |t| if (t == .pane and p.paneBySerial(t.pane.serial) == null)
+ return Reply.fail(req.tag, E.NOENT);
+ return Reply.fail(req.tag, E.PERM);
+ }
const host = req.node == fs.os_root or req.node & fs.os_node != 0;
const archive = req.node & sources.archive_node != 0;
// Only a pane directory is removed. The host tree and the embedded
@@ -381,7 +417,7 @@ pub fn handle(p: *Pardes, req: Req) Reply {
const target = Node.target(req.node) orelse return Reply.fail(req.tag, E.NOENT);
if (req.op == .write or req.op == .setattr) p.fs.origin = switch (target) {
.pane => |t| @as(u8, if (t.file == .body or t.file == .tag) 'E' else 'F'),
- .top => 'F',
+ .top, .col => 'F',
};
return switch (req.op) {
.lookup => lookup(p, req, target),
@@ -403,7 +439,7 @@ pub fn handle(p: *Pardes, req: Req) Reply {
fn runAttr(p: *Pardes, req: Req, target: Target) ?Reply.Attr {
const t = switch (target) {
.pane => |t| t,
- .top => return null,
+ .top, .col => return null,
};
if (t.file != .pty_run) return null;
const run = if (req.handle != 0) openOf(p, req) else for (&p.fs.opens) |*o| {
@@ -422,6 +458,23 @@ fn attrOf(p: *Pardes, target: Target) ?Reply.Attr {
.mode = f.mode(),
.size = topSize(p, f),
.mtime = p.fs.started,
+ .append = f == .tag,
+ },
+ .col => |c| {
+ if (layout.columnBySerial(p, c.serial) == null) return null;
+ return .{
+ .name = if (c.file == .dir) (std.fmt.bufPrint(&p.fs.node_name, "{d}", .{c.serial}) catch unreachable) else c.file.fileName(),
+ .node = Node.ofCol(c.serial, c.file),
+ .dir = c.file == .dir,
+ .mode = switch (c.file) {
+ .dir => 0o755,
+ .ctl, .exec => 0o222,
+ .tag => 0o666,
+ },
+ .size = if (c.file == .tag) cols.headerText(p, c.serial).?.len + 1 else 0,
+ .mtime = p.fs.started,
+ .append = c.file == .tag,
+ };
},
.pane => |t| {
const id = p.paneBySerial(t.serial) orelse return null;
@@ -454,7 +507,9 @@ fn topSize(p: *Pardes, f: TopFile) u64 {
return switch (f) {
// ponytail: /screen has no length until an open renders its frame,
// and /pane/new none until an open has a pane to name.
- .root, .pane, .screen, .new => 0,
+ .root, .pane, .col, .screen, .new => 0,
+ .layout => cols.layoutLen(p),
+ .tag => cols.headerText(p, null).?.len + 1,
.index => pane.indexLen(p),
.README => fs.help.len,
.status => ctl.statusLen(p),
@@ -485,6 +540,7 @@ fn lookup(p: *Pardes, req: Req, target: Target) Reply {
.pty => Node.of(t.serial, .dir),
else => return Reply.fail(req.tag, E.NOTDIR),
},
+ .col => |c| if (c.file == .dir) @intFromEnum(TopFile.col) else return Reply.fail(req.tag, E.NOTDIR),
};
return handle(p, .{ .tag = req.tag, .op = .getattr, .node = parent });
}
@@ -507,8 +563,22 @@ fn lookup(p: *Pardes, req: Req, target: Target) Reply {
if (serial == 0) return Reply.fail(req.tag, E.NOENT);
break :pane Node.of(serial, .dir);
},
+ // A column is its serial, in decimal digits and never 0.
+ .col => col: {
+ if (name.len > 10) return Reply.fail(req.tag, E.NOENT);
+ for (name) |c| if (c < '0' or c > '9') return Reply.fail(req.tag, E.NOENT);
+ const serial = std.fmt.parseInt(u32, name, 10) catch return Reply.fail(req.tag, E.NOENT);
+ if (serial == 0) return Reply.fail(req.tag, E.NOENT);
+ break :col Node.ofCol(serial, .dir);
+ },
else => return Reply.fail(req.tag, E.NOTDIR),
},
+ .col => |c| col: {
+ if (c.file != .dir) return Reply.fail(req.tag, E.NOTDIR);
+ const f = std.meta.stringToEnum(ColFile, name) orelse return Reply.fail(req.tag, E.NOENT);
+ if (f == .dir) return Reply.fail(req.tag, E.NOENT);
+ break :col Node.ofCol(c.serial, f);
+ },
.pane => |t| pane: {
_ = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT);
const f = switch (t.file) {
@@ -548,7 +618,7 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply {
.root => {
// The guide, then what describes the session, then the
// files that act, then the directories.
- inline for (.{ TopFile.README, TopFile.index, TopFile.status, TopFile.focus, TopFile.ctl, TopFile.commands, TopFile.look, TopFile.exec, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.pane }) |t| {
+ inline for (.{ TopFile.README, TopFile.index, TopFile.status, TopFile.focus, TopFile.ctl, TopFile.commands, TopFile.look, TopFile.exec, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.layout, TopFile.tag, TopFile.pane, TopFile.col }) |t| {
if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(t), t.dir(), t.fileName());
}
if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, fs.os_root, true, "os");
@@ -568,8 +638,24 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply {
stageDirent(out, p.gpa, Node.of(serial, .dir), true, name);
}
},
+ .col => for (0..p.ncol) |c| {
+ if (skip > 0) {
+ skip -= 1;
+ continue;
+ }
+ var buf: [16]u8 = undefined;
+ const serial = layout.columnSerial(p, c);
+ stageDirent(out, p.gpa, Node.ofCol(serial, .dir), true, std.fmt.bufPrint(&buf, "{d}", .{serial}) catch unreachable);
+ },
else => return Reply.fail(req.tag, E.NOTDIR),
},
+ .col => |c| {
+ if (layout.columnBySerial(p, c.serial) == null) return Reply.fail(req.tag, E.NOENT);
+ if (c.file != .dir) return Reply.fail(req.tag, E.NOTDIR);
+ inline for (.{ ColFile.tag, ColFile.ctl, ColFile.exec }) |f| {
+ if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, Node.ofCol(c.serial, f), false, f.fileName());
+ }
+ },
.pane => |t| {
const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT);
const terminal = p.panes[id].?.isTerminal();
@@ -594,7 +680,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
// pane it made, so reading it twice answers the same one and
// closing it leaves the pane alone.
.new => {
- const slot = p.freeSlot() orelse return Reply.fail(req.tag, E.NFILE);
+ const slot = p.freeSlot() orelse return failText(req.tag, E.NFILE, std.fmt.bufPrint(&p.fs.ename, "pane/new: " ++ pardes.Messages.no_pane_slots, .{pardes.MAX_PANES}) catch "no pane slots");
p.newScratchFor(p.active, .fs);
const made = p.panes[slot] orelse return Reply.fail(req.tag, E.NFILE);
return .{ .tag = req.tag, .handle = made.serial };
@@ -603,6 +689,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
.log => .{ .log = .{} },
else => return .{ .tag = req.tag },
},
+ .col => |c| return if (layout.columnBySerial(p, c.serial) != null) .{ .tag = req.tag } else Reply.fail(req.tag, E.NOENT),
.pane => |t| what: {
const pn = p.panes[p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT)].?;
if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT);
@@ -659,6 +746,7 @@ fn remove(p: *Pardes, req: Req) Reply {
const t = switch (target) {
.pane => |t| t,
.top => return Reply.fail(req.tag, E.PERM),
+ .col => |c| return cols.remove(p, req, c.serial, c.file),
};
if (t.file != .dir) return Reply.fail(req.tag, E.PERM);
const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT);
@@ -676,8 +764,16 @@ fn release(p: *Pardes, req: Req) Reply {
/// its releases before the connection's slot is reused, so none outlives
/// the connection it came on.
fn releaseHandle(p: *Pardes, req: Req) void {
- const o = openOf(p, req) orelse return;
const t = Node.target(req.node).?;
+ // A truncating write to `tag` is over when its open goes (pane.zig
+ // writeTag): a newline it held back is dropped.
+ if (t == .pane and t.pane.file == .tag) if (p.paneBySerial(t.pane.serial)) |id| {
+ p.panes[id].?.fs.tag_rewrite = false;
+ p.panes[id].?.fs.tag_held_newline = false;
+ };
+ if (t == .col and t.col.file == .tag) cols.released(p, t.col.serial);
+ if (t == .top and t.top == .tag) cols.released(p, null);
+ const o = openOf(p, req) orelse return;
const of: ?*Pane = if (t == .pane) (if (p.paneBySerial(t.pane.serial)) |id| p.panes[id] else null) else null;
if (of) |pn| switch (o.what) {
.event => |reader| {
@@ -712,7 +808,8 @@ fn setattr(p: *Pardes, req: Req, target: Target) Reply {
return failText(req.tag, E.INVAL, pane.e_addr_failed);
if (pane.truncate(p, p.panes[id].?, t.file) != .ok) return Reply.fail(req.tag, E.NOMEM);
},
- .top => {},
+ .col => |c| if (c.file == .tag and cols.truncate(p, c.serial) != .ok) return Reply.fail(req.tag, E.NOENT),
+ .top => |f| if (f == .tag and cols.truncate(p, null) != .ok) return Reply.fail(req.tag, E.NOMEM),
};
return .{ .tag = req.tag, .attr = attrOf(p, target) orelse return Reply.fail(req.tag, E.NOENT) };
}
@@ -757,8 +854,9 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply {
var last: u32 = 0;
while (nextSerialAfter(p, last)) |serial| {
last = serial;
- const pn = p.panes[p.paneBySerial(serial).?].?;
- out.print(p.gpa, "{d} {s} {d} {s}\n", .{ serial, pane.kindOf(pn), @intFromBool(pane.dirtyOf(pn)), pane.nameOf(pn) }) catch {};
+ const id = p.paneBySerial(serial).?;
+ const pn = p.panes[id].?;
+ out.print(p.gpa, "{d} {s} {d} {s} {d}\n", .{ serial, pane.kindOf(pn), @intFromBool(pane.dirtyOf(pn)), pane.nameOf(pn), pane.columnOf(p, id) }) catch {};
}
break :index stagedReply(p, req);
},
@@ -770,6 +868,13 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply {
.focus => ctl.readFocus(p, req),
.ctl => ctl.readRoot(p, req),
.commands => ctl.readCommands(p, req),
+ .layout => layout: {
+ var text: std.Io.Writer.Allocating = .init(p.scratch.allocator());
+ cols.writeLayout(p, &text.writer) catch break :layout Reply.fail(req.tag, E.NOMEM);
+ p.fs.stage(p.gpa).appendSlice(p.gpa, text.written()) catch break :layout Reply.fail(req.tag, E.NOMEM);
+ break :layout stagedReply(p, req);
+ },
+ .tag => cols.read(p, req, null),
// 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: {
@@ -778,7 +883,7 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply {
break :serial Reply.fail(req.tag, E.NOMEM);
break :serial stagedReply(p, req);
},
- .root, .pane => Reply.fail(req.tag, E.PERM),
+ .root, .pane, .col => Reply.fail(req.tag, E.PERM),
},
.pane => |t| {
// A run's answer outlives the pane it ran in.
@@ -794,6 +899,7 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply {
if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT);
return pane.read(p, req, id, pn, t.file);
},
+ .col => |c| return if (c.file == .tag) cols.read(p, req, c.serial) else Reply.fail(req.tag, E.PERM),
}
}
@@ -804,6 +910,7 @@ fn write(p: *Pardes, req: Req, target: Target) Reply {
.log => events.writeLog(p, req),
.focus => ctl.writeFocus(p, req),
.ctl => ctl.writeRoot(p, req),
+ .tag => cols.write(p, req, null),
else => Reply.fail(req.tag, E.PERM),
},
.pane => |t| {
@@ -812,6 +919,12 @@ fn write(p: *Pardes, req: Req, target: Target) Reply {
if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT);
return pane.write(p, req, id, pn, t.file);
},
+ .col => |c| return switch (c.file) {
+ .tag => cols.write(p, req, c.serial),
+ .ctl => cols.writeCtl(p, req, c.serial),
+ .exec => cols.writeExec(p, req, c.serial),
+ .dir => Reply.fail(req.tag, E.PERM),
+ },
}
}
@@ -823,6 +936,7 @@ test {
_ = addr;
_ = ctl;
_ = pty;
+ _ = cols;
_ = sources;
_ = screen;
}
@@ -955,12 +1069,13 @@ test "readdir lists the root and a pane directory without creating anything" {
@memcpy(top_bytes[0..top_reply.bytes.len], top_reply.bytes);
const top = th.dirents(top_bytes[0..top_reply.bytes.len], &buf);
try testing.expect(top.len >= 10);
- for ([_][]const u8{ "README", "index", "status", "focus", "ctl", "commands", "look", "exec", "log", "screen", "listeners", "pane", "os" }, 0..) |name, i| {
+ for ([_][]const u8{ "README", "index", "status", "focus", "ctl", "commands", "look", "exec", "log", "screen", "listeners", "layout", "tag", "pane", "col", "os" }, 0..) |name, i| {
try testing.expectEqualStrings(name, top[i].name);
- try testing.expectEqual(i >= 11, top[i].dir);
+ try testing.expectEqual(i >= 13, top[i].dir);
}
- try testing.expectEqual(@intFromEnum(TopFile.pane), top[11].node);
- try testing.expectEqual(fs.os_root, top[12].node);
+ try testing.expectEqual(@intFromEnum(TopFile.pane), top[13].node);
+ try testing.expectEqual(@intFromEnum(TopFile.col), top[14].node);
+ try testing.expectEqual(fs.os_root, top[15].node);
var idbuf: [16]u8 = undefined;
try testing.expect(th.nameAt(top, try std.fmt.bufPrint(&idbuf, "{d}", .{serial})) == null);
try testing.expect(th.nameAt(top, "self") == null);
@@ -1099,10 +1214,11 @@ test "opening /pane/new makes a pane and removing one closes it" {
try testing.expect(p.paneBySerial(serial) != null);
try testing.expectEqualStrings("hi", p.panes[id].?.file.?.content);
- // The tree declares no create, so the engine refuses every Tcreate
- // (cloud9 fs.zig: `.tcreate => if (features.create) ... else fail(e_perm)`),
- // and `new` is the only name in /pane that is not a serial.
- try testing.expect(!features.create);
+ // Every create is refused, a pane's that has closed as gone, and `new`
+ // is the only name in /pane that is not a serial.
+ try testing.expectEqual(E.PERM, call(p, .{ .tag = 6, .op = .open, .node = panes_dir, .create = true, .data = "scratch" }).errno());
+ try testing.expectEqual(E.PERM, call(p, .{ .tag = 6, .op = .open, .node = Node.of(serial, .dir), .create = true, .data = "x" }).errno());
+ try testing.expectEqual(E.NOENT, call(p, .{ .tag = 6, .op = .open, .node = Node.of(999_999, .dir), .create = true, .data = "exec" }).errno());
try testing.expectEqual(E.NOENT, look_up(p, panes_dir, "scratch").errno());
try testing.expectEqual(E.NOENT, look_up(p, root, new_pane).errno());
// ...and it is listed, because the user asked to see it in `ls`.
@@ -1190,3 +1306,32 @@ test "editor paths resolve to the same nodes the wire serves" {
try testing.expect(resolveSelf(p, "pane/new/body") == null);
try testing.expectEqual(before, p.next_serial);
}
+
+test "at the pane cap, pane/new, look and New each say so, and look reads back empty" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const new = @intFromEnum(TopFile.new);
+ while (p.freeSlot() != null) try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .open, .node = new }).reply.status);
+ var want: [64]u8 = undefined;
+ const said = try std.fmt.bufPrint(&want, "no pane slots: {d} max", .{pardes.MAX_PANES});
+ const refused = call(p, .{ .tag = 2, .op = .open, .node = new });
+ try testing.expectEqual(E.NFILE, refused.errno());
+ try testing.expect(std.mem.endsWith(u8, refused.reply.ename, said));
+ try testing.expect(th.logHas(p, said));
+ // A look that would open a pane fails its write, and says the same.
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "f\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ var line: [4200]u8 = undefined;
+ const look_node = @intFromEnum(TopFile.look);
+ const looked = th.wr(p, look_node, try std.fmt.bufPrint(&line, "{s}/f.txt\n", .{dir}));
+ try testing.expect(looked.errno() != 0);
+ try testing.expect(std.mem.indexOf(u8, looked.reply.ename, said) != null);
+ try testing.expectEqualStrings("", th.rd(p, look_node, 0, 64).bytes);
+ // New from a pane's ctl fails as well.
+ const ctl_node = Node.of(p.panes[0].?.serial, .ctl);
+ const made = th.wr(p, ctl_node, "New\n");
+ try testing.expect(std.mem.indexOf(u8, made.reply.ename, said) != null);
+}
diff --git a/src/panes.zig b/src/panes.zig
index 8ca2bb4d..7aea5898 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -25,7 +25,7 @@ pub const EditText = struct { text: []u8, row0: i32 };
pub const Pane = struct {
/// The words that throw unsaved text away (builtins.zig warnModified).
- pub const Discarding = enum { Exit, Restore, get };
+ pub const Discarding = enum { Exit, Restore, get, Del, Delcol };
/// One mouse sweep, per button: the press (c0, r0) and where it is now
/// (c1, r1), a stream between them as acme's (edit.sweepCols). c/r are
@@ -135,6 +135,10 @@ pub const Pane = struct {
repl: ?struct { lang: u8, letter: u8 } = null,
/// It has been told that its program takes no bracketed paste.
repl_warned: bool = false,
+ /// Enters owed to a REPL after a bracketed paste (exec.sendToRepl): sent
+ /// with its next output, the paste taken, or at `repl_enter_due_ns`.
+ repl_enter: u8 = 0,
+ repl_enter_due_ns: u64 = 0,
/// A question with its answers in it (`repl_choice`).
question: [96]u8 = undefined,
question_len: u8 = 0,
diff --git a/src/pardes.zig b/src/pardes.zig
index 6294b6cb..54fe2df0 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -250,7 +250,11 @@ pub const pdf_raster_policy: PdfRasterPolicy = switch (platform) {
pub const Pane = panes.Pane;
const Text = panes.Text;
-pub const MAX_PANES = 16;
+/// Pane slots: 64 on a desktop, where a session keeps many files and
+/// terminals open; the board keeps its 16. A slot costs its share of the
+/// fixed tables (MAX_PANES-sized arrays in the core and its surface) whether
+/// or not a pane is in it; a pane itself is allocated when it opens.
+pub const MAX_PANES = if (platform == .esp32p4) 16 else 64;
pub const Layer = @import("Layer.zig");
pub const RowMetrics = Layer.Metrics;
pub const TagHit = Layer.TagHit;
@@ -1638,6 +1642,24 @@ test "a REPL bound for a language takes the exec made on a body in it; tags, Exe
_ = exec.executeFrom(p, 0, "a = 1\r\nb = \x1b[201~2", true);
Sent.drain(p);
try std.testing.expectEqualStrings("a = 1\rb = [201~2\r", Sent.said(1));
+ // Into a REPL that takes a bracketed paste (3.13's), the Enter waits
+ // for the REPL to answer the paste: read with it, it would be part of it.
+ p.update(.{ .output = .{ .pane = 1, .bytes = "\x1b[?2004h" } });
+ Sent.drain(p);
+ _ = exec.executeFrom(p, 0, "print(1)", true);
+ Sent.drain(p);
+ try std.testing.expectEqualStrings("\x1b[200~print(1)\x1b[201~", Sent.said(1));
+ p.update(.{ .output = .{ .pane = 1, .bytes = "print(1)" } });
+ Sent.drain(p);
+ try std.testing.expectEqualStrings("\r", Sent.said(1));
+ // Nothing answering, it goes when its wait is up.
+ _ = exec.executeFrom(p, 0, "print(2)", true);
+ p.stepped_ns += 300 * std.time.ns_per_ms;
+ p.update(.tick);
+ Sent.drain(p);
+ try std.testing.expect(std.mem.endsWith(u8, Sent.said(1), "\x1b[200~print(2)\x1b[201~\r"));
+ p.update(.{ .output = .{ .pane = 1, .bytes = "\x1b[?2004l" } });
+ Sent.len[1] = 0;
// A block left open by its indented last line gets the blank line that
// ends it: a second Enter.
_ = exec.executeFrom(p, 0, "def f():\n return 1", true);
@@ -1684,6 +1706,9 @@ test "a REPL bound for a language takes the exec made on a body in it; tags, Exe
_ = exec.executeFrom(p, 0, "print(5)", true);
var want: [32]u8 = undefined;
try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "ask {d} repl a b\n", .{file.serial})));
+ // A choice not offered is refused, and the question stands.
+ try std.testing.expectEqual(ctlfs.E.INVAL, th.wr(p, ctlfs.Node.of(file.serial, .ctl), "answer z\n").errno());
+ try std.testing.expectEqual(Pane.Prompt.repl_choice, file.prompt);
const answered = th.wr(p, ctlfs.Node.of(file.serial, .ctl), "answer a\n");
try std.testing.expectEqual(ctlfs.Status.ok, answered.reply.status);
try std.testing.expectEqualStrings("print(5)\r", answered.pty());
@@ -3462,6 +3487,11 @@ pub const Mouse = struct {
ctrl: bool = false,
body_hit: ?BodyHit = null,
tag_hit: ?TagHit = null,
+ /// A pixel frontend's press on the rule it draws at the top of this row,
+ /// which is where two panes meet if they do: the one grab that resizes
+ /// them there. A row of text never is one; a terminal, with no rule,
+ /// resizes by the grip.
+ rule_above: bool = false,
};
pub const Key = struct {
@@ -4260,6 +4290,12 @@ pub const Pardes = struct {
/// spring. `lift_serials` says whose it is, so a reused slot starts flat.
lifts: [MAX_PANES]animation.Spring = @splat(.{}),
lift_serials: [MAX_PANES]u32 = @splat(0),
+ /// The focused cursor's quad on its springs (animation.CursorGlide),
+ /// the pane and view origin it was last aimed in, and the last key or
+ /// click, which a blink holds for.
+ cursor_glide: animation.CursorGlide = .{},
+ cursor_view: struct { serial: u32 = 0, line: i64 = 0 } = .{},
+ input_ns: u64 = 0,
animate_theme_changes: bool = false,
native_images: bool = false,
/// The shell can take a changed image a few rows at a time (a texture row
@@ -4771,7 +4807,7 @@ pub const Pardes = struct {
/// A scratch for New (`.scratch`) or 9P's pane/new (`.fs`).
pub fn newScratchFor(p: *Pardes, from_id: usize, kind: exec.NewPane) void {
- const free = p.freeSlot() orelse return;
+ const free = p.freeSlot() orelse return p.reportError(from_id, "New", error.NoPaneSlots);
_ = p.newScratch(from_id, free) catch return;
exec.placeNew(p, from_id, from_id, free, kind);
}
@@ -5102,6 +5138,20 @@ pub const Pardes = struct {
p.fs.late_failure_len = @intCast(kept.len);
}
+ /// A Dump the host could not write: said on the message row and in the
+ /// log, and the 9P write that asked for it fails with it, as a failed
+ /// Save's does.
+ pub fn dumpFailed(p: *Pardes, path: []const u8, err: anyerror) void {
+ var what: [limits.host_path_cap + 8]u8 = undefined;
+ const operation = std.fmt.bufPrint(&what, "Dump {s}", .{path}) catch "Dump";
+ p.reportError(p.active, operation, err);
+ var buf: [256]u8 = undefined;
+ const said = std.fmt.bufPrint(&buf, "{s}: {s}", .{ operation, @errorName(err) }) catch "Dump failed";
+ const kept = @import("Messages.zig").clip(said, p.fs.late_failure.len);
+ @memcpy(p.fs.late_failure[0..kept.len], kept);
+ p.fs.late_failure_len = @intCast(kept.len);
+ }
+
/// The path a watch is about: a real file's, or a PDF's.
fn watchPath(p: *const Pardes, id: u8) ?[]const u8 {
const pane = p.panes[id] orelse return null;
@@ -5265,7 +5315,7 @@ pub const Pardes = struct {
p.raw_hover_intent = false;
look.cancelLookHover(p);
const reply = ctlfs.handle(p, req);
- if (reply.status == .err and (req.op == .write or req.op == .setattr)) ctlfs.events.noteError(p, req, reply);
+ if (reply.status == .err and (req.op == .write or req.op == .setattr or (req.op == .open and reply.errno == ctlfs.E.NFILE) or (req.op == .release and req.remove))) ctlfs.events.noteError(p, req, reply);
// The request was a whole step of its own, so it settles the way a
// step does: the cursor and scroll reconciled, the scripted panes
// told, and the panes it made announced to /log now rather than at
@@ -5311,12 +5361,16 @@ pub const Pardes = struct {
}
switch (ev) {
.key, .mouse => {
+ if (ev == .key or ev.mouse.kind != .motion) p.input_ns = p.now_ns;
for (p.panes) |slot| {
if (slot) |pane| Messages.dismissMessage(p, pane);
}
p.clip_pending = null;
},
- .tick => Messages.advanceMessages(p),
+ .tick => {
+ Messages.advanceMessages(p);
+ for (0..p.panes.len) |id| exec.flushReplEnter(p, id, false);
+ },
else => {},
}
if (p.fs.listeners != 0) p.fs.origin = switch (ev) {
@@ -5360,6 +5414,7 @@ pub const Pardes = struct {
ctlfs.events.notePtyOutput(p, o.pane, o.bytes);
panes.Terminal.feedOutput(p, pane, o.bytes);
ctlfs.pty.noteMarks(p, o.pane, pane);
+ exec.flushReplEnter(p, o.pane, true);
},
.exited => |e| if (p.panes[e.pane]) |pane| {
if (pane.command == null)
@@ -5499,6 +5554,25 @@ pub const Pardes = struct {
/// the REPL to send to (its letter). Null, or a choice not offered,
/// takes the question back. A key answers it, or `answer` on the pane's
/// ctl; false when the pane asks nothing.
+ /// The choices the question pane `id` asks offers, space-separated
+ /// (`k j`, `a b`), or null when it asks none.
+ pub fn questionChoices(p: *const Pardes, id: usize, buf: []u8) ?[]const u8 {
+ const asked = p.panes[id] orelse return null;
+ switch (asked.prompt) {
+ .del_side => return "k j",
+ .repl_choice => {
+ const ask = p.repl_ask orelse return null;
+ var w = std.Io.Writer.fixed(buf);
+ for (ask.offered, 0..) |serial, i| if (serial != 0) {
+ if (w.buffered().len > 0) w.writeByte(' ') catch {};
+ w.writeByte(@intCast('a' + i)) catch {};
+ };
+ return w.buffered();
+ },
+ else => return null,
+ }
+ }
+
pub fn answerQuestion(p: *Pardes, id: usize, choice: ?u8) bool {
const asked = p.panes[id] orelse return false;
switch (asked.prompt) {
@@ -5602,7 +5676,7 @@ pub const Pardes = struct {
// global window ops (any mode): Alt-n new terminal below, Alt-c move
// the active terminal into a fresh column
if (hit(key, config.new_shell_below)) {
- const free = p.freeSlot() orelse return;
+ const free = p.freeSlot() orelse return p.reportError(p.active, "Tty", error.NoPaneSlots);
const nt = p.newShell(free, "") catch return;
nt.greet = true;
// From a tag, as a 9P new is: acme's t->w == nil.
@@ -6438,16 +6512,22 @@ pub const Pardes = struct {
const wait = Messages.messageWake(p, pane) orelse continue;
frames = if (frames) |soonest| @min(soonest, wait) else wait;
};
+ // A paste's Enter waiting on its REPL goes at the end of its wait.
+ for (p.panes) |slot| if (slot) |pane| if (pane.repl_enter > 0) {
+ const wait = @max(1, (pane.repl_enter_due_ns -| p.stepped_ns) / animation.frame_ns);
+ frames = if (frames) |soonest| @min(soonest, wait) else wait;
+ };
return if (frames) |n| p.stepped_ns + n * animation.frame_ns else null;
}
+
/// A render-time animation is moving (a pane's lift): it is sampled at
/// each frame's own `now`, so while it moves the shell draws every loop
/// and its present (vsync) paces it, at the display's rate. `nextWake`
/// is for the stepped animations, which count frames of `frame_ns`.
pub fn continuous(p: *const Pardes) bool {
for (p.lifts) |spring| if (!spring.settled) return true;
- return false;
+ return p.cursor_glide.moving();
}
/// For shells that only need a yes or no: macOS and the board, which
@@ -7001,6 +7081,8 @@ test "Del from a key asks which neighbour takes the rows; nothing else asks" {
p.update(.{ .key = .{ .cp = 'd' } });
var want: [32]u8 = undefined;
try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "ask {d} del k j\n", .{serial})));
+ try std.testing.expectEqual(ctlfs.E.INVAL, th.wr(p, ctl, "answer x\n").errno());
+ try std.testing.expectEqual(Pane.Prompt.del_side, p.panes[asked].?.prompt);
try std.testing.expectEqual(ctlfs.Status.ok, th.wr(p, ctl, "answer -\n").reply.status);
try std.testing.expect(p.panes[asked] != null and p.panes[asked].?.prompt == .none);
p.update(.{ .key = .{ .cp = ' ' } });
diff --git a/src/regexp.zig b/src/regexp.zig
index 249763c2..97e3dc91 100644
--- a/src/regexp.zig
+++ b/src/regexp.zig
@@ -235,3 +235,26 @@ test "a \\x without two hex digits is refused, not handed to mvzr to panic on" {
var rx = try Regex.compile("\\x41");
try std.testing.expectEqual(@as(usize, 1), (try rx.find("xA", 0, 1, 2)).?.start);
}
+
+test "a repeat inside a group gives back to what follows the group" {
+ const Case = struct { pat: []const u8, text: []const u8, start: usize, end: usize };
+ for ([_]Case{
+ .{ .pat = "a+ab", .text = "aab", .start = 0, .end = 3 },
+ .{ .pat = "(a+)ab", .text = "aab", .start = 0, .end = 3 },
+ .{ .pat = "(a*)ab", .text = "aab", .start = 0, .end = 3 },
+ .{ .pat = "(.+)_area", .text = "the_total_area x", .start = 0, .end = 14 },
+ .{ .pat = "([a-z_]+)_area", .text = "the_total_area x", .start = 0, .end = 14 },
+ .{ .pat = "\\w+_area", .text = "the_total_area x", .start = 0, .end = 14 },
+ .{ .pat = "(\\w)+_area", .text = "the_total_area x", .start = 0, .end = 14 },
+ .{ .pat = "([a-z]+)_area", .text = "the_total_area x", .start = 4, .end = 14 },
+ .{ .pat = "x(ab|a)bc", .text = "xabc", .start = 0, .end = 4 },
+ }) |c| {
+ var rx = try Regex.compile(c.pat);
+ const m = (try rx.find(c.text, 0, c.text.len, c.text.len)) orelse {
+ std.debug.print("no match for {s}\n", .{c.pat});
+ return error.NoMatch;
+ };
+ try std.testing.expectEqual(c.start, m.start);
+ try std.testing.expectEqual(c.end, m.end);
+ }
+}
diff --git a/src/sam_edit.zig b/src/sam_edit.zig
index 679823da..29dec726 100644
--- a/src/sam_edit.zig
+++ b/src/sam_edit.zig
@@ -390,7 +390,11 @@ const Exec = struct {
fn address(ex: *Exec, expr: ?[]const u8, dot: Range) Failure!Range {
const e = expr orelse return dot;
var a: addr_lang.Addr = .{ .text = ex.text, .lim = null, .expr = e };
- const r = a.address(dot) orelse return fail(ex.why, "{s}", .{a.err});
+ // A miss names the pattern that missed, not the whole Edit line.
+ const r = a.address(dot) orelse return if (std.mem.eql(u8, a.err, addr_lang.e_no_match))
+ fail(ex.why, "no match for regexp {s}", .{e})
+ else
+ fail(ex.why, "{s}", .{a.err});
if (a.i < e.len) return fail(ex.why, "bad address syntax", .{});
if (r.q0 > r.q1) return fail(ex.why, "addresses out of order", .{});
return r;
@@ -537,7 +541,10 @@ const Exec = struct {
did = true;
if (!c.global) break;
}
- if (!did and ex.nest == 0) return fail(ex.why, "no substitution", .{});
+ if (!did and ex.nest == 0) return if (r.q0 == r.q1)
+ fail(ex.why, "no substitution: dot is empty", .{})
+ else
+ fail(ex.why, "no substitution", .{});
return r;
}
@@ -679,8 +686,9 @@ test "an Edit that fails halfway changes nothing, and says why in acme's words"
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
for ([_][2][]const u8{
- .{ ",x/foo/c/bar/\n/nomatch/d", "no match for regexp" },
+ .{ ",x/foo/c/bar/\n/nomatch/d", "no match for regexp /nomatch/" },
.{ ",s/zzz/y/", "no substitution" },
+ .{ "s/foo/y/", "no substitution: dot is empty" },
.{ "k", "unknown command k" },
.{ ",x/a/{\na/1/\ni/2/\n}", "changes out of sequence" },
.{ "}", "right brace with no left brace" },
diff --git a/src/surface.zig b/src/surface.zig
index 15de128f..523cba43 100644
--- a/src/surface.zig
+++ b/src/surface.zig
@@ -412,6 +412,16 @@ pub const Chrome = struct {
grip_focus_ring: [3]u8 = .{ 0, 0, 0 },
/// A dirty pane's button fill.
grip_dirty: [3]u8 = .{ 0, 0, 0 },
+ /// The focused cursor's quad (four corners, x then y, in grid cells, top
+ /// left clockwise): `cursor_glide` bit 1 says there is one, bit 0 that
+ /// it glides, when a pixel shell draws it at `cursor_alpha` in place of
+ /// its cursors (as it does while it blinks, from `cursor_idle_ms`, the
+ /// time since the last key or click when the frame was made; maxInt,
+ /// no blink).
+ cursor_glide: u8 = 0,
+ cursor_alpha: f32 = 1,
+ cursor_quad: [8]f32 = @splat(0),
+ cursor_idle_ms: u32 = std.math.maxInt(u32),
/// The rule between a pane's tag and its body; null is halfway between
/// the tag's ground and the page the shell actually draws.
tag_rule: ?[3]u8 = null,
diff --git a/src/tagline.zig b/src/tagline.zig
index 68fa2645..d024be4e 100644
--- a/src/tagline.zig
+++ b/src/tagline.zig
@@ -286,10 +286,12 @@ pub fn wrapTag(arena: std.mem.Allocator, whole: []const u8, width: usize) !Shown
return .{ .text = out.items, .soft = soft.items };
}
-/// The tag's width for wrapping: its columns, one kept for the caret past
-/// a full row's end.
+/// The tag's width for wrapping: all its columns. A row the text fills
+/// exactly stays one row, as acme's frame wraps a box only when it is wider
+/// than what is left (libframe frutil.c _frcklinewrap), and the caret past
+/// its end sits at the edge (frptofchar.c clamps it to r.max.x).
pub fn tagWidth(p: *const Pardes, id: usize) usize {
- return @max(1, @as(usize, p.tagCapacity(p.rects[id].w -| TAG_TEXT_INSET)) -| 1);
+ return @max(1, @as(usize, p.tagCapacity(p.rects[id].w -| TAG_TEXT_INSET)));
}
pub fn shownTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) !Shown {
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index 96aec9a9..754b4b96 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -1329,8 +1329,8 @@ const Shell = struct {
fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void {
const s = of(ctx);
var pbuf: [1024:0]u8 = undefined;
- const path = pardes.dump.outPath(&pbuf, s.core.settings.dump_dir.get()) orelse return;
- filesystem.write(s.core, path, bytes) catch |err| return s.core.reportError(0, "dump", err);
+ const path = pardes.dump.outPath(&pbuf, s.core.settings.dump_dir.get()) orelse return s.core.dumpFailed(s.core.settings.dump_dir.get(), error.NoDumpDirectory);
+ filesystem.write(s.core, path, bytes) catch |err| return s.core.dumpFailed(path, err);
s.core.setLastDump(path);
}
diff --git a/src/tutor.txt b/src/tutor.txt
index 0893415e..c59deeae 100644
--- a/src/tutor.txt
+++ b/src/tutor.txt
@@ -132,6 +132,15 @@
DelAbove close it, giving the space to the pane above (Del k)
DelBelow close it, giving the space to the pane below (Del j)
+ RESIZE, AS ACME MOVES A WINDOW
+ the grip (the box left of a pane's tag) drag it: drop it in another
+ column to move the pane there, or up or down its own
+ column to move its top, the pane above taking or giving
+ the rows -- down to its tag alone
+ the rule in the GUI, the line between two panes drags their seam
+ a column's right edge drags the column's width
+ A pane's last row is text like any other, never a handle.
+
A new file opens in a new COLUMN only if both panes would still get
100 columns of width. Otherwise it stacks in the one you are in.