src/client/layout.zig
Ref: Size: 61.9 KiB History
//! The wall's container tree: where pane rects come from. An i3-style nestable
//! split tree — `.beside` children share columns left→right, `.stacked` share
//! rows top→bottom — flattened to the `Rect` list every tile claims. Who claims
//! them is wallview's business; the tree answers geometry.
//!
//! Leftover cells go to the EARLIEST children, so a single-container tree is
//! the old stripe cut exactly. Rails (one column per `.beside` gap) are
//! reported alongside the rects for relayout to paint.
//!
//! Floors are a parameter, not a constant. A leaf whose rect would fall under
//! one is `error.TooSmall`, and the caller refuses the operation rather than
//! shrinking a pane below the daemon's floor.
const std = @import("std");
pub const Orient = enum { beside, stacked };
pub const Dir = enum { left, down, up, right };
pub const Rect = struct { top: u16, left: u16, rows: u16, cols: u16 };
pub const Rail = struct { col: u16, top: u16, rows: u16 };
pub const Placed = struct { tile: u8, rect: Rect };
pub const Flat = struct {
placed: []Placed,
rails: []Rail,
pub fn deinit(self: *const Flat, alloc: std.mem.Allocator) void {
alloc.free(self.placed);
alloc.free(self.rails);
}
pub fn rectOf(self: Flat, tile: u8) ?Rect {
for (self.placed) |p| {
if (p.tile == tile) return p.rect;
}
return null;
}
};
pub const Floors = struct { rows: u16, cols: u16 };
const Node = union(enum) {
leaf: u8,
container: *Container,
};
const Container = struct {
orient: Orient,
children: std.ArrayListUnmanaged(*Node),
weights: std.ArrayListUnmanaged(u32),
};
/// Geometric adjacency over a FLAT result, not the tree. From the midpoint of
/// the focused rect's `dir` edge, candidates are panes whose opposite edge abuts
/// it (gap <= 1, since a rail sits between beside panes) and whose perpendicular
/// span contains the midpoint or ends within one cell of it. The tolerance on
/// the span is not optional: a pane stacked over two side-by-side panes has
/// its midpoint column exactly on the rail between them whenever the widths
/// divide evenly (81 cols → mid 40 → rail 40), and a rail is in no pane's
/// span, so an exact-containment test found nothing and `j` did nothing.
/// Nearest edge wins; among equal edges the span nearest the midpoint, then
/// the first placed. No focus history.
pub fn neighbor(flat: Flat, focus: u8, dir: Dir) ?u8 {
const fr = flat.rectOf(focus) orelse return null;
// The edge midpoint the candidate must reach across.
const mid_row = fr.top + (fr.rows -| 1) / 2;
const mid_col = fr.left + (fr.cols -| 1) / 2;
var best: ?u8 = null;
var best_gap: u16 = std.math.maxInt(u16);
var best_perp: u16 = std.math.maxInt(u16);
for (flat.placed) |p| {
if (p.tile == focus) continue;
const r = p.rect;
// How far the midpoint sits outside the candidate's perpendicular
// span: 0 inside it, 1 when only a rail separates them.
const perp: u16 = switch (dir) {
.left, .right => spanDistance(mid_row, r.top, r.rows),
.up, .down => spanDistance(mid_col, r.left, r.cols),
};
if (perp > 1) continue;
// Compute the gap between the focus's `dir` edge and the candidate's
// opposite edge. A candidate on the wrong side (its opposite edge is
// past the focus, not before it) is rejected by saturating to max.
const gap: u16 = switch (dir) {
.left => blk: {
const cand_right = r.left +% r.cols;
if (cand_right > fr.left) break :blk std.math.maxInt(u16);
break :blk fr.left - cand_right;
},
.right => blk: {
const focus_right = fr.left +% fr.cols;
if (r.left < focus_right) break :blk std.math.maxInt(u16);
break :blk r.left - focus_right;
},
.up => blk: {
const cand_bottom = r.top +% r.rows;
if (cand_bottom > fr.top) break :blk std.math.maxInt(u16);
break :blk fr.top - cand_bottom;
},
.down => blk: {
const focus_bottom = fr.top +% fr.rows;
if (r.top < focus_bottom) break :blk std.math.maxInt(u16);
break :blk r.top - focus_bottom;
},
};
if (gap > 1) continue;
if (gap < best_gap or (gap == best_gap and perp < best_perp)) {
best_gap = gap;
best_perp = perp;
best = p.tile;
}
}
return best;
}
/// Cells between `point` and the half-open span `[start, start + len)`; 0
/// when the point is inside it.
fn spanDistance(point: u16, start: u16, len: u16) u16 {
if (point < start) return start - point;
const end = start +% len; // exclusive
if (point >= end) return point - (end -| 1);
return 0;
}
/// The most panes a layout file may carry, and the number `wallview.max_tiles`
/// IS: the terminal wall and the browser hub read one file, so a file one
/// front writes has to be a file the other can seat, and a wall that silently
/// seated part of one is a wall the user cannot see is short. It lives here
/// rather than on the wall because the FILE is what both fronts share. The
/// parse's own ceiling is 255, a leaf id being a u8; this is the product's
/// bound, not the encoding's, and the wall's hard array is why it is small.
pub const max_leaves: usize = 32;
pub const Tree = struct {
alloc: std.mem.Allocator,
root: ?*Node = null,
pub fn init(alloc: std.mem.Allocator) Tree {
return .{ .alloc = alloc };
}
pub fn deinit(self: *Tree) void {
if (self.root) |r| self.freeNode(r);
self.root = null;
}
fn freeNode(self: *Tree, node: *Node) void {
switch (node.*) {
.leaf => {},
.container => |c| {
for (c.children.items) |child| self.freeNode(child);
c.children.deinit(self.alloc);
c.weights.deinit(self.alloc);
self.alloc.destroy(c);
},
}
self.alloc.destroy(node);
}
pub fn count(self: *const Tree) usize {
if (self.root) |r| return countLeaves(r);
return 0;
}
fn countLeaves(node: *const Node) usize {
return switch (node.*) {
.leaf => 1,
.container => |c| blk: {
var n: usize = 0;
for (c.children.items) |child| n += countLeaves(child);
break :blk n;
},
};
}
pub fn addFirst(self: *Tree, tile: u8) !void {
std.debug.assert(self.root == null);
const node = try self.alloc.create(Node);
node.* = .{ .leaf = tile };
self.root = node;
}
const Found = struct { node: *Node, parent: ?*Container, index: usize };
fn findLeaf(self: *const Tree, tile: u8) ?Found {
if (self.root) |r| return findLeafIn(r, null, 0, tile);
return null;
}
fn findLeafIn(node: *Node, parent: ?*Container, index: usize, tile: u8) ?Found {
switch (node.*) {
.leaf => |t| {
if (t == tile) return .{ .node = node, .parent = parent, .index = index };
return null;
},
.container => |c| {
for (c.children.items, 0..) |child, i| {
if (findLeafIn(child, c, i, tile)) |f| return f;
}
return null;
},
}
}
/// Insert `tile` as the next sibling of the focused leaf in its parent
/// container. If the focused leaf IS the root, the root becomes a
/// `.stacked` container holding `[old, new]` — matching today's stripes.
pub fn insert(self: *Tree, focus: u8, tile: u8) !void {
const found = self.findLeaf(focus) orelse return error.NotFound;
// A leaf root is a stacked split of itself, which is what `split`
// already does when the focused leaf has no parent.
const p = found.parent orelse return self.split(focus, tile, .stacked);
const new_node = try self.alloc.create(Node);
new_node.* = .{ .leaf = tile };
const w = p.weights.items[found.index];
try p.children.insert(self.alloc, found.index + 1, new_node);
try p.weights.insert(self.alloc, found.index + 1, w);
}
/// Replace the focused leaf with a two-child container of the forced
/// orientation holding `[old, new]`, weights `{1,1}`. The old leaf node
/// stays a leaf; a new node holds the container and takes its slot.
fn split(self: *Tree, focus: u8, tile: u8, orient: Orient) !void {
const found = self.findLeaf(focus) orelse return error.NotFound;
const c = try self.alloc.create(Container);
c.* = .{ .orient = orient, .children = .empty, .weights = .empty };
try c.children.append(self.alloc, found.node);
const new_node = try self.alloc.create(Node);
new_node.* = .{ .leaf = tile };
try c.children.append(self.alloc, new_node);
try c.weights.append(self.alloc, 1);
try c.weights.append(self.alloc, 1);
const c_node = try self.alloc.create(Node);
c_node.* = .{ .container = c };
if (found.parent) |p| {
p.children.items[found.index] = c_node;
} else {
self.root = c_node;
}
}
pub fn splitRight(self: *Tree, focus: u8, tile: u8) !void {
try self.split(focus, tile, .beside);
}
pub fn splitBelow(self: *Tree, focus: u8, tile: u8) !void {
try self.split(focus, tile, .stacked);
}
/// Set the root container's orientation. `insert` wraps a bare root in
/// `.stacked` by default (matching the stripe era); hydration picks
/// `.beside` when the terminal is wide enough, and calls this to flip
/// the root the first `insert` created.
pub fn setRootOrient(self: *Tree, o: Orient) void {
if (self.root) |r| switch (r.*) {
.container => r.container.orient = o,
.leaf => {},
};
}
/// Sidecar text (spec: layout-persistence design). Leaf ids index
/// `spellings`. Every node line carries its weight in the parent's
/// axis; the root writes 0 because it has no parent.
pub fn serialize(self: *const Tree, spellings: []const []const u8, focus: ?u8, writer: anytype) !void {
try writer.writeAll("mux-layout 1\n");
if (self.root) |r| try serializeNode(r, 0, 0, spellings, writer);
// The focus record comes after every node line, so the parse walk has a
// complete tree to range-check K against. K is the focused leaf's
// position in the depth-first walk — `collectLeafIds`' order.
if (focus) |fid| {
var ids: std.ArrayListUnmanaged(u8) = .{};
defer ids.deinit(self.alloc);
try collectLeafIds(self.alloc, self.root, &ids);
for (ids.items, 0..) |id, k| {
if (id == fid) {
try writer.print("focus {d}\n", .{k});
break;
}
}
}
}
/// Delete the leaf; a container left with one child dissolves — the child
/// takes its place in the grandparent (or becomes root).
pub fn remove(self: *Tree, tile: u8) void {
const found = self.findLeaf(tile) orelse return;
if (found.parent) |p| {
self.alloc.destroy(found.node);
_ = p.children.orderedRemove(found.index);
_ = p.weights.orderedRemove(found.index);
if (p.children.items.len == 1) {
const sole = p.children.items[0];
self.replaceContainer(p, sole);
}
} else {
self.alloc.destroy(found.node);
self.root = null;
}
}
/// Rewrite leaf ids through `map`: a null entry removes the leaf and lets
/// containers collapse. Removals ALL happen before any rewrite, so old ids
/// stay addressable through the removal pass; the rewrite is one pass, so a
/// new id cannot collide with a not-yet-rewritten old one.
///
/// `error.OutOfMemory` leaves the tree UNTOUCHED: the only allocation is
/// the id list, and it is taken before the first mutation, so a caller that
/// gives up on the error is giving up on a tree it never modified.
pub fn remapLeaves(self: *Tree, map: []const ?u8) std.mem.Allocator.Error!void {
// Collect ids first because removal mutates the tree and may
// collapse containers, invalidating node pointers.
var ids: std.ArrayListUnmanaged(u8) = .{};
defer ids.deinit(self.alloc);
try collectLeafIds(self.alloc, self.root, &ids);
// Pass 1: removals. All null-mapped leaves are removed before any
// id rewrite, so the old ids remain addressable throughout.
for (ids.items) |id| {
if (id < map.len and map[id] == null) self.remove(id);
}
// One walk rewriting every remaining leaf, so a new id cannot collide
// with a not-yet-rewritten old one: in the swap {2, null, 0}, 0→2 and
// 2→0 happen in the same walk and neither sees the other's result.
rewriteLeafIds(self.root, map);
}
/// Replace the node holding `c` with `sole` in the grandparent, or make
/// `sole` the root. Then free `c` and its node (but not `sole`).
fn replaceContainer(self: *Tree, c: *Container, sole: *Node) void {
if (self.root) |r| {
if (r.* == .container and r.container == c) {
self.root = sole;
c.children.deinit(self.alloc);
c.weights.deinit(self.alloc);
self.alloc.destroy(c);
self.alloc.destroy(r);
return;
}
}
self.replaceContainerIn(self.root.?, c, sole);
}
fn replaceContainerIn(self: *Tree, node: *Node, c: *Container, sole: *Node) void {
switch (node.*) {
.leaf => {},
.container => |cont| {
for (cont.children.items) |child| {
switch (child.*) {
.container => |cc| {
if (cc == c) {
child.* = sole.*;
self.alloc.destroy(sole);
c.children.deinit(self.alloc);
c.weights.deinit(self.alloc);
self.alloc.destroy(c);
return;
}
self.replaceContainerIn(child, c, sole);
},
.leaf => {},
}
}
},
}
}
pub fn flatten(
self: *const Tree,
alloc: std.mem.Allocator,
rows: u16,
cols: u16,
floors: Floors,
fullscreen: ?u8,
) error{ TooSmall, OutOfMemory }!Flat {
var placed = std.ArrayList(Placed).empty;
var rails = std.ArrayList(Rail).empty;
errdefer {
placed.deinit(alloc);
rails.deinit(alloc);
}
if (self.root) |r| {
if (fullscreen) |fs_tile| {
try flattenFullscreen(alloc, &placed, r, rows, cols, fs_tile);
} else {
try flattenNode(alloc, &placed, &rails, r, 0, 0, rows, cols, floors);
}
}
return .{
.placed = try placed.toOwnedSlice(alloc),
.rails = try rails.toOwnedSlice(alloc),
};
}
/// Move the focus pane's `dir` boundary by `delta_cells`. Walks up to the
/// nearest container of the right axis; inside it, the child holding focus
/// and its adjacent sibling trade cells, so weights become exact cell
/// counts. False leaves the tree untouched. The per-axis floor is a fast
/// pre-filter and the FLATTEN probe is the contract, because a nested
/// container slot needs more than one leaf's floor.
pub fn resize(
self: *Tree,
alloc: std.mem.Allocator,
rows: u16,
cols: u16,
floors: Floors,
focus: u8,
dir: Dir,
delta_cells: u16,
) bool {
const want_orient: Orient = switch (dir) {
.left, .right => .beside,
.up, .down => .stacked,
};
// Find the deepest ancestor of the right orientation. The focus
// always gains delta_cells; the sibling toward `dir` loses them.
const anc = self.findAncestor(focus, want_orient) orelse return false;
const focus_idx = childIndexContaining(anc, focus) orelse return false;
const neighbor_idx = switch (dir) {
.right, .down => if (focus_idx + 1 < anc.children.items.len) focus_idx + 1 else null,
.left, .up => if (focus_idx > 0) focus_idx - 1 else null,
};
if (neighbor_idx == null) return false;
const ni = neighbor_idx.?;
// Flatten to read current cell spans along the container's axis.
var flat = self.flatten(alloc, rows, cols, floors, null) catch return false;
defer flat.deinit(alloc);
// Each child's span: for a leaf child, its own rect; for a container
// child, any leaf inside it (siblings share the axis span).
const fc = childSpan(anc.children.items[focus_idx], flat, want_orient) orelse return false;
const nc = childSpan(anc.children.items[ni], flat, want_orient) orelse return false;
const new_fc = fc + delta_cells;
const new_nc = nc -| delta_cells;
// Per-axis floor: a fast pre-filter. A nested container slot needs
// more than one leaf's floor (rails, siblings), so the probe below
// is the real contract — this just avoids the flatten when the
// single-leaf case is already under.
const floor = if (want_orient == .beside) floors.cols else floors.rows;
if (new_nc < floor) return false;
// Snapshot weights so the probe can restore them on refusal.
const old_weights = alloc.dupe(u32, anc.weights.items) catch return false;
defer alloc.free(old_weights);
// Rewrite weights to cell counts — exact and stable on re-flatten.
// Other children keep their current spans as weights.
for (anc.children.items, 0..) |child, i| {
if (i == focus_idx) {
anc.weights.items[i] = new_fc;
} else if (i == ni) {
anc.weights.items[i] = new_nc;
} else {
anc.weights.items[i] = childSpan(child, flat, want_orient) orelse {
// The snapshot exists for the probe path; this bail is
// inside the rewrite, so restore before returning.
@memcpy(anc.weights.items, old_weights);
return false;
};
}
}
// The floor check cannot see inside a nested container: a slot
// holding beside[1,2] needs 2*floor+1, not floor. Probe the
// re-flatten; if it fails, the weights are wrong and the tree
// must stand as it was.
var probe = self.flatten(alloc, rows, cols, floors, null) catch {
@memcpy(anc.weights.items, old_weights);
return false;
};
probe.deinit(alloc);
return true;
}
/// Walk from the focus leaf upward, returning the deepest container of
/// `want_orient` on the path from root to focus.
fn findAncestor(self: *const Tree, focus: u8, want_orient: Orient) ?*Container {
if (self.root) |r| {
var result: ?*Container = null;
findAncestorIn(r, focus, want_orient, &result);
return result;
}
return null;
}
fn findAncestorIn(node: *Node, focus: u8, want_orient: Orient, result: *?*Container) void {
switch (node.*) {
.leaf => |t| {
if (t != focus) return;
},
.container => |c| {
for (c.children.items) |child| {
if (containsLeaf(child, focus)) {
if (c.orient == want_orient) result.* = c;
findAncestorIn(child, focus, want_orient, result);
return;
}
}
},
}
}
};
/// Result of a successful `parse`: a tree whose leaf ids index `spellings`.
/// Each spelling is an alloc-duped copy the caller owns via `deinit`.
pub const ParsedLayout = struct {
tree: Tree,
spellings: std.ArrayListUnmanaged([]u8) = .{},
/// Encounter index of the focused leaf, or null if the sidecar had
/// no focus line. Indices `spellings`, so a valid K is < len.
focus: ?u8 = null,
pub fn deinit(self: *ParsedLayout, alloc: std.mem.Allocator) void {
self.tree.deinit();
for (self.spellings.items) |s| alloc.free(s);
self.spellings.deinit(alloc);
}
};
/// Null on any malformation: the sidecar is derived convenience, so a bad one
/// degrades to the default cut rather than refusing startup — the deliberate
/// opposite of the hosts file's strictness about lines the user authored.
pub fn parse(alloc: std.mem.Allocator, bytes: []const u8) ?ParsedLayout {
var ignored: []const u8 = "";
return parseReporting(alloc, bytes, &ignored);
}
/// The first line with anything but blanks on it, or empty for a file with
/// nothing on any line. What to print when the file as a whole is not a
/// layout: a leading blank line names nothing a reader could go and fix.
pub fn firstNonBlankLine(bytes: []const u8) []const u8 {
var it = std.mem.splitScalar(u8, bytes, '\n');
while (it.next()) |line| {
if (std.mem.trim(u8, line, " \t\r").len != 0) return line;
}
return bytes[0..0];
}
/// `parse`, and the line it gave up on. `failed` is set to a slice of
/// `bytes` — the raw line, indentation and all — so a caller that refuses a
/// file can say WHICH line to fix. Malformation is found at four depths
/// (the header, the node grammar, a leaf's spelling, and the `focus` index
/// checked after the walk), and a caller naming line 1 for all four sends
/// the reader to the wrong place.
pub fn parseReporting(alloc: std.mem.Allocator, bytes: []const u8, failed: *[]const u8) ?ParsedLayout {
var line_iter = std.mem.splitScalar(u8, bytes, '\n');
// Until a body line is read, the whole file is what failed.
failed.* = firstNonBlankLine(bytes);
const header = line_iter.next() orelse return null;
if (!std.mem.eql(u8, header, "mux-layout 1")) return null;
var result: ParsedLayout = .{ .tree = Tree.init(alloc) };
// Stack of open containers (the path from root to the current insertion
// point). Each entry is the container node that children are being added to.
var stack: std.ArrayListUnmanaged(*Container) = .{};
defer stack.deinit(alloc);
// Track the depth of the stack so we can validate the "deepen by exactly 1"
// rule: a container line at depth D opens children at depth D+1.
var prev_depth: ?usize = null;
var saw_root = false;
var saw_focus = false;
while (line_iter.next()) |raw_line| {
if (raw_line.len == 0) continue;
// Every refusal from here down is this line's, including the ones
// raised after the walk: `focus` is the last line a file carries.
failed.* = raw_line;
// After a focus line the tree is complete: any further non-empty
// line — node or otherwise — is malformed.
if (saw_focus) return cleanup(&result, alloc, &stack);
const depth = countLeadingSpaces(raw_line);
const trimmed = raw_line[depth..];
if (trimmed.len == 0) return cleanup(&result, alloc, &stack);
// Depth may only be 0 (root) or exactly prev_depth+1 (child of the
// just-opened container).
if (prev_depth) |pd| {
if (depth > pd + 1) return cleanup(&result, alloc, &stack);
} else {
if (depth != 0) return cleanup(&result, alloc, &stack);
}
// Pop the stack to the current depth. A container being popped must
// have at least one child — an empty container is malformed.
while (stack.items.len > depth) {
const popped = stack.pop() orelse break;
if (popped.children.items.len == 0) return cleanup(&result, alloc, &stack);
}
// A line at depth D needs a container at stack[D-1] to be its parent.
// A leaf at depth D leaves stack height D (it doesn't push), so a
// following line at depth D+1 would pass the `> prev+1` check but
// has no container to adopt it — reject it.
if (stack.items.len < depth) return cleanup(&result, alloc, &stack);
// Parse the line: "beside CELLS", "stacked CELLS", or "leaf CELLS SPELLING".
const space = std.mem.indexOfScalar(u8, trimmed, ' ') orelse return cleanup(&result, alloc, &stack);
const kind = trimmed[0..space];
const rest = trimmed[space + 1 ..];
if (std.mem.eql(u8, kind, "beside") or std.mem.eql(u8, kind, "stacked")) {
const cells = std.fmt.parseInt(u32, rest, 10) catch return cleanup(&result, alloc, &stack);
if (cells == 0 and stack.items.len != 0) return cleanup(&result, alloc, &stack);
const orient: Orient = if (std.mem.eql(u8, kind, "beside")) .beside else .stacked;
const c = alloc.create(Container) catch return cleanup(&result, alloc, &stack);
c.* = .{ .orient = orient, .children = .empty, .weights = .empty };
const c_node = alloc.create(Node) catch {
alloc.destroy(c);
return cleanup(&result, alloc, &stack);
};
c_node.* = .{ .container = c };
if (stack.items.len == 0) {
// Root cells are read and discarded; the root has no parent.
if (saw_root) {
alloc.destroy(c_node);
alloc.destroy(c);
return cleanup(&result, alloc, &stack);
}
result.tree.root = c_node;
saw_root = true;
} else {
const parent = stack.items[stack.items.len - 1];
parent.children.append(alloc, c_node) catch {
alloc.destroy(c_node);
alloc.destroy(c);
return cleanup(&result, alloc, &stack);
};
parent.weights.append(alloc, cells) catch return cleanup(&result, alloc, &stack);
}
stack.append(alloc, c) catch return cleanup(&result, alloc, &stack);
prev_depth = depth;
} else if (std.mem.eql(u8, kind, "leaf")) {
// "leaf CELLS SPELLING" — CELLS is a u32, SPELLING is the rest.
const sp = std.mem.indexOfScalar(u8, rest, ' ') orelse return cleanup(&result, alloc, &stack);
const cells_str = rest[0..sp];
const spelling = rest[sp + 1 ..];
if (spelling.len == 0) return cleanup(&result, alloc, &stack);
const cells = std.fmt.parseInt(u32, cells_str, 10) catch return cleanup(&result, alloc, &stack);
// A zero weight would make its parent's total zero and divide
// `flatten` by it; the writer never emits one below the root.
if (cells == 0 and stack.items.len != 0) return cleanup(&result, alloc, &stack);
if (result.spellings.items.len >= 255) return cleanup(&result, alloc, &stack);
const id: u8 = @intCast(result.spellings.items.len);
const dup = alloc.dupe(u8, spelling) catch return cleanup(&result, alloc, &stack);
result.spellings.append(alloc, dup) catch {
alloc.free(dup);
return cleanup(&result, alloc, &stack);
};
const leaf_node = alloc.create(Node) catch return cleanup(&result, alloc, &stack);
leaf_node.* = .{ .leaf = id };
if (stack.items.len == 0) {
if (saw_root) {
alloc.destroy(leaf_node);
return cleanup(&result, alloc, &stack);
}
result.tree.root = leaf_node;
saw_root = true;
} else {
const parent = stack.items[stack.items.len - 1];
parent.children.append(alloc, leaf_node) catch {
alloc.destroy(leaf_node);
return cleanup(&result, alloc, &stack);
};
parent.weights.append(alloc, cells) catch return cleanup(&result, alloc, &stack);
}
prev_depth = depth;
} else if (std.mem.eql(u8, kind, "focus")) {
// `focus K` names the focused leaf by encounter index. It may
// only appear at depth 0 after a root, once, and K must be a
// valid leaf index — the parse walk is done by this point so
// spellings.len is the leaf count.
if (depth != 0) return cleanup(&result, alloc, &stack);
if (!saw_root) return cleanup(&result, alloc, &stack);
if (saw_focus) return cleanup(&result, alloc, &stack);
const k = std.fmt.parseInt(u8, rest, 10) catch return cleanup(&result, alloc, &stack);
saw_focus = true;
result.focus = k;
} else {
return cleanup(&result, alloc, &stack);
}
}
if (!saw_root) return cleanup(&result, alloc, &stack);
// Any container still on the stack was never closed by a shallower line;
// it must have at least one child.
for (stack.items) |c| {
if (c.children.items.len == 0) return cleanup(&result, alloc, &stack);
}
// A focus index past the last leaf is malformed — degrade to default.
if (result.focus) |k| {
if (k >= result.spellings.items.len) return cleanup(&result, alloc, &stack);
}
return result;
}
fn cleanup(result: *ParsedLayout, alloc: std.mem.Allocator, stack: *std.ArrayListUnmanaged(*Container)) ?ParsedLayout {
_ = stack;
result.deinit(alloc);
return null;
}
fn countLeadingSpaces(line: []const u8) usize {
var i: usize = 0;
while (i < line.len and line[i] == ' ') : (i += 1) {}
return i;
}
/// Depth-first leaf ids, in encounter order. The allocation failure is
/// PROPAGATED, never swallowed: a short list is not a smaller tree, it is a
/// wrong answer about this one. Dropping a leaf here made `serialize` write a
/// `focus K` naming a different pane, and made `remapLeaves` skip a removal so
/// a pane the wall no longer has stayed in the saved tree.
fn collectLeafIds(alloc: std.mem.Allocator, node: ?*const Node, ids: *std.ArrayListUnmanaged(u8)) std.mem.Allocator.Error!void {
const n = node orelse return;
switch (n.*) {
.leaf => |t| try ids.append(alloc, t),
.container => |c| {
for (c.children.items) |child| try collectLeafIds(alloc, child, ids);
},
}
}
fn rewriteLeafIds(node: ?*Node, map: []const ?u8) void {
const n = node orelse return;
switch (n.*) {
.leaf => |t| {
if (t < map.len) {
if (map[t]) |new_id| n.leaf = new_id;
}
},
.container => |c| {
for (c.children.items) |child| rewriteLeafIds(child, map);
},
}
}
fn serializeNode(node: *const Node, depth: usize, cells: u32, spellings: []const []const u8, writer: anytype) !void {
try writer.writeByteNTimes(' ', depth);
switch (node.*) {
.leaf => |id| try writer.print("leaf {d} {s}\n", .{ cells, spellings[id] }),
.container => |c| {
try writer.print("{s} {d}\n", .{ @tagName(c.orient), cells });
for (c.children.items, c.weights.items) |child, w|
try serializeNode(child, depth + 1, w, spellings, writer);
},
}
}
/// Which child of `c` contains the leaf `tile`?
fn childIndexContaining(c: *const Container, tile: u8) ?usize {
for (c.children.items, 0..) |child, i| {
if (containsLeaf(child, tile)) return i;
}
return null;
}
fn containsLeaf(node: *const Node, tile: u8) bool {
return switch (node.*) {
.leaf => |t| t == tile,
.container => |c| blk: {
for (c.children.items) |child| {
if (containsLeaf(child, tile)) break :blk true;
}
break :blk false;
},
};
}
/// A same-orient child's first leaf understates the slot — bounding box
/// over all leaves is the span the parent's axis cares about.
fn childSpan(child: *const Node, flat: Flat, orient: Orient) ?u16 {
var min_near: u16 = std.math.maxInt(u16);
var max_far: u16 = 0;
var found = false;
forEachLeaf(child, flat, orient, &min_near, &max_far, &found);
if (!found) return null;
return max_far -| min_near;
}
fn forEachLeaf(node: *const Node, flat: Flat, orient: Orient, min_near: *u16, max_far: *u16, found: *bool) void {
switch (node.*) {
.leaf => |t| {
const r = flat.rectOf(t) orelse return;
const near = switch (orient) {
.beside => r.left,
.stacked => r.top,
};
const far = switch (orient) {
.beside => r.left + r.cols,
.stacked => r.top + r.rows,
};
if (near < min_near.*) min_near.* = near;
if (far > max_far.*) max_far.* = far;
found.* = true;
},
.container => |c| {
for (c.children.items) |child| {
forEachLeaf(child, flat, orient, min_near, max_far, found);
}
},
}
}
fn flattenFullscreen(
alloc: std.mem.Allocator,
placed: *std.ArrayList(Placed),
node: *const Node,
rows: u16,
cols: u16,
fs_tile: u8,
) error{OutOfMemory}!void {
switch (node.*) {
.leaf => |t| {
if (t == fs_tile) {
try placed.append(alloc, .{ .tile = t, .rect = .{ .top = 0, .left = 0, .rows = rows, .cols = cols } });
} else {
try placed.append(alloc, .{ .tile = t, .rect = .{ .top = 0, .left = 0, .rows = 0, .cols = 0 } });
}
},
.container => |c| {
for (c.children.items) |child| {
try flattenFullscreen(alloc, placed, child, rows, cols, fs_tile);
}
},
}
}
/// One axis of a container, cut by weight: `avail` cells across `weights`,
/// the division's remainder handed out a cell at a time from the first
/// child, so the parts always add back to `avail`. Caller frees.
fn weightSplit(alloc: std.mem.Allocator, weights: []const u32, avail: u16, total: u64) ![]u16 {
const base = try alloc.alloc(u16, weights.len);
var sum: u64 = 0;
for (weights, 0..) |w, i| {
base[i] = @intCast(@as(u64, avail) * w / total);
sum += base[i];
}
var rem = avail - @as(u16, @intCast(sum));
var i: usize = 0;
while (rem > 0 and i < weights.len) : (i += 1) {
base[i] += 1;
rem -= 1;
}
return base;
}
fn flattenNode(
alloc: std.mem.Allocator,
placed: *std.ArrayList(Placed),
rails: *std.ArrayList(Rail),
node: *const Node,
top: u16,
left: u16,
rows: u16,
cols: u16,
floors: Floors,
) error{ TooSmall, OutOfMemory }!void {
switch (node.*) {
.leaf => |t| {
if (rows < floors.rows or cols < floors.cols) return error.TooSmall;
try placed.append(alloc, .{ .tile = t, .rect = .{ .top = top, .left = left, .rows = rows, .cols = cols } });
},
.container => |c| {
const n = c.children.items.len;
if (n == 0) return;
var total_weight: u64 = 0;
for (c.weights.items) |w| total_weight += w;
// The divisor below. Refusing beats trapping: a sidecar is
// derived convenience and the caller falls back to the cut.
if (total_weight == 0) return error.TooSmall;
switch (c.orient) {
.stacked => {
const base = try weightSplit(alloc, c.weights.items, rows, total_weight);
defer alloc.free(base);
var cur_top = top;
for (c.children.items, 0..) |child, ci| {
try flattenNode(alloc, placed, rails, child, cur_top, left, base[ci], cols, floors);
cur_top += base[ci];
}
},
.beside => {
// Reserve n-1 columns for rails, then split cols by weight.
const rail_count: u16 = @intCast(n - 1);
if (cols < rail_count) return error.TooSmall;
const base = try weightSplit(alloc, c.weights.items, cols - rail_count, total_weight);
defer alloc.free(base);
var cur_left = left;
for (c.children.items, 0..) |child, ci| {
try flattenNode(alloc, placed, rails, child, top, cur_left, rows, base[ci], floors);
cur_left += base[ci];
if (ci < n - 1) {
try rails.append(alloc, .{ .col = cur_left, .top = top, .rows = rows });
cur_left += 1;
}
}
},
}
},
}
}
// ======================================================================
// TESTS
// ======================================================================
test "a lone tile owns the whole terminal, no rails" {
var t = Tree.init(std.testing.allocator);
defer t.deinit();
try t.addFirst(7);
var f = try t.flatten(std.testing.allocator, 24, 80, .{ .rows = 2, .cols = 2 }, null);
defer f.deinit(std.testing.allocator);
try std.testing.expectEqual(@as(usize, 1), f.placed.len);
try std.testing.expectEqual(Rect{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, f.rectOf(7).?);
try std.testing.expectEqual(@as(usize, 0), f.rails.len);
}
test "a stacked cut reproduces layoutStripes' remainder-at-the-top rule" {
var t = Tree.init(std.testing.allocator);
defer t.deinit();
try t.addFirst(0);
try t.insert(0, 1);
try t.insert(1, 2);
var f = try t.flatten(std.testing.allocator, 25, 80, .{ .rows = 2, .cols = 2 }, null);
defer f.deinit(std.testing.allocator);
try std.testing.expectEqual(Rect{ .top = 0, .left = 0, .rows = 9, .cols = 80 }, f.rectOf(0).?);
try std.testing.expectEqual(Rect{ .top = 9, .left = 0, .rows = 8, .cols = 80 }, f.rectOf(1).?);
try std.testing.expectEqual(Rect{ .top = 17, .left = 0, .rows = 8, .cols = 80 }, f.rectOf(2).?);
}
test "a beside cut spends one column per rail" {
var t = Tree.init(std.testing.allocator);
defer t.deinit();
try t.addFirst(0);
try t.splitRight(0, 1);
var f = try t.flatten(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, null);
defer f.deinit(std.testing.allocator);
try std.testing.expectEqual(Rect{ .top = 0, .left = 0, .rows = 24, .cols = 40 }, f.rectOf(0).?);
try std.testing.expectEqual(Rect{ .top = 0, .left = 41, .rows = 24, .cols = 40 }, f.rectOf(1).?);
try std.testing.expectEqual(Rail{ .col = 40, .top = 0, .rows = 24 }, f.rails[0]);
}
test "splitBelow nests: 1 beside (2 over 3)" {
var t = Tree.init(std.testing.allocator);
defer t.deinit();
try t.addFirst(0);
try t.splitRight(0, 1);
try t.splitBelow(1, 2);
var f = try t.flatten(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, null);
defer f.deinit(std.testing.allocator);
try std.testing.expectEqual(Rect{ .top = 0, .left = 0, .rows = 24, .cols = 40 }, f.rectOf(0).?);
try std.testing.expectEqual(Rect{ .top = 0, .left = 41, .rows = 12, .cols = 40 }, f.rectOf(1).?);
try std.testing.expectEqual(Rect{ .top = 12, .left = 41, .rows = 12, .cols = 40 }, f.rectOf(2).?);
}
test "remove collapses a single-child container into its parent" {
var t = Tree.init(std.testing.allocator);
defer t.deinit();
try t.addFirst(0);
try t.splitRight(0, 1);
try t.splitBelow(1, 2);
t.remove(2);
var f = try t.flatten(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, null);
defer f.deinit(std.testing.allocator);
try std.testing.expectEqual(Rect{ .top = 0, .left = 41, .rows = 24, .cols = 40 }, f.rectOf(1).?);
t.remove(1);
try std.testing.expectEqual(@as(usize, 1), t.count());
}
test "a cut under the floors is refused, rails included" {
var t = Tree.init(std.testing.allocator);
defer t.deinit();
try t.addFirst(0);
try t.splitRight(0, 1);
try std.testing.expectError(error.TooSmall, t.flatten(std.testing.allocator, 24, 4, .{ .rows = 2, .cols = 2 }, null));
}
test "fullscreen is a rect assignment, not a tree change" {
var t = Tree.init(std.testing.allocator);
defer t.deinit();
try t.addFirst(0);
try t.splitRight(0, 1);
var f = try t.flatten(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, 1);
defer f.deinit(std.testing.allocator);
try std.testing.expectEqual(Rect{ .top = 0, .left = 0, .rows = 24, .cols = 81 }, f.rectOf(1).?);
try std.testing.expectEqual(Rect{ .top = 0, .left = 0, .rows = 0, .cols = 0 }, f.rectOf(0).?);
try std.testing.expectEqual(@as(usize, 0), f.rails.len);
var g = try t.flatten(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, null);
defer g.deinit(std.testing.allocator);
try std.testing.expectEqual(@as(u16, 40), g.rectOf(0).?.cols);
}
test "insert lands beside the focus, along the container's orientation" {
var t = Tree.init(std.testing.allocator);
defer t.deinit();
try t.addFirst(0);
try t.splitRight(0, 1);
try t.insert(0, 2);
var f = try t.flatten(std.testing.allocator, 24, 82, .{ .rows = 2, .cols = 2 }, null);
defer f.deinit(std.testing.allocator);
const r0 = f.rectOf(0).?;
const r2 = f.rectOf(2).?;
const r1 = f.rectOf(1).?;
try std.testing.expect(r0.left < r2.left and r2.left < r1.left);
}
test "neighbor crosses a rail and lands on the abutting pane" {
var t = Tree.init(std.testing.allocator);
defer t.deinit();
try t.addFirst(0);
try t.splitRight(0, 1);
try t.splitBelow(1, 2);
var f = try t.flatten(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, null);
defer f.deinit(std.testing.allocator);
// Midpoint formula is `top + (rows - 1) / 2` = row 11 here, which lands
// in the upper right pane (rows 0-11). The formula is the module's to
// state in a comment beside `neighbor` — the point is determinism.
try std.testing.expectEqual(@as(?u8, 1), neighbor(f, 0, .right));
try std.testing.expectEqual(@as(?u8, 0), neighbor(f, 1, .left));
try std.testing.expectEqual(@as(?u8, 2), neighbor(f, 1, .down));
try std.testing.expectEqual(@as(?u8, null), neighbor(f, 0, .left));
}
test "resize trades cells between beside siblings and holds the floors" {
var t = Tree.init(std.testing.allocator);
defer t.deinit();
try t.addFirst(0);
try t.splitRight(0, 1);
try std.testing.expect(t.resize(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, 0, .right, 3));
var f = try t.flatten(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, null);
defer f.deinit(std.testing.allocator);
try std.testing.expectEqual(@as(u16, 43), f.rectOf(0).?.cols);
try std.testing.expectEqual(@as(u16, 37), f.rectOf(1).?.cols);
// Shrinking the neighbour under its floor is refused, layout stands.
try std.testing.expect(!t.resize(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, 0, .right, 40));
}
test "resize walks up to the container of the right axis" {
var t = Tree.init(std.testing.allocator);
defer t.deinit();
try t.addFirst(0);
try t.splitRight(0, 1);
try t.splitBelow(1, 2);
// Tile 2 sits in a stacked pair; growing it RIGHT resizes the beside
// container above — the whole right column widens.
try std.testing.expect(!t.resize(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, 2, .right, 2));
try std.testing.expect(t.resize(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, 2, .left, 2));
var f = try t.flatten(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, null);
defer f.deinit(std.testing.allocator);
try std.testing.expectEqual(@as(u16, 42), f.rectOf(2).?.cols);
}
test "a beside cut underflowing its rail count is refused, not trapped" {
// Three beside panes need 2 rail columns; cols=1 underflows u16 if the
// guard is missing — reachable from a live winch on a narrow terminal.
var t = Tree.init(std.testing.allocator);
defer t.deinit();
try t.addFirst(0);
try t.splitRight(0, 1);
try t.splitRight(1, 2);
try std.testing.expectError(error.TooSmall, t.flatten(std.testing.allocator, 24, 1, .{ .rows = 2, .cols = 2 }, null));
}
test "resize reads a nested same-orient child's bounding box, not its first leaf" {
// beside[0, beside[1,2]] at 24x81 flattens to cols 40/20/19.
// `resize(0, .right, 3)` must read the neighbour child's span as 40 — the
// SLOT — and not 20; otherwise weights go {43,17} and tile 0 lands at 58.
var t = Tree.init(std.testing.allocator);
defer t.deinit();
try t.addFirst(0);
try t.splitRight(0, 1);
try t.splitRight(1, 2);
try std.testing.expect(t.resize(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, 0, .right, 3));
var f = try t.flatten(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, null);
defer f.deinit(std.testing.allocator);
try std.testing.expectEqual(@as(u16, 43), f.rectOf(0).?.cols);
// The right column lost 3 cols total: 40-3=37, split into 18/18 (rail).
const r1 = f.rectOf(1).?;
const r2 = f.rectOf(2).?;
try std.testing.expectEqual(@as(u16, 18), r1.cols);
try std.testing.expectEqual(@as(u16, 18), r2.cols);
// The right column starts after tile 0's 43 cols + 1 rail = 44.
try std.testing.expectEqual(@as(u16, 44), r1.left);
try std.testing.expectEqual(@as(u16, 63), r2.left);
}
test "resize refuses before a nested container's slot goes sub-minimum" {
// The right slot holds a nested beside needing 2*2+1 = 5 cols to flatten,
// while the per-axis floor check alone passes at 4. So the PROBE is the
// contract and the floor is only a pre-filter.
var t = Tree.init(std.testing.allocator);
defer t.deinit();
try t.addFirst(0);
try t.splitRight(0, 1);
try t.splitRight(1, 2);
while (t.resize(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, 0, .right, 1)) {}
// After the loop refuses, the tree must still flatten: resize's
// contract is "returns false, tree untouched".
var f = try t.flatten(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, null);
defer f.deinit(std.testing.allocator);
// The right slot never went below its real minimum of 5.
const r1 = f.rectOf(1).?;
try std.testing.expect(r1.cols >= 2);
}
test "setRootOrient flips the root container's axis" {
// insert wraps a bare root in .stacked; hydration calls setRootOrient
// to pick .beside when the terminal is wide. Three tiles left→right
// is the proof the root's axis moved.
var t = Tree.init(std.testing.allocator);
defer t.deinit();
try t.addFirst(0);
try t.insert(0, 1);
try t.insert(1, 2);
t.setRootOrient(.beside);
var f = try t.flatten(std.testing.allocator, 24, 82, .{ .rows = 2, .cols = 2 }, null);
defer f.deinit(std.testing.allocator);
// Three beside panes over 82 cols: 2 rails → 80 cols, 80/3 = 26r2,
// so 27/27/26 left→right. The left-to-right ordering is what a
// .beside root means, and what a .stacked root would not give.
try std.testing.expect(f.rectOf(0).?.left < f.rectOf(1).?.left);
try std.testing.expect(f.rectOf(1).?.left < f.rectOf(2).?.left);
try std.testing.expectEqual(@as(usize, 2), f.rails.len);
}
test "serialize: every node line carries its parent-axis cells" {
const alloc = std.testing.allocator;
var t = Tree.init(alloc);
defer t.deinit();
try t.addFirst(0);
try t.splitRight(0, 1); // beside: 0 | 1
try t.splitBelow(1, 2); // beside: 0 | stacked(1, 2)
// Weights after splits are the equal-cut defaults; rewrite them to
// known cells the way resize does, via a flatten-consistent resize:
// not needed — assert against whatever weights the tree holds by
// reading the emitted CELLS back structurally instead of literally.
const spellings = [_][]const u8{ "--sock /tmp/x#a", "--sock /tmp/x#b", "--sock /tmp/x#c" };
var buf: std.ArrayListUnmanaged(u8) = .{};
defer buf.deinit(alloc);
try t.serialize(&spellings, null, buf.writer(alloc));
const out = buf.items;
// Header, then root at depth 0 with cells 0, children one space deep.
try std.testing.expect(std.mem.startsWith(u8, out, "mux-layout 1\nbeside 0\n leaf "));
// The stacked child is a container line WITH cells at depth 1.
try std.testing.expect(std.mem.indexOf(u8, out, "\n stacked ") != null);
// Both nested leaves at depth 2, spellings verbatim to end of line.
try std.testing.expect(std.mem.indexOf(u8, out, "\n leaf ") != null);
try std.testing.expect(std.mem.indexOf(u8, out, " --sock /tmp/x#c\n") != null);
}
test "serialize: a single-leaf tree is a bare root leaf line" {
const alloc = std.testing.allocator;
var t = Tree.init(alloc);
defer t.deinit();
try t.addFirst(4);
const spellings = [_][]const u8{ "", "", "", "", "box1#b" };
var buf: std.ArrayListUnmanaged(u8) = .{};
defer buf.deinit(alloc);
try t.serialize(&spellings, null, buf.writer(alloc));
try std.testing.expectEqualStrings("mux-layout 1\nleaf 0 box1#b\n", buf.items);
}
test "parse: round-trips serialize, structure and weights intact" {
const alloc = std.testing.allocator;
var t = Tree.init(alloc);
defer t.deinit();
try t.addFirst(0);
try t.splitRight(0, 1);
try t.splitBelow(1, 2);
const spellings = [_][]const u8{ "--sock /tmp/x#a", "--sock /tmp/x#a", "box2#c" };
var buf: std.ArrayListUnmanaged(u8) = .{};
defer buf.deinit(alloc);
try t.serialize(&spellings, null, buf.writer(alloc));
var p = parse(alloc, buf.items) orelse return error.TestUnexpectedResult;
defer p.deinit(alloc);
try std.testing.expectEqual(@as(usize, 3), p.tree.count());
try std.testing.expectEqual(@as(usize, 3), p.spellings.items.len);
// Duplicate spellings survive as distinct entries, order preserved.
try std.testing.expectEqualStrings("--sock /tmp/x#a", p.spellings.items[1]);
// The parsed tree flattens like the original: same rects.
const fa = try t.flatten(alloc, 24, 80, .{ .rows = 2, .cols = 3 }, null);
defer fa.deinit(alloc);
const fb = try p.tree.flatten(alloc, 24, 80, .{ .rows = 2, .cols = 3 }, null);
defer fb.deinit(alloc);
for (fa.placed, fb.placed) |a, b| {
try std.testing.expectEqual(a.rect, b.rect);
}
}
test "serialize+parse: focus round-trips the focused leaf's encounter index" {
const alloc = std.testing.allocator;
var t = Tree.init(alloc);
defer t.deinit();
try t.addFirst(0);
try t.splitRight(0, 1); // 0 | 1
try t.splitBelow(1, 2); // 0 | stacked(1, 2) — encounter order: 0, 1, 2
const spellings = [_][]const u8{ "a", "b", "c" };
var buf: std.ArrayListUnmanaged(u8) = .{};
defer buf.deinit(alloc);
// Focus leaf 1 (the SECOND encountered leaf) → K == 1.
try t.serialize(&spellings, 1, buf.writer(alloc));
try std.testing.expect(std.mem.indexOf(u8, buf.items, "focus 1\n") != null);
var p = parse(alloc, buf.items) orelse return error.TestUnexpectedResult;
defer p.deinit(alloc);
try std.testing.expectEqual(@as(?u8, 1), p.focus);
}
test "serialize: focus null writes no focus line" {
const alloc = std.testing.allocator;
var t = Tree.init(alloc);
defer t.deinit();
try t.addFirst(0);
try t.splitRight(0, 1);
const spellings = [_][]const u8{ "a", "b" };
var buf: std.ArrayListUnmanaged(u8) = .{};
defer buf.deinit(alloc);
try t.serialize(&spellings, null, buf.writer(alloc));
try std.testing.expect(std.mem.indexOf(u8, buf.items, "focus") == null);
var p = parse(alloc, buf.items) orelse return error.TestUnexpectedResult;
defer p.deinit(alloc);
try std.testing.expectEqual(@as(?u8, null), p.focus);
}
test "parse: focus on a single-leaf root" {
const alloc = std.testing.allocator;
var p = parse(alloc, "mux-layout 1\nleaf 0 x\nfocus 0\n") orelse return error.TestUnexpectedResult;
defer p.deinit(alloc);
try std.testing.expectEqual(@as(?u8, 0), p.focus);
}
test "parse: focus out of range, duplicate, after-node, and bad number degrade to null" {
const alloc = std.testing.allocator;
const bad = [_][]const u8{
"mux-layout 1\nleaf 0 x\nleaf 1 y\nfocus 5\n", // K >= leaf count (2)
"mux-layout 1\nleaf 0 x\nfocus 0\nfocus 1\n", // second focus line
"mux-layout 1\nleaf 0 x\nfocus 0\nleaf 1 y\n", // node after focus
"mux-layout 1\nleaf 0 x\nfocus\n", // no number
"mux-layout 1\nleaf 0 x\nfocus 1x\n", // non-numeric
"mux-layout 1\nfocus 0\n", // focus before any root
"mux-layout 1\n leaf 0 x\nfocus 0\n", // leaf at depth 1 before any root — rejected before focus is reached
};
for (bad) |b| try std.testing.expect(parse(alloc, b) == null);
}
test "parse: every malformation degrades to null, never an error" {
const alloc = std.testing.allocator;
const bad = [_][]const u8{
"mux-layout 2\nleaf 0 x\n", // wrong version
"wall 1\nleaf 0 x\n", // wrong magic
"mux-layout 1\nbeside 0\n leaf 1 x\n", // depth jump (0 -> 3)
"mux-layout 1\nbeside 0\n leaf 1 x\n leaf 2 y\n", // leaf cannot adopt children
"mux-layout 1\nbeside 0\n", // empty container
"mux-layout 1\nleaf zz x\n", // bad cells
"mux-layout 1\nleaf 0 x\nleaf 0 y\n", // second root
"mux-layout 1\n", // no tree at all
"", // empty input
};
for (bad) |b| try std.testing.expect(parse(alloc, b) == null);
}
test "parseReporting: the line it gave up on, at every depth the parse can fail" {
const alloc = std.testing.allocator;
var failed: []const u8 = undefined;
// A leaf whose spelling is empty: the raw line, indentation kept, so a
// reader can find it among siblings that differ only by indent.
const empty_spelling = "mux-layout 1\nbeside 0\n leaf 1 --sock /a#0\n leaf 1 \n";
try std.testing.expect(parseReporting(alloc, empty_spelling, &failed) == null);
try std.testing.expectEqualStrings(" leaf 1 ", failed);
// The 256th leaf, which is the first the id byte cannot number.
var buf = std.ArrayListUnmanaged(u8){};
defer buf.deinit(alloc);
try buf.appendSlice(alloc, "mux-layout 1\nstacked 0\n");
for (0..256) |i| try buf.writer(alloc).print(" leaf 1 --sock /a#s{d}\n", .{i});
try std.testing.expect(parseReporting(alloc, buf.items, &failed) == null);
try std.testing.expectEqualStrings(" leaf 1 --sock /a#s255", failed);
// A focus index past the last leaf is caught after the walk, and the
// focus line is still the line to fix.
try std.testing.expect(parseReporting(alloc, "mux-layout 1\nleaf 0 x\nfocus 9\n", &failed) == null);
try std.testing.expectEqualStrings("focus 9", failed);
// Not a layout at all: the first line with anything on it, so a file
// that opens with a blank line still names something.
try std.testing.expect(parseReporting(alloc, "\nnot a layout\n", &failed) == null);
try std.testing.expectEqualStrings("not a layout", failed);
// Nothing on any line: there is no line to name.
try std.testing.expect(parseReporting(alloc, "", &failed) == null);
try std.testing.expectEqual(@as(usize, 0), failed.len);
}
test "remapLeaves: null removes, containers collapse, ids rewrite in one pass" {
const alloc = std.testing.allocator;
var t = Tree.init(alloc);
defer t.deinit();
try t.addFirst(0);
try t.splitRight(0, 1);
try t.splitBelow(1, 2);
// Leaf 1 has no wall line; 0 and 2 map to tiles 2 and 0 (a swap, the
// collision-prone case a two-pass rewrite gets wrong).
try t.remapLeaves(&[_]?u8{ 2, null, 0 });
try std.testing.expectEqual(@as(usize, 2), t.count());
const f = try t.flatten(alloc, 24, 80, .{ .rows = 2, .cols = 3 }, null);
defer f.deinit(alloc);
try std.testing.expect(f.rectOf(2) != null);
try std.testing.expect(f.rectOf(0) != null);
try std.testing.expect(f.rectOf(1) == null);
}
test "serialize: a failed leaf-id collection refuses rather than misnaming the focus" {
const alloc = std.testing.allocator;
var t = Tree.init(alloc);
defer t.deinit();
try t.addFirst(0);
try t.splitRight(0, 1);
try t.splitBelow(1, 2); // encounter order: 0, 1, 2
const spellings = [_][]const u8{ "a", "b", "c" };
var buf: std.ArrayListUnmanaged(u8) = .{};
defer buf.deinit(alloc);
// The tree is built through the test allocator and only the id list is
// taken through the failing one, so the failure lands on exactly the
// allocation under test. `buf` keeps the test allocator: the node lines
// are written before the id walk and are not what this pins.
var failing = std.testing.FailingAllocator.init(alloc, .{ .fail_index = 0 });
t.alloc = failing.allocator();
const err = t.serialize(&spellings, 2, buf.writer(alloc));
t.alloc = alloc;
try std.testing.expectError(error.OutOfMemory, err);
// Not "focus 0" or a missing focus line pointing the next attach at the
// wrong pane: a truncated walk finds leaf 2 at no index, or at the index
// of whichever leaf survived the truncation.
try std.testing.expect(std.mem.indexOf(u8, buf.items, "focus") == null);
}
test "remapLeaves: a failed leaf-id collection leaves the tree untouched" {
const alloc = std.testing.allocator;
var t = Tree.init(alloc);
defer t.deinit();
try t.addFirst(0);
try t.splitRight(0, 1);
try t.splitBelow(1, 2);
var failing = std.testing.FailingAllocator.init(alloc, .{ .fail_index = 0 });
t.alloc = failing.allocator();
const err = t.remapLeaves(&[_]?u8{ 2, null, 0 });
t.alloc = alloc;
try std.testing.expectError(error.OutOfMemory, err);
// All three leaves still there under their original ids: the collection
// is taken before the first removal, so the caller that gives up gets the
// tree it handed in, not one with an arbitrary prefix of the map applied.
try std.testing.expectEqual(@as(usize, 3), t.count());
const f = try t.flatten(alloc, 24, 80, .{ .rows = 2, .cols = 3 }, null);
defer f.deinit(alloc);
try std.testing.expect(f.rectOf(0) != null);
try std.testing.expect(f.rectOf(1) != null);
try std.testing.expect(f.rectOf(2) != null);
}
test "parse: a zero-weight child degrades to null, not a divide by zero" {
const alloc = std.testing.allocator;
// Root cells are read and discarded, so only a non-root zero is a
// malformation — and `serialize` never writes one.
const zero = [_][]const u8{
"mux-layout 1\nbeside 0\n leaf 0 a\n leaf 0 b\n",
"mux-layout 1\nstacked 0\n leaf 0 a\n leaf 0 b\n",
"mux-layout 1\nbeside 0\n leaf 1 a\n stacked 0\n leaf 1 b\n leaf 1 c\n",
};
for (zero) |b| try std.testing.expect(parse(alloc, b) == null);
// The same shape with real weights parses: the reject is the zero.
var ok = parse(alloc, "mux-layout 1\nbeside 0\n leaf 1 a\n leaf 1 b\n") orelse
return error.TestUnexpectedResult;
defer ok.deinit(alloc);
try std.testing.expectEqual(@as(usize, 2), ok.tree.count());
}
test "flatten: a container of zero total weight is TooSmall, not a divide by zero" {
const alloc = std.testing.allocator;
// Hand-built because no writer can reach it: every split writes 1 and
// `parse` now rejects 0. This pins the division's precondition where
// the next weight-writer will trip it.
for ([_]Orient{ .beside, .stacked }) |o| {
var t = Tree.init(alloc);
defer t.deinit();
const a = try alloc.create(Node);
a.* = .{ .leaf = 0 };
const b = try alloc.create(Node);
b.* = .{ .leaf = 1 };
const c = try alloc.create(Container);
c.* = .{ .orient = o, .children = .empty, .weights = .empty };
try c.children.append(alloc, a);
try c.children.append(alloc, b);
try c.weights.append(alloc, 0);
try c.weights.append(alloc, 0);
const cn = try alloc.create(Node);
cn.* = .{ .container = c };
t.root = cn;
try std.testing.expectError(
error.TooSmall,
t.flatten(alloc, 24, 80, .{ .rows = 2, .cols = 3 }, null),
);
}
}
test "neighbor down from a pane stacked on two side-by-side panes" {
var t = Tree.init(std.testing.allocator);
defer t.deinit();
try t.addFirst(0);
try t.splitBelow(0, 1);
try t.splitRight(1, 2);
// 81 cols: the top pane's midpoint is col 40, which is the rail between
// the two bottom panes (cols 0-39 and 41-80). Neither contains it.
var f = try t.flatten(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, null);
defer f.deinit(std.testing.allocator);
try std.testing.expectEqual(@as(u16, 40), f.rails[0].col);
// Both bottom panes abut the rail equally; the first placed wins, and
// the answer must be one of the two rather than nothing.
try std.testing.expectEqual(@as(?u8, 1), neighbor(f, 0, .down));
try std.testing.expectEqual(@as(?u8, 0), neighbor(f, 1, .up));
try std.testing.expectEqual(@as(?u8, 0), neighbor(f, 2, .up));
try std.testing.expectEqual(@as(?u8, 2), neighbor(f, 1, .right));
try std.testing.expectEqual(@as(?u8, 1), neighbor(f, 2, .left));
}
test "neighbor up from a pane below two side-by-side panes" {
// The mirror of the test above: the rail is between the two TOP panes,
// and the wide bottom pane's midpoint column lands on it.
var t = Tree.init(std.testing.allocator);
defer t.deinit();
try t.addFirst(0);
try t.splitBelow(0, 1);
try t.splitRight(0, 2);
var f = try t.flatten(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, null);
defer f.deinit(std.testing.allocator);
try std.testing.expectEqual(@as(u16, 40), f.rails[0].col);
try std.testing.expectEqual(@as(?u8, 0), neighbor(f, 1, .up));
try std.testing.expectEqual(@as(?u8, 1), neighbor(f, 0, .down));
try std.testing.expectEqual(@as(?u8, 1), neighbor(f, 2, .down));
try std.testing.expectEqual(@as(?u8, 2), neighbor(f, 0, .right));
try std.testing.expectEqual(@as(?u8, 0), neighbor(f, 2, .left));
}