a73x

src/gui/workspace.zig

Ref:   Size: 30.9 KiB   History

//! Native workspace ownership and pixel geometry. One tab today; pane IDs
//! and attachment generations never depend on array slots or current focus.
//! A bounded binary tree keeps leaf insertion transactional without importing
//! the terminal wall's cell rails, allocation paths, or insertion policy.
const std = @import("std");
const client = @import("client");

pub const PaneId = u64;
pub const DividerId = u64;
pub const TabId = u64;
pub const max_panes = 32;
pub const Direction = enum { beside, stacked };
pub const Neighbor = enum { left, down, up, right };
pub const Attachment = struct { pane: PaneId, generation: u64 };
pub const Rect = struct {
    x: u32 = 0,
    y: u32 = 0,
    w: u32 = 0,
    h: u32 = 0,
    pub fn intersect(a: Rect, b: Rect) Rect {
        const x = @max(a.x, b.x);
        const y = @max(a.y, b.y);
        return .{ .x = x, .y = y, .w = @min(a.x + a.w, b.x + b.w) -| x, .h = @min(a.y + a.h, b.y + b.h) -| y };
    }
    pub fn contains(r: Rect, x: u32, y: u32) bool {
        return x >= r.x and y >= r.y and x - r.x < r.w and y - r.y < r.h;
    }
};
pub const Metrics = struct {
    cell_w: u16,
    cell_h: u16,
    divider: u16 = 1,
    fn minimum(self: Metrics) Rect {
        std.debug.assert(self.cell_w > 0 and self.cell_h > 0);
        const proto = @import("term").protocol;
        return .{ .w = @as(u32, self.cell_w) * proto.min_session_cols, .h = @as(u32, self.cell_h) * (proto.min_session_rows + 1) };
    }
};
pub const Placement = struct { id: PaneId, outer: Rect, header: Rect, content: Rect, visible: Rect, cols: u16, rows: u16 };
pub const Divider = struct {
    id: DividerId,
    direction: Direction,
    parent: Rect,
    rect: Rect,
    visible: Rect,
    minimum: u32,
    maximum: u32,
    pub fn position(self: Divider) u32 {
        return if (self.direction == .beside) self.rect.x else self.rect.y;
    }
};
pub const Layout = struct {
    panes: [max_panes]Placement = undefined,
    len: usize = 0,
    dividers: [max_panes - 1]Divider = undefined,
    divider_len: usize = 0,
    viewport: Rect = .{},
    fits: bool = true,
    pub fn items(self: *const Layout) []const Placement {
        return self.panes[0..self.len];
    }
    pub fn get(self: *const Layout, id: PaneId) ?Placement {
        for (self.items()) |p| if (p.id == id) return p;
        return null;
    }
    pub fn hit(self: *const Layout, x: u32, y: u32) ?PaneId {
        for (self.items()) |p| if (p.visible.contains(x, y)) return p.id;
        return null;
    }
    pub fn boundaries(self: *const Layout) []const Divider {
        return self.dividers[0..self.divider_len];
    }
    pub fn divider(self: *const Layout, id: DividerId) ?Divider {
        for (self.boundaries()) |d| if (d.id == id) return d;
        return null;
    }
    /// Grab widths are total physical pixels on each axis. Prefer the closest
    /// line, then the deeper split at intersections; never hit clipped space.
    pub fn hitDivider(self: *const Layout, x: u32, y: u32, grab_x: u32, grab_y: u32) ?DividerId {
        if (!self.fits) return null;
        var best: ?DividerId = null;
        var distance: u64 = std.math.maxInt(u64);
        for (self.boundaries()) |d| {
            var band = d.rect;
            const vertical = d.direction == .beside;
            const thickness = if (vertical) band.w else band.h;
            const size = @max(thickness, if (vertical) grab_x else grab_y);
            const begin = d.position() -| ((size - thickness) / 2);
            if (vertical) {
                band.x = begin;
                band.w = size;
            } else {
                band.y = begin;
                band.h = size;
            }
            if (!band.intersect(d.parent).intersect(self.viewport).contains(x, y)) continue;
            const coordinate = if (vertical) x else y;
            const center = @as(u64, d.position()) * 2 + thickness;
            const point = @as(u64, coordinate) * 2 + 1;
            const delta = @max(center, point) - @min(center, point);
            if (delta <= distance) {
                distance = delta;
                best = d.id;
            }
        }
        return best;
    }
};

pub const Identity = struct {
    arena: std.heap.ArenaAllocator,
    target: client.Target,
    session: []const u8,
    label: []const u8,
    pub fn init(alloc: std.mem.Allocator, target: client.Target, session: []const u8) !Identity {
        if (session.len > 0 and !@import("term").protocol.validSessionName(session)) return error.InvalidSession;
        var arena = std.heap.ArenaAllocator.init(alloc);
        errdefer arena.deinit();
        const a = arena.allocator();
        const owned = try client.discovery.cloneTarget(a, target);
        const name = try a.dupe(u8, @import("term").protocol.resolveName(session));
        const host = switch (owned) {
            .sock => |s| s,
            .via => |s| s,
            .quic => |q| q.host_port,
            .hand => |h| h.host,
        };
        const label = try std.fmt.allocPrint(a, "{s}#{s}", .{ host, name });
        return .{ .arena = arena, .target = owned, .session = name, .label = label };
    }
    pub fn deinit(self: *Identity) void {
        self.arena.deinit();
    }
};
pub const Pane = struct { id: PaneId, generation: u64 = 1, identity: Identity };
const Node = union(enum) { empty, leaf: PaneId, split: struct { id: DividerId, direction: Direction, a: u8, b: u8, first: u32 = 1, total: u32 = 2 } };
const Tree = struct {
    nodes: [max_panes * 2 - 1]Node = @splat(.empty),
    root: ?u8 = null,
    // Trial divider IDs stay private until insertion commits. Committed IDs
    // never alias reused node slots, including after sibling promotion.
    next_divider_id: DividerId = 1,
    fn free(self: *Tree) !u8 {
        for (self.nodes, 0..) |n, i| if (n == .empty) return @intCast(i);
        return error.WorkspaceFull;
    }
    fn leaf(self: *const Tree, id: PaneId) ?u8 {
        for (self.nodes, 0..) |n, i| if (n == .leaf and n.leaf == id) return @intCast(i);
        return null;
    }
    fn insert(self: *Tree, origin: ?PaneId, id: PaneId, direction: Direction) !void {
        if (self.root == null) {
            const at = try self.free();
            self.nodes[at] = .{ .leaf = id };
            self.root = at;
            return;
        }
        if (self.next_divider_id == std.math.maxInt(u64)) return error.IdExhausted;
        const at = self.leaf(origin orelse return error.MissingPane) orelse return error.MissingPane;
        const a = try self.free();
        self.nodes[a] = self.nodes[at];
        const b = try self.free();
        self.nodes[b] = .{ .leaf = id };
        self.nodes[at] = .{ .split = .{ .id = self.next_divider_id, .direction = direction, .a = a, .b = b } };
        self.next_divider_id += 1;
    }
    fn remove(self: *Tree, id: PaneId) void {
        const at = self.leaf(id) orelse return;
        if (self.root == at) {
            self.nodes[at] = .empty;
            self.root = null;
            return;
        }
        for (&self.nodes) |*node| {
            if (node.* != .split) continue;
            const s = node.split;
            if (s.a == at or s.b == at) {
                const sibling = if (s.a == at) s.b else s.a;
                node.* = self.nodes[sibling];
                self.nodes[sibling] = .empty;
                self.nodes[at] = .empty;
                return;
            }
        }
    }
    fn minimum(self: *const Tree, at: u8, m: Metrics) Rect {
        return switch (self.nodes[at]) {
            .leaf => m.minimum(),
            .split => |s| blk: {
                const a = self.minimum(s.a, m);
                const b = self.minimum(s.b, m);
                break :blk switch (s.direction) {
                    .beside => .{ .w = a.w + b.w + m.divider, .h = @max(a.h, b.h) },
                    .stacked => .{ .w = @max(a.w, b.w), .h = a.h + b.h + m.divider },
                };
            },
            .empty => unreachable,
        };
    }
    fn layout(self: *const Tree, at: ?u8, width: u32, height: u32, m: Metrics) Layout {
        var out: Layout = .{ .viewport = .{ .w = width, .h = height } };
        if (at) |root| {
            const min = self.minimum(root, m);
            out.fits = width >= min.w and height >= min.h;
            self.flatten(root, .{ .w = @max(width, min.w), .h = @max(height, min.h) }, .{ .w = width, .h = height }, m, &out);
        }
        return out;
    }
    fn flatten(self: *const Tree, at: u8, rect: Rect, viewport: Rect, m: Metrics, out: *Layout) void {
        switch (self.nodes[at]) {
            .leaf => |id| {
                const content: Rect = .{ .x = rect.x, .y = rect.y + m.cell_h, .w = rect.w, .h = rect.h - m.cell_h };
                out.panes[out.len] = .{ .id = id, .outer = rect, .header = .{ .x = rect.x, .y = rect.y, .w = rect.w, .h = m.cell_h }, .content = content, .visible = rect.intersect(viewport), .cols = @intCast(@min(@max(content.w / m.cell_w, 1), @import("term").protocol.max_cols)), .rows = @intCast(@min(@max(content.h / m.cell_h, 1), std.math.maxInt(u16))) };
                out.len += 1;
            },
            .split => |s| {
                const amin = self.minimum(s.a, m);
                const bmin = self.minimum(s.b, m);
                var a = rect;
                var b = rect;
                var line = rect;
                const vertical = s.direction == .beside;
                const space = (if (vertical) rect.w else rect.h) - m.divider;
                const low = if (vertical) amin.w else amin.h;
                const high = space - (if (vertical) bmin.w else bmin.h);
                const intended: u32 = @intCast((@as(u64, space) * s.first + s.total / 2) / s.total);
                const cut = std.math.clamp(intended, low, high);
                switch (s.direction) {
                    .beside => {
                        a.w = cut;
                        b.x += a.w + m.divider;
                        b.w = space - a.w;
                        line.x += cut;
                        line.w = m.divider;
                    },
                    .stacked => {
                        a.h = cut;
                        b.y += a.h + m.divider;
                        b.h = space - a.h;
                        line.y += cut;
                        line.h = m.divider;
                    },
                }
                const start = if (vertical) rect.x else rect.y;
                out.dividers[out.divider_len] = .{ .id = s.id, .direction = s.direction, .parent = rect, .rect = line, .visible = line.intersect(viewport), .minimum = start + low, .maximum = start + high };
                out.divider_len += 1;
                self.flatten(s.a, a, viewport, m, out);
                self.flatten(s.b, b, viewport, m, out);
            },
            .empty => unreachable,
        }
    }
};
pub const Pending = struct { pane: PaneId, direction: Direction };
pub const Tab = struct {
    id: TabId = 1,
    tree: Tree = .{},
    focus: ?PaneId = null,
    // Transient view of the focused leaf; the saved split tree is unchanged.
    fullscreen: bool = false,
    pending: ?Pending = null,
    panes: [max_panes]?*Pane = @splat(null),
};
pub const Prepared = struct {
    pane: *Pane,
    tree: Tree,
    placement: Placement,
    pub fn discard(self: *Prepared, alloc: std.mem.Allocator) void {
        self.pane.identity.deinit();
        alloc.destroy(self.pane);
    }
};
pub const Workspace = struct {
    alloc: std.mem.Allocator,
    tabs: [1]Tab = .{.{}},
    active_tab_id: TabId = 1,
    next_pane_id: PaneId = 1,
    pub fn init(alloc: std.mem.Allocator) Workspace {
        return .{ .alloc = alloc };
    }
    pub fn tab(self: *Workspace) *Tab {
        return &self.tabs[0];
    }
    pub fn deinit(self: *Workspace) void {
        for (self.tab().panes) |p| if (p) |v| {
            v.identity.deinit();
            self.alloc.destroy(v);
        };
    }
    pub fn pane(self: *Workspace, id: PaneId) ?*Pane {
        for (self.tab().panes) |p| if (p) |v| {
            if (v.id == id) return v;
        };
        return null;
    }
    pub fn hasTarget(self: *Workspace, target: client.Target) bool {
        for (self.tab().panes) |p| if (p) |pane_value| {
            if (client.forward.targetEqual(pane_value.identity.target, target)) return true;
        };
        return false;
    }
    pub fn layout(self: *Workspace, width: u32, height: u32, m: Metrics) Layout {
        const t = self.tab();
        const root = if (t.fullscreen and t.focus != null) t.tree.leaf(t.focus.?) else t.tree.root;
        return t.tree.layout(root, width, height, m);
    }
    /// Both interaction paths use a fresh layout and the same minimum clamp.
    /// A no-op must not replace an intended ratio with its temporary clamp.
    pub fn resizeDivider(self: *Workspace, id: DividerId, position: i64, width: u32, height: u32, m: Metrics) bool {
        const flat = self.layout(width, height, m);
        if (!flat.fits) return false;
        const d = flat.divider(id) orelse return false;
        const cut: u32 = @intCast(std.math.clamp(position, d.minimum, d.maximum));
        if (cut == d.position()) return false;
        for (&self.tab().tree.nodes) |*n| {
            if (n.* != .split or n.split.id != id) continue;
            const vertical = d.direction == .beside;
            n.split.first = cut - (if (vertical) d.parent.x else d.parent.y);
            n.split.total = (if (vertical) d.parent.w else d.parent.h) - m.divider;
            return true;
        }
        return false;
    }
    pub fn resizeFocused(self: *Workspace, direction: Neighbor, width: u32, height: u32, m: Metrics) bool {
        const id = self.tab().focus orelse return false;
        const axis: Direction = switch (direction) {
            .left, .right => .beside,
            .up, .down => .stacked,
        };
        const tree = &self.tab().tree;
        var at = tree.leaf(id) orelse return false;
        var boundary: ?DividerId = null;
        while (at != tree.root.?) {
            var found = false;
            for (tree.nodes, 0..) |node, index| {
                if (node != .split or (node.split.a != at and node.split.b != at)) continue;
                if (node.split.direction == axis) boundary = node.split.id;
                at = @intCast(index);
                found = true;
                break;
            }
            std.debug.assert(found);
            if (boundary != null) break;
        }
        const flat = self.layout(width, height, m);
        const d = flat.divider(boundary orelse return false) orelse return false;
        const step: i64 = if (axis == .beside) m.cell_w else m.cell_h;
        const negative = direction == .left or direction == .up;
        return self.resizeDivider(d.id, @as(i64, d.position()) + (if (negative) -step else step), width, height, m);
    }
    pub fn arm(self: *Workspace, direction: Direction) void {
        if (self.tab().focus) |id| self.tab().pending = .{ .pane = id, .direction = direction };
    }
    pub fn cancel(self: *Workspace) void {
        self.tab().pending = null;
    }
    pub fn focus(self: *Workspace, id: PaneId) bool {
        if (self.pane(id) == null) return false;
        self.tab().focus = id;
        return true;
    }
    /// Allocation-free sizing for an explicit create, before its remote side
    /// effect. prepare uses the identical plan when it commits a real pane.
    pub fn preview(self: *Workspace, width: u32, height: u32, m: Metrics) !Placement {
        return (try self.insertionPlan(width, height, m)).placement;
    }
    pub fn prepare(self: *Workspace, target: client.Target, session: []const u8, width: u32, height: u32, m: Metrics) !Prepared {
        const plan = try self.insertionPlan(width, height, m);
        const p = try self.alloc.create(Pane);
        errdefer self.alloc.destroy(p);
        p.* = .{ .id = self.next_pane_id, .identity = try Identity.init(self.alloc, target, session) };
        self.next_pane_id += 1;
        return .{ .pane = p, .tree = plan.tree, .placement = plan.placement };
    }
    fn insertionPlan(self: *Workspace, width: u32, height: u32, m: Metrics) !struct { tree: Tree, placement: Placement } {
        if (self.next_pane_id == std.math.maxInt(u64)) return error.IdExhausted;
        const t = self.tab();
        var count: usize = 0;
        for (t.panes) |p| {
            if (p != null) count += 1;
        }
        if (count == max_panes) return error.WorkspaceFull;
        const origin = if (t.pending) |p| p.pane else t.focus;
        const direction = if (t.pending) |p| p.direction else Direction.beside;
        if (t.tree.root) |r| {
            const min = t.tree.minimum(r, m);
            if (min.w > width or min.h > height) return error.TooSmall;
            const flat = t.tree.layout(t.tree.root, width, height, m);
            const old = flat.get(origin orelse return error.MissingPane) orelse return error.MissingPane;
            const leaf_min = m.minimum();
            if ((direction == .beside and old.outer.w < leaf_min.w * 2 + m.divider) or (direction == .stacked and old.outer.h < leaf_min.h * 2 + m.divider)) return error.TooSmall;
        }
        var tree = t.tree;
        const id = self.next_pane_id;
        try tree.insert(origin, id, direction);
        const flat = tree.layout(tree.root, width, height, m);
        return .{ .tree = tree, .placement = flat.get(id).? };
    }
    /// Call immediately after preparing the attachment; no other model edits
    /// may occur between prepare and commit. This operation cannot allocate.
    pub fn commit(self: *Workspace, prepared: Prepared) void {
        const t = self.tab();
        for (&t.panes) |*p| if (p.* == null) {
            p.* = prepared.pane;
            break;
        };
        t.tree = prepared.tree;
        t.focus = prepared.pane.id;
        t.fullscreen = false;
        t.pending = null;
    }
    /// The caller has already joined this pane's pump and discarded its wake
    /// context. Removing a pending origin cancels that pending insertion.
    pub fn remove(self: *Workspace, id: PaneId) void {
        const t = self.tab();
        for (&t.panes) |*p| if (p.*) |v| {
            if (v.id == id) {
                v.identity.deinit();
                self.alloc.destroy(v);
                p.* = null;
                break;
            }
        };
        t.tree.remove(id);
        if (t.pending) |p| if (p.pane == id) {
            t.pending = null;
        };
        if (t.focus == id) {
            t.fullscreen = false;
            t.focus = null;
            for (t.panes) |p| if (p) |v| {
                t.focus = v.id;
                break;
            };
        }
    }
    pub fn moveFocus(self: *Workspace, direction: Neighbor, width: u32, height: u32, m: Metrics) void {
        const t = self.tab();
        const flat = t.tree.layout(t.tree.root, width, height, m);
        const from = flat.get(self.tab().focus orelse return) orelse return;
        var best: ?PaneId = null;
        var score: u64 = std.math.maxInt(u64);
        for (flat.items()) |p| {
            if (p.id == from.id) continue;
            const f = from.outer;
            const r = p.outer;
            const valid = switch (direction) {
                .left => r.x + r.w <= f.x,
                .right => r.x >= f.x + f.w,
                .up => r.y + r.h <= f.y,
                .down => r.y >= f.y + f.h,
            };
            if (!valid) continue;
            const main: u32 = switch (direction) {
                .left => f.x - (r.x + r.w),
                .right => r.x - (f.x + f.w),
                .up => f.y - (r.y + r.h),
                .down => r.y - (f.y + f.h),
            };
            const mid = switch (direction) {
                .left, .right => f.y + f.h / 2,
                .up, .down => f.x + f.w / 2,
            };
            const lo = switch (direction) {
                .left, .right => r.y,
                .up, .down => r.x,
            };
            const hi = lo + switch (direction) {
                .left, .right => r.h,
                .up, .down => r.w,
            };
            const perp = if (mid < lo) lo - mid else mid -| hi;
            const value = @as(u64, main) * 1_000_000 + perp;
            if (value < score) {
                score = value;
                best = p.id;
            }
        }
        if (best) |id| _ = self.focus(id);
    }
};

fn add(w: *Workspace, width: u32, height: u32) !PaneId {
    const p = try w.prepare(.{ .via = "cat" }, "test", width, height, .{ .cell_w = 10, .cell_h = 20 });
    w.commit(p);
    return p.pane.id;
}
test "workspace nested insertion focus cancellation removal and tiny clipping preserve IDs" {
    var w = Workspace.init(std.testing.allocator);
    defer w.deinit();
    const a = try add(&w, 800, 600);
    w.arm(.beside);
    const unchanged = w.layout(800, 600, .{ .cell_w = 10, .cell_h = 20 });
    w.cancel();
    try std.testing.expectEqualDeep(unchanged.items(), w.layout(800, 600, .{ .cell_w = 10, .cell_h = 20 }).items());
    w.arm(.beside);
    const b = try add(&w, 800, 600);
    var flat = w.layout(800, 600, .{ .cell_w = 10, .cell_h = 20 });
    const left = flat.get(a).?.outer;
    w.arm(.stacked);
    _ = w.focus(a);
    const c = try add(&w, 800, 600);
    flat = w.layout(800, 600, .{ .cell_w = 10, .cell_h = 20 });
    try std.testing.expectEqual(left, flat.get(a).?.outer);
    try std.testing.expect(flat.get(c).?.outer.y > flat.get(b).?.outer.y);
    w.moveFocus(.up, 800, 600, .{ .cell_w = 10, .cell_h = 20 });
    try std.testing.expectEqual(b, w.tab().focus.?);
    w.moveFocus(.left, 800, 600, .{ .cell_w = 10, .cell_h = 20 });
    try std.testing.expectEqual(a, w.tab().focus.?);
    const tiny = w.layout(5, 5, .{ .cell_w = 10, .cell_h = 20 });
    try std.testing.expectEqual(@as(usize, 3), tiny.len);
    try std.testing.expect(tiny.hit(6, 2) == null);
    for (tiny.items()) |p| {
        try std.testing.expect(p.cols > 0 and p.rows > 0);
    }
    try std.testing.expectError(error.TooSmall, w.prepare(.{ .via = "cat" }, "x", 5, 5, .{ .cell_w = 10, .cell_h = 20 }));
    w.arm(.beside);
    w.remove(a);
    try std.testing.expect(w.tab().pending == null);
    const d = try add(&w, 800, 600);
    try std.testing.expect(d > c);
}
test "workspace refuses a nested leaf split even if global minima would fit" {
    var w = Workspace.init(std.testing.allocator);
    defer w.deinit();
    const a = try add(&w, 100, 100);
    _ = try add(&w, 100, 100);
    _ = w.focus(a);
    _ = try add(&w, 100, 100);
    // Four leaves need83px globally, but the armed25px leaf needs41px.
    w.tab().fullscreen = true;
    try std.testing.expectError(error.TooSmall, w.prepare(.{ .via = "cat" }, "x", 100, 100, .{ .cell_w = 10, .cell_h = 20 }));
    try std.testing.expect(w.tab().fullscreen);
}
fn allocationCase(alloc: std.mem.Allocator) !void {
    var w = Workspace.init(alloc);
    defer w.deinit();
    _ = try add(&w, 800, 600);
    w.arm(.stacked);
    const before = w.tab().focus;
    const p = w.prepare(.{ .hand = .{ .host = "host", .ssh_argv = &.{ "ssh", "host" }, .cache_path = "/tmp/cache" } }, "other", 800, 600, .{ .cell_w = 10, .cell_h = 20 }) catch |err| {
        try std.testing.expectEqual(before, w.tab().focus);
        try std.testing.expectEqual(@as(usize, 1), w.layout(800, 600, .{ .cell_w = 10, .cell_h = 20 }).len);
        return err;
    };
    w.commit(p);
}
test "workspace insertion allocation failures leave no half insertion or identity leak" {
    try std.testing.checkAllAllocationFailures(std.testing.allocator, allocationCase, .{});
}

test "workspace identity owns nested target arguments beyond caller buffers" {
    var host = [_]u8{ 'h', 'o', 's', 't' };
    var key = [_]u8{ '/', 'k', 'e', 'y' };
    var name = [_]u8{ 'w', 'o', 'r', 'k' };
    var hand = try Identity.init(std.testing.allocator, .{ .hand = .{ .host = &host, .ssh_argv = &.{ "ssh", &host }, .asked_argv = &.{ "ssh", &host, "start" }, .cache_path = &key, .ask_sock = &key, .ask_exe = &key } }, &name);
    defer hand.deinit();
    var quic = try Identity.init(std.testing.allocator, .{ .quic = .{ .host_port = &host, .key_path = &key } }, &name);
    defer quic.deinit();
    @memset(&host, 'x');
    @memset(&key, 'x');
    @memset(&name, 'x');
    try std.testing.expectEqualStrings("host", hand.target.hand.ssh_argv[1]);
    try std.testing.expectEqualStrings("host", hand.target.hand.asked_argv[1]);
    try std.testing.expectEqualStrings("/key", hand.target.hand.cache_path.?);
    try std.testing.expectEqualStrings("/key", hand.target.hand.ask_sock.?);
    try std.testing.expectEqualStrings("/key", hand.target.hand.ask_exe);
    try std.testing.expectEqualStrings("host", quic.target.quic.host_port);
    try std.testing.expectEqualStrings("/key", quic.target.quic.key_path);
    try std.testing.expectEqualStrings("work", hand.session);
    try std.testing.expectEqualStrings("host#work", hand.label);
}

test "weighted nested dividers share keyboard clamps and restore intended proportions" {
    var w = Workspace.init(std.testing.allocator);
    defer w.deinit();
    const m: Metrics = .{ .cell_w = 10, .cell_h = 20 };
    const a = try add(&w, 801, 601);
    const b = try add(&w, 801, 601);
    w.arm(.stacked);
    const c = try add(&w, 801, 601);
    var flat = w.layout(801, 601, m);
    const outer = flat.boundaries()[0].id;
    const inner = flat.boundaries()[1].id;
    try std.testing.expect(w.resizeDivider(outer, 200, 801, 601, m));
    try std.testing.expect(w.resizeDivider(inner, 150, 801, 601, m));
    flat = w.layout(1601, 1201, m);
    try std.testing.expectEqual(@as(u32, 400), flat.divider(outer).?.position());
    try std.testing.expectEqual(@as(u32, 300), flat.divider(inner).?.position());

    // Focus is bottom-right: left moves its outer ancestor, up its nearest
    // horizontal ancestor. Direction describes the divider, not pane growth.
    try std.testing.expect(w.resizeFocused(.left, 801, 601, m));
    try std.testing.expect(w.resizeFocused(.up, 801, 601, m));
    flat = w.layout(801, 601, m);
    try std.testing.expectEqual(@as(u32, 190), flat.divider(outer).?.position());
    try std.testing.expectEqual(@as(u32, 130), flat.divider(inner).?.position());
    try std.testing.expectEqual(c, w.tab().focus.?);
    try std.testing.expectEqual(@as(u32, 190), flat.get(a).?.outer.w);
    try std.testing.expectEqual(@as(u32, 130), flat.get(b).?.outer.h);

    const restored = flat;
    w.tab().fullscreen = true;
    const full = w.layout(1601, 1201, m);
    try std.testing.expectEqual(@as(usize, 1), full.len);
    try std.testing.expectEqual(c, full.items()[0].id);
    try std.testing.expectEqual(full.viewport, full.get(c).?.outer);
    try std.testing.expectEqual(@as(usize, 0), full.divider_len);
    try std.testing.expect(full.get(a) == null and full.hit(0, 0) == c);
    try std.testing.expect(!w.resizeFocused(.left, 1601, 1201, m));
    const full_tiny = w.layout(10, 10, m);
    try std.testing.expect(!full_tiny.fits and full_tiny.get(c).?.cols >= 2 and full_tiny.get(c).?.rows >= 2);
    w.moveFocus(.up, 801, 601, m);
    try std.testing.expect(w.tab().fullscreen and w.layout(801, 601, m).items()[0].id == b);
    _ = w.focus(c);
    w.tab().fullscreen = false;
    try std.testing.expectEqualDeep(restored.items(), w.layout(801, 601, m).items());
    try std.testing.expectEqualDeep(restored.boundaries(), w.layout(801, 601, m).boundaries());
    const tiny = w.layout(10, 10, m);
    try std.testing.expect(!tiny.fits);
    try std.testing.expect(!w.resizeFocused(.right, 10, 10, m));
    try std.testing.expect(!w.resizeDivider(inner, 1000, 10, 10, m));
    try std.testing.expect(tiny.hitDivider(5, 5, 100, 100) == null);
    for (tiny.items()) |p| try std.testing.expect(p.cols > 0 and p.rows > 0);
    try std.testing.expectEqualDeep(restored.items(), w.layout(801, 601, m).items());
    // At exactly the recursive minimum, a clamped no-op also retains ratios.
    try std.testing.expect(!w.resizeDivider(outer, -100, 41, 121, m));
    try std.testing.expectEqualDeep(restored.items(), w.layout(801, 601, m).items());
    try std.testing.expect(w.resizeDivider(outer, -100, 801, 601, m));
    try std.testing.expectEqual(@as(u32, 20), w.layout(801, 601, m).divider(outer).?.position());
    try std.testing.expect(w.resizeDivider(inner, 10000, 801, 601, m));
    try std.testing.expectEqual(@as(u32, 540), w.layout(801, 601, m).divider(inner).?.position());
    const clamped = w.layout(801, 601, m);
    try std.testing.expectEqual(@as(u16, @import("term").protocol.min_session_rows), clamped.get(c).?.rows);
    try std.testing.expectEqual(@as(u16, @import("term").protocol.min_session_cols), clamped.get(a).?.cols);
}

test "divider IDs survive sibling promotion and never alias reused slots" {
    var w = Workspace.init(std.testing.allocator);
    defer w.deinit();
    const m: Metrics = .{ .cell_w = 10, .cell_h = 20 };
    const a = try add(&w, 801, 601);
    const b = try add(&w, 801, 601);
    w.arm(.stacked);
    _ = try add(&w, 801, 601);
    const before = w.layout(801, 601, m);
    const removed = before.boundaries()[0].id;
    const promoted = before.boundaries()[1].id;
    try std.testing.expect(w.resizeDivider(promoted, 200, 801, 601, m));
    w.remove(a);
    try std.testing.expectEqual(@as(u32, 200), w.layout(801, 601, m).divider(promoted).?.position());
    _ = w.focus(b);
    w.arm(.beside);
    _ = try add(&w, 801, 601);
    const after = w.layout(801, 601, m);
    try std.testing.expect(after.divider(removed) == null);
    try std.testing.expect(!w.resizeDivider(removed, 400, 801, 601, m));
    try std.testing.expect(after.boundaries()[1].id > promoted);
    try std.testing.expectEqual(@as(u32, 200), after.divider(promoted).?.position());
}

test "keyboard resize chooses the closest matching ancestor and leaves the outer cut unchanged" {
    var w = Workspace.init(std.testing.allocator);
    defer w.deinit();
    const m: Metrics = .{ .cell_w = 10, .cell_h = 20 };
    _ = try add(&w, 801, 601);
    _ = try add(&w, 801, 601);
    _ = try add(&w, 801, 601);
    const before = w.layout(801, 601, m);
    const outer = before.boundaries()[0];
    const inner = before.boundaries()[1];
    try std.testing.expect(w.resizeFocused(.left, 801, 601, m));
    const after = w.layout(801, 601, m);
    try std.testing.expectEqual(outer.rect, after.divider(outer.id).?.rect);
    try std.testing.expectEqual(inner.position() - 10, after.divider(inner.id).?.position());
}

test "divider hit areas respect axis density nested boundaries and viewport clipping" {
    var w = Workspace.init(std.testing.allocator);
    defer w.deinit();
    _ = try add(&w, 801, 601);
    _ = try add(&w, 801, 601);
    w.arm(.stacked);
    _ = try add(&w, 801, 601);
    const flat = w.layout(801, 601, .{ .cell_w = 10, .cell_h = 20 });
    const vertical = flat.boundaries()[0];
    const horizontal = flat.boundaries()[1];
    try std.testing.expectEqual(vertical.id, flat.hitDivider(395, 100, 12, 6).?);
    try std.testing.expect(flat.hitDivider(394, 100, 12, 6) == null);
    try std.testing.expectEqual(horizontal.id, flat.hitDivider(600, 298, 12, 6).?);
    try std.testing.expect(flat.hitDivider(600, 297, 12, 6) == null);
    try std.testing.expect(flat.hitDivider(100, 300, 12, 6) == null);
    try std.testing.expect(flat.hitDivider(400, 601, 12, 6) == null);
    try std.testing.expectEqual(vertical.id, flat.hitDivider(400, 300, 12, 6).?);
    try std.testing.expectEqual(horizontal.id, flat.hitDivider(401, 300, 12, 6).?);
}