mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-06 10:47:12 +08:00
Compare commits
3 Commits
main
...
bb/widget-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dff5dc34ce | ||
|
|
4532182bda | ||
|
|
dbbd5512d5 |
60
ui-tui/src/__tests__/widgetGrid.test.ts
Normal file
60
ui-tui/src/__tests__/widgetGrid.test.ts
Normal file
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -110,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.
|
||||
@@ -117,66 +123,99 @@ export function FloatingOverlays({
|
||||
|
||||
const start = Math.max(0, Math.min(compIdx - Math.floor(COMPLETION_WINDOW / 2), completions.length - viewportSize))
|
||||
|
||||
return (
|
||||
<Box alignItems="flex-start" bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
|
||||
{overlay.picker && (
|
||||
const widgets: WidgetGridWidget[] = []
|
||||
|
||||
if (overlay.picker) {
|
||||
widgets.push({
|
||||
id: 'picker',
|
||||
render: width => (
|
||||
<FloatBox color={theme.color.border}>
|
||||
<SessionPicker
|
||||
gw={gw}
|
||||
maxWidth={capWidth(width)}
|
||||
onCancel={() => patchOverlayState({ picker: false })}
|
||||
onSelect={onPickerSelect}
|
||||
t={theme}
|
||||
/>
|
||||
</FloatBox>
|
||||
)}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
{overlay.modelPicker && (
|
||||
if (overlay.modelPicker) {
|
||||
widgets.push({
|
||||
id: 'model-picker',
|
||||
render: width => (
|
||||
<FloatBox color={theme.color.border}>
|
||||
<ModelPicker
|
||||
gw={gw}
|
||||
maxWidth={capWidth(width)}
|
||||
onCancel={() => patchOverlayState({ modelPicker: false })}
|
||||
onSelect={onModelSelect}
|
||||
sessionId={sid}
|
||||
t={theme}
|
||||
/>
|
||||
</FloatBox>
|
||||
)}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
{overlay.skillsHub && (
|
||||
if (overlay.skillsHub) {
|
||||
widgets.push({
|
||||
id: 'skills-hub',
|
||||
render: width => (
|
||||
<FloatBox color={theme.color.border}>
|
||||
<SkillsHub gw={gw} onClose={() => patchOverlayState({ skillsHub: false })} t={theme} />
|
||||
<SkillsHub
|
||||
gw={gw}
|
||||
maxWidth={capWidth(width)}
|
||||
onClose={() => patchOverlayState({ skillsHub: false })}
|
||||
t={theme}
|
||||
/>
|
||||
</FloatBox>
|
||||
)}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
{overlay.pager && (
|
||||
if (overlay.pager) {
|
||||
const pager = overlay.pager
|
||||
|
||||
widgets.push({
|
||||
id: 'pager',
|
||||
render: width => (
|
||||
<FloatBox color={theme.color.border}>
|
||||
<Box flexDirection="column" paddingX={1} paddingY={1}>
|
||||
{overlay.pager.title && (
|
||||
<Box flexDirection="column" paddingX={1} paddingY={1} width={capWidth(width)}>
|
||||
{pager.title && (
|
||||
<Box justifyContent="center" marginBottom={1}>
|
||||
<Text bold color={theme.color.primary}>
|
||||
{overlay.pager.title}
|
||||
{pager.title}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{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) => (
|
||||
<Text key={i}>{line}</Text>
|
||||
))}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<OverlayHint t={theme}>
|
||||
{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)`}
|
||||
</OverlayHint>
|
||||
</Box>
|
||||
</Box>
|
||||
</FloatBox>
|
||||
)}
|
||||
),
|
||||
span: fullSpan
|
||||
})
|
||||
}
|
||||
|
||||
{!!completions.length && (
|
||||
if (completions.length) {
|
||||
widgets.push({
|
||||
id: 'completions',
|
||||
render: width => (
|
||||
<FloatBox color={theme.color.primary}>
|
||||
<Box flexDirection="column" width={Math.max(28, cols - 6)}>
|
||||
<Box flexDirection="column" width={capWidth(width)}>
|
||||
{completions.slice(start, start + viewportSize).map((item, i) => {
|
||||
const active = start + i === compIdx
|
||||
|
||||
@@ -197,7 +236,14 @@ export function FloatingOverlays({
|
||||
})}
|
||||
</Box>
|
||||
</FloatBox>
|
||||
)}
|
||||
),
|
||||
span: fullSpan
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Box alignItems="flex-start" bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
|
||||
<WidgetGrid cols={gridCols} maxColumns={gridMaxColumns} minColumnWidth={46} rowGap={0} widgets={widgets} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<ModelOptionProvider[]>([])
|
||||
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<ModelOptionsResponse>('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)
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.muted} wrap="truncate-end"> </Text>
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{' '}
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{provider.key_env}:
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.accent} wrap="truncate-end">
|
||||
{' '}{masked || '(empty)'}{keySaving ? '' : '▎'}
|
||||
{' '}
|
||||
{masked || '(empty)'}
|
||||
{keySaving ? '' : '▎'}
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.muted} wrap="truncate-end"> </Text>
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{' '}
|
||||
</Text>
|
||||
|
||||
{keyError ? (
|
||||
<Text color={t.color.label} wrap="truncate-end">
|
||||
@@ -323,7 +336,9 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
saving…
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={t.color.muted} wrap="truncate-end"> </Text>
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{' '}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<OverlayHint t={t}>Enter save · Ctrl+U clear · Esc back</OverlayHint>
|
||||
@@ -339,7 +354,9 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
Disconnect {provider.name}?
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.muted} wrap="truncate-end"> </Text>
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{' '}
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
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.
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.muted} wrap="truncate-end"> </Text>
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{' '}
|
||||
</Text>
|
||||
|
||||
{keySaving ? (
|
||||
<Text color={t.color.muted} wrap="truncate-end">disconnecting…</Text>
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
disconnecting…
|
||||
</Text>
|
||||
) : (
|
||||
<OverlayHint t={t}>y/Enter confirm · n/Esc cancel</OverlayHint>
|
||||
)}
|
||||
@@ -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
|
||||
|
||||
@@ -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<SessionListItem[]>([])
|
||||
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
|
||||
</Text>
|
||||
|
||||
{offset > 0 && <Text color={t.color.muted}> ↑ {offset} more</Text>}
|
||||
{offset > 0 && <Text color={t.color.muted}> ↑ {offset} more</Text>}
|
||||
|
||||
{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 && <Text color={t.color.muted}> ↓ {items.length - offset - VISIBLE} more</Text>}
|
||||
{offset + VISIBLE < items.length && <Text color={t.color.muted}> ↓ {items.length - offset - VISIBLE} more</Text>}
|
||||
{err && <Text color={t.color.label}>error: {err}</Text>}
|
||||
{deleting ? (
|
||||
<OverlayHint t={t}>deleting…</OverlayHint>
|
||||
@@ -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
|
||||
|
||||
@@ -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<Record<string, string[]>>({})
|
||||
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<string, string[]> }>('skills.manage', { action: 'list' })
|
||||
@@ -303,6 +306,7 @@ interface SkillInfo {
|
||||
|
||||
interface SkillsHubProps {
|
||||
gw: GatewayClient
|
||||
maxWidth?: number
|
||||
onClose: () => void
|
||||
t: Theme
|
||||
}
|
||||
|
||||
90
ui-tui/src/components/widgetGrid.tsx
Normal file
90
ui-tui/src/components/widgetGrid.tsx
Normal file
@@ -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 (
|
||||
<Box flexDirection="column" width={Math.max(1, cols)}>
|
||||
{layout.rows.map((row, rowIdx) => (
|
||||
<Box flexDirection="column" key={`row-${rowIdx}`}>
|
||||
<Box flexDirection="row">
|
||||
{row.map((cell, cellIdx) => (
|
||||
<WidgetCell
|
||||
cell={cell}
|
||||
gap={gap}
|
||||
isLast={cellIdx === row.length - 1}
|
||||
key={cell.id}
|
||||
widget={widgetById.get(cell.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{rowGap > 0 && rowIdx < layout.rows.length - 1 ? <Box height={rowGap} /> : null}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
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 (
|
||||
<Fragment>
|
||||
<Box flexShrink={0} width={cell.width}>
|
||||
{node}
|
||||
</Box>
|
||||
|
||||
{!isLast && gap > 0 ? <Box flexShrink={0} width={gap} /> : null}
|
||||
</Fragment>
|
||||
)
|
||||
})
|
||||
104
ui-tui/src/lib/widgetGrid.ts
Normal file
104
ui-tui/src/lib/widgetGrid.ts
Normal file
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user