summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-21 20:07:43 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commit9070942b29bd10dddcdecdb0e88ba0fb40608467 (patch)
treefa1fc5984c7847c52bf4e78d277586edce2d8325 /src/pardes.zig
parent0122e94fb37422085325f2dc78ca015051ce5f91 (diff)
downloadpardes-9070942b29bd10dddcdecdb0e88ba0fb40608467.tar.gz
pardes-9070942b29bd10dddcdecdb0e88ba0fb40608467.zip
Plan 9 idiom for the control filesystem, and the regressions a624a56 left
The 9P tree stops being a command language wearing a filesystem. /new created a pane as a side effect of a *read*; it is now Tcreate in /pane, with Tremove to close, which cloud9's engine has always supported and the editor never declared: tree.zig now says `features = .{ .create = true, .remove = true }`. Eleven pane ctl verbs become files that can be read as well as written -- dot, limit, dirty, mark, scroll, look, exec -- leaving ctl with `get`, the one verb no file would say better. Root /ctl splits into a read-only /status and the /look and /exec files whose write IS the click. stat carries real sizes where it used to answer 0, and qid versions track a pane's revision, so a client can poll for change without re-reading the body. Commit a624a56 moved raw-tty keys to an early-return branch that knew only Ctrl-B and bare Escape, and in the same edit deleted the paste branch below it. That cost Shift-Escape (the unconditional way out of tty mode) and both paste chords: Ctrl-V and Ctrl-Shift-V reached the child as keystrokes, so an agent CLI running in a pane took Ctrl-V for its image-paste binding and answered "No image found in clipboard". Both are restored, with tests. Nested detection was not subtly broken but deleted: 60367d8 removed nested.zig's process-ancestry walk and left "am I inside pardes" derived from PARDES_FORWARD_LOOK, which read "0" both for --nested and for "the listener did not come up". PARDES_PID now answers that question on its own, checked with kill(pid, 0); PARDES_9P and PARDES_PANE answer how to reach it; the flag is gone. The posted-9P registry also self-heals now -- a session that aborts cannot unlink its own socket, so posting sweeps entries whose target refuses a connection, symlinks only and on a definite ECONNREFUSED only. Elsewhere: tty scrolling is sticky-bottom, following new output only from the last row, with typing and entering raw mode snapping back to live; the boot layouts are a Boot enum instead of a chain of ifs, and the bare tty startup (Boot.tty, which main.zig names) opens an empty text pane under the shell while tests keep Boot.tty_shell; builtins announce themselves on the message row under a Verbose setting that is on by default; Config prints each setting the way you would type it back, so WindowOpacity 70 rather than "WindowOpacity: 70%"; LocationsConfig opens its window only when called bare; every tagline puts the word that closes the thing last, and a column now outlives its panes -- closing the last one leaves an empty pane, and only Delcol, newly on the column tagline, takes the column away. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig294
1 files changed, 247 insertions, 47 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index ed71102e..c66688f5 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -295,6 +295,12 @@ const pre_collapse_pdf_pane_tail = " Tty Del PdfSections PdfTint";
const pre_collapse_terminal_pane_tail = " Save Tty Del Togglettymode Filter";
const pre_tty_first_terminal_pane_tail = " Save Tty Del Togglettymode Filter Collapse";
const pre_mode_terminal_pane_tail = " Tty Save Del Togglettymode Filter Collapse";
+// ...and the defaults from before the closing word moved to the end of every
+// tagline, where a click cannot land on it by overshooting the one before.
+const pre_close_last_pane_tail = " Tty Collapse Del";
+const pre_close_last_file_pane_tail = " Save Tty Del Collapse";
+const pre_close_last_pdf_pane_tail = " Tty Del PdfSections PdfTint Collapse";
+const pre_close_last_terminal_pane_tail = " Tty Save Del Mode Filter Collapse";
const Builtin = builtins.registry.Builtin();
@@ -1078,7 +1084,7 @@ test "Collapse builtin keeps content and restores the pane from its default tag"
const height = p.rects[0].h;
const revision = pane.file.?.revision;
for ([_][]const u8{ pane_tail, file_pane_tail, pdf_pane_tail, terminal_pane_tail }) |tail|
- try std.testing.expect(std.mem.endsWith(u8, tail, " Collapse"));
+ try std.testing.expect(std.mem.endsWith(u8, tail, " Collapse Del"));
try std.testing.expect(p.executeBuiltinLine(0, "Collapse"));
p.sync();
@@ -1115,6 +1121,8 @@ test "LocationsConfig command reports partial updates and survives restore" {
try std.testing.expectEqual(expected, p.locations_config);
try std.testing.expect(p.executeBuiltinLine(0, "LocationsConfig context:2 unknown:on"));
try std.testing.expectEqual(expected, p.locations_config);
+ // Setting it is silent; asking bare is what opens the window.
+ try std.testing.expect(p.executeBuiltinLine(0, "LocationsConfig"));
var reports: usize = 0;
for (p.panes) |slot| {
const pane = slot orelse continue;
@@ -1409,7 +1417,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" {
});
p.restoreTail(file, file_old);
try std.testing.expect(!file.tag_init);
- try std.testing.expectEqualStrings(" Save Tty Del Collapse", Pardes.curTail(file));
+ try std.testing.expectEqualStrings(" Save Tty Collapse Del", Pardes.curTail(file));
const file_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Mine Del", .{
try p.tagPrefix(file),
@@ -1430,7 +1438,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" {
try std.testing.expectEqualStrings(" New Del", Pardes.curTail(file));
const image_doc = try panes.Image.create(p, 1, "/tmp/legacy image.ppm", &.{});
- try std.testing.expectEqualStrings(" Tty Del Collapse", Pardes.curTail(image_doc));
+ try std.testing.expectEqualStrings(" Tty Collapse Del", Pardes.curTail(image_doc));
p.restoreDumpTail(image_doc, .{
.kind = .image,
.tag = "img /tmp/legacy image.ppm Keep Del",
@@ -1576,7 +1584,7 @@ test "pane defaults keep creation of scratch panes in column tags" {
var words = std.mem.tokenizeScalar(u8, tag, ' ');
while (words.next()) |word| try std.testing.expect(!std.mem.eql(u8, word, "New"));
}
- try std.testing.expectEqualStrings("New Tty Find Grep Joincol", p.columnTag(0));
+ try std.testing.expectEqualStrings("New Tty Find Grep Joincol Delcol", p.columnTag(0));
const terminal = p.panes[0].?;
p.restoreTailAt(terminal, pre_column_terminal_pane_tail, "", .terminal);
try std.testing.expect(!terminal.tag_init);
@@ -2441,13 +2449,13 @@ test "Save reaches every tagline with text behind it and no other" {
});
p.restoreTail(out, scratch_dumped);
try std.testing.expect(!out.tag_init);
- try std.testing.expectEqualStrings(" Save Tty Del Collapse", Pardes.curTail(out));
+ try std.testing.expectEqualStrings(" Save Tty Collapse Del", Pardes.curTail(out));
// An image's bytes on disk already are what they are: nothing of the
// pane's own is unwritten, so the word is absent and inert.
const img_id = p.freeSlot().?;
const img = try panes.Image.create(p, img_id, "/tmp/pardes-tag.ppm", &.{});
- try std.testing.expectEqualStrings(" Tty Del Collapse", Pardes.curTail(img));
+ try std.testing.expectEqualStrings(" Tty Collapse Del", Pardes.curTail(img));
try std.testing.expect(p.executeBuiltinLine(img_id, "Save"));
try std.testing.expect(img.prompt != .save);
var buf: [256]u8 = undefined;
@@ -5818,8 +5826,42 @@ test "repeated non-dyadic column splits preserve every unrelated boundary" {
pub const Rect = layout.Rect;
+/// The session layouts `init` can put on screen. One name each, so choosing
+/// between them reads as a choice instead of a chain of ifs over four options.
+pub const Boot = enum {
+ /// the document named on the command line, or the pane explaining why not
+ document,
+ /// a Look that found nothing: reported, not opened
+ missing,
+ /// the firmware's boot buffer, the only pane a board has
+ embedded,
+ /// the bare tty startup: one shell in raw tty mode, with an empty text
+ /// pane under it so the editor half is there before you want it
+ tty,
+ /// that raw shell by itself, which is all a core booted for a test wants
+ tty_shell,
+ /// acme's boot: two columns, the left one split in two
+ classic,
+ /// one greeted shell
+ shell,
+
+ pub fn of(opts: Options) Boot {
+ if (opts.boot) |named| return named;
+ if (opts.file != null) return .document;
+ if (opts.missing != null) return .missing;
+ if (comptime platform == .esp32p4) return .embedded;
+ if (opts.tty_only) return .tty_shell;
+ // ponytail: 1 and 3 are the only shell counts anyone asked for
+ return if (opts.shells >= 3) .classic else .shell;
+ }
+};
+
pub const Options = struct {
tty_only: bool = false,
+ /// The layout to boot, when a host means one outright rather than leaving
+ /// it to be read off the options below. `--tty` names `.tty`; a core
+ /// booted for a test names nothing and gets `.tty_shell`.
+ boot: ?Boot = null,
/// initial shell panes: 1 (default) or 3 for the classic two-column boot.
/// A `file` outranks this — see there.
shells: u8 = 1,
@@ -6035,6 +6077,11 @@ pub const Pardes = struct {
pipe_wait: ?PendingPipe = null,
fs: filesystem.Namespace = .{},
+ boot: Boot = .shell,
+ /// Builtins announce themselves on the message row. Off while startup
+ /// configuration is applied: those lines are nobody clicking, and should
+ /// not leave the row holding the name of the last one.
+ announce: bool = true,
// Reject overflow: evicting an older effect would reorder a byte stream.
effects: [limits.effect_cap]Effect = undefined,
@@ -6114,7 +6161,10 @@ pub const Pardes = struct {
for (opts.mounts) |mount| try p.fs.mount(gpa, mount.name, mount.dial);
p.opts.mounts = &.{};
p.opts.ninep_identity = .{};
- if (opts.file) |path| {
+ p.boot = Boot.of(opts);
+ switch (p.boot) {
+ .document => {
+ const path = opts.file.?;
const opened = initial_doc: {
if (comptime pdf_enabled) if (look.isPdfPath(path))
break :initial_doc panes.Pdf.openPane(p, 0, path, opts.file_line);
@@ -6137,10 +6187,10 @@ pub const Pardes = struct {
errdefer gpa.free(content);
_ = try panes.Output.open(p, 0, std.fs.path.dirname(path) orelse "/", .errors, "", content);
}
- p.ncol = 1;
- p.col_n[0] = 1;
- p.col_panes[0][0] = 0;
- } else if (opts.missing) |missing| {
+ p.oneColumn();
+ },
+ .missing => {
+ const missing = opts.missing.?;
const content = try std.fmt.allocPrint(
gpa,
"file or directory not found\n\n\t{s}\n",
@@ -6148,24 +6198,26 @@ pub const Pardes = struct {
);
errdefer gpa.free(content);
_ = try panes.Output.open(p, 0, missing.dir, .errors, "", content);
- p.ncol = 1;
- p.col_n[0] = 1;
- p.col_panes[0][0] = 0;
- } else if (comptime platform == .esp32p4) {
+ p.oneColumn();
+ },
+ .embedded => {
const content = try p.gpa.dupe(u8, boot_buffer);
errdefer p.gpa.free(content);
_ = try panes.Output.open(p, 0, "", .{ .cmd = .New }, "", content);
- p.ncol = 1;
- p.col_n[0] = 1;
- p.col_panes[0][0] = 0;
- } else if (opts.tty_only) {
+ p.oneColumn();
+ },
+ .tty, .tty_shell => {
_ = try p.newShell(0, "");
p.panes[0].?.mode = .tty;
- p.ncol = 1;
- p.col_n[0] = 1;
- p.col_panes[0][0] = 0;
- } else if (opts.shells >= 3) {
- // classic layout: two columns, the left one split in two.
+ p.oneColumn();
+ // A bare tty is still an editor: it opens with somewhere to put
+ // text under the shell. The shell keeps the keyboard.
+ if (p.boot == .tty) {
+ p.newScratchBelow(0);
+ p.active = 0;
+ }
+ },
+ .classic => {
_ = try p.newShell(0, "");
_ = try p.newShell(1, "");
_ = try p.newShell(2, "");
@@ -6176,13 +6228,12 @@ pub const Pardes = struct {
p.col_panes[0][1] = 1;
p.col_n[1] = 1;
p.col_panes[1][0] = 2;
- } else {
- // ponytail: 1 and 3 are the only boot layouts; anything else acts as 1
+ },
+ .shell => {
_ = try p.newShell(0, "");
p.panes[0].?.greet = true;
- p.ncol = 1;
- p.col_n[0] = 1;
- p.col_panes[0][0] = 0;
+ p.oneColumn();
+ },
}
p.sync();
p.applyStartupConfig();
@@ -6195,6 +6246,13 @@ pub const Pardes = struct {
return p;
}
+ /// The layout every single-pane boot starts from.
+ fn oneColumn(p: *Pardes) void {
+ p.ncol = 1;
+ p.col_n[0] = 1;
+ p.col_panes[0][0] = 0;
+ }
+
pub fn deinit(p: *Pardes) void {
p.cancelLookHover();
for (0..MAX_PANES) |id| p.dropPendingWrite(id);
@@ -6268,7 +6326,7 @@ pub const Pardes = struct {
}
pub fn columnTag(p: *const Pardes, c: usize) []const u8 {
- return p.col_tags[c].slice("New Tty Find Grep Joincol");
+ return p.col_tags[c].slice("New Tty Find Grep Joincol Delcol");
}
fn headerText(p: *Pardes) []const u8 {
@@ -6413,6 +6471,17 @@ pub const Pardes = struct {
return np;
}
+ /// The empty pane that stands in for one that closed, keeping its column
+ /// alive and its directory. Unlike `newScratch` it inherits nothing: the
+ /// pane it replaces is on its way out.
+ fn newScratchIn(p: *Pardes, free: usize, dir: []const u8) !*Pane {
+ const content = try p.gpa.dupe(u8, "");
+ errdefer p.gpa.free(content);
+ const np = try panes.Output.open(p, free, dir, .{ .cmd = .New }, "", content);
+ np.cur_pinned = true;
+ return np;
+ }
+
/// New: a scratch below the calling pane, in its column.
pub fn newScratchBelow(p: *Pardes, from_id: usize) void {
const free = p.freeSlot() orelse return;
@@ -8237,8 +8306,23 @@ pub const Pardes = struct {
p.ctrl_w_pending = false;
if (key.ctrl and !key.alt and key.cp == p.opts.tty_toggle)
return p.toggleTty(p.active);
+ // Shift-Escape is the unconditional way out, even mid-application;
+ // bare Escape only leaves at a shell prompt.
+ if (hit(key, config.tty_toggle_alt))
+ return p.runBuiltin(.Last, p.active, "", null);
if (key.cp == Key.escape and !key.ctrl and !key.alt and !key.shift and p.takesCommandLine(p.active))
return p.runBuiltin(.Last, p.active, "", null);
+ panes.Terminal.followOutput(pane); // typing snaps back to live output
+ // Paste stays the window's, the one exception to forwarding a raw
+ // tty's keys: Ctrl-V types the register at the program and
+ // Ctrl-Shift-V asks the desktop for its clipboard first. Forwarded,
+ // they reach an application that reads the clipboard on its own
+ // account instead — a coding agent goes there hunting for an image.
+ // A host that folds Shift into the letter says the same thing.
+ if (key.ctrl and !key.alt and (key.cp == 'v' or key.cp == 'V')) {
+ if (key.shift or key.cp == 'V') return p.clipRequest(p.active, .after);
+ return p.typeToTty(p.active, pane, p.yank orelse return);
+ }
return panes.Terminal.forwardKey(p, p.active, key);
}
if (p.leader_on) return p.leaderKey(key);
@@ -12499,6 +12583,8 @@ pub const Pardes = struct {
}
fn applyStartupConfig(p: *Pardes) void {
+ p.announce = false;
+ defer p.announce = true;
var lines = std.mem.splitScalar(u8, p.opts.startup_config orelse return, '\n');
while (lines.next()) |line| _ = p.executeBuiltinLine(p.active, line);
}
@@ -12506,6 +12592,11 @@ pub const Pardes = struct {
fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8) void {
if (!p.multiOnce()) return; // a builtin is per-keystroke, never per-cursor
const pane = p.panes[id] orelse return;
+ // A builtin says its own name before it runs, so a tagline click is
+ // legible even when what it did is invisible. Whatever the builtin
+ // then says for itself replaces the announcement.
+ if (p.announce and p.settings.verbose and builtins.registry.announces(b))
+ p.setMessage(id, @tagName(b));
const c: builtins.Ctx = .{ .p = p, .pane = pane, .id = id, .txt = txt, .arg = arg };
builtins.registry.dispatch(b, c);
}
@@ -12887,11 +12978,13 @@ pub const Pardes = struct {
const rest = saved_tag[pfx.len..];
const said = std.mem.trimStart(u8, rest, " ");
const defaults: []const []const u8 = switch (class) {
- .generic => &.{ pdf_pane_tail, pane_tail, pre_collapse_pdf_pane_tail, pre_collapse_pane_tail, pre_column_pdf_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail },
- .file => &.{ context_pane_tail, file_pane_tail, pre_collapse_file_pane_tail, pre_column_file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail },
+ .generic => &.{ pdf_pane_tail, pane_tail, pre_close_last_pdf_pane_tail, pre_close_last_pane_tail, pre_collapse_pdf_pane_tail, pre_collapse_pane_tail, pre_column_pdf_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail },
+ .file => &.{ context_pane_tail, file_pane_tail, pre_close_last_file_pane_tail, pre_collapse_file_pane_tail, pre_column_file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail },
.output => &.{
locations_pane_tail,
file_pane_tail,
+ pre_close_last_file_pane_tail,
+ pre_close_last_pane_tail,
prev_file_pane_tail,
legacy_file_pane_tail,
pane_tail,
@@ -12902,7 +12995,7 @@ pub const Pardes = struct {
pre_collapse_file_pane_tail,
pre_collapse_pane_tail,
},
- .terminal => &.{ terminal_pane_tail, pre_mode_terminal_pane_tail, pre_tty_first_terminal_pane_tail, pre_collapse_terminal_pane_tail, pre_column_terminal_pane_tail, pre_tty_terminal_pane_tail, prev_terminal_pane_tail, legacy_terminal_pane_tail, pane_tail, pre_collapse_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail },
+ .terminal => &.{ terminal_pane_tail, pre_close_last_terminal_pane_tail, pre_close_last_pane_tail, pre_mode_terminal_pane_tail, pre_tty_first_terminal_pane_tail, pre_collapse_terminal_pane_tail, pre_column_terminal_pane_tail, pre_tty_terminal_pane_tail, prev_terminal_pane_tail, legacy_terminal_pane_tail, pane_tail, pre_collapse_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail },
};
for (defaults) |default|
if (std.mem.eql(u8, said, std.mem.trimStart(u8, default, " "))) return;
@@ -12912,11 +13005,34 @@ pub const Pardes = struct {
pub fn removePane(p: *Pardes, id: usize) !void {
const pane = p.panes[id] orelse return;
+ // A column outlives its panes. Closing the last one leaves an empty
+ // pane standing in its place and in its directory; only Delcol ever
+ // takes a column away.
+ const place = layout.findPane(p, id);
+ const last_in_column = if (place) |f| p.col_n[f.col] == 1 else false;
+ var dir_buf: [limits.host_path_cap]u8 = undefined;
+ var dir: []const u8 = "";
+ if (last_in_column) {
+ const had = paneDir(pane);
+ const n = @min(had.len, dir_buf.len);
+ @memcpy(dir_buf[0..n], had[0..n]);
+ dir = dir_buf[0..n];
+ }
+ // Before anything is torn down, so a failure here changes nothing.
try p.deinitPane(pane);
+ // ...and after it, so the stand-in inherits none of the closing pane.
+ const heir: ?usize = heir: {
+ if (!last_in_column) break :heir null;
+ const f = place.?;
+ const free = p.freeSlot() orelse break :heir null;
+ _ = p.newScratchIn(free, dir) catch break :heir null;
+ layout.insert(p, f.col, f.idx + 1, free);
+ break :heir free;
+ };
layout.absorbVWeight(p, id);
layout.removePane(p, id);
p.panes[id] = null;
- if (p.active == id) p.active = p.prevFocus(id) orelse {
+ if (p.active == id) p.active = heir orelse p.prevFocus(id) orelse {
p.quit = true;
p.emit(.quit);
return;
@@ -14408,6 +14524,75 @@ test "Esc back into a tty leaves its view at the prompt" {
try std.testing.expectEqual(live, sp.terminal.?.vt.screens.active.pages.scrollbar().offset);
}
+test "the bare tty startup opens a text pane under the shell, unfocused" {
+ if (platform == .web) return;
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .boot = .tty });
+ defer p.deinit();
+ try std.testing.expectEqual(Boot.tty, p.boot);
+
+ // One column, shell on top in raw mode, an empty text pane under it.
+ try std.testing.expectEqual(@as(usize, 1), p.ncol);
+ try std.testing.expectEqual(@as(usize, 2), p.col_n[0]);
+ const shell = p.panes[p.col_panes[0][0]].?;
+ const scratch = p.panes[p.col_panes[0][1]].?;
+ try std.testing.expect(shell.isTerminal());
+ try std.testing.expectEqual(Pane.Mode.tty, shell.mode);
+ try std.testing.expect(!scratch.isTerminal());
+ try std.testing.expectEqualStrings("", scratch.file.?.content);
+
+ // ...and the shell keeps the keyboard.
+ try std.testing.expectEqual(p.col_panes[0][0], p.active);
+
+ // Unnamed, it is the bare shell every test asks for.
+ const plain = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer plain.deinit();
+ try std.testing.expectEqual(Boot.tty_shell, plain.boot);
+ try std.testing.expectEqual(@as(usize, 1), plain.col_n[0]);
+}
+
+test "tty output follows only from the bottom" {
+ if (platform == .web) return;
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24, .file = "mise.toml" });
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
+ p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc
+ const shell = p.active;
+ const sp = p.panes[shell].?;
+ p.update(.{ .key = .{ .cp = Key.escape, .shift = true } });
+ try std.testing.expectEqual(Pane.Mode.tty, sp.mode);
+
+ for (0..60) |i| {
+ var buf: [32]u8 = undefined;
+ p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = std.fmt.bufPrint(&buf, "line {d}\r\n", .{i}) catch unreachable } });
+ }
+ p.sync();
+ const bottom = panes.Terminal.scrollbar(sp).offset;
+ try std.testing.expect(bottom > 0);
+
+ // Scrolled back, the reader stays put however much the shell prints.
+ sp.scrollBy(-10);
+ p.sync();
+ const parked = panes.Terminal.scrollbar(sp).offset;
+ try std.testing.expect(parked < bottom);
+ for (60..70) |i| {
+ var buf: [32]u8 = undefined;
+ p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = std.fmt.bufPrint(&buf, "line {d}\r\n", .{i}) catch unreachable } });
+ }
+ p.sync();
+ try std.testing.expectEqual(parked, panes.Terminal.scrollbar(sp).offset);
+
+ // Typing snaps back to live output, so nobody types blind.
+ p.update(.{ .key = .{ .cp = 'x', .text = "x" } });
+ p.sync();
+ try std.testing.expect(panes.Terminal.scrollbar(sp).offset > parked);
+
+ // ...and from there output follows again.
+ const live = panes.Terminal.scrollbar(sp).offset;
+ p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = "tail\r\n" } });
+ p.sync();
+ try std.testing.expect(panes.Terminal.scrollbar(sp).offset > live);
+}
+
test "Esc back into a file leaves its view where it was" {
if (platform == .web) return;
const gpa = std.testing.allocator;
@@ -14461,15 +14646,23 @@ test "raw tty Escape only leaves at a shell prompt" {
const pane = p.panes[shell].?;
var buf: [256]u8 = undefined;
_ = drainWrites(p, &buf);
- for ([_]Key{
- .{ .cp = Key.escape, .shift = true },
- }) |key| {
- p.update(.{ .key = key });
- try std.testing.expectEqual(shell, p.active);
- try std.testing.expectEqual(Pane.Mode.tty, pane.mode);
- try std.testing.expect(drainWrites(p, &buf).len > 0);
- }
- p.update(.{ .key = .{ .cp = Key.escape } });
+
+ // Ordinary keys reach the child; Shift-Escape is the one unconditional way
+ // out and sends it nothing.
+ p.update(.{ .key = .{ .cp = 'x', .text = "x" } });
+ try std.testing.expectEqual(shell, p.active);
+ try std.testing.expect(drainWrites(p, &buf).len > 0);
+
+ p.update(.{ .key = .{ .cp = Key.escape, .shift = true } });
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+ try std.testing.expectEqualStrings("", drainWrites(p, &buf));
+ try std.testing.expectEqual(Pane.Mode.tty, pane.mode);
+
+ p.update(.{ .key = .{ .cp = Key.escape } }); // back into the shell
+ try std.testing.expectEqual(shell, p.active);
+ _ = drainWrites(p, &buf);
+
+ p.update(.{ .key = .{ .cp = Key.escape } }); // bare Escape leaves at a prompt
try std.testing.expectEqual(@as(usize, 0), p.active);
try std.testing.expectEqualStrings("", drainWrites(p, &buf));
try std.testing.expectEqual(Pane.Mode.tty, pane.mode);
@@ -14620,9 +14813,10 @@ test "raw tty forwards Ctrl letters and former global shortcuts to the child" {
defer p.deinit();
var buf: [256]u8 = undefined;
_ = drainWrites(p, &buf);
- p.setYank("must not be pasted");
+ p.setYank("register text");
for ('a'..'z' + 1) |letter| {
if (letter == p.opts.tty_toggle) continue; // editor/TTY mode shortcut
+ if (letter == 'v') continue; // the window's paste, asserted just below
p.update(.{ .key = .{ .cp = @intCast(letter), .ctrl = true } });
const expected = [_]u8{@intCast(letter - 'a' + 1)};
// The terminal encoder distinguishes Ctrl-I/M from Tab/Return.
@@ -14636,15 +14830,21 @@ test "raw tty forwards Ctrl letters and former global shortcuts to the child" {
try std.testing.expectEqual(Pane.Mode.tty, p.panes[0].?.mode);
try std.testing.expect(!p.ctrl_w_pending and p.clip_pending == null);
}
- p.update(.{ .key = .{ .cp = 'v', .ctrl = true, .shift = true } });
- try std.testing.expectEqualStrings("\x1b[118;6u", drainWrites(p, &buf));
+ // ...and the two paste chords are the exception: Ctrl-V types the register
+ // at the program, Ctrl-Shift-V asks the desktop first and types nothing yet.
+ p.update(.{ .key = .{ .cp = 'v', .ctrl = true } });
+ try std.testing.expectEqualStrings("register text", drainWrites(p, &buf));
try std.testing.expect(p.clip_pending == null);
+ p.update(.{ .key = .{ .cp = 'v', .ctrl = true, .shift = true } });
+ try std.testing.expectEqualStrings("", drainWrites(p, &buf));
+ try std.testing.expect(p.clip_pending != null);
+ const unopened = p.freeSlot(); // whatever the boot layout already used
for ([_]u8{ 'n', 'c' }) |letter| {
p.update(.{ .key = .{ .cp = letter, .alt = true } });
const expected = [_]u8{ 0x1b, letter };
try std.testing.expectEqualStrings(&expected, drainWrites(p, &buf));
try std.testing.expectEqual(@as(usize, 0), p.active);
- try std.testing.expect(p.panes[1] == null);
+ try std.testing.expectEqual(unopened, p.freeSlot());
}
p.update(.{ .key = .{ .cp = Key.up } });
try std.testing.expectEqualStrings("\x1b[A", drainWrites(p, &buf));