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'); });