export type SplitOrientation = 'row' | 'column'; export type TileNode = { kind: 'tile'; id: string; }; export type SplitNode = { kind: 'split'; id: string; orientation: SplitOrientation; ratio: number; first: TilingNode; second: TilingNode; }; export type TilingNode = TileNode | SplitNode; export const DEFAULT_SPLIT_RATIO = 0.5; export const MIN_TILE_WIDTH_PX = 220; export const MIN_TILE_HEIGHT_PX = 160; export function createInitialTree(tileId: string): TilingNode { return { kind: 'tile', id: tileId }; } type SplitResult = | { found: true; node: TilingNode; newTileId: string; } | { found: false; node: TilingNode; }; export function splitLeaf( tree: TilingNode, activeTileId: string, orientation: SplitOrientation, ids: { splitId: string; tileId: string }, /** When true the new tile becomes the first child (left/top); else second. */ before = false, ): { nextTree: TilingNode; newTileId: string } | null { const res = splitLeafInternal(tree, activeTileId, orientation, ids, before); if (!res.found) return null; return { nextTree: res.node, newTileId: res.newTileId }; } function splitLeafInternal( node: TilingNode, activeTileId: string, orientation: SplitOrientation, ids: { splitId: string; tileId: string }, before: boolean, ): SplitResult { if (node.kind === 'tile') { if (node.id !== activeTileId) { return { found: false, node }; } const newTile: TileNode = { kind: 'tile', id: ids.tileId }; return { found: true, newTileId: ids.tileId, node: { kind: 'split', id: ids.splitId, orientation, ratio: DEFAULT_SPLIT_RATIO, first: before ? newTile : node, second: before ? node : newTile, }, }; } const firstRes = splitLeafInternal( node.first, activeTileId, orientation, ids, before, ); if (firstRes.found) { return { found: true, newTileId: firstRes.newTileId, node: { ...node, first: firstRes.node, }, }; } const secondRes = splitLeafInternal( node.second, activeTileId, orientation, ids, before, ); if (secondRes.found) { return { found: true, newTileId: secondRes.newTileId, node: { ...node, second: secondRes.node, }, }; } return { found: false, node }; } export function clampRatioForAxis( ratio: number, orientation: SplitOrientation, axisSizePx: number, ): number { if (axisSizePx <= 0) return ratio; const minPx = orientation === 'row' ? MIN_TILE_WIDTH_PX : MIN_TILE_HEIGHT_PX; const minRatio = minPx / axisSizePx; const maxRatio = 1 - minRatio; if (minRatio >= maxRatio) return DEFAULT_SPLIT_RATIO; return Math.max(minRatio, Math.min(maxRatio, ratio)); } export function updateSplitRatio( node: TilingNode, splitId: string, ratio: number, ): TilingNode { if (node.kind === 'tile') return node; if (node.id === splitId) { return { ...node, ratio }; } return { ...node, first: updateSplitRatio(node.first, splitId, ratio), second: updateSplitRatio(node.second, splitId, ratio), }; } /** * Remove a tile from the tree, collapsing its parent split into the sibling * subtree (the sibling expands to fill the freed space — VS Code / Obsidian * behavior on closing the last tab in a pane). * * Removing the root (only) tile is a **no-op**: there is always at least one * tile, so callers should keep it as a single empty tile rather than emptying * the workspace. Returns the same reference when `tileId` is not found. */ export function removeLeaf(node: TilingNode, tileId: string): TilingNode { if (node.kind === 'tile') { // The only tile cannot be removed; the workspace always has >= 1 tile. return node; } if (node.first.kind === 'tile' && node.first.id === tileId) { return node.second; } if (node.second.kind === 'tile' && node.second.id === tileId) { return node.first; } const first = removeLeaf(node.first, tileId); const second = removeLeaf(node.second, tileId); if (first === node.first && second === node.second) { return node; } return { ...node, first, second }; } /** * In-order (left-to-right, depth-first) list of every tile id in the tree. * Used to detect the single-tile case and to pick a focus target after a * collapse. */ export function listTileIds(node: TilingNode): string[] { if (node.kind === 'tile') return [node.id]; return [...listTileIds(node.first), ...listTileIds(node.second)]; } /** * What to do with the split tree when a tile loses its last tab. * - `reseed`: it was the only tile — keep it and refill it with an empty tab. * - `collapse`: remove the tile (sibling expands) and report the next focus. */ export type TileClosePlan = | { kind: 'reseed' } | { kind: 'collapse'; nextTree: TilingNode; nextActiveTileId: string }; /** * Decide how to update the tree when `tileId`'s last tab closes. Pure so the * component can stay a thin applier. When the closed tile was active, focus * moves to the first surviving tile (left-to-right). */ export function planTileRemoval( tree: TilingNode, tileId: string, activeTileId: string, ): TileClosePlan { if (listTileIds(tree).length <= 1) { return { kind: 'reseed' }; } const nextTree = removeLeaf(tree, tileId); const survivors = listTileIds(nextTree); const nextActiveTileId = activeTileId === tileId ? (survivors[0] ?? tileId) : activeTileId; return { kind: 'collapse', nextTree, nextActiveTileId }; }