@silvery/ui 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +71 -0
- package/src/animation/easing.ts +38 -0
- package/src/animation/index.ts +18 -0
- package/src/animation/useAnimation.ts +143 -0
- package/src/animation/useInterval.ts +39 -0
- package/src/animation/useLatest.ts +35 -0
- package/src/animation/useTimeout.ts +65 -0
- package/src/animation/useTransition.ts +110 -0
- package/src/animation.ts +24 -0
- package/src/ansi/index.ts +43 -0
- package/src/canvas/index.ts +169 -0
- package/src/cli/ansi.ts +85 -0
- package/src/cli/index.ts +39 -0
- package/src/cli/multi-progress.ts +340 -0
- package/src/cli/progress-bar.ts +222 -0
- package/src/cli/spinner.ts +275 -0
- package/src/components/Badge.tsx +54 -0
- package/src/components/Breadcrumb.tsx +72 -0
- package/src/components/Button.tsx +73 -0
- package/src/components/CommandPalette.tsx +186 -0
- package/src/components/Console.tsx +79 -0
- package/src/components/CursorLine.tsx +71 -0
- package/src/components/Divider.tsx +67 -0
- package/src/components/EditContextDisplay.tsx +164 -0
- package/src/components/ErrorBoundary.tsx +179 -0
- package/src/components/Form.tsx +86 -0
- package/src/components/GridCell.tsx +42 -0
- package/src/components/HorizontalVirtualList.tsx +375 -0
- package/src/components/ModalDialog.tsx +179 -0
- package/src/components/PickerDialog.tsx +208 -0
- package/src/components/PickerList.tsx +93 -0
- package/src/components/ProgressBar.tsx +126 -0
- package/src/components/Screen.tsx +78 -0
- package/src/components/ScrollbackList.tsx +92 -0
- package/src/components/ScrollbackView.tsx +390 -0
- package/src/components/SelectList.tsx +176 -0
- package/src/components/Skeleton.tsx +87 -0
- package/src/components/Spinner.tsx +64 -0
- package/src/components/SplitView.tsx +199 -0
- package/src/components/Table.tsx +139 -0
- package/src/components/Tabs.tsx +203 -0
- package/src/components/TextArea.tsx +264 -0
- package/src/components/TextInput.tsx +240 -0
- package/src/components/Toast.tsx +216 -0
- package/src/components/Toggle.tsx +73 -0
- package/src/components/Tooltip.tsx +60 -0
- package/src/components/TreeView.tsx +212 -0
- package/src/components/Typography.tsx +233 -0
- package/src/components/VirtualList.tsx +318 -0
- package/src/components/VirtualView.tsx +221 -0
- package/src/components/useReadline.ts +213 -0
- package/src/components/useTextArea.ts +648 -0
- package/src/components.ts +133 -0
- package/src/display/Table.tsx +179 -0
- package/src/display/index.ts +13 -0
- package/src/hooks/useTea.ts +133 -0
- package/src/image/Image.tsx +187 -0
- package/src/image/index.ts +15 -0
- package/src/image/kitty-graphics.ts +161 -0
- package/src/image/sixel-encoder.ts +194 -0
- package/src/images.ts +22 -0
- package/src/index.ts +34 -0
- package/src/input/Select.tsx +155 -0
- package/src/input/TextInput.tsx +227 -0
- package/src/input/index.ts +25 -0
- package/src/progress/als-context.ts +160 -0
- package/src/progress/declarative.ts +519 -0
- package/src/progress/index.ts +54 -0
- package/src/progress/step-node.ts +152 -0
- package/src/progress/steps.ts +425 -0
- package/src/progress/task.ts +138 -0
- package/src/progress/tasks.ts +216 -0
- package/src/react/ProgressBar.tsx +146 -0
- package/src/react/Spinner.tsx +74 -0
- package/src/react/Tasks.tsx +144 -0
- package/src/react/context.tsx +145 -0
- package/src/react/index.ts +30 -0
- package/src/types.ts +252 -0
- package/src/utils/eta.ts +155 -0
- package/src/utils/index.ts +13 -0
- package/src/wrappers/index.ts +36 -0
- package/src/wrappers/with-progress.ts +250 -0
- package/src/wrappers/with-select.ts +194 -0
- package/src/wrappers/with-spinner.ts +108 -0
- package/src/wrappers/with-text-input.ts +388 -0
- package/src/wrappers/wrap-emitter.ts +158 -0
- package/src/wrappers/wrap-generator.ts +143 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ScrollbackList - Declarative wrapper around useScrollback.
|
|
3
|
+
*
|
|
4
|
+
* Manages a list of items where completed items freeze into terminal
|
|
5
|
+
* scrollback. Items signal completion by calling `freeze()` from the
|
|
6
|
+
* useScrollbackItem hook. Frozen items are written to stdout in order
|
|
7
|
+
* and removed from the live render area.
|
|
8
|
+
*
|
|
9
|
+
* The component enforces a contiguous prefix invariant: items freeze
|
|
10
|
+
* in order from the start. If item 3 calls freeze() but items 0-2
|
|
11
|
+
* have not yet frozen, item 3 is marked but won't flush to scrollback
|
|
12
|
+
* until 0-2 are also frozen.
|
|
13
|
+
*
|
|
14
|
+
* This is a thin wrapper around ScrollbackView (which adds maxHistory support).
|
|
15
|
+
* The two components share identical scrollback semantics.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```tsx
|
|
19
|
+
* function App() {
|
|
20
|
+
* const [tasks, setTasks] = useState<Task[]>(initialTasks)
|
|
21
|
+
*
|
|
22
|
+
* return (
|
|
23
|
+
* <ScrollbackList
|
|
24
|
+
* items={tasks}
|
|
25
|
+
* keyExtractor={(t) => t.id}
|
|
26
|
+
* footer={<Text>Status bar</Text>}
|
|
27
|
+
* >
|
|
28
|
+
* {(task) => <TaskItem task={task} />}
|
|
29
|
+
* </ScrollbackList>
|
|
30
|
+
* )
|
|
31
|
+
* }
|
|
32
|
+
*
|
|
33
|
+
* function TaskItem({ task }: { task: Task }) {
|
|
34
|
+
* const { freeze } = useScrollbackItem()
|
|
35
|
+
* useEffect(() => { if (task.done) freeze() }, [task.done])
|
|
36
|
+
* return <Text>{task.title}</Text>
|
|
37
|
+
* }
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import type { ReactElement } from "react"
|
|
42
|
+
import type { ScrollbackMarkerCallbacks } from "@silvery/react/hooks/useScrollback"
|
|
43
|
+
import type { ReactNode } from "react"
|
|
44
|
+
import { ScrollbackView } from "./ScrollbackView"
|
|
45
|
+
|
|
46
|
+
// ============================================================================
|
|
47
|
+
// Types
|
|
48
|
+
// ============================================================================
|
|
49
|
+
|
|
50
|
+
export interface ScrollbackListProps<T> {
|
|
51
|
+
/** Array of items to render. */
|
|
52
|
+
items: T[]
|
|
53
|
+
/** Render function for each item. Receives item and its index. */
|
|
54
|
+
children?: (item: T, index: number) => ReactNode
|
|
55
|
+
/** Render function for each item. Alternative to children — prefer this for performance
|
|
56
|
+
* as it can be wrapped in useCallback for memoization. */
|
|
57
|
+
renderItem?: (item: T, index: number) => ReactNode
|
|
58
|
+
/** Extract a unique key for each item. */
|
|
59
|
+
keyExtractor: (item: T, index: number) => string | number
|
|
60
|
+
/**
|
|
61
|
+
* Data-driven frozen predicate. Items matching this predicate are frozen
|
|
62
|
+
* immediately on render (no effect roundtrip needed). Works in addition
|
|
63
|
+
* to the freeze() callback from useScrollbackItem.
|
|
64
|
+
*/
|
|
65
|
+
isFrozen?: (item: T, index: number) => boolean
|
|
66
|
+
/** Optional footer pinned at the bottom of the terminal. */
|
|
67
|
+
footer?: ReactNode
|
|
68
|
+
/** @deprecated Footer now auto-sizes to content. This prop is ignored. */
|
|
69
|
+
footerHeight?: number
|
|
70
|
+
/** OSC 133 marker configuration, forwarded to useScrollback. */
|
|
71
|
+
markers?: boolean | ScrollbackMarkerCallbacks<T>
|
|
72
|
+
/** Terminal width in columns. Default: process.stdout.columns. */
|
|
73
|
+
width?: number
|
|
74
|
+
/** Output stream for writing frozen items. Default: process.stdout. */
|
|
75
|
+
stdout?: { write(data: string): boolean }
|
|
76
|
+
/** Called when recovery from inconsistent state occurs. */
|
|
77
|
+
onRecovery?: () => void
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ============================================================================
|
|
81
|
+
// Component
|
|
82
|
+
// ============================================================================
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A list component that pushes completed items to terminal scrollback.
|
|
86
|
+
*
|
|
87
|
+
* Thin wrapper around ScrollbackView — delegates all rendering and scrollback
|
|
88
|
+
* management to ScrollbackView without maxHistory (unlimited by default).
|
|
89
|
+
*/
|
|
90
|
+
export function ScrollbackList<T>(props: ScrollbackListProps<T>): ReactElement {
|
|
91
|
+
return <ScrollbackView {...props} />
|
|
92
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ScrollbackView - Native scrollback root component.
|
|
3
|
+
*
|
|
4
|
+
* Uses the normal terminal buffer. Children flow vertically. As items scroll
|
|
5
|
+
* off the top of the screen, they transition through the virtualization
|
|
6
|
+
* lifecycle (Live → Virtualized → Static) and are committed to terminal
|
|
7
|
+
* scrollback.
|
|
8
|
+
*
|
|
9
|
+
* The user scrolls with their terminal's native scroll (mouse wheel, scrollbar,
|
|
10
|
+
* Shift+PageUp). Text selection is free. Content becomes part of the terminal's
|
|
11
|
+
* permanent history.
|
|
12
|
+
*
|
|
13
|
+
* This is an evolution of ScrollbackList with automatic lifecycle management
|
|
14
|
+
* via the shared useVirtualizer() engine.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```tsx
|
|
18
|
+
* <ScrollbackView footer={<StatusBar />}>
|
|
19
|
+
* {messages.map(m => <Message key={m.id} data={m} />)}
|
|
20
|
+
* </ScrollbackView>
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```tsx
|
|
25
|
+
* // With item-level lifecycle control via useScrollbackItem
|
|
26
|
+
* <ScrollbackView
|
|
27
|
+
* items={tasks}
|
|
28
|
+
* keyExtractor={(t) => t.id}
|
|
29
|
+
* isFrozen={(t) => t.done}
|
|
30
|
+
* footer={<Text>Status bar</Text>}
|
|
31
|
+
* >
|
|
32
|
+
* {(task) => <TaskItem task={task} />}
|
|
33
|
+
* </ScrollbackView>
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import {
|
|
38
|
+
memo,
|
|
39
|
+
useCallback,
|
|
40
|
+
useEffect,
|
|
41
|
+
useLayoutEffect,
|
|
42
|
+
useMemo,
|
|
43
|
+
useRef,
|
|
44
|
+
useState,
|
|
45
|
+
type ReactElement,
|
|
46
|
+
type ReactNode,
|
|
47
|
+
} from "react"
|
|
48
|
+
import type { ScrollbackMarkerCallbacks } from "@silvery/react/hooks/useScrollback"
|
|
49
|
+
import { useScrollback } from "@silvery/react/hooks/useScrollback"
|
|
50
|
+
import { renderStringSync } from "@silvery/react/render-string"
|
|
51
|
+
import { ScrollbackItemProvider } from "@silvery/react/hooks/useScrollbackItem"
|
|
52
|
+
import type { TeaNode } from "@silvery/tea/types"
|
|
53
|
+
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// Types
|
|
56
|
+
// ============================================================================
|
|
57
|
+
|
|
58
|
+
export interface ScrollbackViewProps<T> {
|
|
59
|
+
/** Array of items to render. */
|
|
60
|
+
items: T[]
|
|
61
|
+
/** Render function for each item. Receives item and its index. */
|
|
62
|
+
children?: (item: T, index: number) => ReactNode
|
|
63
|
+
/** Render function for each item. Alternative to children — prefer this for performance
|
|
64
|
+
* as it can be wrapped in useCallback for memoization. */
|
|
65
|
+
renderItem?: (item: T, index: number) => ReactNode
|
|
66
|
+
/** Extract a unique key for each item. */
|
|
67
|
+
keyExtractor: (item: T, index: number) => string | number
|
|
68
|
+
/**
|
|
69
|
+
* Data-driven frozen predicate. Items matching this predicate are frozen
|
|
70
|
+
* immediately on render (no effect roundtrip needed). Works in addition
|
|
71
|
+
* to the freeze() callback from useScrollbackItem.
|
|
72
|
+
*/
|
|
73
|
+
isFrozen?: (item: T, index: number) => boolean
|
|
74
|
+
/** Optional footer pinned at the bottom of the terminal. */
|
|
75
|
+
footer?: ReactNode
|
|
76
|
+
/** @deprecated Footer now auto-sizes to content. This prop is ignored. */
|
|
77
|
+
footerHeight?: number
|
|
78
|
+
/**
|
|
79
|
+
* Maximum lines to retain in dynamic scrollback before promoting to static.
|
|
80
|
+
* Items beyond this boundary become static (data dropped, terminal owns them).
|
|
81
|
+
* Default: 10000
|
|
82
|
+
*/
|
|
83
|
+
maxHistory?: number
|
|
84
|
+
/** OSC 133 marker configuration, forwarded to useScrollback. */
|
|
85
|
+
markers?: boolean | ScrollbackMarkerCallbacks<T>
|
|
86
|
+
/** Terminal width in columns. Default: process.stdout.columns. */
|
|
87
|
+
width?: number
|
|
88
|
+
/** Output stream for writing frozen items. Default: process.stdout. */
|
|
89
|
+
stdout?: { write(data: string): boolean }
|
|
90
|
+
/** Called when recovery from inconsistent state occurs. */
|
|
91
|
+
onRecovery?: () => void
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ============================================================================
|
|
95
|
+
// Helpers
|
|
96
|
+
// ============================================================================
|
|
97
|
+
|
|
98
|
+
/** Get terminal columns, falling back to 80 for non-TTY environments. */
|
|
99
|
+
function getTermCols(): number {
|
|
100
|
+
return process.stdout.columns ?? 80
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ============================================================================
|
|
104
|
+
// Component
|
|
105
|
+
// ============================================================================
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Native scrollback view with automatic item lifecycle management.
|
|
109
|
+
*
|
|
110
|
+
* Items rendered inside ScrollbackView have access to `useScrollbackItem()`
|
|
111
|
+
* which provides a `freeze()` function. When an item calls freeze(), it is
|
|
112
|
+
* marked for scrollback. Once a contiguous prefix of items are all frozen,
|
|
113
|
+
* they are rendered to strings and written to stdout via useScrollback.
|
|
114
|
+
*
|
|
115
|
+
* This is the native-scrollback counterpart to VirtualView. Where
|
|
116
|
+
* VirtualView keeps everything in the React tree, ScrollbackView commits
|
|
117
|
+
* completed items to the terminal's scrollback buffer.
|
|
118
|
+
*
|
|
119
|
+
* NOTE: DO NOT use DECSTBM scroll regions to pin the footer. Lines scrolled
|
|
120
|
+
* out of a DECSTBM region are DISCARDED by the terminal — they never enter
|
|
121
|
+
* scrollback history. This has been confirmed across multiple terminals
|
|
122
|
+
* (xterm, iTerm2, Ghostty, etc.) and is a fundamental terminal limitation.
|
|
123
|
+
* The footer is pinned purely via flex layout (flexShrink={0}).
|
|
124
|
+
*/
|
|
125
|
+
export function ScrollbackView<T>({
|
|
126
|
+
items,
|
|
127
|
+
children,
|
|
128
|
+
renderItem,
|
|
129
|
+
keyExtractor,
|
|
130
|
+
isFrozen: isFrozenProp,
|
|
131
|
+
footer,
|
|
132
|
+
footerHeight: _footerHeight,
|
|
133
|
+
maxHistory: _maxHistory = 10000,
|
|
134
|
+
markers,
|
|
135
|
+
width,
|
|
136
|
+
stdout = process.stdout as unknown as { write(data: string): boolean },
|
|
137
|
+
onRecovery,
|
|
138
|
+
}: ScrollbackViewProps<T>): ReactElement {
|
|
139
|
+
// Track terminal width reactively so we re-render on resize.
|
|
140
|
+
// Without this, getTermCols() is only called during render — if no React
|
|
141
|
+
// state changes on resize, the component never re-renders and useScrollback
|
|
142
|
+
// never detects the width change (frozen items aren't re-emitted).
|
|
143
|
+
const [termWidth, setTermWidth] = useState(getTermCols)
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
if (width !== undefined) return // Parent controls width — skip listener
|
|
146
|
+
// Use the stdout prop (defaults to process.stdout) — works with both
|
|
147
|
+
// real terminals and test mocks that emit "resize" events.
|
|
148
|
+
const stream = stdout as { on?: Function; off?: Function; columns?: number }
|
|
149
|
+
if (!stream?.on || !stream?.columns) return
|
|
150
|
+
const onResize = () => setTermWidth((stream as { columns: number }).columns ?? 80)
|
|
151
|
+
stream.on("resize", onResize)
|
|
152
|
+
return () => {
|
|
153
|
+
stream.off?.("resize", onResize)
|
|
154
|
+
}
|
|
155
|
+
}, [width, stdout])
|
|
156
|
+
|
|
157
|
+
const effectiveWidth = width ?? termWidth
|
|
158
|
+
|
|
159
|
+
// Track the outer node's layout to derive horizontal padding.
|
|
160
|
+
//
|
|
161
|
+
// When the component is inside a parent with padding/borders, the layout
|
|
162
|
+
// engine gives a narrower width. Frozen items must be rendered at this
|
|
163
|
+
// narrower width to match live items.
|
|
164
|
+
//
|
|
165
|
+
// Key insight: horizontal padding (in columns) is STABLE across resize.
|
|
166
|
+
// paddingX=1 always means 2 columns of padding whether the terminal is
|
|
167
|
+
// 80 or 60 cols wide. So we store padding as a stable offset and compute
|
|
168
|
+
// frozenWidth = effectiveWidth - hPadding on every render. This avoids
|
|
169
|
+
// the stale-layoutInfo problem where resize triggers a re-emit before
|
|
170
|
+
// the layout engine has recomputed at the new width.
|
|
171
|
+
const outerNodeRef = useRef<TeaNode | null>(null)
|
|
172
|
+
const [layoutInfo, setLayoutInfo] = useState<{ width: number; x: number } | null>(null)
|
|
173
|
+
|
|
174
|
+
// Horizontal padding: total left+right padding from parent containers.
|
|
175
|
+
// Updated only when layoutInfo changes (at which point effectiveWidth and
|
|
176
|
+
// layoutInfo.width are consistent — both computed at the same terminal width).
|
|
177
|
+
const hPaddingRef = useRef(0)
|
|
178
|
+
const prevLayoutInfoRef = useRef<{ width: number; x: number } | null>(null)
|
|
179
|
+
|
|
180
|
+
useLayoutEffect(() => {
|
|
181
|
+
const node = outerNodeRef.current
|
|
182
|
+
if (!node) return
|
|
183
|
+
|
|
184
|
+
const update = () => {
|
|
185
|
+
const rect = node.contentRect
|
|
186
|
+
if (rect && rect.width > 0) {
|
|
187
|
+
setLayoutInfo((prev) => {
|
|
188
|
+
if (prev && prev.width === rect.width && prev.x === rect.x) return prev
|
|
189
|
+
return { width: rect.width, x: rect.x }
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
update()
|
|
195
|
+
node.layoutSubscribers.add(update)
|
|
196
|
+
return () => {
|
|
197
|
+
node.layoutSubscribers.delete(update)
|
|
198
|
+
}
|
|
199
|
+
}, [])
|
|
200
|
+
|
|
201
|
+
// Update hPadding only when layoutInfo changes (not on every render).
|
|
202
|
+
// When layoutInfo changes, the layout engine just ran, so effectiveWidth
|
|
203
|
+
// and layoutInfo.width are consistent — safe to compute the delta.
|
|
204
|
+
if (layoutInfo !== prevLayoutInfoRef.current) {
|
|
205
|
+
prevLayoutInfoRef.current = layoutInfo
|
|
206
|
+
if (layoutInfo && layoutInfo.width > 0 && width === undefined) {
|
|
207
|
+
const padding = effectiveWidth - layoutInfo.width
|
|
208
|
+
if (padding >= 0) hPaddingRef.current = padding
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Frozen rendering width: terminal width minus stable horizontal padding.
|
|
213
|
+
// This is correct even during resize (before layout recomputes) because
|
|
214
|
+
// hPadding is stable — it was computed from the previous layout and doesn't
|
|
215
|
+
// change when the terminal resizes.
|
|
216
|
+
const frozenWidth = width ?? Math.max(1, effectiveWidth - hPaddingRef.current)
|
|
217
|
+
const frozenLeftPad = layoutInfo?.x ?? 0
|
|
218
|
+
|
|
219
|
+
// Resolve render function: renderItem takes precedence over children
|
|
220
|
+
const render = renderItem ?? children
|
|
221
|
+
if (!render) {
|
|
222
|
+
throw new Error("ScrollbackView requires either a `renderItem` prop or `children` render function")
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Set of item keys that have been marked as frozen via freeze()
|
|
226
|
+
const [frozenKeys, setFrozenKeys] = useState<Set<string | number>>(() => new Set())
|
|
227
|
+
|
|
228
|
+
// Optional snapshot overrides: key -> ReactElement
|
|
229
|
+
const snapshotRef = useRef<Map<string | number, ReactElement>>(new Map())
|
|
230
|
+
|
|
231
|
+
// Cached freeze functions per key — stable references for memoization
|
|
232
|
+
const freezeCache = useRef(new Map<string | number, (snapshot?: ReactElement) => void>())
|
|
233
|
+
|
|
234
|
+
const getFreeze = useCallback((key: string | number) => {
|
|
235
|
+
let fn = freezeCache.current.get(key)
|
|
236
|
+
if (!fn) {
|
|
237
|
+
fn = (snapshot?: ReactElement) => {
|
|
238
|
+
if (snapshot) snapshotRef.current.set(key, snapshot)
|
|
239
|
+
setFrozenKeys((prev) => {
|
|
240
|
+
if (prev.has(key)) return prev
|
|
241
|
+
const next = new Set(prev)
|
|
242
|
+
next.add(key)
|
|
243
|
+
return next
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
freezeCache.current.set(key, fn)
|
|
247
|
+
}
|
|
248
|
+
return fn
|
|
249
|
+
}, [])
|
|
250
|
+
|
|
251
|
+
// Frozen predicate for useScrollback: combine data-driven isFrozen prop
|
|
252
|
+
// with the imperative freeze() callback (frozenKeys set).
|
|
253
|
+
const frozenPredicate = useCallback(
|
|
254
|
+
(item: T, index: number): boolean => {
|
|
255
|
+
if (isFrozenProp?.(item, index)) return true
|
|
256
|
+
const key = keyExtractor(item, index)
|
|
257
|
+
return frozenKeys.has(key)
|
|
258
|
+
},
|
|
259
|
+
[frozenKeys, keyExtractor, isFrozenProp],
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
// Render callback for useScrollback: render frozen item to string.
|
|
263
|
+
// Uses frozenWidth (layout-aware) instead of effectiveWidth (terminal-based)
|
|
264
|
+
// to match the width that live items get from the layout engine.
|
|
265
|
+
// Prepends left-padding to align frozen output with the parent's position.
|
|
266
|
+
const renderFrozen = useCallback(
|
|
267
|
+
(item: T, index: number): string => {
|
|
268
|
+
const key = keyExtractor(item, index)
|
|
269
|
+
const snapshot = snapshotRef.current.get(key)
|
|
270
|
+
const noop = () => {}
|
|
271
|
+
const inner = snapshot ?? (render(item, index) as ReactElement)
|
|
272
|
+
const element = (
|
|
273
|
+
<ScrollbackItemProvider freeze={noop} isFrozen={true} index={index} nearScrollback={false}>
|
|
274
|
+
{inner}
|
|
275
|
+
</ScrollbackItemProvider>
|
|
276
|
+
)
|
|
277
|
+
try {
|
|
278
|
+
let text = renderStringSync(element, { width: frozenWidth, plain: false })
|
|
279
|
+
// Add left-padding to match the parent's layout position.
|
|
280
|
+
// Without this, frozen items start at column 0 while live items
|
|
281
|
+
// are indented by the parent's padding.
|
|
282
|
+
if (frozenLeftPad > 0) {
|
|
283
|
+
const pad = " ".repeat(frozenLeftPad)
|
|
284
|
+
text = text
|
|
285
|
+
.split("\n")
|
|
286
|
+
.map((line) => pad + line)
|
|
287
|
+
.join("\n")
|
|
288
|
+
}
|
|
289
|
+
return text
|
|
290
|
+
} catch {
|
|
291
|
+
return `[frozen item ${index}]`
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
[render, keyExtractor, frozenWidth, frozenLeftPad],
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
// Use the underlying useScrollback hook to manage stdout writes
|
|
298
|
+
const frozenCount = useScrollback(items, {
|
|
299
|
+
frozen: frozenPredicate,
|
|
300
|
+
render: renderFrozen,
|
|
301
|
+
stdout,
|
|
302
|
+
markers,
|
|
303
|
+
width: effectiveWidth,
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
// Clean up snapshot refs for items that have been flushed to scrollback
|
|
307
|
+
useEffect(() => {
|
|
308
|
+
if (frozenCount > 0) {
|
|
309
|
+
for (let i = 0; i < frozenCount; i++) {
|
|
310
|
+
const key = keyExtractor(items[i]!, i)
|
|
311
|
+
snapshotRef.current.delete(key)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}, [frozenCount, items, keyExtractor])
|
|
315
|
+
|
|
316
|
+
// Recovery: detect if frozen keys reference items no longer in the list
|
|
317
|
+
useEffect(() => {
|
|
318
|
+
if (frozenKeys.size === 0) return
|
|
319
|
+
const currentKeys = new Set(items.map((item, i) => keyExtractor(item, i)))
|
|
320
|
+
let hasStale = false
|
|
321
|
+
for (const key of frozenKeys) {
|
|
322
|
+
if (!currentKeys.has(key)) {
|
|
323
|
+
hasStale = true
|
|
324
|
+
break
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (hasStale) {
|
|
328
|
+
setFrozenKeys((prev) => {
|
|
329
|
+
const next = new Set<string | number>()
|
|
330
|
+
for (const key of prev) {
|
|
331
|
+
if (currentKeys.has(key)) next.add(key)
|
|
332
|
+
}
|
|
333
|
+
return next
|
|
334
|
+
})
|
|
335
|
+
// Clean up stale freeze cache entries
|
|
336
|
+
for (const key of freezeCache.current.keys()) {
|
|
337
|
+
if (!currentKeys.has(key)) freezeCache.current.delete(key)
|
|
338
|
+
}
|
|
339
|
+
onRecovery?.()
|
|
340
|
+
}
|
|
341
|
+
}, [items, keyExtractor, frozenKeys, onRecovery])
|
|
342
|
+
|
|
343
|
+
// Build live (non-frozen) items
|
|
344
|
+
const liveItems = useMemo(() => {
|
|
345
|
+
const result: Array<{ item: T; index: number; key: string | number }> = []
|
|
346
|
+
for (let i = frozenCount; i < items.length; i++) {
|
|
347
|
+
const key = keyExtractor(items[i]!, i)
|
|
348
|
+
result.push({ item: items[i]!, index: i, key })
|
|
349
|
+
}
|
|
350
|
+
return result
|
|
351
|
+
}, [items, frozenCount, keyExtractor])
|
|
352
|
+
|
|
353
|
+
// Render live items with memoized wrappers
|
|
354
|
+
return (
|
|
355
|
+
<silvery-box ref={outerNodeRef} flexDirection="column" flexGrow={1}>
|
|
356
|
+
{/* Content area: live (unfrozen) items, grows to push footer to bottom */}
|
|
357
|
+
<silvery-box flexDirection="column" flexGrow={1}>
|
|
358
|
+
{liveItems.map(({ item, index, key }) => (
|
|
359
|
+
<MemoItem key={key} item={item} index={index} freeze={getFreeze(key)} renderFn={render} />
|
|
360
|
+
))}
|
|
361
|
+
</silvery-box>
|
|
362
|
+
|
|
363
|
+
{/* Footer pinned at bottom — auto-sizes to content */}
|
|
364
|
+
{footer != null && (
|
|
365
|
+
<silvery-box flexDirection="column" flexShrink={0}>
|
|
366
|
+
{footer}
|
|
367
|
+
</silvery-box>
|
|
368
|
+
)}
|
|
369
|
+
</silvery-box>
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ============================================================================
|
|
374
|
+
// MemoItem — skips reconciliation when item/index/freeze/renderFn are stable
|
|
375
|
+
// ============================================================================
|
|
376
|
+
|
|
377
|
+
interface MemoItemProps<T> {
|
|
378
|
+
item: T
|
|
379
|
+
index: number
|
|
380
|
+
freeze: (snapshot?: ReactElement) => void
|
|
381
|
+
renderFn: (item: T, index: number) => ReactNode
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const MemoItem = memo(function MemoItem<T>({ item, index, freeze, renderFn }: MemoItemProps<T>) {
|
|
385
|
+
return (
|
|
386
|
+
<ScrollbackItemProvider freeze={freeze} isFrozen={false} index={index} nearScrollback={false}>
|
|
387
|
+
{renderFn(item, index)}
|
|
388
|
+
</ScrollbackItemProvider>
|
|
389
|
+
)
|
|
390
|
+
}) as <T>(props: MemoItemProps<T> & { key?: React.Key }) => ReactElement
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SelectList Component
|
|
3
|
+
*
|
|
4
|
+
* A keyboard-navigable single-select list. Supports controlled and uncontrolled modes.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* ```tsx
|
|
8
|
+
* const items = [
|
|
9
|
+
* { label: "Apple", value: "apple" },
|
|
10
|
+
* { label: "Banana", value: "banana" },
|
|
11
|
+
* { label: "Cherry", value: "cherry", disabled: true },
|
|
12
|
+
* ]
|
|
13
|
+
*
|
|
14
|
+
* <SelectList items={items} onSelect={(opt) => console.log(opt.value)} />
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
import React, { useCallback, useState } from "react"
|
|
18
|
+
import { useInput } from "@silvery/react/hooks/useInput"
|
|
19
|
+
import { Box } from "@silvery/react/components/Box"
|
|
20
|
+
import { Text } from "@silvery/react/components/Text"
|
|
21
|
+
|
|
22
|
+
// =============================================================================
|
|
23
|
+
// Types
|
|
24
|
+
// =============================================================================
|
|
25
|
+
|
|
26
|
+
export interface SelectOption {
|
|
27
|
+
label: string
|
|
28
|
+
value: string
|
|
29
|
+
disabled?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SelectListProps {
|
|
33
|
+
/** List of options */
|
|
34
|
+
items: SelectOption[]
|
|
35
|
+
/** Controlled: current highlighted index */
|
|
36
|
+
highlightedIndex?: number
|
|
37
|
+
/** Called when highlight changes (controlled mode) */
|
|
38
|
+
onHighlight?: (index: number) => void
|
|
39
|
+
/** Called when user confirms selection (Enter) */
|
|
40
|
+
onSelect?: (option: SelectOption, index: number) => void
|
|
41
|
+
/** Initial index for uncontrolled mode */
|
|
42
|
+
initialIndex?: number
|
|
43
|
+
/** Max visible items (rest scrolled) */
|
|
44
|
+
maxVisible?: number
|
|
45
|
+
/** Whether this list captures input (default: true) */
|
|
46
|
+
isActive?: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// =============================================================================
|
|
50
|
+
// Helpers
|
|
51
|
+
// =============================================================================
|
|
52
|
+
|
|
53
|
+
function findNextEnabled(items: SelectOption[], current: number, direction: 1 | -1): number {
|
|
54
|
+
const len = items.length
|
|
55
|
+
if (len === 0) return current
|
|
56
|
+
|
|
57
|
+
let next = current + direction
|
|
58
|
+
for (let i = 0; i < len; i++) {
|
|
59
|
+
if (next < 0) next = len - 1
|
|
60
|
+
if (next >= len) next = 0
|
|
61
|
+
if (!items[next]!.disabled) return next
|
|
62
|
+
next += direction
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// All items disabled; stay put
|
|
66
|
+
return current
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function findFirstEnabled(items: SelectOption[]): number {
|
|
70
|
+
for (let i = 0; i < items.length; i++) {
|
|
71
|
+
if (!items[i]!.disabled) return i
|
|
72
|
+
}
|
|
73
|
+
return 0
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function findLastEnabled(items: SelectOption[]): number {
|
|
77
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
78
|
+
if (!items[i]!.disabled) return i
|
|
79
|
+
}
|
|
80
|
+
return 0
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// =============================================================================
|
|
84
|
+
// Component
|
|
85
|
+
// =============================================================================
|
|
86
|
+
|
|
87
|
+
export function SelectList({
|
|
88
|
+
items,
|
|
89
|
+
highlightedIndex: controlledIndex,
|
|
90
|
+
onHighlight,
|
|
91
|
+
onSelect,
|
|
92
|
+
initialIndex,
|
|
93
|
+
maxVisible,
|
|
94
|
+
isActive = true,
|
|
95
|
+
}: SelectListProps): React.ReactElement {
|
|
96
|
+
const isControlled = controlledIndex !== undefined
|
|
97
|
+
|
|
98
|
+
const [uncontrolledIndex, setUncontrolledIndex] = useState(initialIndex ?? findFirstEnabled(items))
|
|
99
|
+
|
|
100
|
+
const currentIndex = isControlled ? controlledIndex : uncontrolledIndex
|
|
101
|
+
|
|
102
|
+
const setIndex = useCallback(
|
|
103
|
+
(index: number) => {
|
|
104
|
+
if (!isControlled) {
|
|
105
|
+
setUncontrolledIndex(index)
|
|
106
|
+
}
|
|
107
|
+
onHighlight?.(index)
|
|
108
|
+
},
|
|
109
|
+
[isControlled, onHighlight],
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
useInput(
|
|
113
|
+
(input, key) => {
|
|
114
|
+
if (items.length === 0) return
|
|
115
|
+
|
|
116
|
+
if (key.upArrow || input === "k") {
|
|
117
|
+
setIndex(findNextEnabled(items, currentIndex, -1))
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (key.downArrow || input === "j") {
|
|
122
|
+
setIndex(findNextEnabled(items, currentIndex, 1))
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (key.return) {
|
|
127
|
+
const item = items[currentIndex]
|
|
128
|
+
if (item && !item.disabled) {
|
|
129
|
+
onSelect?.(item, currentIndex)
|
|
130
|
+
}
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Home: Ctrl+A
|
|
135
|
+
if (key.ctrl && input === "a") {
|
|
136
|
+
setIndex(findFirstEnabled(items))
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// End: Ctrl+E
|
|
141
|
+
if (key.ctrl && input === "e") {
|
|
142
|
+
setIndex(findLastEnabled(items))
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
{ isActive },
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
// Compute visible window
|
|
150
|
+
const showAll = !maxVisible || items.length <= maxVisible
|
|
151
|
+
let startIdx = 0
|
|
152
|
+
let visibleItems = items
|
|
153
|
+
|
|
154
|
+
if (!showAll) {
|
|
155
|
+
// Center the highlighted item in the visible window
|
|
156
|
+
const half = Math.floor(maxVisible / 2)
|
|
157
|
+
startIdx = Math.max(0, Math.min(currentIndex - half, items.length - maxVisible))
|
|
158
|
+
visibleItems = items.slice(startIdx, startIdx + maxVisible)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<Box flexDirection="column">
|
|
163
|
+
{visibleItems.map((item, i) => {
|
|
164
|
+
const actualIndex = showAll ? i : startIdx + i
|
|
165
|
+
const isHighlighted = actualIndex === currentIndex
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<Text key={item.value} inverse={isHighlighted} dimColor={item.disabled}>
|
|
169
|
+
{isHighlighted ? "▸ " : " "}
|
|
170
|
+
{item.label}
|
|
171
|
+
</Text>
|
|
172
|
+
)
|
|
173
|
+
})}
|
|
174
|
+
</Box>
|
|
175
|
+
)
|
|
176
|
+
}
|