mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, number>): Layout;
|
||||
tileGridCapacity(p: Record<string, number>): number;
|
||||
sanitizeTileGridState(raw: unknown, live: unknown, detached?: Set<string>): Record<string, unknown> | 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user