feat(grid): v3b/c — resizable splitters + saved layout presets
Resizable splitters: draggable gutters between grid tracks adjust per-layout column/row fractions (fr units), applied as an inline grid-template and persisted (web-terminal:grid-splits). The fraction math (adjustSplit — trades delta between adjacent tracks, clamps to a minimum, conserves the total) is a pure, unit-tested helper in grid-layout.ts; the drag translates pixel motion into an fr delta. Saved presets: public/grid-presets.ts — a toolbar dropdown to save the current layout + its split under a name, re-apply it in one click, or delete it (web-terminal:grid-presets). Desktop-only, like the layout toggle. - grid-layout.ts: layoutTracks, defaultSplit, adjustSplit, tracksToTemplate, load/saveGridSplits, splitForLayout (validates stored shape). - tabs.ts: renderGutters/beginGutterDrag/setSplit; applyLayout sets the inline template; gridArrangement()/applyGridPreset() hooks. - main.ts: mount the presets dropdown. style.css: gutters + presets menu. - tests: splitter math + persistence + a stubbed drag repro (1.2fr/0.8fr); presets persistence + dropdown (save/apply/delete/close). typecheck+build clean, 1612 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,13 @@ import {
|
||||
saveGridLayout,
|
||||
mountGridToggle,
|
||||
matchFocusCycleKey,
|
||||
layoutTracks,
|
||||
defaultSplit,
|
||||
adjustSplit,
|
||||
tracksToTemplate,
|
||||
splitForLayout,
|
||||
loadGridSplits,
|
||||
saveGridSplits,
|
||||
} from '../public/grid-layout.js'
|
||||
|
||||
const KEY = 'web-terminal:grid-layout'
|
||||
@@ -110,6 +117,65 @@ describe('grid-layout: pure logic', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('grid-layout: resizable splitters (v3)', () => {
|
||||
beforeEach(() => localStorage.clear())
|
||||
|
||||
it('layoutTracks gives each layout its column/row counts', () => {
|
||||
expect(layoutTracks('single')).toEqual({ cols: 1, rows: 1 })
|
||||
expect(layoutTracks('split-2')).toEqual({ cols: 2, rows: 1 })
|
||||
expect(layoutTracks('row-3')).toEqual({ cols: 3, rows: 1 })
|
||||
expect(layoutTracks('grid-4')).toEqual({ cols: 2, rows: 2 })
|
||||
expect(layoutTracks('grid-6')).toEqual({ cols: 3, rows: 2 })
|
||||
})
|
||||
|
||||
it('defaultSplit is equal fractions matching the track counts', () => {
|
||||
expect(defaultSplit('grid-4')).toEqual({ cols: [1, 1], rows: [1, 1] })
|
||||
expect(defaultSplit('grid-6')).toEqual({ cols: [1, 1, 1], rows: [1, 1] })
|
||||
})
|
||||
|
||||
it('adjustSplit trades the delta between adjacent tracks, conserving the total', () => {
|
||||
expect(adjustSplit([1, 1], 0, 0.5)).toEqual([1.5, 0.5])
|
||||
expect(adjustSplit([1, 1, 1], 1, 0.4)).toEqual([1, 1.4, 0.6])
|
||||
})
|
||||
|
||||
it('adjustSplit clamps both tracks to the minimum (no collapse)', () => {
|
||||
const out = adjustSplit([1, 1], 0, 5, 0.3) // huge delta
|
||||
expect(out[1]).toBeCloseTo(0.3)
|
||||
expect(out[0]! + out[1]!).toBeCloseTo(2) // total preserved
|
||||
expect(Math.min(...out)).toBeGreaterThanOrEqual(0.3)
|
||||
})
|
||||
|
||||
it('adjustSplit ignores an out-of-range gutter and never mutates the input', () => {
|
||||
const input = [1, 1]
|
||||
expect(adjustSplit(input, 1, 0.5)).toEqual([1, 1]) // gutter 1 has no right neighbor
|
||||
expect(input).toEqual([1, 1]) // untouched
|
||||
})
|
||||
|
||||
it('tracksToTemplate renders an fr template', () => {
|
||||
expect(tracksToTemplate([1, 1.4])).toBe('1fr 1.4fr')
|
||||
})
|
||||
|
||||
it('splitForLayout returns the stored split only when its shape matches', () => {
|
||||
const good = { 'grid-4': { cols: [1.3, 0.7], rows: [1, 1] } }
|
||||
expect(splitForLayout(good, 'grid-4')).toEqual({ cols: [1.3, 0.7], rows: [1, 1] })
|
||||
// wrong column count for grid-4 → falls back to equal default
|
||||
expect(splitForLayout({ 'grid-4': { cols: [1, 1, 1], rows: [1, 1] } }, 'grid-4')).toEqual(
|
||||
defaultSplit('grid-4'),
|
||||
)
|
||||
expect(splitForLayout({}, 'split-2')).toEqual(defaultSplit('split-2'))
|
||||
})
|
||||
|
||||
it('saveGridSplits + loadGridSplits round-trip', () => {
|
||||
saveGridSplits({ 'grid-4': { cols: [1.5, 0.5], rows: [1, 1] } })
|
||||
expect(loadGridSplits()).toEqual({ 'grid-4': { cols: [1.5, 0.5], rows: [1, 1] } })
|
||||
})
|
||||
|
||||
it('loadGridSplits tolerates garbage', () => {
|
||||
localStorage.setItem('web-terminal:grid-splits', 'not json')
|
||||
expect(loadGridSplits()).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('grid-layout: gate + persistence', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
|
||||
133
test/grid-presets.test.ts
Normal file
133
test/grid-presets.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* test/grid-presets.test.ts — saved split-grid layout presets + dropdown.
|
||||
*
|
||||
* Covers persistence (round-trip, garbage tolerance, malformed drop) and the
|
||||
* dropdown behavior: empty state, save current, apply (calls back), delete.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { loadPresets, savePresets, mountGridPresets, type GridPreset } from '../public/grid-presets.js'
|
||||
|
||||
const KEY = 'web-terminal:grid-presets'
|
||||
const CURRENT: { layout: GridPreset['layout']; split: GridPreset['split'] } = {
|
||||
layout: 'grid-4',
|
||||
split: { cols: [1.5, 0.5], rows: [1, 1] },
|
||||
}
|
||||
|
||||
beforeEach(() => localStorage.clear())
|
||||
|
||||
describe('grid-presets: persistence', () => {
|
||||
it('round-trips presets through localStorage', () => {
|
||||
const list: GridPreset[] = [{ name: 'wide-left', layout: 'grid-4', split: CURRENT.split }]
|
||||
savePresets(list)
|
||||
expect(loadPresets()).toEqual(list)
|
||||
})
|
||||
|
||||
it('returns [] for missing or garbage storage', () => {
|
||||
expect(loadPresets()).toEqual([])
|
||||
localStorage.setItem(KEY, 'not json')
|
||||
expect(loadPresets()).toEqual([])
|
||||
})
|
||||
|
||||
it('drops malformed entries', () => {
|
||||
localStorage.setItem(
|
||||
KEY,
|
||||
JSON.stringify([
|
||||
{ name: 'ok', layout: 'grid-4', split: { cols: [1, 1], rows: [1, 1] } },
|
||||
{ name: 'bad-layout', layout: 'nope', split: {} },
|
||||
{ nope: true },
|
||||
]),
|
||||
)
|
||||
const out = loadPresets()
|
||||
expect(out).toHaveLength(1)
|
||||
expect(out[0]!.name).toBe('ok')
|
||||
})
|
||||
})
|
||||
|
||||
describe('grid-presets: dropdown', () => {
|
||||
let toolbar: HTMLElement
|
||||
let apply: ReturnType<typeof vi.fn>
|
||||
const mount = () => {
|
||||
apply = vi.fn()
|
||||
return mountGridPresets(toolbar, { current: () => CURRENT, apply })
|
||||
}
|
||||
beforeEach(() => {
|
||||
toolbar = document.createElement('div')
|
||||
document.body.replaceChildren(toolbar)
|
||||
})
|
||||
|
||||
const openMenu = (): HTMLElement => {
|
||||
toolbar.querySelector<HTMLButtonElement>('.grid-presets-btn')!.click()
|
||||
return toolbar.querySelector<HTMLElement>('.grid-presets-menu')!
|
||||
}
|
||||
|
||||
it('shows an empty state when there are no presets', () => {
|
||||
mount()
|
||||
const menu = openMenu()
|
||||
expect(menu.hidden).toBe(false)
|
||||
expect(menu.querySelector('.gp-empty')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('saves the current arrangement under a typed name', () => {
|
||||
mount()
|
||||
openMenu()
|
||||
const input = toolbar.querySelector<HTMLInputElement>('.gp-name')!
|
||||
input.value = 'wide-left'
|
||||
toolbar.querySelector<HTMLButtonElement>('.gp-save-btn')!.click()
|
||||
expect(loadPresets()).toEqual([{ name: 'wide-left', layout: 'grid-4', split: CURRENT.split }])
|
||||
expect(toolbar.querySelector('.gp-apply-name')?.textContent).toBe('wide-left')
|
||||
})
|
||||
|
||||
it('does not save a blank name', () => {
|
||||
mount()
|
||||
openMenu()
|
||||
toolbar.querySelector<HTMLButtonElement>('.gp-save-btn')!.click()
|
||||
expect(loadPresets()).toEqual([])
|
||||
})
|
||||
|
||||
it('applies a preset (calls back) and closes the menu', () => {
|
||||
savePresets([{ name: 'p1', layout: 'grid-4', split: CURRENT.split }])
|
||||
mount()
|
||||
const menu = openMenu()
|
||||
toolbar.querySelector<HTMLButtonElement>('.gp-apply')!.click()
|
||||
expect(apply).toHaveBeenCalledWith({ name: 'p1', layout: 'grid-4', split: CURRENT.split })
|
||||
expect(menu.hidden).toBe(true)
|
||||
})
|
||||
|
||||
it('deletes a preset', () => {
|
||||
savePresets([{ name: 'p1', layout: 'grid-4', split: CURRENT.split }])
|
||||
mount()
|
||||
openMenu()
|
||||
toolbar.querySelector<HTMLButtonElement>('.gp-del')!.click()
|
||||
expect(loadPresets()).toEqual([])
|
||||
expect(toolbar.querySelector('.gp-empty')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('closes on an outside pointerdown', () => {
|
||||
mount()
|
||||
const menu = openMenu()
|
||||
expect(menu.hidden).toBe(false)
|
||||
document.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
|
||||
expect(menu.hidden).toBe(true)
|
||||
})
|
||||
|
||||
it('closes on Escape', () => {
|
||||
mount()
|
||||
const menu = openMenu()
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||
expect(menu.hidden).toBe(true)
|
||||
})
|
||||
|
||||
it('replaces a same-named preset instead of duplicating', () => {
|
||||
savePresets([{ name: 'dup', layout: 'split-2', split: { cols: [1, 1], rows: [1] } }])
|
||||
mount()
|
||||
openMenu()
|
||||
const input = toolbar.querySelector<HTMLInputElement>('.gp-name')!
|
||||
input.value = 'dup'
|
||||
toolbar.querySelector<HTMLButtonElement>('.gp-save-btn')!.click()
|
||||
const out = loadPresets()
|
||||
expect(out).toHaveLength(1)
|
||||
expect(out[0]!.layout).toBe('grid-4') // overwritten with the current arrangement
|
||||
})
|
||||
})
|
||||
@@ -1460,4 +1460,70 @@ describe('TabApp — split-grid watch board (v1)', () => {
|
||||
app.setGridLayout('single')
|
||||
expect(monitorHandles[0]!.dispose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// ── v3: resizable splitters ──────────────────────────────────────────────────
|
||||
|
||||
it('grid-4 renders one column + one row splitter and sets an fr template', () => {
|
||||
const { app, paneHost } = withTabs(4)
|
||||
app.focusTab(0)
|
||||
app.setGridLayout('grid-4')
|
||||
expect(paneHost.querySelectorAll('.grid-gutter-col')).toHaveLength(1)
|
||||
expect(paneHost.querySelectorAll('.grid-gutter-row')).toHaveLength(1)
|
||||
expect(paneHost.style.gridTemplateColumns).toBe('1fr 1fr')
|
||||
expect(paneHost.style.gridTemplateRows).toBe('1fr 1fr')
|
||||
})
|
||||
|
||||
it('grid-6 renders two column + one row splitters', () => {
|
||||
const { app, paneHost } = withTabs(6)
|
||||
app.focusTab(0)
|
||||
app.setGridLayout('grid-6')
|
||||
expect(paneHost.querySelectorAll('.grid-gutter-col')).toHaveLength(2)
|
||||
expect(paneHost.querySelectorAll('.grid-gutter-row')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('single mode has no splitters and clears the inline grid template', () => {
|
||||
const { app, paneHost } = withTabs(3)
|
||||
app.setGridLayout('grid-4')
|
||||
app.setGridLayout('single')
|
||||
expect(paneHost.querySelectorAll('.grid-gutter')).toHaveLength(0)
|
||||
expect(paneHost.style.gridTemplateColumns).toBe('')
|
||||
})
|
||||
|
||||
it('a persisted custom split is applied to the grid template on load', () => {
|
||||
localStorage.setItem(
|
||||
'web-terminal:grid-splits',
|
||||
JSON.stringify({ 'grid-4': { cols: [1.5, 0.5], rows: [1, 1] } }),
|
||||
)
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
app.newTab()
|
||||
app.setGridLayout('grid-4')
|
||||
expect(paneHost.style.gridTemplateColumns).toBe('1.5fr 0.5fr')
|
||||
})
|
||||
|
||||
it('dragging a column splitter re-templates the grid and persists on release', () => {
|
||||
const { app, paneHost } = withTabs(4)
|
||||
app.focusTab(0)
|
||||
app.setGridLayout('grid-4')
|
||||
// jsdom has no layout — give #term a measurable width for the drag math.
|
||||
vi.spyOn(paneHost, 'getBoundingClientRect').mockReturnValue({
|
||||
width: 800,
|
||||
height: 600,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 800,
|
||||
bottom: 600,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
const gutter = paneHost.querySelector<HTMLElement>('.grid-gutter-col')!
|
||||
gutter.dispatchEvent(new MouseEvent('pointerdown', { clientX: 400, bubbles: true }))
|
||||
gutter.dispatchEvent(new MouseEvent('pointermove', { clientX: 480, bubbles: true })) // +0.2fr
|
||||
gutter.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
|
||||
expect(paneHost.style.gridTemplateColumns).toBe('1.2fr 0.8fr')
|
||||
const saved = JSON.parse(localStorage.getItem('web-terminal:grid-splits')!)
|
||||
expect(saved['grid-4'].cols[0]).toBeCloseTo(1.2)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user