src/tui/wall_layout.zig
Ref: Size: 21.9 KiB History
//! The pane tree's operations and the layout file. `relayout` is the
//! single flatten point that turns the tree into tile rects and paints the
//! rails; `persist` writes that tree after every change to it and
//! `seedLayout` seats it back verbatim, refusing a file it cannot seat
//! whole.
const std = @import("std");
const proto = @import("term").protocol;
const hosts = @import("client").hosts;
const interact = @import("interact.zig");
const layout = @import("client").layout;
const wall_host = @import("wall_host.zig");
const wv = @import("wallview.zig");
const Host = wall_host.Host;
const Shared = wv.Shared;
const Tile = wv.Tile;
const Wall = wv.Wall;
/// The daemon's row floor plus the label-bar arithmetic. The bar follows
/// the tty, not the tile count (`relayout`), so the caller says whether one
/// is drawn: under a bar each stripe must hold the floor PLUS that row, or
/// the daemon drops the resize and the tile freezes on a stale grid.
pub fn wallFloors(bar: bool) layout.Floors {
return .{
.rows = proto.min_session_rows + @as(u16, @intFromBool(bar)),
.cols = proto.min_session_cols,
};
}
/// The floors for THIS wall: `wallFloors` keyed by the one bar rule,
/// `Shared.labelRows`, so a production call site cannot key the floor off
/// anything else and cut stripes the painted bars then overflow.
pub fn floorsOf(shared: *const Shared) layout.Floors {
return wallFloors(shared.labelRows() != 0);
}
/// The root container's orientation for N tiles at a given terminal size:
/// `.beside` when the terminal is wide enough that columns are the natural
/// cut, `.stacked` otherwise. The 2x corrects for cell shape — a terminal
/// twice as wide as it is tall has roughly square panes side-by-side.
pub fn rootOrient(size: proto.Size) layout.Orient {
return if (size.cols >= 2 * size.rows) .beside else .stacked;
}
/// `interact.Dir` and `layout.Dir` are the same enum tags in different
/// modules; this is the one place they meet, so the switch stays explicit.
pub fn dirOf(d: interact.PrefixFilter.Dir) layout.Dir {
return switch (d) {
.left => .left,
.down => .down,
.up => .up,
.right => .right,
};
}
/// layout.resize always GAINS focus cells; shrink = grow a neighbor at
/// focus's expense. Fullscreen refuses — a hidden layout resizing
/// invisibly is surprise, not power.
pub fn doResize(w: Wall, sel: usize, d: interact.PrefixFilter.Dir) bool {
if (w.shared.fullscreen) return false;
const ld = dirOf(d);
const grow = switch (ld) {
.right, .down => true,
.left, .up => false,
};
const flat = w.shared.base_flat orelse w.shared.last_flat orelse return false;
const focus_tile: u8 = @intCast(sel);
var moved = false;
if (grow) {
// Focus gains from the sibling toward `ld`; if none there (edge
// pane), try the opposite side — gaining from either sibling
// widens or tallens the focus.
if (w.shared.tree.resize(w.alloc, w.shared.size.rows, w.shared.size.cols, floorsOf(w.shared), focus_tile, ld, 1)) {
moved = true;
} else {
const opp = switch (ld) {
.right => layout.Dir.left,
.down => layout.Dir.up,
.left => layout.Dir.right,
.up => layout.Dir.down,
};
moved = w.shared.tree.resize(w.alloc, w.shared.size.rows, w.shared.size.cols, floorsOf(w.shared), focus_tile, opp, 1);
}
} else {
// Shrink: a neighbor on the same axis gains a cell from focus.
// Try the side the key points at first, then the opposite — a
// pane pressed against one wall can still shrink toward the other.
const opp = switch (ld) {
.left => layout.Dir.right,
.up => layout.Dir.down,
.right => layout.Dir.left,
.down => layout.Dir.up,
};
if (layout.neighbor(flat, focus_tile, ld)) |nb| {
moved = w.shared.tree.resize(w.alloc, w.shared.size.rows, w.shared.size.cols, floorsOf(w.shared), nb, opp, 1);
}
if (!moved) {
if (layout.neighbor(flat, focus_tile, opp)) |nb| {
moved = w.shared.tree.resize(w.alloc, w.shared.size.rows, w.shared.size.cols, floorsOf(w.shared), nb, ld, 1);
}
}
}
if (moved) relayout(w, sel);
return moved;
}
/// Paint the vertical rails between `.beside` siblings. One column of
/// `\x1b[7m \x1b[0m` per rail — a reverse-video bar in the label-bar's
/// style, so a rail reads as structure and not as session output. The wall
/// owns rails; tiles never touch them (paint.zig's span-bounded clears).
fn paintRailsLocked(shared: *Shared, flat: layout.Flat) void {
if (!shared.is_tty) return;
if (flat.rails.len == 0) return;
var buf: [128]u8 = undefined;
for (flat.rails) |rail| {
var row: u16 = rail.top;
while (row < rail.top + rail.rows) : (row += 1) {
const out = std.fmt.bufPrint(&buf, "\x1b[{d};{d}H\x1b[7m \x1b[0m", .{ row + 1, rail.col + 1 }) catch continue;
proto.writeAllFd(shared.out_fd, out) catch {};
}
}
}
/// One `paint_mu` hold: no window where a pump paints rows that just
/// changed owner.
pub fn relayout(w: Wall, sel: usize) void {
w.shared.paint_mu.lock();
defer w.shared.paint_mu.unlock();
w.shared.sel = sel;
var live: usize = 0;
for (w.livePresent()) |p| {
if (p) live += 1;
}
if (w.shared.is_tty) proto.writeAllFd(w.shared.out_fd, "\x1b[?25l\x1b[H\x1b[2J") catch {};
// The screen the popup was on has just been cleared, so the next paint
// owes it however unchanged its rows are — and owes no clear for the
// box that was there, whose rows this clear has already taken.
if (w.shared.picker_open.load(.acquire)) w.shared.picker_frame = .{};
if (live == 0) {
wv.paintEmptyWallLocked(w.shared);
return;
}
// The base flat (null) is always computed so `focus_dir` can read
// adjacency from the real layout while fullscreened.
if (w.shared.tree.flatten(w.alloc, w.shared.size.rows, w.shared.size.cols, floorsOf(w.shared), null)) |base| {
if (w.shared.base_flat) |*old| old.deinit(w.shared.flat_alloc);
w.shared.base_flat = base;
} else |_| {}
const fs_arg: ?u8 = if (w.shared.fullscreen) @intCast(sel) else null;
var cut = w.shared.tree.flatten(w.alloc, w.shared.size.rows, w.shared.size.cols, floorsOf(w.shared), fs_arg);
if (cut) |_| {} else |e| {
// A split or a resize key is an OPERATION the user asked for, and
// refusing leaves the screen as it was. A SIGWINCH is not: the terminal
// has ALREADY shrunk, so refusing leaves every rect pointing past the
// bottom of a cleared screen. Degrade to the focused tile whole and the
// rest at 0x0. The TREE is untouched, so growing back re-cuts.
if (e == error.TooSmall and fs_arg == null) {
if (w.shared.tree.flatten(w.alloc, w.shared.size.rows, w.shared.size.cols, floorsOf(w.shared), @intCast(sel))) |only| {
cut = only;
} else |_| {}
}
}
if (cut) |flat| {
if (w.shared.last_flat) |*old| old.deinit(w.shared.flat_alloc);
w.shared.last_flat = flat;
for (w.liveTiles(), w.livePresent()) |*t, p| {
if (!p) continue;
if (flat.rectOf(@intCast(t.idx))) |r| {
t.rect = r;
}
t.resize_pending = true;
}
paintRailsLocked(w.shared, flat);
} else |_| {}
// The generation bump is what puts the rects back: every surviving
// pump repaints from its hot replica at its NEW rows, and the doorbell
// makes that immediate rather than one poll timeout away.
_ = w.shared.repaint_gen.fetchAdd(1, .release);
for (w.liveTiles(), w.livePresent()) |*t, p| {
if (p) wv.ring(t);
}
// ...except the tiles with no pump left to hear it: their bars are the
// keyboard's. No `labelRows` guard, unlike `setFocus` — the screen was
// just cleared, and that bar is all that says the target refused.
wv.paintDeadBarsLocked(w.liveTiles());
}
/// The file half of the seed: `.none` on a pipe or a missing file, and
/// otherwise whatever `seedLayout` made of the bytes. The VERDICT is
/// returned, not flattened to a plan-or-nothing, because the caller has to
/// tell a file it refused from a file that was not there: a refused file is
/// still the user's wall, and a run that wrote its own one-leaf tree over
/// it would destroy the thing the printed line asked them to fix.
/// `layout_path` is the gate, the same one `persist` reads: a wall that
/// writes no file must not seed from one either, or the next start would
/// restore a shape this run has already stopped recording.
///
/// The refused line is COPIED into `line_buf`: it points into bytes this
/// frees, and the caller says it again on the notice line much later.
pub fn seedSidecar(
alloc: std.mem.Allocator,
table: []const Host,
shared: *Shared,
entry_spelling: ?[]const u8,
line_buf: *[96]u8,
) SeedResult {
const path = shared.layout_path orelse return .none;
const bytes = loadLayout(alloc, path) orelse return .none;
defer alloc.free(bytes);
return switch (seedLayout(alloc, table, shared, bytes, entry_spelling)) {
.plan => |p| .{ .plan = p },
.refused => |line| blk: {
// Said once, on stderr, before the alternate screen: the line is
// the thing to fix or delete, and stderr is the only place a
// whole one fits. The notice the caller sets from the copy is
// the same sentence cut to the wall's 96 bytes.
std.debug.print("mux: layout ignored ({s}): {s}\n", .{ path, line });
const n = @min(line.len, line_buf.len);
@memcpy(line_buf[0..n], line[0..n]);
break :blk .{ .refused = line_buf[0..n] };
},
.self_only => .self_only,
.none => .none,
};
}
/// Every failure is the same null: a caller degrades the same way whatever
/// kept the layout from arriving.
pub fn loadLayout(alloc: std.mem.Allocator, path: []const u8) ?[]u8 {
return std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024) catch null;
}
/// `serialize` indexes spellings by leaf ID (tile index), so the array is
/// tile-indexed: holes get "" and are never serialized (the tree dropped
/// them). A failure is RETURNED, never printed: every save runs while the
/// wall owns the alternate screen, so the caller is the only one who knows
/// where a sentence can safely go.
pub fn saveLayoutTo(
alloc: std.mem.Allocator,
path: []const u8,
tiles: []Tile,
present: []const bool,
shared: *Shared,
) !void {
const spellings = try alloc.alloc([]const u8, tiles.len);
defer alloc.free(spellings);
for (tiles, present, 0..) |*t, p, i| {
spellings[i] = if (p) t.r.label else "";
}
var buf = std.ArrayListUnmanaged(u8){};
defer buf.deinit(alloc);
// shared.sel is the focused tile index; serialize maps it to the
// encounter index of its leaf in the depth-first walk.
const focus: ?u8 = if (shared.sel < tiles.len and present[shared.sel]) @intCast(shared.sel) else null;
try shared.tree.serialize(spellings, focus, buf.writer(alloc));
try hosts.saveBytes(path, buf.items);
}
/// The one save path. Every change to the pane set or the tree comes
/// through here — a birth, a removal, a split, a resize, a detach — so two
/// terminals on one device see each other's adds on their next start, and
/// a wall that crashes loses nothing it committed.
pub fn persist(w: Wall) void {
const path = w.shared.layout_path orelse return;
saveLayoutTo(w.alloc, path, w.liveTiles(), w.livePresent(), w.shared) catch |err| {
// Said on the notice line and nowhere else. A save runs on every
// change to the wall, all of them under the alternate screen, so a
// stderr line here would print into the middle of whichever pane
// the cursor happened to be in and stay there until a repaint.
var why: [96]u8 = undefined;
wv.setNotice(w.shared, std.fmt.bufPrint(&why, "[layout not saved: {s}]", .{@errorName(err)}) catch
"[layout not saved]");
};
}
pub const SeedPane = struct { host: usize, session: []u8, label: []u8 };
const SeedKeep = struct { saved: usize, host: usize };
/// What a seeded wall is made of, index = tile index. A null pane is the
/// ENTRY tile's own slot: real from birth, never pending.
pub const SeedPlan = struct {
panes: []?SeedPane,
focus: ?usize,
/// Leaves the file named that this wall does not seat: the session
/// this `mux` runs inside, and whatever the terminal was too small to
/// cut. Every OTHER disagreement with the file is a refusal, so this
/// counts only what the wall chose to leave out. Nonzero is what stops
/// `run` writing the file back — see `Shared.layout_path`.
dropped: usize,
/// How many of `dropped` are the session this `mux` runs inside; the
/// rest are panes the terminal could not cut. The two are counted
/// apart because they are different sentences to say: a wall that
/// blamed the terminal's size for a shell's own stripe would send the
/// user resizing a window that was never the problem.
dropped_self: usize,
pub fn deinit(self: *SeedPlan, alloc: std.mem.Allocator) void {
for (self.panes) |mp| {
if (mp) |pane| {
alloc.free(pane.session);
alloc.free(pane.label);
}
}
alloc.free(self.panes);
}
};
pub const SeedResult = union(enum) {
plan: SeedPlan,
/// The file is not a wall: the first offending line, borrowed from
/// `bytes`, for the caller to print. The wall then starts as if the
/// file were missing.
refused: []const u8,
/// Every leaf named the session this `mux` runs inside, and a wall may
/// not attach to itself. Apart from `.none` because the file is good
/// and the user authored it: the run degrades to the default cut like
/// any other, but writing this run's tree back would replace their wall
/// with one leaf.
self_only,
/// No file, no leaves at all, or a file with no leaves this wall can
/// seat at this size.
none,
};
/// The layout is authored: every leaf is a pane, in the saved tree. The
/// only things that keep a leaf off the wall are the session this `mux`
/// runs inside (a wall may not attach to itself) and a terminal too small
/// for the whole tree, which trims from the end. Anything else wrong with
/// the file — a host the hosts file does not list, a leaf with no session
/// or a bad name, a repeated leaf, more leaves than the wall seats, or
/// text that is not a layout — refuses the FILE, with the first bad line,
/// because silently seating part of a wall is how a user loses one.
pub fn seedLayout(
alloc: std.mem.Allocator,
table: []const Host,
shared: *Shared,
bytes: []const u8,
entry_spelling: ?[]const u8,
) SeedResult {
// An empty file is not a bad file: nothing was authored, so there is
// nothing to name in a refusal and nothing for the user to fix.
if (std.mem.trim(u8, bytes, " \t\r\n").len == 0) return .none;
// The line the parse gave up on, not the header: a file can be a wall
// down to its last line and still be refused by it.
var bad_line: []const u8 = "";
var probe = layout.parseReporting(alloc, bytes, &bad_line) orelse return .{ .refused = bad_line };
defer probe.deinit(alloc);
var keeps = std.ArrayListUnmanaged(SeedKeep){};
defer keeps.deinit(alloc);
var entry_at: ?usize = null;
var dropped: usize = 0;
for (probe.spellings.items, 0..) |sp, i| {
if (entry_spelling) |es| {
if (entry_at == null and std.mem.eql(u8, sp, es)) {
entry_at = i;
continue;
}
// A second leaf spelling the entry is a repeated leaf like any
// other: only the first can be the tile the user is typing in.
if (std.mem.eql(u8, sp, es)) return .{ .refused = inFile(bytes, sp) };
}
const cut = std.mem.lastIndexOfScalar(u8, sp, '#') orelse return .{ .refused = inFile(bytes, sp) };
const sess = sp[cut + 1 ..];
if (!proto.validSessionName(sess)) return .{ .refused = inFile(bytes, sp) };
// The host part must be ON the wall, byte for byte: the file is a
// match key, never an address, so nothing here can be resurrected.
const hi = for (table, 0..) |*h, j| {
if (std.mem.eql(u8, h.spec.spelling, sp[0..cut])) break j;
} else return .{ .refused = inFile(bytes, sp) };
// The shell's own session would wait forever: never birthed
// (`showsSelf`) and named by every list, so never collapsed.
if (table[hi].self_name) |self| {
if (std.mem.eql(u8, self, sess)) {
dropped += 1;
continue;
}
}
// A host's list names a session once; a second pane on the same
// (host, session) could never bind.
for (keeps.items) |k| {
if (std.mem.eql(u8, probe.spellings.items[k.saved], sp)) return .{ .refused = inFile(bytes, sp) };
}
keeps.append(alloc, .{ .saved = i, .host = hi }) catch return .none;
}
const base: usize = if (entry_spelling != null) 1 else 0;
if (base + keeps.items.len > wv.max_tiles)
return .{ .refused = inFile(bytes, probe.spellings.items[keeps.items[wv.max_tiles - base].saved]) };
// Nothing to seat and no entry tile: `.self_only` when the shell's own
// session is the reason, so the caller can stop saving over a file that
// is not wrong, just unseatable from inside one of its own panes.
if (keeps.items.len == 0 and entry_at == null) return if (dropped > 0) .self_only else .none;
// A tree cut on a big screen must not refuse the boot on a laptop:
// drop the last pane and retry until the terminal can hold what is
// left. The floor is a wall of ONE — the entry tile's leaf when the
// file named it, the first saved pane otherwise — because an empty
// tree is not a wall, and seating none of them is the default cut.
const floor: usize = if (entry_at != null) 0 else 1;
var n = keeps.items.len;
while (n >= floor) : (n -= 1) {
if (seedAttempt(alloc, shared, bytes, entry_at, keeps.items[0..n], base)) |plan| {
var out = plan;
out.dropped = dropped + (keeps.items.len - n);
out.dropped_self = dropped;
return .{ .plan = out };
}
if (n == 0) break;
}
return .none;
}
/// The same text, in the FILE's own bytes. A refusal outlives the parse
/// that found it — `probe` owns its spellings and frees them on the way
/// out — while `bytes` belongs to the caller that is about to print.
fn inFile(bytes: []const u8, spelling: []const u8) []const u8 {
const at = std.mem.indexOf(u8, bytes, spelling) orelse return layout.firstNonBlankLine(bytes);
return bytes[at..][0..spelling.len];
}
/// One sizing attempt over the first `keeps` panes; the tree is
/// installed only if the terminal can cut it.
fn seedAttempt(
alloc: std.mem.Allocator,
shared: *Shared,
bytes: []const u8,
entry_at: ?usize,
keeps: []const SeedKeep,
base: usize,
) ?SeedPlan {
var parsed = layout.parse(alloc, bytes) orelse return null;
const map = alloc.alloc(?u8, parsed.spellings.items.len) catch {
parsed.deinit(alloc);
return null;
};
defer alloc.free(map);
@memset(map, null);
if (entry_at) |e| map[e] = 0;
for (keeps, 0..) |k, i| map[k.saved] = @intCast(base + i);
var focus: ?usize = null;
if (parsed.focus) |k| {
if (k < map.len) {
if (map[k]) |f| focus = f;
}
}
const total = base + keeps.len;
var plan: SeedPlan = .{
.panes = alloc.alloc(?SeedPane, total) catch {
parsed.deinit(alloc);
return null;
},
.focus = focus,
.dropped = 0,
.dropped_self = 0,
};
@memset(plan.panes, null);
// Duped: a wall tile owns its two copies, and the birth that reuses a
// collapsed pane's digit frees them with the wall's allocator.
const ok = blk: {
for (keeps, 0..) |k, i| {
const sp = parsed.spellings.items[k.saved];
const cut = std.mem.lastIndexOfScalar(u8, sp, '#').?;
const sess = alloc.dupe(u8, sp[cut + 1 ..]) catch break :blk false;
const label = alloc.dupe(u8, sp) catch {
alloc.free(sess);
break :blk false;
};
plan.panes[base + i] = .{ .host = k.host, .session = sess, .label = label };
}
break :blk true;
};
if (!ok) {
plan.deinit(alloc);
parsed.deinit(alloc);
return null;
}
parsed.tree.remapLeaves(map) catch {
plan.deinit(alloc);
parsed.deinit(alloc);
return null;
};
if (base == 1 and entry_at == null) {
const anchor: u8 = if (focus) |f| @intCast(f) else @intCast(base);
parsed.tree.insert(anchor, 0) catch {
plan.deinit(alloc);
parsed.deinit(alloc);
return null;
};
}
if (parsed.tree.flatten(alloc, shared.size.rows, shared.size.cols, floorsOf(shared), null)) |flat| {
var f = flat;
f.deinit(alloc);
} else |_| {
plan.deinit(alloc);
parsed.deinit(alloc);
return null;
}
shared.tree.deinit();
shared.tree = parsed.tree;
for (parsed.spellings.items) |sp| alloc.free(sp);
parsed.spellings.deinit(alloc);
return plan;
}
/// The leaf a birth sits beside: `birthTile` inserts against a LEAF, and
/// the focus can be a hole.
pub fn anchorTile(present: []const bool, sel: usize) usize {
if (sel < present.len and present[sel]) return sel;
return firstPresent(present) orelse 0;
}
pub fn firstPresent(present: []const bool) ?usize {
for (present, 0..) |p, i| {
if (p) return i;
}
return null;
}