Something went wrong. Try again.
A GPU terminal emulator for Wayland and Windows on libghostty-vt, drawing with Vulkan, running a program on a pty or a serial port.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! A tab: one or more panes — terminals — in a tree of splits, and which of//! them has the focus.//!//! A split divides its space between two children, side by side or one//! above the other, in a ratio, with a divider between them the width of a//! line; either child is a pane or another split. Splitting a pane puts it//! and a new one where it was; closing one gives its place to its sibling.//! One pane can be zoomed to fill the tab, hiding the others until it is//! zoomed back.//!//! The tree knows panes only by where they are laid out, so everything here//! is geometry: where each pane goes, which one a point or a direction//! finds, and which divider the pointer is on.
const std = @import("std");const Allocator = std.mem.Allocator;
const App = @import("App.zig");const Pane = @import("Pane.zig");const req = @import("request.zig");const Backend = @import("backend/Backend.zig");
const Tab = @This();
pub const Rect = Pane.Rect;
gpa: Allocator,root: *Node,focused: *Pane,/// The focused pane fills the tab, and the others are hidden.zoomed: bool = false,/// Where the tab was last laid out.area: Rect = .{},
/// The most panes a tab may hold: room for a fixed buffer of them, and more/// than fit on a screen.pub const max_panes = 64;
pub const Axis = enum { /// Side by side: the split divides the width. horizontal, /// One above the other: the split divides the height. vertical,};
pub const Direction = enum { left, right, up, down,
fn axis(d: Direction) Axis { return switch (d) { .left, .right => .horizontal, .up, .down => .vertical, }; }};
pub const Node = union(enum) { pane: *Pane, split: Split,};
pub const Split = struct { axis: Axis, /// How much of the space, less the divider, goes to `a`: the left or /// upper child. ratio: f32 = 0.5, a: *Node, b: *Node, /// Where it was last laid out, and its divider, for the pointer. rect: Rect = .{}, divider: Rect = .{},};
/// A tab of one pane, running what `request` asks for.pub fn create(app: *App, request: *const req.Request, size: Backend.Size) !*Tab { const first = try Pane.create(app, request, .{ .size = size }); errdefer first.destroy(); return createWith(app.gpa, first);}
/// A tab of `first`, which it takes if it succeeds.pub fn createWith(gpa: Allocator, first: *Pane) Allocator.Error!*Tab { const node = try gpa.create(Node); errdefer gpa.destroy(node); node.* = .{ .pane = first }; const t = try gpa.create(Tab); t.* = .{ .gpa = gpa, .root = node, .focused = first }; return t;}
pub fn destroy(t: *Tab) void { var buf: [max_panes]*Pane = undefined; for (t.panes(&buf)) |p| p.destroy(); t.freeNodes(t.root); t.gpa.destroy(t);}
fn freeNodes(t: *Tab, node: *Node) void { switch (node.*) { .pane => {}, .split => |s| { t.freeNodes(s.a); t.freeNodes(s.b); }, } t.gpa.destroy(node);}
/// The tab's title: its focused pane's.pub fn title(t: *const Tab) []const u8 { return t.focused.title.items;}
/// Every pane, left to right and top to bottom, into `buf`.pub fn panes(t: *const Tab, buf: *[max_panes]*Pane) []*Pane { var n: usize = 0; collect(t.root, buf, &n); return buf[0..n];}
fn collect(node: *const Node, buf: *[max_panes]*Pane, n: *usize) void { switch (node.*) { .pane => |p| { buf[n.*] = p; n.* += 1; }, .split => |s| { collect(s.a, buf, n); collect(s.b, buf, n); }, }}
pub fn count(t: *const Tab) usize { var buf: [max_panes]*Pane = undefined; return t.panes(&buf).len;}
fn leafOf(node: *Node, pane: *const Pane) ?*Node { return switch (node.*) { .pane => |p| if (p == pane) node else null, .split => |s| leafOf(s.a, pane) orelse leafOf(s.b, pane), };}
fn parentOf(node: *Node, child: *const Node) ?*Node { return switch (node.*) { .pane => null, .split => |s| if (s.a == child or s.b == child) node else parentOf(s.a, child) orelse parentOf(s.b, child), };}
fn firstPane(node: *const Node) *Pane { return switch (node.*) { .pane => |p| p, .split => |s| firstPane(s.a), };}
fn lastPane(node: *const Node) *Pane { return switch (node.*) { .pane => |p| p, .split => |s| lastPane(s.b), };}
pub const SplitError = Allocator.Error || error{ /// The tab has as many panes as it may. TooManyPanes,};
/// Splits `pane` along `axis`: it keeps the left or upper half and `new`/// takes the other, and the focus. Zooming ends.pub fn split(t: *Tab, pane: *Pane, axis: Axis, new: *Pane) SplitError!void { if (t.count() >= max_panes) return error.TooManyPanes; const leaf = leafOf(t.root, pane) orelse unreachable; const a = try t.gpa.create(Node); errdefer t.gpa.destroy(a); const b = try t.gpa.create(Node); a.* = leaf.*; b.* = .{ .pane = new }; leaf.* = .{ .split = .{ .axis = axis, .a = a, .b = b } }; t.focused = new; t.zoomed = false;}
/// Takes `pane` out of the tree, its sibling taking its place; true when it/// was the last, and the tab is empty. The pane itself is the caller's. The/// focus, if it was on the pane, goes to the pane beside it.pub fn remove(t: *Tab, pane: *Pane) bool { const leaf = leafOf(t.root, pane) orelse return false; if (leaf == t.root) return true; const parent = parentOf(t.root, leaf).?; const s = parent.split; const sibling = if (s.a == leaf) s.b else s.a; parent.* = sibling.*; t.gpa.destroy(sibling); t.gpa.destroy(leaf); if (t.focused == pane) { // The neighbour nearest where it was: the first of what was to its // right or below, or the last of what was to its left or above. t.focused = if (s.a == leaf) firstPane(parent) else lastPane(parent); } if (t.count() == 1) t.zoomed = false; return false;}
/// Moves `pane` beside `onto`, on `onto`'s `side`, the two sharing what/// was `onto`'s place half each; the sibling `pane` leaves behind takes its/// old place. Nothing for a pane onto itself or one not in the tab. Zooming/// ends; the focus is the caller's.pub fn move(t: *Tab, pane: *Pane, onto: *Pane, side: Direction) void { if (pane == onto or leafOf(t.root, onto) == null) return; const leaf = leafOf(t.root, pane) orelse return; const parent = parentOf(t.root, leaf) orelse return; const s = parent.split; const sibling = if (s.a == leaf) s.b else s.a; parent.* = sibling.*; // The two nodes that leaves spare are the two the new split wants, so // that a move cannot fail. const dest = leafOf(t.root, onto).?; sibling.* = dest.*; leaf.* = .{ .pane = pane }; const first = side == .left or side == .up; dest.* = .{ .split = .{ .axis = side.axis(), .a = if (first) leaf else sibling, .b = if (first) sibling else leaf, } }; t.zoomed = false;}
/// Which side of `r` a pane dropped at `x`, `y` goes: the edge nearest,/// measured in proportion to `r`'s size, so that `r` is cut corner to/// corner into four triangles.pub fn dropSide(r: Rect, x: f32, y: f32) Direction { const fx = (x - @as(f32, @floatFromInt(r.x))) / @max(1, @as(f32, @floatFromInt(r.w))); const fy = (y - @as(f32, @floatFromInt(r.y))) / @max(1, @as(f32, @floatFromInt(r.h))); const left = fx; const right = 1 - fx; const up = fy; const down = 1 - fy; const nearest = @min(@min(left, right), @min(up, down)); if (nearest == left) return .left; if (nearest == right) return .right; if (nearest == up) return .up; return .down;}
/// Lays the panes out in `area`, with `gap` between them for the dividers,/// none narrower than `min_w` or shorter than `min_h` where there is room.pub fn layout(t: *Tab, area: Rect, gap: u32, min_w: u32, min_h: u32) void { t.area = area; if (t.zoomed) { var buf: [max_panes]*Pane = undefined; for (t.panes(&buf)) |p| p.rect = .{}; t.focused.rect = area; clearSplits(t.root); return; } layoutNode(t.root, area, gap, min_w, min_h);}
fn clearSplits(node: *Node) void { switch (node.*) { .pane => {}, .split => |*s| { s.rect = .{}; s.divider = .{}; clearSplits(s.a); clearSplits(s.b); }, }}
fn layoutNode(node: *Node, r: Rect, gap: u32, min_w: u32, min_h: u32) void { switch (node.*) { .pane => |p| p.rect = r, .split => |*s| { s.rect = r; const total = switch (s.axis) { .horizontal => r.w, .vertical => r.h, } -| gap; const min = switch (s.axis) { .horizontal => min_w, .vertical => min_h, }; var a_len: u32 = @intFromFloat(@round(@as(f32, @floatFromInt(total)) * s.ratio)); if (total >= 2 * min) a_len = std.math.clamp(a_len, min, total - min); const b_len = total - @min(a_len, total); switch (s.axis) { .horizontal => { s.divider = .{ .x = r.x + a_len, .y = r.y, .w = gap, .h = r.h }; layoutNode(s.a, .{ .x = r.x, .y = r.y, .w = a_len, .h = r.h }, gap, min_w, min_h); layoutNode(s.b, .{ .x = r.x + a_len + gap, .y = r.y, .w = b_len, .h = r.h }, gap, min_w, min_h); }, .vertical => { s.divider = .{ .x = r.x, .y = r.y + a_len, .w = r.w, .h = gap }; layoutNode(s.a, .{ .x = r.x, .y = r.y, .w = r.w, .h = a_len }, gap, min_w, min_h); layoutNode(s.b, .{ .x = r.x, .y = r.y + a_len + gap, .w = r.w, .h = b_len }, gap, min_w, min_h); }, } }, }}
/// Where every divider was laid out, into `buf`: none while zoomed.pub fn dividers(t: *const Tab, buf: *[max_panes]Rect) []Rect { var n: usize = 0; if (!t.zoomed) collectDividers(t.root, buf, &n); return buf[0..n];}
fn collectDividers(node: *const Node, buf: *[max_panes]Rect, n: *usize) void { switch (node.*) { .pane => {}, .split => |s| { buf[n.*] = s.divider; n.* += 1; collectDividers(s.a, buf, n); collectDividers(s.b, buf, n); }, }}
/// The pane shown at `x`, `y`.pub fn paneAt(t: *const Tab, x: f32, y: f32) ?*Pane { var buf: [max_panes]*Pane = undefined; for (t.panes(&buf)) |p| if (!p.rect.empty() and p.rect.contains(x, y)) return p; return null;}
/// The divider at `x`, `y`, give or take `grab` pixels either side.pub fn dividerAt(t: *Tab, x: f32, y: f32, grab: u32) ?*Split { if (t.zoomed) return null; return dividerIn(t.root, x, y, grab);}
fn dividerIn(node: *Node, x: f32, y: f32, grab: u32) ?*Split { switch (node.*) { .pane => return null, .split => |*s| { var d = s.divider; switch (s.axis) { .horizontal => { d.x -|= grab; d.w += 2 * grab; }, .vertical => { d.y -|= grab; d.h += 2 * grab; }, } if (d.contains(x, y)) return s; return dividerIn(s.a, x, y, grab) orelse dividerIn(s.b, x, y, grab); }, }}
/// Moves a divider to put it under the pointer at `x`, `y`.pub fn dragDivider(s: *Split, x: f32, y: f32, gap: u32) void { const along: f32, const start: f32, const len: f32 = switch (s.axis) { .horizontal => .{ x, @floatFromInt(s.rect.x), @floatFromInt(s.rect.w -| gap) }, .vertical => .{ y, @floatFromInt(s.rect.y), @floatFromInt(s.rect.h -| gap) }, }; if (len <= 0) return; s.ratio = std.math.clamp((along - start - @as(f32, @floatFromInt(gap)) / 2) / len, 0.02, 0.98);}
/// The pane next to `from` in direction `dir`: the nearest one on that/// side that lines up with it, preferring the one level with its middle.pub fn neighbor(t: *const Tab, from: *const Pane, dir: Direction) ?*Pane { if (t.zoomed) return null; var buf: [max_panes]*Pane = undefined; const f = from.rect; const fx: i64 = f.x; const fy: i64 = f.y; const fr: i64 = f.x + f.w; const fb: i64 = f.y + f.h; const mid_x = fx + @divTrunc(fr - fx, 2); const mid_y = fy + @divTrunc(fb - fy, 2); var best: ?*Pane = null; var best_score: i64 = std.math.maxInt(i64); for (t.panes(&buf)) |p| { if (p == from or p.rect.empty()) continue; const r = p.rect; const x: i64 = r.x; const y: i64 = r.y; const right: i64 = r.x + r.w; const bottom: i64 = r.y + r.h; // How far away it is in the direction, and whether it lines up. const gap: i64, const overlaps: bool, const off_center: i64 = switch (dir) { .right => .{ x - fr, y < fb and bottom > fy, distance(mid_y, y, bottom) }, .left => .{ fx - right, y < fb and bottom > fy, distance(mid_y, y, bottom) }, .down => .{ y - fb, x < fr and right > fx, distance(mid_x, x, right) }, .up => .{ fy - bottom, x < fr and right > fx, distance(mid_x, x, right) }, }; if (gap < 0 or !overlaps) continue; const score = gap * 100_000 + off_center; if (score < best_score) { best_score = score; best = p; } } return best;}
/// How far `v` is from the span `lo`..`hi`: zero inside it.fn distance(v: i64, lo: i64, hi: i64) i64 { if (v < lo) return lo - v; if (v >= hi) return v - hi + 1; return 0;}
/// Moves the divider nearest `pane` along the axis of `dir`, `amount`/// pixels in that direction. False when there is no such divider.pub fn resize(t: *Tab, pane: *const Pane, dir: Direction, amount: u32) bool { if (t.zoomed) return false; const s = nearestSplit(t.root, pane, dir.axis()) orelse return false; const len: f32 = @floatFromInt(switch (s.axis) { .horizontal => s.rect.w, .vertical => s.rect.h, }); if (len <= 0) return false; const delta = @as(f32, @floatFromInt(amount)) / len; s.ratio = std.math.clamp(switch (dir) { .right, .down => s.ratio + delta, .left, .up => s.ratio - delta, }, 0.02, 0.98); return true;}
/// The innermost split along `axis` with `pane` in it.fn nearestSplit(node: *Node, pane: *const Pane, axis: Axis) ?*Split { switch (node.*) { .pane => return null, .split => |*s| { const inner = nearestSplit(s.a, pane, axis) orelse nearestSplit(s.b, pane, axis); if (inner) |i| return i; if (s.axis == axis and (leafOf(s.a, pane) != null or leafOf(s.b, pane) != null)) return s; return null; }, }}
/// Every split's ratio, so that panes in a row of splits along the same/// axis all get the same share.pub fn equalize(t: *Tab) void { equalizeNode(t.root);}
fn equalizeNode(node: *Node) void { switch (node.*) { .pane => {}, .split => |*s| { const a = weight(s.a, s.axis); const b = weight(s.b, s.axis); s.ratio = @as(f32, @floatFromInt(a)) / @as(f32, @floatFromInt(a + b)); equalizeNode(s.a); equalizeNode(s.b); }, }}
/// How many panes `node` puts side by side along `axis`.fn weight(node: *const Node, axis: Axis) u32 { return switch (node.*) { .pane => 1, .split => |s| if (s.axis == axis) weight(s.a, axis) + weight(s.b, axis) else @max(weight(s.a, axis), weight(s.b, axis)), };}
// ---------------------------------------------------------------------------
const testing = std.testing;
/// Panes that are only places in a tree: nothing in this file looks inside/// one but its rect.fn testPanes(comptime n: usize) [n]Pane { var ps: [n]Pane = undefined; for (&ps) |*p| p.rect = .{}; return ps;}
fn testTab(p: *Pane) !*Tab { return createWith(testing.allocator, p);}
fn freeTestTab(t: *Tab) void { t.freeNodes(t.root); t.gpa.destroy(t);}
test "a split puts the new pane right of or below the old, and focuses it" { var ps = testPanes(3); const t = try testTab(&ps[0]); defer freeTestTab(t); try t.split(&ps[0], .horizontal, &ps[1]); try t.split(&ps[1], .vertical, &ps[2]); try testing.expectEqual(&ps[2], t.focused); t.layout(.{ .x = 0, .y = 0, .w = 801, .h = 601 }, 1, 10, 10); // Left half, then the right half one above the other. try testing.expectEqual(Rect{ .x = 0, .y = 0, .w = 400, .h = 601 }, ps[0].rect); try testing.expectEqual(Rect{ .x = 401, .y = 0, .w = 400, .h = 300 }, ps[1].rect); try testing.expectEqual(Rect{ .x = 401, .y = 301, .w = 400, .h = 300 }, ps[2].rect); var buf: [max_panes]*Pane = undefined; try testing.expectEqualSlices(*Pane, &.{ &ps[0], &ps[1], &ps[2] }, t.panes(&buf));}
test "closing a pane gives its place to its sibling, and the focus beside it" { var ps = testPanes(3); const t = try testTab(&ps[0]); defer freeTestTab(t); try t.split(&ps[0], .horizontal, &ps[1]); try t.split(&ps[1], .vertical, &ps[2]); try testing.expect(!t.remove(&ps[2])); try testing.expectEqual(&ps[1], t.focused); t.layout(.{ .x = 0, .y = 0, .w = 801, .h = 600 }, 1, 10, 10); try testing.expectEqual(Rect{ .x = 401, .y = 0, .w = 400, .h = 600 }, ps[1].rect); t.focused = &ps[0]; try testing.expect(!t.remove(&ps[0])); try testing.expectEqual(&ps[1], t.focused); try testing.expectEqual(@as(usize, 1), t.count()); // The last one leaves the tab empty. try testing.expect(t.remove(&ps[1]));}
test "neighbours by direction, preferring the one level with the middle" { var ps = testPanes(4); const t = try testTab(&ps[0]); defer freeTestTab(t); // 0 | (1 over 2 over 3) try t.split(&ps[0], .horizontal, &ps[1]); try t.split(&ps[1], .vertical, &ps[2]); try t.split(&ps[2], .vertical, &ps[3]); t.layout(.{ .x = 0, .y = 0, .w = 800, .h = 600 }, 0, 10, 10); try testing.expectEqual(@as(?*Pane, &ps[2]), t.neighbor(&ps[0], .right)); try testing.expectEqual(@as(?*Pane, &ps[0]), t.neighbor(&ps[3], .left)); try testing.expectEqual(@as(?*Pane, &ps[2]), t.neighbor(&ps[1], .down)); try testing.expectEqual(@as(?*Pane, &ps[1]), t.neighbor(&ps[2], .up)); try testing.expectEqual(@as(?*Pane, null), t.neighbor(&ps[0], .left)); try testing.expectEqual(@as(?*Pane, null), t.neighbor(&ps[3], .down));}
test "the pointer finds panes and dividers, and dragging a divider moves it" { var ps = testPanes(2); const t = try testTab(&ps[0]); defer freeTestTab(t); try t.split(&ps[0], .horizontal, &ps[1]); t.layout(.{ .x = 0, .y = 0, .w = 802, .h = 600 }, 2, 10, 10); try testing.expectEqual(@as(?*Pane, &ps[0]), t.paneAt(10, 10)); try testing.expectEqual(@as(?*Pane, &ps[1]), t.paneAt(700, 10)); const d = t.dividerAt(401, 300, 3).?; try testing.expectEqual(@as(?*Split, null), t.dividerAt(380, 300, 3)); dragDivider(d, 201, 300, 2); t.layout(t.area, 2, 10, 10); try testing.expectEqual(@as(u32, 200), ps[0].rect.w);}
test "resizing moves the nearest divider along the axis, within limits" { var ps = testPanes(3); const t = try testTab(&ps[0]); defer freeTestTab(t); try t.split(&ps[0], .horizontal, &ps[1]); try t.split(&ps[1], .vertical, &ps[2]); t.layout(.{ .x = 0, .y = 0, .w = 1000, .h = 1000 }, 0, 50, 50); try testing.expect(t.resize(&ps[2], .left, 100)); t.layout(t.area, 0, 50, 50); try testing.expectEqual(@as(u32, 400), ps[0].rect.w); try testing.expect(t.resize(&ps[2], .up, 100)); t.layout(t.area, 0, 50, 50); try testing.expectEqual(@as(u32, 400), ps[1].rect.h); // Pane 0 has no vertical split to move. try testing.expect(!t.resize(&ps[0], .up, 100)); // However far, never past the smallest a pane may be. try testing.expect(t.resize(&ps[2], .left, 5000)); t.layout(t.area, 0, 50, 50); try testing.expectEqual(@as(u32, 50), ps[0].rect.w);}
test "equalizing gives a row of panes equal shares" { var ps = testPanes(3); const t = try testTab(&ps[0]); defer freeTestTab(t); try t.split(&ps[0], .horizontal, &ps[1]); try t.split(&ps[1], .horizontal, &ps[2]); t.equalize(); t.layout(.{ .x = 0, .y = 0, .w = 900, .h = 300 }, 0, 10, 10); try testing.expectEqual(@as(u32, 300), ps[0].rect.w); try testing.expectEqual(@as(u32, 300), ps[1].rect.w); try testing.expectEqual(@as(u32, 300), ps[2].rect.w);}
test "a pane moved beside another takes half its place" { var ps = testPanes(3); const t = try testTab(&ps[0]); defer freeTestTab(t); // 0 | (1 over 2): 2 moved to the left of 0 makes (2 | 0) | 1. try t.split(&ps[0], .horizontal, &ps[1]); try t.split(&ps[1], .vertical, &ps[2]); t.move(&ps[2], &ps[0], .left); t.layout(.{ .x = 0, .y = 0, .w = 800, .h = 600 }, 0, 10, 10); try testing.expectEqual(Rect{ .x = 0, .y = 0, .w = 200, .h = 600 }, ps[2].rect); try testing.expectEqual(Rect{ .x = 200, .y = 0, .w = 200, .h = 600 }, ps[0].rect); try testing.expectEqual(Rect{ .x = 400, .y = 0, .w = 400, .h = 600 }, ps[1].rect); // And 0 moved below 1: 2 | (1 over 0). t.move(&ps[0], &ps[1], .down); t.layout(t.area, 0, 10, 10); try testing.expectEqual(Rect{ .x = 0, .y = 0, .w = 400, .h = 600 }, ps[2].rect); try testing.expectEqual(Rect{ .x = 400, .y = 0, .w = 400, .h = 300 }, ps[1].rect); try testing.expectEqual(Rect{ .x = 400, .y = 300, .w = 400, .h = 300 }, ps[0].rect); try testing.expectEqual(@as(usize, 3), t.count()); // Onto its own sibling, the other way round. t.move(&ps[2], &ps[1], .right); t.layout(t.area, 0, 10, 10); var buf: [max_panes]*Pane = undefined; try testing.expectEqualSlices(*Pane, &.{ &ps[1], &ps[2], &ps[0] }, t.panes(&buf)); // Onto itself, nothing. t.move(&ps[0], &ps[0], .left); try testing.expectEqualSlices(*Pane, &.{ &ps[1], &ps[2], &ps[0] }, t.panes(&buf));}
test "a pane alone in its tab has nowhere to go" { var ps = testPanes(2); const t = try testTab(&ps[0]); defer freeTestTab(t); t.move(&ps[0], &ps[1], .left); try testing.expectEqual(@as(usize, 1), t.count());}
test "the side a drop goes to is the nearest edge, in proportion" { const r: Rect = .{ .x = 100, .y = 100, .w = 400, .h = 200 }; try testing.expectEqual(Direction.left, dropSide(r, 110, 200)); try testing.expectEqual(Direction.right, dropSide(r, 490, 200)); try testing.expectEqual(Direction.up, dropSide(r, 300, 110)); try testing.expectEqual(Direction.down, dropSide(r, 300, 290)); // A quarter of the way across and a third of the way down: nearer the // left, in proportion, though nearer the top in pixels. try testing.expectEqual(Direction.left, dropSide(r, 180, 166));}
test "zooming fills the tab with the focused pane and hides the rest" { var ps = testPanes(2); const t = try testTab(&ps[0]); defer freeTestTab(t); try t.split(&ps[0], .horizontal, &ps[1]); t.zoomed = true; t.layout(.{ .x = 5, .y = 5, .w = 800, .h = 600 }, 1, 10, 10); try testing.expectEqual(Rect{ .x = 5, .y = 5, .w = 800, .h = 600 }, ps[1].rect); try testing.expect(ps[0].rect.empty()); try testing.expectEqual(@as(?*Pane, null), t.neighbor(&ps[1], .left)); try testing.expectEqual(@as(?*Split, null), t.dividerAt(400, 300, 3));}