/** * Pure, framework-free layout logic for the spreadsheet-like terminal grid * (L4). No React, no Tauri here — these functions are the TS mirror of the * domain's pure `LayoutTree` operations (ARCHITECTURE §1.3, §7), so they are * trivially unit-testable (Vitest) and reusable by the mock gateway. * * The size calculation (`normalizeWeights`) is the load-bearing pure function * the spec calls out: it turns a list of relative weights into rendering * percentages, independent of any DOM. */ import type { Direction, LayoutNode, LayoutOperation, LayoutTree, LeafCell, SplitContainer, WeightedChild, } from "@/domain"; /** A simple UUID v4 generator (mock/UI side; the backend mints its own ids). */ export function newId(): string { // crypto.randomUUID is available in modern browsers and jsdom. if (typeof crypto !== "undefined" && "randomUUID" in crypto) { return crypto.randomUUID(); } // Fallback (deterministic enough for non-crypto UI ids). return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0; const v = c === "x" ? r : (r & 0x3) | 0x8; return v.toString(16); }); } /** A single empty leaf tree — the default layout (one cell, no session). */ export function singleLeafTree(id: string = newId()): LayoutTree { return { root: { type: "leaf", node: { id, session: null } } }; } /** * Normalises relative weights into rendering percentages that sum to ~100. * * Pure and total: an empty list yields `[]`; non-positive/NaN weights are * floored to a tiny epsilon so a malformed tree still renders rather than * collapsing a track to zero. This is the function the grid component uses to * size each track (`grid-template-*` / flex-basis). */ export function normalizeWeights(weights: number[]): number[] { if (weights.length === 0) return []; const EPS = 1e-6; const safe = weights.map((w) => (Number.isFinite(w) && w > 0 ? w : EPS)); const total = safe.reduce((a, b) => a + b, 0); if (total <= 0) { const even = 100 / safe.length; return safe.map(() => even); } return safe.map((w) => (w / total) * 100); } /** * Computes new weights for a split after dragging the separator between * children `index` and `index + 1` by `deltaFraction` (a signed fraction of the * container's full extent, e.g. `+0.05` = grow the left/top child by 5%). * * Pure: only the two adjacent tracks are affected; both stay strictly positive * (clamped to a small minimum). Returns a fresh array. */ export function resizeAdjacent( weights: number[], index: number, deltaFraction: number, ): number[] { const next = [...weights]; if (index < 0 || index + 1 >= next.length) return next; const total = next.reduce((a, b) => a + b, 0); const delta = deltaFraction * total; const MIN = total * 0.05; const a = next[index] + delta; const b = next[index + 1] - delta; if (a < MIN || b < MIN) return next; // refuse to crush a track next[index] = a; next[index + 1] = b; return next; } /** Recursively rebuilds a node, applying `f` (post-order, like the domain). */ function mapNode(node: LayoutNode, f: (n: LayoutNode) => LayoutNode): LayoutNode { let rebuilt: LayoutNode; switch (node.type) { case "leaf": rebuilt = node; break; case "split": rebuilt = { type: "split", node: { ...node.node, children: node.node.children.map((c) => ({ node: mapNode(c.node, f), weight: c.weight, })), }, }; break; case "grid": rebuilt = { type: "grid", node: { ...node.node, cells: node.node.cells.map((c) => ({ ...c, node: mapNode(c.node, f), })), }, }; break; } return f(rebuilt); } /** Finds the session hosted by leaf `id`, or `undefined` if no such leaf. */ function sessionInLeaf(node: LayoutNode, id: string): string | null | undefined { let found: string | null | undefined; mapNode(node, (n) => { if (n.type === "leaf" && n.node.id === id) found = n.node.session ?? null; return n; }); return found; } /** * Applies a {@link LayoutOperation} to a tree, returning a **new** tree (pure, * immutable). Mirrors the domain operations so the mock gateway behaves like the * real backend offline. Throws on an invalid operation (unknown node, etc.). */ export function applyOperation( tree: LayoutTree, op: LayoutOperation, ): LayoutTree { switch (op.type) { case "split": { let found = false; const root = mapNode(tree.root, (n) => { if (n.type === "leaf" && n.node.id === op.target) { found = true; const newLeaf: LeafCell = { id: op.newLeaf, session: null }; const container: SplitContainer = { id: op.container, direction: op.direction, children: [ { node: n, weight: 1 }, { node: { type: "leaf", node: newLeaf }, weight: 1 }, ], }; return { type: "split", node: container }; } return n; }); if (!found) throw notFound(op.target); return { root }; } case "merge": { let touched = false; const root = mapNode(tree.root, (n) => { if (n.type === "split" && n.node.id === op.container) { const keep = n.node.children[op.keepIndex]; if (!keep) throw invalid(`merge keepIndex ${op.keepIndex} out of range`); touched = true; return keep.node; } return n; }); if (!touched) throw notFound(op.container); return { root }; } case "resize": { let touched = false; const root = mapNode(tree.root, (n) => { if (n.type === "split" && n.node.id === op.container) { if (n.node.children.length !== op.weights.length) { throw invalid("resize weight count mismatch"); } touched = true; const children: WeightedChild[] = n.node.children.map((c, i) => ({ node: c.node, weight: op.weights[i], })); return { type: "split", node: { ...n.node, children } }; } return n; }); if (!touched) throw notFound(op.container); return { root }; } case "move": { const session = sessionInLeaf(tree.root, op.from); if (session === undefined) throw notFound(op.from); if (session === null) throw invalid("source cell has no session"); const target = sessionInLeaf(tree.root, op.to); if (target === undefined) throw notFound(op.to); if (target !== null) throw invalid("target cell is occupied"); const root = mapNode(tree.root, (n) => { if (n.type === "leaf" && n.node.id === op.from) { return { type: "leaf", node: { id: n.node.id, session: null } }; } if (n.type === "leaf" && n.node.id === op.to) { return { type: "leaf", node: { id: n.node.id, session } }; } return n; }); return { root }; } case "setSession": { let found = false; const root = mapNode(tree.root, (n) => { if (n.type === "leaf" && n.node.id === op.target) { found = true; return { type: "leaf", node: { ...n.node, session: op.session ?? null }, }; } return n; }); if (!found) throw notFound(op.target); return { root }; } case "setCellAgent": { let found = false; const root = mapNode(tree.root, (n) => { if (n.type === "leaf" && n.node.id === op.target) { found = true; const { agent: _a, ...rest } = n.node; const updated = op.agent !== null ? { ...rest, agent: op.agent } : rest; return { type: "leaf", node: updated }; } return n; }); if (!found) throw notFound(op.target); return { root }; } } } /** Collects all leaf cells of a tree in document order. */ export function leaves(tree: LayoutTree): LeafCell[] { const out: LeafCell[] = []; mapNode(tree.root, (n) => { if (n.type === "leaf") out.push(n.node); return n; }); return out; } /** Convenience: builds a `split` operation splitting `target` in `direction`. */ export function splitOp(target: string, direction: Direction): LayoutOperation { return { type: "split", target, direction, newLeaf: newId(), container: newId(), }; } function notFound(id: string): GatewayLikeError { return { code: "NOT_FOUND", message: `layout node ${id} not found` }; } function invalid(msg: string): GatewayLikeError { return { code: "INVALID", message: msg }; } interface GatewayLikeError { code: string; message: string; }