/** * public/grid-layout.ts — desktop split-grid ("watch board") layout logic + toggle. * * When several sessions are open on a large screen, the user can split #term into * a 2×2 grid (or 1×2 side-by-side) so multiple LIVE terminals are visible at once * — a monitoring/multitasking convenience for vibe-coding several Claude sessions. * * This module owns the PURE layout logic (capacity, which tabs are visible, the * matchMedia desktop gate, persistence) plus the toolbar toggle control. The DOM * of the panes themselves stays in tabs.ts (TabApp) — this keeps tabs.ts from * growing past its size budget and makes the layout math unit-testable in isolation. * * v1 layouts: 'single' (today's behavior) · 'split-2' (side-by-side) · 'grid-4' (2×2). * The design is deliberately extensible: adding 'row-3'/'grid-6' in v2 is a * CAPACITY entry + a LAYOUT_META entry + CSS, with no change to the state model. */ /** A screen layout for the terminal area. Ordered by pane capacity. */ export type GridLayout = 'single' | 'split-2' | 'row-3' | 'grid-4' | 'grid-6' /** All layouts, in the order the toggle presents them (by capacity). */ export const GRID_LAYOUTS: readonly GridLayout[] = [ 'single', 'split-2', 'row-3', 'grid-4', 'grid-6', ] const CAPACITY: Readonly> = { single: 1, 'split-2': 2, 'row-3': 3, 'grid-4': 4, 'grid-6': 6, } /** How many panes a layout shows at once. */ export function layoutCapacity(layout: GridLayout): number { return CAPACITY[layout] ?? 1 } /** * The tab indices visible under `layout`: the first `min(tabCount, capacity)` in * tab order. Membership is controlled by reordering tabs (v1) — drag a tab into * the first N and it joins the board. */ export function visibleIndices(tabCount: number, layout: GridLayout): number[] { const n = Math.min(Math.max(0, tabCount), layoutCapacity(layout)) return Array.from({ length: n }, (_, i) => i) } /** * Whether tab `idx` is on-screen. In 'single' that's only the focused pane; in a * grid it's any of the first-N visible tabs. Used for both rendering and to * suppress OS notifications for panes the user can already see. */ export function isVisibleIndex( idx: number, activeIndex: number, tabCount: number, layout: GridLayout, ): boolean { if (layout === 'single') return idx === activeIndex return idx >= 0 && idx < Math.min(Math.max(0, tabCount), layoutCapacity(layout)) } /** * Match the split-grid focus-cycle keybinding (Ctrl+` forward, Ctrl+Shift+` * reverse). Returns the cycle direction, or null if the event isn't the binding. * Extracted from the DOM listener so it's unit-testable in isolation. */ export function matchFocusCycleKey(e: KeyboardEvent): 1 | -1 | null { if (!e.ctrlKey || e.altKey || e.metaKey) return null if (e.key !== '`' && e.code !== 'Backquote') return null return e.shiftKey ? -1 : 1 } /** Minimum viewport width (px) at which multi-pane layouts are offered. Four * terminals need real width; below this the board falls back to 'single'. */ export const GRID_MIN_WIDTH = 1024 /** True when the screen is wide enough for split layouts (desktop/large only). */ export function gridAllowed(): boolean { if (typeof window === 'undefined') return false if (typeof window.matchMedia === 'function') { return window.matchMedia(`(min-width: ${GRID_MIN_WIDTH}px)`).matches } return (window.innerWidth ?? 0) >= GRID_MIN_WIDTH } const GRID_LAYOUT_KEY = 'web-terminal:grid-layout' function isGridLayout(v: unknown): v is GridLayout { return ( v === 'single' || v === 'split-2' || v === 'row-3' || v === 'grid-4' || v === 'grid-6' ) } /** Load the persisted layout. Forced to 'single' when the screen is too narrow * (a stored grid choice must not resurrect on a phone). */ export function loadGridLayout(): GridLayout { if (!gridAllowed()) return 'single' try { const stored = localStorage.getItem(GRID_LAYOUT_KEY) if (isGridLayout(stored)) return stored } catch { // localStorage unavailable — use default } return 'single' } /** Persist the chosen layout (mirrors the other web-terminal:* prefs). */ export function saveGridLayout(layout: GridLayout): void { try { localStorage.setItem(GRID_LAYOUT_KEY, layout) } catch { // localStorage unavailable — run without persistence } } export interface GridToggleHooks { getLayout: () => GridLayout setLayout: (layout: GridLayout) => void } export interface GridToggle { /** Re-sync the control's pressed state + visibility (after setLayout / resize). */ refresh: () => void dispose: () => void } /** Per-layout label/title for the toggle. The glyph is drawn in CSS from the * cell count (see makeGlyph), not from a font, so it renders identically everywhere. */ const LAYOUT_META: Readonly> = { single: { title: 'Single pane', cells: 1 }, 'split-2': { title: 'Side by side — compare two', cells: 2 }, 'row-3': { title: '1×3 row — three across', cells: 3 }, 'grid-4': { title: '2×2 watch board', cells: 4 }, 'grid-6': { title: '2×3 grid — six sessions', cells: 6 }, } /** A small CSS-drawn layout glyph (N cells), reliable across platforms. */ function makeGlyph(layout: GridLayout): HTMLElement { const g = document.createElement('span') g.className = `grid-glyph gl-${layout}` g.setAttribute('aria-hidden', 'true') for (let i = 0; i < LAYOUT_META[layout].cells; i++) g.appendChild(document.createElement('i')) return g } /** * Mount the layout segmented control into the toolbar. Desktop-only: it hides * below GRID_MIN_WIDTH, and if the window narrows past the threshold while a grid * is active it forces the layout back to 'single' (the caller re-renders). */ export function mountGridToggle(toolbar: HTMLElement, hooks: GridToggleHooks): GridToggle { const seg = document.createElement('div') seg.className = 'grid-toggle' seg.setAttribute('role', 'group') seg.setAttribute('aria-label', 'Terminal layout') const buttons = GRID_LAYOUTS.map((layout) => { const btn = document.createElement('button') btn.className = 'grid-toggle-btn' btn.dataset.layout = layout btn.title = LAYOUT_META[layout].title btn.setAttribute('aria-label', LAYOUT_META[layout].title) btn.appendChild(makeGlyph(layout)) btn.addEventListener('click', () => hooks.setLayout(layout)) seg.appendChild(btn) return btn }) const refresh = (): void => { seg.style.display = gridAllowed() ? 'inline-flex' : 'none' const current = hooks.getLayout() for (const btn of buttons) { btn.setAttribute('aria-pressed', String(btn.dataset.layout === current)) } } const mql = typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia(`(min-width: ${GRID_MIN_WIDTH}px)`) : null const onChange = (): void => { if (!gridAllowed() && hooks.getLayout() !== 'single') hooks.setLayout('single') refresh() } mql?.addEventListener('change', onChange) toolbar.appendChild(seg) refresh() return { refresh, dispose: (): void => { mql?.removeEventListener('change', onChange) seg.remove() }, } } /* ── Resizable splitters (v3) ────────────────────────────────────────── */ /** The (columns, rows) track counts a layout lays its panes out in. */ export function layoutTracks(layout: GridLayout): { cols: number; rows: number } { switch (layout) { case 'single': return { cols: 1, rows: 1 } case 'split-2': return { cols: 2, rows: 1 } case 'row-3': return { cols: 3, rows: 1 } case 'grid-4': return { cols: 2, rows: 2 } case 'grid-6': return { cols: 3, rows: 2 } } } /** Per-axis track fractions (relative fr units) for one layout. */ export interface TrackSplit { cols: number[] rows: number[] } /** Persisted custom track fractions, per layout (missing → equal tracks). */ export type GridSplits = Partial> /** Equal-fraction split for a layout (every track = 1fr). */ export function defaultSplit(layout: GridLayout): TrackSplit { const { cols, rows } = layoutTracks(layout) return { cols: Array(cols).fill(1), rows: Array(rows).fill(1) } } /** Smallest fraction a track may shrink to while its neighbor grows. */ export const MIN_TRACK_FRACTION = 0.3 /** * Move the boundary between track `gutterIndex` and `gutterIndex+1` by `delta` * (in fr units), trading the delta between the two adjacent tracks and clamping * both to MIN_TRACK_FRACTION. Pure — returns a new array (the input untouched). */ export function adjustSplit( fractions: readonly number[], gutterIndex: number, delta: number, min = MIN_TRACK_FRACTION, ): number[] { if (gutterIndex < 0 || gutterIndex >= fractions.length - 1) return fractions.slice() let a = (fractions[gutterIndex] ?? 1) + delta let b = (fractions[gutterIndex + 1] ?? 1) - delta if (a < min) { b -= min - a a = min } if (b < min) { a -= min - b b = min } if (a < min || b < min) return fractions.slice() // can't satisfy both const next = fractions.slice() next[gutterIndex] = a next[gutterIndex + 1] = b return next } /** Render fractions as a grid-template value (e.g. "1fr 1.4fr"). */ export function tracksToTemplate(fractions: readonly number[]): string { return fractions.map((f) => `${f}fr`).join(' ') } const GRID_SPLITS_KEY = 'web-terminal:grid-splits' /** Load persisted splits (best-effort; shape validated per-layout at use). */ export function loadGridSplits(): GridSplits { try { const raw = localStorage.getItem(GRID_SPLITS_KEY) if (raw === null) return {} const parsed: unknown = JSON.parse(raw) if (parsed !== null && typeof parsed === 'object') return parsed as GridSplits } catch { // localStorage / JSON unavailable — use equal tracks } return {} } /** Persist the custom splits map. */ export function saveGridSplits(splits: GridSplits): void { try { localStorage.setItem(GRID_SPLITS_KEY, JSON.stringify(splits)) } catch { // localStorage unavailable — run without persistence } } /** The valid split for a layout: the stored one if its shape matches the layout's * track counts and all fractions are positive, else the equal default. */ export function splitForLayout(splits: GridSplits, layout: GridLayout): TrackSplit { const { cols, rows } = layoutTracks(layout) const stored = splits[layout] const ok = stored !== undefined && stored !== null && // a null entry ({"grid-4":null}) is valid JSON but not a TrackSplit typeof stored === 'object' && Array.isArray(stored.cols) && stored.cols.length === cols && Array.isArray(stored.rows) && stored.rows.length === rows && [...stored.cols, ...stored.rows].every((n) => typeof n === 'number' && n > 0) return ok ? stored : defaultSplit(layout) }