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

@@ -37,10 +37,18 @@ import { createApproveConfirm, type ApproveConfirm } from './voice-confirm.js'
import {
type GridLayout,
type GridToggle,
type GridSplits,
type TrackSplit,
isVisibleIndex,
layoutCapacity,
layoutTracks,
loadGridLayout,
saveGridLayout,
adjustSplit,
tracksToTemplate,
loadGridSplits,
saveGridSplits,
splitForLayout,
} from './grid-layout.js'
import { mountCellMonitor, type CellMonitor } from './cell-monitor.js'
@@ -118,6 +126,8 @@ export class TabApp {
// v2: when true, the focused quadrant is expanded to fill the grid (others stay
// live behind it). Follows the focused pane; reset on layout change / tab close.
private maximized = false
// v3: persisted per-layout column/row track fractions (draggable splitters).
private gridSplits: GridSplits = {}
// When true, the home chooser is overlaid on top of the open tabs (so you can
// jump back to Sessions/Projects without closing anything). Reset whenever a
// tab is activated. Irrelevant while no tab is open (home shows regardless).
@@ -168,8 +178,9 @@ export class TabApp {
this.paneHost.appendChild(this.segControl)
// Split-grid: restore the persisted layout (forced to 'single' on screens
// too narrow for a multi-pane board — see loadGridLayout).
// too narrow for a multi-pane board — see loadGridLayout) + custom splits.
this.gridLayout = loadGridLayout()
this.gridSplits = loadGridSplits()
// v0.7 Walk-away Workbench panels (mounted once; survive tab rebuilds):
this.setupPushToggle() // A1 🔔
@@ -965,6 +976,23 @@ export class TabApp {
this.gridToggle = toggle
}
/** v3: the current layout + its split, for saving as a preset. */
gridArrangement(): { layout: GridLayout; split: TrackSplit } {
return { layout: this.gridLayout, split: splitForLayout(this.gridSplits, this.gridLayout) }
}
/** v3: apply a saved preset — its layout plus its custom track fractions. */
applyGridPreset(preset: { layout: GridLayout; split: TrackSplit }): void {
this.gridSplits = { ...this.gridSplits, [preset.layout]: preset.split }
saveGridSplits(this.gridSplits)
if (preset.layout === this.gridLayout) {
this.updateHomeView() // same layout, new split → just re-apply the template
this.gridToggle?.refresh()
} else {
this.setGridLayout(preset.layout)
}
}
/** Re-assert every VISIBLE pane's size (latest-writer-wins) when this device
* regains focus — in a grid all visible quadrants reclaim size, not just the
* focused one. */
@@ -1057,6 +1085,17 @@ export class TabApp {
// inline approve, …) independent of how many layouts exist.
this.paneHost.classList.toggle('grid', layout !== 'single')
// v3: apply custom track fractions (draggable splitters) as inline templates;
// clear them in single mode so #term is not a grid.
if (layout !== 'single') {
const sp = splitForLayout(this.gridSplits, layout)
this.paneHost.style.gridTemplateColumns = tracksToTemplate(sp.cols)
this.paneHost.style.gridTemplateRows = tracksToTemplate(sp.rows)
} else {
this.paneHost.style.gridTemplateColumns = ''
this.paneHost.style.gridTemplateRows = ''
}
const cap = layoutCapacity(this.gridLayout)
const visibleCount = showHome ? 0 : Math.min(this.tabs.length, cap)
@@ -1088,6 +1127,98 @@ export class TabApp {
const placeholders =
showHome || this.gridLayout === 'single' ? 0 : Math.max(0, cap - visibleCount)
this.renderPlaceholders(placeholders, visibleCount)
this.renderGutters(layout)
}
/* ── v3: draggable splitters ──────────────────────────────────────── */
/** Replace the gutter handles for the current layout (none in single mode). */
private renderGutters(layout: GridLayout): void {
this.paneHost.querySelectorAll('.grid-gutter').forEach((g) => g.remove())
if (layout === 'single') return
const { cols, rows } = layoutTracks(layout)
const sp = splitForLayout(this.gridSplits, layout)
this.placeAxisGutters('col', cols, sp.cols, true)
this.placeAxisGutters('row', rows, sp.rows, true)
}
/** Create (create=true) or reposition the gutter handles for one axis. */
private placeAxisGutters(
axis: 'col' | 'row',
count: number,
fractions: number[],
create: boolean,
): void {
const total = fractions.reduce((a, b) => a + b, 0) || 1
const existing = create
? null
: this.paneHost.querySelectorAll<HTMLElement>(`.grid-gutter-${axis}`)
let acc = 0
for (let g = 0; g < count - 1; g++) {
acc += fractions[g] ?? 1
const pct = (acc / total) * 100
const handle = create ? this.makeGutter(axis, g) : existing?.[g]
if (!handle) continue
if (axis === 'col') handle.style.left = `${pct}%`
else handle.style.top = `${pct}%`
if (create) this.paneHost.appendChild(handle)
}
}
private makeGutter(axis: 'col' | 'row', gutterIndex: number): HTMLElement {
const g = document.createElement('div')
g.className = `grid-gutter grid-gutter-${axis}`
g.setAttribute('role', 'separator')
g.setAttribute('aria-orientation', axis === 'col' ? 'vertical' : 'horizontal')
g.addEventListener('pointerdown', (e) => this.beginGutterDrag(e, axis, gutterIndex))
return g
}
/** Drag a gutter: translate pixel motion into an fr delta and re-template live. */
private beginGutterDrag(e: PointerEvent, axis: 'col' | 'row', gutterIndex: number): void {
const layout = this.gridLayout
if (layout === 'single') return
e.preventDefault()
e.stopPropagation()
const handle = e.currentTarget as HTMLElement
const rect = this.paneHost.getBoundingClientRect()
const area = axis === 'col' ? rect.width : rect.height
const start = axis === 'col' ? e.clientX : e.clientY
const base = splitForLayout(this.gridSplits, layout)
const startFractions = (axis === 'col' ? base.cols : base.rows).slice()
const total = startFractions.reduce((a, b) => a + b, 0)
try {
handle.setPointerCapture(e.pointerId)
} catch {
// setPointerCapture may be unavailable (e.g. jsdom) — drag still works
}
const move = (ev: PointerEvent): void => {
if (area <= 0) return
const pos = axis === 'col' ? ev.clientX : ev.clientY
const delta = ((pos - start) / area) * total
this.setSplit(layout, axis, adjustSplit(startFractions, gutterIndex, delta))
}
const up = (): void => {
handle.removeEventListener('pointermove', move)
handle.removeEventListener('pointerup', up)
handle.removeEventListener('pointercancel', up)
saveGridSplits(this.gridSplits) // persist only on release
}
handle.addEventListener('pointermove', move)
handle.addEventListener('pointerup', up)
handle.addEventListener('pointercancel', up)
}
/** Apply a new fraction array live (template + gutter positions), no persist. */
private setSplit(layout: GridLayout, axis: 'col' | 'row', fractions: number[]): void {
const cur = splitForLayout(this.gridSplits, layout)
const next: TrackSplit =
axis === 'col' ? { cols: fractions, rows: cur.rows } : { cols: cur.cols, rows: fractions }
this.gridSplits = { ...this.gridSplits, [layout]: next }
if (axis === 'col') this.paneHost.style.gridTemplateColumns = tracksToTemplate(fractions)
else this.paneHost.style.gridTemplateRows = tracksToTemplate(fractions)
const { cols, rows } = layoutTracks(layout)
this.placeAxisGutters(axis, axis === 'col' ? cols : rows, fractions, false)
}
/** Replace the placeholder cells with `count` new ones ordered after the panes. */