a73x

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