@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,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ModalDialog Component
|
|
3
|
+
*
|
|
4
|
+
* Reusable modal dialog with consistent styling: double border, title bar,
|
|
5
|
+
* optional footer, and solid background that covers board content.
|
|
6
|
+
*
|
|
7
|
+
* Moved from km-tui shared-components to silvery for reuse across apps.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* ```tsx
|
|
11
|
+
* <ModalDialog title="Settings" width={60} footer="ESC to close">
|
|
12
|
+
* <Text>Dialog content here</Text>
|
|
13
|
+
* </ModalDialog>
|
|
14
|
+
*
|
|
15
|
+
* <ModalDialog title="Help" hotkey="?" titleRight={<Text>1/3</Text>}>
|
|
16
|
+
* <Text>Help content</Text>
|
|
17
|
+
* </ModalDialog>
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
import React from "react"
|
|
21
|
+
import { Box } from "@silvery/react/components/Box"
|
|
22
|
+
import { Text } from "@silvery/react/components/Text"
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// Types
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
export interface ModalDialogProps {
|
|
29
|
+
/** Border color (default: $border). Cyan is reserved for text input focus rings. */
|
|
30
|
+
borderColor?: string
|
|
31
|
+
/** Dialog title (rendered bold in titleColor or borderColor) */
|
|
32
|
+
title?: string
|
|
33
|
+
/** Title color override (default: $primary). Separate from border for independent styling. */
|
|
34
|
+
titleColor?: string
|
|
35
|
+
/** Title alignment (default: center) */
|
|
36
|
+
titleAlign?: "center" | "flex-start" | "flex-end"
|
|
37
|
+
/** Toggle hotkey character (e.g., "?" for help). Renders [X] prefix in title. */
|
|
38
|
+
hotkey?: string
|
|
39
|
+
/** Content to render on the right side of the title bar (e.g., hotkey indicator, match count) */
|
|
40
|
+
titleRight?: React.ReactNode
|
|
41
|
+
/** Dialog width */
|
|
42
|
+
width?: number
|
|
43
|
+
/** Dialog height (optional, omit for auto-height) */
|
|
44
|
+
height?: number
|
|
45
|
+
/** Footer hint text (rendered dimColor at bottom) */
|
|
46
|
+
footer?: React.ReactNode
|
|
47
|
+
/** Footer alignment (default: center) */
|
|
48
|
+
footerAlign?: "center" | "flex-start" | "flex-end"
|
|
49
|
+
/** Called when ESC is pressed (optional convenience handler) */
|
|
50
|
+
onClose?: () => void
|
|
51
|
+
/** Whether to create a focus scope (default: true, for future focus system integration) */
|
|
52
|
+
focusScope?: boolean
|
|
53
|
+
/** Dialog children */
|
|
54
|
+
children: React.ReactNode
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// =============================================================================
|
|
58
|
+
// Helpers
|
|
59
|
+
// =============================================================================
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Format a dialog title with a hotkey prefix.
|
|
63
|
+
*
|
|
64
|
+
* If the hotkey letter appears in the title (case-insensitive), highlights it inline:
|
|
65
|
+
* hotkey="D", title="Details" -> [D]etails
|
|
66
|
+
* If the hotkey is not found in the title, prepends it:
|
|
67
|
+
* hotkey="?", title="Help" -> [?] Help
|
|
68
|
+
*
|
|
69
|
+
* Brackets are dim, the hotkey letter is bold/bright.
|
|
70
|
+
*/
|
|
71
|
+
export function formatTitleWithHotkey(title: string, hotkey: string, color?: string): React.ReactElement {
|
|
72
|
+
const idx = title.toLowerCase().indexOf(hotkey.toLowerCase())
|
|
73
|
+
if (idx >= 0 && hotkey.length === 1 && hotkey.toLowerCase() !== hotkey.toUpperCase()) {
|
|
74
|
+
// Letter found in title — highlight it inline: prefix + [X] + rest
|
|
75
|
+
const before = title.slice(0, idx)
|
|
76
|
+
const matched = title[idx]
|
|
77
|
+
const after = title.slice(idx + 1)
|
|
78
|
+
return (
|
|
79
|
+
<Text color={color} bold>
|
|
80
|
+
{before}
|
|
81
|
+
<Text dimColor bold={false}>
|
|
82
|
+
[
|
|
83
|
+
</Text>
|
|
84
|
+
<Text bold>{matched}</Text>
|
|
85
|
+
<Text dimColor bold={false}>
|
|
86
|
+
]
|
|
87
|
+
</Text>
|
|
88
|
+
{after}
|
|
89
|
+
</Text>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
// Hotkey not in title (or symbol) — prepend [X] Title
|
|
93
|
+
return (
|
|
94
|
+
<Text color={color} bold>
|
|
95
|
+
<Text dimColor bold={false}>
|
|
96
|
+
[
|
|
97
|
+
</Text>
|
|
98
|
+
<Text bold>{hotkey}</Text>
|
|
99
|
+
<Text dimColor bold={false}>
|
|
100
|
+
]
|
|
101
|
+
</Text>{" "}
|
|
102
|
+
{title}
|
|
103
|
+
</Text>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// =============================================================================
|
|
108
|
+
// Component
|
|
109
|
+
// =============================================================================
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Reusable modal dialog with consistent styling.
|
|
113
|
+
*
|
|
114
|
+
* Features:
|
|
115
|
+
* - Solid raised background (covers board content)
|
|
116
|
+
* - Double border (configurable color). Cyan reserved for focus rings.
|
|
117
|
+
* - Horizontal padding (2), vertical padding (1)
|
|
118
|
+
* - Title: bold, colored, with spacer below
|
|
119
|
+
* - Footer: centered, dimColor, with spacer above
|
|
120
|
+
*/
|
|
121
|
+
export function ModalDialog({
|
|
122
|
+
borderColor = "$border",
|
|
123
|
+
title,
|
|
124
|
+
titleColor,
|
|
125
|
+
titleAlign = "center",
|
|
126
|
+
hotkey,
|
|
127
|
+
titleRight,
|
|
128
|
+
width,
|
|
129
|
+
height,
|
|
130
|
+
footer,
|
|
131
|
+
footerAlign = "center",
|
|
132
|
+
onClose: _onClose,
|
|
133
|
+
focusScope: _focusScope = true,
|
|
134
|
+
children,
|
|
135
|
+
}: ModalDialogProps): React.ReactElement {
|
|
136
|
+
const effectiveTitleColor = titleColor ?? "$primary"
|
|
137
|
+
// When titleRight is provided, use space-between layout for the title bar
|
|
138
|
+
const effectiveTitleAlign = titleRight ? "space-between" : titleAlign
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<Box
|
|
142
|
+
flexDirection="column"
|
|
143
|
+
width={width}
|
|
144
|
+
height={height}
|
|
145
|
+
borderStyle="double"
|
|
146
|
+
borderColor={borderColor}
|
|
147
|
+
backgroundColor={"$surface-bg"}
|
|
148
|
+
paddingX={2}
|
|
149
|
+
paddingY={1}
|
|
150
|
+
>
|
|
151
|
+
{title && (
|
|
152
|
+
<Box flexShrink={0} flexDirection="column">
|
|
153
|
+
<Box justifyContent={effectiveTitleAlign}>
|
|
154
|
+
{hotkey ? (
|
|
155
|
+
formatTitleWithHotkey(title, hotkey, effectiveTitleColor)
|
|
156
|
+
) : (
|
|
157
|
+
<Text color={effectiveTitleColor} bold>
|
|
158
|
+
{title}
|
|
159
|
+
</Text>
|
|
160
|
+
)}
|
|
161
|
+
{titleRight}
|
|
162
|
+
</Box>
|
|
163
|
+
<Text> </Text>
|
|
164
|
+
</Box>
|
|
165
|
+
)}
|
|
166
|
+
{/* Content area - flexGrow pushes footer to bottom, overflow hidden prevents title displacement */}
|
|
167
|
+
<Box flexDirection="column" flexGrow={1} overflow="hidden">
|
|
168
|
+
{children}
|
|
169
|
+
</Box>
|
|
170
|
+
{/* Footer with spacer line above */}
|
|
171
|
+
{footer && (
|
|
172
|
+
<>
|
|
173
|
+
<Text> </Text>
|
|
174
|
+
<Box justifyContent={footerAlign}>{typeof footer === "string" ? <Text dimColor>{footer}</Text> : footer}</Box>
|
|
175
|
+
</>
|
|
176
|
+
)}
|
|
177
|
+
</Box>
|
|
178
|
+
)
|
|
179
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PickerDialog Component
|
|
3
|
+
*
|
|
4
|
+
* Generic search-and-select dialog combining ModalDialog + text input + scrolling
|
|
5
|
+
* result list. Handles keyboard routing: arrows for selection, Enter to confirm,
|
|
6
|
+
* Esc to cancel, printable chars for filtering.
|
|
7
|
+
*
|
|
8
|
+
* Uses useReadline internally for full readline editing (kill ring, word movement).
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* ```tsx
|
|
12
|
+
* <PickerDialog
|
|
13
|
+
* title="Search"
|
|
14
|
+
* items={filteredResults}
|
|
15
|
+
* renderItem={(item, selected) => (
|
|
16
|
+
* <Text inverse={selected}>{item.name}</Text>
|
|
17
|
+
* )}
|
|
18
|
+
* keyExtractor={(item) => item.id}
|
|
19
|
+
* onSelect={(item) => navigateTo(item)}
|
|
20
|
+
* onCancel={() => closeDialog()}
|
|
21
|
+
* onChange={(query) => setFilter(query)}
|
|
22
|
+
* placeholder="Type to search..."
|
|
23
|
+
* />
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
import React, { useCallback, useRef, useState } from "react"
|
|
27
|
+
import { useInput } from "@silvery/react/hooks/useInput"
|
|
28
|
+
import { Box } from "@silvery/react/components/Box"
|
|
29
|
+
import { Text } from "@silvery/react/components/Text"
|
|
30
|
+
import { CursorLine } from "./CursorLine"
|
|
31
|
+
import { ModalDialog } from "./ModalDialog"
|
|
32
|
+
import { PickerList } from "./PickerList"
|
|
33
|
+
import { useReadline } from "./useReadline"
|
|
34
|
+
|
|
35
|
+
// =============================================================================
|
|
36
|
+
// Types
|
|
37
|
+
// =============================================================================
|
|
38
|
+
|
|
39
|
+
export interface PickerDialogProps<T> {
|
|
40
|
+
/** Dialog title */
|
|
41
|
+
title: string
|
|
42
|
+
/** Placeholder text when input is empty */
|
|
43
|
+
placeholder?: string
|
|
44
|
+
/** Items to display in the result list */
|
|
45
|
+
items: T[]
|
|
46
|
+
/** Render function for each item. `selected` is true for the highlighted item. */
|
|
47
|
+
renderItem: (item: T, selected: boolean) => React.ReactNode
|
|
48
|
+
/** Unique key for each item */
|
|
49
|
+
keyExtractor: (item: T) => string
|
|
50
|
+
/** Called when an item is confirmed (Enter) */
|
|
51
|
+
onSelect: (item: T) => void
|
|
52
|
+
/** Called when the dialog is cancelled (Esc) */
|
|
53
|
+
onCancel: () => void
|
|
54
|
+
/** Called when the input text changes (for filtering) */
|
|
55
|
+
onChange?: (query: string) => void
|
|
56
|
+
/** Initial input value */
|
|
57
|
+
initialValue?: string
|
|
58
|
+
/** Message when items list is empty */
|
|
59
|
+
emptyMessage?: string
|
|
60
|
+
/** Maximum visible items before scrolling (default: 10) */
|
|
61
|
+
maxVisible?: number
|
|
62
|
+
/** Dialog width */
|
|
63
|
+
width?: number
|
|
64
|
+
/** Dialog height (auto-sized if omitted) */
|
|
65
|
+
height?: number
|
|
66
|
+
/** Footer content */
|
|
67
|
+
footer?: React.ReactNode
|
|
68
|
+
/** Input prompt prefix (e.g., "/ " or "All > ") */
|
|
69
|
+
prompt?: string
|
|
70
|
+
/** Prompt color */
|
|
71
|
+
promptColor?: string
|
|
72
|
+
/** Whether the input is active (default: true) */
|
|
73
|
+
isActive?: boolean
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// =============================================================================
|
|
77
|
+
// Component
|
|
78
|
+
// =============================================================================
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Generic search-and-select dialog.
|
|
82
|
+
*
|
|
83
|
+
* Keyboard routing:
|
|
84
|
+
* - Printable chars, Ctrl shortcuts: readline text editing
|
|
85
|
+
* - Up/Down arrows: navigate result list
|
|
86
|
+
* - PgUp/PgDn: scroll by page
|
|
87
|
+
* - Enter: confirm selected item
|
|
88
|
+
* - Esc: cancel dialog
|
|
89
|
+
*/
|
|
90
|
+
export function PickerDialog<T>({
|
|
91
|
+
title,
|
|
92
|
+
placeholder,
|
|
93
|
+
items,
|
|
94
|
+
renderItem,
|
|
95
|
+
keyExtractor,
|
|
96
|
+
onSelect,
|
|
97
|
+
onCancel,
|
|
98
|
+
onChange,
|
|
99
|
+
initialValue = "",
|
|
100
|
+
emptyMessage = "No items",
|
|
101
|
+
maxVisible = 10,
|
|
102
|
+
width,
|
|
103
|
+
height,
|
|
104
|
+
footer,
|
|
105
|
+
prompt,
|
|
106
|
+
promptColor,
|
|
107
|
+
isActive = true,
|
|
108
|
+
}: PickerDialogProps<T>): React.ReactElement {
|
|
109
|
+
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
110
|
+
|
|
111
|
+
// Refs for stable callbacks in useInput closures
|
|
112
|
+
const onSelectRef = useRef(onSelect)
|
|
113
|
+
onSelectRef.current = onSelect
|
|
114
|
+
const onCancelRef = useRef(onCancel)
|
|
115
|
+
onCancelRef.current = onCancel
|
|
116
|
+
const itemsRef = useRef(items)
|
|
117
|
+
itemsRef.current = items
|
|
118
|
+
const selectedIndexRef = useRef(selectedIndex)
|
|
119
|
+
selectedIndexRef.current = selectedIndex
|
|
120
|
+
|
|
121
|
+
// Readline hook for text editing (kill ring, word movement, etc.)
|
|
122
|
+
const readline = useReadline({
|
|
123
|
+
initialValue,
|
|
124
|
+
onChange: useCallback(
|
|
125
|
+
(value: string) => {
|
|
126
|
+
onChange?.(value)
|
|
127
|
+
setSelectedIndex(0)
|
|
128
|
+
},
|
|
129
|
+
[onChange],
|
|
130
|
+
),
|
|
131
|
+
isActive,
|
|
132
|
+
handleEnter: false, // We handle Enter for item selection
|
|
133
|
+
handleEscape: false, // We handle Esc for cancel
|
|
134
|
+
handleVerticalArrows: false, // We handle Up/Down for list navigation
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const clampedIndex = items.length > 0 ? Math.min(selectedIndex, items.length - 1) : 0
|
|
138
|
+
if (clampedIndex !== selectedIndex) {
|
|
139
|
+
setSelectedIndex(clampedIndex)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Effective max visible for page navigation step size
|
|
143
|
+
const effectiveMaxVisible = Math.min(maxVisible, items.length)
|
|
144
|
+
|
|
145
|
+
// Navigation handler (separate from readline text editing)
|
|
146
|
+
useInput(
|
|
147
|
+
(_input, key) => {
|
|
148
|
+
if (key.escape) {
|
|
149
|
+
onCancelRef.current()
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
if (key.return) {
|
|
153
|
+
const currentItems = itemsRef.current
|
|
154
|
+
const idx = selectedIndexRef.current
|
|
155
|
+
const item = currentItems[Math.min(idx, currentItems.length - 1)]
|
|
156
|
+
if (item) onSelectRef.current(item)
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
if (key.upArrow) {
|
|
160
|
+
setSelectedIndex((i) => Math.max(0, i - 1))
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
if (key.downArrow) {
|
|
164
|
+
setSelectedIndex((i) => Math.min(i + 1, Math.max(0, itemsRef.current.length - 1)))
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
if (key.pageUp) {
|
|
168
|
+
setSelectedIndex((i) => Math.max(0, i - effectiveMaxVisible))
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
if (key.pageDown) {
|
|
172
|
+
setSelectedIndex((i) => Math.min(i + effectiveMaxVisible, Math.max(0, itemsRef.current.length - 1)))
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
{ isActive },
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
// Show placeholder when input is empty
|
|
180
|
+
const showPlaceholder = !readline.value && placeholder
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<ModalDialog title={title} width={width} height={height} footer={footer}>
|
|
184
|
+
{/* Search input */}
|
|
185
|
+
<Box flexShrink={0} flexDirection="column">
|
|
186
|
+
<Box>
|
|
187
|
+
{prompt && <Text color={promptColor}>{prompt}</Text>}
|
|
188
|
+
{showPlaceholder ? (
|
|
189
|
+
<Text dimColor>{placeholder}</Text>
|
|
190
|
+
) : (
|
|
191
|
+
<CursorLine beforeCursor={readline.beforeCursor} afterCursor={readline.afterCursor} showCursor={isActive} />
|
|
192
|
+
)}
|
|
193
|
+
</Box>
|
|
194
|
+
<Text dimColor>{"─".repeat(40)}</Text>
|
|
195
|
+
</Box>
|
|
196
|
+
|
|
197
|
+
{/* Result list (delegated to PickerList) */}
|
|
198
|
+
<PickerList
|
|
199
|
+
items={items}
|
|
200
|
+
selectedIndex={clampedIndex}
|
|
201
|
+
renderItem={renderItem}
|
|
202
|
+
keyExtractor={keyExtractor}
|
|
203
|
+
emptyMessage={emptyMessage}
|
|
204
|
+
maxVisible={maxVisible}
|
|
205
|
+
/>
|
|
206
|
+
</ModalDialog>
|
|
207
|
+
)
|
|
208
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PickerList Component
|
|
3
|
+
*
|
|
4
|
+
* Standalone scrolling result list with selection highlighting. Extracted from
|
|
5
|
+
* PickerDialog so it can be composed independently by callers that manage their
|
|
6
|
+
* own input (e.g., km-tui command-system dialogs).
|
|
7
|
+
*
|
|
8
|
+
* Handles:
|
|
9
|
+
* - Scroll offset calculation (centers selected item in view)
|
|
10
|
+
* - Visible items slicing
|
|
11
|
+
* - Empty state rendering
|
|
12
|
+
* - Item rendering via renderItem callback
|
|
13
|
+
*
|
|
14
|
+
* Does NOT handle:
|
|
15
|
+
* - Keyboard navigation (caller manages selectedIndex)
|
|
16
|
+
* - Input/search (caller's responsibility)
|
|
17
|
+
*
|
|
18
|
+
* Usage:
|
|
19
|
+
* ```tsx
|
|
20
|
+
* <PickerList
|
|
21
|
+
* items={filteredResults}
|
|
22
|
+
* selectedIndex={selected}
|
|
23
|
+
* renderItem={(item, sel) => <Text inverse={sel}>{item.name}</Text>}
|
|
24
|
+
* keyExtractor={(item) => item.id}
|
|
25
|
+
* />
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
import React from "react"
|
|
29
|
+
import { Box } from "@silvery/react/components/Box"
|
|
30
|
+
import { Text } from "@silvery/react/components/Text"
|
|
31
|
+
|
|
32
|
+
// =============================================================================
|
|
33
|
+
// Types
|
|
34
|
+
// =============================================================================
|
|
35
|
+
|
|
36
|
+
export interface PickerListProps<T> {
|
|
37
|
+
/** Items to display */
|
|
38
|
+
items: T[]
|
|
39
|
+
/** Currently selected index (caller-managed) */
|
|
40
|
+
selectedIndex: number
|
|
41
|
+
/** Render function for each item. `selected` is true for the highlighted item. */
|
|
42
|
+
renderItem: (item: T, selected: boolean) => React.ReactNode
|
|
43
|
+
/** Unique key for each item */
|
|
44
|
+
keyExtractor: (item: T) => string
|
|
45
|
+
/** Message when items list is empty (default: "No items") */
|
|
46
|
+
emptyMessage?: string
|
|
47
|
+
/** Maximum visible items before scrolling (default: 10) */
|
|
48
|
+
maxVisible?: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// =============================================================================
|
|
52
|
+
// Component
|
|
53
|
+
// =============================================================================
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Scrolling result list with selection highlighting.
|
|
57
|
+
*
|
|
58
|
+
* Centers the selected item in the visible window. When there are fewer items
|
|
59
|
+
* than maxVisible, all items are shown without scrolling.
|
|
60
|
+
*/
|
|
61
|
+
export function PickerList<T>({
|
|
62
|
+
items,
|
|
63
|
+
selectedIndex,
|
|
64
|
+
renderItem,
|
|
65
|
+
keyExtractor,
|
|
66
|
+
emptyMessage = "No items",
|
|
67
|
+
maxVisible = 10,
|
|
68
|
+
}: PickerListProps<T>): React.ReactElement {
|
|
69
|
+
const clampedIndex = items.length > 0 ? Math.min(selectedIndex, items.length - 1) : 0
|
|
70
|
+
const effectiveMaxVisible = Math.min(maxVisible, items.length)
|
|
71
|
+
|
|
72
|
+
// Scroll offset: center the selected item in the visible window
|
|
73
|
+
const scrollOffset =
|
|
74
|
+
items.length > effectiveMaxVisible
|
|
75
|
+
? Math.max(0, Math.min(clampedIndex - Math.floor(effectiveMaxVisible / 2), items.length - effectiveMaxVisible))
|
|
76
|
+
: 0
|
|
77
|
+
|
|
78
|
+
const visibleItems = items.slice(scrollOffset, scrollOffset + effectiveMaxVisible)
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<Box flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
|
|
82
|
+
{items.length === 0 ? (
|
|
83
|
+
<Text dimColor>{emptyMessage}</Text>
|
|
84
|
+
) : (
|
|
85
|
+
visibleItems.map((item, i) => {
|
|
86
|
+
const actualIndex = scrollOffset + i
|
|
87
|
+
const isSelected = actualIndex === clampedIndex
|
|
88
|
+
return <React.Fragment key={keyExtractor(item)}>{renderItem(item, isSelected)}</React.Fragment>
|
|
89
|
+
})
|
|
90
|
+
)}
|
|
91
|
+
</Box>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProgressBar Component
|
|
3
|
+
*
|
|
4
|
+
* A terminal progress bar with determinate and indeterminate modes.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* ```tsx
|
|
8
|
+
* <ProgressBar value={0.5} />
|
|
9
|
+
* <ProgressBar value={0.75} color="green" label="Downloading..." />
|
|
10
|
+
* <ProgressBar /> // indeterminate (animated)
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
import React, { useEffect, useState } from "react"
|
|
14
|
+
import { useContentRect } from "@silvery/react/hooks/useLayout"
|
|
15
|
+
import { Box } from "@silvery/react/components/Box"
|
|
16
|
+
import { Text } from "@silvery/react/components/Text"
|
|
17
|
+
|
|
18
|
+
// =============================================================================
|
|
19
|
+
// Types
|
|
20
|
+
// =============================================================================
|
|
21
|
+
|
|
22
|
+
export interface ProgressBarProps {
|
|
23
|
+
/** Progress value 0-1 (omit for indeterminate) */
|
|
24
|
+
value?: number
|
|
25
|
+
/** Width in columns (default: uses available width via useContentRect) */
|
|
26
|
+
width?: number
|
|
27
|
+
/** Fill character (default: "█") */
|
|
28
|
+
fillChar?: string
|
|
29
|
+
/** Empty character (default: "░") */
|
|
30
|
+
emptyChar?: string
|
|
31
|
+
/** Show percentage label (default: true for determinate) */
|
|
32
|
+
showPercentage?: boolean
|
|
33
|
+
/** Label text */
|
|
34
|
+
label?: string
|
|
35
|
+
/** Color of the filled portion */
|
|
36
|
+
color?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// =============================================================================
|
|
40
|
+
// Constants
|
|
41
|
+
// =============================================================================
|
|
42
|
+
|
|
43
|
+
const DEFAULT_FILL = "█"
|
|
44
|
+
const DEFAULT_EMPTY = "░"
|
|
45
|
+
const DEFAULT_WIDTH = 30
|
|
46
|
+
const INDETERMINATE_BLOCK_SIZE = 4
|
|
47
|
+
const INDETERMINATE_INTERVAL = 100
|
|
48
|
+
|
|
49
|
+
// =============================================================================
|
|
50
|
+
// Component
|
|
51
|
+
// =============================================================================
|
|
52
|
+
|
|
53
|
+
export function ProgressBar({
|
|
54
|
+
value,
|
|
55
|
+
width: widthProp,
|
|
56
|
+
fillChar = DEFAULT_FILL,
|
|
57
|
+
emptyChar = DEFAULT_EMPTY,
|
|
58
|
+
showPercentage,
|
|
59
|
+
label,
|
|
60
|
+
color,
|
|
61
|
+
}: ProgressBarProps): React.ReactElement {
|
|
62
|
+
const { width: contentWidth } = useContentRect()
|
|
63
|
+
const [bouncePos, setBouncePos] = useState(0)
|
|
64
|
+
const [bounceDir, setBounceDir] = useState(1)
|
|
65
|
+
|
|
66
|
+
const isDeterminate = value !== undefined
|
|
67
|
+
const showPct = showPercentage ?? isDeterminate
|
|
68
|
+
|
|
69
|
+
// Calculate available bar width
|
|
70
|
+
const labelWidth = label ? label.length + 1 : 0
|
|
71
|
+
const pctWidth = showPct ? 5 : 0 // " 100%"
|
|
72
|
+
const availableWidth = widthProp ?? (contentWidth > 0 ? contentWidth : DEFAULT_WIDTH)
|
|
73
|
+
const barWidth = Math.max(1, availableWidth - labelWidth - pctWidth)
|
|
74
|
+
|
|
75
|
+
// Indeterminate animation
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (isDeterminate) return
|
|
78
|
+
|
|
79
|
+
const timer = setInterval(() => {
|
|
80
|
+
setBouncePos((prev) => {
|
|
81
|
+
const maxPos = barWidth - INDETERMINATE_BLOCK_SIZE
|
|
82
|
+
if (maxPos <= 0) return 0
|
|
83
|
+
|
|
84
|
+
const next = prev + bounceDir
|
|
85
|
+
if (next >= maxPos) {
|
|
86
|
+
setBounceDir(-1)
|
|
87
|
+
return maxPos
|
|
88
|
+
}
|
|
89
|
+
if (next <= 0) {
|
|
90
|
+
setBounceDir(1)
|
|
91
|
+
return 0
|
|
92
|
+
}
|
|
93
|
+
return next
|
|
94
|
+
})
|
|
95
|
+
}, INDETERMINATE_INTERVAL)
|
|
96
|
+
|
|
97
|
+
return () => clearInterval(timer)
|
|
98
|
+
}, [isDeterminate, barWidth, bounceDir])
|
|
99
|
+
|
|
100
|
+
let filledPart: string
|
|
101
|
+
let emptyPart: string
|
|
102
|
+
|
|
103
|
+
if (isDeterminate) {
|
|
104
|
+
const clamped = Math.max(0, Math.min(1, value))
|
|
105
|
+
const filled = Math.round(clamped * barWidth)
|
|
106
|
+
filledPart = fillChar.repeat(filled)
|
|
107
|
+
emptyPart = emptyChar.repeat(barWidth - filled)
|
|
108
|
+
} else {
|
|
109
|
+
// Indeterminate: sliding block
|
|
110
|
+
const blockSize = Math.min(INDETERMINATE_BLOCK_SIZE, barWidth)
|
|
111
|
+
const pos = Math.max(0, Math.min(bouncePos, barWidth - blockSize))
|
|
112
|
+
filledPart = emptyChar.repeat(pos) + fillChar.repeat(blockSize)
|
|
113
|
+
emptyPart = emptyChar.repeat(barWidth - pos - blockSize)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const pct = isDeterminate ? Math.round(Math.max(0, Math.min(1, value)) * 100) : 0
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<Box>
|
|
120
|
+
{label && <Text>{label} </Text>}
|
|
121
|
+
<Text color={color}>{filledPart}</Text>
|
|
122
|
+
<Text dimColor>{emptyPart}</Text>
|
|
123
|
+
{showPct && <Text> {pct}%</Text>}
|
|
124
|
+
</Box>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screen - Fullscreen root component.
|
|
3
|
+
*
|
|
4
|
+
* Claims the full terminal dimensions for flexbox layout. This is the
|
|
5
|
+
* declarative equivalent of the implicit fullscreen mode from run()/createApp().
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* <Screen>
|
|
10
|
+
* <Sidebar />
|
|
11
|
+
* <MainContent />
|
|
12
|
+
* <StatusBar />
|
|
13
|
+
* </Screen>
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```tsx
|
|
18
|
+
* // Fullscreen + scrollable region (log viewer, dashboard)
|
|
19
|
+
* <Screen>
|
|
20
|
+
* <Sidebar />
|
|
21
|
+
* <VirtualView items={logs} renderItem={...} />
|
|
22
|
+
* <StatusBar />
|
|
23
|
+
* </Screen>
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { useState, useEffect, type ReactNode, type ReactElement } from "react"
|
|
28
|
+
import { Box } from "@silvery/react/components/Box"
|
|
29
|
+
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// Types
|
|
32
|
+
// =============================================================================
|
|
33
|
+
|
|
34
|
+
export interface ScreenProps {
|
|
35
|
+
/** Children to render in the fullscreen area */
|
|
36
|
+
children: ReactNode
|
|
37
|
+
/** Flex direction for layout. Default: "column" (screens are typically vertical) */
|
|
38
|
+
flexDirection?: "row" | "column" | "row-reverse" | "column-reverse"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// =============================================================================
|
|
42
|
+
// Helpers
|
|
43
|
+
// =============================================================================
|
|
44
|
+
|
|
45
|
+
function getTermDims(): { width: number; height: number } {
|
|
46
|
+
return {
|
|
47
|
+
width: process.stdout.columns ?? 80,
|
|
48
|
+
height: process.stdout.rows ?? 24,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// =============================================================================
|
|
53
|
+
// Component
|
|
54
|
+
// =============================================================================
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Fullscreen root component.
|
|
58
|
+
*
|
|
59
|
+
* Provides a Box that fills the entire terminal. Tracks terminal resize
|
|
60
|
+
* events to stay in sync with the actual terminal dimensions.
|
|
61
|
+
*/
|
|
62
|
+
export function Screen({ children, flexDirection = "column" }: ScreenProps): ReactElement {
|
|
63
|
+
const [dims, setDims] = useState(getTermDims)
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
const onResize = () => setDims(getTermDims())
|
|
67
|
+
process.stdout.on("resize", onResize)
|
|
68
|
+
return () => {
|
|
69
|
+
process.stdout.off("resize", onResize)
|
|
70
|
+
}
|
|
71
|
+
}, [])
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<Box width={dims.width} height={dims.height} flexDirection={flexDirection}>
|
|
75
|
+
{children}
|
|
76
|
+
</Box>
|
|
77
|
+
)
|
|
78
|
+
}
|