@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,264 @@
1
+ /**
2
+ * TextArea Component
3
+ *
4
+ * Multi-line text input with word wrapping, scrolling, and cursor movement.
5
+ * Uses useContentRect for width-aware word wrapping and VirtualList-style
6
+ * scroll tracking to keep the cursor visible.
7
+ *
8
+ * Built on useTextArea hook — use the hook directly for custom rendering.
9
+ *
10
+ * Includes full readline-style editing: word movement, word kill, kill ring
11
+ * (yank/cycle), and character transpose -- shared with TextInput via readline-ops.
12
+ *
13
+ * Usage:
14
+ * ```tsx
15
+ * const [value, setValue] = useState('')
16
+ * <TextArea
17
+ * value={value}
18
+ * onChange={setValue}
19
+ * onSubmit={(val) => console.log('Submitted:', val)}
20
+ * height={10}
21
+ * placeholder="Type here..."
22
+ * />
23
+ * ```
24
+ *
25
+ * Supported shortcuts:
26
+ * - Arrow keys: Move cursor (clears selection)
27
+ * - Shift+Arrow: Extend selection
28
+ * - Shift+Home/End: Select to line boundaries
29
+ * - Ctrl+Shift+Arrow: Word-wise selection
30
+ * - Ctrl+A: Select all text
31
+ * - Ctrl+E: End of line
32
+ * - Home/End: Beginning/end of line
33
+ * - Alt+B/F: Move by word (wraps across lines)
34
+ * - Ctrl+W, Alt+Backspace: Delete word backwards (kill ring)
35
+ * - Alt+D: Delete word forwards (kill ring)
36
+ * - Ctrl+K: Kill to end of line (kill ring)
37
+ * - Ctrl+U: Kill to beginning of line (kill ring)
38
+ * - Ctrl+Y: Yank (paste from kill ring)
39
+ * - Alt+Y: Cycle through kill ring (after Ctrl+Y)
40
+ * - Ctrl+T: Transpose characters
41
+ * - PageUp/PageDown: Scroll by viewport height
42
+ * - Backspace/Delete: Delete characters (or selected text)
43
+ * - Enter: Insert newline (replaces selection, or submit with submitKey)
44
+ * - Typing with selection: Replaces selected text
45
+ */
46
+ import { forwardRef, useImperativeHandle } from "react"
47
+ import { useContentRect } from "@silvery/react/hooks/useLayout"
48
+ import { useFocusable } from "@silvery/react/hooks/useFocusable"
49
+ import { useCursor } from "@silvery/react/hooks/useCursor"
50
+ import { Box } from "@silvery/react/components/Box"
51
+ import { Text } from "@silvery/react/components/Text"
52
+ import { useTextArea } from "./useTextArea"
53
+
54
+ // =============================================================================
55
+ // Types
56
+ // =============================================================================
57
+
58
+ export interface TextAreaProps {
59
+ /** Current value (controlled) */
60
+ value?: string
61
+ /** Initial value (uncontrolled) */
62
+ defaultValue?: string
63
+ /** Called when value changes */
64
+ onChange?: (value: string) => void
65
+ /** Called on submit (Ctrl+Enter by default, or Enter if submitKey="enter") */
66
+ onSubmit?: (value: string) => void
67
+ /** Key to trigger submit: "ctrl+enter" (default), "enter", or "meta+enter" */
68
+ submitKey?: "ctrl+enter" | "enter" | "meta+enter"
69
+ /** Placeholder text when empty */
70
+ placeholder?: string
71
+ /** Whether input is focused/active (overrides focus system) */
72
+ isActive?: boolean
73
+ /** Visible height in rows (required) */
74
+ height: number
75
+ /** Cursor style: 'block' (inverse) or 'underline' */
76
+ cursorStyle?: "block" | "underline"
77
+ /** Number of context lines to keep visible above/below cursor when scrolling (default: 1) */
78
+ scrollMargin?: number
79
+ /** When true, ignore all input and dim the text */
80
+ disabled?: boolean
81
+ /** Maximum number of characters allowed */
82
+ maxLength?: number
83
+ /** Border style (e.g., "round", "single") — wraps input in bordered Box */
84
+ borderStyle?: string
85
+ /** Border color when unfocused (default: "$border") */
86
+ borderColor?: string
87
+ /** Border color when focused (default: "$focusborder") */
88
+ focusBorderColor?: string
89
+ /** Test ID for focus system identification */
90
+ testID?: string
91
+ }
92
+
93
+ /** Selection range as [start, end) character offsets */
94
+ export { type TextAreaSelection } from "./useTextArea"
95
+
96
+ export interface TextAreaHandle {
97
+ /** Clear the input */
98
+ clear: () => void
99
+ /** Get current value */
100
+ getValue: () => string
101
+ /** Set value programmatically */
102
+ setValue: (value: string) => void
103
+ /** Get the current selection range, or null if no selection */
104
+ getSelection: () => import("./useTextArea").TextAreaSelection | null
105
+ }
106
+
107
+ // =============================================================================
108
+ // Component
109
+ // =============================================================================
110
+
111
+ export const TextArea = forwardRef<TextAreaHandle, TextAreaProps>(function TextArea(
112
+ {
113
+ value: controlledValue,
114
+ defaultValue = "",
115
+ onChange,
116
+ onSubmit,
117
+ submitKey = "ctrl+enter",
118
+ placeholder = "",
119
+ isActive: isActiveProp,
120
+ height,
121
+ cursorStyle = "block",
122
+ scrollMargin = 1,
123
+ disabled,
124
+ maxLength,
125
+ borderStyle: borderStyleProp,
126
+ borderColor: borderColorProp = "$border",
127
+ focusBorderColor = "$focusborder",
128
+ testID,
129
+ },
130
+ ref,
131
+ ) {
132
+ // Focus system integration: prop overrides hook.
133
+ // When testID is set, the component participates in the focus tree and
134
+ // isActive derives from focus state. Without testID, default to true
135
+ // for backward compatibility.
136
+ const { focused } = useFocusable()
137
+ const isActive = isActiveProp ?? (testID ? focused : true)
138
+
139
+ const { width } = useContentRect()
140
+
141
+ const ta = useTextArea({
142
+ value: controlledValue,
143
+ defaultValue,
144
+ onChange,
145
+ onSubmit,
146
+ submitKey,
147
+ isActive,
148
+ height,
149
+ wrapWidth: width,
150
+ scrollMargin,
151
+ disabled,
152
+ maxLength,
153
+ })
154
+
155
+ // Imperative handle
156
+ useImperativeHandle(ref, () => ({
157
+ clear: ta.clear,
158
+ getValue: () => ta.value,
159
+ setValue: ta.setValue,
160
+ getSelection: ta.getSelection,
161
+ }))
162
+
163
+ // =========================================================================
164
+ // Rendering
165
+ // =========================================================================
166
+
167
+ // Hide hardware cursor when selection is active (cursor shown as part of selection rendering)
168
+ useCursor({
169
+ col: ta.cursorCol,
170
+ row: ta.visibleCursorRow,
171
+ visible: isActive && !disabled && !ta.selection,
172
+ })
173
+
174
+ const showPlaceholder = !ta.value && placeholder
175
+
176
+ const borderProps = borderStyleProp
177
+ ? {
178
+ borderStyle: borderStyleProp as any,
179
+ borderColor: isActive ? focusBorderColor : borderColorProp,
180
+ paddingX: 1 as const,
181
+ }
182
+ : {}
183
+
184
+ if (showPlaceholder) {
185
+ return (
186
+ <Box
187
+ focusable
188
+ testID={testID}
189
+ flexDirection="column"
190
+ height={height}
191
+ justifyContent="center"
192
+ alignItems="center"
193
+ {...borderProps}
194
+ >
195
+ <Text dimColor>{placeholder}</Text>
196
+ </Box>
197
+ )
198
+ }
199
+
200
+ return (
201
+ <Box focusable testID={testID} key={ta.scrollOffset} flexDirection="column" height={height} {...borderProps}>
202
+ {ta.visibleLines.map((wl, i) => {
203
+ const absoluteRow = ta.scrollOffset + i
204
+ const isCursorRow = absoluteRow === ta.cursorRow
205
+ const lineStart = wl.startOffset
206
+ const lineEnd = lineStart + wl.line.length
207
+
208
+ // Check if this line has any selection overlap
209
+ const hasSelectionOnLine = ta.selection && lineStart < ta.selection.end && lineEnd > ta.selection.start
210
+
211
+ if (disabled) {
212
+ return (
213
+ <Text key={absoluteRow} dimColor>
214
+ {wl.line || " "}
215
+ </Text>
216
+ )
217
+ }
218
+
219
+ if (hasSelectionOnLine) {
220
+ // Compute selection overlap on this line (in line-local coordinates)
221
+ const selStart = Math.max(0, ta.selection!.start - lineStart)
222
+ const selEnd = Math.min(wl.line.length, ta.selection!.end - lineStart)
223
+
224
+ const before = wl.line.slice(0, selStart)
225
+ const selected = wl.line.slice(selStart, selEnd)
226
+ const after = wl.line.slice(selEnd)
227
+
228
+ return (
229
+ <Text key={absoluteRow}>
230
+ {before}
231
+ <Text inverse>{selected || (selEnd === wl.line.length && isCursorRow ? " " : "")}</Text>
232
+ {after}
233
+ </Text>
234
+ )
235
+ }
236
+
237
+ if (!isCursorRow) {
238
+ return <Text key={absoluteRow}>{wl.line || " "}</Text>
239
+ }
240
+
241
+ const beforeCursor = wl.line.slice(0, ta.cursorCol)
242
+ const atCursor = wl.line[ta.cursorCol] ?? " "
243
+ const afterCursor = wl.line.slice(ta.cursorCol + 1)
244
+
245
+ // Active: plain text (real cursor handles it). Inactive: fake cursor.
246
+ const cursorEl = isActive ? (
247
+ <Text>{atCursor}</Text>
248
+ ) : cursorStyle === "block" ? (
249
+ <Text inverse>{atCursor}</Text>
250
+ ) : (
251
+ <Text underline>{atCursor}</Text>
252
+ )
253
+
254
+ return (
255
+ <Text key={absoluteRow}>
256
+ {beforeCursor}
257
+ {cursorEl}
258
+ {afterCursor}
259
+ </Text>
260
+ )
261
+ })}
262
+ </Box>
263
+ )
264
+ })
@@ -0,0 +1,240 @@
1
+ /**
2
+ * TextInput Component
3
+ *
4
+ * Full readline-style single-line text input with kill ring, word movement, and
5
+ * all standard shortcuts. Built on useReadline hook.
6
+ *
7
+ * Usage:
8
+ * ```tsx
9
+ * const [value, setValue] = useState('')
10
+ * <TextInput
11
+ * value={value}
12
+ * onChange={setValue}
13
+ * onSubmit={(val) => console.log('Submitted:', val)}
14
+ * placeholder="Type here..."
15
+ * />
16
+ * ```
17
+ *
18
+ * Supported shortcuts:
19
+ * - Ctrl+A/E: Beginning/end of line
20
+ * - Ctrl+B/F, Left/Right: Move cursor
21
+ * - Alt+B/F: Move by word
22
+ * - Ctrl+W, Alt+Backspace: Delete word backwards (kill)
23
+ * - Alt+D: Delete word forwards (kill)
24
+ * - Ctrl+U/K: Delete to beginning/end (kill)
25
+ * - Ctrl+Y: Yank (paste)
26
+ * - Alt+Y: Cycle kill ring
27
+ * - Ctrl+T: Transpose characters
28
+ */
29
+ import { useCallback, useImperativeHandle, forwardRef, useState, useEffect, useRef } from "react"
30
+ import { Box } from "@silvery/react/components/Box"
31
+ import { Text } from "@silvery/react/components/Text"
32
+ import { useReadline } from "./useReadline"
33
+ import { useFocusable } from "@silvery/react/hooks/useFocusable"
34
+ import { useCursor } from "@silvery/react/hooks/useCursor"
35
+
36
+ // =============================================================================
37
+ // Types
38
+ // =============================================================================
39
+
40
+ export interface TextInputProps {
41
+ /** Current value (controlled) */
42
+ value?: string
43
+ /** Initial value (uncontrolled) */
44
+ defaultValue?: string
45
+ /** Called when value changes */
46
+ onChange?: (value: string) => void
47
+ /** Called when Enter is pressed */
48
+ onSubmit?: (value: string) => void
49
+ /** Called on Ctrl+D with empty input */
50
+ onEOF?: () => void
51
+ /** Placeholder text when empty */
52
+ placeholder?: string
53
+ /** Whether input is focused/active (overrides focus system) */
54
+ isActive?: boolean
55
+ /** Prompt prefix (e.g., "$ " or "> ") */
56
+ prompt?: string
57
+ /** Prompt color */
58
+ promptColor?: string
59
+ /** Text color */
60
+ color?: string
61
+ /** Cursor style: 'block' (inverse) or 'underline' */
62
+ cursorStyle?: "block" | "underline"
63
+ /** Show underline below input */
64
+ showUnderline?: boolean
65
+ /** Underline width (default: 40) */
66
+ underlineWidth?: number
67
+ /** Mask character for passwords */
68
+ mask?: string
69
+ /** Border style (e.g., "round", "single") — wraps input in bordered Box */
70
+ borderStyle?: string
71
+ /** Border color when unfocused (default: "$border") */
72
+ borderColor?: string
73
+ /** Border color when focused (default: "$focusborder") */
74
+ focusBorderColor?: string
75
+ /** Test ID for focus system identification */
76
+ testID?: string
77
+ }
78
+
79
+ export interface TextInputHandle {
80
+ /** Clear the input */
81
+ clear: () => void
82
+ /** Get current value */
83
+ getValue: () => string
84
+ /** Set value programmatically */
85
+ setValue: (value: string) => void
86
+ /** Get kill ring contents */
87
+ getKillRing: () => string[]
88
+ }
89
+
90
+ // =============================================================================
91
+ // Component
92
+ // =============================================================================
93
+
94
+ export const TextInput = forwardRef<TextInputHandle, TextInputProps>(function TextInput(
95
+ {
96
+ value: controlledValue,
97
+ defaultValue = "",
98
+ onChange,
99
+ onSubmit,
100
+ onEOF,
101
+ placeholder = "",
102
+ isActive: isActiveProp,
103
+ prompt = "",
104
+ promptColor = "$control",
105
+ color,
106
+ cursorStyle = "block",
107
+ showUnderline = false,
108
+ underlineWidth = 40,
109
+ mask,
110
+ borderStyle: borderStyleProp,
111
+ borderColor: borderColorProp = "$border",
112
+ focusBorderColor = "$focusborder",
113
+ testID,
114
+ },
115
+ ref,
116
+ ) {
117
+ // Focus system integration: prop overrides hook.
118
+ // When testID is set, the component participates in the focus tree and
119
+ // isActive derives from focus state. Without testID, default to true
120
+ // for backward compatibility.
121
+ const { focused } = useFocusable()
122
+ const isActive = isActiveProp ?? (testID ? focused : true)
123
+
124
+ // Track whether we're in controlled mode
125
+ const isControlled = controlledValue !== undefined
126
+
127
+ // Track value changes that originated from internal editing (keystroke → onChange).
128
+ // When the parent feeds back the same value via controlledValue, we skip
129
+ // readline.setValue() since readline already has the correct cursor position.
130
+ const internalChangeRef = useRef(false)
131
+
132
+ // Use readline hook
133
+ const readline = useReadline({
134
+ initialValue: isControlled ? (controlledValue ?? "") : defaultValue,
135
+ onChange: useCallback(
136
+ (newValue: string) => {
137
+ internalChangeRef.current = true
138
+ onChange?.(newValue)
139
+ },
140
+ [onChange],
141
+ ),
142
+ isActive,
143
+ handleEnter: !!onSubmit,
144
+ onSubmit,
145
+ onEOF,
146
+ })
147
+
148
+ // Sync controlled value to readline — only for external changes.
149
+ // Internal changes (from editing) already have correct cursor position.
150
+ const [lastControlledValue, setLastControlledValue] = useState(controlledValue)
151
+ useEffect(() => {
152
+ if (isControlled && controlledValue !== lastControlledValue) {
153
+ if (internalChangeRef.current) {
154
+ // Change originated from our own editing — readline already has correct state
155
+ internalChangeRef.current = false
156
+ } else {
157
+ // External change — sync readline (cursor goes to end)
158
+ readline.setValue(controlledValue ?? "")
159
+ }
160
+ setLastControlledValue(controlledValue)
161
+ }
162
+ }, [isControlled, controlledValue, lastControlledValue, readline])
163
+
164
+ // Handle Enter separately for onSubmit
165
+ const { value, cursor, clear, setValue, killRing } = readline
166
+
167
+ // Imperative handle for parent control
168
+ useImperativeHandle(ref, () => ({
169
+ clear,
170
+ getValue: () => value,
171
+ setValue,
172
+ getKillRing: () => killRing,
173
+ }))
174
+
175
+ // Compute display value (with optional mask)
176
+ const displayValue = mask ? mask.repeat(value.length) : value
177
+ const displayBeforeCursor = displayValue.slice(0, cursor)
178
+ const displayAtCursor = displayValue[cursor] ?? " "
179
+ const displayAfterCursor = displayValue.slice(cursor + 1)
180
+ const showPlaceholder = !value && placeholder
181
+
182
+ // Always show visual cursor (inverse/underline). When active, the hardware
183
+ // cursor is also positioned via useCursor() for terminal blink support.
184
+ const cursorEl =
185
+ cursorStyle === "underline" ? <Text underline>{displayAtCursor}</Text> : <Text inverse>{displayAtCursor}</Text>
186
+ useCursor({
187
+ col: prompt.length + displayBeforeCursor.length,
188
+ row: 0,
189
+ visible: isActive,
190
+ })
191
+
192
+ const inputContent = (
193
+ <Text color={color}>
194
+ {prompt && <Text color={promptColor}>{prompt}</Text>}
195
+ {showPlaceholder ? (
196
+ <>
197
+ {cursorStyle === "underline" ? (
198
+ <Text underline dimColor>
199
+ {placeholder[0]}
200
+ </Text>
201
+ ) : (
202
+ <Text inverse dimColor>
203
+ {placeholder[0]}
204
+ </Text>
205
+ )}
206
+ <Text dimColor>{placeholder.slice(1)}</Text>
207
+ </>
208
+ ) : (
209
+ <>
210
+ <Text>{displayBeforeCursor}</Text>
211
+ {cursorEl}
212
+ <Text>{displayAfterCursor}</Text>
213
+ </>
214
+ )}
215
+ </Text>
216
+ )
217
+
218
+ if (borderStyleProp) {
219
+ return (
220
+ <Box
221
+ focusable
222
+ testID={testID}
223
+ flexDirection="column"
224
+ borderStyle={borderStyleProp as any}
225
+ borderColor={isActive ? focusBorderColor : borderColorProp}
226
+ paddingX={1}
227
+ >
228
+ {inputContent}
229
+ {showUnderline && <Text dimColor>{"─".repeat(underlineWidth)}</Text>}
230
+ </Box>
231
+ )
232
+ }
233
+
234
+ return (
235
+ <Box focusable testID={testID} flexDirection="column">
236
+ {inputContent}
237
+ {showUnderline && <Text dimColor>{"─".repeat(underlineWidth)}</Text>}
238
+ </Box>
239
+ )
240
+ })