@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.
Files changed (87) hide show
  1. package/package.json +71 -0
  2. package/src/animation/easing.ts +38 -0
  3. package/src/animation/index.ts +18 -0
  4. package/src/animation/useAnimation.ts +143 -0
  5. package/src/animation/useInterval.ts +39 -0
  6. package/src/animation/useLatest.ts +35 -0
  7. package/src/animation/useTimeout.ts +65 -0
  8. package/src/animation/useTransition.ts +110 -0
  9. package/src/animation.ts +24 -0
  10. package/src/ansi/index.ts +43 -0
  11. package/src/canvas/index.ts +169 -0
  12. package/src/cli/ansi.ts +85 -0
  13. package/src/cli/index.ts +39 -0
  14. package/src/cli/multi-progress.ts +340 -0
  15. package/src/cli/progress-bar.ts +222 -0
  16. package/src/cli/spinner.ts +275 -0
  17. package/src/components/Badge.tsx +54 -0
  18. package/src/components/Breadcrumb.tsx +72 -0
  19. package/src/components/Button.tsx +73 -0
  20. package/src/components/CommandPalette.tsx +186 -0
  21. package/src/components/Console.tsx +79 -0
  22. package/src/components/CursorLine.tsx +71 -0
  23. package/src/components/Divider.tsx +67 -0
  24. package/src/components/EditContextDisplay.tsx +164 -0
  25. package/src/components/ErrorBoundary.tsx +179 -0
  26. package/src/components/Form.tsx +86 -0
  27. package/src/components/GridCell.tsx +42 -0
  28. package/src/components/HorizontalVirtualList.tsx +375 -0
  29. package/src/components/ModalDialog.tsx +179 -0
  30. package/src/components/PickerDialog.tsx +208 -0
  31. package/src/components/PickerList.tsx +93 -0
  32. package/src/components/ProgressBar.tsx +126 -0
  33. package/src/components/Screen.tsx +78 -0
  34. package/src/components/ScrollbackList.tsx +92 -0
  35. package/src/components/ScrollbackView.tsx +390 -0
  36. package/src/components/SelectList.tsx +176 -0
  37. package/src/components/Skeleton.tsx +87 -0
  38. package/src/components/Spinner.tsx +64 -0
  39. package/src/components/SplitView.tsx +199 -0
  40. package/src/components/Table.tsx +139 -0
  41. package/src/components/Tabs.tsx +203 -0
  42. package/src/components/TextArea.tsx +264 -0
  43. package/src/components/TextInput.tsx +240 -0
  44. package/src/components/Toast.tsx +216 -0
  45. package/src/components/Toggle.tsx +73 -0
  46. package/src/components/Tooltip.tsx +60 -0
  47. package/src/components/TreeView.tsx +212 -0
  48. package/src/components/Typography.tsx +233 -0
  49. package/src/components/VirtualList.tsx +318 -0
  50. package/src/components/VirtualView.tsx +221 -0
  51. package/src/components/useReadline.ts +213 -0
  52. package/src/components/useTextArea.ts +648 -0
  53. package/src/components.ts +133 -0
  54. package/src/display/Table.tsx +179 -0
  55. package/src/display/index.ts +13 -0
  56. package/src/hooks/useTea.ts +133 -0
  57. package/src/image/Image.tsx +187 -0
  58. package/src/image/index.ts +15 -0
  59. package/src/image/kitty-graphics.ts +161 -0
  60. package/src/image/sixel-encoder.ts +194 -0
  61. package/src/images.ts +22 -0
  62. package/src/index.ts +34 -0
  63. package/src/input/Select.tsx +155 -0
  64. package/src/input/TextInput.tsx +227 -0
  65. package/src/input/index.ts +25 -0
  66. package/src/progress/als-context.ts +160 -0
  67. package/src/progress/declarative.ts +519 -0
  68. package/src/progress/index.ts +54 -0
  69. package/src/progress/step-node.ts +152 -0
  70. package/src/progress/steps.ts +425 -0
  71. package/src/progress/task.ts +138 -0
  72. package/src/progress/tasks.ts +216 -0
  73. package/src/react/ProgressBar.tsx +146 -0
  74. package/src/react/Spinner.tsx +74 -0
  75. package/src/react/Tasks.tsx +144 -0
  76. package/src/react/context.tsx +145 -0
  77. package/src/react/index.ts +30 -0
  78. package/src/types.ts +252 -0
  79. package/src/utils/eta.ts +155 -0
  80. package/src/utils/index.ts +13 -0
  81. package/src/wrappers/index.ts +36 -0
  82. package/src/wrappers/with-progress.ts +250 -0
  83. package/src/wrappers/with-select.ts +194 -0
  84. package/src/wrappers/with-spinner.ts +108 -0
  85. package/src/wrappers/with-text-input.ts +388 -0
  86. package/src/wrappers/wrap-emitter.ts +158 -0
  87. package/src/wrappers/wrap-generator.ts +143 -0
@@ -0,0 +1,221 @@
1
+ /**
2
+ * VirtualView - App-managed scrolling within a Screen rectangle.
3
+ *
4
+ * A scrollable area where items mount/unmount based on scroll position,
5
+ * managed entirely by the app. Uses the shared useVirtualizer() engine.
6
+ *
7
+ * Unlike ScrollbackView (which uses native terminal scrollback), VirtualView
8
+ * keeps everything in the React tree. Items are simply unmounted when they
9
+ * scroll out of the viewport and remounted when they scroll back in.
10
+ *
11
+ * Trade-offs vs ScrollbackView:
12
+ * - Mouse events work on scrolled-off items (if you scroll back)
13
+ * - App controls scroll position (no snap-to-bottom issue)
14
+ * - Text selection requires Shift+drag (mouse tracking active)
15
+ * - Memory lives in the React tree, not the terminal buffer
16
+ *
17
+ * @example
18
+ * ```tsx
19
+ * <Screen>
20
+ * <Header />
21
+ * <VirtualView
22
+ * items={logs}
23
+ * height={20}
24
+ * renderItem={(item, index) => <LogEntry key={item.id} data={item} />}
25
+ * estimateHeight={() => 3}
26
+ * />
27
+ * <StatusBar />
28
+ * </Screen>
29
+ * ```
30
+ */
31
+
32
+ import React, { forwardRef, useImperativeHandle } from "react"
33
+ import { useVirtualizer } from "@silvery/react/hooks/useVirtualizer"
34
+ import { Box } from "@silvery/react/components/Box"
35
+
36
+ // =============================================================================
37
+ // Types
38
+ // =============================================================================
39
+
40
+ export interface VirtualViewProps<T> {
41
+ /** Array of items to render */
42
+ items: T[]
43
+
44
+ /** Height of the viewport in rows */
45
+ height: number
46
+
47
+ /** Estimated height of each item in rows (fixed or per-index function). Default: 1 */
48
+ estimateHeight?: number | ((index: number) => number)
49
+
50
+ /** Render function for each item */
51
+ renderItem: (item: T, index: number) => React.ReactNode
52
+
53
+ /** Index to scroll to (declarative). When undefined, scroll state freezes. */
54
+ scrollTo?: number
55
+
56
+ /** Extra items to render beyond viewport for smooth scrolling. Default: 5 */
57
+ overscan?: number
58
+
59
+ /** Maximum items to render at once. Default: 100 */
60
+ maxRendered?: number
61
+
62
+ /** Padding from edge before scrolling (in items). Default: 2 */
63
+ scrollPadding?: number
64
+
65
+ /** Show overflow indicators (▲N/▼N). Default: false */
66
+ overflowIndicator?: boolean
67
+
68
+ /** Optional key extractor (defaults to index) */
69
+ keyExtractor?: (item: T, index: number) => string | number
70
+
71
+ /** Width of the viewport (optional, uses parent width if not specified) */
72
+ width?: number
73
+
74
+ /** Gap between items in rows. Default: 0 */
75
+ gap?: number
76
+
77
+ /** Render separator between items (alternative to gap) */
78
+ renderSeparator?: () => React.ReactNode
79
+
80
+ /** Mouse wheel handler for scrolling */
81
+ onWheel?: (event: { deltaY: number }) => void
82
+
83
+ /** Called when the visible range reaches near the end of the list (infinite scroll). */
84
+ onEndReached?: () => void
85
+ /** How many items from the end to trigger onEndReached. Default: 5 */
86
+ onEndReachedThreshold?: number
87
+
88
+ /** Content rendered after all items inside the scroll container (e.g., hidden count indicator) */
89
+ listFooter?: React.ReactNode
90
+ }
91
+
92
+ export interface VirtualViewHandle {
93
+ /** Imperatively scroll to a specific item index */
94
+ scrollToItem(index: number): void
95
+ }
96
+
97
+ // =============================================================================
98
+ // Constants
99
+ // =============================================================================
100
+
101
+ const DEFAULT_ESTIMATE_HEIGHT = 1
102
+ const DEFAULT_OVERSCAN = 5
103
+ const DEFAULT_MAX_RENDERED = 100
104
+ const DEFAULT_SCROLL_PADDING = 2
105
+
106
+ // =============================================================================
107
+ // Component
108
+ // =============================================================================
109
+
110
+ /**
111
+ * App-managed scrollable view with virtualization.
112
+ *
113
+ * Items mount/unmount based on scroll position within a fixed-height viewport.
114
+ * Scroll state management:
115
+ * - When scrollTo is defined: actively track and scroll to that index
116
+ * - When scrollTo is undefined: freeze scroll state (critical for multi-column layouts)
117
+ */
118
+ function VirtualViewInner<T>(
119
+ {
120
+ items,
121
+ height,
122
+ estimateHeight = DEFAULT_ESTIMATE_HEIGHT,
123
+ renderItem,
124
+ scrollTo,
125
+ overscan = DEFAULT_OVERSCAN,
126
+ maxRendered = DEFAULT_MAX_RENDERED,
127
+ scrollPadding = DEFAULT_SCROLL_PADDING,
128
+ overflowIndicator,
129
+ keyExtractor,
130
+ width,
131
+ gap = 0,
132
+ renderSeparator,
133
+ onWheel,
134
+ onEndReached,
135
+ onEndReachedThreshold,
136
+ listFooter,
137
+ }: VirtualViewProps<T>,
138
+ ref: React.ForwardedRef<VirtualViewHandle>,
139
+ ): React.ReactElement {
140
+ // Convert item-based estimateHeight to index-based for useVirtualizer
141
+ const indexEstimate = typeof estimateHeight === "function" ? estimateHeight : estimateHeight
142
+
143
+ const { range, leadingHeight, trailingHeight, scrollOffset, scrollToItem } = useVirtualizer({
144
+ count: items.length,
145
+ estimateHeight: indexEstimate,
146
+ viewportHeight: height,
147
+ scrollTo,
148
+ scrollPadding,
149
+ overscan,
150
+ maxRendered,
151
+ gap,
152
+ getItemKey: keyExtractor ? (index) => keyExtractor(items[index]!, index) : undefined,
153
+ onEndReached,
154
+ onEndReachedThreshold,
155
+ })
156
+
157
+ // Expose scrollToItem method via ref
158
+ useImperativeHandle(ref, () => ({ scrollToItem }), [scrollToItem])
159
+
160
+ // Empty state
161
+ if (items.length === 0) {
162
+ return (
163
+ <Box flexDirection="column" height={height} width={width}>
164
+ {/* Empty - nothing to render */}
165
+ </Box>
166
+ )
167
+ }
168
+
169
+ // Get the slice of items to render
170
+ const { startIndex, endIndex } = range
171
+ const visibleItems = items.slice(startIndex, endIndex)
172
+
173
+ // Calculate scrollTo index for silvery Box overflow="scroll"
174
+ const hasTopPlaceholder = leadingHeight > 0
175
+ const currentSelectedIndex = scrollTo !== undefined ? Math.max(0, Math.min(scrollTo, items.length - 1)) : scrollOffset
176
+ const selectedIndexInSlice = currentSelectedIndex - startIndex
177
+ const isSelectedInSlice = selectedIndexInSlice >= 0 && selectedIndexInSlice < visibleItems.length
178
+ const scrollToIndex = hasTopPlaceholder ? selectedIndexInSlice + 1 : selectedIndexInSlice
179
+ const boxScrollTo = isSelectedInSlice ? Math.max(0, scrollToIndex) : undefined
180
+
181
+ return (
182
+ <Box
183
+ flexDirection="column"
184
+ height={height}
185
+ width={width}
186
+ overflow="scroll"
187
+ scrollTo={boxScrollTo}
188
+ overflowIndicator={overflowIndicator}
189
+ onWheel={onWheel}
190
+ >
191
+ {/* Leading placeholder for virtual height */}
192
+ {leadingHeight > 0 && <Box height={leadingHeight} flexShrink={0} />}
193
+
194
+ {/* Render visible items */}
195
+ {visibleItems.map((item, i) => {
196
+ const originalIndex = startIndex + i
197
+ const key = keyExtractor ? keyExtractor(item, originalIndex) : originalIndex
198
+ const isLast = i === visibleItems.length - 1
199
+
200
+ return (
201
+ <React.Fragment key={key}>
202
+ {renderItem(item, originalIndex)}
203
+ {!isLast && renderSeparator && renderSeparator()}
204
+ {!isLast && gap > 0 && !renderSeparator && <Box height={gap} flexShrink={0} />}
205
+ </React.Fragment>
206
+ )
207
+ })}
208
+
209
+ {/* Footer content (e.g., filter hidden count) */}
210
+ {listFooter}
211
+
212
+ {/* Trailing placeholder for virtual height */}
213
+ {trailingHeight > 0 && <Box height={trailingHeight} flexShrink={0} />}
214
+ </Box>
215
+ )
216
+ }
217
+
218
+ // Export with forwardRef - use type assertion for generic component
219
+ export const VirtualView = forwardRef(VirtualViewInner) as <T>(
220
+ props: VirtualViewProps<T> & { ref?: React.ForwardedRef<VirtualViewHandle> },
221
+ ) => React.ReactElement
@@ -0,0 +1,213 @@
1
+ /**
2
+ * useReadline Hook
3
+ *
4
+ * This hook lives in components/ because it's tightly coupled to TextInput.
5
+ * It manages readline state (cursor position, history, kill ring) that TextInput renders.
6
+ *
7
+ * Full readline-style line editing for terminal text input.
8
+ * Supports cursor movement, word operations, kill ring, and all standard shortcuts.
9
+ *
10
+ * Shortcuts:
11
+ * - Ctrl+A: Move to beginning of line
12
+ * - Ctrl+E: Move to end of line
13
+ * - Ctrl+B / Left: Move cursor left
14
+ * - Ctrl+F / Right: Move cursor right
15
+ * - Alt+B: Move cursor back one word
16
+ * - Alt+F: Move cursor forward one word
17
+ * - Ctrl+W / Alt+Backspace: Delete word backwards (adds to kill ring)
18
+ * - Alt+D: Delete word forwards (adds to kill ring)
19
+ * - Ctrl+U: Delete to beginning (adds to kill ring)
20
+ * - Ctrl+K: Delete to end (adds to kill ring)
21
+ * - Ctrl+Y: Yank (paste from kill ring)
22
+ * - Alt+Y: Cycle through kill ring (after Ctrl+Y)
23
+ * - Ctrl+T: Transpose characters
24
+ * - Ctrl+H / Backspace: Delete char before cursor
25
+ * - Ctrl+D / Delete: Delete char at cursor (or exit if empty)
26
+ *
27
+ * Note: Alt key detection requires terminal support. Some terminals send
28
+ * ESC followed by the key instead of a proper alt modifier.
29
+ */
30
+ import { useCallback, useRef, useState } from "react"
31
+ import { useInput } from "@silvery/react/hooks"
32
+ import { killRing, addToKillRing, handleReadlineKey, type YankState } from "@silvery/react/hooks/readline-ops"
33
+
34
+ // =============================================================================
35
+ // Types
36
+ // =============================================================================
37
+
38
+ export interface ReadlineState {
39
+ /** Current text value */
40
+ value: string
41
+ /** Cursor position (0 = before first char, value.length = after last char) */
42
+ cursor: number
43
+ }
44
+
45
+ export interface UseReadlineOptions {
46
+ /** Initial value */
47
+ initialValue?: string
48
+ /** Called when value changes */
49
+ onChange?: (value: string) => void
50
+ /** Whether input is active */
51
+ isActive?: boolean
52
+ /** Handle Enter key (default: false - let parent handle) */
53
+ handleEnter?: boolean
54
+ /** Called when Enter is pressed (requires handleEnter: true) */
55
+ onSubmit?: (value: string) => void
56
+ /** Handle Escape key (default: false - let parent handle) */
57
+ handleEscape?: boolean
58
+ /** Handle Up/Down arrows (default: false - let parent handle for history) */
59
+ handleVerticalArrows?: boolean
60
+ /** Called on Ctrl+D with empty input (default: undefined) */
61
+ onEOF?: () => void
62
+ }
63
+
64
+ export interface UseReadlineResult {
65
+ /** Current text value */
66
+ value: string
67
+ /** Cursor position */
68
+ cursor: number
69
+ /** Text before cursor (for rendering) */
70
+ beforeCursor: string
71
+ /** Text after cursor (for rendering) */
72
+ afterCursor: string
73
+ /** Clear the input */
74
+ clear: () => void
75
+ /** Set value programmatically (cursor moves to end) */
76
+ setValue: (value: string) => void
77
+ /** Set both value and cursor position */
78
+ setValueWithCursor: (value: string, cursor: number) => void
79
+ /** Kill ring contents (for debugging/display) */
80
+ killRing: string[]
81
+ }
82
+
83
+ // =============================================================================
84
+ // Hook
85
+ // =============================================================================
86
+
87
+ export function useReadline({
88
+ initialValue = "",
89
+ onChange,
90
+ isActive = true,
91
+ handleEnter = false,
92
+ handleEscape = false,
93
+ handleVerticalArrows = false,
94
+ onEOF,
95
+ onSubmit,
96
+ }: UseReadlineOptions = {}): UseReadlineResult {
97
+ const [state, setState] = useState<ReadlineState>({
98
+ value: initialValue,
99
+ cursor: initialValue.length,
100
+ })
101
+
102
+ // Mutable ref for synchronous reads in the event handler.
103
+ // Without this, rapid keypresses between React renders all read the same
104
+ // stale closure state and overwrite each other.
105
+ const stateRef = useRef<ReadlineState>({ value: initialValue, cursor: initialValue.length })
106
+ stateRef.current = state
107
+
108
+ const yankStateRef = useRef<YankState | null>(null)
109
+
110
+ /** Apply a ReadlineKeyResult to state */
111
+ const applyResult = useCallback(
112
+ (result: { value: string; cursor: number; yankState: YankState | null }, prevValue: string) => {
113
+ yankStateRef.current = result.yankState
114
+ if (result.value === prevValue && result.cursor === stateRef.current.cursor) return
115
+ const next = { value: result.value, cursor: result.cursor }
116
+ stateRef.current = next
117
+ setState(next)
118
+ if (result.value !== prevValue) onChange?.(result.value)
119
+ },
120
+ [onChange],
121
+ )
122
+
123
+ const clear = useCallback(() => {
124
+ const next = { value: "", cursor: 0 }
125
+ stateRef.current = next
126
+ setState(next)
127
+ onChange?.("")
128
+ yankStateRef.current = null
129
+ }, [onChange])
130
+
131
+ const setValue = useCallback(
132
+ (value: string) => {
133
+ const next = { value, cursor: value.length }
134
+ stateRef.current = next
135
+ setState(next)
136
+ onChange?.(value)
137
+ yankStateRef.current = null
138
+ },
139
+ [onChange],
140
+ )
141
+
142
+ const setValueWithCursor = useCallback(
143
+ (value: string, cursor: number) => {
144
+ const next = { value, cursor: Math.max(0, Math.min(cursor, value.length)) }
145
+ stateRef.current = next
146
+ setState(next)
147
+ onChange?.(value)
148
+ yankStateRef.current = null
149
+ },
150
+ [onChange],
151
+ )
152
+
153
+ useInput(
154
+ (input, key) => {
155
+ const { value, cursor } = stateRef.current
156
+
157
+ // Let parent handle Enter/Escape/vertical arrows unless explicitly enabled
158
+ if (key.return && !handleEnter) return
159
+ if (key.return && handleEnter) {
160
+ onSubmit?.(value)
161
+ return
162
+ }
163
+ if (key.escape && !handleEscape) return
164
+ if ((key.upArrow || key.downArrow) && !handleVerticalArrows) return
165
+
166
+ // Single-line specific: Ctrl+D on empty input = EOF
167
+ if (key.ctrl && input === "d" && value.length === 0) {
168
+ onEOF?.()
169
+ return
170
+ }
171
+
172
+ // Single-line specific: Ctrl+A/E move to beginning/end of entire text
173
+ if (key.ctrl && input === "a") {
174
+ applyResult({ value, cursor: 0, yankState: null }, value)
175
+ return
176
+ }
177
+ if (key.ctrl && input === "e") {
178
+ applyResult({ value, cursor: value.length, yankState: null }, value)
179
+ return
180
+ }
181
+
182
+ // Single-line specific: Ctrl+U/K kill to beginning/end of entire text
183
+ if (key.ctrl && input === "u") {
184
+ if (cursor === 0) return
185
+ addToKillRing(value.slice(0, cursor))
186
+ applyResult({ value: value.slice(cursor), cursor: 0, yankState: null }, value)
187
+ return
188
+ }
189
+ if (key.ctrl && input === "k") {
190
+ if (cursor >= value.length) return
191
+ addToKillRing(value.slice(cursor))
192
+ applyResult({ value: value.slice(0, cursor), cursor, yankState: null }, value)
193
+ return
194
+ }
195
+
196
+ // Shared readline operations (cursor movement, word ops, kill ring, yank, etc.)
197
+ const result = handleReadlineKey(input, key, value, cursor, yankStateRef.current)
198
+ if (result) applyResult(result, value)
199
+ },
200
+ { isActive },
201
+ )
202
+
203
+ return {
204
+ value: state.value,
205
+ cursor: state.cursor,
206
+ beforeCursor: state.value.slice(0, state.cursor),
207
+ afterCursor: state.value.slice(state.cursor),
208
+ clear,
209
+ setValue,
210
+ setValueWithCursor,
211
+ killRing: [...killRing],
212
+ }
213
+ }