From 64298101b06ee054340890508884cf55187b62c1 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 8 Oct 2026 03:53:10 +0200 Subject: [PATCH] feat(tiles): pure helpers for a tile count of 2, 4 or 6 The Tiles button's right-click becomes a count menu (owner decision 10): how many tiles, 2, 4 or 6, default 6, remembered per device. These are its pure parts in constants.js (window.CodemanTileGrid): - TILE_GRID_COUNTS / TILE_GRID_COUNT_DEFAULT and sanitizeTileCount: a remembered count is one of 2, 4, 6, anything else reads as 6. - tileGridSetForCount: what the grid opens (or an open grid shows) trimmed or filled to N: trimmed from the end with the session to focus always kept, filled from the open sessions in tab order; fewer sessions than N give fewer tiles; never past the cap. - tileCellCols: the column count a stored cell list was laid out with (stored cells carry no shape of their own). - reformTileCells: a count change is a shape change: the tiles that stay keep their cells, the cell model's rule (fitTileCells) reshapes, and the tiles that join fill the empty cells in reading order, holes first. Nothing uses them yet; the menu comes next. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/constants.js | 93 ++++++++++++++++ test/tile-grid-count-helpers.test.ts | 157 +++++++++++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 test/tile-grid-count-helpers.test.ts diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 03d352bd..b170c453 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1848,6 +1848,45 @@ function tileGridOpenSet({ stored = null, split = null, sessions, sessionOrder, return { source: 'tabs', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] }; } +/** + * The tile counts the Tiles button's right-click menu offers, and the count a + * click opens until one is picked (owner decision 10 in docs/tile-grid-plan.md). + */ +const TILE_GRID_COUNTS = [2, 4, 6]; +const TILE_GRID_COUNT_DEFAULT = 6; + +/** A remembered tile count made safe: one of TILE_GRID_COUNTS, else the default. */ +function sanitizeTileCount(raw) { + const n = Number(raw); + return TILE_GRID_COUNTS.includes(n) ? n : TILE_GRID_COUNT_DEFAULT; +} + +/** + * `base` (what the grid would open, or what an open grid shows, in its order) + * trimmed or filled to `n` tiles: trimmed from the end, the session to focus + * (`keepId`) always kept (it takes the last place when it sat past `n`, as in + * tileGridOpenSet's case c); filled from `all` (the open sessions in tab order) + * with the ones not in it yet. Fewer sessions than `n` give fewer tiles. + * + * @param {string[]} base + * @param {string[]} all + * @param {number} n - at most TILE_GRID_MAX + * @param {string|null} [keepId] + * @returns {string[]} + */ +function tileGridSetForCount(base, all, n, keepId = null) { + const max = Math.max(1, Math.min(Math.floor(Number(n) || 0), TILE_GRID_MAX)); + const ids = []; + for (const id of [...(base || []), ...(all || [])]) { + if (typeof id === 'string' && id && !ids.includes(id)) ids.push(id); + } + // Every `base` id comes before every filler, so a trim never drops a base id + // in favour of one. + let out = ids.slice(0, max); + if (keepId && ids.includes(keepId) && !out.includes(keepId)) out = [...out.slice(0, max - 1), keepId]; + return out; +} + /** * Which tile takes focus when `id` leaves the grid: the next one in grid * order, else the previous one, else null. @@ -1962,6 +2001,54 @@ function fitTileCells(cells, oldCols, cols, rows) { return out; } +/** + * How many columns a grid of `length` cells was laid out with: stored cells + * carry no shape of their own, and the layout table gives each cell count one + * shape (computeTileLayout: 1x1, 2x1, 3x1, 2x2, 3x2, 3x3). 0 for any other + * length (fitTileCells then packs). + * + * @param {number} length + * @returns {number} + */ +function tileCellCols(length) { + // Every cell count is some count's shape on a wide grid area (2x2, the + // narrow 3-tile shape, is also the 4-tile one). + for (let n = 1; n <= TILE_LAYOUT_MAX; n++) { + const { cols, rows } = computeTileLayout({ count: n }); + if (cols * rows === length) return cols; + } + return 0; +} + +/** + * The cells of a grid re-formed to another set of tiles (a count picked in the + * Tiles menu, or the Tiles button bringing back a remembered grid with more or + * fewer tiles): the tiles in `keep` stay in their cells and every other cell + * empties, then the cell model's shape rule (fitTileCells: each tile keeps its + * row and column when all fit, else they pack in reading order), then the + * tiles in `add` fill the empty cells in reading order, holes first. + * + * @param {(string|null)[]} cells - the cells now, laid out `oldCols` wide + * @param {number} oldCols + * @param {string[]} keep - tiles that stay + * @param {string[]} add - tiles that join, in the order they fill + * @param {number} cols - the new shape + * @param {number} rows + * @returns {(string|null)[]} + */ +function reformTileCells(cells, oldCols, keep, add, cols, rows) { + const kept = (cells || []).map((id) => (id && keep.includes(id) ? id : null)); + const out = fitTileCells(kept, oldCols, cols, rows); + for (const id of add) { + if (!id || out.includes(id)) continue; + const k = out.indexOf(null); + // Not for a shape made for the count; a full grid takes no more. + if (k === -1) break; + out[k] = id; + } + return out; +} + /** * 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. @@ -2288,6 +2375,12 @@ if (typeof window !== 'undefined') { fitTileCells, cycleTile, tileGridOpenSet, + sanitizeTileCount, + tileGridSetForCount, + tileCellCols, + reformTileCells, + TILE_GRID_COUNTS, + TILE_GRID_COUNT_DEFAULT, TILE_GRID_MAX, TILE_LAYOUT_MAX, TILE_MIN_W, diff --git a/test/tile-grid-count-helpers.test.ts b/test/tile-grid-count-helpers.test.ts new file mode 100644 index 00000000..99b0fcb0 --- /dev/null +++ b/test/tile-grid-count-helpers.test.ts @@ -0,0 +1,157 @@ +/** + * @fileoverview The tile count (owner decision 10): the pure helpers behind the + * Tiles button's 2 / 4 / 6 right-click menu. + * + * - `sanitizeTileCount`: a remembered count is one of TILE_GRID_COUNTS, else + * the default (6). + * - `tileGridSetForCount`: what the grid opens (or an open grid shows) trimmed + * or filled to N: trimmed from the end with the session to focus always + * kept, filled from the open sessions in tab order; fewer sessions than N + * give fewer tiles; never past the cap. + * - `tileCellCols`: the column count a stored cell list was laid out with. + * - `reformTileCells`: a count change is a shape change: kept tiles stay in + * their cells, the cell model's rule (fitTileCells) reshapes, joining tiles + * fill the empty cells in reading order, holes first. + * + * The helpers from constants.js as the shared harness loads them. + * Port: N/A. + */ +import { describe, expect, it } from 'vitest'; +import { windowStub } from './mocks/tile-grid-vm.js'; + +type Cells = (string | null)[]; +type Helpers = { + sanitizeTileCount(raw: unknown): number; + tileCellCols(length: number): number; + reformTileCells(cells: Cells, oldCols: number, keep: string[], add: string[], cols: number, rows: number): Cells; + computeTileLayout(p: { count: number; width?: number }): { cols: number; rows: number }; + tileGridSetForCount(base: string[], all: string[], n: number, keepId?: string | null): string[]; + TILE_GRID_COUNTS: number[]; + TILE_GRID_COUNT_DEFAULT: number; + TILE_GRID_MAX: number; +}; +const T = windowStub.CodemanTileGrid as Helpers; +const all = ['t1', 't2', 't3', 't4', 't5', 't6', 't7', 't8']; + +describe('sanitizeTileCount', () => { + it('offers 2, 4 and 6, default 6', () => { + expect(T.TILE_GRID_COUNTS).toEqual([2, 4, 6]); + expect(T.TILE_GRID_COUNT_DEFAULT).toBe(6); + }); + + it('keeps a valid count, as a number or as stored text', () => { + expect(T.sanitizeTileCount(2)).toBe(2); + expect(T.sanitizeTileCount('4')).toBe(4); + expect(T.sanitizeTileCount('6')).toBe(6); + }); + + it('anything else is the default', () => { + for (const raw of [null, undefined, '', '3', 5, 9, 0, -2, 'six', '{}', NaN]) { + expect(T.sanitizeTileCount(raw)).toBe(6); + } + }); +}); + +describe('tileGridSetForCount', () => { + it('fills from tab order after the base, skipping ones already in it', () => { + expect(T.tileGridSetForCount(['t5', 't2'], all, 6)).toEqual(['t5', 't2', 't1', 't3', 't4', 't6']); + }); + + it('trims from the end', () => { + expect(T.tileGridSetForCount(['t1', 't2', 't3', 't4', 't5', 't6'], all, 4)).toEqual(['t1', 't2', 't3', 't4']); + }); + + it('keeps the session to focus when it sat past N, in the last place', () => { + expect(T.tileGridSetForCount(['t1', 't2', 't3', 't4', 't5', 't6'], all, 2, 't5')).toEqual(['t1', 't5']); + expect(T.tileGridSetForCount(['t1', 't2', 't3', 't4'], all, 2, 't2')).toEqual(['t1', 't2']); + }); + + it('a base id is never trimmed in favour of a filler', () => { + expect(T.tileGridSetForCount(['t7', 't8'], all, 2)).toEqual(['t7', 't8']); + }); + + it('fewer open sessions than N: fewer tiles', () => { + expect(T.tileGridSetForCount(['t1'], ['t1', 't2', 't3'], 6)).toEqual(['t1', 't2', 't3']); + expect(T.tileGridSetForCount([], [], 4)).toEqual([]); + }); + + it('never past the cap, and at least one', () => { + expect(T.tileGridSetForCount([], all, 9)).toHaveLength(T.TILE_GRID_MAX); + expect(T.tileGridSetForCount([], all, 0)).toEqual(['t1']); + }); + + it('drops duplicates and non-ids', () => { + expect(T.tileGridSetForCount(['t2', 't2', '', null as unknown as string], all, 3)).toEqual(['t2', 't1', 't3']); + }); +}); + +describe('tileCellCols', () => { + it('reads the shape back from the cell count, as the layout table lays it out', () => { + expect([1, 2, 3, 4, 6, 9].map((n) => T.tileCellCols(n))).toEqual([1, 2, 3, 2, 3, 3]); + }); + + it('agrees with computeTileLayout for every count, wide and narrow', () => { + for (let n = 1; n <= 9; n++) { + for (const width of [Infinity, 0]) { + const { cols, rows } = T.computeTileLayout({ count: n, width }); + expect(T.tileCellCols(cols * rows)).toBe(cols); + } + } + }); + + it('an unknown length is 0 (the tiles then pack)', () => { + for (const n of [0, 5, 7, 8, 10, -1, NaN]) expect(T.tileCellCols(n)).toBe(0); + }); +}); + +describe('reformTileCells', () => { + it('growing 2 (2x1) to 6 (3x2): both stay in their cells, the four new ones fill in reading order', () => { + expect(T.reformTileCells(['a', 'b'], 2, ['a', 'b'], ['c', 'd', 'e', 'f'], 3, 2)).toEqual([ + 'a', 'b', 'c', + 'd', 'e', 'f', + ]); + }); + + it('growing fills the holes first, in reading order', () => { + // 5 of a 3x2 with the hole in the middle of the first row, then 6. + expect(T.reformTileCells(['a', null, 'b', 'c', 'd', 'e'], 3, ['a', 'b', 'c', 'd', 'e'], ['f'], 3, 2)).toEqual([ + 'a', 'f', 'b', + 'c', 'd', 'e', + ]); + // 3 of a 2x2 (hole first) growing to 4 keeps the shape: the hole fills. + expect(T.reformTileCells([null, 'a', 'b', 'c'], 2, ['a', 'b', 'c'], ['d'], 2, 2)).toEqual(['d', 'a', 'b', 'c']); + }); + + it('a 2x2 growing to 3x2 keeps every tile at its row and column', () => { + expect(T.reformTileCells(['a', 'b', 'c', 'd'], 2, ['a', 'b', 'c', 'd'], ['e', 'f'], 3, 2)).toEqual([ + 'a', 'b', 'e', + 'c', 'd', 'f', + ]); + }); + + it('shrinking 6 (3x2) to 4 (2x2): a tile in the third column does not fit, so the kept ones pack', () => { + expect(T.reformTileCells(['a', 'b', 'c', 'd', 'e', 'f'], 3, ['a', 'b', 'c', 'd'], [], 2, 2)).toEqual([ + 'a', 'b', 'c', 'd', + ]); + }); + + it('shrinking keeps rows and columns when every kept tile still fits', () => { + // 3x2 to 2x2 keeping two tiles inside the first two columns: they stay at + // their row and column, holes and all (packing would move b up). + expect(T.reformTileCells([null, 'a', 'x', 'b', null, 'y'], 3, ['a', 'b'], [], 2, 2)).toEqual([ + null, 'a', + 'b', null, + ]); + // 2x2 to 2x1 keeping the first row. + expect(T.reformTileCells(['a', 'b', 'c', 'd'], 2, ['a', 'b'], [], 2, 1)).toEqual(['a', 'b']); + }); + + it('a stored grid of unknown shape (oldCols 0) packs', () => { + expect(T.reformTileCells([null, 'a', 'b'], 0, ['a', 'b'], ['c'], 2, 2)).toEqual(['a', 'b', 'c', null]); + }); + + it('a full grid takes no more, and a tile already there is not added twice', () => { + expect(T.reformTileCells(['a', 'b'], 2, ['a', 'b'], ['c', 'a'], 2, 1)).toEqual(['a', 'b']); + expect(T.reformTileCells(['a', null], 2, ['a'], ['a', 'c'], 2, 1)).toEqual(['a', 'c']); + }); +});