From e702f3c1514432617fc143a5edad2463a1e97d1f Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 8 Oct 2026 02:46:23 +0200 Subject: [PATCH] feat(tiles): pure helpers for a grid whose empty cells can be anywhere Owner: an empty cell need not be the last one ("the empty tab can also be tab nr 4 or 3"). The helpers that let the grid hold cells instead of a packed list, with no behaviour change on their own: - fitTileCells: the cells after a shape change. The same shape keeps every cell, holes included; a new shape keeps each tile at its row and column when all fit (2x2 growing to 3x2: the four tiles stay put), else the tiles pack in reading order. - tileInDirection takes cells: focus never lands on an empty cell. Left and right go along the row past a hole and never leave it; up and down take the nearest row with a tile, the same column else the nearest (lower on a tie). For a packed list this is exactly the old rule, short last row included. - tileCellInDirection: the adjacent cell a Move Tile chord moves into or swaps with. - sanitizeTileGridState reads the stored ids as cells (null for an empty one) and returns them as `cells` beside the packed `ids`; a dropped id becomes a hole, never a shift. The old packed format reads as cells with no hole. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/constants.js | 132 +++++++++++++++++++++++++++------- test/tile-grid-layout.test.ts | 111 +++++++++++++++++++++++++++- 2 files changed, 215 insertions(+), 28 deletions(-) diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 42dabac0..03d352bd 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1717,11 +1717,17 @@ function tileGridCapacity({ width, height }) { * must be 1 to 3 finite positive numbers. Anything that is not a v1 object * (or its JSON) gives null. * + * The stored `ids` are the grid's CELLS in reading order, `null` for an empty + * one (a hole can be any cell). The old packed list (no nulls) reads as cells + * with no hole. `ids` comes back packed (the tiles in reading order, what + * every list consumer wants) and `cells` keeps the holes: a dropped id (gone, + * detached, a duplicate, past the cap) becomes `null` there, never a shift. + * * @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} + * @returns {{v: 1, open: boolean, ids: string[], cells: (string|null)[], focused: string|null, + * zoomed: string|null, colFr: number[]|null, rowFr: number[]|null}|null} */ function sanitizeTileGridState(raw, liveSessions, detachedIds) { let value = raw; @@ -1731,12 +1737,14 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) { 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 cells = []; + for (const id of (Array.isArray(value.ids) ? value.ids : []).slice(0, TILE_LAYOUT_MAX)) { + const keep = + typeof id === 'string' && id && !ids.includes(id) && live.has(id) && !detachedIds?.has?.(id) && + ids.length < TILE_GRID_MAX; + if (keep) ids.push(id); + // A malformed entry (not a string, not null) is a hole too. + cells.push(keep ? id : null); } const fractions = (fr) => { if (!Array.isArray(fr) || fr.length < 1 || fr.length > 3) return null; @@ -1746,6 +1754,7 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) { v: 1, open: value.open === true && ids.length > 0, ids, + cells, focused: ids.includes(value.focused) ? value.focused : (ids[0] ?? null), zoomed: ids.includes(value.zoomed) ? value.zoomed : null, colFr: fractions(value.colFr), @@ -1855,31 +1864,102 @@ function tileNeighbor(ids, id) { /** * 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. + * columns whose empty cells are `null` (a hole can be any cell) or simply + * missing at the end. Focus never lands on a hole. Left and right go along + * the row, past any hole, and never leave it. Up and down take the nearest + * row in that direction that has a tile: the tile in the same column, else + * the one in the nearest column (the lower column on a tie), so moving down + * onto a short or holed last row lands on its nearest tile. Null when there + * is no tile in that direction. * - * @param {string[]} ids - the grid's tiles, in reading order + * @param {(string|null)[]} cells - the grid's cells in reading order (or its packed tiles) * @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; +function tileInDirection(cells, focusedId, direction, cols) { + const i = focusedId ? cells.indexOf(focusedId) : -1; + if (i === -1 || !(cols >= 1)) return null; + const rows = Math.ceil(cells.length / cols); + const row = Math.floor(i / cols); 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; + const at = (r, c) => cells[r * cols + c] || null; + if (direction === 'left' || direction === 'right') { + const step = direction === 'left' ? -1 : 1; + for (let c = col + step; c >= 0 && c < cols; c += step) { + if (at(row, c)) return at(row, c); + } + return null; } - return j >= 0 && j < n && j !== i ? ids[j] : null; + if (direction !== 'up' && direction !== 'down') return null; + const step = direction === 'up' ? -1 : 1; + for (let r = row + step; r >= 0 && r < rows; r += step) { + let best = null; + let bestDistance = Infinity; + for (let c = 0; c < cols; c++) { + const id = at(r, c); + if (id && Math.abs(c - col) < bestDistance) { + best = id; + bestDistance = Math.abs(c - col); + } + } + if (best) return best; + } + return null; +} + +/** + * The cell next to cell `index` in that direction (Move Tile: a tile moves + * into an empty neighbour cell, or swaps with a tiled one), or -1 at the + * edge. Adjacent only: a move never jumps over a cell. + * + * @param {number} index - the cell, in reading order + * @param {'left'|'right'|'up'|'down'} direction + * @param {number} cols - the layout's column count + * @param {number} cellCount - cols x rows + * @returns {number} + */ +function tileCellInDirection(index, direction, cols, cellCount) { + if (!(cols >= 1) || index < 0 || index >= cellCount) return -1; + const col = index % cols; + let j = -1; + if (direction === 'left') j = col > 0 ? index - 1 : -1; + else if (direction === 'right') j = col < cols - 1 ? index + 1 : -1; + else if (direction === 'up') j = index - cols; + else if (direction === 'down') j = index + cols; + return j >= 0 && j < cellCount ? j : -1; +} + +/** + * The grid's cells after its shape changed (or to fill one for the first + * time): `cols` x `rows` cells, `null` for an empty one. The same shape keeps + * every cell as it is. A new shape keeps each tile at its row and column when + * every tile still fits there (2x2 growing to 3x2: the four tiles stay put), + * and otherwise packs the tiles in reading order from the first cell, holes + * collapsed (positions do not map between shapes). `oldCols` 0 (no layout + * yet) always packs. + * + * @param {(string|null)[]} cells - the current cells, laid out `oldCols` wide + * @param {number} oldCols - the column count they were laid out with + * @param {number} cols + * @param {number} rows + * @returns {(string|null)[]} + */ +function fitTileCells(cells, oldCols, cols, rows) { + const size = Math.max(0, cols * rows); + if (oldCols === cols && cells.length === size) return cells.slice(); + const out = new Array(size).fill(null); + const placed = cells.map((id, k) => (id ? { id, row: Math.floor(k / oldCols), col: k % oldCols } : null)); + const keep = oldCols >= 1 && placed.every((p) => !p || (p.row < rows && p.col < cols)); + if (keep) { + for (const p of placed) if (p) out[p.row * cols + p.col] = p.id; + return out; + } + cells.filter(Boolean).slice(0, size).forEach((id, k) => { + out[k] = id; + }); + return out; } /** @@ -2204,6 +2284,8 @@ if (typeof window !== 'undefined') { dragTrackFractions, tileNeighbor, tileInDirection, + tileCellInDirection, + fitTileCells, cycleTile, tileGridOpenSet, TILE_GRID_MAX, diff --git a/test/tile-grid-layout.test.ts b/test/tile-grid-layout.test.ts index 4eeb2814..4540b705 100644 --- a/test/tile-grid-layout.test.ts +++ b/test/tile-grid-layout.test.ts @@ -6,9 +6,17 @@ * 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). + * apply (unknown, deleted, detached and duplicate ids dropped); the stored + * `ids` are the grid's cells, `null` for a hole, and come back as `cells` + * (holes kept, a dropped id a hole) beside the packed `ids`. The old packed + * format reads as cells with no hole. + * - `fitTileCells`: the cells after a shape change (owner: an empty cell can be + * any cell). Same shape: unchanged; a new one: each tile keeps its row and + * column when all fit, else the tiles pack in reading order. * - `tileNeighbor`, `tileInDirection`, `cycleTile`: which tile takes focus when - * one leaves, on a directional chord, and on Ctrl+Tab / Alt+[ ]. + * one leaves, on a directional chord (over cells: never onto a hole), and on + * Ctrl+Tab / Alt+[ ]. `tileCellInDirection`: the adjacent cell a Move Tile + * chord moves into or swaps with. * * Loaded via `vm` like split-pane-helpers.test.ts. Port: N/A. */ @@ -23,7 +31,9 @@ type TileGrid = { 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; + tileInDirection(cells: Array, focused: string, dir: string, cols: number): string | null; + tileCellInDirection(index: number, dir: string, cols: number, cellCount: number): number; + fitTileCells(cells: Array, oldCols: number, cols: number, rows: number): Array; cycleTile(ids: string[], focused: string, delta: number): string | null; TILE_GRID_MAX: number; TILE_LAYOUT_MAX: number; @@ -111,6 +121,7 @@ describe('sanitizeTileGridState', () => { v: 1, open: true, ids: ['a', 'b'], + cells: ['a', 'b'], focused: 'b', zoomed: 'a', colFr: [1, 2], @@ -161,6 +172,33 @@ describe('sanitizeTileGridState', () => { expect(out?.rowFr).toBeNull(); }); + it('stored cells keep their holes; the list beside them is packed', () => { + const raw = { v: 1, open: true, ids: ['a', null, 'b', 'c', 'd', null], focused: 'c' }; + const out = T.sanitizeTileGridState(raw, live); + expect(out?.cells).toEqual(['a', null, 'b', 'c', 'd', null]); + expect(out?.ids).toEqual(['a', 'b', 'c', 'd']); + expect(out?.focused).toBe('c'); + }); + + it('a dropped id (gone, detached, a duplicate, malformed) leaves a hole where it was, never a shift', () => { + const raw = { v: 1, open: true, ids: ['a', 'gone', 'b', 'a', 7, 'c'] }; + const out = T.sanitizeTileGridState(raw, live, new Set(['b'])); + expect(out?.cells).toEqual(['a', null, null, null, null, 'c']); + expect(out?.ids).toEqual(['a', 'c']); + }); + + it('the old packed format reads unchanged: cells with no hole', () => { + const out = T.sanitizeTileGridState({ v: 1, open: true, ids: ['a', 'b', 'c'] }, live); + expect(out?.cells).toEqual(['a', 'b', 'c']); + expect(out?.ids).toEqual(['a', 'b', 'c']); + }); + + it('past the cap a tile becomes a hole; no more cells than the largest layout', () => { + const many = Array.from({ length: 12 }, (_, i) => `s${i}`); + const out = T.sanitizeTileGridState({ v: 1, open: true, ids: many }, many); + expect(out?.cells).toEqual([...many.slice(0, 6), null, null, null]); + }); + it.each([null, 'not json', '[]', 42, { v: 2, ids: ['a'] }, { ids: ['a'] }])('rejects %j', (raw) => { expect(T.sanitizeTileGridState(raw, live)).toBeNull(); }); @@ -190,6 +228,47 @@ describe('focus helpers', () => { expect(T.tileInDirection(ids, 'e', 'down', 3)).toBeNull(); }); + it('tileInDirection never lands on a hole: along a row it skips one, never leaving the row', () => { + // 3x2: a _ c + // d e f + const cells = ['a', null, 'c', 'd', 'e', 'f']; + expect(T.tileInDirection(cells, 'a', 'right', 3)).toBe('c'); + expect(T.tileInDirection(cells, 'c', 'left', 3)).toBe('a'); + expect(T.tileInDirection(cells, 'c', 'right', 3)).toBeNull(); + expect(T.tileInDirection(cells, 'd', 'left', 3)).toBeNull(); + // Up from e (a hole above): the nearest tile in that row, the lower column on a tie. + expect(T.tileInDirection(cells, 'e', 'up', 3)).toBe('a'); + expect(T.tileInDirection(cells, 'f', 'up', 3)).toBe('c'); + expect(T.tileInDirection(cells, 'a', 'down', 3)).toBe('d'); + }); + + it('tileInDirection: up and down go to the nearest row that has a tile, even across a column', () => { + // 3x2: a b c + // _ _ f + const cells = ['a', 'b', 'c', null, null, 'f']; + expect(T.tileInDirection(cells, 'a', 'down', 3)).toBe('f'); + expect(T.tileInDirection(cells, 'c', 'down', 3)).toBe('f'); + expect(T.tileInDirection(cells, 'f', 'up', 3)).toBe('c'); + expect(T.tileInDirection(cells, 'f', 'left', 3)).toBeNull(); + // A row with no tile at all is passed over (a 3x3, which the layout table keeps). + const tall = ['a', null, null, null, null, null, null, 'h', null]; + expect(T.tileInDirection(tall, 'a', 'down', 3)).toBe('h'); + expect(T.tileInDirection(tall, 'h', 'up', 3)).toBe('a'); + // A hole is never the focused cell either. + expect(T.tileInDirection(cells, null as unknown as string, 'up', 3)).toBeNull(); + }); + + it('tileCellInDirection: the adjacent cell, or -1 at the edge', () => { + // 3x2 cells 0 1 2 / 3 4 5 + expect([0, 1, 2, 3, 4, 5].map((i) => T.tileCellInDirection(i, 'left', 3, 6))).toEqual([-1, 0, 1, -1, 3, 4]); + expect([0, 1, 2, 3, 4, 5].map((i) => T.tileCellInDirection(i, 'right', 3, 6))).toEqual([1, 2, -1, 4, 5, -1]); + expect([0, 1, 2, 3, 4, 5].map((i) => T.tileCellInDirection(i, 'up', 3, 6))).toEqual([-1, -1, -1, 0, 1, 2]); + expect([0, 1, 2, 3, 4, 5].map((i) => T.tileCellInDirection(i, 'down', 3, 6))).toEqual([3, 4, 5, -1, -1, -1]); + // 2x2 + expect([0, 1, 2, 3].map((i) => T.tileCellInDirection(i, 'down', 2, 4))).toEqual([2, 3, -1, -1]); + expect(T.tileCellInDirection(7, 'left', 3, 6)).toBe(-1); + }); + 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'); @@ -197,6 +276,32 @@ describe('focus helpers', () => { }); }); +describe('fitTileCells (the cells after a shape change)', () => { + it('the same shape keeps every cell, holes included', () => { + expect(T.fitTileCells(['a', null, 'c', 'd', 'e', 'f'], 3, 3, 2)).toEqual(['a', null, 'c', 'd', 'e', 'f']); + }); + + it('a first layout (no columns yet) packs and pads', () => { + expect(T.fitTileCells(['a', 'b', 'c', 'd', 'e'], 0, 3, 2)).toEqual(['a', 'b', 'c', 'd', 'e', null]); + expect(T.fitTileCells(['a', 'b', 'c'], 0, 2, 2)).toEqual(['a', 'b', 'c', null]); + }); + + it('growing 2x2 to 3x2: every tile keeps its row and column', () => { + expect(T.fitTileCells(['a', 'b', 'c', 'd'], 2, 3, 2)).toEqual(['a', 'b', null, 'c', 'd', null]); + // 2x1 to 2x2 likewise (the same as packing there). + expect(T.fitTileCells(['a', 'b'], 2, 2, 2)).toEqual(['a', 'b', null, null]); + }); + + it('shrinking: kept where every tile fits, else packed in reading order', () => { + // 3x2 -> 2x2 with the third column empty: the tiles stay put. + expect(T.fitTileCells(['a', 'b', null, 'd', 'e', null], 3, 2, 2)).toEqual(['a', 'b', 'd', 'e']); + // A tile in the third column: packed, holes collapsed. + expect(T.fitTileCells(['a', null, 'c', 'd', 'e', null], 3, 2, 2)).toEqual(['a', 'c', 'd', 'e']); + // 2x2 -> 3x1 (a wider window, three tiles): row 1 does not fit, packed. + expect(T.fitTileCells(['a', null, 'c', 'd'], 2, 3, 1)).toEqual(['a', 'c', 'd']); + }); +}); + describe('dragTrackFractions (divider drags)', () => { const drag = (fr: number[], i: number, d: number, total = 1200, min = 300) => (T as unknown as { dragTrackFractions: (...a: unknown[]) => number[] }).dragTrackFractions(fr, i, d, total, min);