diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/File.zig | 8 | ||||
| -rw-r--r-- | src/Messages.zig | 12 | ||||
| -rw-r--r-- | src/Terminal.zig | 62 | ||||
| -rw-r--r-- | src/animation.zig | 201 | ||||
| -rw-r--r-- | src/builtins.zig | 91 | ||||
| -rw-r--r-- | src/config.zig | 63 | ||||
| -rw-r--r-- | src/detached/server.zig | 4 | ||||
| -rw-r--r-- | src/detached/wire.zig | 22 | ||||
| -rw-r--r-- | src/draw.zig | 182 | ||||
| -rw-r--r-- | src/dump.zig | 127 | ||||
| -rw-r--r-- | src/exec.zig | 29 | ||||
| -rw-r--r-- | src/fs-help.txt | 16 | ||||
| -rw-r--r-- | src/fs.zig | 24 | ||||
| -rw-r--r-- | src/gui/gui.zig | 97 | ||||
| -rw-r--r-- | src/layout.zig | 29 | ||||
| -rw-r--r-- | src/look.zig | 1 | ||||
| -rw-r--r-- | src/macos.zig | 4 | ||||
| -rw-r--r-- | src/mouse.zig | 59 | ||||
| -rw-r--r-- | src/ninep/cols.zig | 284 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 142 | ||||
| -rw-r--r-- | src/ninep/events.zig | 8 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 61 | ||||
| -rw-r--r-- | src/ninep/screen.zig | 22 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 197 | ||||
| -rw-r--r-- | src/panes.zig | 6 | ||||
| -rw-r--r-- | src/pardes.zig | 94 | ||||
| -rw-r--r-- | src/regexp.zig | 23 | ||||
| -rw-r--r-- | src/sam_edit.zig | 14 | ||||
| -rw-r--r-- | src/surface.zig | 10 | ||||
| -rw-r--r-- | src/tagline.zig | 8 | ||||
| -rw-r--r-- | src/tty/tty.zig | 4 | ||||
| -rw-r--r-- | src/tutor.txt | 9 |
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. @@ -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. |
