src/tui/wall_test_layout.zig
Ref: Size: 63.3 KiB History
//! The pane tree, the rects it cuts and the layout file (wall_layout.zig).
const std = @import("std");
const proto = @import("term").protocol;
const hosts = @import("client").hosts;
const layout = @import("client").layout;
const TmpDir = @import("testtmp").TmpDir;
const fixture = @import("wall_test_harness.zig");
const wall_host = @import("wall_host.zig");
const wall_layout = @import("wall_layout.zig");
const wv = @import("wallview.zig");
const Host = wall_host.Host;
const ResizeWitness = fixture.ResizeWitness;
const Shared = wv.Shared;
const Tile = wv.Tile;
const WallScreen = fixture.WallScreen;
test "the tree's stacked cut splits rows with the remainder at the top" {
const alloc = std.testing.allocator;
var tree = layout.Tree.init(alloc);
defer tree.deinit();
try tree.addFirst(0);
try tree.insert(0, 1);
try tree.insert(1, 2);
var f = try tree.flatten(alloc, 25, 80, wall_layout.wallFloors(true), null);
defer f.deinit(alloc);
try std.testing.expectEqual(layout.Rect{ .top = 0, .left = 0, .rows = 9, .cols = 80 }, f.rectOf(0).?);
try std.testing.expectEqual(layout.Rect{ .top = 9, .left = 0, .rows = 8, .cols = 80 }, f.rectOf(1).?);
try std.testing.expectEqual(layout.Rect{ .top = 17, .left = 0, .rows = 8, .cols = 80 }, f.rectOf(2).?);
}
test "the tree refuses a wall that cannot show a content row" {
const alloc = std.testing.allocator;
var tree = layout.Tree.init(alloc);
defer tree.deinit();
// 12 tiles over 23 rows: 1 row each — a label with no content.
try tree.addFirst(0);
for (1..12) |i| try tree.insert(@intCast(i - 1), @intCast(i));
try std.testing.expectError(error.TooSmall, tree.flatten(alloc, 23, 80, wall_layout.wallFloors(true), null));
}
test "the tree refuses a multi-tile cut too thin for the daemon's row floor" {
const alloc = std.testing.allocator;
var tree = layout.Tree.init(alloc);
defer tree.deinit();
try tree.addFirst(0);
try tree.insert(0, 1);
// Two tiles on five rows: each rect is two rows, and under a label
// bar that is one content row — below `min_session_rows`, so the
// daemon refuses the resize and the tile freezes on a stale grid.
try std.testing.expectError(error.TooSmall, tree.flatten(alloc, 5, 80, wall_layout.wallFloors(true), null));
try std.testing.expectError(error.TooSmall, tree.flatten(alloc, 4, 80, wall_layout.wallFloors(true), null));
// Six rows is the first cut that fits: three a rect, two of them
// content under the bar — exactly the daemon's row floor.
var f = try tree.flatten(alloc, 6, 80, wall_layout.wallFloors(true), null);
defer f.deinit(alloc);
try std.testing.expectEqual(@as(u16, 3), f.rectOf(0).?.rows);
try std.testing.expectEqual(@as(u16, 3), f.rectOf(1).?.rows);
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 6 }, .is_tty = true };
var t0 = Tile{
.r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
.rect = f.rectOf(0).?,
.shared = &shared,
.idx = 0,
.wake_r = -1,
.wake_w = -1,
};
var t1 = Tile{
.r = .{ .target = .{ .sock = "/tmp/y" }, .label = "y", .session = "" },
.rect = f.rectOf(1).?,
.shared = &shared,
.idx = 1,
.wake_r = -1,
.wake_w = -1,
};
try std.testing.expectEqual(proto.min_session_rows, t0.viewRows());
try std.testing.expectEqual(proto.min_session_rows, t1.viewRows());
// A piped wall draws no bar, so its floor is the daemon's alone.
var one = layout.Tree.init(alloc);
defer one.deinit();
try one.addFirst(0);
var of = try one.flatten(alloc, 2, 80, wall_layout.wallFloors(false), null);
defer of.deinit(alloc);
try std.testing.expectEqual(@as(u16, 2), of.rectOf(0).?.rows);
}
test "viewRows: a bar-less wall keeps every row, a barred tile loses one" {
// No bar (a pipe): the tile claims every row.
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
var t0 = Tile{
.r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
.rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
.shared = &shared,
.idx = 0,
.wake_r = -1,
.wake_w = -1,
};
try std.testing.expectEqual(@as(u16, 24), t0.viewRows());
// Under a bar (any tty wall): each tile loses one row to it.
shared.is_tty = true;
var t1 = Tile{
.r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
.rect = .{ .top = 0, .left = 0, .rows = 12, .cols = 80 },
.shared = &shared,
.idx = 0,
.wake_r = -1,
.wake_w = -1,
};
var t2 = Tile{
.r = .{ .target = .{ .sock = "/tmp/y" }, .label = "y", .session = "" },
.rect = .{ .top = 12, .left = 0, .rows = 12, .cols = 80 },
.shared = &shared,
.idx = 1,
.wake_r = -1,
.wake_w = -1,
};
try std.testing.expectEqual(@as(u16, 11), t1.viewRows());
try std.testing.expectEqual(@as(u16, 11), t2.viewRows());
}
test "relayout sets resize_pending on every live tile" {
const alloc = std.testing.allocator;
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
shared.tree = layout.Tree.init(alloc);
shared.flat_alloc = alloc;
defer shared.tree.deinit();
defer if (shared.last_flat) |*f| f.deinit(alloc);
defer if (shared.base_flat) |*f| f.deinit(alloc);
try shared.tree.addFirst(0);
try shared.tree.insert(0, 1);
const tiles = try alloc.alloc(Tile, 2);
defer alloc.free(tiles);
const present = try alloc.alloc(bool, 2);
defer alloc.free(present);
present[0] = true;
present[1] = true;
for (tiles, 0..) |*t, i| {
t.* = Tile{
.r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
.rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
.shared = &shared,
.idx = i,
.wake_r = -1,
.wake_w = -1,
};
}
wall_layout.relayout(fixture.wallAll(alloc, tiles, present, &shared), 0);
// A tty wall draws its label bar.
try std.testing.expectEqual(@as(u8, 1), shared.labelRows());
// Every tile is doorbelled: its pump sends the new rect.
try std.testing.expect(tiles[0].resize_pending);
try std.testing.expect(tiles[1].resize_pending);
}
test "a relayout landing mid-pass is still sent" {
const alloc = std.testing.allocator;
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
shared.tree = layout.Tree.init(alloc);
shared.flat_alloc = alloc;
defer shared.tree.deinit();
defer if (shared.last_flat) |*f| f.deinit(alloc);
defer if (shared.base_flat) |*f| f.deinit(alloc);
try shared.tree.addFirst(0);
try shared.tree.insert(0, 1);
const tiles = try alloc.alloc(Tile, 2);
defer alloc.free(tiles);
var present = [_]bool{ true, true };
for (tiles, 0..) |*t, i| {
t.* = Tile{
.r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
.rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
.shared = &shared,
.idx = i,
.wake_r = -1,
.wake_w = -1,
};
}
var w = ResizeWitness{ .t = &tiles[0] };
const th = try std.Thread.spawn(.{}, ResizeWitness.run, .{&w});
defer {
w.stop.store(true, .release);
th.join();
}
// The dimension this fixture varies is TIME: the wall is re-cut while a
// pump is mid-pass, over and over, so a relayout is free to land at any
// point inside one. Both heights clear `wallFloors(2)`, so every cut
// really does move the rect.
const heights = [_]u16{ 24, 22, 24, 23 };
var i: usize = 0;
while (i < 400) : (i += 1) {
shared.paint_mu.lock();
shared.size = .{ .cols = 80, .rows = heights[i % heights.len] };
shared.paint_mu.unlock();
wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0);
// Quiesce: the pump owes nothing, and the pass that took the last
// doorbell has finished. Only then is "what the daemon holds" a
// settled question.
while (true) {
shared.paint_mu.lock();
const owed = tiles[0].resize_pending;
shared.paint_mu.unlock();
if (!owed) break;
}
const n = w.passes.load(.acquire);
while (w.passes.load(.acquire) < n + 1) {}
shared.paint_mu.lock();
const want = ResizeWitness.pack(tiles[0].rect.cols, tiles[0].rect.rows -| shared.labelRows());
shared.paint_mu.unlock();
// The claim: a quiet wall and a quiet pump agree on the grid. A
// doorbell consumed apart from the rect it describes breaks this —
// the pump sent the rect it snapshotted BEFORE the re-cut, cleared
// the flag, and nothing re-sends.
try std.testing.expectEqual(want, w.sent.load(.acquire));
}
}
test "a terminal too small for the cut falls back to the focused pane, and grows back" {
const alloc = std.testing.allocator;
const n = 7;
var screen = try WallScreen.init(alloc, 80, 24);
defer screen.deinit();
var shared = Shared{ .out_fd = screen.w, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
shared.tree = layout.Tree.init(alloc);
shared.flat_alloc = alloc;
defer shared.tree.deinit();
defer if (shared.last_flat) |*f| f.deinit(alloc);
defer if (shared.base_flat) |*f| f.deinit(alloc);
try shared.tree.addFirst(0);
var k: u8 = 1;
while (k < n) : (k += 1) try shared.tree.insert(k - 1, k);
const tiles = try alloc.alloc(Tile, n);
defer alloc.free(tiles);
const present = try alloc.alloc(bool, n);
defer alloc.free(present);
var labels: [n][2]u8 = undefined;
for (tiles, 0..) |*t, i| {
present[i] = true;
labels[i] = .{ 't', '0' + @as(u8, @intCast(i)) };
t.* = Tile{
.r = .{ .target = .{ .sock = "/tmp/x" }, .label = &labels[i], .session = "" },
.rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
.shared = &shared,
.idx = i,
.wake_r = -1,
.wake_w = -1,
};
}
// One tile whose pump has ended: the keyboard is the only thread left
// that can paint its bar, so it is the tile that proves a HIDDEN pane
// gets no bar either.
tiles[n - 1].alive.store(false, .release);
// The dimension this fixture varies is the terminal's height — the one
// the wall is cut against. 24 rows hold seven panes under their bars;
// 20 do not, and `flatten` says so; and the wall has to come back.
for ([_]u16{ 24, 20, 24 }) |rows| {
try screen.eng.resize(80, rows);
shared.paint_mu.lock();
shared.size = .{ .cols = 80, .rows = rows };
shared.paint_mu.unlock();
wall_layout.relayout(fixture.wallAll(alloc, tiles, present, &shared), 0);
// What the pumps do with the generation bump: repaint their bars.
for (tiles, present) |*t, p| if (p) wv.paintLabel(t, .up);
screen.drain();
const dump = try screen.eng.dumpPlain(alloc);
defer alloc.free(dump);
// No tile ever claims a row the terminal does not have. A SIGWINCH
// is not an operation and cannot be refused: doing nothing leaves
// every rect pointing past the bottom of the screen.
for (tiles, present) |*t, p| {
if (!p) continue;
try std.testing.expect(t.rect.top + t.rect.rows <= rows);
try std.testing.expect(t.rect.left + t.rect.cols <= 80);
}
if (rows == 20) {
// The degrade: the focused pane whole, every other pane at 0x0
// — a rect that paints nothing and claims no size — and the
// tree untouched underneath. The bar stays: it follows the
// tty, and the one visible pane still deserves its name.
try std.testing.expectEqual(@as(u16, 1), shared.labelRows());
try std.testing.expectEqual(@as(u16, 20), tiles[0].rect.rows);
try std.testing.expectEqual(@as(u16, 80), tiles[0].rect.cols);
try std.testing.expectEqual(@as(u16, 0), tiles[0].rect.top);
for (tiles[1..]) |*t| try std.testing.expectEqual(@as(u16, 0), t.rect.rows);
// The focused pane's bar is the only ink: a hidden tile's
// 0-wide rect can paint no bar, so every other row the wall
// itself wrote is blank.
const l0 = WallScreen.line(dump, 0) orelse return error.NoSuchRow;
try std.testing.expect(std.mem.indexOf(u8, l0, tiles[0].r.label) != null);
var it = std.mem.splitScalar(u8, dump, '\n');
var row: usize = 0;
while (it.next()) |l| : (row += 1) {
if (row == 0) continue;
try std.testing.expectEqual(@as(usize, 0), std.mem.trim(u8, l, " ").len);
}
} else {
try std.testing.expectEqual(@as(u16, 1), shared.labelRows());
// Seven bars, each on the first row of the pane it names.
for (tiles) |*t| {
const l = WallScreen.line(dump, t.rect.top) orelse return error.NoSuchRow;
try std.testing.expect(std.mem.indexOf(u8, l, t.r.label) != null);
}
}
}
}
test "fullscreen gives the focused tile the whole terminal and hides the rest" {
const alloc = std.testing.allocator;
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
shared.tree = layout.Tree.init(alloc);
shared.flat_alloc = alloc;
defer shared.tree.deinit();
defer if (shared.last_flat) |*f| f.deinit(alloc);
defer if (shared.base_flat) |*f| f.deinit(alloc);
try shared.tree.addFirst(0);
try shared.tree.insert(0, 1);
const tiles = try alloc.alloc(Tile, 2);
defer alloc.free(tiles);
var present = [_]bool{ true, true };
for (tiles, 0..) |*t, i| {
t.* = Tile{
.r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
.rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
.shared = &shared,
.idx = i,
.wake_r = -1,
.wake_w = -1,
};
}
shared.fullscreen = true;
wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0);
// No label bar on a pipe, fullscreened or not — the bar is the tty's.
try std.testing.expectEqual(@as(u8, 0), shared.labelRows());
// Tile 0 gets the whole terminal; tile 1 gets nothing.
try std.testing.expectEqual(@as(u16, 24), tiles[0].rect.rows);
try std.testing.expectEqual(@as(u16, 80), tiles[0].rect.cols);
try std.testing.expectEqual(@as(u16, 0), tiles[1].rect.rows);
try std.testing.expectEqual(@as(u16, 0), tiles[1].rect.cols);
// The base flat is kept for neighbor.
try std.testing.expect(shared.base_flat != null);
}
test "toggle off fullscreen restores the real rects" {
const alloc = std.testing.allocator;
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
shared.tree = layout.Tree.init(alloc);
shared.flat_alloc = alloc;
defer shared.tree.deinit();
defer if (shared.last_flat) |*f| f.deinit(alloc);
defer if (shared.base_flat) |*f| f.deinit(alloc);
try shared.tree.addFirst(0);
try shared.tree.insert(0, 1);
const tiles = try alloc.alloc(Tile, 2);
defer alloc.free(tiles);
var present = [_]bool{ true, true };
for (tiles, 0..) |*t, i| {
t.* = Tile{
.r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
.rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
.shared = &shared,
.idx = i,
.wake_r = -1,
.wake_w = -1,
};
}
shared.fullscreen = true;
wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0);
shared.fullscreen = false;
wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0);
// Two tiles again: the bar (never gone on a tty) and both tiles'
// real rects are back (stacked: full width, half height each).
try std.testing.expectEqual(@as(u8, 1), shared.labelRows());
try std.testing.expectEqual(@as(u16, 80), tiles[0].rect.cols);
try std.testing.expectEqual(@as(u16, 80), tiles[1].rect.cols);
try std.testing.expect(tiles[0].rect.rows < 24);
try std.testing.expect(tiles[1].rect.rows > 0);
}
test "the label bar follows the tty, not the tile count" {
// The bar is what names the session on screen, so hiding it when one
// tile is visible left a zoomed or solo session anonymous. On a tty it
// is always drawn — one tile, many, or fullscreen. A piped `mux` never
// draws one: its byte stream is a script's input, and a bar in it
// would be bytes the session never wrote.
const alloc = std.testing.allocator;
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
shared.tree = layout.Tree.init(alloc);
shared.flat_alloc = alloc;
defer shared.tree.deinit();
defer if (shared.last_flat) |*f| f.deinit(alloc);
defer if (shared.base_flat) |*f| f.deinit(alloc);
try shared.tree.addFirst(0);
const tiles = try alloc.alloc(Tile, 1);
defer alloc.free(tiles);
var present = [_]bool{true};
tiles[0] = Tile{
.r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
.rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
.shared = &shared,
.idx = 0,
.wake_r = -1,
.wake_w = -1,
};
// One tile on a tty: the bar stays, and the session runs a row short.
wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0);
try std.testing.expectEqual(@as(u16, 1), shared.labelRows());
try std.testing.expectEqual(@as(u16, 23), tiles[0].viewRows());
// Fullscreen is still a tty view, so the zoomed tile keeps its name.
shared.fullscreen = true;
wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0);
try std.testing.expectEqual(@as(u16, 1), shared.labelRows());
// A pipe draws no bar whatever the count.
shared.fullscreen = false;
shared.is_tty = false;
wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0);
try std.testing.expectEqual(@as(u16, 0), shared.labelRows());
}
test "focus_dir while fullscreened follows the focus" {
const alloc = std.testing.allocator;
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
shared.tree = layout.Tree.init(alloc);
shared.flat_alloc = alloc;
defer shared.tree.deinit();
defer if (shared.last_flat) |*f| f.deinit(alloc);
defer if (shared.base_flat) |*f| f.deinit(alloc);
try shared.tree.addFirst(0);
try shared.tree.insert(0, 1);
const tiles = try alloc.alloc(Tile, 2);
defer alloc.free(tiles);
var present = [_]bool{ true, true };
for (tiles, 0..) |*t, i| {
t.* = Tile{
.r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
.rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
.shared = &shared,
.idx = i,
.wake_r = -1,
.wake_w = -1,
};
}
shared.fullscreen = true;
wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0);
// Move focus to tile 1 while fullscreened.
wv.focusAnswer(fixture.wallAll(alloc, tiles, &present, &shared), false, 1);
// The full rect followed: tile 1 now owns the terminal.
try std.testing.expectEqual(@as(u16, 24), tiles[1].rect.rows);
try std.testing.expectEqual(@as(u16, 80), tiles[1].rect.cols);
try std.testing.expectEqual(@as(u16, 0), tiles[0].rect.rows);
try std.testing.expectEqual(@as(usize, 1), shared.sel);
}
test "resize: l grows the focused pane, h shrinks it" {
// Spec: h shrinks width, l grows width. layout.resize always makes
// the focus GAIN cells, so shrink = grow a neighbor at focus's
// expense. Two beside panes at 80x24 (floors {3,2}): equal split is
// 39/40 (rail takes 1). l moves the rail right; h moves it left.
const alloc = std.testing.allocator;
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
shared.tree = layout.Tree.init(alloc);
shared.flat_alloc = alloc;
defer shared.tree.deinit();
defer if (shared.last_flat) |*f| f.deinit(alloc);
defer if (shared.base_flat) |*f| f.deinit(alloc);
try shared.tree.addFirst(0);
try shared.tree.splitRight(0, 1);
const tiles = try alloc.alloc(Tile, 2);
defer alloc.free(tiles);
var present = [_]bool{ true, true };
for (tiles, 0..) |*t, i| {
t.* = Tile{
.r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
.rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
.shared = &shared,
.idx = i,
.wake_r = -1,
.wake_w = -1,
};
}
wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0);
const before = tiles[0].rect.cols;
// l (grow width): focus 0 gains from its right sibling.
try std.testing.expect(wall_layout.doResize(fixture.wallAll(alloc, tiles, &present, &shared), 0, .right));
try std.testing.expect(tiles[0].rect.cols > before);
// h (shrink width): the right neighbor gains a cell back from focus.
try std.testing.expect(wall_layout.doResize(fixture.wallAll(alloc, tiles, &present, &shared), 0, .left));
try std.testing.expectEqual(before, tiles[0].rect.cols);
}
test "resize: j grows height, k shrinks height" {
const alloc = std.testing.allocator;
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
shared.tree = layout.Tree.init(alloc);
shared.flat_alloc = alloc;
defer shared.tree.deinit();
defer if (shared.last_flat) |*f| f.deinit(alloc);
defer if (shared.base_flat) |*f| f.deinit(alloc);
try shared.tree.addFirst(0);
try shared.tree.splitBelow(0, 1);
const tiles = try alloc.alloc(Tile, 2);
defer alloc.free(tiles);
var present = [_]bool{ true, true };
for (tiles, 0..) |*t, i| {
t.* = Tile{
.r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
.rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
.shared = &shared,
.idx = i,
.wake_r = -1,
.wake_w = -1,
};
}
wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0);
const before = tiles[0].rect.rows;
try std.testing.expect(wall_layout.doResize(fixture.wallAll(alloc, tiles, &present, &shared), 0, .down));
try std.testing.expect(tiles[0].rect.rows > before);
try std.testing.expect(wall_layout.doResize(fixture.wallAll(alloc, tiles, &present, &shared), 0, .up));
try std.testing.expectEqual(before, tiles[0].rect.rows);
}
test "resize refuses while fullscreened" {
const alloc = std.testing.allocator;
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
shared.tree = layout.Tree.init(alloc);
shared.flat_alloc = alloc;
defer shared.tree.deinit();
defer if (shared.last_flat) |*f| f.deinit(alloc);
defer if (shared.base_flat) |*f| f.deinit(alloc);
try shared.tree.addFirst(0);
try shared.tree.splitRight(0, 1);
const tiles = try alloc.alloc(Tile, 2);
defer alloc.free(tiles);
var present = [_]bool{ true, true };
for (tiles, 0..) |*t, i| {
t.* = Tile{
.r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
.rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
.shared = &shared,
.idx = i,
.wake_r = -1,
.wake_w = -1,
};
}
shared.fullscreen = true;
wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0);
try std.testing.expect(!wall_layout.doResize(fixture.wallAll(alloc, tiles, &present, &shared), 0, .right));
}
test "a pump's answer that grew the wall re-cuts it; a mere focus move does not" {
const alloc = std.testing.allocator;
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
defer if (shared.last_flat) |*f| f.deinit(shared.flat_alloc);
defer if (shared.base_flat) |*f| f.deinit(shared.flat_alloc);
const tiles = try alloc.alloc(Tile, 2);
defer alloc.free(tiles);
const present = try alloc.alloc(bool, 2);
defer alloc.free(present);
present[0] = true;
present[1] = true;
for (tiles, 0..) |*t, i| {
t.* = Tile{
.r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
.rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
.shared = &shared,
.idx = i,
.wake_r = -1,
.wake_w = -1,
};
}
// Growth: without the re-cut the new tile sits on placeholder geometry
// and the old tile still owns the whole terminal — the screen keeps
// showing tile 0 while the keyboard types into tile 1's rows.
wv.focusAnswer(fixture.wallAll(alloc, tiles, present, &shared), true, 1);
try std.testing.expectEqual(@as(u8, 1), shared.labelRows());
try std.testing.expect(tiles[0].resize_pending);
try std.testing.expect(tiles[1].resize_pending);
try std.testing.expectEqual(@as(usize, 1), shared.sel);
try std.testing.expect(tiles[1].claim_pending.load(.acquire));
tiles[0].resize_pending = false;
tiles[1].resize_pending = false;
// No growth: a full clear here would flash the wall for a focus move.
wv.focusAnswer(fixture.wallAll(alloc, tiles, present, &shared), false, 0);
try std.testing.expect(!tiles[0].resize_pending);
try std.testing.expect(!tiles[1].resize_pending);
try std.testing.expectEqual(@as(usize, 0), shared.sel);
try std.testing.expect(tiles[0].claim_pending.load(.acquire));
}
test "a piped wall draws no label bar and paints row 1" {
const alloc = std.testing.allocator;
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
shared.tree = layout.Tree.init(alloc);
shared.flat_alloc = alloc;
defer shared.tree.deinit();
defer if (shared.last_flat) |*f| f.deinit(alloc);
defer if (shared.base_flat) |*f| f.deinit(alloc);
try shared.tree.addFirst(0);
const tiles = try alloc.alloc(Tile, 1);
defer alloc.free(tiles);
const present = try alloc.alloc(bool, 1);
defer alloc.free(present);
present[0] = true;
tiles[0] = Tile{
.r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
.rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
.shared = &shared,
.idx = 0,
.wake_r = -1,
.wake_w = -1,
};
wall_layout.relayout(fixture.wallAll(alloc, tiles, present, &shared), 0);
// A pipe: no label bar, every row is content the script can read.
try std.testing.expectEqual(@as(u8, 0), shared.labelRows());
try std.testing.expectEqual(@as(u16, 24), tiles[0].viewRows());
}
test "a relayout drops every tile's highlight, because the stripes move under it" {
// The drag is per-tile now, on each pump's Core, so relayout cannot
// reach it directly. It bumps repaint_gen instead, and every pump
// clears its own core.drag when it sees the generation move under it —
// a relayout re-cut the rect a held drag's anchor was resolved against.
// The generation is the whole mechanism, so it is what this pins.
const alloc = std.testing.allocator;
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
shared.tree = layout.Tree.init(alloc);
shared.flat_alloc = alloc;
defer shared.tree.deinit();
defer if (shared.last_flat) |*f| f.deinit(alloc);
defer if (shared.base_flat) |*f| f.deinit(alloc);
try shared.tree.addFirst(0);
try shared.tree.insert(0, 1);
const tiles = try alloc.alloc(Tile, 2);
defer alloc.free(tiles);
const present = try alloc.alloc(bool, 2);
defer alloc.free(present);
present[0] = true;
present[1] = true;
for (tiles, 0..) |*t, i| {
t.* = Tile{
.r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
.rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
.shared = &shared,
.idx = i,
.wake_r = -1,
.wake_w = -1,
};
}
const before = shared.repaint_gen.load(.acquire);
wall_layout.relayout(fixture.wallAll(alloc, tiles, present, &shared), 0);
// One bump, every tile's pump clears its drag on it: the stripes moved,
// so a held drag's anchor names a rect that is somewhere else now.
try std.testing.expect(shared.repaint_gen.load(.acquire) > before);
}
test "saveLayoutTo writes the sidecar for the present tiles, spellings verbatim" {
// A wall saves its tree on the way out: the leaf spellings are the
// tiles' labels verbatim, so a reload restores the same view.
const alloc = std.testing.allocator;
var tmp = try TmpDir.make();
defer tmp.cleanup();
const path = try std.fmt.allocPrint(alloc, "{s}/layout", .{tmp.path()});
defer alloc.free(path);
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
shared.tree = layout.Tree.init(alloc);
shared.flat_alloc = alloc;
defer shared.tree.deinit();
defer if (shared.last_flat) |*f| f.deinit(alloc);
defer if (shared.base_flat) |*f| f.deinit(alloc);
try shared.tree.addFirst(0);
try shared.tree.splitRight(0, 1);
const tiles = try alloc.alloc(Tile, 2);
defer alloc.free(tiles);
for (tiles, 0..) |*t, i| {
t.* = Tile{
.r = .{
.target = .{ .sock = "/tmp/x" },
.label = switch (i) {
0 => "--sock /tmp/x#a",
else => "--sock /tmp/x#b",
},
.session = "",
},
.rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
.shared = &shared,
.idx = i,
.wake_r = -1,
.wake_w = -1,
};
}
var present = [_]bool{ true, true };
try wall_layout.saveLayoutTo(alloc, path, tiles, &present, &shared);
const got = wall_layout.loadLayout(alloc, path) orelse return error.TestUnexpectedResult;
defer alloc.free(got);
try std.testing.expect(std.mem.startsWith(u8, got, "mux-layout 1\nbeside 0\n"));
try std.testing.expect(std.mem.indexOf(u8, got, "--sock /tmp/x#b\n") != null);
}
test "saveLayoutTo handles a vanished middle tile without panicking" {
// Tiles are never compacted (pump threads hold pointers into the
// array), so a vanished middle tile leaves a hole: present = {t,f,t}
// and the tree holds leaves 0 and 2. serialize indexes spellings by
// leaf ID, so the array must be tile-indexed — a dense array would
// panic on spellings[2] with only two entries.
const alloc = std.testing.allocator;
var tmp = try TmpDir.make();
defer tmp.cleanup();
const path = try std.fmt.allocPrint(alloc, "{s}/layout", .{tmp.path()});
defer alloc.free(path);
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
shared.tree = layout.Tree.init(alloc);
shared.flat_alloc = alloc;
defer shared.tree.deinit();
defer if (shared.last_flat) |*f| f.deinit(alloc);
defer if (shared.base_flat) |*f| f.deinit(alloc);
try shared.tree.addFirst(0);
try shared.tree.splitRight(0, 1);
try shared.tree.splitRight(1, 2);
// Tile 1 vanished: remove leaf 1 from the tree.
shared.tree.remove(1);
const tiles = try alloc.alloc(Tile, 3);
defer alloc.free(tiles);
for (tiles, 0..) |*t, i| {
t.* = Tile{
.r = .{
.target = .{ .sock = "/tmp/x" },
.label = switch (i) {
0 => "--sock /tmp/x#a",
1 => "--sock /tmp/x#b",
else => "--sock /tmp/x#c",
},
.session = "",
},
.rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
.shared = &shared,
.idx = i,
.wake_r = -1,
.wake_w = -1,
};
}
var present = [_]bool{ true, false, true };
try wall_layout.saveLayoutTo(alloc, path, tiles, &present, &shared);
const got = wall_layout.loadLayout(alloc, path) orelse return error.TestUnexpectedResult;
defer alloc.free(got);
// Tile 2's label is verbatim; the hole (tile 1) is never serialized.
try std.testing.expect(std.mem.indexOf(u8, got, "--sock /tmp/x#c\n") != null);
try std.testing.expect(std.mem.indexOf(u8, got, "--sock /tmp/x#b") == null);
}
test "layout sidecar: save round-trips through load; a missing file is null" {
const alloc = std.testing.allocator;
var tmp = try TmpDir.make();
defer tmp.cleanup();
const path = try std.fmt.allocPrint(alloc, "{s}/layout", .{tmp.path()});
defer alloc.free(path);
try std.testing.expect(wall_layout.loadLayout(alloc, path) == null);
try hosts.saveBytes(path, "mux-layout 1\nleaf 0 x\n");
const got = wall_layout.loadLayout(alloc, path) orelse return error.TestUnexpectedResult;
defer alloc.free(got);
try std.testing.expectEqualStrings("mux-layout 1\nleaf 0 x\n", got);
}
test "layoutPathFrom: the sidecar sits beside the hosts file" {
const alloc = std.testing.allocator;
const p = try hosts.layoutPathFrom(alloc, "/xdg", null);
defer alloc.free(p);
try std.testing.expectEqualStrings("/xdg/mux/layout", p);
const q = try hosts.layoutPathFrom(alloc, null, "/home/u");
defer alloc.free(q);
try std.testing.expectEqualStrings("/home/u/.local/state/mux/layout", q);
}
// ---- the seed: the sidecar's cut before any host has answered ----
/// Two hosts and a three-leaf sidecar — beside(a, stacked(b, c)) with
/// UNEQUAL weights — so orientation, weight and host aliasing are all
/// dimensions every seed test exercises by default.
const seed_bytes = "mux-layout 1\nbeside 0\n leaf 70 --sock /tmp/h0.sock#a\n stacked 29\n leaf 20 --sock /tmp/h0.sock#b\n leaf 8 --sock /tmp/h1.sock#a\nfocus 1\n";
fn seedShared(alloc: std.mem.Allocator, shared: *Shared) void {
shared.* = Shared{ .out_fd = -1, .size = .{ .cols = 100, .rows = 30 }, .is_tty = false };
shared.tree = layout.Tree.init(alloc);
shared.flat_alloc = alloc;
}
test "seed: the saved cut is the tree before any host has answered - orientation and weights both" {
const alloc = std.testing.allocator;
var shared: Shared = undefined;
seedShared(alloc, &shared);
defer shared.tree.deinit();
var table = [_]wall_host.Host{
fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
};
var res = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, null);
defer if (res == .plan) res.plan.deinit(alloc);
try std.testing.expect(res == .plan);
const plan = res.plan;
try std.testing.expectEqual(@as(usize, 3), plan.panes.len);
try std.testing.expectEqual(@as(usize, 0), plan.dropped);
try std.testing.expectEqual(@as(usize, 0), plan.panes[0].?.host);
try std.testing.expectEqualStrings("a", plan.panes[0].?.session);
try std.testing.expectEqual(@as(usize, 1), plan.panes[2].?.host);
var f = try shared.tree.flatten(alloc, 30, 100, wall_layout.wallFloors(true), null);
defer f.deinit(alloc);
// The saved orientation: pane 0 beside the stack of 1 over 2. The old
// heal lost both to the default cut; the rects are the pin.
const r0 = f.rectOf(0).?;
const r1 = f.rectOf(1).?;
const r2 = f.rectOf(2).?;
try std.testing.expect(r1.left > 0 and r1.top == 0);
try std.testing.expect(r2.left == r1.left and r2.top > 0);
// The saved weights: 70/29 is not an even cut.
try std.testing.expect(r0.cols > 2 * r1.cols);
}
test "seed: the session this shell is standing in is never seeded" {
const alloc = std.testing.allocator;
var shared: Shared = undefined;
seedShared(alloc, &shared);
defer shared.tree.deinit();
var table = [_]wall_host.Host{
fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
};
table[0].self_name = "a";
// The one leaf a good file can name and this wall still not seat: it
// is a drop, counted, and not a refusal of the file around it.
var res = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, null);
defer if (res == .plan) res.plan.deinit(alloc);
try std.testing.expect(res == .plan);
const plan = res.plan;
try std.testing.expectEqual(@as(usize, 2), plan.panes.len);
try std.testing.expectEqual(@as(usize, 1), plan.dropped);
// Host 1's aliasing "a" is another host's session and stays.
try std.testing.expectEqualStrings("--sock /tmp/h0.sock#b", plan.panes[0].?.label);
try std.testing.expectEqualStrings("--sock /tmp/h1.sock#a", plan.panes[1].?.label);
}
test "seed: a file naming nothing but this shell's own sessions is self_only, not the silence of an empty one" {
const alloc = std.testing.allocator;
var shared: Shared = undefined;
seedShared(alloc, &shared);
defer shared.tree.deinit();
var table = [_]wall_host.Host{
fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
};
// Plural and off-origin: BOTH hosts are the shell's own, so nothing is
// left after the drops and the answer cannot come from a single leaf.
table[0].self_name = "a";
table[1].self_name = "a";
const all_self = "mux-layout 1\nbeside 0\n leaf 50 --sock /tmp/h0.sock#a\n leaf 50 --sock /tmp/h1.sock#a\n";
// Told apart from `.none` because the caller acts differently: this file
// is a wall the user authored and this run must not write over it, while
// an empty file has nothing to lose.
try std.testing.expect(wall_layout.seedLayout(alloc, &table, &shared, all_self, null) == .self_only);
try std.testing.expect(wall_layout.seedLayout(alloc, &table, &shared, " \n\t\n", null) == .none);
// One leaf that is not this shell's is a plan again, with the other
// counted as dropped - the distinction is "none seated", not "any self".
const one_free = "mux-layout 1\nbeside 0\n leaf 50 --sock /tmp/h0.sock#a\n leaf 50 --sock /tmp/h1.sock#b\n";
var res = wall_layout.seedLayout(alloc, &table, &shared, one_free, null);
defer if (res == .plan) res.plan.deinit(alloc);
try std.testing.expect(res == .plan);
try std.testing.expectEqual(@as(usize, 1), res.plan.dropped_self);
}
test "refusalNotice: the sentence names the line and is cut to what a notice holds" {
var buf: [96]u8 = undefined;
const short = wv.refusalNotice(&buf, "box#0");
try std.testing.expectEqualStrings("[layout not saved: the layout file was refused - fix box#0]", short);
// A leaf spelling can be longer than the whole notice; the head is what
// says what happened, so the tail of the line is what goes.
const long = wv.refusalNotice(&buf, "--sock /tmp/a/very/long/socket/path/that/nobody/would/type.sock#work");
try std.testing.expect(long.len <= buf.len);
try std.testing.expect(std.mem.startsWith(u8, long, "[layout not saved: the layout file was refused - fix --sock /tmp/a"));
try std.testing.expect(std.mem.endsWith(u8, long, "]"));
}
test "seed: garbage and a leaf no host on this wall can seat refuse the file; an empty file is silence - and the tree survives all three" {
const alloc = std.testing.allocator;
var shared: Shared = undefined;
seedShared(alloc, &shared);
defer shared.tree.deinit();
try shared.tree.addFirst(7);
var table = [_]wall_host.Host{
fixture.testHost(&shared, "--sock /tmp/other.sock", "/tmp/other.sock"),
};
// Text that is not a layout is named by its first line; a layout whose
// first leaf names a host this wall does not have is named by that leaf.
const junk = wall_layout.seedLayout(alloc, &table, &shared, "not a layout at all", null);
try std.testing.expect(junk == .refused);
try std.testing.expectEqualStrings("not a layout at all", junk.refused);
const unseatable = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, null);
try std.testing.expect(unseatable == .refused);
try std.testing.expectEqualStrings("--sock /tmp/h0.sock#a", unseatable.refused);
// An empty file was never authored: there is no line to print and
// nothing for the user to fix, so it degrades in silence.
try std.testing.expect(wall_layout.seedLayout(alloc, &table, &shared, "", null) == .none);
try std.testing.expectEqual(@as(usize, 1), shared.tree.count());
}
test "seed: more leaves than the terminal can cut seeds what fits and counts the rest" {
const alloc = std.testing.allocator;
var shared: Shared = undefined;
seedShared(alloc, &shared);
// Nine stacked panes at 3 rows each need 27; give them 13 rows, which
// holds four. The boot must not refuse - relayout's TooSmall degrade
// never runs on the first flatten, so the seed carries its own.
// A tty, as every restoring wall is: only a terminal saves a sidecar,
// and the bar row is part of what each pane must clear.
shared.is_tty = true;
shared.size = .{ .cols = 100, .rows = 13 };
defer shared.tree.deinit();
var table = [_]wall_host.Host{
fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
};
var buf = std.ArrayListUnmanaged(u8){};
defer buf.deinit(alloc);
try buf.appendSlice(alloc, "mux-layout 1\nstacked 0\n");
for (0..9) |i| try buf.writer(alloc).print(" leaf 10 --sock /tmp/h0.sock#s{d}\n", .{i});
var res = wall_layout.seedLayout(alloc, &table, &shared, buf.items, null);
defer if (res == .plan) res.plan.deinit(alloc);
try std.testing.expect(res == .plan);
try std.testing.expectEqual(@as(usize, 4), res.plan.panes.len);
try std.testing.expectEqual(@as(usize, 5), res.plan.dropped);
try std.testing.expectEqual(@as(usize, 4), shared.tree.count());
}
test "seed: more leaves than the wall seats refuses the file, naming the first leaf past the cap" {
const alloc = std.testing.allocator;
var shared: Shared = undefined;
seedShared(alloc, &shared);
// Tall enough that every leaf would fit: the cap under test is the
// wall's, not the terminal's.
shared.size = .{ .cols = 100, .rows = 500 };
defer shared.tree.deinit();
var table = [_]wall_host.Host{
fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
};
var buf = std.ArrayListUnmanaged(u8){};
defer buf.deinit(alloc);
try buf.appendSlice(alloc, "mux-layout 1\nstacked 0\n");
for (0..40) |i| try buf.writer(alloc).print(" leaf 10 --sock /tmp/h0.sock#s{d}\n", .{i});
const res = wall_layout.seedLayout(alloc, &table, &shared, buf.items, null);
try std.testing.expect(res == .refused);
var want_buf: [64]u8 = undefined;
const want = try std.fmt.bufPrint(&want_buf, "--sock /tmp/h0.sock#s{d}", .{wv.max_tiles});
try std.testing.expectEqualStrings(want, res.refused);
// Seating the first 32 of a 40-pane wall is not the wall that was
// saved, so nothing is seated at all.
try std.testing.expectEqual(@as(usize, 0), shared.tree.count());
}
test "seed: the saved focus comes back as the pane that carries it, and is null when that leaf was dropped" {
const alloc = std.testing.allocator;
var shared: Shared = undefined;
seedShared(alloc, &shared);
defer shared.tree.deinit();
var table = [_]wall_host.Host{
fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
};
var res = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, null);
try std.testing.expect(res == .plan);
try std.testing.expectEqual(@as(?usize, 1), res.plan.focus);
res.plan.deinit(alloc);
// The focused leaf (saved index 1, host 0's "b") is the session this
// shell is standing in, so it is dropped: nothing carries the record.
shared.tree.deinit();
shared.tree = layout.Tree.init(alloc);
table[0].self_name = "b";
var res2 = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, null);
defer if (res2 == .plan) res2.plan.deinit(alloc);
try std.testing.expect(res2 == .plan);
try std.testing.expectEqual(@as(?usize, null), res2.plan.focus);
}
test "seed: the entry tile's own pane is not pending; a sidecar that does not know it inserts it" {
const alloc = std.testing.allocator;
var shared: Shared = undefined;
seedShared(alloc, &shared);
defer shared.tree.deinit();
var table = [_]wall_host.Host{
fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
};
// Matched: the entry stands where its saved pane was, id 0, not
// pending, and no second pane claims its (host, session).
var res = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, "--sock /tmp/h0.sock#a");
try std.testing.expect(res == .plan);
try std.testing.expectEqual(@as(usize, 3), res.plan.panes.len);
try std.testing.expectEqual(@as(?wall_layout.SeedPane, null), res.plan.panes[0]);
try std.testing.expect(res.plan.panes[1] != null and res.plan.panes[2] != null);
try std.testing.expectEqual(@as(usize, 3), shared.tree.count());
res.plan.deinit(alloc);
// Unmatched: the sidecar predates this entry; its pane is inserted
// beside the saved focus, and every saved leaf is still a pane.
shared.tree.deinit();
shared.tree = layout.Tree.init(alloc);
var res2 = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, "--sock /tmp/h9.sock#z");
defer if (res2 == .plan) res2.plan.deinit(alloc);
try std.testing.expect(res2 == .plan);
try std.testing.expectEqual(@as(usize, 4), res2.plan.panes.len);
try std.testing.expectEqual(@as(?wall_layout.SeedPane, null), res2.plan.panes[0]);
try std.testing.expectEqual(@as(usize, 4), shared.tree.count());
}
test "a wall left before its hosts answered saves the shape it was given" {
const alloc = std.testing.allocator;
var shared: Shared = undefined;
seedShared(alloc, &shared);
defer shared.tree.deinit();
var tiles: [2]Tile = undefined;
var present = [_]bool{ true, true };
try shared.tree.addFirst(0);
try shared.tree.insert(0, 1);
for (0..2) |i| {
try wv.seedTile(&tiles[i], .{
.target = .{ .sock = "/tmp/h0.sock" },
.label = if (i == 0)
try alloc.dupe(u8, "--sock /tmp/h0.sock#a")
else
try alloc.dupe(u8, "--sock /tmp/h0.sock#b"),
.session = try alloc.dupe(u8, if (i == 0) "a" else "b"),
}, .{ .top = 0, .left = 0, .rows = 12, .cols = 100 }, &shared, i, 0);
}
defer for (tiles[0..2]) |*t| {
alloc.free(t.r.label);
alloc.free(t.r.session);
if (t.wake_r >= 0) std.posix.close(t.wake_r);
if (t.wake_w >= 0) std.posix.close(t.wake_w);
};
var tmp = try TmpDir.make();
defer tmp.cleanup();
var pbuf: [256]u8 = undefined;
const path = try std.fmt.bufPrint(&pbuf, "{s}/layout", .{tmp.path()});
// Detaching inside the settle window: the pending panes' spellings go
// back out verbatim, so a quick in-and-out does not erode the sidecar.
try wall_layout.saveLayoutTo(alloc, path, &tiles, &present, &shared);
const bytes = wall_layout.loadLayout(alloc, path) orelse return error.NothingSaved;
defer alloc.free(bytes);
try std.testing.expect(std.mem.indexOf(u8, bytes, "--sock /tmp/h0.sock#a") != null);
try std.testing.expect(std.mem.indexOf(u8, bytes, "--sock /tmp/h0.sock#b") != null);
}
test "seedLayout: every leaf of a good file is a pane, in the file's tree, and nothing else is consulted" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
defer shared.tree.deinit();
var table = [_]Host{
fixture.testHost(&shared, "--sock /a", "/a"),
fixture.testHost(&shared, "box", "/b"),
};
// No poll answer on either host: the file alone decides.
const file =
\\mux-layout 1
\\beside 0
\\ leaf 1 --sock /a#0
\\ leaf 1 box#work
\\ leaf 1 --sock /a#2
\\focus 1
\\
;
var res = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, null);
defer if (res == .plan) res.plan.deinit(std.testing.allocator);
try std.testing.expect(res == .plan);
try std.testing.expectEqual(@as(usize, 3), res.plan.panes.len);
try std.testing.expectEqualStrings("0", res.plan.panes[0].?.session);
try std.testing.expectEqual(@as(usize, 0), res.plan.panes[0].?.host);
try std.testing.expectEqualStrings("work", res.plan.panes[1].?.session);
try std.testing.expectEqual(@as(usize, 1), res.plan.panes[1].?.host);
try std.testing.expectEqual(@as(?usize, 1), res.plan.focus);
try std.testing.expectEqual(@as(usize, 0), res.plan.dropped);
try std.testing.expectEqual(@as(usize, 3), shared.tree.count());
}
test "seedLayout: a leaf whose host is not in the hosts file refuses the whole file and names the line" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
defer shared.tree.deinit();
var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
const file =
\\mux-layout 1
\\beside 0
\\ leaf 1 --sock /a#0
\\ leaf 1 nowhere#0
\\
;
const res = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, null);
try std.testing.expect(res == .refused);
try std.testing.expectEqualStrings("nowhere#0", res.refused);
// Nothing was seated: the caller starts as if the file were missing.
try std.testing.expect(shared.tree.root == null);
}
test "seedLayout: a leaf with no session, a bad name, or a repeat refuses; garbage refuses with its first line" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
defer shared.tree.deinit();
var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
const no_session = "mux-layout 1\nleaf 0 --sock /a\n";
const bad_name = "mux-layout 1\nleaf 0 --sock /a#no space\n";
const repeat = "mux-layout 1\nbeside 0\n leaf 1 --sock /a#0\n leaf 1 --sock /a#0\n";
const garbage = "not a layout\n";
for ([_][]const u8{ no_session, bad_name, repeat, garbage }) |file| {
const res = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, null);
try std.testing.expect(res == .refused);
try std.testing.expect(res.refused.len > 0);
}
const on_repeat = wall_layout.seedLayout(std.testing.allocator, &table, &shared, repeat, null);
try std.testing.expect(on_repeat == .refused);
try std.testing.expectEqualStrings("--sock /a#0", on_repeat.refused);
const on_garbage = wall_layout.seedLayout(std.testing.allocator, &table, &shared, garbage, null);
try std.testing.expect(on_garbage == .refused);
try std.testing.expectEqualStrings("not a layout", on_garbage.refused);
}
test "seedLayout: a --via leaf refuses the whole file, which is why a --via wall records nothing" {
const alloc = std.testing.allocator;
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
defer shared.tree.deinit();
// The leaf a `--via` entry would be saved as. `tileLabel` is the one
// speller of a leaf, so the file below is byte for byte the file the
// start-up `persist` used to produce for `mux --via CMD`.
const label = try wv.tileLabel(alloc, .{ .via = "ssh box mux d proxy" }, "0");
defer alloc.free(label);
try std.testing.expectEqualStrings("--via ssh box mux d proxy#0", label);
// `hosts.zig` refuses to write a `--via` line, so no hosts table can
// ever have a row for one: this leaf names a host that cannot be there.
var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
var file: std.ArrayListUnmanaged(u8) = .{};
defer file.deinit(alloc);
try file.appendSlice(alloc, "mux-layout 1\nbeside 0\n leaf 1 --sock /a#0\n leaf 1 ");
try file.appendSlice(alloc, label);
try file.append(alloc, '\n');
const res = wall_layout.seedLayout(alloc, &table, &shared, file.items, null);
try std.testing.expect(res == .refused);
try std.testing.expectEqualStrings(label, res.refused);
// The refusal is the WHOLE file: the pane on `--sock /a`, and every
// other pane the user authored, goes with it. That is the cost `run`
// avoids by nulling `Shared.layout_path` for a `--via` entry — it
// neither reads the file nor writes one.
try std.testing.expect(shared.tree.root == null);
}
test "seedLayout: the entry spelling takes leaf 0 when the file has it, and is inserted beside the focus when it does not" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
defer shared.tree.deinit();
var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
const file = "mux-layout 1\nbeside 0\n leaf 1 --sock /a#0\n leaf 1 --sock /a#1\nfocus 1\n";
var has = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, "--sock /a#1");
defer if (has == .plan) has.plan.deinit(std.testing.allocator);
try std.testing.expect(has == .plan);
// The entry is pane 0 by contract; the other leaf follows.
try std.testing.expect(has.plan.panes[0] == null); // the entry tile is the caller's
try std.testing.expectEqualStrings("0", has.plan.panes[1].?.session);
try std.testing.expectEqual(@as(usize, 2), shared.tree.count());
shared.tree.deinit();
shared.tree = layout.Tree.init(std.testing.allocator);
var not = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, "--sock /a#9");
defer if (not == .plan) not.plan.deinit(std.testing.allocator);
try std.testing.expect(not == .plan);
try std.testing.expectEqual(@as(usize, 3), shared.tree.count());
}
test "seedLayout: a file that spells the entry tile twice is a repeat like any other, and refuses" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
defer shared.tree.deinit();
var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
// Only the FIRST leaf can be the tile the user is already typing in;
// seating the second would put two panes on one session.
const file = "mux-layout 1\nbeside 0\n leaf 1 --sock /a#1\n leaf 1 --sock /a#1\n";
const res = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, "--sock /a#1");
try std.testing.expect(res == .refused);
try std.testing.expectEqualStrings("--sock /a#1", res.refused);
try std.testing.expect(shared.tree.root == null);
}
test "seedLayout: a file good down to its last line is refused BY that line, not by its header" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
defer shared.tree.deinit();
var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
// `focus 9` names a leaf this file does not have, and the parse only
// finds it after the walk. A reader told "mux-layout 1" would go and
// fix the one line that is correct.
const file = "mux-layout 1\nleaf 0 --sock /a#0\nfocus 9\n";
const res = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, null);
try std.testing.expect(res == .refused);
try std.testing.expectEqualStrings("focus 9", res.refused);
}
test "seedLayout: a terminal too small for even one saved pane leaves the wall's own tree alone" {
const alloc = std.testing.allocator;
var shared: Shared = undefined;
seedShared(alloc, &shared);
// A tty, as every restoring wall is, so the label row counts toward
// each pane's floor: two rows cannot hold ONE pane, let alone two.
shared.is_tty = true;
shared.size = .{ .cols = 100, .rows = 2 };
defer shared.tree.deinit();
// Something for the seed to lose. A trim that ran out of panes and
// installed the empty remainder would leave a count of 0 here - which
// an untouched fresh wall also has, so the pin needs a tree first.
try shared.tree.addFirst(7);
var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
const file = "mux-layout 1\nstacked 0\n leaf 1 --sock /a#0\n leaf 1 --sock /a#1\n";
const res = wall_layout.seedLayout(alloc, &table, &shared, file, null);
try std.testing.expect(res == .none);
try std.testing.expectEqual(@as(usize, 1), shared.tree.count());
}
test "seedLayout: a terminal that holds one pane keeps the entry tile when the file's other leaves will not fit" {
const alloc = std.testing.allocator;
var shared: Shared = undefined;
seedShared(alloc, &shared);
// Three rows under a label bar: one pane clears the daemon's floor,
// two do not, so the trim runs down to the entry tile's own leaf.
shared.is_tty = true;
shared.size = .{ .cols = 100, .rows = 3 };
defer shared.tree.deinit();
var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
const file = "mux-layout 1\nstacked 0\n leaf 1 --sock /a#0\n leaf 1 --sock /a#1\n";
var res = wall_layout.seedLayout(alloc, &table, &shared, file, "--sock /a#0");
defer if (res == .plan) res.plan.deinit(alloc);
try std.testing.expect(res == .plan);
try std.testing.expectEqual(@as(usize, 1), res.plan.panes.len);
// The one pane is the entry tile's slot, which the caller fills.
try std.testing.expect(res.plan.panes[0] == null);
try std.testing.expectEqual(@as(usize, 1), res.plan.dropped);
try std.testing.expectEqual(@as(usize, 1), shared.tree.count());
}
test "persist: a birth and a vanish each write the layout, and no layout_path writes nothing" {
// An arena: a birth dupes the tile's own session and label, and the wall
// frees those only when a later birth takes the digit back.
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var tmp = try TmpDir.make();
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const path = try std.fmt.bufPrint(&path_buf, "{s}/layout", .{tmp.path()});
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
shared.size = .{ .cols = 120, .rows = 40 };
var tiles: [wv.max_tiles]Tile = undefined;
var present = [_]bool{false} ** wv.max_tiles;
var live: usize = 0;
defer fixture.endPumps(tiles[0..live]);
var hosts_table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
const w = fixture.wallOf(alloc, &tiles, &present, &live, &shared, &hosts_table);
// Not yet a wall that persists: nothing is written.
wall_layout.persist(w);
try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(path, .{}));
shared.layout_path = path;
const at = wv.birthTile(w, .{
.r = .{ .target = hosts_table[0].spec.target, .label = "", .session = "0" },
.from = 0,
.place = .beside_focus,
.creates = false,
.born_from = null,
.host = 0,
.borrowed = true,
}).?;
// No pump was spawned, so nothing will ever clear the liveness a born
// tile carries and `endPumps` would wait on it forever.
tiles[at].alive.store(false, .release);
wall_layout.persist(w);
const first = try std.fs.cwd().readFileAlloc(alloc, path, 4096);
try std.testing.expect(std.mem.indexOf(u8, first, "leaf 0 --sock /a#0") != null);
wv.vanishTile(w.liveTiles(), w.livePresent(), &shared, at, null);
wall_layout.persist(w);
const second = try std.fs.cwd().readFileAlloc(alloc, path, 4096);
try std.testing.expect(std.mem.indexOf(u8, second, "#0") == null);
}
test "persist: a save that fails says so on the notice line, where the wall says everything else" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var tmp = try TmpDir.make();
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
// A path whose parent is a FILE. `hosts.saveBytes` makes missing
// directories, so a merely absent one would be created and the save
// would succeed; this one fails at the write, past every check
// `persist` itself makes.
const blocker = try std.fmt.bufPrint(&path_buf, "{s}/blocker", .{tmp.path()});
(try std.fs.cwd().createFile(blocker, .{})).close();
var path_buf2: [std.fs.max_path_bytes]u8 = undefined;
const path = try std.fmt.bufPrint(&path_buf2, "{s}/blocker/layout", .{tmp.path()});
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
shared.size = .{ .cols = 120, .rows = 40 };
var tiles: [wv.max_tiles]Tile = undefined;
var present = [_]bool{false} ** wv.max_tiles;
var live: usize = 0;
defer fixture.endPumps(tiles[0..live]);
var hosts_table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
const w = fixture.wallOf(alloc, &tiles, &present, &live, &shared, &hosts_table);
shared.layout_path = path;
const at = wv.birthTile(w, .{
.r = .{ .target = hosts_table[0].spec.target, .label = "", .session = "0" },
.from = 0,
.place = .beside_focus,
.creates = false,
.born_from = null,
.host = 0,
.borrowed = true,
}).?;
tiles[at].alive.store(false, .release);
wall_layout.persist(w);
// Not on stderr and not `std.debug.print`: by the time any save runs the
// wall owns the alternate screen, so the only place a sentence can go is
// the slot every other refusal goes into.
var buf: [96]u8 = undefined;
const said = wv.takeNotice(&shared, &buf);
if (!std.mem.startsWith(u8, said, "[layout not saved: ")) {
std.debug.print("notice was: {s}\n", .{said});
return error.FailedSaveSaidNothing;
}
}
test "seed: a plan that leaves leaves behind counts them apart - the shell's own stripe and the panes that would not fit" {
const alloc = std.testing.allocator;
var shared: Shared = undefined;
seedShared(alloc, &shared);
defer shared.tree.deinit();
// A tty, as every restoring wall is, so a label row counts toward each
// pane's floor: seven rows hold two panes of three rows and not three.
shared.is_tty = true;
shared.size = .{ .cols = 100, .rows = 7 };
var table = [_]Host{
fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
};
// Four panes across TWO hosts, one of them the session this shell is
// standing in: the two reasons a leaf goes unseated happen at once, and
// a fixture with only one of them cannot see them counted apart.
table[0].self_name = "s";
const file = "mux-layout 1\nstacked 0\n" ++
" leaf 1 --sock /tmp/h0.sock#s\n leaf 1 --sock /tmp/h0.sock#a\n" ++
" leaf 1 --sock /tmp/h1.sock#b\n leaf 1 --sock /tmp/h1.sock#c\n";
var res = wall_layout.seedLayout(alloc, &table, &shared, file, null);
defer if (res == .plan) res.plan.deinit(alloc);
try std.testing.expect(res == .plan);
try std.testing.expectEqual(@as(usize, 2), res.plan.panes.len);
// What `run` reads: nonzero `dropped` is a wall this terminal cannot
// show whole, so the run stops saving rather than write the file back
// without the leaves it left out. `dropped_self` is the half that no
// terminal size would have helped, and it gets its own sentence.
try std.testing.expectEqual(@as(usize, 2), res.plan.dropped);
try std.testing.expectEqual(@as(usize, 1), res.plan.dropped_self);
}