@visulima/tui-kit 0.0.0 → 1.0.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/CHANGELOG.md +6 -0
- package/LICENSE.md +2095 -0
- package/README.md +115 -29
- package/dist/accordion.d.ts +59 -0
- package/dist/accordion.js +1 -0
- package/dist/alert.d.ts +26 -0
- package/dist/alert.js +1 -0
- package/dist/animate-presence.d.ts +18 -0
- package/dist/animate-presence.js +1 -0
- package/dist/approval-prompt.d.ts +65 -0
- package/dist/approval-prompt.js +1 -0
- package/dist/area-chart.d.ts +66 -0
- package/dist/area-chart.js +1 -0
- package/dist/badge.d.ts +22 -0
- package/dist/badge.js +1 -0
- package/dist/bar-chart.d.ts +67 -0
- package/dist/bar-chart.js +1 -0
- package/dist/big-text.d.ts +81 -0
- package/dist/big-text.js +1 -0
- package/dist/blink-dot.d.ts +36 -0
- package/dist/blink-dot.js +1 -0
- package/dist/breadcrumb.d.ts +33 -0
- package/dist/breadcrumb.js +1 -0
- package/dist/button.d.ts +45 -0
- package/dist/button.js +1 -0
- package/dist/calendar.d.ts +72 -0
- package/dist/calendar.js +1 -0
- package/dist/card.d.ts +57 -0
- package/dist/card.js +1 -0
- package/dist/checkbox-group.d.ts +59 -0
- package/dist/checkbox-group.js +1 -0
- package/dist/checkbox.d.ts +48 -0
- package/dist/checkbox.js +1 -0
- package/dist/code.d.ts +48 -0
- package/dist/code.js +2 -0
- package/dist/collapsible.d.ts +53 -0
- package/dist/collapsible.js +1 -0
- package/dist/color-picker.d.ts +71 -0
- package/dist/color-picker.js +1 -0
- package/dist/command-block.d.ts +47 -0
- package/dist/command-block.js +4 -0
- package/dist/command-palette.d.ts +82 -0
- package/dist/command-palette.js +1 -0
- package/dist/confirm-dialog.d.ts +75 -0
- package/dist/confirm-dialog.js +1 -0
- package/dist/confirm-input.d.ts +38 -0
- package/dist/confirm-input.js +1 -0
- package/dist/console-overlay.d.ts +54 -0
- package/dist/console-overlay.js +1 -0
- package/dist/content-switcher.d.ts +57 -0
- package/dist/content-switcher.js +1 -0
- package/dist/controlled-scroll-view.d.ts +3 -0
- package/dist/controlled-scroll-view.js +1 -0
- package/dist/data-grid.d.ts +49 -0
- package/dist/data-grid.js +1 -0
- package/dist/date-picker.d.ts +65 -0
- package/dist/date-picker.js +1 -0
- package/dist/definition-list.d.ts +44 -0
- package/dist/definition-list.js +1 -0
- package/dist/dialog.d.ts +110 -0
- package/dist/dialog.js +1 -0
- package/dist/diff-view.d.ts +85 -0
- package/dist/diff-view.js +1 -0
- package/dist/digits.d.ts +30 -0
- package/dist/digits.js +1 -0
- package/dist/directory-tree.d.ts +51 -0
- package/dist/directory-tree.js +1 -0
- package/dist/divider.d.ts +49 -0
- package/dist/divider.js +1 -0
- package/dist/drawer.d.ts +53 -0
- package/dist/drawer.js +1 -0
- package/dist/file-change.d.ts +40 -0
- package/dist/file-change.js +1 -0
- package/dist/file-picker-entry.d.ts +2 -0
- package/dist/file-picker-entry.js +1 -0
- package/dist/form.d.ts +81 -0
- package/dist/form.js +1 -0
- package/dist/gauge.d.ts +87 -0
- package/dist/gauge.js +1 -0
- package/dist/gradient.d.ts +42 -0
- package/dist/gradient.js +1 -0
- package/dist/grid.d.ts +40 -0
- package/dist/grid.js +1 -0
- package/dist/heading.d.ts +33 -0
- package/dist/heading.js +1 -0
- package/dist/heatmap.d.ts +62 -0
- package/dist/heatmap.js +1 -0
- package/dist/help.d.ts +52 -0
- package/dist/help.js +1 -0
- package/dist/histogram.d.ts +62 -0
- package/dist/histogram.js +1 -0
- package/dist/image.d.ts +26 -0
- package/dist/image.js +1 -0
- package/dist/index.d.ts +125 -0
- package/dist/index.js +1 -0
- package/dist/json.d.ts +58 -0
- package/dist/json.js +1 -0
- package/dist/kbd.d.ts +31 -0
- package/dist/kbd.js +1 -0
- package/dist/line-chart.d.ts +109 -0
- package/dist/line-chart.js +1 -0
- package/dist/link.d.ts +38 -0
- package/dist/link.js +1 -0
- package/dist/loading-indicator.d.ts +29 -0
- package/dist/loading-indicator.js +1 -0
- package/dist/log.d.ts +38 -0
- package/dist/log.js +1 -0
- package/dist/markdown.d.ts +40 -0
- package/dist/markdown.js +1 -0
- package/dist/masked-input.d.ts +50 -0
- package/dist/masked-input.js +1 -0
- package/dist/menu.d.ts +78 -0
- package/dist/menu.js +1 -0
- package/dist/message-bubble.d.ts +43 -0
- package/dist/message-bubble.js +1 -0
- package/dist/model-badge.d.ts +39 -0
- package/dist/model-badge.js +1 -0
- package/dist/model-selector.d.ts +52 -0
- package/dist/model-selector.js +1 -0
- package/dist/multi-progress.d.ts +55 -0
- package/dist/multi-progress.js +1 -0
- package/dist/multi-select.d.ts +65 -0
- package/dist/multi-select.js +1 -0
- package/dist/notification-badge.d.ts +47 -0
- package/dist/notification-badge.js +1 -0
- package/dist/number-input.d.ts +85 -0
- package/dist/number-input.js +1 -0
- package/dist/operation-tree.d.ts +49 -0
- package/dist/operation-tree.js +1 -0
- package/dist/option-list.d.ts +47 -0
- package/dist/option-list.js +1 -0
- package/dist/ordered-list.d.ts +24 -0
- package/dist/ordered-list.js +1 -0
- package/dist/packem_shared/ControlledScrollView-BzlbIUph.js +1 -0
- package/dist/packem_shared/FilePicker-V5H2fb-v.js +1 -0
- package/dist/packem_shared/ScrollBar-C9i3z3Ft.js +1 -0
- package/dist/packem_shared/ScrollBarBox-DZEC8YnV.js +1 -0
- package/dist/packem_shared/ScrollList-Djtb2vVy.js +1 -0
- package/dist/packem_shared/ScrollView-ESxpl6Hy.js +1 -0
- package/dist/packem_shared/TreeNodeMap-cJlYqckB.js +1 -0
- package/dist/packem_shared/TreeView-q0l446NV.js +1 -0
- package/dist/packem_shared/controlled-scroll-view-DerVKKv6.js +1 -0
- package/dist/packem_shared/controlled-scroll-view.d-vF4wevEJ.d.ts +54 -0
- package/dist/packem_shared/file-picker.d-Cie6aAMD.d.ts +104 -0
- package/dist/packem_shared/line-chart-BbhMhcZg.js +1 -0
- package/dist/packem_shared/scroll-bar-box.d-DSBbBsRc.d.ts +30 -0
- package/dist/packem_shared/scroll-bar.d-B4sD1hBR.d.ts +18 -0
- package/dist/packem_shared/scroll-list.d-BP0UZSY4.d.ts +12 -0
- package/dist/packem_shared/scroll-view.d-BqKpI-F-.d.ts +119 -0
- package/dist/packem_shared/treeViewTheme-Dq3ekRAC.js +1 -0
- package/dist/packem_shared/use-tree-view.d-dXTKoDPM.d.ts +350 -0
- package/dist/packem_shared/useTreeView-D0JEqT6W.js +1 -0
- package/dist/packem_shared/useTreeViewState-BiTJakkh.js +1 -0
- package/dist/packem_shared/variant-config-BJqpNo7H.js +1 -0
- package/dist/packem_shared/variant-config.d-D5NnrrQK.d.ts +2 -0
- package/dist/paginator.d.ts +69 -0
- package/dist/paginator.js +1 -0
- package/dist/paragraph.d.ts +38 -0
- package/dist/paragraph.js +1 -0
- package/dist/path-input.d.ts +67 -0
- package/dist/path-input.js +1 -0
- package/dist/pie-chart.d.ts +50 -0
- package/dist/pie-chart.js +1 -0
- package/dist/placeholder.d.ts +55 -0
- package/dist/placeholder.js +1 -0
- package/dist/popover.d.ts +46 -0
- package/dist/popover.js +1 -0
- package/dist/progress-bar.d.ts +51 -0
- package/dist/progress-bar.js +1 -0
- package/dist/progress-circle.d.ts +59 -0
- package/dist/progress-circle.js +1 -0
- package/dist/qr-code.d.ts +45 -0
- package/dist/qr-code.js +1 -0
- package/dist/r/accordion.json +24 -0
- package/dist/r/alert.json +24 -0
- package/dist/r/animate-presence.json +22 -0
- package/dist/r/approval-prompt.json +24 -0
- package/dist/r/area-chart.json +27 -0
- package/dist/r/badge.json +24 -0
- package/dist/r/bar-chart.json +24 -0
- package/dist/r/big-text.json +23 -0
- package/dist/r/blink-dot.json +24 -0
- package/dist/r/breadcrumb.json +24 -0
- package/dist/r/button.json +24 -0
- package/dist/r/calendar.json +24 -0
- package/dist/r/card.json +26 -0
- package/dist/r/chart-utils.json +24 -0
- package/dist/r/checkbox-group.json +24 -0
- package/dist/r/checkbox.json +24 -0
- package/dist/r/code.json +23 -0
- package/dist/r/collapsible.json +24 -0
- package/dist/r/color-picker.json +24 -0
- package/dist/r/command-block.json +26 -0
- package/dist/r/command-palette.json +24 -0
- package/dist/r/confirm-dialog.json +24 -0
- package/dist/r/confirm-input.json +22 -0
- package/dist/r/console-overlay.json +22 -0
- package/dist/r/content-switcher.json +24 -0
- package/dist/r/controlled-scroll-view.json +24 -0
- package/dist/r/data-grid.json +24 -0
- package/dist/r/date-picker.json +26 -0
- package/dist/r/definition-list.json +24 -0
- package/dist/r/dialog.json +24 -0
- package/dist/r/diff-view.json +24 -0
- package/dist/r/digits.json +24 -0
- package/dist/r/directory-tree.json +24 -0
- package/dist/r/divider.json +24 -0
- package/dist/r/drawer.json +24 -0
- package/dist/r/file-change.json +24 -0
- package/dist/r/file-picker.json +44 -0
- package/dist/r/form.json +24 -0
- package/dist/r/gauge.json +24 -0
- package/dist/r/gradient.json +24 -0
- package/dist/r/grid.json +22 -0
- package/dist/r/heading.json +24 -0
- package/dist/r/heatmap.json +24 -0
- package/dist/r/help.json +22 -0
- package/dist/r/histogram.json +26 -0
- package/dist/r/image.json +24 -0
- package/dist/r/json.json +24 -0
- package/dist/r/kbd.json +24 -0
- package/dist/r/line-chart.json +26 -0
- package/dist/r/link.json +23 -0
- package/dist/r/loading-indicator.json +27 -0
- package/dist/r/log.json +24 -0
- package/dist/r/markdown.json +29 -0
- package/dist/r/masked-input.json +22 -0
- package/dist/r/menu.json +24 -0
- package/dist/r/message-bubble.json +24 -0
- package/dist/r/model-badge.json +24 -0
- package/dist/r/model-selector.json +24 -0
- package/dist/r/multi-progress.json +24 -0
- package/dist/r/multi-select.json +24 -0
- package/dist/r/notification-badge.json +24 -0
- package/dist/r/number-input.json +24 -0
- package/dist/r/operation-tree.json +26 -0
- package/dist/r/option-list.json +24 -0
- package/dist/r/ordered-list.json +22 -0
- package/dist/r/paginator.json +22 -0
- package/dist/r/paragraph.json +24 -0
- package/dist/r/path-input.json +26 -0
- package/dist/r/pie-chart.json +24 -0
- package/dist/r/placeholder.json +24 -0
- package/dist/r/popover.json +24 -0
- package/dist/r/progress-bar.json +22 -0
- package/dist/r/progress-circle.json +24 -0
- package/dist/r/qr-code.json +24 -0
- package/dist/r/radio-group.json +24 -0
- package/dist/r/registry.json +1051 -0
- package/dist/r/scatter-plot.json +27 -0
- package/dist/r/scroll-bar-box.json +24 -0
- package/dist/r/scroll-bar.json +24 -0
- package/dist/r/scroll-list.json +24 -0
- package/dist/r/scroll-view.json +24 -0
- package/dist/r/scroll.json +58 -0
- package/dist/r/search-input.json +26 -0
- package/dist/r/select-input-indicator.json +24 -0
- package/dist/r/select-input-item.json +24 -0
- package/dist/r/select-input.json +28 -0
- package/dist/r/shimmer-text.json +24 -0
- package/dist/r/sidebar.json +24 -0
- package/dist/r/slider.json +24 -0
- package/dist/r/sparkline.json +24 -0
- package/dist/r/spinner.json +23 -0
- package/dist/r/status-line.json +24 -0
- package/dist/r/status-message.json +24 -0
- package/dist/r/stepper.json +24 -0
- package/dist/r/stopwatch.json +22 -0
- package/dist/r/streaming-text.json +24 -0
- package/dist/r/switch.json +24 -0
- package/dist/r/tab.json +22 -0
- package/dist/r/table.json +23 -0
- package/dist/r/tabs.json +26 -0
- package/dist/r/tag-input.json +24 -0
- package/dist/r/tag.json +24 -0
- package/dist/r/text-input.json +22 -0
- package/dist/r/textarea.json +22 -0
- package/dist/r/thinking-block.json +24 -0
- package/dist/r/time-picker.json +24 -0
- package/dist/r/timer.json +22 -0
- package/dist/r/toast.json +24 -0
- package/dist/r/token-usage.json +24 -0
- package/dist/r/tool-call.json +24 -0
- package/dist/r/tooltip.json +24 -0
- package/dist/r/transition.json +22 -0
- package/dist/r/tree-select.json +24 -0
- package/dist/r/tree-view.json +64 -0
- package/dist/r/unordered-list.json +22 -0
- package/dist/r/use-linked-scroll.json +24 -0
- package/dist/r/variant-config.json +22 -0
- package/dist/radio-group.d.ts +74 -0
- package/dist/radio-group.js +1 -0
- package/dist/scatter-plot.d.ts +35 -0
- package/dist/scatter-plot.js +1 -0
- package/dist/scroll-bar-box.d.ts +3 -0
- package/dist/scroll-bar-box.js +1 -0
- package/dist/scroll-bar.d.ts +3 -0
- package/dist/scroll-bar.js +1 -0
- package/dist/scroll-list.d.ts +4 -0
- package/dist/scroll-list.js +1 -0
- package/dist/scroll-view.d.ts +3 -0
- package/dist/scroll-view.js +1 -0
- package/dist/search-input.d.ts +55 -0
- package/dist/search-input.js +1 -0
- package/dist/select-input-indicator.d.ts +32 -0
- package/dist/select-input-indicator.js +1 -0
- package/dist/select-input-item.d.ts +41 -0
- package/dist/select-input-item.js +1 -0
- package/dist/select-input.d.ts +110 -0
- package/dist/select-input.js +1 -0
- package/dist/shimmer-text.d.ts +47 -0
- package/dist/shimmer-text.js +1 -0
- package/dist/sidebar.d.ts +47 -0
- package/dist/sidebar.js +1 -0
- package/dist/slider.d.ts +103 -0
- package/dist/slider.js +1 -0
- package/dist/sparkline.d.ts +42 -0
- package/dist/sparkline.js +1 -0
- package/dist/spinner.d.ts +17 -0
- package/dist/spinner.js +1 -0
- package/dist/status-line.d.ts +37 -0
- package/dist/status-line.js +1 -0
- package/dist/status-message.d.ts +21 -0
- package/dist/status-message.js +1 -0
- package/dist/stepper.d.ts +48 -0
- package/dist/stepper.js +1 -0
- package/dist/stopwatch.d.ts +49 -0
- package/dist/stopwatch.js +1 -0
- package/dist/streaming-text.d.ts +46 -0
- package/dist/streaming-text.js +1 -0
- package/dist/switch.d.ts +55 -0
- package/dist/switch.js +1 -0
- package/dist/tab.d.ts +19 -0
- package/dist/tab.js +1 -0
- package/dist/table.d.ts +119 -0
- package/dist/table.js +1 -0
- package/dist/tabs.d.ts +107 -0
- package/dist/tabs.js +1 -0
- package/dist/tag-input.d.ts +71 -0
- package/dist/tag-input.js +1 -0
- package/dist/tag.d.ts +35 -0
- package/dist/tag.js +1 -0
- package/dist/text-input.d.ts +48 -0
- package/dist/text-input.js +1 -0
- package/dist/textarea.d.ts +68 -0
- package/dist/textarea.js +2 -0
- package/dist/thinking-block.d.ts +57 -0
- package/dist/thinking-block.js +1 -0
- package/dist/time-picker.d.ts +67 -0
- package/dist/time-picker.js +1 -0
- package/dist/timer.d.ts +59 -0
- package/dist/timer.js +1 -0
- package/dist/toast.d.ts +49 -0
- package/dist/toast.js +1 -0
- package/dist/token-usage.d.ts +50 -0
- package/dist/token-usage.js +1 -0
- package/dist/tool-call.d.ts +33 -0
- package/dist/tool-call.js +1 -0
- package/dist/tooltip.d.ts +43 -0
- package/dist/tooltip.js +1 -0
- package/dist/transition.d.ts +74 -0
- package/dist/transition.js +1 -0
- package/dist/tree-select.d.ts +65 -0
- package/dist/tree-select.js +1 -0
- package/dist/tree-view-entry.d.ts +4 -0
- package/dist/tree-view-entry.js +1 -0
- package/dist/unordered-list.d.ts +30 -0
- package/dist/unordered-list.js +1 -0
- package/dist/use-linked-scroll.d.ts +42 -0
- package/dist/use-linked-scroll.js +1 -0
- package/package.json +553 -7
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "model-selector",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Model Selector",
|
|
6
|
+
"description": "Model Selector component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useFocus from \"@visulima/tui/hooks/use-focus\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport type { ReactElement } from \"react\";\nimport { useCallback, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type ModelOption = {\n /**\n * Optional context-window hint shown on the right (e.g. `200k`).\n */\n readonly context?: string;\n readonly id: string;\n readonly name: string;\n readonly provider?: string;\n};\n\nexport type Props = {\n /**\n * Accent color for the focused row and selected marker.\n * @default \"blue\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Auto-focus on mount.\n */\n readonly autoFocus?: boolean;\n\n /**\n * Selected model id when uncontrolled.\n */\n readonly defaultValue?: string;\n\n /**\n * The models to choose from.\n */\n readonly models: ReadonlyArray<ModelOption>;\n\n /**\n * Fires when the highlighted model is committed with Enter.\n */\n readonly onSelect?: (model: ModelOption) => void;\n\n /**\n * Controlled selected model id. When set, `defaultValue` is ignored.\n */\n readonly value?: string;\n};\n\nfunction wrap(value: number, size: number): number {\n return size === 0 ? 0 : ((value % size) + size) % size;\n}\n\n/**\n * A selectable list of models with provider and context-window hints. ↑/↓ move\n * the cursor and Enter commits; a ● marks the currently-selected model.\n */\nexport default function ModelSelector({\n accentColor = \"blue\",\n autoFocus = false,\n defaultValue,\n models,\n onSelect,\n value: controlledValue,\n}: Props): ReactElement {\n const { isFocused } = useFocus({ autoFocus });\n const isControlled = controlledValue !== undefined;\n\n const [internal, setInternal] = useState(defaultValue);\n const selectedId = controlledValue ?? internal;\n\n const startIndex = Math.max(0, models.findIndex((model) => model.id === selectedId));\n const [cursor, setCursor] = useState(startIndex);\n\n const inputHandler = useCallback(\n (_input: string, key: { downArrow: boolean; return: boolean; upArrow: boolean }) => {\n if (key.upArrow) {\n setCursor((index) => wrap(index - 1, models.length));\n\n return;\n }\n\n if (key.downArrow) {\n setCursor((index) => wrap(index + 1, models.length));\n\n return;\n }\n\n if (key.return) {\n const model = models[cursor];\n\n if (model !== undefined) {\n if (!isControlled) {\n setInternal(model.id);\n }\n\n onSelect?.(model);\n }\n }\n },\n [cursor, isControlled, models, onSelect],\n );\n\n useInput(inputHandler, { isActive: isFocused });\n\n return (\n <Box flexDirection=\"column\">\n {models.map((model, index) => {\n const isActive = isFocused && index === cursor;\n const isSelected = model.id === selectedId;\n\n return (\n <Box gap={1} key={model.id}>\n <Text color={isActive ? accentColor : undefined}>{isActive ? \"❯\" : \" \"}</Text>\n <Text color={isSelected ? accentColor : undefined}>{isSelected ? \"●\" : \"○\"}</Text>\n <Text bold={isActive}>{model.name}</Text>\n {model.provider === undefined ? undefined : <Text dimColor>{model.provider}</Text>}\n {model.context === undefined\n ? undefined\n : (\n <Box marginLeft={1}>\n <Text dimColor>{model.context}</Text>\n </Box>\n )}\n </Box>\n );\n })}\n </Box>\n );\n}\n\nexport { ModelSelector };\nexport type { Props as ModelSelectorProps };\n",
|
|
19
|
+
"path": "registry/ui/model-selector.tsx",
|
|
20
|
+
"target": "components/ui/model-selector.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "multi-progress",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Multi Progress",
|
|
6
|
+
"description": "Multi Progress component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\ntype Color = LiteralUnion<AnsiColors, string>;\n\nexport type ProgressItem = {\n readonly color?: Color;\n readonly label: string;\n\n /**\n * Progress in the range [0, 1]. Values outside are clamped.\n */\n readonly value: number;\n};\n\nexport type Props = {\n /**\n * Width of each bar in cells.\n * @default 20\n */\n readonly barWidth?: number;\n\n /**\n * Fallback bar color when an item has none.\n * @default \"cyan\"\n */\n readonly color?: Color;\n\n /**\n * Character for the empty portion of each bar; defaults to a light shade.\n */\n readonly emptyChar?: string;\n\n /**\n * Character for the filled portion of each bar; defaults to a full block.\n */\n readonly filledChar?: string;\n\n /**\n * The bars to render, top to bottom.\n */\n readonly items: ReadonlyArray<ProgressItem>;\n\n /**\n * Render the percentage after each bar.\n * @default true\n */\n readonly showPercent?: boolean;\n};\n\nconst clamp01 = (value: number): number => Math.max(0, Math.min(1, value));\n\n/**\n * A stack of labeled progress bars sharing one aligned column layout — useful\n * for parallel downloads, multi-step jobs, or per-file progress. Labels are\n * right-padded to the widest so the bars line up.\n */\nexport default function MultiProgress({\n barWidth = 20,\n color = \"cyan\",\n emptyChar = \"░\",\n filledChar = \"█\",\n items,\n showPercent = true,\n}: Props): ReactElement {\n let labelWidth = 0;\n\n for (const item of items) {\n labelWidth = Math.max(labelWidth, item.label.length);\n }\n\n return (\n <Box flexDirection=\"column\">\n {items.map((item, index) => {\n const ratio = clamp01(item.value);\n const filled = Math.round(ratio * barWidth);\n\n return (\n // eslint-disable-next-line react-x/no-array-index-key -- item index is stable for the render\n <Box gap={1} key={index}>\n <Box flexShrink={0} width={labelWidth}>\n <Text>{item.label.padEnd(labelWidth)}</Text>\n </Box>\n <Box>\n <Text color={item.color ?? color}>{filledChar.repeat(filled)}</Text>\n <Text dimColor>{emptyChar.repeat(Math.max(0, barWidth - filled))}</Text>\n </Box>\n {showPercent ? <Text dimColor>{`${Math.round(ratio * 100)}%`}</Text> : undefined}\n </Box>\n );\n })}\n </Box>\n );\n}\n\nexport { MultiProgress };\nexport type { Props as MultiProgressProps };\n",
|
|
19
|
+
"path": "registry/ui/multi-progress.tsx",
|
|
20
|
+
"target": "components/ui/multi-progress.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "multi-select",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Multi Select",
|
|
6
|
+
"description": "Multi Select component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "/* eslint-disable react/function-component-definition */\n\n/**\n * Multi-select input component for Ink.\n *\n * Inspired by ink-ui MultiSelect by Vadim Demedes.\n * @see https://github.com/vadimdemedes/ink-ui\n *\n * MIT License\n * Copyright (c) Vadym Demedes (github.com/vadimdemedes)\n */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport useWindowSize from \"@visulima/tui/hooks/use-window-size\";\nimport type { ReactElement } from \"react\";\nimport { useCallback, useMemo, useReducer, useRef, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type MultiSelectOption = {\n readonly key?: string;\n readonly label: string;\n readonly value: string;\n};\n\nexport type Props = {\n /**\n * Color for the focused item indicator and selected checkmarks.\n * @default \"blue\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Color for unselected, unfocused option labels.\n */\n readonly defaultColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Initially selected values.\n */\n readonly defaultValue?: ReadonlyArray<string>;\n\n /**\n * When true, all input is ignored and labels are dimmed.\n * @default false\n */\n readonly isDisabled?: boolean;\n\n /**\n * Whether the parent has focus. When false, indicators are dimmed.\n * @default true\n */\n readonly isFocused?: boolean;\n\n /**\n * Number of visible options before scrolling.\n * When omitted, auto-limits to terminal height.\n */\n readonly limit?: number;\n\n /**\n * Fires when the set of selected values changes (space to toggle).\n */\n readonly onChange?: (values: ReadonlyArray<string>) => void;\n\n /**\n * Fires when the user presses Enter to submit.\n */\n readonly onSubmit?: (values: ReadonlyArray<string>) => void;\n\n /**\n * Available choices to pick from.\n */\n readonly options: ReadonlyArray<MultiSelectOption>;\n};\n\nconst EMPTY_DEFAULT: ReadonlyArray<string> = [];\n\ntype MultiSelectState = {\n readonly focusedIndex: number;\n readonly visibleFrom: number;\n};\n\n// eslint-disable-next-line @stylistic/operator-linebreak\ntype MultiSelectAction =\n | { readonly index: number; readonly type: \"focus\" }\n | { readonly limit: number; readonly type: \"focus-next\" }\n | { readonly limit: number; readonly type: \"focus-prev\" };\n\nconst multiSelectReducer = (state: MultiSelectState, action: MultiSelectAction): MultiSelectState => {\n switch (action.type) {\n case \"focus\": {\n return { ...state, focusedIndex: action.index };\n }\n\n case \"focus-next\": {\n const nextIndex = state.focusedIndex + 1;\n const needsScroll = nextIndex >= state.visibleFrom + action.limit;\n\n return {\n ...state,\n focusedIndex: nextIndex,\n visibleFrom: needsScroll ? state.visibleFrom + 1 : state.visibleFrom,\n };\n }\n\n case \"focus-prev\": {\n const previousIndex = state.focusedIndex - 1;\n const needsScroll = previousIndex < state.visibleFrom;\n\n return {\n ...state,\n focusedIndex: previousIndex,\n visibleFrom: needsScroll ? state.visibleFrom - 1 : state.visibleFrom,\n };\n }\n\n default: {\n return state;\n }\n }\n};\n\n/**\n * Renders a multi-choice selection list with checkboxes and keyboard navigation.\n */\nexport default function MultiSelect({\n accentColor = \"blue\",\n defaultColor,\n defaultValue = EMPTY_DEFAULT,\n isDisabled = false,\n isFocused = true,\n limit: customLimit,\n onChange,\n onSubmit,\n options,\n}: Props): ReactElement {\n const { rows: terminalRows } = useWindowSize();\n const effectiveLimit = typeof customLimit === \"number\" ? customLimit : terminalRows;\n const limit = Math.min(effectiveLimit, options.length);\n\n const [state, dispatch] = useReducer(multiSelectReducer, {\n focusedIndex: 0,\n visibleFrom: 0,\n });\n\n const [selectedValues, setSelectedValues] = useState(defaultValue);\n const selectedValuesRef = useRef(selectedValues);\n\n selectedValuesRef.current = selectedValues;\n const onSubmitRef = useRef(onSubmit);\n\n onSubmitRef.current = onSubmit;\n const onChangeRef = useRef(onChange);\n\n onChangeRef.current = onChange;\n\n const stateRef = useRef(state);\n\n stateRef.current = state;\n\n const visibleOptions = useMemo(() => options.slice(state.visibleFrom, state.visibleFrom + limit), [options, state.visibleFrom, limit]);\n\n const handleToggle = useCallback(() => {\n const focusedValue = options[stateRef.current.focusedIndex]?.value;\n\n if (!focusedValue) {\n return;\n }\n\n setSelectedValues((previous) => {\n const next = previous.includes(focusedValue) ? previous.filter((v) => v !== focusedValue) : [...previous, focusedValue];\n\n onChangeRef.current?.(next);\n\n return next;\n });\n }, [options]);\n\n const handleToggleAll = useCallback(() => {\n setSelectedValues((previous) => {\n const next = previous.length === options.length ? [] : options.map((o) => o.value);\n\n onChangeRef.current?.(next);\n\n return next;\n });\n }, [options]);\n\n useInput(\n useCallback(\n (input: string, key) => {\n const { focusedIndex } = stateRef.current;\n\n if ((key.downArrow || input === \"j\") && focusedIndex < options.length - 1) {\n dispatch({ limit, type: \"focus-next\" });\n }\n\n if ((key.upArrow || input === \"k\") && focusedIndex > 0) {\n dispatch({ limit, type: \"focus-prev\" });\n }\n\n if (input === \" \") {\n handleToggle();\n }\n\n if (input === \"a\") {\n handleToggleAll();\n }\n\n if (key.return) {\n onSubmitRef.current?.(selectedValuesRef.current);\n }\n },\n [options.length, limit, handleToggle, handleToggleAll],\n ),\n { isActive: !isDisabled && isFocused },\n );\n\n return (\n <Box flexDirection=\"column\">\n {visibleOptions.map((option, visibleIndex) => {\n const absoluteIndex = state.visibleFrom + visibleIndex;\n const isFocusedOption = absoluteIndex === state.focusedIndex;\n const isSelected = selectedValues.includes(option.value);\n let labelColor: LiteralUnion<AnsiColors, string> | undefined = defaultColor;\n\n if (isSelected) {\n labelColor = \"green\";\n } else if (isFocusedOption) {\n labelColor = accentColor;\n }\n\n return (\n <Box gap={1} key={option.key ?? option.value}>\n {/* eslint-disable-next-line @stylistic/multiline-ternary */}\n {isFocusedOption ? (\n <Text color={accentColor} dimColor={!isFocused}>\n ▸\n </Text>\n ) : (\n <Text> </Text>\n )}\n <Text color={labelColor} dimColor={isDisabled}>\n {isSelected ? \"◼\" : \"◻\"}\n{\" \"}\n{option.label}\n </Text>\n {isSelected\n ? (\n <Text color=\"green\" dimColor={!isFocused}>\n ✓\n </Text>\n )\n : undefined}\n </Box>\n );\n })}\n </Box>\n );\n}\n\nexport { MultiSelect };\nexport type { Props as MultiSelectProps };\n",
|
|
19
|
+
"path": "registry/ui/multi-select.tsx",
|
|
20
|
+
"target": "components/ui/multi-select.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "notification-badge",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Notification Badge",
|
|
6
|
+
"description": "Notification Badge component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type Props = {\n /**\n * The element the badge is attached to (rendered before the badge).\n */\n readonly children?: ReactNode;\n\n /**\n * Badge background color.\n * @default \"red\"\n */\n readonly color?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Numeric count. Values above `max` render as `{max}+`. A count of `0`\n * hides the badge unless `showZero` is set.\n */\n readonly count: number;\n\n /**\n * Upper bound before the count is abbreviated with a trailing `+`.\n * @default 99\n */\n readonly max?: number;\n\n /**\n * Render the badge even when `count` is `0`.\n * @default false\n */\n readonly showZero?: boolean;\n\n /**\n * Text color inside the badge.\n * @default \"white\"\n */\n readonly textColor?: LiteralUnion<AnsiColors, string>;\n};\n\n/**\n * A small count badge, optionally attached to a child element. Counts above\n * `max` are abbreviated (e.g. `99+`); a zero count is hidden unless `showZero`.\n */\nexport default function NotificationBadge({ children, color = \"red\", count, max = 99, showZero = false, textColor = \"white\" }: Props): ReactElement {\n const visible = count > 0 || showZero;\n const label = count > max ? `${max}+` : String(count);\n\n return (\n <Box>\n {children}\n {visible\n ? (\n <Box marginLeft={children === undefined ? 0 : 1}>\n <Text backgroundColor={color} color={textColor}>\n {` ${label} `}\n </Text>\n </Box>\n )\n : undefined}\n </Box>\n );\n}\n\nexport { NotificationBadge };\nexport type { Props as NotificationBadgeProps };\n",
|
|
19
|
+
"path": "registry/ui/notification-badge.tsx",
|
|
20
|
+
"target": "components/ui/notification-badge.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "number-input",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Number Input",
|
|
6
|
+
"description": "Number Input component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useFocus from \"@visulima/tui/hooks/use-focus\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport type { ReactElement } from \"react\";\nimport { useCallback, useRef, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type Props = {\n /**\n * Accent color for the border/caret when focused.\n * @default \"blue\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Auto-focus the input on mount.\n */\n readonly autoFocus?: boolean;\n\n /**\n * Initial value when uncontrolled.\n */\n readonly defaultValue?: number;\n\n /**\n * Disable input and dim the display.\n */\n readonly isDisabled?: boolean;\n\n /**\n * Upper bound. Values are clamped on commit and by the stepper.\n */\n readonly max?: number;\n\n /**\n * Lower bound. Values are clamped on commit and by the stepper.\n */\n readonly min?: number;\n\n /**\n * Fires whenever the numeric value changes.\n */\n readonly onChange?: (value: number) => void;\n\n /**\n * Fires when the user presses Enter.\n */\n readonly onSubmit?: (value: number) => void;\n\n /**\n * Placeholder rendered when the field is empty.\n * @default \"0\"\n */\n readonly placeholder?: string;\n\n /**\n * Number of decimal places to allow and display. `0` restricts input to\n * integers.\n * @default 0\n */\n readonly precision?: number;\n\n /**\n * Show ▲/▼ stepper hints and let ↑/↓ adjust by `step`.\n * @default true\n */\n readonly showControls?: boolean;\n\n /**\n * Increment applied by ↑/↓ (and Shift for 10× via PageUp/PageDown).\n * @default 1\n */\n readonly step?: number;\n\n /**\n * Controlled value. When provided, `defaultValue` is ignored.\n */\n readonly value?: number;\n};\n\nconst clamp = (value: number, min: number | undefined, max: number | undefined): number => {\n let next = value;\n\n if (min !== undefined && next < min) {\n next = min;\n }\n\n if (max !== undefined && next > max) {\n next = max;\n }\n\n return next;\n};\n\nconst roundTo = (value: number, precision: number): number => {\n const factor = 10 ** precision;\n\n return Math.round(value * factor) / factor;\n};\n\nconst DIGIT_PATTERN = /^\\d$/;\n\nfunction format(value: number, precision: number): string {\n return precision > 0 ? value.toFixed(precision) : String(value);\n}\n\n/**\n * A keyboard-driven numeric input. Type digits (and `-`/`.` where allowed),\n * use ↑/↓ to step by `step`, PageUp/PageDown for 10× steps, and Enter to\n * submit. The raw text buffer is validated so partial entries like `-` or\n * `1.` are allowed while typing but clamped/parsed on change.\n */\nexport default function NumberInput({\n accentColor = \"blue\",\n autoFocus = false,\n defaultValue,\n isDisabled = false,\n max,\n min,\n onChange,\n onSubmit,\n placeholder = \"0\",\n precision = 0,\n showControls = true,\n step = 1,\n value: controlledValue,\n}: Props): ReactElement {\n const { isFocused } = useFocus({ autoFocus, isActive: !isDisabled });\n const isControlled = controlledValue !== undefined;\n\n const [buffer, setBuffer] = useState<string>(defaultValue === undefined ? \"\" : format(defaultValue, precision));\n\n const onChangeRef = useRef(onChange);\n\n onChangeRef.current = onChange;\n\n const displayBuffer = isControlled ? format(controlledValue, precision) : buffer;\n\n const commit = useCallback(\n (next: number) => {\n const clamped = clamp(roundTo(next, precision), min, max);\n\n if (!isControlled) {\n setBuffer(format(clamped, precision));\n }\n\n onChangeRef.current?.(clamped);\n\n return clamped;\n },\n [isControlled, max, min, precision],\n );\n\n const parseBuffer = useCallback((raw: string): number => {\n const parsed = Number.parseFloat(raw);\n\n return Number.isFinite(parsed) ? parsed : 0;\n }, []);\n\n const inputHandler = useCallback(\n (input: string, key: { backspace: boolean; delete: boolean; downArrow: boolean; pageDown: boolean; pageUp: boolean; return: boolean; upArrow: boolean }) => {\n if (key.upArrow) {\n commit(parseBuffer(displayBuffer) + step);\n\n return;\n }\n\n if (key.downArrow) {\n commit(parseBuffer(displayBuffer) - step);\n\n return;\n }\n\n if (key.pageUp) {\n commit(parseBuffer(displayBuffer) + step * 10);\n\n return;\n }\n\n if (key.pageDown) {\n commit(parseBuffer(displayBuffer) - step * 10);\n\n return;\n }\n\n if (key.return) {\n onSubmit?.(commit(parseBuffer(displayBuffer)));\n\n return;\n }\n\n // Text editing works in both modes. Uncontrolled mode keeps a string\n // buffer so partial entries like `-` or `1.` survive; controlled mode\n // edits from the displayed value and emits onChange (partial states\n // can't round-trip a numeric `value`, but digit entry still works).\n const base = isControlled ? displayBuffer : buffer;\n\n if (key.backspace || key.delete) {\n const next = base.slice(0, -1);\n\n if (!isControlled) {\n setBuffer(next);\n }\n\n onChangeRef.current?.(clamp(roundTo(parseBuffer(next), precision), min, max));\n\n return;\n }\n\n // Accept a leading minus, a single decimal point (when precision > 0),\n // and digits. Reject anything that would make the buffer unparseable.\n if (input === \"-\" && base.length === 0 && (min === undefined || min < 0)) {\n if (!isControlled) {\n setBuffer(\"-\");\n }\n\n return;\n }\n\n if (input === \".\" && precision > 0 && !base.includes(\".\")) {\n if (!isControlled) {\n setBuffer(base.length === 0 ? \"0.\" : `${base}.`);\n }\n\n return;\n }\n\n if (DIGIT_PATTERN.test(input)) {\n const next = `${base}${input}`;\n\n // Guard against more decimals than precision allows.\n const dot = next.indexOf(\".\");\n\n if (dot !== -1 && next.length - dot - 1 > precision) {\n return;\n }\n\n if (!isControlled) {\n setBuffer(next);\n }\n\n onChangeRef.current?.(clamp(roundTo(parseBuffer(next), precision), min, max));\n }\n },\n [buffer, commit, displayBuffer, isControlled, max, min, onSubmit, parseBuffer, precision, step],\n );\n\n useInput(inputHandler, { isActive: isFocused && !isDisabled });\n\n const isEmpty = displayBuffer.length === 0 || displayBuffer === \"-\";\n\n return (\n <Box borderColor={isFocused ? accentColor : undefined} borderStyle=\"round\" paddingX={1}>\n {isEmpty ? <Text dimColor>{placeholder}</Text> : <Text color={isDisabled ? undefined : accentColor} dimColor={isDisabled}>{displayBuffer}</Text>}\n {isFocused && !isEmpty ? <Text color={accentColor}>▏</Text> : undefined}\n {showControls\n ? (\n <Box marginLeft={1}>\n <Text dimColor>▲▼</Text>\n </Box>\n )\n : undefined}\n </Box>\n );\n}\n\nexport { NumberInput };\nexport type { Props as NumberInputProps };\n",
|
|
19
|
+
"path": "registry/ui/number-input.tsx",
|
|
20
|
+
"target": "components/ui/number-input.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "operation-tree",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Operation Tree",
|
|
6
|
+
"description": "Operation Tree component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [
|
|
16
|
+
"https://visulima.com/r/spinner.json"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
{
|
|
20
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nimport Spinner from \"@/components/ui/spinner\";\n\nexport type OperationStatus = \"completed\" | \"error\" | \"pending\" | \"running\" | \"skipped\";\n\nexport type OperationNode = {\n /**\n * Nested child operations rendered beneath this node.\n */\n readonly children?: ReadonlyArray<OperationNode>;\n\n /**\n * Optional supplementary info rendered on the next line (dimmed).\n */\n readonly details?: ReactNode;\n\n /**\n * Wall-clock duration in ms, displayed right-aligned.\n */\n readonly durationMs?: number;\n\n /**\n * Unique identifier within the tree.\n */\n readonly id: string;\n\n /**\n * Human-readable label.\n */\n readonly label: ReactNode;\n\n /**\n * Lifecycle state controlling icon + color.\n */\n readonly status: OperationStatus;\n};\n\nexport type Props = {\n /**\n * Tree of operations. Each node can have its own children.\n */\n readonly nodes: ReadonlyArray<OperationNode>;\n\n /**\n * When true, runs the running-spinner animation.\n * @default true\n */\n readonly showSpinner?: boolean;\n};\n\nconst STATUS_COLOR: Record<OperationStatus, LiteralUnion<AnsiColors, string>> = {\n completed: \"green\",\n error: \"red\",\n pending: \"gray\",\n running: \"cyan\",\n skipped: \"yellow\",\n};\n\nconst STATUS_ICON: Record<Exclude<OperationStatus, \"running\">, string> = {\n completed: \"✔\",\n error: \"✖\",\n pending: \"○\",\n skipped: \"⊘\",\n};\n\nconst formatDuration = (ms: number): string => {\n if (ms < 1000) {\n return `${Math.round(ms)}ms`;\n }\n\n if (ms < 60_000) {\n return `${(ms / 1000).toFixed(1)}s`;\n }\n\n // Compute total seconds first so rounding can never produce \"Xm 60s\".\n const totalSeconds = Math.round(ms / 1000);\n const minutes = Math.floor(totalSeconds / 60);\n const seconds = totalSeconds % 60;\n\n return `${minutes}m ${seconds}s`;\n};\n\ntype NodeLineProps = {\n readonly isLast: boolean;\n readonly node: OperationNode;\n readonly prefix: string;\n readonly showSpinner: boolean;\n};\n\nconst NodeLine = ({ isLast, node, prefix, showSpinner }: NodeLineProps): ReactElement => {\n const color = STATUS_COLOR[node.status];\n const branchConnector = isLast ? \"└─ \" : \"├─ \";\n const connector = prefix.length === 0 ? \"\" : branchConnector;\n const childPrefix = prefix + (isLast ? \" \" : \"│ \");\n const runningGlyph = showSpinner ? <Spinner type=\"dots\" /> : \"…\";\n const statusGlyph = node.status === \"running\" ? runningGlyph : STATUS_ICON[node.status];\n\n return (\n <>\n <Box>\n <Text dimColor>{prefix}</Text>\n <Text dimColor>{connector}</Text>\n <Box flexShrink={0}>\n <Text color={color}>{statusGlyph}</Text>\n </Box>\n <Text> </Text>\n <Box flexGrow={1} flexShrink={1} minWidth={0}>\n <Text\n bold={node.status === \"running\"}\n color={node.status === \"pending\" ? \"gray\" : undefined}\n dimColor={node.status === \"skipped\"}\n wrap=\"truncate-end\"\n >\n {node.label}\n </Text>\n </Box>\n {node.durationMs === undefined\n ? undefined\n : (\n <Box flexShrink={0}>\n <Text dimColor>\n{\" \"}\n{formatDuration(node.durationMs)}\n </Text>\n </Box>\n )}\n </Box>\n {node.details === undefined\n ? undefined\n : (\n <Box>\n <Text dimColor>{childPrefix}</Text>\n <Text dimColor>{node.details}</Text>\n </Box>\n )}\n {node.children?.map((child, index, all) => (\n <NodeLine isLast={index === all.length - 1} key={child.id} node={child} prefix={childPrefix} showSpinner={showSpinner} />\n ))}\n </>\n );\n};\n\n/**\n * Renders a tree of operations with per-node status. Perfect for agent\n * progress panels (reading → editing → running tests → done).\n * @returns A `ReactElement` rendering the nested tree of operation rows.\n */\nexport default function OperationTree({ nodes, showSpinner = true }: Props): ReactElement {\n return (\n <Box flexDirection=\"column\">\n {nodes.map((node, index) => (\n <NodeLine isLast={index === nodes.length - 1} key={node.id} node={node} prefix=\"\" showSpinner={showSpinner} />\n ))}\n </Box>\n );\n}\n\nexport { OperationTree };\nexport type { Props as OperationTreeProps };\n",
|
|
21
|
+
"path": "registry/ui/operation-tree.tsx",
|
|
22
|
+
"target": "components/ui/operation-tree.tsx",
|
|
23
|
+
"type": "registry:ui"
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "option-list",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Option List",
|
|
6
|
+
"description": "Option List component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type OptionListEntry = {\n readonly description?: string;\n readonly icon?: ReactNode;\n readonly id: string;\n readonly label: ReactNode;\n readonly trailing?: ReactNode;\n};\n\nexport type Props = {\n /**\n * Highlight color for the row marked as current.\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Render the list with a border.\n */\n readonly bordered?: boolean;\n\n /**\n * Id of the row to highlight. No visual change if omitted.\n */\n readonly currentId?: string;\n\n /**\n * Icon width in characters for column alignment.\n * @default 2\n */\n readonly iconColumnWidth?: number;\n\n /**\n * Entries to render.\n */\n readonly options: ReadonlyArray<OptionListEntry>;\n};\n\n/**\n * Read-only presentational option list. Like `SelectInput` but without focus,\n * input handling, or scrolling — just a styled list of rows. Useful inside\n * modals, sidebars, or summary panels.\n * @returns A `ReactElement` rendering the list; bordered when requested.\n */\nexport default function OptionList({ accentColor = \"blue\", bordered = false, currentId, iconColumnWidth = 2, options }: Props): ReactElement {\n const list = (\n <Box flexDirection=\"column\">\n {options.map((option) => {\n const isCurrent = option.id === currentId;\n const color = isCurrent ? accentColor : undefined;\n\n return (\n <Box flexDirection=\"column\" key={option.id}>\n <Box>\n <Box flexShrink={0} width={iconColumnWidth}>\n <Text color={color}>{option.icon ?? (isCurrent ? \"▸\" : \" \")}</Text>\n </Box>\n <Box flexGrow={1} flexShrink={1} minWidth={0}>\n <Text bold={isCurrent} color={color} wrap=\"truncate-end\">\n {option.label}\n </Text>\n </Box>\n {option.trailing === undefined ? undefined : <Box flexShrink={0}>{option.trailing}</Box>}\n </Box>\n {option.description === undefined\n ? undefined\n : (\n <Box marginLeft={iconColumnWidth}>\n <Text dimColor wrap=\"truncate-end\">\n {option.description}\n </Text>\n </Box>\n )}\n </Box>\n );\n })}\n </Box>\n );\n\n if (!bordered) {\n return list;\n }\n\n return (\n <Box borderStyle=\"round\" paddingX={1}>\n {list}\n </Box>\n );\n}\n\nexport { OptionList };\nexport type { Props as OptionListProps };\n",
|
|
19
|
+
"path": "registry/ui/option-list.tsx",
|
|
20
|
+
"target": "components/ui/option-list.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "ordered-list",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Ordered List",
|
|
6
|
+
"description": "Ordered List component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/tui"
|
|
12
|
+
],
|
|
13
|
+
"registryDependencies": [],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"content": "/**\n * Ordered list component for Ink.\n *\n * Inspired by ink-ui OrderedList by Vadim Demedes.\n * @see https://github.com/vadimdemedes/ink-ui\n *\n * MIT License\n * Copyright (c) Vadym Demedes (github.com/vadimdemedes)\n */\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement, ReactNode } from \"react\";\n\nexport type OrderedListEntry = {\n /**\n * Nested sub-items, rendered as a sub-list with hierarchical numbering.\n */\n readonly children?: ReadonlyArray<OrderedListEntry>;\n\n /**\n * Display text for this item.\n */\n readonly label: ReactNode;\n};\n\nexport type Props = {\n /**\n * Entries to render as a numbered list.\n */\n readonly items: ReadonlyArray<OrderedListEntry>;\n};\n\n/**\n * Render a numbered list recursively with a parent marker prefix.\n */\nconst renderList = (items: ReadonlyArray<OrderedListEntry>, parentMarker: string): ReactElement => {\n const maxWidth = String(items.length).length;\n\n return (\n <Box flexDirection=\"column\">\n {items.map((item, index) => {\n const paddedNumber = `${String(index + 1).padStart(maxWidth)}.`;\n const marker = `${parentMarker}${paddedNumber}`;\n\n return (\n // eslint-disable-next-line react-x/no-array-index-key -- list entries have no unique key\n <Box gap={1} key={index}>\n <Text dimColor>{marker}</Text>\n <Box flexDirection=\"column\">\n <Text>{item.label}</Text>\n {item.children ? renderList(item.children, marker) : undefined}\n </Box>\n </Box>\n );\n })}\n </Box>\n );\n};\n\n/**\n * Renders a numbered list with hierarchical nesting support.\n */\nexport default function OrderedList({ items }: Props): ReactElement {\n return renderList(items, \"\");\n}\n\nexport { OrderedList };\nexport type { Props as OrderedListProps };\n",
|
|
17
|
+
"path": "registry/ui/ordered-list.tsx",
|
|
18
|
+
"target": "components/ui/ordered-list.tsx",
|
|
19
|
+
"type": "registry:ui"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "paginator",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Paginator",
|
|
6
|
+
"description": "Paginator component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/tui"
|
|
12
|
+
],
|
|
13
|
+
"registryDependencies": [],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"content": "import Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { useCallback, useState } from \"react\";\n\n/**\n * Metadata about the current page state, passed to the render-prop children.\n */\nexport type PageMeta = {\n readonly currentPage: number;\n readonly endIndex: number;\n readonly isFirstPage: boolean;\n readonly isLastPage: boolean;\n readonly startIndex: number;\n readonly totalPages: number;\n};\n\nexport type Props<T> = {\n /**\n * Render-prop that receives the current page items and page metadata.\n */\n readonly children: (pageItems: ReadonlyArray<T>, meta: PageMeta) => ReactNode;\n\n /**\n * Uncontrolled initial page (0-indexed).\n * @default 0\n */\n readonly defaultPage?: number;\n\n /**\n * Color for the active page indicator.\n * @default \"cyan\"\n */\n readonly indicatorColor?: string;\n\n /**\n * Whether keyboard navigation is active.\n * @default true\n */\n readonly isFocused?: boolean;\n\n /**\n * The full list of items to paginate.\n */\n readonly items: ReadonlyArray<T>;\n\n /**\n * Called when the page changes.\n */\n readonly onChange?: (page: number) => void;\n\n /**\n * Controlled current page (0-indexed). When provided, the component is controlled.\n */\n readonly page?: number;\n\n /**\n * Number of items per page.\n */\n readonly pageSize: number;\n\n /**\n * Indicator style.\n * - `\"dots\"`: `● ○ ○ ○`\n * - `\"numeric\"`: `‹ 1 / 4 ›`\n * - `\"fraction\"`: `1/4`\n * @default \"dots\"\n */\n readonly style?: \"dots\" | \"fraction\" | \"numeric\";\n};\n\n/**\n * Paginator component with render-prop children and a page indicator.\n *\n * ```tsx\n * <Paginator items={allItems} pageSize={5}>\n * {(pageItems, meta) => (\n * <Box flexDirection=\"column\">\n * {pageItems.map((item, i) => <Text key={i}>{item}</Text>)}\n * </Box>\n * )}\n * </Paginator>\n * ```\n */\nconst Indicator = ({\n color,\n currentPage,\n style,\n totalPages,\n}: {\n color: string;\n currentPage: number;\n style: \"dots\" | \"fraction\" | \"numeric\";\n totalPages: number;\n}): ReactElement => {\n if (style === \"fraction\") {\n return (\n <Text>\n <Text color={color}>{currentPage + 1}</Text>\n <Text dimColor>\n/\n{totalPages}\n </Text>\n </Text>\n );\n }\n\n if (style === \"numeric\") {\n const previousArrow = currentPage > 0 ? <Text color={color}>{\"\\u2039 \"}</Text> : <Text dimColor>{\"\\u2039 \"}</Text>;\n const nextArrow = currentPage < totalPages - 1 ? <Text color={color}>{\" \\u203A\"}</Text> : <Text dimColor>{\" \\u203A\"}</Text>;\n\n return (\n <Box>\n {previousArrow}\n <Text color={color}>{currentPage + 1}</Text>\n <Text dimColor>\n{\" \"}\n/\n{totalPages}\n </Text>\n {nextArrow}\n </Box>\n );\n }\n\n // Dots style (default)\n const dots = Array.from({ length: totalPages }, (_, i) => {\n if (i === currentPage) {\n return (\n <Text color={color} key={i}>\n {\"\\u25CF\"}\n </Text>\n );\n }\n\n return (\n <Text dimColor key={i}>\n {\"\\u25CB\"}\n </Text>\n );\n });\n\n return <Box gap={1}>{dots}</Box>;\n};\n\nconst Paginator = <T,>({\n children,\n defaultPage = 0,\n indicatorColor = \"cyan\",\n isFocused = true,\n items,\n onChange,\n page: controlledPage,\n pageSize,\n style = \"dots\",\n}: Props<T>): ReactElement => {\n const isControlled = controlledPage !== undefined;\n const [internalPage, setInternalPage] = useState(defaultPage);\n // Use the controlled value directly during render — no effect needed to sync.\n const currentPage = isControlled ? controlledPage : internalPage;\n\n const totalPages = Math.max(1, Math.ceil(items.length / pageSize));\n const safePage = Math.max(0, Math.min(currentPage, totalPages - 1));\n\n const setPage = useCallback(\n (newPage: number) => {\n const clamped = Math.max(0, Math.min(newPage, totalPages - 1));\n\n if (!isControlled) {\n setInternalPage(clamped);\n }\n\n onChange?.(clamped);\n },\n [totalPages, isControlled, onChange],\n );\n\n useInput(\n useCallback(\n (_input: string, key) => {\n if (key.leftArrow || key.pageUp) {\n setPage(safePage - 1);\n } else if (key.rightArrow || key.pageDown) {\n setPage(safePage + 1);\n } else if (key.home) {\n setPage(0);\n } else if (key.end) {\n setPage(totalPages - 1);\n }\n },\n [safePage, totalPages, setPage],\n ),\n { isActive: isFocused },\n );\n\n const startIndex = safePage * pageSize;\n const endIndex = Math.min(startIndex + pageSize, items.length);\n const pageItems = items.slice(startIndex, endIndex);\n\n const meta: PageMeta = {\n currentPage: safePage,\n endIndex,\n isFirstPage: safePage === 0,\n isLastPage: safePage === totalPages - 1,\n startIndex,\n totalPages,\n };\n\n return (\n <Box flexDirection=\"column\">\n {children(pageItems, meta)}\n {totalPages > 1 && <Indicator color={indicatorColor} currentPage={safePage} style={style} totalPages={totalPages} />}\n </Box>\n );\n};\n\nexport default Paginator;\n\nexport { Paginator };\nexport type { Props as PaginatorProps };\n",
|
|
17
|
+
"path": "registry/ui/paginator.tsx",
|
|
18
|
+
"target": "components/ui/paginator.tsx",
|
|
19
|
+
"type": "registry:ui"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "paragraph",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Paragraph",
|
|
6
|
+
"description": "Paragraph component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type Props = {\n /**\n * Paragraph body.\n */\n readonly children: ReactNode;\n\n /**\n * Foreground color.\n */\n readonly color?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Render the paragraph with dim color.\n */\n readonly dimColor?: boolean;\n\n /**\n * Blank lines to add after the paragraph.\n * @default 1\n */\n readonly marginBottom?: number;\n\n /**\n * Blank lines to add before the paragraph.\n * @default 0\n */\n readonly marginTop?: number;\n};\n\n/**\n * Wraps body text with a trailing blank line. Useful for README-style layouts.\n */\nexport default function Paragraph({ children, color, dimColor = false, marginBottom = 1, marginTop = 0 }: Props): ReactElement {\n return (\n <Box flexDirection=\"column\" marginBottom={marginBottom} marginTop={marginTop}>\n <Text color={color} dimColor={dimColor}>\n {children}\n </Text>\n </Box>\n );\n}\n\nexport { Paragraph };\nexport type { Props as ParagraphProps };\n",
|
|
19
|
+
"path": "registry/ui/paragraph.tsx",
|
|
20
|
+
"target": "components/ui/paragraph.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "path-input",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Path Input",
|
|
6
|
+
"description": "Path Input component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"node:fs",
|
|
14
|
+
"node:path",
|
|
15
|
+
"type-fest"
|
|
16
|
+
],
|
|
17
|
+
"registryDependencies": [],
|
|
18
|
+
"files": [
|
|
19
|
+
{
|
|
20
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport { readdirSync, statSync } from \"node:fs\";\nimport { basename, dirname, join } from \"node:path\";\n\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useFocus from \"@visulima/tui/hooks/use-focus\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport type { ReactElement } from \"react\";\nimport { useCallback, useMemo, useRef, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nconst TRAILING_SLASH = /\\/$/;\n\n/**\n * Default completer: list entries of the directory portion of `input` whose\n * basename starts with the typed leaf. Directories get a trailing separator.\n * Any filesystem error yields no completions rather than throwing.\n */\nconst listPathCompletions = (input: string): ReadonlyArray<string> => {\n const directory = input.endsWith(\"/\") ? input : dirname(input);\n const leaf = input.endsWith(\"/\") ? \"\" : basename(input);\n\n try {\n return readdirSync(directory.length === 0 ? \".\" : directory)\n .filter((entry) => entry.startsWith(leaf))\n .map((entry) => {\n const full = join(directory, entry);\n\n try {\n return statSync(full).isDirectory() ? `${full}/` : full;\n } catch {\n return full;\n }\n })\n .toSorted((a, b) => a.localeCompare(b));\n } catch {\n return [];\n }\n};\n\nexport type Props = {\n /**\n * Accent color for the focused border and caret.\n * @default \"blue\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Auto-focus on mount.\n */\n readonly autoFocus?: boolean;\n\n /**\n * Initial path when uncontrolled.\n */\n readonly defaultValue?: string;\n\n /**\n * Override how completions are produced (e.g. to sandbox or mock the\n * filesystem). Defaults to reading the real filesystem.\n */\n readonly getCompletions?: (input: string) => ReadonlyArray<string>;\n\n /**\n * Disable input and dim the display.\n */\n readonly isDisabled?: boolean;\n\n /**\n * Fires whenever the path text changes.\n */\n readonly onChange?: (path: string) => void;\n\n /**\n * Fires on Enter with the current path.\n */\n readonly onSubmit?: (path: string) => void;\n\n /**\n * Placeholder shown when empty.\n * @default \"Enter a path…\"\n */\n readonly placeholder?: string;\n\n /**\n * Show the first few completion candidates beneath the field.\n * @default true\n */\n readonly showSuggestions?: boolean;\n\n /**\n * Controlled path. When provided, `defaultValue` is ignored.\n */\n readonly value?: string;\n};\n\nconst MAX_SUGGESTIONS = 5;\n\n/**\n * A text input specialised for filesystem paths. Tab completes against the\n * directory being typed (cycling through matches on repeated presses), and a\n * short list of candidates is shown below the field.\n */\nexport default function PathInput({\n accentColor = \"blue\",\n autoFocus = false,\n defaultValue = \"\",\n getCompletions = listPathCompletions,\n isDisabled = false,\n onChange,\n onSubmit,\n placeholder = \"Enter a path…\",\n showSuggestions = true,\n value: controlledValue,\n}: Props): ReactElement {\n const { isFocused } = useFocus({ autoFocus, isActive: !isDisabled });\n const isControlled = controlledValue !== undefined;\n\n const [internal, setInternal] = useState(defaultValue);\n const path = controlledValue ?? internal;\n\n // The completion candidates frozen at the first Tab, cycled on repeat\n // presses. Reset to null on any edit so the next Tab re-snapshots.\n const cycleRef = useRef<{ index: number; matches: ReadonlyArray<string> } | null>(null);\n\n const onChangeRef = useRef(onChange);\n\n onChangeRef.current = onChange;\n\n const completions = useMemo(() => getCompletions(path), [getCompletions, path]);\n\n const setPath = useCallback(\n (next: string) => {\n if (!isControlled) {\n setInternal(next);\n }\n\n onChangeRef.current?.(next);\n },\n [isControlled],\n );\n\n const inputHandler = useCallback(\n (input: string, key: { backspace: boolean; delete: boolean; return: boolean; tab: boolean }) => {\n if (key.tab) {\n // Snapshot the candidate list on the first Tab, then cycle the\n // frozen list — recomputing from `path` each press would collapse\n // to the just-picked entry and never advance.\n cycleRef.current ??= { index: 0, matches: completions };\n\n const { index, matches } = cycleRef.current;\n\n if (matches.length > 0) {\n cycleRef.current = { index: index + 1, matches };\n setPath(matches[index % matches.length]!);\n }\n\n return;\n }\n\n if (key.return) {\n onSubmit?.(path);\n\n return;\n }\n\n if (key.backspace || key.delete) {\n cycleRef.current = null;\n setPath(path.slice(0, -1));\n\n return;\n }\n\n if (input.length > 0 && input.codePointAt(0)! >= 0x20) {\n cycleRef.current = null;\n setPath(`${path}${input}`);\n }\n },\n [completions, onSubmit, path, setPath],\n );\n\n useInput(inputHandler, { isActive: isFocused && !isDisabled });\n\n return (\n <Box flexDirection=\"column\">\n <Box borderColor={isFocused ? accentColor : undefined} borderStyle=\"round\" paddingX={1}>\n <Text color={accentColor}>📁 </Text>\n {path.length === 0 ? <Text dimColor>{placeholder}</Text> : <Text dimColor={isDisabled}>{path}</Text>}\n {isFocused && path.length > 0 ? <Text color={accentColor}>▏</Text> : undefined}\n </Box>\n {/* eslint-disable-next-line @stylistic/multiline-ternary -- prettier formats JSX ternaries on one line */}\n {showSuggestions && isFocused && completions.length > 0 ? (\n <Box flexDirection=\"column\" marginLeft={1}>\n {completions.slice(0, MAX_SUGGESTIONS).map((candidate, index) => (\n // eslint-disable-next-line react-x/no-array-index-key -- candidate index is stable for the render\n <Text dimColor key={index}>\n {basename(candidate.replace(TRAILING_SLASH, \"\"))}\n {candidate.endsWith(\"/\") ? \"/\" : \"\"}\n </Text>\n ))}\n {completions.length > MAX_SUGGESTIONS ? <Text dimColor>{`…and ${completions.length - MAX_SUGGESTIONS} more`}</Text> : undefined}\n </Box>\n ) : undefined}\n </Box>\n );\n}\n\nexport { PathInput };\nexport type { Props as PathInputProps };\n",
|
|
21
|
+
"path": "registry/ui/path-input.tsx",
|
|
22
|
+
"target": "components/ui/path-input.tsx",
|
|
23
|
+
"type": "registry:ui"
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "pie-chart",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Pie Chart",
|
|
6
|
+
"description": "Pie Chart component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport type { CanvasContext } from \"@visulima/tui/canvas\";\nimport { createBrailleGrid } from \"@visulima/tui/canvas\";\nimport Box from \"@visulima/tui/components/box\";\nimport Canvas from \"@visulima/tui/components/canvas\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement } from \"react\";\nimport { useMemo } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type PieSlice = {\n /**\n * Per-slice color override. Falls back to the chart palette.\n */\n readonly color?: LiteralUnion<AnsiColors, string>;\n readonly label?: string;\n readonly value: number;\n};\n\nexport type Props = {\n /**\n * Slices to render. Non-positive values are ignored.\n */\n readonly data: ReadonlyArray<PieSlice>;\n\n /**\n * Format a slice's legend value. Defaults to `value (percent%)`.\n */\n readonly formatLegend?: (slice: PieSlice, percent: number) => string;\n\n /**\n * Fallback palette cycled through when a slice has no color.\n * @default [\"cyan\", \"magenta\", \"yellow\", \"green\", \"blue\", \"red\"]\n */\n readonly palette?: ReadonlyArray<LiteralUnion<AnsiColors, string>>;\n\n /**\n * Render a legend with per-slice values and percentages.\n * @default true\n */\n readonly showLegend?: boolean;\n\n /**\n * Diameter of the disc in character rows. Width is derived to keep the\n * circle roughly round given the 2x4 braille cell aspect.\n * @default 8\n */\n readonly size?: number;\n};\n\nconst DEFAULT_PALETTE: ReadonlyArray<LiteralUnion<AnsiColors, string>> = [\"cyan\", \"magenta\", \"yellow\", \"green\", \"blue\", \"red\"];\n\nconst colorFor = (index: number, palette: ReadonlyArray<LiteralUnion<AnsiColors, string>>, slice: PieSlice): LiteralUnion<AnsiColors, string> =>\n slice.color ?? palette[index % palette.length] ?? \"cyan\";\n\nconst defaultLegend = (slice: PieSlice, percent: number): string => `${slice.label ?? \"\"} ${slice.value} (${percent.toFixed(0)}%)`.trim();\n\n/**\n * A filled pie chart rendered at 2x4 braille sub-cell resolution. Each pixel of\n * the disc is assigned to the slice whose angular wedge contains it, so slices\n * keep clean colors. An optional legend lists values and percentages.\n */\nexport default function PieChart({\n data,\n formatLegend = defaultLegend,\n palette = DEFAULT_PALETTE,\n showLegend = true,\n size = 8,\n}: Props): ReactElement {\n const slices = useMemo(() => data.filter((slice) => slice.value > 0), [data]);\n const total = useMemo(() => slices.reduce((sum, slice) => sum + slice.value, 0), [slices]);\n\n const rows = Math.max(2, size);\n // Braille cells are 2 wide × 4 tall; double the columns so the disc reads round.\n const cols = Math.max(2, Math.round((rows * 4) / 2 / 2));\n\n return (\n <Box flexDirection=\"row\" gap={2}>\n <Canvas\n // eslint-disable-next-line react-perf/jsx-no-new-function-as-prop -- canvas re-renders on `version` change, not draw identity\n draw={(context: CanvasContext) => {\n context.clear();\n\n if (total <= 0) {\n return;\n }\n\n const pixelWidth = context.width * 2;\n const pixelHeight = context.height * 4;\n const cx = pixelWidth / 2;\n const cy = pixelHeight / 2;\n const radius = Math.max(1, Math.min(cx, cy) - 1);\n\n // Precompute cumulative angle boundaries per slice (clockwise from top).\n const bounds: { color: string; end: number; start: number }[] = [];\n let accumulator = 0;\n\n slices.forEach((slice, index) => {\n const start = (accumulator / total) * Math.PI * 2;\n\n accumulator += slice.value;\n\n const end = (accumulator / total) * Math.PI * 2;\n\n bounds.push({ color: colorFor(index, palette, slice), end, start });\n });\n\n const gridsByColor = new Map<string, ReturnType<typeof createBrailleGrid>>();\n const gridFor = (color: string) => {\n const existing = gridsByColor.get(color);\n\n if (existing !== undefined) {\n return existing;\n }\n\n const grid = createBrailleGrid(context.width, context.height);\n\n gridsByColor.set(color, grid);\n\n return grid;\n };\n\n for (let py = 0; py < pixelHeight; py += 1) {\n for (let px = 0; px < pixelWidth; px += 1) {\n const dx = px - cx;\n const dy = py - cy;\n\n if (dx * dx + dy * dy > radius * radius) {\n continue;\n }\n\n // Angle clockwise from the top (12 o'clock).\n let angle = Math.atan2(dx, -dy);\n\n if (angle < 0) {\n angle += Math.PI * 2;\n }\n\n const slice = bounds.find((bound) => angle >= bound.start && angle < bound.end) ?? bounds[bounds.length - 1];\n\n if (slice) {\n gridFor(slice.color).plotPoint(px, py);\n }\n }\n }\n\n for (const [color, grid] of gridsByColor) {\n grid.flush(context, { color });\n }\n }}\n height={rows}\n // eslint-disable-next-line react-perf/jsx-no-new-array-as-prop -- version array is the canvas redraw key\n version={[data, total, palette, rows, cols]}\n width={cols}\n />\n {/* eslint-disable-next-line @stylistic/multiline-ternary -- prettier formats JSX ternaries on one line */}\n {showLegend && slices.length > 0 ? (\n <Box flexDirection=\"column\">\n {slices.map((slice, index) => (\n // eslint-disable-next-line react-x/no-array-index-key -- slice index is stable for the render\n <Box gap={1} key={index}>\n <Text color={colorFor(index, palette, slice)}>●</Text>\n <Text dimColor>{formatLegend(slice, (slice.value / total) * 100)}</Text>\n </Box>\n ))}\n </Box>\n ) : undefined}\n </Box>\n );\n}\n\nexport { PieChart };\nexport type { Props as PieChartProps };\n",
|
|
19
|
+
"path": "registry/ui/pie-chart.tsx",
|
|
20
|
+
"target": "components/ui/pie-chart.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "placeholder",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Placeholder",
|
|
6
|
+
"description": "Placeholder component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useAnimation from \"@visulima/tui/hooks/use-animation\";\nimport useWindowSize from \"@visulima/tui/hooks/use-window-size\";\nimport type { ReactElement } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type Props = {\n /**\n * Whether to pulse the skeleton blocks (shimmer).\n * @default true\n */\n readonly animated?: boolean;\n\n /**\n * Glyph repeated to fill each placeholder block.\n * @default \"█\" (full block)\n */\n readonly character?: string;\n\n /**\n * Block color.\n * @default \"gray\"\n */\n readonly color?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Milliseconds between pulses.\n * @default 500\n */\n readonly interval?: number;\n\n /**\n * Number of skeleton rows to render.\n * @default 3\n */\n readonly rows?: number;\n\n /**\n * Total width reference. When omitted, the block flexes to fill its\n * parent.\n */\n readonly width?: number;\n\n /**\n * Relative widths of each row (0–1). The list is sampled cyclically.\n * @default [1, 0.85, 0.6]\n */\n readonly widths?: ReadonlyArray<number>;\n};\n\nconst DEFAULT_WIDTHS = [1, 0.85, 0.6] as const;\n\n/**\n * Skeleton loading indicator. Renders a stack of solid blocks whose widths\n * vary to mimic text placeholders. Pair with `async` data fetching.\n * @returns A `ReactElement` rendering the skeleton rows.\n */\nexport default function Placeholder({\n animated = true,\n character = \"█\",\n color = \"gray\",\n interval = 500,\n rows = 3,\n width,\n widths = DEFAULT_WIDTHS,\n}: Props): ReactElement {\n const { frame } = useAnimation({ interval, isActive: animated });\n const { columns } = useWindowSize();\n const dim = animated && frame % 2 === 0;\n // When `width` is omitted, fall back to the terminal width so the\n // skeleton actually fills its parent rather than truncating to 40 cells.\n const baseWidth = width ?? Math.max(1, columns);\n\n return (\n <Box flexDirection=\"column\" width={width}>\n {Array.from({ length: rows }, (_, index) => {\n // Guard against an explicitly empty `widths` prop — modulo\n // by zero would yield NaN and the nullish fallback would\n // still force every row to full width, hiding the bug.\n const ratio = widths.length > 0 ? widths[index % widths.length] ?? 1 : 1;\n const effectiveWidth = Math.max(1, Math.round(baseWidth * ratio));\n const fill = character.repeat(effectiveWidth);\n\n return (\n <Box key={index} width={effectiveWidth}>\n <Text color={color} dimColor={dim} wrap=\"truncate-end\">\n {fill}\n </Text>\n </Box>\n );\n })}\n </Box>\n );\n}\n\nexport { Placeholder };\nexport type { Props as PlaceholderProps };\n",
|
|
19
|
+
"path": "registry/ui/placeholder.tsx",
|
|
20
|
+
"target": "components/ui/placeholder.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "popover",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Popover",
|
|
6
|
+
"description": "Popover component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { useCallback } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type Props = {\n /**\n * Accent color for the content border.\n * @default \"blue\"\n */\n readonly accentColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * The trigger element the popover is anchored to.\n */\n readonly anchor: ReactNode;\n\n /**\n * The floating content, shown in a bordered box while open.\n */\n readonly children: ReactNode;\n\n /**\n * Whether the popover content is shown.\n */\n readonly isOpen: boolean;\n\n /**\n * Fires when Escape is pressed while open.\n */\n readonly onClose?: () => void;\n\n /**\n * Whether the content sits above or below the anchor.\n * @default \"bottom\"\n */\n readonly placement?: \"bottom\" | \"top\";\n};\n\n/**\n * Floating content anchored to a trigger element. The anchor always renders;\n * the bordered content appears above or below it while `isOpen`, and Escape\n * fires `onClose`. Terminals have no true overlay layer, so the content takes\n * layout space rather than floating over other content.\n */\nexport default function Popover({ accentColor = \"blue\", anchor, children, isOpen, onClose, placement = \"bottom\" }: Props): ReactElement {\n const inputHandler = useCallback(\n (_input: string, key: { escape: boolean }) => {\n if (key.escape) {\n onClose?.();\n }\n },\n [onClose],\n );\n\n useInput(inputHandler, { isActive: isOpen });\n\n const content = isOpen\n ? (\n <Box alignSelf=\"flex-start\" borderColor={accentColor} borderStyle=\"round\" paddingX={1}>\n {children}\n </Box>\n )\n : undefined;\n\n return (\n <Box flexDirection=\"column\">\n {placement === \"top\" ? content : undefined}\n {anchor}\n {placement === \"bottom\" ? content : undefined}\n </Box>\n );\n}\n\nexport { Popover };\nexport type { Props as PopoverProps };\n",
|
|
19
|
+
"path": "registry/ui/popover.tsx",
|
|
20
|
+
"target": "components/ui/popover.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "progress-bar",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Progress Bar",
|
|
6
|
+
"description": "Progress Bar component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/tui"
|
|
12
|
+
],
|
|
13
|
+
"registryDependencies": [],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"content": "/* eslint-disable react/function-component-definition */\n\n/**\n * Progress bar component for Ink.\n *\n * Based on ink-progress-bar by Frankie Bagnardi (brigand).\n * @see https://github.com/brigand/ink-progress-bar\n *\n * MIT License\n * Copyright (c) Frankie Bagnardi\n */\nimport type { Props as TextProps } from \"@visulima/tui/components/text\";\nimport Text from \"@visulima/tui/components/text\";\nimport useWindowSize from \"@visulima/tui/hooks/use-window-size\";\nimport type { ReactElement } from \"react\";\n\nexport type Props = {\n /** Fill glyph repeated across the completed portion of the progress bar. Defaults to \"█\". */\n readonly character?: string;\n\n /**\n * Override the terminal width used for calculation.\n * When 0, uses the actual terminal width.\n * @default 0\n */\n readonly columns?: number;\n\n /**\n * Number of columns reserved on the left side (e.g. for labels).\n * @default 0\n */\n readonly left?: number;\n\n /**\n * Completion percentage between 0 and 1.\n * @default 1\n */\n readonly percent?: number;\n\n /**\n * Number of columns reserved on the right side (e.g. for percentage text).\n * @default 0\n */\n readonly right?: number;\n\n /**\n * Whether to pad the remaining space with whitespace.\n * Useful when rendering the bar over a background color.\n * @default false\n */\n readonly rightPad?: boolean;\n} & Omit<TextProps, \"children\">;\n\n/**\n * A terminal progress bar that fills proportionally to a percentage value.\n *\n * ```tsx\n * <ProgressBar percent={0.5} />\n * <ProgressBar percent={0.75} color=\"green\" left={10} right={5} />\n * ```\n */\nexport default function ProgressBar({\n character = \"\\u2588\",\n columns: columnsProp = 0,\n left = 0,\n percent = 1,\n right = 0,\n rightPad = false,\n ...textProps\n}: Props): ReactElement {\n const { columns: terminalColumns } = useWindowSize();\n\n const screen = columnsProp || terminalColumns || 80;\n const space = Math.max(0, screen - right - left);\n const filled = Math.min(Math.floor(space * Math.max(0, Math.min(1, percent))), space);\n const bar = character.repeat(filled) + (rightPad ? \" \".repeat(space - filled) : \"\");\n\n return <Text {...textProps}>{bar}</Text>;\n}\n\nexport { ProgressBar };\nexport type { Props as ProgressBarProps };\n",
|
|
17
|
+
"path": "registry/ui/progress-bar.tsx",
|
|
18
|
+
"target": "components/ui/progress-bar.tsx",
|
|
19
|
+
"type": "registry:ui"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "progress-circle",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Progress Circle",
|
|
6
|
+
"description": "Progress Circle component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport type { CanvasContext } from \"@visulima/tui/canvas\";\nimport { createBrailleGrid } from \"@visulima/tui/canvas\";\nimport Box from \"@visulima/tui/components/box\";\nimport Canvas from \"@visulima/tui/components/canvas\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nexport type Props = {\n /**\n * Color of the filled portion of the ring.\n * @default \"cyan\"\n */\n readonly color?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Format the centered readout. Defaults to a rounded percentage.\n */\n readonly formatValue?: (ratio: number) => string;\n\n /**\n * Upper bound of the range.\n * @default 100\n */\n readonly max?: number;\n\n /**\n * Lower bound of the range.\n * @default 0\n */\n readonly min?: number;\n\n /**\n * Show the readout in the middle of the ring.\n * @default true\n */\n readonly showValue?: boolean;\n\n /**\n * Diameter of the ring in character rows.\n * @default 8\n */\n readonly size?: number;\n\n /**\n * Color of the unfilled track behind the ring.\n * @default \"gray\"\n */\n readonly trackColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Current value, clamped into `[min, max]`.\n */\n readonly value: number;\n};\n\nconst defaultFormat = (ratio: number): string => `${Math.round(ratio * 100)}%`;\n\n/**\n * A circular progress ring rendered at 2x4 braille sub-cell resolution. The\n * filled arc grows clockwise from the top in proportion to `value`, with an\n * optional centered readout.\n */\nexport default function ProgressCircle({\n color = \"cyan\",\n formatValue = defaultFormat,\n max = 100,\n min = 0,\n showValue = true,\n size = 8,\n trackColor = \"gray\",\n value,\n}: Props): ReactElement {\n const range = max - min || 1;\n const ratio = Math.max(0, Math.min(1, (value - min) / range));\n\n const rows = Math.max(3, size);\n const cols = Math.max(3, Math.round((rows * 4) / 2 / 2));\n\n return (\n <Box alignItems=\"center\" flexDirection=\"column\">\n <Canvas\n // eslint-disable-next-line react-perf/jsx-no-new-function-as-prop -- canvas re-renders on `version` change, not draw identity\n draw={(context: CanvasContext) => {\n context.clear();\n\n const pixelWidth = context.width * 2;\n const pixelHeight = context.height * 4;\n const cx = pixelWidth / 2;\n const cy = pixelHeight / 2;\n const radius = Math.max(1, Math.min(cx, cy) - 1);\n\n const trackGrid = createBrailleGrid(context.width, context.height);\n const fillGrid = createBrailleGrid(context.width, context.height);\n\n const steps = Math.max(64, Math.round(Math.PI * 2 * radius));\n const filledSteps = Math.round(ratio * steps);\n\n for (let step = 0; step < steps; step += 1) {\n // Clockwise from the top.\n const angle = (step / steps) * Math.PI * 2 - Math.PI / 2;\n const px = Math.round(cx + radius * Math.cos(angle));\n const py = Math.round(cy + radius * Math.sin(angle));\n\n (step < filledSteps ? fillGrid : trackGrid).plotPoint(px, py);\n }\n\n trackGrid.flush(context, { color: trackColor });\n fillGrid.flush(context, { color });\n }}\n height={rows}\n // eslint-disable-next-line react-perf/jsx-no-new-array-as-prop -- version array is the canvas redraw key\n version={[value, min, max, size, color, trackColor]}\n width={cols}\n />\n {showValue\n ? (\n <Box marginTop={-Math.ceil(rows / 2) - 1}>\n <Text bold color={color}>\n {formatValue(ratio)}\n </Text>\n </Box>\n )\n : undefined}\n </Box>\n );\n}\n\nexport { ProgressCircle };\nexport type { Props as ProgressCircleProps };\n",
|
|
19
|
+
"path": "registry/ui/progress-circle.tsx",
|
|
20
|
+
"target": "components/ui/progress-circle.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "qr-code",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Qr Code",
|
|
6
|
+
"description": "Qr Code component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/tui",
|
|
13
|
+
"type-fest"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport type { ReactElement } from \"react\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\ntype Color = LiteralUnion<AnsiColors, string>;\n\nexport type Props = {\n /**\n * Color of the \"dark\" modules.\n * @default \"black\"\n */\n readonly darkColor?: Color;\n\n /**\n * Color of the \"light\" modules and quiet zone.\n * @default \"white\"\n */\n readonly lightColor?: Color;\n\n /**\n * A pre-encoded square grid of modules (`true` = dark). Provide this to\n * render without the optional `qrcode` peer; it takes precedence over\n * `value`.\n */\n readonly matrix?: ReadonlyArray<ReadonlyArray<boolean>>;\n\n /**\n * Width of the light border around the code, in modules.\n * @default 2\n */\n readonly quietZone?: number;\n\n /**\n * A string to encode. Requires the optional `qrcode` peer dependency; when\n * absent, provide `matrix` instead.\n */\n readonly value?: string;\n};\n\nconst UPPER_HALF = \"▀\";\n\n/** Pad a module matrix with a light quiet zone on all sides. */\nconst withQuietZone = (matrix: ReadonlyArray<ReadonlyArray<boolean>>, zone: number): boolean[][] => {\n if (zone <= 0) {\n return matrix.map((row) => [...row]);\n }\n\n let maxWidth = 0;\n\n for (const row of matrix) {\n maxWidth = Math.max(maxWidth, row.length);\n }\n\n const width = maxWidth + zone * 2;\n const blankRow = (): boolean[] => {\n const row: boolean[] = [];\n\n for (let index = 0; index < width; index += 1) {\n row.push(false);\n }\n\n return row;\n };\n const out: boolean[][] = [];\n\n for (let index = 0; index < zone; index += 1) {\n out.push(blankRow());\n }\n\n for (const row of matrix) {\n const padded = blankRow();\n\n row.forEach((cell, index) => {\n padded[index + zone] = cell;\n });\n\n out.push(padded);\n }\n\n for (let index = 0; index < zone; index += 1) {\n out.push(blankRow());\n }\n\n return out;\n};\n\n/**\n * Render a QR code from a module matrix using the upper-half-block technique,\n * so two module rows share one character row. Pass `matrix` for a self-contained\n * render, or `value` to encode a string via the optional `qrcode` peer.\n */\nexport default function QrCode({ darkColor = \"black\", lightColor = \"white\", matrix, quietZone = 2, value }: Props): ReactElement {\n const [encoded, setEncoded] = useState<ReadonlyArray<ReadonlyArray<boolean>> | undefined>(undefined);\n\n useEffect(() => {\n if (matrix !== undefined || value === undefined) {\n return undefined;\n }\n\n let cancelled = false;\n\n const encode = async (): Promise<void> => {\n try {\n // Optional peer — only needed for the string-encoding path.\n const qrcode = (await import(\"qrcode\")) as unknown as {\n create: (text: string) => { modules: { data: Uint8Array | number[]; size: number } };\n };\n const { modules } = qrcode.create(value);\n const grid: boolean[][] = [];\n\n for (let y = 0; y < modules.size; y += 1) {\n const row: boolean[] = [];\n\n for (let x = 0; x < modules.size; x += 1) {\n row.push(Boolean(modules.data[y * modules.size + x]));\n }\n\n grid.push(row);\n }\n\n if (!cancelled) {\n setEncoded(grid);\n }\n } catch {\n // qrcode not installed — nothing to render from `value`.\n }\n };\n\n // eslint-disable-next-line react-you-might-not-need-an-effect/no-external-store-subscription -- lazily loads the optional `qrcode` peer, not a subscribable store\n encode().catch(() => {\n // Swallow: the string-encoding path is best-effort.\n });\n\n return () => {\n cancelled = true;\n };\n }, [matrix, value]);\n\n const source = matrix ?? encoded;\n const padded = useMemo(() => {\n if (source === undefined) {\n return undefined;\n }\n\n return withQuietZone(source, quietZone);\n }, [quietZone, source]);\n\n if (padded === undefined) {\n return (\n <Box>\n <Text dimColor>{value === undefined ? \"\" : \"Encoding…\"}</Text>\n </Box>\n );\n }\n\n const rows: ReactElement[] = [];\n\n for (let y = 0; y < padded.length; y += 2) {\n const top = padded[y] ?? [];\n const bottom = padded[y + 1] ?? [];\n const spans: ReactElement[] = [];\n\n for (const [x, element] of top.entries()) {\n spans.push(\n\n <Text backgroundColor={bottom[x] ? darkColor : lightColor} color={element ? darkColor : lightColor} key={x}>\n {UPPER_HALF}\n </Text>,\n );\n }\n\n rows.push(\n\n <Box key={y}>{spans}</Box>,\n );\n }\n\n return <Box flexDirection=\"column\">{rows}</Box>;\n}\n\nexport { QrCode };\nexport type { Props as QrCodeProps };\n",
|
|
19
|
+
"path": "registry/ui/qr-code.tsx",
|
|
20
|
+
"target": "components/ui/qr-code.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|