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:
Yaojia Wang
2026-07-11 20:09:49 +02:00
parent cd97114f87
commit 007e598802
9 changed files with 902 additions and 1 deletions

View File

@@ -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()