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