import { uuidv7 as newId } from '../api'; // A board's layout: a tree whose leaves name windows and whose inner tiles // arrange them. A split lays its children side by side (row) or stacked // (column), a tabs tile shows one child at a time, and a canvas lets each // child sit anywhere on a plane that pans and zooms, another canvas too. A // window is in a tree at most once. Any tile may have a // name: a named split or tabs is a section, and a named tab shows its name. // // Everything here is pure: every change returns a new tree, normalized. export type Axis = 'row' | 'column'; export type Side = 'left' | 'right' | 'top' | 'bottom'; export type Viewport = { x: number; y: number; zoom: number }; export type Rect = { x: number; y: number; width: number; height: number }; export type Leaf = { id: string; kind: 'leaf'; window: string; name?: string }; export type Split = { id: string; kind: 'split'; axis: Axis; children: Tile[]; sizes: number[]; name?: string }; /** `active` is the id of the child shown. */ export type Tabs = { id: string; kind: 'tabs'; children: Tile[]; active: string; name?: string }; /** A child on a canvas; without a height it takes the height of what it holds. */ export type Placed = { tile: Tile; x: number; y: number; width: number; height?: number; z: number }; /** `map` is whether its overview map shows. */ export type Canvas = { id: string; kind: 'canvas'; children: Placed[]; viewport: Viewport; map?: boolean; name?: string }; export type Tile = Leaf | Split | Tabs | Canvas; export type Kind = Tile['kind']; /** The least a tile may be across or down: a gutter can't squeeze one smaller, nor can a drop. */ export const LEAST = 160; /** The gap between a split's tiles, which is its resize handle (SplitView.svelte). */ export const GUTTER = 6; /** Where a new canvas's view starts. */ export const VIEW: Viewport = { x: 40, y: 72, zoom: 1 }; /** What a section's frame takes along an axis: its padding, and across the top its header (TileView.svelte). */ const FRAME = { row: 12, column: 30 }; /** What a tabs tile's strip and, round a tab that is not a window, its padding take (TabsView.svelte). */ const STRIP = { row: 0, column: 38 }; const PAGE = 12; /** * How short `tile` can be along `axis` with every window in it at LEAST or * more. `headed` is whether a section's frame is drawn, as in TileView. */ export function leastAlong(tile: Tile, axis: Axis, headed = true): number { if (tile.kind === 'leaf' || tile.kind === 'canvas') return LEAST; const frame = headed && tile.name ? FRAME[axis] : 0; if (tile.kind === 'tabs') { const pages = tile.children.map((child) => leastAlong(child, axis, false) + (child.kind === 'leaf' ? 0 : PAGE)); return Math.max(LEAST, ...pages) + STRIP[axis] + frame; } const each = tile.children.map((child) => leastAlong(child, axis)); if (tile.axis === axis) return each.reduce((sum, length) => sum + length, GUTTER * (each.length - 1) + frame); return Math.max(LEAST, ...each) + frame; } type Extent = { tile: Tile; width: number; height: number }; /** The size of each tile laid out side by side in `width` by `height`, a leaf's by its window, so a window moved keeps its key. */ function extents(tile: Tile, width: number, height: number, into = new Map()): Map { if (tile.kind !== 'split') return into.set(tile.kind === 'leaf' ? tile.window : tile.id, { tile, width, height }); const row = tile.axis === 'row'; const length = (row ? width : height) - GUTTER * (tile.children.length - 1); tile.children.forEach((child, at) => extents(child, row ? length * tile.sizes[at] : width, row ? height : length * tile.sizes[at], into)); return into; } export type Squeezed = { tile: Tile; way: 'narrower' | 'shorter' }; /** * A tile `next` squeezes under LEAST one way, as a gutter would not, and * which way, laid out in `width` by `height`. One already under it in * `before` only counts if it shrinks further, so a board that has a cramped * tile can still take a change elsewhere. */ export function squeezed(before: Tile, next: Tile, width: number, height: number): Squeezed | null { const was = extents(before, width, height); for (const [key, now] of extents(next, width, height)) { const old = was.get(key); if (now.width < LEAST && now.width < (old?.width ?? Infinity) - 1) return { tile: now.tile, way: 'narrower' }; if (now.height < LEAST && now.height < (old?.height ?? Infinity) - 1) return { tile: now.tile, way: 'shorter' }; } return null; } export function leaf(window: string): Leaf { return { id: newId(), kind: 'leaf', window }; } export function split(axis: Axis, children: Tile[], sizes?: number[]): Split { return { id: newId(), kind: 'split', axis, children, sizes: sizes ?? children.map(() => 1 / children.length) }; } export function tabs(children: Tile[], active = children[0]?.id ?? ''): Tabs { return { id: newId(), kind: 'tabs', children, active }; } export function canvas(children: Placed[] = [], viewport: Viewport = VIEW): Canvas { return { id: newId(), kind: 'canvas', children, viewport }; } export function childrenOf(tile: Tile): Tile[] { if (tile.kind === 'leaf') return []; if (tile.kind === 'canvas') return tile.children.map((placed) => placed.tile); return tile.children; } /** Every window in the tree, in order. */ export function windows(tile: Tile): string[] { return tile.kind === 'leaf' ? [tile.window] : childrenOf(tile).flatMap(windows); } /** The tiles from the root down to the leaf showing `window`, or null when it is not in the tree. */ export function pathTo(root: Tile, window: string): Tile[] | null { if (root.kind === 'leaf') return root.window === window ? [root] : null; for (const child of childrenOf(root)) { const path = pathTo(child, window); if (path) return [root, ...path]; } return null; } /** The tile `window` sits directly in, or null for a missing window or a root leaf. */ export function parentOf(root: Tile, window: string): Exclude | null { const path = pathTo(root, window); return path && path.length > 1 ? (path[path.length - 2] as Exclude) : null; } /** The tiles from the root down to pane `id`: a window's leaf, or the tile with that id; null when it is neither. */ export function paneTrail(root: Tile, id: string): Tile[] | null { const path = pathTo(root, id) ?? trail(root, id); return path.length ? path : null; } /** The tile pane `id` (a window, or a tile by its id) is shown in: its tabs block when it is a tab, else its own. */ export function frameOf(root: Tile, id: string): Tile | null { const path = paneTrail(root, id); return (path?.at(-2)?.kind === 'tabs' ? path.at(-2) : path?.at(-1)) ?? null; } /** The nearest canvas above pane `id`, which is where it is placed freely, if anywhere. */ export function canvasOf(root: Tile, id: string): Canvas | null { const path = paneTrail(root, id) ?? []; for (let at = path.length - 2; at >= 0; at--) if (path[at].kind === 'canvas') return path[at] as Canvas; return null; } /** The tiles from the root down to the one with `id`. */ export function trail(root: Tile, id: string): Tile[] { if (root.id === id) return [root]; for (const child of childrenOf(root)) { const path = trail(child, id); if (path.length) return [root, ...path]; } return []; } export function find(root: Tile, id: string): Tile | null { return trail(root, id).at(-1) ?? null; } /** An empty root of a kind; an emptied split or tabs root stays as one, so the board keeps its layout. */ export function empty(kind: Kind): Tile { if (kind === 'canvas') return canvas(); return kind === 'tabs' ? tabs([]) : split('row', []); } /** * Stacks `items` back to front as 1..n with the one `top` picks in front, so * raising never grows the numbers. Unchanged items come back as the same array. */ export function raised(items: T[], top: (item: T) => boolean): T[] { const order = [...items].sort((a, b) => a.z - b.z); const stacked = [...order.filter((item) => !top(item)), ...order.filter(top)]; if (stacked.every((item, at) => item.z === at + 1)) return items; const z = new Map(stacked.map((item, at) => [item, at + 1])); return items.map((item) => (z.get(item) === item.z ? item : { ...item, z: z.get(item)! })); } /** Replaces the tile with `id` by what `change` makes of it, then normalizes. */ export function update(root: Tile, id: string, change: (tile: Tile) => Tile | null): Tile { return normalize(replace(root, id, change)) ?? empty(root.kind); } function same(before: T[], after: T[]) { return before.length === after.length && before.every((item, at) => item === after[at]); } function replace(tile: Tile, id: string, change: (tile: Tile) => Tile | null): Tile | null { if (tile.id === id) return change(tile); if (tile.kind === 'leaf') return tile; if (tile.kind === 'canvas') { const children = tile.children.flatMap((placed) => { const next = replace(placed.tile, id, change); if (next === placed.tile) return [placed]; return next ? [{ ...placed, tile: next }] : []; }); return same(tile.children, children) ? tile : { ...tile, children }; } if (tile.kind === 'tabs') { let active = tile.active; const children = tile.children.flatMap((child) => { const next = replace(child, id, change); if (next && child.id === tile.active) active = next.id; return next ? [next] : []; }); return same(tile.children, children) ? tile : { ...tile, children, active }; } const kept = tile.children.map((child) => replace(child, id, change)); if (same(tile.children, kept)) return tile; return { ...tile, children: kept.filter((child) => child !== null), sizes: tile.sizes.filter((_, at) => kept[at] !== null) }; } /** * Drops empty splits and tabs, collapses an unnamed split or tabs of one into * its child, folds an unnamed split into a parent split of the same axis and * unnamed tabs into parent tabs, keeps sizes summing to 1, and points a tabs * tile whose shown child left at one that stayed. A section (a named split or * tabs) stays while it holds anything, so a window moved within it stays in * it. An empty canvas stays: it is somewhere to put things. */ export function normalize(tile: Tile | null): Tile | null { if (!tile || tile.kind === 'leaf') return tile; if (tile.kind === 'canvas') { const children = tile.children.flatMap((placed) => { const next = normalize(placed.tile); if (next === placed.tile) return [placed]; return next ? [{ ...placed, tile: next }] : []; }); return same(tile.children, children) ? tile : { ...tile, children }; } if (tile.kind === 'tabs') { let active = tile.active; const children = tile.children.flatMap((child) => { const next = normalize(child); if (!next) return []; const inner = next.kind === 'tabs' && !next.name; if (child.id === tile.active) active = inner ? next.active : next.id; return inner ? next.children : [next]; }); if (children.length === 0) return null; if (children.length === 1 && !tile.name) return children[0]; if (!children.some((child) => child.id === active)) active = children[0].id; return same(tile.children, children) && active === tile.active ? tile : { ...tile, children, active }; } const children: Tile[] = []; const sizes: number[] = []; tile.children.forEach((child, at) => { const next = normalize(child); if (!next) return; const size = tile.sizes[at] ?? 0; if (next.kind === 'split' && next.axis === tile.axis && !next.name) { children.push(...next.children); sizes.push(...next.sizes.map((inner) => size * inner)); } else { children.push(next); sizes.push(size); } }); if (children.length === 0) return null; if (children.length === 1 && !tile.name) return children[0]; const total = sizes.reduce((sum, size) => sum + size, 0); if (same(tile.children, children) && Math.abs(total - 1) < 1e-9) return tile; return { ...tile, children, sizes: sizes.map((size) => (total > 0 ? size / total : 1 / sizes.length)) }; } /** Takes `window` out of the tree. */ export function remove(root: Tile, window: string): Tile { const path = pathTo(root, window); return path ? update(root, path[path.length - 1].id, () => null) : root; } /** Splits the tile with `id` in two, putting `add` on `side` of it; a tab splits its whole tabs tile. */ export function splitBeside(root: Tile, id: string, add: Tile, side: Side): Tile { const axis: Axis = side === 'left' || side === 'right' ? 'row' : 'column'; const first = side === 'left' || side === 'top'; const holder = trail(root, id).at(-2); return update(root, holder?.kind === 'tabs' ? holder.id : id, (tile) => split(axis, first ? [add, tile] : [tile, add])); } /** Adds `add` as a tab of the tabs tile `id` (or of any other tile, which becomes tabs with it) at `at`, last by default, and shows it. */ export function addTab(root: Tile, id: string, add: Tile, at?: number): Tile { return update(root, id, (tile) => { if (tile.kind !== 'tabs') return tabs(at === 0 ? [add, tile] : [tile, add], add.id); const index = Math.min(at ?? tile.children.length, tile.children.length); return { ...tile, children: [...tile.children.slice(0, index), add, ...tile.children.slice(index)], active: add.id }; }); } /** Trades the places of windows `a` and `b`. */ export function swap(root: Tile, a: string, b: string): Tile { const other = (window: string) => (window === a ? b : window === b ? a : window); return swapped(root, other); } function swapped(tile: Tile, other: (window: string) => string): Tile { if (tile.kind === 'leaf') { const window = other(tile.window); return window === tile.window ? tile : { ...tile, window }; } if (tile.kind === 'canvas') { const children = tile.children.map((placed) => { const next = swapped(placed.tile, other); return next === placed.tile ? placed : { ...placed, tile: next }; }); return same(tile.children, children) ? tile : { ...tile, children }; } const children = tile.children.map((child) => swapped(child, other)); return same(tile.children, children) ? tile : { ...tile, children }; } /** Puts `tile` on the canvas `id`, above everything already there. */ export function place(root: Tile, id: string, tile: Tile, rect: Omit): Tile { return update(root, id, (target) => { if (target.kind !== 'canvas') return target; const z = Math.max(0, ...target.children.map((placed) => placed.z)) + 1; return { ...target, children: [...target.children, { tile, ...rect, z }] }; }); } /** A tree's arrangement without sizes or ids (a canvas keeps its id), to tell whether a change moves anything. */ export function shape(tile: Tile): string { if (tile.kind === 'leaf') return tile.window; const inner = childrenOf(tile).map(shape).join(','); if (tile.kind === 'canvas') return `canvas:${tile.id}(${inner})`; return tile.kind === 'split' ? `${tile.axis}(${inner})` : `tabs(${inner})`; } /** Whether the tree keeps its rule: no window twice. */ export function valid(root: Tile): boolean { const seen = windows(root); return new Set(seen).size === seen.length; } /** The nearest canvas above the tile `id`, which it is placed on (or inside a group on), if any. */ export function canvasAbove(root: Tile, id: string): Canvas | null { const path = trail(root, id); for (let at = path.length - 2; at >= 0; at--) if (path[at].kind === 'canvas') return path[at] as Canvas; return null; } /** * Tiles a canvas's children by where they sat: the widest clear gap between * them, across or down, cuts them in two, and each side is tiled the same * way. Children that overlap with no gap left fall back to a row by x. */ export function tileOut(placed: Placed[]): Tile | null { if (placed.length === 0) return null; if (placed.length === 1) return placed[0].tile; const box = (child: Placed): Rect => ({ x: child.x, y: child.y, width: child.width, height: child.height ?? child.width }); const cut = (axis: Axis) => { const start = (rect: Rect) => (axis === 'row' ? rect.x : rect.y); const end = (rect: Rect) => (axis === 'row' ? rect.x + rect.width : rect.y + rect.height); const sorted = [...placed].sort((a, b) => start(box(a)) - start(box(b))); let best: { at: number; gap: number } | null = null; let reach = end(box(sorted[0])); for (let at = 1; at < sorted.length; at++) { const gap = start(box(sorted[at])) - reach; if (gap >= 0 && (!best || gap > best.gap)) best = { at, gap }; reach = Math.max(reach, end(box(sorted[at]))); } return best && { ...best, axis, sorted }; }; const across = cut('row'); const down = cut('column'); const chosen = across && (!down || across.gap >= down.gap) ? across : down; if (!chosen) return split('row', [...placed].sort((a, b) => a.x - b.x).map((child) => child.tile)); const span = (side: Placed[]) => { const rects = side.map(box); const start = (rect: Rect) => (chosen.axis === 'row' ? rect.x : rect.y); const end = (rect: Rect) => (chosen.axis === 'row' ? rect.x + rect.width : rect.y + rect.height); return Math.max(...rects.map(end)) - Math.min(...rects.map(start)); }; const before = chosen.sorted.slice(0, chosen.at); const after = chosen.sorted.slice(chosen.at); return normalize(split(chosen.axis, [tileOut(before)!, tileOut(after)!], [span(before), span(after)])); } /** * Lays a tile's windows out on a canvas where they were shown, `rects` being * each window's box as last measured; a tabs block or a section stays one, * where what it showed was. */ export function spreadOut(tile: Tile, rects: Map, viewport?: Viewport): Canvas { const children = pieces(tile).map((piece, z) => { const boxes = shownWindows(piece).flatMap((window) => rects.get(window) ?? []); const x = Math.min(...boxes.map((box) => box.x)); const y = Math.min(...boxes.map((box) => box.y)); const rect = boxes.length ? { x, y, width: Math.max(...boxes.map((box) => box.x + box.width)) - x, height: Math.max(...boxes.map((box) => box.y + box.height)) - y } : { x: z * 40, y: z * 40, width: 560, height: 720 }; return { tile: piece, ...rect, z: z + 1 }; }); return canvas(children, viewport); } function pieces(tile: Tile): Tile[] { const whole = tile.kind === 'leaf' || ((tile.kind === 'tabs' || tile.name) && !holdsCanvas(tile)); return whole ? [tile] : childrenOf(tile).flatMap(pieces); } function holdsCanvas(tile: Tile): boolean { return tile.kind === 'canvas' || childrenOf(tile).some(holdsCanvas); } /** The windows a tile shows: of a tabs tile, only its shown child's. */ function shownWindows(tile: Tile): string[] { if (tile.kind === 'tabs') { const shown = tile.children.find((child) => child.id === tile.active); return shown ? shownWindows(shown) : []; } return tile.kind === 'leaf' ? [tile.window] : childrenOf(tile).flatMap(shownWindows); } /** The same windows under a root of another kind. */ export function rearrange(root: Tile, kind: Exclude, rects: Map): Tile { if (windows(root).length === 0) return empty(kind); if (kind === 'canvas') return root.kind === 'canvas' ? root : spreadOut(root, rects); if (kind === 'tabs') return tabs(windows(root).map(leaf)); if (root.kind === 'canvas') return tileOut(root.children) ?? empty('split'); return root.kind === 'tabs' ? (normalize(split('row', root.children)) ?? empty('split')) : root; }