diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 799 |
1 files changed, 665 insertions, 134 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index 5fa3307b..f559b125 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -20,6 +20,8 @@ pub const edit = @import("edit.zig"); pub const body_layer = @import("body_layer.zig"); pub const draw = @import("draw.zig"); pub const Messages = @import("Messages.zig"); +pub const Registers = @import("Registers.zig"); +pub const Macro = @import("Macro.zig"); pub const Pipe = @import("Pipe.zig"); pub const colors = @import("colors.zig"); pub const Theme = colors.Theme; @@ -455,6 +457,8 @@ test { _ = @import("regexp.zig"); _ = @import("edit.zig"); _ = @import("Messages.zig"); + _ = @import("Registers.zig"); + _ = @import("Macro.zig"); _ = @import("selection_pipe.zig"); _ = @import("Pipe.zig"); _ = @import("colors.zig"); @@ -555,7 +559,7 @@ test "Detach asks the frontend to leave, and says so when there is nothing to le p.perform(.{ .detach = asked }); const pane = p.panes[0].?; - try std.testing.expectEqualStrings("detach: NotAttached", pane.msg[0..pane.msg_len]); + try std.testing.expectEqualStrings("detach: not attached", pane.msg[0..pane.msg_len]); try std.testing.expect(!p.quit); } @@ -626,8 +630,9 @@ test "pane-tag Exec prefers Tty and argument builtins before shell fallback" { while (p.nextEffect()) |_| {} // initial shell spawn/geometry p.acknowledgeShell(0, "/bin/sh", false); + // The Tty word names the shell the host ran, and opens another of it. const pane = p.panes[0].?; - try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(pane)); + try std.testing.expectEqualStrings(" Tty+sh" ++ tagline.terminal_pane_tail[" Tty".len..], tagline.curTail(pane)); const tag_x = p.rects[0].x + TAG_TEXT_INSET; const tag_y = p.rects[0].y; @@ -649,6 +654,7 @@ test "pane-tag Exec prefers Tty and argument builtins before shell fallback" { }; try std.testing.expectEqual(panes_before + 1, panes_after); try std.testing.expect(p.panes[p.active].?.isTerminal()); + try std.testing.expectEqualStrings("sh", p.panes[p.active].?.shell.?); // Argument-taking builtins use the same gesture path: a tag sweep is the // complete command line, and Theme consumes its tail without a PTY write. @@ -1186,7 +1192,7 @@ test "first document fallback splits like Tty from the originating pane" { const free = p.freeSlot().?; const doc = try p.openTutorView(free); exec.placeDoc(p, from, free, doc); - exec.spawnTty(tty, from); + _ = exec.spawnTty(tty, from); p.sync(); tty.sync(); try std.testing.expectEqual(@as(usize, 2), p.ncol); @@ -1277,34 +1283,35 @@ test "an unsaved file marker sits between its path and builtins until Save" { try std.testing.expect(std.mem.indexOf(u8, output, "/test.txt *") == null); } -test "unknown Exec from an image writes to a terminal in the image directory" { +test "unknown Exec from a document runs as a command pane in its directory, typed into no terminal" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{}); defer p.deinit(); while (p.nextEffect()) |_| {} p.acknowledgeShell(0, "/bin/sh", false); + // A terminal free at a prompt in the same directory is not borrowed. p.setCwd(0, "/tmp/pardes-image-dir"); const image_doc = try p.newDocPane(1); image_doc.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-image-dir/pic.ppm") }; try std.testing.expectEqualStrings("/tmp/pardes-image-dir", Pardes.paneDir(image_doc)); - try std.testing.expectEqual(@as(?usize, 0), exec.execute(p, 1, "echo image-fallback")); + const dst = exec.execute(p, 1, "echo image-fallback") orelse return error.ExecFoundNowhereToRun; + try std.testing.expect(dst != 0 and dst != 1); + try std.testing.expectEqualStrings("echo image-fallback", p.panes[dst].?.command.?); - var sent: [256]u8 = undefined; - var sent_len: usize = 0; + var spawned = false; while (p.nextEffect()) |effect| switch (effect) { - .write => |w| { - try std.testing.expectEqual(@as(u8, 0), w.pane); - try std.testing.expect(w.bytes.slice().len <= sent.len - sent_len); - @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); - sent_len += w.bytes.slice().len; + .write => return error.TypedIntoATerminal, + .spawn => |sp| if (sp.pane == dst) { + try std.testing.expectEqualStrings("/tmp/pardes-image-dir", sp.cwd.slice()); + spawned = true; }, - .spawn => return error.ImageExecSpawnedDespiteMatchingTerminal, else => {}, }; - try std.testing.expectEqualStrings("echo image-fallback\r", sent[0..sent_len]); + try std.testing.expect(spawned); } + const FakeTtyQuery = struct { taken: [MAX_PANES]bool = @splat(false), asked: usize = 0, @@ -1322,7 +1329,7 @@ const FakeTtyQuery = struct { } }; -test "Exec in a terminal whose tty is taken spawns a shell instead of typing at the program" { +test "Exec in a terminal whose tty is taken runs as a command pane instead of typing at the program" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{}); defer p.deinit(); @@ -1335,42 +1342,384 @@ test "Exec in a terminal whose tty is taken spawns a shell instead of typing at host.install(p); host.taken[0] = true; const dst = exec.execute(p, 0, "echo taken-fallback") orelse return error.ExecFoundNowhereToRun; - // somewhere ELSE — and still without moving focus, which is execute's + // somewhere ELSE -- and still without moving focus, which is execute's // contract and the whole difference between it and a look try std.testing.expect(dst != 0); try std.testing.expectEqual(@as(usize, 0), p.active); - - var spawned: ?Effect = null; - var sent: [256]u8 = undefined; - var sent_len: usize = 0; + try std.testing.expectEqualStrings("echo taken-fallback", p.panes[dst].?.command.?); + var spawned = false; while (p.nextEffect()) |effect| switch (effect) { - .write => |w| { - if (w.pane == 0) return error.WroteACommandLineIntoTheProgramOnTheTty; - try std.testing.expectEqual(@as(u8, @intCast(dst)), w.pane); - try std.testing.expect(w.bytes.slice().len <= sent.len - sent_len); - @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); - sent_len += w.bytes.slice().len; + .write => return error.WroteACommandLineIntoATerminal, + .spawn => |sp| if (sp.pane == dst) { + // ...in the directory the command was about, the taken pane's own + try std.testing.expectEqualStrings("/tmp/pardes-taken", sp.cwd.slice()); + spawned = true; }, - .spawn => spawned = effect, else => {}, }; - const sp = (spawned orelse return error.NoShellForTheOccupiedTerminal).spawn; - try std.testing.expectEqual(@as(u8, @intCast(dst)), sp.pane); - // ...in the directory the command was about, which is the taken pane's own - try std.testing.expectEqualStrings("/tmp/pardes-taken", sp.cwd.slice()); - try std.testing.expectEqual(@as(usize, 0), sent_len); - p.acknowledgeShell(dst, "/bin/sh", false); - while (p.nextEffect()) |effect| switch (effect) { - .write => |w| { - try std.testing.expectEqual(@as(u8, @intCast(dst)), w.pane); - @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); - sent_len += w.bytes.slice().len; - }, - else => {}, + try std.testing.expect(spawned); +} + +test "a command of several lines keeps its tag one row, and its echo does not staircase" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const doc = try p.newDocPane(1); + doc.image = .{ .path = try gpa.dupe(u8, "/tmp/pic.ppm") }; + const two = "def greet(name):\n return name\n\nprint(greet(\"x\"))\n"; + const dst = exec.execute(p, 1, two) orelse return error.NoCommandPane; + while (p.nextEffect()) |_| {} + const pane = p.panes[dst].?; + const prefix = try tagline.tagPrefix(p, pane); + try std.testing.expect(std.mem.indexOfScalar(u8, prefix, '\n') == null); + try std.testing.expect(std.mem.indexOf(u8, prefix, "(def greet(name):...) running") != null); + // Rendered without a panic, however the tag wraps. + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + p.update(.{ .exited = .{ .pane = @intCast(dst), .status = 0 } }); + _ = exec.execute(p, 1, two); + while (p.nextEffect()) |_| {} + _ = try p.render(frame.allocator()); + const body = try panes.Terminal.screenTextAlloc(pane, gpa); + defer gpa.free(body); + try std.testing.expect(std.mem.indexOf(u8, body, "% def greet(name):\n return name") != null); +} + +test "a spawn is for the pane it was made for, not whatever took the slot since" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const Count = struct { + var spawns: usize = 0; + var command: [32]u8 = undefined; + var core: *Pardes = undefined; + const vtable: Host.VTable = .{ .spawn = spawn }; + fn spawn(_: ?*anyopaque, pane: u8, _: []const u8) void { + spawns += 1; + const line = core.panes[pane].?.command.?; + @memcpy(command[0..line.len], line); + } + }; + Count.core = p; + p.host = .{ .vtable = &Count.vtable }; + // A command pane closed and its slot taken, in one step, before the + // effects run: only the second's spawn is performed. + const slot = p.freeSlot().?; + _ = try p.newCommand(slot, "", "first"); + try p.removePane(slot, null); + try std.testing.expectEqual(slot, p.freeSlot().?); + _ = try p.newCommand(slot, "", "second"); + while (p.nextEffect()) |effect| p.perform(effect); + try std.testing.expectEqual(@as(usize, 1), Count.spawns); + try std.testing.expectEqualStrings("second", Count.command[0.."second".len]); +} + +test "a REPL bound for a language takes the exec made on a body in it; tags, Exec by name and command words still run" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .shells = 3, .cols = 100, .rows = 30 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const file = try p.setTestFile("print(1)\nls\n"); + gpa.free(file.file.?.path); + file.file.?.path = try gpa.dupe(u8, "/x.py"); + const Sent = struct { + var to: [MAX_PANES][128]u8 = undefined; + var len: [MAX_PANES]usize = @splat(0); + var spawned: bool = false; + fn drain(pp: *Pardes) void { + len = @splat(0); + spawned = false; + while (pp.nextEffect()) |effect| switch (effect) { + .write => |w| { + @memcpy(to[w.pane][len[w.pane]..][0..w.bytes.slice().len], w.bytes.slice()); + len[w.pane] += w.bytes.slice().len; + }, + .spawn => spawned = true, + else => {}, + }; + } + fn said(pane: usize) []const u8 { + return to[pane][0..len[pane]]; + } + }; + // The programs the REPLs run hold their terminals' ttys. + var host: FakeTtyQuery = .{}; + host.install(p); + host.taken[1] = true; + host.taken[2] = true; + // Only a terminal is a REPL; a language's name is the syntax table's. + try std.testing.expect(p.executeBuiltinLine(0, "Repl python")); + try std.testing.expect(file.repl == null); + try std.testing.expect(p.executeBuiltinLine(1, "Repl pithon")); + try std.testing.expect(p.panes[1].?.repl == null); + try std.testing.expect(std.mem.indexOf(u8, p.panes[1].?.msg[0..p.panes[1].?.msg_len], "python") != null); + try std.testing.expect(p.executeBuiltinLine(1, "Repl python")); + // again, as a code fence may name it: no change + try std.testing.expect(p.executeBuiltinLine(1, "Repl PY")); + var idbuf: [48]u8 = undefined; + try std.testing.expectEqualStrings("python-a", exec.replId(&idbuf, p.panes[1].?)); + try std.testing.expect(std.mem.endsWith(u8, try tagline.tagPrefix(p, p.panes[1].?), " python-a")); + Sent.drain(p); + + // A click on the file's body, a single word: typed into the REPL. + try std.testing.expectEqual(@as(?usize, 1), exec.executeFrom(p, 0, "ls", true)); + Sent.drain(p); + try std.testing.expectEqualStrings("ls\r", Sent.said(1)); + try std.testing.expect(!Sent.spawned); + try std.testing.expect(file.msg_sent); + try std.testing.expectEqualStrings("\u{2192} python-a", file.msg[0..file.msg_len]); + // A body's click written back to event is done as the click: sent. A + // line written to exec came back as F and runs as the command it was. + { + const th = @import("ninep/testing.zig"); + const event = ctlfs.Node.of(file.serial, .event); + const h = th.call(p, .{ .tag = 1, .op = .open, .node = event }).reply.handle; + defer _ = th.call(p, .{ .tag = 2, .op = .release, .node = event, .handle = h }); + // The 9P harness performs the effects: what it wrote is its answer's. + const sent = th.wr(p, event, "MX0 8\n"); + try std.testing.expectEqualStrings("print(1)\r", sent.pty_buf[0..sent.pty_len]); + const ran = th.wr(p, event, "FX0 0 2 2 ls\n"); + try std.testing.expect(ran.pty_len == 0 and ran.spawned); + // The pane's ctl line ends with the id it is bound as. + var line: [ctlfs.ctl.pane_text_cap]u8 = undefined; + try std.testing.expect(std.mem.endsWith(u8, ctlfs.ctl.paneText(p, p.panes[1].?, &line), " python-a ")); + } + // What is sent is plain text: CRLF is LF, no ESC or other control. + _ = 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)); + // Several selections executed at once: each is sent, not the first. + { + const text = try edit.flatSurface(p, &file.body); + file.body.setRanges(text, &.{ .{ .anchor = 0, .head = 8 }, .{ .anchor = 9, .head = 11 } }, &.{}, 0, true); + try std.testing.expect(exec.chordEachSel(p, file, .Exec)); + Sent.drain(p); + try std.testing.expectEqualStrings("print(1)\rls\r", Sent.said(1)); + } + // Its own body too. + try std.testing.expectEqual(@as(?usize, 1), exec.executeFrom(p, 1, "x = 1", true)); + Sent.drain(p); + try std.testing.expectEqualStrings("x = 1\r", Sent.said(1)); + + // Escapes: a tag's word, Exec by name, a command word. Each a command. + _ = exec.execute(p, 0, "ls"); + Sent.drain(p); + try std.testing.expect(Sent.spawned and Sent.len[1] == 0); + try std.testing.expect(p.executeBuiltinLine(0, "Exec ls")); + Sent.drain(p); + try std.testing.expect(Sent.spawned and Sent.len[1] == 0); + _ = exec.executeFrom(p, 0, "@`ls`", true); + Sent.drain(p); + try std.testing.expect(Sent.spawned and Sent.len[1] == 0); + + // Two bound: it asks, one key answers, Esc sends nowhere. + try std.testing.expect(p.executeBuiltinLine(2, "Repl python")); + try std.testing.expectEqualStrings("python-b", exec.replId(&idbuf, p.panes[2].?)); + Sent.drain(p); + try std.testing.expectEqual(@as(?usize, null), exec.executeFrom(p, 0, "print(2)", true)); + try std.testing.expectEqual(Pane.Prompt.repl_choice, file.prompt); + try std.testing.expect(std.mem.indexOf(u8, file.promptText().?, "a b") != null); + p.update(.{ .key = .{ .cp = 'b' } }); + Sent.drain(p); + try std.testing.expectEqualStrings("print(2)\r", Sent.said(2)); + try std.testing.expect(Sent.len[1] == 0 and file.prompt == .none); + _ = exec.executeFrom(p, 0, "print(3)", true); + p.update(.{ .key = .{ .cp = Key.escape } }); + Sent.drain(p); + try std.testing.expect(Sent.len[1] == 0 and Sent.len[2] == 0); + // The pane a letter offered is the one it answers for: unbound while + // the question stood, it is sent nothing, and that is said. + _ = exec.executeFrom(p, 0, "print(4)", true); + try std.testing.expect(p.executeBuiltinLine(2, "Repl -")); + p.update(.{ .key = .{ .cp = 'b' } }); + Sent.drain(p); + try std.testing.expect(Sent.len[1] == 0 and Sent.len[2] == 0); + try std.testing.expectEqualStrings("Repl: the REPL chosen is gone; nothing sent", file.msg[0..file.msg_len]); + try std.testing.expect(p.executeBuiltinLine(2, "Repl python")); + try std.testing.expectEqualStrings("python-b", exec.replId(&idbuf, p.panes[2].?)); + + // A REPL whose program has left (Ctrl-D: the shell has the tty) is + // sent nothing, and nothing runs: it says so. + host.taken[1] = false; + host.taken[2] = false; + _ = exec.executeFrom(p, 0, "import os", true); + Sent.drain(p); + try std.testing.expect(Sent.len[1] == 0 and Sent.len[2] == 0 and !Sent.spawned); + try std.testing.expect(std.mem.indexOf(u8, file.msg[0..file.msg_len], "no program running") != null); + host.taken[1] = true; + host.taken[2] = true; + + // Unbound, its letter is free again; bare, it says what it is. + try std.testing.expect(p.executeBuiltinLine(1, "Repl -")); + try std.testing.expect(p.panes[1].?.repl == null); + try std.testing.expect(p.executeBuiltinLine(1, "Repl python")); + try std.testing.expectEqualStrings("python-a", exec.replId(&idbuf, p.panes[1].?)); + try std.testing.expect(p.executeBuiltinLine(2, "Repl")); + try std.testing.expectEqualStrings("Repl: python-b", p.panes[2].?.msg[0..p.panes[2].?.msg_len]); + + // A command pane is a REPL while its command runs: done, it is none, + // and a done one cannot be bound. A document pane is no terminal. + const cmd = p.freeSlot().?; + _ = try p.newCommand(cmd, "", "python3"); + try std.testing.expect(p.executeBuiltinLine(cmd, "Repl python")); + try std.testing.expect(p.panes[cmd].?.repl != null); + p.update(.{ .exited = .{ .pane = @intCast(cmd), .status = 0 } }); + try std.testing.expect(p.panes[cmd].?.repl == null); + try std.testing.expect(p.executeBuiltinLine(cmd, "Repl python")); + try std.testing.expect(p.panes[cmd].?.repl == null); + const doc_slot = p.freeSlot().?; + const doc = try p.newDocPane(doc_slot); + try std.testing.expect(p.executeBuiltinLine(doc_slot, "Repl python")); + try std.testing.expect(doc.repl == null); +} + +test "a restored terminal's tag names neither the REPL it was nor the shell it had" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + p.acknowledgeShell(0, "/usr/bin/fish", true); + var host: FakeTtyQuery = .{}; + host.install(p); + try std.testing.expect(p.executeBuiltinLine(0, "Repl python")); + try dump.dumpState(p); + const restored = try dump.restore(p, p.dump_out.?, "test"); + defer restored.deinit(); + const pane = restored.panes[0].?; + try std.testing.expect(pane.repl == null and pane.tag.own == null); + const shown = try tagline.tagText(restored, restored.scratch.allocator(), pane); + try std.testing.expect(std.mem.indexOf(u8, shown, "python-a") == null); + try std.testing.expect(std.mem.indexOf(u8, shown, "Tty+fish") == null); +} + +test "Tty+fish, one word a tag can hold, opens a terminal on that shell" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + // sh, the one shell every host has. + try std.testing.expect(p.executeBuiltinLine(0, "Tty+sh")); + try std.testing.expect(p.active != 0); + try std.testing.expectEqualStrings("sh", p.panes[p.active].?.shell.?); + p.acknowledgeShell(p.active, "/bin/sh", false); + try std.testing.expect(std.mem.startsWith(u8, tagline.curTail(p.panes[p.active].?), " Tty+sh ")); + p.active = 0; + // A shell that is not there is said, not quietly replaced, and the + // pane that asked gets no shell of its own. + const before = p.active; + const caller = p.panes[0].?; + try std.testing.expect(p.executeBuiltinLine(0, "Tty /nonexistent")); + try std.testing.expect(p.active == before and caller.shell == null); + try std.testing.expect(std.mem.indexOf(u8, caller.msg[0..caller.msg_len], "no shell \"/nonexistent\"") != null); + try std.testing.expect(p.executeBuiltinLine(0, "Tty fsh-not-a-shell")); + try std.testing.expect(caller.shell == null); + // Only a word that says so splits at `+`: `Dump+x.zon` is no Dump. + try std.testing.expect(!p.executeBuiltinLine(0, "Dump+x.zon")); + try std.testing.expect(!p.executeBuiltinLine(0, "Msg+hello")); +} + +test "a command pane shows how its command ended, and the next command there runs below it" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{}); + defer p.deinit(); + while (p.nextEffect()) |_| {} + p.setCwd(0, "/tmp/pardes-cmd"); + const doc = try p.newDocPane(1); + doc.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-cmd/pic.ppm") }; + + const dst = exec.execute(p, 1, "make -j8") orelse return error.NoCommandPane; + while (p.nextEffect()) |_| {} + const pane = p.panes[dst].?; + // Its first command is echoed as every later one is. + { + const first = try panes.Terminal.screenTextAlloc(pane, gpa); + defer gpa.free(first); + try std.testing.expect(std.mem.startsWith(u8, first, "% make -j8")); + } + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + try std.testing.expect(std.mem.endsWith(u8, try tagline.tagPrefix(p, pane), "(make -j8) running")); + // Running, a second command goes to a pane of its own. + const other = exec.execute(p, 1, "ls") orelse return error.NoCommandPane; + try std.testing.expect(other != dst); + while (p.nextEffect()) |_| {} + p.update(.{ .output = .{ .pane = @intCast(dst), .bytes = "compiled\r\n" } }); + // Its exit comes first, a job it left still printing: done, and not + // the next command's until its pty closes. + p.update(.{ .exited = .{ .pane = @intCast(dst), .status = 2 } }); + try std.testing.expect(pane.command_done and pane.command_pty); + const busy = exec.execute(p, 1, "true") orelse return error.NoCommandPane; + try std.testing.expect(busy != dst); + while (p.nextEffect()) |_| {} + // Its output ending: now it may be reused. + p.update(.{ .eof = .{ .pane = @intCast(dst) } }); + try std.testing.expect(p.panes[dst] == pane and !pane.command_pty); + try std.testing.expect(p.panes[dst] == pane); + try std.testing.expect(pane.command_done); + try std.testing.expect(std.mem.endsWith(u8, try tagline.tagPrefix(p, pane), "(make -j8) exit 2")); + // Kill leaves a finished one be. + try std.testing.expect(p.executeBuiltinLine(0, "Kill make")); + while (p.nextEffect()) |effect| if (effect == .signal_pty and effect.signal_pty.pane == dst) return error.SignalledAFinishedCommand; + // A program that crashed out of its full screen leaves the emulator + // in its modes; the next command starts clear of them. + p.update(.{ .output = .{ .pane = @intCast(dst), .bytes = "\x1b[?1049h\x1b[?2004h\x1b[?1000h" } }); + try std.testing.expect(panes.Terminal.bracketedPaste(pane)); + // Done, the directory's next command runs there, below what it showed. + try std.testing.expectEqual(@as(?usize, dst), exec.execute(p, 1, "make test")); + try std.testing.expect(!panes.Terminal.bracketedPaste(pane)); + var respawned = false; + while (p.nextEffect()) |effect| if (effect == .spawn and effect.spawn.pane == dst) { + respawned = true; + }; + try std.testing.expect(respawned); + const body = try panes.Terminal.screenTextAlloc(pane, gpa); + defer gpa.free(body); + const said = std.mem.indexOf(u8, body, "compiled") orelse return error.OutputLost; + const ended = std.mem.indexOf(u8, body, "exit 2") orelse return error.ExitLost; + const next = std.mem.indexOf(u8, body, "% make test") orelse return error.NoSeparator; + try std.testing.expect(said < ended and ended < next); + try std.testing.expect(std.mem.endsWith(u8, try tagline.tagPrefix(p, pane), "(make test) running")); + // Kill stops a running one: its whole process group, SIGTERM. + try std.testing.expect(p.executeBuiltinLine(0, "Kill make")); + var signalled = false; + while (p.nextEffect()) |effect| if (effect == .signal_pty and effect.signal_pty.pane == dst) { + try std.testing.expectEqual(PtySignal.term, effect.signal_pty.sig); + signalled = true; }; - try std.testing.expectEqualStrings("echo taken-fallback\r", sent[0..sent_len]); + try std.testing.expect(signalled); + // The log says what ran and how it ended. + const log = try ctlfs.events.freezeLog(p); + defer gpa.free(log.bytes); + var want: [64]u8 = undefined; + // The whole line, after the `new` of the pane it runs in. + const ran = std.mem.indexOf(u8, log.bytes, try std.fmt.bufPrint(&want, "run {d} make -j8\n", .{pane.serial})) orelse return error.NoRunRecord; + const made = std.mem.indexOf(u8, log.bytes, try std.fmt.bufPrint(&want, "new {d} ", .{pane.serial})) orelse return error.NoNewRecord; + try std.testing.expect(made < ran); + try std.testing.expect(std.mem.indexOf(u8, log.bytes, try std.fmt.bufPrint(&want, "exit {d} 2\n", .{pane.serial})) != null); + // A signal for a pane is not performed on whoever takes its slot. + const Count = struct { + var signals: usize = 0; + const vtable: Host.VTable = .{ .pty_signal = signal }; + fn signal(_: ?*anyopaque, _: u8, _: PtySignal) void { + signals += 1; + } + }; + const host_before = p.host; + p.host = .{ .vtable = &Count.vtable }; + try std.testing.expect(p.executeBuiltinLine(0, "Kill make")); + try p.removePane(dst, null); + _ = try p.newCommand(dst, "", "other"); + while (p.nextEffect()) |effect| if (effect == .signal_pty) p.perform(effect); + try std.testing.expectEqual(@as(usize, 0), Count.signals); + p.host = host_before; + // A command line longer than a command pane takes is refused. + const long = "x" ** (exec.command_max + 1); + try std.testing.expectEqual(@as(?usize, null), exec.execute(p, 1, long)); } + test "image dump restores source bytes renderer choices and exact custom tail" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); @@ -1423,50 +1772,6 @@ test "image dump restores source bytes renderer choices and exact custom tail" { try std.testing.expectEqualStrings(" Keep Del", redump.panes[0].tag_tail.?); } -test "Exec from a document pane skips an occupied terminal in its directory and spawns" { - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{}); - defer p.deinit(); - while (p.nextEffect()) |_| {} - - p.setCwd(0, "/tmp/pardes-image-dir"); - const image_doc = try p.newDocPane(1); - image_doc.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-image-dir/pic.ppm") }; - var host: FakeTtyQuery = .{}; - host.install(p); - host.taken[0] = true; - - const dst = exec.execute(p, 1, "echo image-fallback") orelse return error.ExecFoundNowhereToRun; - try std.testing.expect(dst != 0 and dst != 1); - - var spawned: ?Effect = null; - var sent: [256]u8 = undefined; - var sent_len: usize = 0; - while (p.nextEffect()) |effect| switch (effect) { - .write => |w| { - if (w.pane == 0) return error.WroteACommandLineIntoTheProgramOnTheTty; - try std.testing.expectEqual(@as(u8, @intCast(dst)), w.pane); - try std.testing.expect(w.bytes.slice().len <= sent.len - sent_len); - @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); - sent_len += w.bytes.slice().len; - }, - .spawn => spawned = effect, - else => {}, - }; - const sp = (spawned orelse return error.NoShellForTheOccupiedTerminal).spawn; - try std.testing.expectEqualStrings("/tmp/pardes-image-dir", sp.cwd.slice()); - try std.testing.expectEqual(@as(usize, 0), sent_len); - p.acknowledgeShell(dst, "/bin/sh", false); - while (p.nextEffect()) |effect| switch (effect) { - .write => |w| { - try std.testing.expectEqual(@as(u8, @intCast(dst)), w.pane); - @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); - sent_len += w.bytes.slice().len; - }, - else => {}, - }; - try std.testing.expectEqualStrings("echo image-fallback\r", sent[0..sent_len]); -} test "a Look on a directory does not type ls into an occupied terminal" { const gpa = std.testing.allocator; @@ -1569,11 +1874,13 @@ test "the host is asked about a tty only where a command line is about to go" { while (p.nextEffect()) |_| {} try std.testing.expectEqual(@as(usize, 1), host.asked); + // ...and a taken one is asked once too: the line then runs as a command + // pane, with no other terminal to ask about. host.taken[0] = true; host.asked = 0; _ = exec.execute(p, 0, "echo lazy-again"); while (p.nextEffect()) |_| {} - try std.testing.expectEqual(@as(usize, 2), host.asked); + try std.testing.expectEqual(@as(usize, 1), host.asked); } test "New opens an empty scratch below the caller, inheriting its directory" { @@ -1626,14 +1933,14 @@ test "owned cwd preserves long paths aliases and failed updates" { try std.testing.expect(p.panes[free] == null); exec.saveTo(p, 0, "file.txt"); try std.testing.expectEqual(effects, p.effects_len); - try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "PathTooLong") != null); + try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "path too long") != null); const current = pane.cwdSlice(); allocator.fail_index = allocator.alloc_index; p.setCwd(0, "/replacement"); try std.testing.expectEqual(current.ptr, pane.cwdSlice().ptr); try std.testing.expectEqualStrings(&path, pane.cwdSlice()); - try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "OutOfMemory") != null); + try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "out of memory") != null); allocator.fail_index = std.math.maxInt(usize); p.setCwd(0, "/replacement"); try std.testing.expectEqualStrings("/replacement", pane.cwdSlice()); @@ -1765,7 +2072,7 @@ test "owned cwd restore either copies the directory or preserves the old core" { for (0..256) |failure| { allocator.has_induced_failure = false; allocator.fail_index = allocator.alloc_index + failure; - const replacement = dump.restore(p, p.dump_out.?) catch { + const replacement = dump.restore(p, p.dump_out.?, "test") catch { try std.testing.expect(allocator.has_induced_failure); try std.testing.expectEqual(original, p.panes[0].?); try std.testing.expectEqualStrings("/retained/terminal/directory", original.cwdSlice()); @@ -2155,7 +2462,7 @@ test "a host with no methods at all is a complete in-process pardes" { try std.testing.expectEqual(before, p.panes[0].?.terminal.?.vt.screens.active.cursor.y); // the clipboard round-trips through the in-process one - p.yank = try gpa.dupe(u8, "copied"); + p.registers.put(gpa, '+', "copied", 0, 1, true); p.perform(.set_clipboard); try std.testing.expectEqualStrings("copied", p.fallback.clipboard.items); @@ -2184,7 +2491,7 @@ test "a builtin that needs a pane reports capacity failure when every slot is fu }; const help = std.meta.stringToEnum(Builtin, "Help") orelse return error.MissingHelpBuiltin; exec.runBuiltin(p, help, 0, "", null); - try std.testing.expect(std.mem.indexOf(u8, p.panes[0].?.msg[0..p.panes[0].?.msg_len], "help: NoPaneSlots") != null); + try std.testing.expect(std.mem.indexOf(u8, p.panes[0].?.msg[0..p.panes[0].?.msg_len], "help: no pane slots") != null); } test "Font keeps requested and acknowledged faces as separate plain state" { @@ -2717,14 +3024,14 @@ test "editable workspace and column tags are typed into and persist" { try std.testing.expect(std.mem.startsWith(u8, tagline.columnTag(p, 0), "Grep New")); try std.testing.expectEqualStrings("untouched\n", p.panes[0].?.file.?.content); try dump.dumpState(p); - const restored = try dump.restore(p, p.dump_out.?); + const restored = try dump.restore(p, p.dump_out.?, "test"); defer restored.deinit(); try std.testing.expectEqualStrings(p.global_tag.own.?, restored.global_tag.own.?); try std.testing.expectEqualStrings(tagline.columnTag(p, 0), tagline.columnTag(restored, 0)); p.gpa.free(p.col_tags[0].own.?); p.col_tags[0].own = try p.gpa.dupe(u8, ""); try dump.dumpState(p); - const empty = try dump.restore(p, p.dump_out.?); + const empty = try dump.restore(p, p.dump_out.?, "test"); defer empty.deinit(); try std.testing.expectEqualStrings("", tagline.columnTag(empty, 0)); } @@ -2948,7 +3255,7 @@ test "s in a header selects in the header's own text" { const text = try edit.flatSurface(p, &p.global_tag); const got = p.global_tag.ranges(text, 0, &ranges); try std.testing.expectEqual(@as(usize, 3), got.n); // Newcol, Joincol, NextColor - try std.testing.expectEqual(@as(u8, 0), pane.body.nsel); + try std.testing.expectEqual(@as(u16, 0), pane.body.nsel); } test "Enter in a header runs the word under its cursor, as the execute key does" { @@ -3076,7 +3383,13 @@ pub const Event = union(enum) { row_metrics: ?RowMetrics = null, }, output: struct { pane: u8, bytes: []const u8 }, - eof: struct { pane: u8 }, + /// The pane's pty reached end of file: its output is over. An + /// interactive terminal closes; a command pane waits for `exited`. + eof: struct { pane: u8, status: ?u8 = null }, + /// A command pane's child exited, with this status (null: unknown). It + /// is the command's end, whatever still holds its pty (a job it left in + /// the background prints on until end of file). + exited: struct { pane: u8, status: ?u8 }, lsp_resp: struct { id: u32, rows: ?[]const u8 }, pipe_resp: struct { id: u32, @@ -3114,10 +3427,14 @@ pub const WatchMode = enum { reconcile, baseline_disk }; pub const effect_path_cap = 256; pub const Effect = union(enum) { - spawn: struct { pane: u8, cwd: Buf(effect_path_cap) }, + /// A child for the pane in slot `pane` -- the one whose serial is + /// `serial`: a pane closed and its slot taken before the effect is + /// performed is not the one it was for (perform). + spawn: struct { pane: u8, serial: u32, cwd: Buf(effect_path_cap) }, write: struct { pane: u8, bytes: Buf(64) }, resize_pty: struct { pane: u8, cols: u16, rows: u16 }, - signal_pty: struct { pane: u8, sig: PtySignal }, + /// For the pane whose serial is `serial`, as `spawn` is (perform). + signal_pty: struct { pane: u8, serial: u32, sig: PtySignal }, /// The terminal pane in this slot is gone: the shell hangs up its pty /// and ends the shell, rather than leave it running with nobody to read. close_pty: struct { pane: u8 }, @@ -3801,6 +4118,9 @@ pub const Pardes = struct { /// it is dragged instead of once on release. native_image_patches: bool = false, quit: bool = false, + /// A detached session with no frontend attached: nothing renders, so + /// no input ever dismisses a message (Messages.expireUnattended). + unattended: bool = false, haptic: HapticSlot = if (platform == .macos) .none else {}, drag: mouse.Drag = .none, hover_col: u16 = 0, @@ -3819,6 +4139,14 @@ pub const Pardes = struct { multi_on: bool = false, multi_first: bool = false, multi_stop: bool = false, + /// While a key is replayed across the ranges: which range this pass is, + /// in document order, and how many there are. A register value belongs + /// to its range by this index. + multi_index: usize = 0, + multi_count: usize = 1, + /// The register the running normal command names (`"<reg>`); 0 for the + /// default one. + register: u21 = 0, leader_on: bool = false, leader_keys: [4]u8 = undefined, leader_n: u8 = 0, @@ -3839,6 +4167,11 @@ pub const Pardes = struct { chord_arg: ?[]u8 = null, /// how many `execute` calls are on the stack — see max_exec_depth exec_depth: u8 = 0, + /// An exec waiting on the REPL question (`Pane.Prompt.repl_choice`): + /// the pane it came from, the language, and the text, owned. + /// `offered` holds, by letter, the serial of the pane each answer named + /// when asked: a letter answered later is that pane or nothing. + repl_ask: ?struct { from: u32, lang: u8, text: []u8, offered: [26]u32 = @splat(0) } = null, /// A key press is being handled: someone is at the keyboard to answer, /// so a builtin may ask rather than guess. Nothing else sets it -- not /// a click, a 9P write, a startup line or a pane's shell exiting. @@ -3901,7 +4234,10 @@ pub const Pardes = struct { in_head: usize = 0, in_len: usize = 0, - yank: ?[]u8 = null, + registers: Registers = .{}, + macro: Macro = .{}, + /// an edited text nothing kept (a tag's shown text): see edit.retireEditText + retired_edit: ?[]u8 = null, clip_pending: ?edit.ClipRequest = null, /// the last serialized dump (gpa-owned), read by the write_dump effect dump_out: ?[]u8 = null, @@ -3947,6 +4283,8 @@ pub const Pardes = struct { .pdf_gpa = pdf_gpa, .tree_sitter_gpa = tree_sitter_gpa, .opts = opts, + .col_tags = @splat(.{ .what = .column, .gpa = gpa }), + .global_tag = .{ .what = .workspace, .gpa = gpa }, .fs = .{}, .screen_w = opts.cols, .screen_h = opts.rows, @@ -4055,6 +4393,7 @@ pub const Pardes = struct { pub fn deinit(p: *Pardes) void { look.cancelLookHover(p); + if (p.repl_ask) |ask| p.gpa.free(ask.text); for (0..MAX_PANES) |id| p.dropPendingWrite(id); for (&p.panes) |*slot| if (slot.*) |pane| { p.teardownPane(pane); @@ -4071,7 +4410,9 @@ pub const Pardes = struct { const gpa = p.gpa; for (&p.col_tags) |*t| t.deinit(gpa); p.global_tag.deinit(gpa); - if (p.yank) |y| gpa.free(y); + p.registers.deinit(gpa); + p.macro.deinit(gpa); + if (p.retired_edit) |r| gpa.free(r); if (p.dump_out) |d| gpa.free(d); p.fallback.deinit(); if (p.last_dump) |d| gpa.free(d); @@ -4097,6 +4438,7 @@ pub const Pardes = struct { const copy = p.gpa.dupe(u8, path) catch return; if (p.last_dump) |old| p.gpa.free(old); p.last_dump = copy; + ctlfs.events.notePath(p, "dump", path); } /// the shell polls this each frame: a pending Restore's dump path, or null @@ -4181,6 +4523,8 @@ pub const Pardes = struct { p.shell_rows.dropPane(pane); for (0..pane.pointer_selections.len) |slot| pane.clearPointerSelection(slot); panes.Terminal.deinitPendingCommand(pane); + if (pane.command) |line| p.gpa.free(line); + if (pane.shell) |bin| p.gpa.free(bin); if (pane.image) |*iv| { iv.deinit(p.image_gpa); } @@ -4190,6 +4534,7 @@ pub const Pardes = struct { pane.body.deinit(p.gpa); pane.tag.deinit(p.gpa); pane.input.deinit(p.gpa); + pane.jump.deinit(p.gpa); panes.Terminal.deinitEmulator(pane, p.gpa); pane.clearCwd(); pane.fs.deinit(p.gpa); @@ -4222,7 +4567,27 @@ pub const Pardes = struct { const pane = try panes.Terminal.create(p.gpa, p.screen_w, p.screen_h); panes.Terminal.armShellSpawn(pane); p.installPane(id, pane); - p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(cwd) } }); + p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = pane.serial, .cwd = .from(cwd) } }); + return pane; + } + + /// A command pane: a terminal whose child is `$SHELL -c line` rather + /// than an interactive shell, left showing its output and `exit N` when + /// it is done (exec.zig runCommand). + pub fn newCommand(p: *Pardes, id: usize, cwd: []const u8, line: []const u8) !*Pane { + std.debug.assert(p.panes[id] == null); + if (cwd.len > effect_path_cap) return error.PathTooLong; + const owned = try p.gpa.dupe(u8, line); + errdefer p.gpa.free(owned); + const pane = try panes.Terminal.create(p.gpa, p.screen_w, p.screen_h); + pane.command = owned; + pane.command_pty = true; + pane.body.mode = .tty; + // Its directory is the one it was run for, and says which of the + // directory's commands reuse it; the child's own cd does not move it. + pane.setOwnedCwd(cwd) catch {}; + p.installPane(id, pane); + p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = pane.serial, .cwd = .from(cwd) } }); return pane; } @@ -4300,6 +4665,14 @@ pub const Pardes = struct { if (off < bytes.len) p.parkPendingWrite(id, bytes[off..]); } + /// How many bytes `emitWrite` can take for pane `id` now without + /// dropping any: the effects left, then what the pending buffer holds. + pub fn writeRoom(p: *const Pardes, id: usize) usize { + const pending = limits.pending_write_cap -| p.pending_write_bytes; + if (p.pending_write[id] != null) return pending; + return (p.effects.len - p.effects_len) * 64 + pending; + } + fn parkPendingWrite(p: *Pardes, id: usize, bytes: []const u8) void { if (comptime limits.pending_write_cap == 0) return; if (p.pending_write_bytes + bytes.len > limits.pending_write_cap) return; @@ -4322,6 +4695,8 @@ pub const Pardes = struct { /// changes it observes, e.g. via /proc/<pid>/cwd before each frame). pub fn setCwd(p: *Pardes, id: usize, cwd: []const u8) void { const pane = p.panes[id] orelse return; + // A command pane stays the directory's it was run for (newCommand). + if (pane.command != null) return; const cur = switch (pane.cwd) { .owned => |dir| dir, else => "", @@ -4343,6 +4718,15 @@ pub const Pardes = struct { if (id < MAX_PANES) if (p.panes[id]) |pane| { ctlfs.pty.shellGone(p, pane, true); // a respawn replaced whatever ran pane.fs.unmarked = !prompt_marks; + // The tag's Tty word names the shell the host ran, and a click + // on it (`Tty+fish`) opens another of the same. + if (pane.command == null) { + const base = std.fs.path.basename(executable); + const rest = tagline.terminal_pane_tail[" Tty".len..]; + if (std.fmt.bufPrint(&pane.shell_tail, " Tty+{s}{s}", .{ base, rest })) |tail| { + pane.shell_tail_len = @intCast(tail.len); + } else |_| {} + } }; if (id < MAX_PANES) panes.Terminal.shellSpawned(p, id, prompt_marks); if (p.settings.shell.effective.set(executable)) @@ -4557,14 +4941,18 @@ pub const Pardes = struct { pub fn perform(p: *Pardes, e: Effect) void { const v = p.host.vtable; switch (e) { - .spawn => |s| if (v.spawn) |f| f(p.host.ctx, s.pane, s.cwd.slice()) else { + // The host reads the pane (its command, its shell) as it forks, + // so the slot must still hold the pane the spawn was for. + .spawn => |s| if (if (p.panes[s.pane]) |pane| pane.serial != s.serial else true) {} else if (v.spawn) |f| f(p.host.ctx, s.pane, s.cwd.slice()) else { p.fallback.spawned[s.pane] = true; }, // A pane with no child is silent: nothing invents output on its // screen, and the bytes are dropped rather than transcribed. .write => |w| if (v.pty_write) |f| f(p.host.ctx, w.pane, w.bytes.slice()), .resize_pty => |r| if (v.pty_resize) |f| f(p.host.ctx, r.pane, r.cols, r.rows), - .signal_pty => |s| if (v.pty_signal) |f| f(p.host.ctx, s.pane, s.sig), + // A signal for a pane that closed, its slot retaken in the same + // step, would reach another pane's child. + .signal_pty => |s| if (if (p.panes[s.pane]) |pane| pane.serial != s.serial else true) {} else if (v.pty_signal) |f| f(p.host.ctx, s.pane, s.sig), .close_pty => |c| if (v.close_pty) |f| f(p.host.ctx, c.pane), .open_link => |u| if (v.open_link) |f| f(p.host.ctx, u.slice()) @@ -4604,7 +4992,7 @@ pub const Pardes = struct { } }, .set_clipboard => { - const text = p.yank orelse ""; + const text = p.registers.text(p.gpa, '+') orelse ""; if (v.set_clipboard) |f| f(p.host.ctx, text) else p.fallback.setClipboard(text); }, // No desktop to ask: answer from the in-process clipboard at once, @@ -4691,6 +5079,10 @@ pub const Pardes = struct { 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); + // A pane that joined a column holding only a stillborn scratch takes + // it now, before the answer: one read of index after the look shows + // the replacement done, and the log has its del with the new. + exec.replaceStillborn(p); // 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 @@ -4707,6 +5099,7 @@ pub const Pardes = struct { // the screen. p.needs_frame = p.needs_frame or ev != .tick; p.shell_rows.sweep(p.gpa); + Messages.expireUnattended(p); // An announcement stands in only until its own step says more. for (p.panes) |slot| if (slot) |pane| { pane.msg_announcement = false; @@ -4786,8 +5179,14 @@ pub const Pardes = struct { panes.Terminal.feedOutput(p, pane, o.bytes); ctlfs.pty.noteMarks(p, o.pane, pane); }, - .eof => |e| p.removePane(e.pane, null) catch |err| { - if (p.panes[e.pane]) |pane| pane.body.mode = .normal; + .exited => |e| if (p.panes[e.pane]) |pane| if (pane.command != null and !pane.command_done) { + exec.commandDone(p, e.pane, e.status); + }, + .eof => |e| if (p.panes[e.pane]) |pane| if (pane.command != null) { + // Output is over; the command is over when its child exits. + pane.command_pty = false; + } else p.removePane(e.pane, null) catch |err| { + pane.body.mode = .normal; p.reportError(e.pane, "terminal exited; Del retries close", err); }, .lsp_resp => |r| p.lspResponse(r.id, r.rows), @@ -4896,11 +5295,42 @@ pub const Pardes = struct { } else panes.Terminal.enterTty(p, id); } + /// Type `keys` again (a macro, `.`): through the modal handling as + /// typed, except that no key leaves the text for pardes's own acts -- + /// Esc does not hop panes, and Enter and Tab neither look nor execute. + pub fn replayKeys(p: *Pardes, keys: []const Macro.Stored) void { + if (p.macro.replaying >= 8) return; // a macro typing itself + p.macro.replaying += 1; + defer p.macro.replaying -= 1; + for (keys) |stored| { + var buf: [4]u8 = undefined; + p.handleKey(stored.typed(&buf)); + } + } + fn handleKey(p: *Pardes, key: Key) void { + if (p.macro.recording != 0 and p.macro.replaying == 0) Macro.append(&p.macro.recorded, p.gpa, key); // A question waiting on a pane takes the next key, whatever mode the // pane or a header edit is in: that key is its answer. for (p.panes, 0..) |slot, id| { const asked = slot orelse continue; + if (asked.prompt == .repl_choice) { + asked.prompt = .none; + const ask = p.repl_ask orelse return; + p.repl_ask = null; + defer p.gpa.free(ask.text); + // Its letter sends it there; anything else takes it back. + if (key.ctrl or key.alt or key.cp > 'z' or key.cp < 'a') return; + const serial = ask.offered[key.cp - 'a']; + if (serial == 0) return; + // The pane offered, still that REPL and still running: it + // may have closed or been rebound while the question stood. + if (p.paneBySerial(serial)) |to| if (p.panes[to].?.repl) |r| + if (r.lang == ask.lang and r.letter == key.cp and exec.replLive(p, to)) + return exec.sendToRepl(p, id, to, ask.text); + p.setMessage(id, "Repl: the REPL chosen is gone; nothing sent"); + return; + } if (asked.prompt != .del_side) continue; asked.prompt = .none; if (hit(key, config.del_above)) exec.runBuiltin(p, .Del, id, "", "k"); @@ -4934,7 +5364,7 @@ pub const Pardes = struct { // in it the chord is the program's -- vim's visual block, // readline's quoted-insert -- and swallowing it would make // Ctrl-V a black hole in every full-screen application. - if (p.yank) |text| return edit.typeToTty(p, p.active, pane, text); + if (p.registers.text(p.gpa, Registers.default)) |text| return edit.typeToTty(p, p.active, pane, text); } return panes.Terminal.forwardKey(p, p.active, key); } @@ -5011,8 +5441,8 @@ pub const Pardes = struct { .search => if (submit) look.submitSearch(p, p.active) else if (normal.selRegexArmed(pane)) |_| if (target) |answered| - normal.applySelRegex(p, pane, answered, "", false), - .none, .del_side, .name => unreachable, + normal.applySelRegex(p, pane, answered, "", .select), + .none, .del_side, .repl_choice, .name => unreachable, } pane.closePrompt(); if (target) |answered| { @@ -5022,10 +5452,11 @@ pub const Pardes = struct { return; } edit.handleInsert(p, &pane.input, key); - if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |answered| normal.applySelRegex(p, pane, answered, a.pat, a.split); + if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |answered| normal.applySelRegex(p, pane, answered, a.pat, a.mode); return; } - if (t.mode == .normal and (hit(key, config.look_key) or hit(key, config.exec_key))) { + if (t == &pane.body and pane.jump.items.len > 0) return normal.jumpKey(p, pane, t, key); + if (t.mode == .normal and p.macro.replaying == 0 and (hit(key, config.look_key) or hit(key, config.exec_key))) { // A header's words are the workspace's and column's commands, so // Enter runs them there, as the execute key does. const header_word = t.what == .column or t.what == .workspace; @@ -5058,32 +5489,59 @@ pub const Pardes = struct { pane.body.vsel.active = false; pane.body.msel.active = false; pane.body.select = false; - exec.runBuiltin(p, cmd, p.active, "", txt); + // The execute key on a body: text a bound REPL may take. + exec.runBuiltinFrom(p, cmd, p.active, "", txt, true); return; } } const sel = look.expandedSel(p, pane, look.cursorWordSel(p, pane)) orelse return; const word = edit.selectionText(p, pane, sel) catch return; - exec.runBuiltin(p, cmd, p.active, "", word); + exec.runBuiltinFrom(p, cmd, p.active, "", word, true); return; } if (t.mode == .normal and t.what == .body and pane.hasPdf()) return normal.handlePdfNormal(p, pane, key); if (t.what == .tag and t.mode == .insert and tagline.typedIntoName(p, pane, key)) return; // A tag's prefix is read-only: a key whose edit reached into it // changes nothing, not even where the cursor is. - const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel }; + const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel }; + var kept_sels: [Text.max_selections - 1]Text.SelRange = undefined; + const kept_n = t.nsel; + @memcpy(kept_sels[0..kept_n], t.sels[0..kept_n]); t.refused = false; defer if (t.refused) { - t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel = kept; + t.cur_row, t.cur_col, t.vsel, t.msel = kept; + t.restoreSels(kept_sels[0..kept_n]); }; + const was = t.mode; switch (t.mode) { .normal => normal.handleNormal(p, t, key), - .insert => { - if (hit(key, config.escape)) return edit.exitInsert(p, t); - edit.handleInsert(p, t, key); - }, + .insert => if (hit(key, config.escape) and !t.reg_wait) + edit.exitInsert(p, t) + else + edit.handleInsert(p, t, key), .tty => panes.Terminal.forwardKey(p, p.active, key), } + // `.`'s log: the keys of the normal command that entered insert + // mode, then everything typed until it is left + if (p.macro.repeating) return; + const m = &p.macro; + switch (was) { + .normal => { + Macro.append(&m.command, p.gpa, key); + if (t.mode == .insert) { + m.last_insert.clearRetainingCapacity(); + m.last_insert.appendSlice(p.gpa, m.command.items) catch {}; + m.typed_from = m.last_insert.items.len; + m.inserting = true; + m.command.clearRetainingCapacity(); + } else if (std.meta.eql(t.normal, modal.Normal.State{})) m.command.clearRetainingCapacity(); + }, + .insert => if (m.inserting) { + Macro.append(&m.last_insert, p.gpa, key); + if (t.mode != .insert) m.inserting = false; + }, + .tty => {}, + } } fn leaderKey(p: *Pardes, key: Key) void { @@ -5480,7 +5938,10 @@ pub const Pardes = struct { // ---- dump / load (acme-style: `pardes -l state.zon` restores a session) ---- /// Close a pane; its rows go to `to`, or where layout.absorbVWeight - /// sends them when that is null. + /// sends them when that is null. Focus on it follows its rows: to the + /// pane that grew into them, or to the scratch left standing in an + /// emptied column. Every route that closes a pane comes through here or + /// removeColumn, so none picks focus for itself, and none by recency. pub fn removePane(p: *Pardes, id: usize, to: ?usize) !void { const pane = p.panes[id] orelse return; // A column outlives its panes. Closing the last one leaves an empty @@ -5507,10 +5968,11 @@ pub const Pardes = struct { layout.insert(p, f.col, f.idx + 1, free); break :heir free; }; - layout.absorbVWeight(p, id, to); + const gainer = layout.absorbVWeight(p, id, to); + const beside = if (last_in_column) p.besideColumn(place.?.col, id) else null; layout.removePane(p, id); p.panes[id] = null; - if (p.active == id) p.active = heir orelse p.prevFocus(id) orelse { + if (p.active == id) p.active = heir orelse gainer orelse beside orelse p.anyPane() orelse { p.quit = true; p.emit(.quit); return; @@ -5519,6 +5981,11 @@ pub const Pardes = struct { pub fn removeColumn(p: *Pardes, id: usize) !void { const place = layout.findPane(p, id) orelse return; + const had_focus = layout.findPane(p, p.active); + const beside = if (had_focus != null and had_focus.?.col == place.col) + p.besideColumn(place.col, p.active) + else + null; var ids: [MAX_PANES]usize = undefined; var parents: [MAX_PANES]*Pane = undefined; const count = p.col_n[place.col]; @@ -5532,7 +5999,7 @@ pub const Pardes = struct { layout.removePane(p, closed); p.panes[closed] = null; } - if (p.panes[p.active] == null) p.active = p.prevFocus(p.active) orelse { + if (p.panes[p.active] == null) p.active = beside orelse p.anyPane() orelse { p.quit = true; p.emit(.quit); return; @@ -5555,16 +6022,26 @@ pub const Pardes = struct { return null; } - pub fn prevFocus(p: *Pardes, closing: usize) ?usize { - var i = p.njumps; - while (i > 0) { - i -= 1; - const id = p.jumps[i].pane; - if (id != closing and p.panes[id] != null) return id; - } - for (p.panes, 0..) |slot, k| { - if (slot != null and k != closing) return k; + /// The pane that takes over `id`'s place when column `col` goes: in the + /// column layout.removePane gives the width to, the one level with the + /// middle of `id`. Asked before the column goes, while rects still hold. + fn besideColumn(p: *const Pardes, col: usize, id: usize) ?usize { + if (p.ncol < 2) return null; + const next = if (col > 0) col - 1 else col + 1; + const r = p.rects[id]; + const mid = r.y + r.h / 2; + var pick: ?usize = null; + for (p.col_panes[next][0..p.col_n[next]]) |k| { + if (p.panes[k] == null) continue; + pick = k; + if (p.rects[k].y +| p.rects[k].h > mid) break; } + return pick; + } + + /// Any live pane: the last resort when nothing took the closed one's place. + fn anyPane(p: *const Pardes) ?usize { + for (p.panes, 0..) |slot, k| if (slot != null) return k; return null; } @@ -6251,6 +6728,7 @@ test "Del from a key asks which neighbour takes the rows; nothing else asks" { // A word that is not a side closes nothing. try std.testing.expect(p.executeBuiltinLine(again, "Del x")); + try std.testing.expectEqualStrings("Del: takes k, j or nothing", p.panes[again].?.msg[0..p.panes[again].?.msg_len]); try std.testing.expect(p.panes[again] != null); // DelAbove is Del k, and a line that is not a key never asks. try std.testing.expect(p.executeBuiltinLine(again, "DelAbove")); @@ -6278,6 +6756,59 @@ test "Del from a key asks which neighbour takes the rows; nothing else asks" { try std.testing.expect(p.panes[low] == null); } +test "a Look that finds nothing says so" { + if (platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + p.newScratchBelow(p.active); + const id = p.active; + p.sync(); + look.lookAt(p, id, "/no/such/zzqq"); + try std.testing.expectEqualStrings("Look: no match for \"/no/such/zzqq\"", p.panes[id].?.msg[0..p.panes[id].?.msg_len]); +} + +test "focus on a closed pane goes to the pane that takes its place, not the one focused before" { + if (platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 40 }); + defer p.deinit(); + const shell = p.active; + p.newScratchColumn(shell); + const right = p.active; + p.sync(); + p.newScratchBelow(shell); + const low = p.active; + p.sync(); + // The dogfood case: `right` was focused just before `low`, but `shell` + // grows into `low`'s rows, so `shell` gets the focus. + try std.testing.expect(p.executeBuiltinLine(low, "Del")); + p.sync(); + try std.testing.expect(p.panes[low] == null); + try std.testing.expectEqual(shell, p.active); + + // Closing a pane that has no focus leaves the focus where it is. + p.newScratchBelow(shell); + const other = p.active; + p.sync(); + p.active = right; + p.sync(); + try std.testing.expect(p.executeBuiltinLine(other, "Del")); + try std.testing.expectEqual(right, p.active); + + // Delcol: the column that takes the width holds, level with the closed + // pane, the pane that takes its place; `right` was focused just before. + p.newScratchBelow(right); + const right_low = p.active; + p.newScratchBelow(shell); + const left_low = p.active; + p.active = right; + p.sync(); + p.active = left_low; + p.sync(); + try p.removeColumn(left_low); + p.sync(); + try std.testing.expectEqual(right_low, p.active); +} + test "closing the last pane in a column leaves an empty one in its place" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 24 }); @@ -6431,7 +6962,7 @@ test "raw tty Escape only leaves at a shell prompt" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24, .file = "mise.toml" }); defer p.deinit(); - exec.spawnTty(p, 0); + _ = exec.spawnTty(p, 0); const shell = p.active; const pane = p.panes[shell].?; var buf: [256]u8 = undefined; @@ -6462,7 +6993,7 @@ test "raw tty Ctrl-B switches to editor mode without sending child input" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .file = "mise.toml" }); defer p.deinit(); - exec.spawnTty(p, 0); + _ = exec.spawnTty(p, 0); const shell = p.active; p.sync(); var buf: [256]u8 = undefined; @@ -6902,7 +7433,7 @@ test "jump history clears selections and lands at the recorded cursor" { try std.testing.expectEqual(@as(usize, 2), p.njumps); try std.testing.expectEqual(@as(u32, 3), p.jumps[0].line); try std.testing.expect(!pane.body.vsel.active and !pane.body.msel.active and !pane.body.select); - try std.testing.expectEqual(@as(u8, 0), pane.body.nsel); + try std.testing.expectEqual(@as(u16, 0), pane.body.nsel); try std.testing.expectEqual(.none, pane.sel[0].state); try std.testing.expect(pane.pointer_selections[0] == null); try std.testing.expectEqual(@as(i32, if (index == 0) 2 else 0), pane.body.cur_row); |
