summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig747
1 files changed, 502 insertions, 245 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index eac21a14..6294b6cb 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -452,6 +452,7 @@ test {
_ = @import("tagline.zig");
_ = @import("exec.zig");
_ = @import("look.zig");
+ _ = @import("sam_edit.zig");
_ = @import("mouse.zig");
_ = @import("normal.zig");
_ = @import("regexp.zig");
@@ -1091,13 +1092,13 @@ test "Joincol folds the active column into its right neighbor, keeping its panes
try std.testing.expectEqual(@as(usize, 2), p.ncol);
p.active = 0; // the left column is current
- layout.joinCol(p);
+ layout.joinCol(p, 0);
try std.testing.expectEqual(@as(usize, 1), p.ncol);
const lf = layout.findPane(p, 0) orelse return error.LostLeftPane;
const rf = layout.findPane(p, right) orelse return error.LostRightPane;
try std.testing.expectEqual(lf.col, rf.col);
- layout.joinCol(p); // no right neighbor left: inert
+ layout.joinCol(p, 0); // no right neighbor left: inert
try std.testing.expectEqual(@as(usize, 1), p.ncol);
}
@@ -1178,6 +1179,8 @@ test "first document fallback splits like Tty from the originating pane" {
defer p.deinit();
const tty = try Pardes.init(std.testing.allocator, .{ .cols = width, .rows = 62, .shells = 3 });
defer tty.deinit();
+ p.settings.placement = .pardes;
+ tty.settings.placement = .pardes;
if (collapsed) {
layout.toggleCollapse(p, from);
layout.toggleCollapse(tty, from);
@@ -1190,8 +1193,8 @@ test "first document fallback splits like Tty from the originating pane" {
// The Look origin need not be the currently active pane.
p.active = (from + 1) % 3;
const free = p.freeSlot().?;
- const doc = try p.openTutorView(free);
- exec.placeDoc(p, from, free, doc);
+ _ = try p.openTutorView(free);
+ exec.placeNew(p, from, from, free, .doc);
_ = exec.spawnTty(tty, from);
p.sync();
tty.sync();
@@ -1214,9 +1217,10 @@ test "first document keeps its originating shell and only opens a column when it
for ([_]u16{ 199, 200 }) |width| {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = width, .rows = 32 });
defer p.deinit();
+ p.settings.placement = .pardes;
const shell = p.panes[0].?;
- const doc = try p.openTutorView(1);
- exec.placeDoc(p, 0, 1, doc);
+ _ = try p.openTutorView(1);
+ exec.placeNew(p, 0, 0, 1, .doc);
p.sync();
try std.testing.expectEqual(shell, p.panes[0].?);
try std.testing.expectEqual(@as(usize, 1), p.active);
@@ -1232,6 +1236,167 @@ test "first document keeps its originating shell and only opens a column when it
}
}
+test "acme placement: an empty active column takes the new pane whole" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 32 });
+ defer p.deinit();
+ try std.testing.expectEqual(config.Runtime.Placement.acme, p.settings.placement);
+ p.newColumn(0); // its tag has the keyboard: it is the active column
+ tagline.exitHeader(p);
+ p.sync();
+ // A Look from the shell's text still lands in the active column, as
+ // acme's makenewwindow puts it in activecol first (util.c:456).
+ _ = try p.openTutorView(1);
+ exec.placeNew(p, 0, 0, 1, .doc);
+ try std.testing.expectEqualSlices(usize, &.{1}, p.col_panes[1][0..p.col_n[1]]);
+ try std.testing.expectEqual(@as(usize, 1), p.active);
+ try std.testing.expectEqual(@as(?usize, 1), p.activeColumn());
+ // Never a new column.
+ try std.testing.expectEqual(@as(usize, 2), p.ncol);
+}
+
+test "acme placement: under a pane's text when it has many blank rows, else halving the biggest" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 60 });
+ defer p.deinit();
+ const top = p.active;
+ _ = try p.setTestFile("one\ntwo\nthree\n");
+ p.sync();
+ p.setActiveColumn(0);
+ // Most of the pane is blank: a document looked from it starts under its
+ // three lines (makenewwindow, util.c:482-486).
+ const second = p.freeSlot().?;
+ _ = try p.openTutorView(second);
+ exec.placeNew(p, top, top, second, .doc);
+ p.sync();
+ const text_end = p.bodyTop(p.panes[top].?, p.rects[top]) + 3;
+ try std.testing.expectEqual(text_end, p.rects[second].y);
+ // Fill both with text: no blank space is big, so the biggest pane is
+ // halved.
+ const full = "x\n" ** 80;
+ for ([_]usize{ top, second }) |id| panes.File.setContent(p, &p.panes[id].?.file.?, try p.gpa.dupe(u8, full));
+ p.sync();
+ const big = if (p.rects[second].h >= p.rects[top].h) second else top;
+ const was = p.rects[big];
+ const third = p.freeSlot().?;
+ _ = try p.openTutorView(third);
+ exec.placeNew(p, second, second, third, .doc);
+ p.sync();
+ try std.testing.expectEqual(big, p.col_panes[0][layout.findPane(p, third).?.idx - 1]);
+ try std.testing.expectEqual(was.y + was.h / 2, p.rects[third].y);
+ try std.testing.expectEqual(was.y + was.h, p.rects[third].y + p.rects[third].h);
+}
+
+test "acme placement: New and 9P's new take the bottom half of the last pane" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 40 });
+ defer p.deinit();
+ p.newScratchBelow(p.active);
+ const last = p.active;
+ p.sync();
+ const was = p.rects[last];
+ p.newScratchFor(last, .fs);
+ const made = p.active;
+ p.sync();
+ try std.testing.expectEqual(layout.findPane(p, last).?.idx + 1, layout.findPane(p, made).?.idx);
+ try std.testing.expectEqual(was.y + was.h, p.rects[made].y + p.rects[made].h);
+ try std.testing.expect(p.rects[made].y > was.y + BOX_H);
+}
+
+test "Placement pardes brings back the old rules, and says so in Config" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 40 });
+ defer p.deinit();
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Placement pardes"));
+ try std.testing.expectEqual(config.Runtime.Placement.pardes, p.settings.placement);
+ const top = p.active;
+ p.sync();
+ const was = p.rects[top];
+ p.newScratchBelow(top);
+ p.sync();
+ // Directly under the asking pane, as New always put it.
+ try std.testing.expectEqual(@as(usize, 1), layout.findPane(p, p.active).?.idx);
+ try std.testing.expectEqual(was.y + was.h, p.rects[p.active].y + p.rects[p.active].h);
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Placement"));
+ try std.testing.expectEqual(config.Runtime.Placement.acme, p.settings.placement);
+}
+
+test "an empty column shows its theme's empty_col under its tag: acme's white, else the border" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 20 });
+ defer p.deinit();
+ p.newColumn(0);
+ tagline.exitHeader(p);
+ inline for (.{ "acme", "dark" }) |name| {
+ try std.testing.expect(p.executeBuiltinLine(0, "Theme " ++ name));
+ p.chrome_animation.displayed = colors.ChromeTheme.fromTheme(p.theme());
+ p.sync();
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const s = try p.render(arena.allocator());
+ const cell = s.at(p.col_x[1] + 3, p.screen_h - 2);
+ const want = p.chromeTheme().empty_col;
+ try std.testing.expectEqual(want, cell.style.bg.rgb);
+ if (std.mem.eql(u8, name, "acme")) try std.testing.expectEqual([3]u8{ 0xff, 0xff, 0xff }, want) else try std.testing.expectEqual(p.chromeTheme().border, want);
+ }
+}
+
+test "BootShell keep leaves the untouched boot shell a dragged document joins; replace closes it" {
+ inline for (.{ "keep", "replace" }) |word| {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ const shell = p.active;
+ try std.testing.expect(p.panes[shell].?.isTerminal());
+ try std.testing.expect(p.executeBuiltinLine(shell, "BootShell " ++ word));
+ _ = try p.openTutorView(1);
+ layout.insert(p, 0, 1, 1);
+ p.sync();
+ exec.evictLonePristineTty(p, 0, 1);
+ p.sync();
+ const kept = std.mem.eql(u8, word, "keep");
+ try std.testing.expectEqual(kept, p.panes[shell] != null);
+ try std.testing.expectEqual(@as(usize, if (kept) 2 else 1), p.col_n[0]);
+ }
+}
+
+test "a pane its whole text fits in never scrolls to keep a margin" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 30 });
+ defer p.deinit();
+ const pane = try p.setTestFile("one\ntwo\nthree\n");
+ // A second pane under it, as acme's placement puts one: right under the
+ // three lines, which leaves the file exactly three body rows.
+ const below = p.freeSlot().?;
+ _ = try p.openTutorView(below);
+ exec.placeNew(p, 0, 0, below, .doc);
+ p.active = 0;
+ p.sync();
+ try std.testing.expectEqual(@as(u16, 3), p.rects[0].h - pane.tag_rows);
+ for ("jj") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } });
+ p.sync();
+ try std.testing.expectEqual(@as(i32, 2), pane.body.cur_row);
+ try std.testing.expectEqual(@as(i32, 0), pane.scroll());
+}
+
+test "dragging a column's only pane to another leaves that column in place, empty" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 32 });
+ defer p.deinit();
+ p.newColumn(0);
+ p.newScratchIn(0, p.activeColumn().?);
+ const moved = p.active;
+ p.sync();
+ const weights = p.col_weight;
+ const tag_serial = p.col_serial[1];
+ layout.movePane(p, moved, p.col_x[0] + 4, p.rects[0].y + p.rects[0].h / 2);
+ p.sync();
+ // acme's coldragwin: colclose then coladd (cols.c:516-517); the column
+ // left behind keeps its place, width and tag.
+ try std.testing.expectEqual(@as(usize, 2), p.ncol);
+ try std.testing.expectEqual(@as(usize, 0), p.col_n[1]);
+ try std.testing.expectEqual(tag_serial, p.col_serial[1]);
+ try std.testing.expectEqualDeep(weights, p.col_weight);
+ try std.testing.expectEqual(@as(usize, 2), p.col_n[0]);
+ // ...and a pane dragged into the empty column takes it whole.
+ layout.movePane(p, moved, p.col_x[1] + 4, p.screen_h / 2);
+ p.sync();
+ try std.testing.expectEqualSlices(usize, &.{moved}, p.col_panes[1][0..p.col_n[1]]);
+ try std.testing.expectEqual(@as(usize, 1), p.col_n[0]);
+}
+
test "pane defaults keep creation of scratch panes in column tags" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
@@ -1250,37 +1415,19 @@ test "pane defaults keep creation of scratch panes in column tags" {
try std.testing.expectEqualStrings(tagline.file_pane_tail, tagline.curTail(file));
}
-test "an unsaved file marker sits between its path and builtins until Save" {
+test "an unsaved file's tag is its name alone: the grip, not a mark, says it is unsaved" {
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 pane = try p.setTestFile("before\n");
-
const clean = try tagline.tagText(p, p.scratch.allocator(), pane);
- try std.testing.expect(std.mem.indexOf(u8, clean, "/test.txt *") == null);
-
panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n"));
const dirty = try tagline.tagText(p, p.scratch.allocator(), pane);
- const marker_at = std.mem.indexOf(u8, dirty, "/test.txt *") orelse return error.MissingDirtyMarker;
- const save_at = std.mem.indexOf(u8, dirty, "Save") orelse return error.MissingSaveBuiltin;
- try std.testing.expect(marker_at < save_at);
-
- try std.testing.expect(p.executeBuiltinLine(0, "Save"));
- while (p.nextEffect()) |effect| p.perform(effect);
- const saved = try tagline.tagText(p, p.scratch.allocator(), pane);
- try std.testing.expect(std.mem.indexOf(u8, saved, "/test.txt *") == null);
-
- panes.File.changed(p, 0, "external\n");
- const reloaded = try tagline.tagText(p, p.scratch.allocator(), pane);
- try std.testing.expect(std.mem.indexOf(u8, reloaded, "/test.txt *") == null);
-
- // A generated output is file-shaped and Save can write it to a path, but
- // there is no file of its own for it to be dirty against.
- pane.file.?.output = .{ .from = .search };
- panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "result\n"));
- const output = try tagline.tagText(p, p.scratch.allocator(), pane);
- try std.testing.expect(std.mem.indexOf(u8, output, "/test.txt *") == null);
+ try std.testing.expectEqualStrings(clean, dirty);
+ try std.testing.expect(std.mem.indexOf(u8, dirty, " *") == null);
+ // The dirty file and index's flag still say it.
+ try std.testing.expect(ctlfs.pane.dirtyOf(pane));
}
test "unknown Exec from a document runs as a command pane in its directory, typed into no terminal" {
@@ -1491,6 +1638,11 @@ test "a REPL bound for a language takes the exec made on a body in it; tags, Exe
_ = exec.executeFrom(p, 0, "a = 1\r\nb = \x1b[201~2", true);
Sent.drain(p);
try std.testing.expectEqualStrings("a = 1\rb = [201~2\r", Sent.said(1));
+ // A block left open by its indented last line gets the blank line that
+ // ends it: a second Enter.
+ _ = exec.executeFrom(p, 0, "def f():\n return 1", true);
+ Sent.drain(p);
+ try std.testing.expectEqualStrings("def f():\r return 1\r\r", Sent.said(1));
// Several selections executed at once: each is sent, not the first.
{
const text = try edit.flatSurface(p, &file.body);
@@ -1526,6 +1678,16 @@ test "a REPL bound for a language takes the exec made on a body in it; tags, Exe
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);
+ // The question is in the log, and a script answers it on the ctl.
+ {
+ const th = @import("ninep/testing.zig");
+ _ = exec.executeFrom(p, 0, "print(5)", true);
+ var want: [32]u8 = undefined;
+ try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "ask {d} repl a b\n", .{file.serial})));
+ const answered = th.wr(p, ctlfs.Node.of(file.serial, .ctl), "answer a\n");
+ try std.testing.expectEqual(ctlfs.Status.ok, answered.reply.status);
+ try std.testing.expectEqualStrings("print(5)\r", answered.pty());
+ }
_ = exec.executeFrom(p, 0, "print(3)", true);
p.update(.{ .key = .{ .cp = Key.escape } });
Sent.drain(p);
@@ -2261,7 +2423,7 @@ test "a save the host could not do leaves the pane dirty" {
const Refusing = struct {
fn writeFile(ctx: ?*anyopaque, pane: u8, _: []const u8, _: []const u8) void {
const core: *Pardes = @ptrCast(@alignCast(ctx.?));
- core.saveFailed(pane, "save", error.PermissionDenied);
+ core.saveFailed(pane, "/x/y.txt", error.PermissionDenied);
}
};
const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 });
@@ -3046,7 +3208,7 @@ test "column tags follow their column through split and close" {
try std.testing.expectEqualStrings("Left Find", tagline.columnTag(p, 0));
p.col_tags[1].own = try p.gpa.dupe(u8, "Right Grep");
p.active = p.col_panes[0][0];
- layout.joinCol(p);
+ layout.joinCol(p, 0);
try std.testing.expectEqual(@as(usize, 1), p.ncol);
try std.testing.expectEqualStrings("Right Grep", tagline.columnTag(p, 0));
}
@@ -3055,7 +3217,8 @@ test "column tags render edit hover and scroll from the column edge" {
const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20, .tty_only = true });
defer p.deinit();
_ = try p.setTestFile("untouched\n");
- p.newScratchColumn(0);
+ p.newColumn(0);
+ p.newScratchIn(0, p.activeColumn().?);
p.sync();
p.acknowledgePanelPresentation(&.{});
var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
@@ -3111,7 +3274,8 @@ test "column tag first-cell execution keeps New local to the clicked column" {
const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20, .tty_only = true });
defer p.deinit();
_ = try p.setTestFile("untouched\n");
- p.newScratchColumn(0);
+ p.newColumn(0);
+ p.newScratchIn(0, p.activeColumn().?);
p.sync();
for (0..2) |column| {
p.active = p.col_panes[1 - column][0];
@@ -3147,7 +3311,7 @@ test "header clicks beyond text append and long UTF-8 tags reveal their caret" {
try std.testing.expectEqual(@as(usize, 0), p.header_scroll);
}
-test "a column tag with a newline in it takes a row per line and pushes its panes down" {
+test "a newline typed into a column tag is a space: the tag stays one row" {
const p = try Pardes.init(std.testing.allocator, .{ .cols = 50, .rows = 12, .tty_only = true });
defer p.deinit();
_ = try p.setTestFile("body\n");
@@ -3157,26 +3321,9 @@ test "a column tag with a newline in it takes a row per line and pushes its pane
p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = 40, .row = TOPBAR_H } });
p.update(.{ .key = .{ .cp = Key.enter } });
p.update(.{ .key = .{ .cp = 'S', .text = "Snarf" } });
- try std.testing.expectEqualStrings("New\nSnarf", tagline.columnTag(p, 0));
- try std.testing.expectEqual(@as(u16, 2), p.columnBarHeight());
- const frame = try p.render(p.scratch.allocator());
- try std.testing.expectEqual(TOPBAR_H + 2, p.rects[0].y);
- try std.testing.expectEqual(TOPBAR_H + 1, frame.cursor.?.y);
- try std.testing.expectEqual(@as(u8, 'S'), frame.at(TAG_TEXT_INSET, TOPBAR_H + 1).text[0]);
-
- // A click on the second row lands on the second line.
- p.update(.{ .key = .{ .cp = Key.escape } });
- p.acknowledgePanelPresentation(&.{});
- p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = TAG_TEXT_INSET + 1, .row = TOPBAR_H + 1 } });
- p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = TAG_TEXT_INSET + 1, .row = TOPBAR_H + 1 } });
- try std.testing.expectEqual(@as(i32, 1), p.col_tags[0].cur_row);
- try std.testing.expectEqual(@as(i32, 1), p.col_tags[0].cur_col);
-
- // Taking the newline back out gives the panes their row again.
- p.update(.{ .key = .{ .cp = Key.home } });
- p.update(.{ .key = .{ .cp = Key.backspace } });
- try std.testing.expectEqualStrings("NewSnarf", tagline.columnTag(p, 0));
_ = try p.render(p.scratch.allocator());
+ try std.testing.expectEqualStrings("New Snarf", tagline.columnTag(p, 0));
+ try std.testing.expectEqual(COLBAR_H, p.columnBarHeight());
try std.testing.expectEqual(TOPBAR_H + COLBAR_H, p.rects[0].y);
}
@@ -3188,7 +3335,7 @@ test "tiny screens keep pane space before adding a column tag" {
_ = try p.render(p.scratch.allocator());
}
-test "ColumnTags reclaims the row and releases hidden header focus without losing commands" {
+test "a screen too short for the column row releases a column tag's focus without losing its commands" {
const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12, .tty_only = true });
defer p.deinit();
const pane = try p.setTestFile("unchanged body\n");
@@ -3200,24 +3347,7 @@ test "ColumnTags reclaims the row and releases hidden header focus without losin
p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = TAG_TEXT_INSET, .row = TOPBAR_H } });
try std.testing.expect(p.header_focus and p.header_column != null);
- p.update(.{ .command = "ColumnTags" });
- try std.testing.expect(!p.settings.column_tags);
- try std.testing.expectEqual(@as(u16, 0), p.columnBarHeight());
- try std.testing.expectEqual(TOPBAR_H, p.rects[0].y);
- try std.testing.expectEqual(shown.h + COLBAR_H, p.rects[0].h);
- try std.testing.expect(!p.header_focus and p.header_column == null);
- try std.testing.expect(!p.header_drag);
- try std.testing.expectEqualStrings("New Help", tagline.columnTag(p, 0));
-
- p.update(.{ .command = "ColumnTags" });
- try std.testing.expect(p.settings.column_tags);
- try std.testing.expectEqual(shown, p.rects[0]);
- p.acknowledgePanelPresentation(&.{});
- p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = TAG_TEXT_INSET, .row = TOPBAR_H } });
- p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = TAG_TEXT_INSET, .row = TOPBAR_H } });
- try std.testing.expect(p.header_column != null);
p.update(.{ .resize = .{ .cols = 60, .rows = 2 } });
- try std.testing.expect(p.settings.column_tags);
try std.testing.expectEqual(@as(u16, 0), p.columnBarHeight());
try std.testing.expectEqual(TOPBAR_H, p.rects[0].y);
try std.testing.expect(!p.header_focus and p.header_column == null);
@@ -3267,15 +3397,17 @@ test "Enter in a header runs the word under its cursor, as the execute key does"
p.global_tag.cur_col = @intCast(std.mem.indexOf(u8, config.topbar_str, "Newcol").?);
p.update(.{ .key = .{ .cp = Key.enter } });
try std.testing.expectEqual(@as(usize, 2), p.ncol);
- // and the header lets go of the keyboard, as a pane tag does
- try std.testing.expect(!p.header_focus);
+ // and the workspace tag lets go of the keyboard: the new, empty
+ // column's tag has it
+ try std.testing.expect(p.header_focus and p.header_column.? == 1);
}
test "window keys walk from a pane up through the column and workspace tags and back" {
const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 20, .tty_only = true });
defer p.deinit();
_ = try p.setTestFile("body\n");
- p.newScratchColumn(0);
+ p.newColumn(0);
+ p.newScratchIn(0, p.activeColumn().?);
p.sync();
p.active = p.col_panes[0][0];
const window = struct {
@@ -3562,6 +3694,7 @@ test "Newcol refuses an unsplittable restored weight before spawning" {
const pane = p.panes[id].?;
try p.deinitPane(pane);
layout.removePane(p, id);
+ layout.dropColumn(p, id);
p.panes[id] = null;
}
while (p.nextEffect()) |_| {}
@@ -4104,9 +4237,15 @@ pub const Pardes = struct {
look_walk_owner: ?u32 = null,
/// hands out Pane.serial; monotonic, never reused
next_serial: u32 = 0,
- /// The pane (by serial) that joined a column this step; `replaceStillborn`
- /// checks it once the step is over and nothing holds the column's panes.
- stillborn_joiner: ?u32 = null,
+ /// The column whose tag the running command came from, while it runs:
+ /// the column acme's commands take from their Text's col.
+ exec_column: ?usize = null,
+ /// acme's activecol (dat.c:37), by column serial, 0 for none: the
+ /// column last typed in (acme.c:487), clicked in with the select button
+ /// (acme.c:659), dropped into by a grip (acme.c:640), or given a new
+ /// pane (util.c:467). Where the acme placement puts new panes
+ /// (exec.placeNew). A Look click moves the keyboard but not this.
+ active_column: u32 = 0,
settings: config.Runtime = .{ .font = .{ .tagline_percent = config.gui_tagline_font_percent } },
locations_config: locations_config.Config = .{},
locations_cache: @import("locations_cache.zig").Cache = .{},
@@ -4141,6 +4280,13 @@ pub const Pardes = struct {
pointer_tag_hit: ?TagHit = null,
raw_hover_intent: bool = false,
pointer_inside: bool = false,
+ /// The last plain B1 click, for acme's double-click (text.c:881,
+ /// textselect: a second click at the same point within 500 ms).
+ last_click: ?struct { id: usize, on_tag: bool, row: i32, col: i32, ms: i64 } = null,
+ /// A B1 chord's edits share the undo point it took (mouse.zig).
+ chord_hold: bool = false,
+ /// The look just run found nothing (look.zig missed).
+ look_missed: bool = false,
look_hover_wait: ?look.LookHoverWait = null,
look_hover_preview: ?look.LookHoverPreview = null,
pdf_hover_preview: if (pdf_enabled) ?look.PdfWordPreview else void = if (pdf_enabled) null else {},
@@ -4356,10 +4502,12 @@ pub const Pardes = struct {
p.panes[0].?.body.mode = .tty;
p.oneColumn();
// A bare tty is still an editor: it opens with somewhere to put
- // text under the shell. The shell keeps the keyboard.
+ // text under the shell. The shell keeps the keyboard. A boot
+ // layout, like .classic's, not a placement.
if (p.boot == .tty) {
- p.newScratchBelow(0);
- p.active = 0;
+ const np = try p.newScratch(0, 1);
+ layout.insert(p, 0, 1, 1);
+ layout.splitBelow(p, 0, np);
}
},
.classic => {
@@ -4466,24 +4614,21 @@ pub const Pardes = struct {
return a;
}
- /// The workspace tag's height: a row per line of it while the setting
- /// keeps it, zero once a native shell takes its commands over. Layout and
- /// hit testing read this rather than the constant, the way they read
- /// `columnBarHeight`.
+ /// The workspace tag's height: one row while the setting keeps it, as
+ /// acme's row tag is, zero once a native shell takes its commands over.
+ /// Layout and hit testing read this rather than the constant, the way
+ /// they read `columnBarHeight`.
pub fn topBarHeight(p: *const Pardes) u16 {
if (!p.settings.workspace_tag) return 0;
- const lines = if (p.global_tag.own) |own| std.mem.count(u8, own, "\n") + 1 else TOPBAR_H;
- return @intCast(@min(lines, MAX_TAG_ROWS, @max(TOPBAR_H, p.screen_h / 3)));
+ return TOPBAR_H;
}
- /// The column tags' height: a row per line of the tallest one.
+ /// The column tags' height: one row, as acme's (cols.c:244, colresize,
+ /// gives a column tag one font height); a newline in one is a space
+ /// (tagline.flattenHeaders).
pub fn columnBarHeight(p: *const Pardes) u16 {
- if (!p.settings.column_tags or p.screen_h < 3) return 0;
- var lines: usize = COLBAR_H;
- for (p.col_tags[0..p.ncol]) |t| if (t.own) |own| {
- lines = @max(lines, std.mem.count(u8, own, "\n") + 1);
- };
- return @intCast(@min(lines, MAX_TAG_ROWS, @max(COLBAR_H, p.screen_h / 3)));
+ if (p.screen_h < 3) return 0;
+ return COLBAR_H;
}
fn detachCwds(p: *Pardes, parents: []const *Pane) !void {
@@ -4618,34 +4763,56 @@ 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.body.cur_pinned = true;
- return np;
- }
-
- /// New: a scratch below the calling pane, in its column.
+ /// New: an empty scratch in `from_id`'s directory, placed as exec.placeNew
+ /// places it.
pub fn newScratchBelow(p: *Pardes, from_id: usize) void {
- const free = p.freeSlot() orelse return;
- const sf = layout.findPane(p, from_id) orelse return;
- const np = p.newScratch(from_id, free) catch return;
- layout.insert(p, sf.col, sf.idx + 1, free);
- layout.splitBelow(p, from_id, np);
- p.active = free;
+ p.newScratchFor(from_id, .scratch);
}
- /// Newcol: a scratch in a fresh column beside the calling pane.
- pub fn newScratchColumn(p: *Pardes, from_id: usize) void {
+ /// A scratch for New (`.scratch`) or 9P's pane/new (`.fs`).
+ pub fn newScratchFor(p: *Pardes, from_id: usize, kind: exec.NewPane) void {
const free = p.freeSlot() orelse return;
- if (!layout.canSplitColumn(p, from_id)) return;
_ = p.newScratch(from_id, free) catch return;
- std.debug.assert(layout.splitColumn(p, from_id, free, false));
- p.active = free;
+ exec.placeNew(p, from_id, from_id, free, kind);
+ }
+
+ /// Newcol: an empty column right of the calling pane's, the keyboard
+ /// on its tag, for the next pane made to fill.
+ pub fn newColumn(p: *Pardes, from_id: usize) void {
+ const col = p.keyboardColumn(from_id) orelse return;
+ const c = layout.insertColumn(p, col, false) orelse return;
+ tagline.enterHeader(p, c);
+ }
+
+ pub fn setActiveColumn(p: *Pardes, c: usize) void {
+ if (c < p.ncol) p.active_column = layout.columnSerial(p, c);
+ }
+
+ /// The active column, while it is still there.
+ pub fn activeColumn(p: *const Pardes) ?usize {
+ return layout.columnBySerial(p, p.active_column);
+ }
+
+ /// The column the keyboard is in: its tag's, or its pane's.
+ pub fn noteTyped(p: *Pardes) void {
+ if (p.header_focus) {
+ if (p.header_column) |c| p.setActiveColumn(c);
+ } else if (layout.findPane(p, p.active)) |f| p.setActiveColumn(f.col);
+ }
+
+ /// New, run from column `c`'s tag.
+ pub fn newScratchIn(p: *Pardes, from_id: usize, c: usize) void {
+ p.exec_column = c;
+ defer p.exec_column = null;
+ p.newScratchBelow(from_id);
+ }
+
+ /// The column a command acts on: the one whose tag it came from, else
+ /// the one whose tag has the keyboard, else `id`'s.
+ pub fn keyboardColumn(p: *Pardes, id: usize) ?usize {
+ if (p.exec_column) |c| return c;
+ if (p.header_focus) if (p.header_column) |c| return c;
+ return if (layout.findPane(p, id)) |f| f.col else null;
}
pub fn freeSlot(p: *Pardes) ?usize {
@@ -4916,15 +5083,23 @@ pub const Pardes = struct {
if (p.host.vtable.write_file) |f| return f(p.host.ctx, pane, path, bytes);
// The in-process filesystem reports the same way a real host does, so
// an OOM here leaves the pane dirty rather than looking saved.
- if (!p.fallback.writeFile(path, bytes)) p.saveFailed(pane, "save", error.OutOfMemory);
+ if (!p.fallback.writeFile(path, bytes)) p.saveFailed(pane, path, error.OutOfMemory);
}
- pub fn saveFailed(p: *Pardes, id: u8, what: []const u8, err: anyerror) void {
+ /// A write of `path` the host could not do: the pane stays dirty, the
+ /// message row says `Save <path>: <why>`, and the 9P write that asked
+ /// for it, waiting on the save, fails with that (`fs.late_failure`).
+ pub fn saveFailed(p: *Pardes, id: u8, path: []const u8, err: anyerror) void {
if (p.panes[id]) |pane| if (pane.file) |*f| {
// The `-%` spelling fs.zig already uses for "make this dirty".
f.saved_revision = f.revision -% 1;
};
- p.reportError(id, what, err);
+ var what: [limits.host_path_cap + 8]u8 = undefined;
+ p.reportError(id, std.fmt.bufPrint(&what, "Save {s}", .{path}) catch "Save", err);
+ const said = if (p.panes[id]) |pane| pane.msg[0..pane.msg_len] else "Save failed";
+ const kept = @import("Messages.zig").clip(said, p.fs.late_failure.len);
+ @memcpy(p.fs.late_failure[0..kept.len], kept);
+ p.fs.late_failure_len = @intCast(kept.len);
}
/// The path a watch is about: a real file's, or a PDF's.
@@ -5091,10 +5266,6 @@ 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
@@ -5106,7 +5277,6 @@ pub const Pardes = struct {
}
pub fn update(p: *Pardes, ev: Event) void {
- defer exec.replaceStillborn(p);
// A tick with nothing animating is the one event that cannot change
// the screen.
p.needs_frame = p.needs_frame or ev != .tick;
@@ -5191,8 +5361,11 @@ pub const Pardes = struct {
panes.Terminal.feedOutput(p, pane, o.bytes);
ctlfs.pty.noteMarks(p, o.pane, pane);
},
- .exited => |e| if (p.panes[e.pane]) |pane| if (pane.command != null and !pane.command_done) {
- exec.commandDone(p, e.pane, e.status);
+ .exited => |e| if (p.panes[e.pane]) |pane| {
+ if (pane.command == null)
+ ctlfs.pty.shellExited(p, pane, e.status)
+ else if (!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.
@@ -5236,6 +5409,7 @@ pub const Pardes = struct {
p.pointer_tag_hit = null;
},
.tick => {
+ mouse.edgeScroll(p);
p.chrome_animation.advance();
p.presentation.advance();
look.advanceLookHover(p);
@@ -5320,36 +5494,66 @@ pub const Pardes = struct {
}
}
- 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) {
+ /// Answers the question pane `id` asks on its notice band, logged as
+ /// `ask <serial> <what> <choices>`: Del's side (`k` above, `j` below) or
+ /// the REPL to send to (its letter). Null, or a choice not offered,
+ /// takes the question back. A key answers it, or `answer` on the pane's
+ /// ctl; false when the pane asks nothing.
+ pub fn answerQuestion(p: *Pardes, id: usize, choice: ?u8) bool {
+ const asked = p.panes[id] orelse return false;
+ switch (asked.prompt) {
+ .repl_choice => {
asked.prompt = .none;
- const ask = p.repl_ask orelse return;
+ const ask = p.repl_ask orelse return true;
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;
+ const letter = choice orelse return true;
+ if (letter < 'a' or letter > 'z') return true;
+ const serial = ask.offered[letter - 'a'];
+ if (serial == 0) return true;
// 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);
+ if (r.lang == ask.lang and r.letter == letter and exec.replLive(p, to)) {
+ exec.sendToRepl(p, id, to, ask.text);
+ return true;
+ };
p.setMessage(id, "Repl: the REPL chosen is gone; nothing sent");
- return;
+ },
+ .del_side => {
+ asked.prompt = .none;
+ if (choice == 'k') exec.runBuiltin(p, .Del, id, "", "k");
+ if (choice == 'j') exec.runBuiltin(p, .Del, id, "", "j");
+ },
+ else => return false,
+ }
+ return true;
+ }
+
+ fn handleKey(p: *Pardes, key: Key) void {
+ // A double-click is two clicks with nothing between them.
+ p.last_click = null;
+ 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;
+ switch (asked.prompt) {
+ // Its letter sends it there; anything else takes it back.
+ .repl_choice => _ = p.answerQuestion(id, if (key.ctrl or key.alt or key.cp > 'z' or key.cp < 'a') null else @intCast(key.cp)),
+ .del_side => _ = p.answerQuestion(id, if (hit(key, config.del_above)) 'k' else if (hit(key, config.del_below)) 'j' else null),
+ else => continue,
}
- if (asked.prompt != .del_side) continue;
- asked.prompt = .none;
- if (hit(key, config.del_above)) exec.runBuiltin(p, .Del, id, "", "k");
- if (hit(key, config.del_below)) exec.runBuiltin(p, .Del, id, "", "j");
return;
}
const pane = p.panes[p.active] orelse return;
+ p.noteTyped();
+ // Alt-Down and Alt-Up in a pane's tag expand and collapse it, in
+ // either mode: acme's Tagdown and Tagup (plan9port text.c:776).
+ if (pane.focus == .tag and key.alt and !key.ctrl and (key.cp == Key.down or key.cp == Key.up) and tagline.focusedHeader(p) == null) {
+ pane.tag_expanded = key.cp == Key.down;
+ return;
+ }
// An open prompt takes the keys even when it answers for a header.
const header = if (pane.prompt != .none) null else tagline.focusedHeader(p);
if (header == null and pane.isTerminal() and pane.body.mode == .tty and pane.focus == .body and pane.prompt == .none) {
@@ -5398,16 +5602,11 @@ pub const Pardes = struct {
// global window ops (any mode): Alt-n new terminal below, Alt-c move
// the active terminal into a fresh column
if (hit(key, config.new_shell_below)) {
- tagline.exitHeader(p);
const free = p.freeSlot() orelse return;
const nt = p.newShell(free, "") catch return;
nt.greet = true;
- const src = layout.splitParent(p, p.active);
- const f = layout.findPane(p, src).?;
- layout.insert(p, f.col, f.idx + 1, free);
- layout.splitBelow(p, src, nt);
- p.active = free;
- p.stillborn_joiner = nt.serial;
+ // From a tag, as a 9P new is: acme's t->w == nil.
+ exec.placeNew(p, p.active, if (p.header_focus) null else p.active, free, .shell);
return;
}
for (config.jump_keys) |jk| {
@@ -5486,6 +5685,9 @@ pub const Pardes = struct {
const b = config.wordBounds(shown, modal.selectionCursor(shown, range));
break :word shown[b.lo..b.hi];
};
+ // A column tag's command acts on that column, empty or not.
+ p.exec_column = if (header != null) p.header_column else null;
+ defer p.exec_column = null;
// Out of the tag or header FIRST: Del frees the pane, Kill
// and Restore the whole session.
if (header != null) tagline.exitHeader(p) else pane.focus = .body;
@@ -5950,59 +6152,38 @@ 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. 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.
+ /// sends them when that is null. Focus on it follows its rows to the
+ /// pane that grew into them. A column outlives its panes, as acme's
+ /// does (cols.c:186): closing its last one leaves it empty, the keyboard
+ /// on its tag; only Delcol takes a column away. The session's last pane
+ /// closing ends the session. 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
- // 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];
- }
+ const emptied = if (place) |f| (if (p.col_n[f.col] == 1) f.col else null) else null;
// 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;
- };
const gainer = layout.absorbVWeight(p, id, to);
- const beside = if (last_in_column) p.besideColumn(place.?.col, id) else null;
+ const beside = if (emptied) |c| p.besideColumn(c, p.rects[id]) else null;
layout.removePane(p, id);
p.panes[id] = null;
- if (p.active == id) p.active = heir orelse gainer orelse beside orelse p.anyPane() orelse {
- p.quit = true;
- p.emit(.quit);
- return;
- };
+ if (p.active != id) return;
+ p.active = gainer orelse beside orelse p.anyPane() orelse return p.quitEmpty();
+ if (emptied) |c| if (!p.header_focus) tagline.enterHeader(p, c);
}
- 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;
+ /// Delcol: close column `col` and every pane in it.
+ pub fn removeColumn(p: *Pardes, col: usize) !void {
+ if (col >= p.ncol) return;
+ const had_focus = p.keyboardColumn(p.active) == col;
+ const beside = if (had_focus) p.besideColumn(col, if (p.header_focus) null else p.rects[p.active]) else null;
var ids: [MAX_PANES]usize = undefined;
var parents: [MAX_PANES]*Pane = undefined;
- const count = p.col_n[place.col];
+ const count = p.col_n[col];
for (0..count) |i| {
- ids[i] = p.col_panes[place.col][i];
+ ids[i] = p.col_panes[col][i];
parents[i] = p.panes[ids[i]].?;
}
try p.detachCwds(parents[0..count]);
@@ -6011,11 +6192,14 @@ pub const Pardes = struct {
layout.removePane(p, closed);
p.panes[closed] = null;
}
- if (p.panes[p.active] == null) p.active = beside orelse p.anyPane() orelse {
- p.quit = true;
- p.emit(.quit);
- return;
- };
+ layout.dropColumn(p, col);
+ if (p.panes[p.active] == null) p.active = beside orelse p.anyPane() orelse return p.quitEmpty();
+ }
+
+ /// No pane is left: the session is over.
+ fn quitEmpty(p: *Pardes) void {
+ p.quit = true;
+ p.emit(.quit);
}
/// The pane Last should fall back to when the jumplist has nothing: the
@@ -6034,14 +6218,13 @@ pub const Pardes = struct {
return null;
}
- /// 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 {
+ /// The pane that takes over the keyboard from column `col`: in the
+ /// column layout.dropColumn gives the width to, the one level with the
+ /// middle of `r` (of the screen without one). Asked while rects hold.
+ fn besideColumn(p: *const Pardes, col: usize, r: ?Rect) ?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;
+ const mid = if (r) |at| at.y + at.h / 2 else p.screen_h / 2;
var pick: ?usize = null;
for (p.col_panes[next][0..p.col_n[next]]) |k| {
if (p.panes[k] == null) continue;
@@ -6122,6 +6305,7 @@ pub const Pardes = struct {
/// Recompute geometry, push grid-size changes to each emulator + pty, fire
/// deferred greetings. The mirror of the prototype's loop epilogue.
pub fn sync(p: *Pardes) void {
+ tagline.flattenHeaders(p);
if (p.header_column != null and p.columnBarHeight() == 0) {
tagline.exitHeader(p);
}
@@ -6172,11 +6356,30 @@ pub const Pardes = struct {
}
pane.tag_lead = lead;
}
- const lines = std.mem.count(u8, tagline.curTail(pane), "\n") + 1;
- pane.tag_rows = @intCast(@min(lines, MAX_TAG_ROWS, @max(BOX_H, r.h -| 1)));
+ // A row per shown row: the tag's lines wrapped at its width.
+ var wrap_room: [32 * 1024]u8 = undefined;
+ var wrap_fba = std.heap.FixedBufferAllocator.init(&wrap_room);
+ const wa = wrap_fba.allocator();
+ var lines = std.mem.count(u8, tagline.curTail(pane), "\n") + 1;
+ var wanted = lines;
+ var caret_row: usize = @intCast(@max(0, pane.tag.cur_row));
+ if (tagline.prefixIn(wa, pane)) |prefix| {
+ if (std.mem.concat(wa, u8, &.{ prefix, tagline.curTail(pane) })) |whole| {
+ if (tagline.wrapTag(wa, whole, tagline.tagWidth(p, id))) |shown| {
+ lines = shown.rows();
+ // Collapsed, one row, as acme's Tagup sets taglines to 1
+ // (plan9port text.c:786).
+ wanted = if (pane.tag_expanded) lines else 1;
+ const cur = modal.offsetAt(whole, .{ .row = caret_row, .col = @intCast(@max(0, pane.tag.cur_col)) });
+ caret_row = std.mem.count(u8, shown.text[0..@min(shown.fromReal(cur), shown.text.len)], "\n");
+ } else |_| {}
+ } else |_| {}
+ } else |_| {}
+ if (!pane.tag_expanded) wanted = 1;
+ pane.tag_rows = @intCast(@min(wanted, MAX_TAG_ROWS, @max(BOX_H, r.h -| 1)));
if (pane.collapsed) pane.tag_rows = BOX_H;
if (pane.focus == .tag and pane.prompt != .name) {
- const cur: usize = @intCast(@max(0, pane.tag.cur_row));
+ const cur: usize = caret_row;
var top: usize = pane.tag_top;
if (cur < top) top = cur;
if (cur >= top + pane.tag_rows) top = cur + 1 - pane.tag_rows;
@@ -6191,6 +6394,7 @@ pub const Pardes = struct {
panes.Terminal.resizeGrid(pane, p.gpa, cols, rows);
p.shell_rows.markStale(pane); // reflow moved every row
p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } });
+ pane.fs.winsize = null;
}
pane.cols = cols;
pane.rows = rows;
@@ -6660,43 +6864,67 @@ test "a prompt is typed on its own line in the notice band and leaves the tag ro
try std.testing.expect(pane.prompt == .none and pane.input.own == null);
}
-test "a stillborn pane gives its column to the first pane that joins it" {
+test "Newcol makes an empty column, and the next pane made from its tag fills it" {
if (platform == .web) return;
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 40 });
defer p.deinit();
- try std.testing.expect(p.executeBuiltinLine(0, "Newcol"));
- const blank = p.active;
- p.sync(); // Tty splits by the rects a frame has laid out
- try std.testing.expect(p.executeBuiltinLine(blank, "Tty"));
+ const first = p.active;
+ const panes_before = p.col_n[0];
+ try std.testing.expect(p.executeBuiltinLine(first, "Newcol"));
+ try std.testing.expectEqual(@as(usize, 2), p.ncol);
+ try std.testing.expectEqual(@as(usize, 0), p.col_n[1]);
+ try std.testing.expectEqual(panes_before, p.col_n[0]);
+ // The keyboard is on the new column's tag; no pane has it.
+ try std.testing.expect(p.header_focus and p.header_column.? == 1);
+ try std.testing.expectEqual(first, p.active);
+ p.sync();
+ try std.testing.expect(p.executeBuiltinLine(first, "Tty"));
const shell = p.active;
- p.update(.tick); // the step ends: the placeholder goes
- try std.testing.expect(p.panes[blank] == null);
- try std.testing.expectEqual(@as(usize, 1), p.col_n[layout.findPane(p, shell).?.col]);
- try std.testing.expectEqual(shell, p.active);
+ try std.testing.expect(shell != first);
+ try std.testing.expectEqual(@as(usize, 1), p.col_n[1]);
+ try std.testing.expectEqual(shell, p.col_panes[1][0]);
+ try std.testing.expect(!p.header_focus);
- // Typed into, body or tag, it is a pane like any other.
- try std.testing.expect(p.executeBuiltinLine(shell, "Newcol"));
- const typed = p.active;
+ // The next Tty splits as it always has: nothing is empty any more.
p.sync();
- for ("ix") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } });
- try std.testing.expect(p.executeBuiltinLine(typed, "Tty"));
- p.update(.tick);
- try std.testing.expect(p.panes[typed] != null);
- try std.testing.expectEqual(@as(usize, 2), p.col_n[layout.findPane(p, typed).?.col]);
+ try std.testing.expect(p.executeBuiltinLine(shell, "Tty"));
+ try std.testing.expectEqual(@as(usize, 2), p.col_n[1]);
+}
- try std.testing.expect(p.executeBuiltinLine(shell, "Newcol"));
- const tagged = p.active;
+test "a column tag's command acts on that column" {
+ if (platform == .web) return;
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 40 });
+ defer p.deinit();
+ const first = p.active;
+ try std.testing.expect(p.executeBuiltinLine(first, "Newcol"));
+ try std.testing.expect(p.executeBuiltinLine(first, "Newcol"));
+ try std.testing.expectEqual(@as(usize, 3), p.ncol);
p.sync();
- try p.panes[tagged].?.appendTag(" make");
- try std.testing.expect(p.executeBuiltinLine(tagged, "Tty"));
- p.update(.tick);
- try std.testing.expect(p.panes[tagged] != null);
+ // New from the middle column's tag lands in it, not below `first`.
+ p.exec_column = 1;
+ try std.testing.expect(p.executeBuiltinLine(first, "New"));
+ p.exec_column = null;
+ try std.testing.expectEqual(@as(usize, 1), p.col_n[1]);
+ try std.testing.expectEqual(p.active, p.col_panes[1][0]);
+ // Delcol from the empty right column's tag closes that one.
+ p.exec_column = 2;
+ try std.testing.expect(p.executeBuiltinLine(first, "Delcol"));
+ p.exec_column = null;
+ try std.testing.expectEqual(@as(usize, 2), p.ncol);
+ try std.testing.expectEqual(@as(usize, 1), p.col_n[1]);
+ // A pane's own Delcol closes its column whatever tag has the keyboard.
+ try std.testing.expect(p.executeBuiltinLine(first, "Newcol"));
+ try std.testing.expect(p.header_focus);
+ try std.testing.expect(p.executeBuiltinLine(p.col_panes[1][0], "Delcol"));
+ try std.testing.expectEqual(@as(usize, 2), p.ncol);
+ try std.testing.expectEqual(@as(usize, 0), p.col_n[1]);
}
test "Del from a key asks which neighbour takes the rows; nothing else asks" {
if (platform == .web) return;
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 40 });
defer p.deinit();
+ p.settings.placement = .pardes; // the neighbours below are what is under test
var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
defer frame.deinit();
const top = p.active;
@@ -6759,6 +6987,28 @@ test "Del from a key asks which neighbour takes the rows; nothing else asks" {
try std.testing.expectEqual(top, p.active);
try std.testing.expectEqual(h[2], p.rects[low].h);
+ // A script answers the question on the pane's ctl, as the log's `ask`
+ // names it: `answer -` takes it back, a side closes the pane toward it.
+ {
+ const th = @import("ninep/testing.zig");
+ p.newScratchBelow(top);
+ const asked = p.active;
+ p.sync();
+ const serial = p.panes[asked].?.serial;
+ const ctl = ctlfs.Node.of(serial, .ctl);
+ try std.testing.expectEqual(ctlfs.E.INVAL, th.wr(p, ctl, "answer k\n").errno());
+ p.update(.{ .key = .{ .cp = ' ' } });
+ p.update(.{ .key = .{ .cp = 'd' } });
+ var want: [32]u8 = undefined;
+ try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "ask {d} del k j\n", .{serial})));
+ try std.testing.expectEqual(ctlfs.Status.ok, th.wr(p, ctl, "answer -\n").reply.status);
+ try std.testing.expect(p.panes[asked] != null and p.panes[asked].?.prompt == .none);
+ p.update(.{ .key = .{ .cp = ' ' } });
+ p.update(.{ .key = .{ .cp = 'd' } });
+ try std.testing.expectEqual(ctlfs.Status.ok, th.wr(p, ctl, "answer k\n").reply.status);
+ try std.testing.expect(p.paneBySerial(serial) == null);
+ }
+
// Bare Del from anything but a key closes at once and gives the rows up,
// as it always has.
p.newScratchBelow(top);
@@ -6793,7 +7043,8 @@ test "focus on a closed pane goes to the pane that takes its place, not the one
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);
+ p.newColumn(shell);
+ p.newScratchIn(shell, p.activeColumn().?);
const right = p.active;
p.sync();
p.newScratchBelow(shell);
@@ -6825,45 +7076,50 @@ test "focus on a closed pane goes to the pane that takes its place, not the one
p.sync();
p.active = left_low;
p.sync();
- try p.removeColumn(left_low);
+ try p.removeColumn(layout.findPane(p, left_low).?.col);
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" {
+test "closing the last pane in a column leaves the column empty, its tag focused" {
if (platform == .web) return;
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 24 });
defer p.deinit();
const shell = p.active;
- p.newScratchColumn(shell);
+ p.newColumn(shell);
+ p.newScratchIn(shell, p.activeColumn().?);
const lone = p.active;
try std.testing.expect(lone != shell);
try std.testing.expectEqual(@as(usize, 2), p.ncol);
const at = layout.findPane(p, lone).?;
try std.testing.expectEqual(@as(usize, 1), p.col_n[at.col]);
- const had = try std.testing.allocator.dupe(u8, p.panes[lone].?.cwdSlice());
- defer std.testing.allocator.free(had);
+ p.sync();
try p.removePane(lone, null);
p.sync();
- // The column outlives the pane: still two columns, and the one that was
- // emptied holds a fresh empty pane in the same directory.
+ // The column outlives the pane, as acme's does: still two columns, the
+ // emptied one with no pane and the keyboard on its tag.
try std.testing.expectEqual(@as(usize, 2), p.ncol);
- try std.testing.expectEqual(@as(usize, 1), p.col_n[at.col]);
- const heir = p.col_panes[at.col][0];
- try std.testing.expect(heir != lone);
+ try std.testing.expectEqual(@as(usize, 0), p.col_n[at.col]);
try std.testing.expect(p.panes[lone] == null);
- const hp = p.panes[heir].?;
- try std.testing.expectEqualStrings("", hp.file.?.content);
- try std.testing.expectEqualStrings(had, hp.cwdSlice());
- try std.testing.expectEqual(heir, p.active);
+ try std.testing.expect(p.header_focus and p.header_column.? == at.col);
+ try std.testing.expectEqual(shell, p.active);
try std.testing.expect(!p.quit);
+ // Down from it goes nowhere: nothing is below.
+ try std.testing.expect(p.executeBuiltinLine(shell, "Down"));
+ try std.testing.expect(p.header_focus and p.header_column.? == at.col);
+
// ...and Delcol is what actually takes the column away.
- try p.removeColumn(heir);
+ try p.removeColumn(at.col);
p.sync();
try std.testing.expectEqual(@as(usize, 1), p.ncol);
+ try std.testing.expectEqual(shell, p.active);
+
+ // The session's last pane closing ends it.
+ try p.removePane(shell, null);
+ try std.testing.expect(p.quit);
}
test "the bare tty startup opens a text pane under the shell, unfocused" {
@@ -7029,6 +7285,7 @@ test "hopping between two panes does not grow the jump stack" {
if (platform == .web) return;
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "mise.toml" });
+ p.settings.placement = .pardes; // two panes of room, not acme's fit to the doc's two lines
defer p.deinit();
p.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc