From dbbd5512d5fc69c93b136eedb2be41a00c45b4d6 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 5 May 2026 15:09:18 -0500 Subject: [PATCH] feat(ui-tui): add responsive overlay widget grid Introduce a shared widget grid and width-capped overlay pickers so wide terminals can tile widgets cleanly while reducing overlay rerenders via focused ui store selectors. --- ui-tui/src/__tests__/widgetGrid.test.ts | 60 +++++++++++ ui-tui/src/app/uiStore.ts | 5 +- ui-tui/src/components/appOverlays.tsx | 131 ++++++++++++++++-------- ui-tui/src/components/modelPicker.tsx | 81 +++++++++------ ui-tui/src/components/sessionPicker.tsx | 12 ++- ui-tui/src/components/skillsHub.tsx | 8 +- ui-tui/src/components/widgetGrid.tsx | 90 ++++++++++++++++ ui-tui/src/lib/widgetGrid.ts | 104 +++++++++++++++++++ 8 files changed, 411 insertions(+), 80 deletions(-) create mode 100644 ui-tui/src/__tests__/widgetGrid.test.ts create mode 100644 ui-tui/src/components/widgetGrid.tsx create mode 100644 ui-tui/src/lib/widgetGrid.ts diff --git a/ui-tui/src/__tests__/widgetGrid.test.ts b/ui-tui/src/__tests__/widgetGrid.test.ts new file mode 100644 index 0000000000..d46ef27b64 --- /dev/null +++ b/ui-tui/src/__tests__/widgetGrid.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' + +import { layoutWidgetGrid } from '../lib/widgetGrid.js' + +describe('layoutWidgetGrid', () => { + it('falls back to a single column on narrow widths', () => { + const layout = layoutWidgetGrid({ + items: [{ id: 'a' }, { id: 'b' }], + maxColumns: 3, + minColumnWidth: 40, + width: 35 + }) + + expect(layout.columnCount).toBe(1) + expect(layout.columns).toEqual([35]) + expect(layout.rows).toEqual([[{ col: 0, id: 'a', span: 1, width: 35 }], [{ col: 0, id: 'b', span: 1, width: 35 }]]) + }) + + it('packs spans left-to-right and wraps to the next row', () => { + const layout = layoutWidgetGrid({ + gap: 2, + items: [ + { id: 'a', span: 1 }, + { id: 'b', span: 2 }, + { id: 'c', span: 1 } + ], + maxColumns: 3, + minColumnWidth: 30, + width: 100 + }) + + expect(layout.columnCount).toBe(3) + expect(layout.columns).toEqual([32, 32, 32]) + expect(layout.rows).toEqual([ + [ + { col: 0, id: 'a', span: 1, width: 32 }, + { col: 1, id: 'b', span: 2, width: 66 } + ], + [{ col: 0, id: 'c', span: 1, width: 32 }] + ]) + }) + + it('clamps spans to available columns', () => { + const layout = layoutWidgetGrid({ + gap: 1, + items: [{ id: 'huge', span: 9 }], + maxColumns: 2, + minColumnWidth: 20, + width: 50 + }) + + expect(layout.columnCount).toBe(2) + expect(layout.rows[0]?.[0]).toEqual({ + col: 0, + id: 'huge', + span: 2, + width: 50 + }) + }) +}) diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index b3d5a942c7..ea592700b7 100644 --- a/ui-tui/src/app/uiStore.ts +++ b/ui-tui/src/app/uiStore.ts @@ -1,4 +1,4 @@ -import { atom } from 'nanostores' +import { atom, computed } from 'nanostores' import { MOUSE_TRACKING } from '../config/env.js' import { ZERO } from '../domain/usage.js' @@ -30,6 +30,9 @@ const buildUiState = (): UiState => ({ export const $uiState = atom(buildUiState()) +export const $uiTheme = computed($uiState, state => state.theme) +export const $uiSessionId = computed($uiState, state => state.sid) + export const getUiState = () => $uiState.get() export const patchUiState = (next: Partial | ((state: UiState) => UiState)) => diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 1e33559f0a..391fa7bc49 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -4,7 +4,7 @@ import { useStore } from '@nanostores/react' import { useGateway } from '../app/gatewayContext.js' import type { AppOverlaysProps } from '../app/interfaces.js' import { $overlayState, patchOverlayState } from '../app/overlayStore.js' -import { $uiState } from '../app/uiStore.js' +import { $uiSessionId, $uiTheme } from '../app/uiStore.js' import { FloatBox } from './appChrome.js' import { MaskedPrompt } from './maskedPrompt.js' @@ -13,6 +13,7 @@ import { OverlayHint } from './overlayControls.js' import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js' import { SessionPicker } from './sessionPicker.js' import { SkillsHub } from './skillsHub.js' +import { WidgetGrid, type WidgetGridWidget } from './widgetGrid.js' const COMPLETION_WINDOW = 16 @@ -24,12 +25,12 @@ export function PromptZone({ onSudoSubmit }: Pick) { const overlay = useStore($overlayState) - const ui = useStore($uiState) + const theme = useStore($uiTheme) if (overlay.approval) { return ( - + ) } @@ -46,7 +47,7 @@ export function PromptZone({ return ( - + ) } @@ -59,7 +60,7 @@ export function PromptZone({ onAnswer={onClarifyAnswer} onCancel={() => onClarifyAnswer('')} req={overlay.clarify} - t={ui.theme} + t={theme} /> ) @@ -68,7 +69,7 @@ export function PromptZone({ if (overlay.sudo) { return ( - + ) } @@ -82,7 +83,7 @@ export function PromptZone({ label={overlay.secret.prompt} onSubmit={onSecretSubmit} sub={`for ${overlay.secret.envVar}`} - t={ui.theme} + t={theme} /> ) @@ -101,7 +102,8 @@ export function FloatingOverlays({ }: Pick) { const { gw } = useGateway() const overlay = useStore($overlayState) - const ui = useStore($uiState) + const sid = useStore($uiSessionId) + const theme = useStore($uiTheme) const hasAny = overlay.modelPicker || overlay.pager || overlay.picker || overlay.skillsHub || completions.length @@ -109,6 +111,11 @@ export function FloatingOverlays({ return null } + const gridCols = Math.max(24, cols - 2) + const gridMaxColumns = cols >= 120 ? 2 : 1 + const fullSpan = gridMaxColumns + const capWidth = (cellWidth: number) => Math.max(24, cellWidth - 4) + // Fixed viewport centered on compIdx β€” previously the slice end was // compIdx + 8 so the dropdown grew from 8 rows to 16 as the user scrolled // down, bouncing the height on every keystroke. @@ -116,87 +123,127 @@ export function FloatingOverlays({ const start = Math.max(0, Math.min(compIdx - Math.floor(COMPLETION_WINDOW / 2), completions.length - viewportSize)) - return ( - - {overlay.picker && ( - + const widgets: WidgetGridWidget[] = [] + + if (overlay.picker) { + widgets.push({ + id: 'picker', + render: width => ( + patchOverlayState({ picker: false })} onSelect={onPickerSelect} - t={ui.theme} + t={theme} /> - )} + ) + }) + } - {overlay.modelPicker && ( - + if (overlay.modelPicker) { + widgets.push({ + id: 'model-picker', + render: width => ( + patchOverlayState({ modelPicker: false })} onSelect={onModelSelect} - sessionId={ui.sid} - t={ui.theme} + sessionId={sid} + t={theme} /> - )} + ) + }) + } - {overlay.skillsHub && ( - - patchOverlayState({ skillsHub: false })} t={ui.theme} /> + if (overlay.skillsHub) { + widgets.push({ + id: 'skills-hub', + render: width => ( + + patchOverlayState({ skillsHub: false })} + t={theme} + /> - )} + ) + }) + } - {overlay.pager && ( - - - {overlay.pager.title && ( + if (overlay.pager) { + const pager = overlay.pager + + widgets.push({ + id: 'pager', + render: width => ( + + + {pager.title && ( - - {overlay.pager.title} + + {pager.title} )} - {overlay.pager.lines.slice(overlay.pager.offset, overlay.pager.offset + pagerPageSize).map((line, i) => ( + {pager.lines.slice(pager.offset, pager.offset + pagerPageSize).map((line, i) => ( {line} ))} - - {overlay.pager.offset + pagerPageSize < overlay.pager.lines.length - ? `↑↓/jk line Β· Enter/Space/PgDn page Β· b/PgUp back Β· g/G top/bottom Β· Esc/q close (${Math.min(overlay.pager.offset + pagerPageSize, overlay.pager.lines.length)}/${overlay.pager.lines.length})` - : `end Β· ↑↓/jk Β· b/PgUp back Β· g top Β· Esc/q close (${overlay.pager.lines.length} lines)`} + + {pager.offset + pagerPageSize < pager.lines.length + ? `↑↓/jk line Β· Enter/Space/PgDn page Β· b/PgUp back Β· g/G top/bottom Β· Esc/q close (${Math.min(pager.offset + pagerPageSize, pager.lines.length)}/${pager.lines.length})` + : `end Β· ↑↓/jk Β· b/PgUp back Β· g top Β· Esc/q close (${pager.lines.length} lines)`} - )} + ), + span: fullSpan + }) + } - {!!completions.length && ( - - + if (completions.length) { + widgets.push({ + id: 'completions', + render: width => ( + + {completions.slice(start, start + viewportSize).map((item, i) => { const active = start + i === compIdx return ( - + {' '} {item.display} - {item.meta ? {item.meta} : null} + {item.meta ? {item.meta} : null} ) })} - )} + ), + span: fullSpan + }) + } + + return ( + + ) } diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index 45c9bc4cda..db5191acf1 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -16,7 +16,7 @@ const MAX_WIDTH = 90 type Stage = 'provider' | 'key' | 'model' | 'disconnect' -export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPickerProps) { +export function ModelPicker({ gw, maxWidth, onCancel, onSelect, sessionId, t }: ModelPickerProps) { const [providers, setProviders] = useState([]) const [currentModel, setCurrentModel] = useState('') const [err, setErr] = useState('') @@ -30,11 +30,13 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke const [keyError, setKeyError] = useState('') const { stdout } = useStdout() - // Pin the picker to a stable width so the FloatBox parent (which shrinks- - // to-fit with alignSelf="flex-start") doesn't resize as long provider / - // model names scroll into view, and so `wrap="truncate-end"` on each row - // has an actual constraint to truncate against. - const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + // Pin the picker to a stable width so the FloatBox parent doesn't resize as + // long provider/model names scroll into view. Optional maxWidth lets + // callers constrain it inside multi-column widget layouts. + const terminalWidth = Math.max(1, (stdout?.columns ?? 80) - 6) + const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, terminalWidth)) + const widthCap = Math.max(24, Math.trunc(maxWidth ?? preferredWidth)) + const width = Math.max(24, Math.min(preferredWidth, widthCap)) useEffect(() => { gw.request('model.options', sessionId ? { session_id: sessionId } : {}) @@ -105,7 +107,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke gw.request<{ provider?: ModelOptionProvider }>('model.save_key', { slug: provider?.slug, api_key: keyInput.trim(), - ...(sessionId ? { session_id: sessionId } : {}), + ...(sessionId ? { session_id: sessionId } : {}) }) .then(raw => { const r = asRpcResult<{ provider?: ModelOptionProvider }>(raw) @@ -118,9 +120,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke } // Update the provider in our list with fresh data - setProviders(prev => - prev.map(p => p.slug === r.provider!.slug ? r.provider! : p) - ) + setProviders(prev => prev.map(p => (p.slug === r.provider!.slug ? r.provider! : p))) setKeyInput('') setKeySaving(false) setStage('model') @@ -166,7 +166,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke setKeySaving(true) gw.request<{ disconnected?: boolean }>('model.disconnect', { slug: provider.slug, - ...(sessionId ? { session_id: sessionId } : {}), + ...(sessionId ? { session_id: sessionId } : {}) }) .then(raw => { const r = asRpcResult<{ disconnected?: boolean }>(raw) @@ -174,9 +174,16 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke if (r?.disconnected) { // Mark provider as unauthenticated in local state setProviders(prev => - prev.map(p => p.slug === provider.slug - ? { ...p, authenticated: false, models: [], total_models: 0, warning: p.key_env ? `paste ${p.key_env} to activate` : 'run `hermes model` to configure' } - : p + prev.map(p => + p.slug === provider.slug + ? { + ...p, + authenticated: false, + models: [], + total_models: 0, + warning: p.key_env ? `paste ${p.key_env} to activate` : 'run `hermes model` to configure' + } + : p ) ) } @@ -302,17 +309,23 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke Paste your API key below (saved to ~/.hermes/.env) - + + {' '} + {provider.key_env}: - {' '}{masked || '(empty)'}{keySaving ? '' : 'β–Ž'} + {' '} + {masked || '(empty)'} + {keySaving ? '' : 'β–Ž'} - + + {' '} + {keyError ? ( @@ -323,7 +336,9 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke saving… ) : ( - + + {' '} + )} Enter save Β· Ctrl+U clear Β· Esc back @@ -339,7 +354,9 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke Disconnect {provider.name}? - + + {' '} + This removes saved credentials for {provider.name}. @@ -349,10 +366,14 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke You can re-authenticate later by selecting it again. - + + {' '} + {keySaving ? ( - disconnecting… + + disconnecting… + ) : ( y/Enter confirm Β· n/Esc cancel )} @@ -362,17 +383,14 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke // ── Provider selection stage ───────────────────────────────────────── if (stage === 'provider') { - const rows = providers.map( - (p, i) => { - const authMark = p.authenticated === false ? 'β—‹' : p.is_current ? '*' : '●' - const modelCount = p.total_models ?? p.models?.length ?? 0 - const suffix = p.authenticated === false - ? (p.auth_type === 'api_key' ? '(no key)' : '(needs setup)') - : `${modelCount} models` + const rows = providers.map((p, i) => { + const authMark = p.authenticated === false ? 'β—‹' : p.is_current ? '*' : '●' + const modelCount = p.total_models ?? p.models?.length ?? 0 + const suffix = + p.authenticated === false ? (p.auth_type === 'api_key' ? '(no key)' : '(needs setup)') : `${modelCount} models` - return `${authMark} ${names[i]} Β· ${suffix}` - } - ) + return `${authMark} ${names[i]} Β· ${suffix}` + }) const { items, offset } = windowItems(rows, providerIdx, VISIBLE) @@ -499,6 +517,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke interface ModelPickerProps { gw: GatewayClient + maxWidth?: number onCancel: () => void onSelect: (value: string) => void sessionId: string | null diff --git a/ui-tui/src/components/sessionPicker.tsx b/ui-tui/src/components/sessionPicker.tsx index e836e59852..1dbd2d52c6 100644 --- a/ui-tui/src/components/sessionPicker.tsx +++ b/ui-tui/src/components/sessionPicker.tsx @@ -26,7 +26,7 @@ const age = (ts: number) => { return `${Math.floor(d)}d ago` } -export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) { +export function SessionPicker({ gw, maxWidth, onCancel, onSelect, t }: SessionPickerProps) { const [items, setItems] = useState([]) const [err, setErr] = useState('') const [sel, setSel] = useState(0) @@ -37,7 +37,10 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) const [deleting, setDeleting] = useState(false) const { stdout } = useStdout() - const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + const terminalWidth = Math.max(1, (stdout?.columns ?? 80) - 6) + const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, terminalWidth)) + const widthCap = Math.max(24, Math.trunc(maxWidth ?? preferredWidth)) + const width = Math.max(24, Math.min(preferredWidth, widthCap)) useOverlayKeys({ onClose: onCancel }) @@ -171,7 +174,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) Resume Session - {offset > 0 && ↑ {offset} more} + {offset > 0 && ↑ {offset} more} {items.slice(offset, offset + VISIBLE).map((s, vi) => { const i = offset + vi @@ -208,7 +211,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) ) })} - {offset + VISIBLE < items.length && ↓ {items.length - offset - VISIBLE} more} + {offset + VISIBLE < items.length && ↓ {items.length - offset - VISIBLE} more} {err && error: {err}} {deleting ? ( deleting… @@ -221,6 +224,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) interface SessionPickerProps { gw: GatewayClient + maxWidth?: number onCancel: () => void onSelect: (id: string) => void t: Theme diff --git a/ui-tui/src/components/skillsHub.tsx b/ui-tui/src/components/skillsHub.tsx index 941ee0b275..31c8f355f0 100644 --- a/ui-tui/src/components/skillsHub.tsx +++ b/ui-tui/src/components/skillsHub.tsx @@ -11,7 +11,7 @@ const VISIBLE = 12 const MIN_WIDTH = 40 const MAX_WIDTH = 90 -export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { +export function SkillsHub({ gw, maxWidth, onClose, t }: SkillsHubProps) { const [skillsByCat, setSkillsByCat] = useState>({}) const [selectedCat, setSelectedCat] = useState('') const [catIdx, setCatIdx] = useState(0) @@ -23,7 +23,10 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { const [loading, setLoading] = useState(true) const { stdout } = useStdout() - const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + const terminalWidth = Math.max(1, (stdout?.columns ?? 80) - 6) + const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, terminalWidth)) + const widthCap = Math.max(24, Math.trunc(maxWidth ?? preferredWidth)) + const width = Math.max(24, Math.min(preferredWidth, widthCap)) useEffect(() => { gw.request<{ skills?: Record }>('skills.manage', { action: 'list' }) @@ -303,6 +306,7 @@ interface SkillInfo { interface SkillsHubProps { gw: GatewayClient + maxWidth?: number onClose: () => void t: Theme } diff --git a/ui-tui/src/components/widgetGrid.tsx b/ui-tui/src/components/widgetGrid.tsx new file mode 100644 index 0000000000..792f6f4fd9 --- /dev/null +++ b/ui-tui/src/components/widgetGrid.tsx @@ -0,0 +1,90 @@ +import { Box } from '@hermes/ink' +import { Fragment, memo, useMemo, type ReactNode } from 'react' + +import { layoutWidgetGrid, type WidgetGridCell, type WidgetGridItem } from '../lib/widgetGrid.js' + +export interface WidgetGridWidget extends WidgetGridItem { + render: (width: number) => ReactNode +} + +interface WidgetGridProps { + cols: number + gap?: number + maxColumns?: number + minColumnWidth?: number + rowGap?: number + widgets: WidgetGridWidget[] +} + +export const WidgetGrid = memo(function WidgetGrid({ + cols, + gap = 2, + maxColumns = 2, + minColumnWidth = 46, + rowGap = 1, + widgets +}: WidgetGridProps) { + const layout = useMemo( + () => + layoutWidgetGrid({ + gap, + items: widgets.map(({ id, span }) => ({ id, span })), + maxColumns, + minColumnWidth, + width: cols + }), + [cols, gap, maxColumns, minColumnWidth, widgets] + ) + + const widgetById = useMemo(() => new Map(widgets.map(widget => [widget.id, widget])), [widgets]) + + if (!layout.rows.length) { + return null + } + + return ( + + {layout.rows.map((row, rowIdx) => ( + + + {row.map((cell, cellIdx) => ( + + ))} + + + {rowGap > 0 && rowIdx < layout.rows.length - 1 ? : null} + + ))} + + ) +}) + +const WidgetCell = memo(function WidgetCell({ + cell, + gap, + isLast, + widget +}: { + cell: WidgetGridCell + gap: number + isLast: boolean + widget?: WidgetGridWidget +}) { + const node = widget?.render(cell.width) ?? null + + return ( + + + {node} + + + {!isLast && gap > 0 ? : null} + + ) +}) diff --git a/ui-tui/src/lib/widgetGrid.ts b/ui-tui/src/lib/widgetGrid.ts new file mode 100644 index 0000000000..bdb28bb0a4 --- /dev/null +++ b/ui-tui/src/lib/widgetGrid.ts @@ -0,0 +1,104 @@ +export interface WidgetGridItem { + id: string + span?: number +} + +export interface WidgetGridCell { + col: number + id: string + span: number + width: number +} + +export interface WidgetGridLayout { + columnCount: number + columns: number[] + rows: WidgetGridCell[][] +} + +export interface WidgetGridLayoutOptions { + gap?: number + items: WidgetGridItem[] + maxColumns?: number + minColumnWidth?: number + width: number +} + +const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)) + +const toInt = (value: number, fallback: number) => { + if (!Number.isFinite(value)) { + return fallback + } + + return Math.trunc(value) +} + +const columnCountForWidth = (width: number, minColumnWidth: number, gap: number, maxColumns: number) => { + const safeWidth = Math.max(1, toInt(width, 1)) + const safeMinWidth = Math.max(1, toInt(minColumnWidth, 1)) + const safeGap = Math.max(0, toInt(gap, 0)) + const safeMaxColumns = Math.max(1, toInt(maxColumns, 1)) + const count = Math.floor((safeWidth + safeGap) / (safeMinWidth + safeGap)) + + return clamp(count || 1, 1, safeMaxColumns) +} + +const buildColumnWidths = (width: number, columnCount: number, gap: number) => { + const safeWidth = Math.max(1, toInt(width, 1)) + const safeGap = Math.max(0, toInt(gap, 0)) + const slots = Math.max(1, toInt(columnCount, 1)) + const usable = Math.max(1, safeWidth - safeGap * Math.max(0, slots - 1)) + const base = Math.floor(usable / slots) + const remainder = usable % slots + + return Array.from({ length: slots }, (_, idx) => base + (idx < remainder ? 1 : 0)) +} + +const spanWidth = (columns: number[], colStart: number, span: number, gap: number) => { + const end = Math.min(columns.length, colStart + span) + const width = columns.slice(colStart, end).reduce((acc, value) => acc + value, 0) + const safeGap = Math.max(0, toInt(gap, 0)) + + return width + safeGap * Math.max(0, end - colStart - 1) +} + +export function layoutWidgetGrid({ + gap = 1, + items, + maxColumns = 3, + minColumnWidth = 28, + width +}: WidgetGridLayoutOptions): WidgetGridLayout { + const safeGap = Math.max(0, toInt(gap, 1)) + const columnCount = columnCountForWidth(width, minColumnWidth, safeGap, maxColumns) + const columns = buildColumnWidths(width, columnCount, safeGap) + const rows: WidgetGridCell[][] = [] + let row: WidgetGridCell[] = [] + let usedCols = 0 + + for (const item of items) { + const wantedSpan = clamp(toInt(item.span ?? 1, 1), 1, columnCount) + + if (row.length > 0 && usedCols + wantedSpan > columnCount) { + rows.push(row) + row = [] + usedCols = 0 + } + + row.push({ + col: usedCols, + id: item.id, + span: wantedSpan, + width: spanWidth(columns, usedCols, wantedSpan, safeGap) + }) + + usedCols += wantedSpan + } + + if (row.length > 0) { + rows.push(row) + } + + return { columnCount, columns, rows } +}