a73x

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));
}