From 9070942b29bd10dddcdecdb0e88ba0fb40608467 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 21 Sep 2026 20:07:43 -0300 Subject: 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) --- src/pardes.zig | 294 ++++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 247 insertions(+), 47 deletions(-) (limited to 'src/pardes.zig') 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)); -- cgit v1.3