From 59eb509d4776f6a3a87e830b1975f09befa8389e Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 6 Oct 2026 16:00:35 +0200 Subject: [PATCH] feat(tiles): pure layout and state helpers for the tile grid window.CodemanTileGrid (constants.js), the grid's pure half: - computeTileLayout: columns x rows by tile count (1x1, 2x1, 3x1 on a grid area at least 1800px wide else 2x2, 2x2, 3x2, 3x3), capped at 9, and whether every cell clears the minimum tile size (480x240). - tileGridCapacity: how many tiles a grid area can hold. - sanitizeTileGridState: a stored grid (ids only) made safe to apply; unknown, deleted, detached and duplicate ids are dropped, focus and zoom must name a kept tile, track fractions must be sane. - tileNeighbor / tileInDirection / cycleTile: which tile takes focus when one leaves, on a directional chord, and on Ctrl+Tab or Alt+[ ]. - TILE_SCROLLBACK (10,000 lines, not the primary pane's 50,000) and the tile font default. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/constants.js | 180 +++++++++++++++++++++++++++++++ test/tile-grid-layout.test.ts | 195 ++++++++++++++++++++++++++++++++++ 2 files changed, 375 insertions(+) create mode 100644 test/tile-grid-layout.test.ts diff --git a/src/web/public/constants.js b/src/web/public/constants.js index fb5ecbea..efcb996f 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1633,6 +1633,173 @@ function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds return result; } +// ── Tile grid (tile-grid.js) ─────────────────────────────────────────────── +// +// Pure layout and state helpers for the tile grid (docs/tile-grid-plan.md): +// 1 to 9 live sessions side by side, each in its own TerminalTile. Desktop +// only, behind the same 1180px gate as the split pane. + +/** Hard cap on tiles in one grid. */ +const TILE_GRID_MAX = 9; +// The smallest tile worth showing: about 60 columns and a dozen rows at the +// default tile font. Bounds how many tiles a window can hold. +const TILE_MIN_W = 480; +const TILE_MIN_H = 240; +// Three tiles go side by side (3x1) only when each still gets ~600px; +// otherwise they take three cells of a 2x2. +const TILE_GRID_WIDE_3X1 = 1800; +// A tile's xterm keeps this many lines, not DEFAULT_SCROLLBACK: nine DOM +// renderers at 50k lines each is a real memory cost, and a tile's load is a +// bounded 1 MiB window anyway, so more scrollback only fills with live output. +const TILE_SCROLLBACK = 10000; +// Tiles have their own per-device font size (a tile is a fraction of the screen). +const TILE_FONT_SIZE_DEFAULT = 13; + +/** + * Columns and rows for `count` tiles, by count (the spec's table), and whether + * that layout gives every cell at least the minimum tile size in a grid area + * of `width` x `height` px. + * + * @param {{count: number, width?: number, height?: number, minTileW?: number, minTileH?: number}} p + * @returns {{cols: number, rows: number, fits: boolean}} + */ +function computeTileLayout({ count, width = Infinity, height = Infinity, minTileW = TILE_MIN_W, minTileH = TILE_MIN_H }) { + const n = Math.min(Math.max(0, Math.floor(Number(count) || 0)), TILE_GRID_MAX); + let cols; + let rows; + if (n === 0) return { cols: 0, rows: 0, fits: true }; + if (n === 1) { cols = 1; rows = 1; } + else if (n === 2) { cols = 2; rows = 1; } + else if (n === 3) { + if (width >= TILE_GRID_WIDE_3X1) { cols = 3; rows = 1; } + else { cols = 2; rows = 2; } + } + else if (n === 4) { cols = 2; rows = 2; } + else if (n <= 6) { cols = 3; rows = 2; } + else { cols = 3; rows = 3; } + const fits = width / cols >= minTileW && height / rows >= minTileH; + return { cols, rows, fits }; +} + +/** + * How many tiles a grid area can hold: the largest count up to TILE_GRID_MAX + * whose layout, and every smaller count's layout, fits. 0 when not even one + * tile fits. + * + * @param {{width: number, height: number, minTileW?: number, minTileH?: number}} p + * @returns {number} + */ +function tileGridCapacity({ width, height, minTileW = TILE_MIN_W, minTileH = TILE_MIN_H }) { + let capacity = 0; + for (let n = 1; n <= TILE_GRID_MAX; n++) { + if (!computeTileLayout({ count: n, width, height, minTileW, minTileH }).fits) break; + capacity = n; + } + return capacity; +} + +/** + * The stored grid (`codeman:tile-grid`, ids only) made safe to apply: unknown, + * deleted, detached and duplicate ids are dropped, the list is capped at + * TILE_GRID_MAX, `focused` / `zoomed` must name a kept id, and track fractions + * must be 1 to 3 finite positive numbers. Anything that is not a v1 object + * (or its JSON) gives null. + * + * @param {unknown} raw - the parsed value, or the stored JSON string + * @param {{has(id: string): boolean}|Iterable} liveSessions - ids that exist now + * @param {{has(id: string): boolean}} [detachedIds] - sessions popped out to their own window + * @returns {{v: 1, open: boolean, ids: string[], focused: string|null, zoomed: string|null, + * colFr: number[]|null, rowFr: number[]|null}|null} + */ +function sanitizeTileGridState(raw, liveSessions, detachedIds) { + let value = raw; + if (typeof value === 'string') { + try { value = JSON.parse(value); } catch { return null; } + } + if (!value || typeof value !== 'object' || Array.isArray(value) || value.v !== 1) return null; + const live = liveSessions && typeof liveSessions.has === 'function' ? liveSessions : new Set(liveSessions || []); + const ids = []; + for (const id of Array.isArray(value.ids) ? value.ids : []) { + if (typeof id !== 'string' || !id || ids.includes(id)) continue; + if (!live.has(id)) continue; + if (detachedIds?.has?.(id)) continue; + ids.push(id); + if (ids.length === TILE_GRID_MAX) break; + } + const fractions = (fr) => { + if (!Array.isArray(fr) || fr.length < 1 || fr.length > 3) return null; + return fr.every((x) => typeof x === 'number' && Number.isFinite(x) && x > 0) ? fr.slice() : null; + }; + return { + v: 1, + open: value.open === true && ids.length > 0, + ids, + focused: ids.includes(value.focused) ? value.focused : (ids[0] ?? null), + zoomed: ids.includes(value.zoomed) ? value.zoomed : null, + colFr: fractions(value.colFr), + rowFr: fractions(value.rowFr), + }; +} + +/** + * Which tile takes focus when `id` leaves the grid: the next one in grid + * order, else the previous one, else null. + * + * @param {string[]} ids - the grid's tiles, in reading order + * @param {string} id - the tile that is leaving + * @returns {string|null} + */ +function tileNeighbor(ids, id) { + const i = ids.indexOf(id); + if (i === -1) return ids[0] ?? null; + return ids[i + 1] ?? ids[i - 1] ?? null; +} + +/** + * The tile a directional focus chord moves to, in a row-major grid of `cols` + * columns. Left and right stay within the row; up and down move a whole row, + * and moving down onto a short last row lands on its last tile. Null when + * there is nothing in that direction. + * + * @param {string[]} ids - the grid's tiles, in reading order + * @param {string} focusedId - the tile the keyboard is in + * @param {'left'|'right'|'up'|'down'} direction + * @param {number} cols - the layout's column count + * @returns {string|null} + */ +function tileInDirection(ids, focusedId, direction, cols) { + const n = ids.length; + const i = ids.indexOf(focusedId); + if (i === -1 || n === 0 || !(cols >= 1)) return null; + const col = i % cols; + let j = -1; + if (direction === 'left') j = col > 0 ? i - 1 : -1; + else if (direction === 'right') j = col < cols - 1 && i + 1 < n ? i + 1 : -1; + else if (direction === 'up') j = i - cols; + else if (direction === 'down') { + j = i + cols; + const lastRow = Math.floor((n - 1) / cols); + if (j >= n && Math.floor(i / cols) < lastRow) j = n - 1; + } + return j >= 0 && j < n && j !== i ? ids[j] : null; +} + +/** + * The tile Ctrl+Tab / Alt+] (delta 1) or Alt+[ (delta -1) moves to while the + * grid is open: tiles cycle in reading order and wrap. + * + * @param {string[]} ids + * @param {string} focusedId + * @param {number} delta - +1 or -1 + * @returns {string|null} + */ +function cycleTile(ids, focusedId, delta) { + if (ids.length === 0) return null; + const i = ids.indexOf(focusedId); + if (i === -1) return ids[0]; + return ids[(i + delta + ids.length) % ids.length]; +} + // ── Renderer liveness ────────────────────────────────────────────────────── // // iOS DISCARDS scheduled requestAnimationFrame callbacks when a PWA goes to @@ -1879,6 +2046,19 @@ if (typeof window !== 'undefined') { buildSplitPickerSessions, SPLIT_PANE_MIN_WIDTH, }; + window.CodemanTileGrid = { + computeTileLayout, + tileGridCapacity, + sanitizeTileGridState, + tileNeighbor, + tileInDirection, + cycleTile, + TILE_GRID_MAX, + TILE_MIN_W, + TILE_MIN_H, + TILE_SCROLLBACK, + TILE_FONT_SIZE_DEFAULT, + }; window.CodemanRenderLiveness = { shouldKickRenderer, RENDER_STALL_MS, RENDER_LIVENESS_POLL_MS }; window.CodemanFetchDeadline = { terminalFetchDeadlineMs, diff --git a/test/tile-grid-layout.test.ts b/test/tile-grid-layout.test.ts new file mode 100644 index 00000000..d8fbad1a --- /dev/null +++ b/test/tile-grid-layout.test.ts @@ -0,0 +1,195 @@ +/** + * @fileoverview The tile grid's pure helpers (constants.js, `window.CodemanTileGrid`). + * + * - `computeTileLayout`: columns x rows by tile count (the spec's table), with + * the 3-tile special case (3x1 only on a wide grid area) and whether every + * cell clears the minimum tile size. + * - `tileGridCapacity`: how many tiles a grid area can hold. + * - `sanitizeTileGridState`: the stored `codeman:tile-grid` value made safe to + * apply (unknown, deleted, detached and duplicate ids dropped). + * - `tileNeighbor`, `tileInDirection`, `cycleTile`: which tile takes focus when + * one leaves, on a directional chord, and on Ctrl+Tab / Alt+[ ]. + * + * Loaded via `vm` like split-pane-helpers.test.ts. Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it } from 'vitest'; + +type Layout = { cols: number; rows: number; fits: boolean }; +type TileGrid = { + computeTileLayout(p: Record): Layout; + tileGridCapacity(p: Record): number; + sanitizeTileGridState(raw: unknown, live: unknown, detached?: Set): Record | null; + tileNeighbor(ids: string[], id: string): string | null; + tileInDirection(ids: string[], focused: string, dir: string, cols: number): string | null; + cycleTile(ids: string[], focused: string, delta: number): string | null; + TILE_GRID_MAX: number; + TILE_MIN_W: number; + TILE_MIN_H: number; + TILE_SCROLLBACK: number; +}; + +function loadTileGrid(): TileGrid { + const context = vm.createContext({ window: {}, globalThis: {} }); + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'); + vm.runInContext(source, context, { filename: 'constants.js' }); + return (context.window as { CodemanTileGrid: TileGrid }).CodemanTileGrid; +} + +const T = loadTileGrid(); +// Plenty of room: every layout fits. +const BIG = { width: 3000, height: 2000 }; + +describe('computeTileLayout', () => { + it.each([ + [1, 1, 1], + [2, 2, 1], + [4, 2, 2], + [5, 3, 2], + [6, 3, 2], + [7, 3, 3], + [8, 3, 3], + [9, 3, 3], + ])('%i tiles lay out as %ix%i', (count, cols, rows) => { + expect(T.computeTileLayout({ count, ...BIG })).toMatchObject({ cols, rows, fits: true }); + }); + + it('puts 3 tiles side by side only on a grid area at least 1800px wide', () => { + expect(T.computeTileLayout({ count: 3, width: 1800, height: 900 })).toMatchObject({ cols: 3, rows: 1 }); + expect(T.computeTileLayout({ count: 3, width: 1799, height: 900 })).toMatchObject({ cols: 2, rows: 2 }); + }); + + it('caps the count at 9 and treats nothing as an empty grid', () => { + expect(T.computeTileLayout({ count: 12, ...BIG })).toMatchObject({ cols: 3, rows: 3 }); + expect(T.computeTileLayout({ count: 0, ...BIG })).toMatchObject({ cols: 0, rows: 0 }); + }); + + it('reports whether every cell clears the minimum tile size', () => { + // 3x2 needs 3 * 480 = 1440 wide and 2 * 240 = 480 high. + expect(T.computeTileLayout({ count: 6, width: 1440, height: 480 }).fits).toBe(true); + expect(T.computeTileLayout({ count: 6, width: 1439, height: 480 }).fits).toBe(false); + expect(T.computeTileLayout({ count: 6, width: 1440, height: 479 }).fits).toBe(false); + }); +}); + +describe('tileGridCapacity', () => { + it('holds all nine on a large monitor', () => { + expect(T.tileGridCapacity(BIG)).toBe(T.TILE_GRID_MAX); + }); + + it('stops at the first count whose layout does not fit', () => { + // 1440 x 600: 3x2 fits (480 x 300) but 3x3 (480 x 200) does not. + expect(T.tileGridCapacity({ width: 1440, height: 600 })).toBe(6); + // 1200 x 900: 2x2 fits (600 x 450), 3x2 does not (400 wide). + expect(T.tileGridCapacity({ width: 1200, height: 900 })).toBe(4); + // 1000 x 400: 2x1 fits (500 x 400); three tiles take a 2x2 (200 high), which does not. + expect(T.tileGridCapacity({ width: 1000, height: 400 })).toBe(2); + }); + + it('is 0 when not even one tile fits', () => { + expect(T.tileGridCapacity({ width: 400, height: 900 })).toBe(0); + }); +}); + +describe('sanitizeTileGridState', () => { + const live = new Map([ + ['a', {}], + ['b', {}], + ['c', {}], + ['d', {}], + ]); + + it('keeps a valid stored grid as it is', () => { + const raw = { v: 1, open: true, ids: ['a', 'b'], focused: 'b', zoomed: 'a', colFr: [1, 2], rowFr: [1] }; + expect(T.sanitizeTileGridState(raw, live, new Set())).toEqual({ + v: 1, + open: true, + ids: ['a', 'b'], + focused: 'b', + zoomed: 'a', + colFr: [1, 2], + rowFr: [1], + }); + }); + + it('accepts the stored JSON string', () => { + const raw = JSON.stringify({ v: 1, open: true, ids: ['c'], focused: 'c' }); + expect(T.sanitizeTileGridState(raw, live)?.ids).toEqual(['c']); + }); + + it('drops unknown (deleted), detached and duplicate ids', () => { + const raw = { v: 1, open: true, ids: ['a', 'gone', 'b', 'a', 'c', 7, ''], focused: 'a' }; + const out = T.sanitizeTileGridState(raw, live, new Set(['b'])); + expect(out?.ids).toEqual(['a', 'c']); + }); + + it('moves focus to the first kept tile when the focused one was dropped, and drops a dropped zoom', () => { + const raw = { v: 1, open: true, ids: ['gone', 'b', 'c'], focused: 'gone', zoomed: 'gone' }; + const out = T.sanitizeTileGridState(raw, live); + expect(out?.focused).toBe('b'); + expect(out?.zoomed).toBeNull(); + }); + + it('is closed when no tile survives', () => { + const out = T.sanitizeTileGridState({ v: 1, open: true, ids: ['gone'] }, live); + expect(out).toMatchObject({ open: false, ids: [], focused: null }); + }); + + it('caps the list at nine tiles', () => { + const many = Array.from({ length: 12 }, (_, i) => `s${i}`); + const out = T.sanitizeTileGridState({ v: 1, open: true, ids: many }, many); + expect(out?.ids).toEqual(many.slice(0, 9)); + }); + + it('drops malformed track fractions', () => { + const out = T.sanitizeTileGridState( + { v: 1, open: true, ids: ['a'], colFr: [1, -1], rowFr: [1, 1, 1, 1] }, + live + ); + expect(out?.colFr).toBeNull(); + expect(out?.rowFr).toBeNull(); + }); + + it.each([null, 'not json', '[]', 42, { v: 2, ids: ['a'] }, { ids: ['a'] }])('rejects %j', (raw) => { + expect(T.sanitizeTileGridState(raw, live)).toBeNull(); + }); +}); + +describe('focus helpers', () => { + it('tileNeighbor prefers the next tile, then the previous one', () => { + expect(T.tileNeighbor(['a', 'b', 'c'], 'b')).toBe('c'); + expect(T.tileNeighbor(['a', 'b', 'c'], 'c')).toBe('b'); + expect(T.tileNeighbor(['a'], 'a')).toBeNull(); + }); + + it('tileInDirection moves within a row-major grid', () => { + // 3x2: a b c + // d e + const ids = ['a', 'b', 'c', 'd', 'e']; + expect(T.tileInDirection(ids, 'b', 'left', 3)).toBe('a'); + expect(T.tileInDirection(ids, 'a', 'left', 3)).toBeNull(); + expect(T.tileInDirection(ids, 'b', 'right', 3)).toBe('c'); + expect(T.tileInDirection(ids, 'c', 'right', 3)).toBeNull(); + expect(T.tileInDirection(ids, 'e', 'right', 3)).toBeNull(); + expect(T.tileInDirection(ids, 'd', 'up', 3)).toBe('a'); + expect(T.tileInDirection(ids, 'a', 'up', 3)).toBeNull(); + expect(T.tileInDirection(ids, 'b', 'down', 3)).toBe('e'); + // Nothing below c in that column: the short last row's last tile. + expect(T.tileInDirection(ids, 'c', 'down', 3)).toBe('e'); + expect(T.tileInDirection(ids, 'e', 'down', 3)).toBeNull(); + }); + + it('cycleTile wraps in reading order', () => { + expect(T.cycleTile(['a', 'b', 'c'], 'c', 1)).toBe('a'); + expect(T.cycleTile(['a', 'b', 'c'], 'a', -1)).toBe('c'); + expect(T.cycleTile([], 'a', 1)).toBeNull(); + }); +}); + +describe('tile constants', () => { + it('a tile keeps 10,000 lines of scrollback, not the primary pane 50,000', () => { + expect(T.TILE_SCROLLBACK).toBe(10000); + }); +});