From 65e8271fbe68d0a40bcf58c4b592b5b266847c82 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 6 Oct 2026 17:59:16 +0200 Subject: [PATCH] feat(tiles): draggable column and row dividers The grid now places every tile explicitly (grid-column / grid-row, reading order) with a 6px divider track between columns and between rows, instead of relying on DOM order and a gap. Track sizes are fractions (grid-template fr values) that reset to equal whenever the column or row count changes. Dragging a divider trades size between the two tracks either side, each kept at the minimum tile size (the pure dragTrackFractions in constants.js, always computed from the fractions the drag started with, so it cannot drift). The affected tiles reflow locally at most once per animation frame, with no PTY resize; each hears exactly one fit (one PTY resize) at pointer-up, and tiles in other tracks hear nothing. Pointer capture keeps the drag on the divider, the body locks the resize cursor and text selection for its duration, and closing the grid or removing a tile mid-drag tears it down, as the split's divider does. A zoomed grid shows no dividers. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/constants.js | 31 +++++ src/web/public/styles.css | 62 ++++++++- src/web/public/tile-grid.js | 170 ++++++++++++++++++++++++- test/mocks/tile-grid-vm.ts | 14 ++- test/tile-grid-dividers.test.ts | 214 ++++++++++++++++++++++++++++++++ test/tile-grid-layout.test.ts | 36 +++++- test/tile-grid-zoom.test.ts | 2 +- 7 files changed, 518 insertions(+), 11 deletions(-) create mode 100644 test/tile-grid-dividers.test.ts diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 0b0ed6dd..0be0d606 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1741,6 +1741,36 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) { }; } +/** + * New track fractions after a divider drag (grid-template `fr` values): the two + * tracks either side of divider `index` trade `deltaPx` of size, each kept at + * least `minPx` (or half the pair, if the pair cannot give both the minimum). + * Every other track keeps its size. Computed from the fractions the drag + * STARTED with and the pointer's total travel, so a drag never drifts. + * + * @param {number[]} fr - the fractions when the drag started + * @param {number} index - the divider: between track `index` and `index + 1` + * @param {number} deltaPx - pointer travel since the drag started + * @param {number} totalPx - the size the tracks share (dividers and padding excluded) + * @param {number} minPx - the smallest a track may get + * @returns {number[]} new fractions, same length + */ +function dragTrackFractions(fr, index, deltaPx, totalPx, minPx) { + const out = fr.slice(); + if (index < 0 || index + 1 >= fr.length || !(totalPx > 0)) return out; + const sum = fr.reduce((a, b) => a + b, 0); + if (!(sum > 0)) return out; + const a = (fr[index] / sum) * totalPx; + const b = (fr[index + 1] / sum) * totalPx; + const pair = a + b; + const lo = Math.min(minPx, pair / 2); + const hi = pair - lo; + const nextA = Math.min(Math.max(a + (Number(deltaPx) || 0), lo), hi); + out[index] = (nextA / totalPx) * sum; + out[index + 1] = ((pair - nextA) / totalPx) * sum; + return out; +} + /** * The sessions the Tiles picker (and a tile's + menu) offers, in tab order: * live ones only, never a session popped out to its own window (that window @@ -2074,6 +2104,7 @@ if (typeof window !== 'undefined') { tileGridCapacity, sanitizeTileGridState, buildTilePickerSessions, + dragTrackFractions, tileNeighbor, tileInDirection, cycleTile, diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 3ec4f522..e1282ec0 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -19386,7 +19386,9 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle flex: 1; min-width: 0; min-height: 0; - gap: 4px; + /* No gap: the divider tracks between columns and rows are the spacing + (tile-grid.js TILE_DIVIDER_PX; the padding is TILE_GRID_PADDING_PX). */ + gap: 0; padding: 4px; background: var(--bg-dark); } @@ -19501,6 +19503,64 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle outline: none; } +/* Column and row dividers: their own grid tracks, dragged to trade size + between neighbours. The line only shows on hover or while dragging. */ +.tile-divider { + position: relative; + z-index: 1; + touch-action: none; +} + +.tile-divider--col { + cursor: col-resize; +} + +.tile-divider--row { + cursor: row-resize; +} + +.tile-divider::after { + content: ''; + position: absolute; + inset: 0; + margin: auto; + background: transparent; +} + +.tile-divider--col::after { + width: 2px; +} + +.tile-divider--row::after { + height: 2px; +} + +.tile-divider:hover::after, +.tile-divider.dragging::after { + background: var(--accent, #4a9eff); +} + +.tile-grid.tile-grid--zoomed .tile-divider { + display: none; +} + +/* While a divider drags: the resize cursor everywhere and no text selection + (mirrors body.split-pane-resizing). */ +body.tile-grid-resizing, +body.tile-grid-resizing * { + user-select: none !important; +} + +body.tile-grid-resizing--col, +body.tile-grid-resizing--col * { + cursor: col-resize !important; +} + +body.tile-grid-resizing--row, +body.tile-grid-resizing--row * { + cursor: row-resize !important; +} + /* Zoom (tmux-style): one tile fills the grid; the others stay connected but hidden, so they measure nothing and send no resize. */ .tile-grid.tile-grid--zoomed .tile:not(.tile--zoomed) { diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index 34b1c581..1251acfe 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -26,6 +26,15 @@ const TILE_GRID_FONT_KEY = 'codeman-tile-font-size'; // Trailing debounce for refitting tiles after the grid area changes size, so a // window drag sends each tile's PTY one resize, not one per frame. const TILE_GRID_REFIT_MS = 150; +// Width of the draggable column and row dividers (their own grid tracks), and +// the grid section's padding (styles.css .tile-grid), for the drag math. +const TILE_DIVIDER_PX = 6; +const TILE_GRID_PADDING_PX = 4; + +/** `minmax(0, 1fr) 6px minmax(0, 2fr) ...`: tracks with a divider track between each. */ +function tileGridTracks(fr) { + return fr.map((f) => `minmax(0, ${Math.round(f * 1000) / 1000}fr)`).join(` ${TILE_DIVIDER_PX}px `); +} // Registry ids of the tile chords (DEFAULT_SHORTCUTS, app.js), and whether each // needs the grid open. The toggle applies wherever a grid could open. const TILE_SHORTCUTS = { @@ -52,6 +61,12 @@ class TileGridModel { // lifts once the window fits again. this.zoomedId = null; this.autoZoom = false; + // Track fractions (grid-template fr values) set by the dividers; equal + // again whenever the column or row count changes. + this.colFr = []; + this.rowFr = []; + // 'col-' / 'row-' -> the divider element between track i and i+1. + this.dividers = new Map(); this.cols = 0; this.rows = 0; this.queue = null; @@ -224,6 +239,8 @@ Object.assign(CodemanApp.prototype, { const focusedId = grid.focusedId; const ids = grid.ids.slice(); this._tileGridRemembered = keepStored ? { ids, focusedId } : null; + // A divider drag in progress ends with the grid. + this._tileDividerDragTeardown?.(); // Closed BEFORE the tiles go: each destroy() updates the header's connection // state, which must read the main terminal again, not half-destroyed tiles. grid.open = false; @@ -237,6 +254,10 @@ Object.assign(CodemanApp.prototype, { el.remove(); } grid.tiles.clear(); + for (const el of grid.dividers.values()) el.remove(); + grid.dividers.clear(); + grid.colFr = []; + grid.rowFr = []; grid.ids = []; grid.focusedId = null; grid.zoomedId = null; @@ -646,6 +667,8 @@ Object.assign(CodemanApp.prototype, { } const wasFocused = grid.focusedId === sessionId; const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, sessionId); + // A divider drag in progress was measured against this tile. + this._tileDividerDragTeardown?.(); // The zoomed tile leaving restores the grid (an automatic zoom moves to // the neighbour with focus, below). if (grid.zoomedId === sessionId) grid.zoomedId = grid.autoZoom && refocus ? neighbor : null; @@ -1010,6 +1033,8 @@ Object.assign(CodemanApp.prototype, { width: rect.width || window.innerWidth, height: rect.height || window.innerHeight, }); + if (grid.colFr.length !== cols) grid.colFr = new Array(cols).fill(1); + if (grid.rowFr.length !== rows) grid.rowFr = new Array(rows).fill(1); grid.cols = cols; grid.rows = rows; // A window too small for the tiles' minimum size shows the focused tile @@ -1039,9 +1064,148 @@ Object.assign(CodemanApp.prototype, { } } // Zoomed: one cell; the other tiles stay connected but hidden (CSS), so - // they measure nothing and send no resize. - section.style.gridTemplateColumns = zoomed ? 'minmax(0, 1fr)' : `repeat(${cols}, minmax(0, 1fr))`; - section.style.gridTemplateRows = zoomed ? 'minmax(0, 1fr)' : `repeat(${rows}, minmax(0, 1fr))`; + // they measure nothing and send no resize. Otherwise every tile is placed + // explicitly in reading order, with a divider track between columns and + // between rows. + section.style.gridTemplateColumns = zoomed ? 'minmax(0, 1fr)' : tileGridTracks(grid.colFr); + section.style.gridTemplateRows = zoomed ? 'minmax(0, 1fr)' : tileGridTracks(grid.rowFr); + grid.ids.forEach((id, k) => { + const el = grid.tiles.get(id)?.el; + if (!el) return; + el.style.gridColumn = id === zoomed ? '1' : String(2 * (k % cols) + 1); + el.style.gridRow = id === zoomed ? '1' : String(2 * Math.floor(k / cols) + 1); + }); + this._syncTileDividers(zoomed ? 0 : cols, zoomed ? 0 : rows); + }, + + // One divider per gap between columns and between rows, created and dropped + // as the counts change (never rebuilt while they stay, so a drag in progress + // keeps its element). + _syncTileDividers(cols, rows) { + const grid = this._tileGrid; + const section = this._tileGridSection(); + const wanted = new Set(); + for (let i = 0; i < cols - 1; i++) wanted.add(`col-${i}`); + for (let i = 0; i < rows - 1; i++) wanted.add(`row-${i}`); + for (const [key, el] of grid.dividers) { + if (wanted.has(key)) continue; + if (this._tileDividerDrag?.key === key) this._tileDividerDragTeardown?.(); + el.remove(); + grid.dividers.delete(key); + } + for (const key of wanted) { + let el = grid.dividers.get(key); + const [axis, n] = key.split('-'); + const index = Number(n); + if (!el) { + el = document.createElement('div'); + el.className = `tile-divider tile-divider--${axis}`; + el.setAttribute('role', 'separator'); + el.setAttribute('aria-orientation', axis === 'col' ? 'vertical' : 'horizontal'); + el.setAttribute('aria-label', axis === 'col' ? 'Resize tile columns' : 'Resize tile rows'); + el.addEventListener('pointerdown', (e) => this._startTileDividerDrag(e, axis, index, el, key)); + section.appendChild(el); + grid.dividers.set(key, el); + } + el.style.gridColumn = axis === 'col' ? String(2 * index + 2) : '1 / -1'; + el.style.gridRow = axis === 'col' ? '1 / -1' : String(2 * index + 2); + } + }, + + /** + * Drags a column or row divider: the two tracks either side trade size, each + * kept at the minimum tile size (dragTrackFractions, constants.js). The + * affected tiles reflow locally once per animation frame; their PTYs hear + * ONE resize each, at pointer-up, never per move (each one is a tmux resize + * and a SIGWINCH). Pointer capture keeps the drag on the divider whatever is + * under the pointer; closing the grid or removing a tile mid-drag tears it + * down through _tileDividerDragTeardown. + */ + _startTileDividerDrag(e, axis, index, divider, key) { + if (e.button !== undefined && e.button !== 0) return; + const grid = this._tileGrid; + if (!grid?.open) return; + e.preventDefault?.(); + e.stopPropagation?.(); + this._tileDividerDragTeardown?.(); + const T = window.CodemanTileGrid; + const section = this._tileGridSection(); + const rect = section.getBoundingClientRect(); + const isCol = axis === 'col'; + const count = isCol ? grid.cols : grid.rows; + const total = (isCol ? rect.width : rect.height) - 2 * TILE_GRID_PADDING_PX - TILE_DIVIDER_PX * (count - 1); + const startFr = (isCol ? grid.colFr : grid.rowFr).slice(); + const start = isCol ? e.clientX : e.clientY; + const minPx = isCol ? T.TILE_MIN_W : T.TILE_MIN_H; + const affected = []; + grid.ids.forEach((id, k) => { + const track = isCol ? k % grid.cols : Math.floor(k / grid.cols); + if (track === index || track === index + 1) affected.push(grid.tiles.get(id).tile); + }); + let raf = null; + let pending = start; + let capturedPointerId = null; + const apply = (pos) => { + if (!grid.open) return; + const fr = T.dragTrackFractions(startFr, index, pos - start, total, minPx); + if (isCol) grid.colFr = fr; + else grid.rowFr = fr; + section.style[isCol ? 'gridTemplateColumns' : 'gridTemplateRows'] = tileGridTracks(fr); + for (const tile of affected) tile.localFit(); + }; + const onMove = (ev) => { + pending = isCol ? ev.clientX : ev.clientY; + if (raf !== null) return; + raf = requestAnimationFrame(() => { + raf = null; + apply(pending); + }); + }; + const endDrag = () => { + divider.classList.remove('dragging'); + document.body.classList.remove('tile-grid-resizing', `tile-grid-resizing--${axis}`); + if (capturedPointerId !== null) { + try { + divider.releasePointerCapture?.(capturedPointerId); + } catch { + /* Already released. */ + } + capturedPointerId = null; + } + divider.removeEventListener('pointermove', onMove); + divider.removeEventListener('pointerup', onUp); + divider.removeEventListener('pointercancel', onUp); + if (raf !== null) { + cancelAnimationFrame(raf); + raf = null; + } + if (this._tileDividerDragTeardown === endDrag) { + this._tileDividerDragTeardown = null; + this._tileDividerDrag = null; + } + }; + const onUp = () => { + // The last queued frame carries the final pointer position. + const queued = raf !== null; + endDrag(); + if (queued) apply(pending); + for (const tile of affected) { + if (!tile._destroyed) tile.fit(); + } + }; + divider.classList.add('dragging'); + document.body.classList.add('tile-grid-resizing', `tile-grid-resizing--${axis}`); + try { + divider.setPointerCapture?.(e.pointerId); + capturedPointerId = e.pointerId ?? null; + } catch { + /* The drag still works through the listeners below. */ + } + divider.addEventListener('pointermove', onMove); + divider.addEventListener('pointerup', onUp); + divider.addEventListener('pointercancel', onUp); + this._tileDividerDragTeardown = endDrag; + this._tileDividerDrag = { key }; }, /** diff --git a/test/mocks/tile-grid-vm.ts b/test/mocks/tile-grid-vm.ts index 7a3e0063..8e48b765 100644 --- a/test/mocks/tile-grid-vm.ts +++ b/test/mocks/tile-grid-vm.ts @@ -61,6 +61,11 @@ export class FakeEl { type = ''; focus = vi.fn(); select = vi.fn(); + setPointerCapture = vi.fn(); + releasePointerCapture = vi.fn(); + removeEventListener(type: string, fn: (ev: unknown) => void) { + this.listeners[type] = (this.listeners[type] ?? []).filter((f) => f !== fn); + } appendChild(child: FakeEl) { child.remove(); child.parentElement = this; @@ -95,7 +100,6 @@ export class FakeEl { addEventListener(type: string, fn: (ev: unknown) => void) { (this.listeners[type] ||= []).push(fn); } - removeEventListener() {} dispatch(type: string, ev: unknown = {}) { for (const fn of this.listeners[type] ?? []) fn(ev); } @@ -169,6 +173,8 @@ export function advanceClock(ms: number) { export const clockNow = () => clock; /** Every callback the code under test handed a PerformanceObserver, newest last. */ export const perfObserverCallbacks: Array<(list: { getEntries(): unknown[] }) => void> = []; +/** Animation-frame callbacks the code under test queued (id = index + 1); a test runs them. */ +export const rafCallbacks: Array<() => void> = []; /** What the code under test deferred with requestIdleCallback; a test runs them. */ export const idleCallbacks: Array<() => void> = []; export const windowStub: Record = { @@ -194,7 +200,10 @@ const context = vm.createContext({ clearInterval: vi.fn(), setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms), clearTimeout: (id: ReturnType) => globalThis.clearTimeout(id), - requestAnimationFrame: vi.fn(), + requestAnimationFrame: (cb: () => void) => rafCallbacks.push(cb), + cancelAnimationFrame: (id: number) => { + if (id > 0) rafCallbacks[id - 1] = () => {}; + }, requestIdleCallback: (cb: () => void) => idleCallbacks.push(cb), HTMLCanvasElement: class HTMLCanvasElement {}, WebSocket: { OPEN: 1 }, @@ -295,6 +304,7 @@ export function makeGridApp(ids: string[] = ['s-a', 's-b', 's-c']): GridApp { export function resetGridHarness() { FakeTile.all = []; idleCallbacks.length = 0; + rafCallbacks.length = 0; localStore.clear(); windowStub.innerWidth = 2400; section.children = []; diff --git a/test/tile-grid-dividers.test.ts b/test/tile-grid-dividers.test.ts new file mode 100644 index 00000000..4071c8f7 --- /dev/null +++ b/test/tile-grid-dividers.test.ts @@ -0,0 +1,214 @@ +/** + * @fileoverview Draggable column and row dividers. + * + * Every tile is placed explicitly (`grid-column` / `grid-row`, reading order), + * with a divider track between columns and between rows. Dragging a divider + * trades size between the two tracks either side, each kept at the minimum tile + * size. The affected tiles reflow LOCALLY at most once per animation frame + * (`localFit`, no PTY resize), and each hears exactly ONE `fit()` (one PTY + * resize) at pointer-up; tiles in other tracks hear nothing. A drag in progress + * is torn down when the grid closes or a tile is removed (the split's mid-drag + * lesson). Fractions reset when the column or row count changes. + * + * Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + FakeEl, + FakeTile, + body, + makeGridApp, + rafCallbacks, + resetGridHarness, + section, + type GridApp, +} from './mocks/tile-grid-vm.js'; + +const SIX = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f']; +const tileEl = (id: string) => section.children.find((el) => el.dataset.sessionId === id) as FakeEl; +const divider = (app: GridApp, key: string) => app._tileGrid.dividers.get(key) as FakeEl; +const tile = (id: string) => FakeTile.all.find((t) => t.sessionId === id) as FakeTile; + +function grid(ids: string[]): GridApp { + const app = makeGridApp(ids); + app.openTileGrid(ids); + for (const t of FakeTile.all) { + t.fit.mockClear(); + t.localFit.mockClear(); + } + return app; +} + +const runFrames = () => { + for (const cb of rafCallbacks.splice(0)) cb(); +}; + +beforeEach(() => { + resetGridHarness(); +}); + +describe('placement', () => { + it('2x2: one column divider, one row divider, tiles placed in reading order', () => { + const app = grid(['s-a', 's-b', 's-c', 's-d']); + expect([...app._tileGrid.dividers.keys()].sort()).toEqual(['col-0', 'row-0']); + expect(section.style.gridTemplateColumns).toBe('minmax(0, 1fr) 6px minmax(0, 1fr)'); + expect(section.style.gridTemplateRows).toBe('minmax(0, 1fr) 6px minmax(0, 1fr)'); + expect([tileEl('s-d').style.gridColumn, tileEl('s-d').style.gridRow]).toEqual(['3', '3']); + expect([tileEl('s-b').style.gridColumn, tileEl('s-b').style.gridRow]).toEqual(['3', '1']); + expect([divider(app, 'col-0').style.gridColumn, divider(app, 'col-0').style.gridRow]).toEqual(['2', '1 / -1']); + expect([divider(app, 'row-0').style.gridColumn, divider(app, 'row-0').style.gridRow]).toEqual(['1 / -1', '2']); + }); + + it('3x2 has two column dividers; a single tile has none', () => { + expect([...grid(SIX)._tileGrid.dividers.keys()].sort()).toEqual(['col-0', 'col-1', 'row-0']); + resetGridHarness(); + expect(grid(['s-a'])._tileGrid.dividers.size).toBe(0); + }); + + it('dividers are separators a screen reader can name', () => { + const app = grid(['s-a', 's-b']); + const d = divider(app, 'col-0'); + expect(d.getAttribute('role')).toBe('separator'); + expect(d.getAttribute('aria-orientation')).toBe('vertical'); + }); +}); + +describe('dragging', () => { + function drag(app: GridApp, key: string, from: number, to: number[]) { + const d = divider(app, key); + const axisCol = key.startsWith('col'); + const at = (v: number) => (axisCol ? { clientX: v, clientY: 0 } : { clientX: 0, clientY: v }); + d.dispatch('pointerdown', { + button: 0, + pointerId: 7, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + ...at(from), + }); + for (const v of to) d.dispatch('pointermove', at(v)); + return d; + } + + it('reflows locally once per frame and resizes each affected PTY exactly once, at pointer-up', () => { + const app = grid(SIX); + const d = drag(app, 'col-1', 1600, [1620, 1640, 1660, 1680]); + // Four moves, one frame. + expect(rafCallbacks).toHaveLength(1); + runFrames(); + // Column 1 and 2 tiles: b, c (row 0) and e, f (row 1). + for (const id of ['s-b', 's-c', 's-e', 's-f']) expect(tile(id).localFit).toHaveBeenCalledTimes(1); + expect(tile('s-a').localFit).not.toHaveBeenCalled(); + expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true); + + d.dispatch('pointerup', {}); + for (const id of ['s-b', 's-c', 's-e', 's-f']) expect(tile(id).fit).toHaveBeenCalledTimes(1); + expect(tile('s-a').fit).not.toHaveBeenCalled(); + expect(tile('s-d').fit).not.toHaveBeenCalled(); + }); + + it('the frame still queued at pointer-up is applied, so the tracks end where the pointer did', () => { + const app = grid(['s-a', 's-b']); + const d = drag(app, 'col-0', 1200, [1300]); + d.dispatch('pointerup', {}); + // 2400 wide, 8px padding, one 6px divider: 2386px shared; the left track gained 100px. + const [a, b] = app._tileGrid.colFr; + expect(Math.round((a / (a + b)) * 2386)).toBe(1293); + expect(section.style.gridTemplateColumns).toMatch(/^minmax\(0, [\d.]+fr\) 6px minmax\(0, [\d.]+fr\)$/); + }); + + it('keeps both neighbours at the minimum tile size', () => { + const app = grid(['s-a', 's-b']); + const d = drag(app, 'col-0', 1200, [100]); + d.dispatch('pointerup', {}); + const [a, b] = app._tileGrid.colFr; + expect(Math.round((a / (a + b)) * 2386)).toBe(480); + }); + + it('a row divider moves rows and resizes the tiles in them', () => { + const app = grid(['s-a', 's-b', 's-c', 's-d']); + const d = drag(app, 'row-0', 600, [650]); + d.dispatch('pointerup', {}); + for (const id of ['s-a', 's-b', 's-c', 's-d']) expect(tile(id).fit).toHaveBeenCalledTimes(1); + expect(app._tileGrid.rowFr[0]).toBeGreaterThan(app._tileGrid.rowFr[1]); + }); + + it('locks the cursor and text selection for the drag, released at the end', () => { + const app = grid(['s-a', 's-b']); + const d = drag(app, 'col-0', 1200, []); + expect(body.classList.contains('tile-grid-resizing')).toBe(true); + expect(d.classList.contains('dragging')).toBe(true); + d.dispatch('pointerup', {}); + expect(body.classList.contains('tile-grid-resizing')).toBe(false); + expect(d.setPointerCapture).toHaveBeenCalledWith(7); + expect(d.releasePointerCapture).toHaveBeenCalledWith(7); + }); +}); + +describe('a drag torn down mid-way', () => { + function startDrag(app: GridApp) { + const d = divider(app, 'col-0'); + d.dispatch('pointerdown', { + button: 0, + pointerId: 1, + clientX: 800, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }); + d.dispatch('pointermove', { clientX: 900 }); + return d; + } + + it('closing the grid ends it: no body lock left behind, no late resize', () => { + const app = grid(['s-a', 's-b']); + app.selectSession = vi.fn(); + const d = startDrag(app); + app.closeTileGrid({ reselect: false }); + expect(body.classList.contains('tile-grid-resizing')).toBe(false); + expect(d.listeners.pointermove ?? []).toHaveLength(0); + runFrames(); + d.dispatch('pointerup', {}); + expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true); + }); + + it('removing a tile ends it', () => { + const app = grid(['s-a', 's-b', 's-c']); + startDrag(app); + app.removeTile('s-c'); + expect(body.classList.contains('tile-grid-resizing')).toBe(false); + expect(app._tileDividerDragTeardown ?? null).toBeNull(); + }); +}); + +describe('fractions', () => { + it('reset to equal when the column count changes', () => { + const app = grid(['s-a', 's-b', 's-c', 's-d']); + app._tileGrid.colFr = [3, 1]; + app._applyTileLayout(); + expect(app._tileGrid.colFr).toEqual([3, 1]); + app.sessions.set('s-e', { id: 's-e', name: 's-e', mode: 'claude', pid: 1 }); + app.addTile('s-e'); + expect(app._tileGrid.colFr).toEqual([1, 1, 1]); + }); +}); + +describe('fractions on fewer columns', () => { + it('reset to equal when the column count drops (no stale track left over)', () => { + const app = grid(['s-a', 's-b', 's-c', 's-d', 's-e']); + app._tileGrid.colFr = [3, 1, 1]; + app.removeTile('s-e'); + expect(app._tileGrid.colFr).toEqual([1, 1]); + expect(section.style.gridTemplateColumns).toBe('minmax(0, 1fr) 6px minmax(0, 1fr)'); + }); +}); + +describe('zoomed', () => { + it('shows no dividers', () => { + const app = grid(['s-a', 's-b', 's-c', 's-d']); + app.zoomTile('s-a'); + expect(app._tileGrid.dividers.size).toBe(0); + const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8'); + expect(css).toMatch(/\.tile-grid\.tile-grid--zoomed \.tile-divider\s*\{\s*display: none;/); + }); +}); diff --git a/test/tile-grid-layout.test.ts b/test/tile-grid-layout.test.ts index d8fbad1a..4734ff35 100644 --- a/test/tile-grid-layout.test.ts +++ b/test/tile-grid-layout.test.ts @@ -144,10 +144,7 @@ describe('sanitizeTileGridState', () => { }); it('drops malformed track fractions', () => { - const out = T.sanitizeTileGridState( - { v: 1, open: true, ids: ['a'], colFr: [1, -1], rowFr: [1, 1, 1, 1] }, - live - ); + 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(); }); @@ -188,6 +185,37 @@ describe('focus helpers', () => { }); }); +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); + const px = (fr: number[], total = 1200) => fr.map((f) => Math.round((f / fr.reduce((a, b) => a + b, 0)) * total)); + + it('moves size from one neighbour to the other, the rest untouched', () => { + // 3 equal tracks of 400px; divider 0 moves 100px right. + expect(px(drag([1, 1, 1], 0, 100))).toEqual([500, 300, 400]); + expect(px(drag([1, 1, 1], 1, -50))).toEqual([400, 350, 450]); + }); + + it('clamps both neighbours to the minimum size', () => { + expect(px(drag([1, 1, 1], 0, 300))).toEqual([500, 300, 400]); + expect(px(drag([1, 1, 1], 0, -300))).toEqual([300, 500, 400]); + }); + + it('a pair too small for two minimums splits evenly instead of inverting', () => { + expect(px(drag([1, 1], 0, 200, 500, 300), 500)).toEqual([250, 250]); + }); + + it('works from the fractions the drag started with (no drift)', () => { + const start = [2, 1]; + expect(px(drag(start, 0, 0))).toEqual([800, 400]); + expect(start).toEqual([2, 1]); + }); + + it('ignores a divider that is not between two tracks', () => { + expect(drag([1, 1], 1, 100)).toEqual([1, 1]); + }); +}); + describe('tile constants', () => { it('a tile keeps 10,000 lines of scrollback, not the primary pane 50,000', () => { expect(T.TILE_SCROLLBACK).toBe(10000); diff --git a/test/tile-grid-zoom.test.ts b/test/tile-grid-zoom.test.ts index ec60ec8b..70f22a36 100644 --- a/test/tile-grid-zoom.test.ts +++ b/test/tile-grid-zoom.test.ts @@ -71,7 +71,7 @@ describe('zoom and restore', () => { expect(app._tileGrid.zoomedId).toBeNull(); expect(section.classList.contains('tile-grid--zoomed')).toBe(false); - expect(section.style.gridTemplateColumns).toBe('repeat(2, minmax(0, 1fr))'); + expect(section.style.gridTemplateColumns).toBe('minmax(0, 1fr) 6px minmax(0, 1fr)'); expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 1)).toBe(true); expect(zoomButton('s-a').getAttribute('aria-pressed')).toBe('false'); });