@visulima/tui-kit 0.0.0 → 1.0.1
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 +13 -0
- package/LICENSE.md +2095 -0
- package/README.md +115 -29
- package/dist/accordion.d.ts +52 -0
- package/dist/accordion.js +1 -0
- package/dist/alert.d.ts +22 -0
- package/dist/alert.js +1 -0
- package/dist/animate-presence.d.ts +16 -0
- package/dist/animate-presence.js +1 -0
- package/dist/approval-prompt.d.ts +55 -0
- package/dist/approval-prompt.js +1 -0
- package/dist/area-chart.d.ts +56 -0
- package/dist/area-chart.js +1 -0
- package/dist/badge.d.ts +19 -0
- package/dist/badge.js +1 -0
- package/dist/bar-chart.d.ts +58 -0
- package/dist/bar-chart.js +1 -0
- package/dist/big-text.d.ts +71 -0
- package/dist/big-text.js +1 -0
- package/dist/blink-dot.d.ts +31 -0
- package/dist/blink-dot.js +1 -0
- package/dist/breadcrumb.d.ts +29 -0
- package/dist/breadcrumb.js +1 -0
- package/dist/button.d.ts +38 -0
- package/dist/button.js +1 -0
- package/dist/calendar.d.ts +60 -0
- package/dist/calendar.js +1 -0
- package/dist/card.d.ts +47 -0
- package/dist/card.js +1 -0
- package/dist/checkbox-group.d.ts +50 -0
- package/dist/checkbox-group.js +1 -0
- package/dist/checkbox.d.ts +40 -0
- package/dist/checkbox.js +1 -0
- package/dist/code.d.ts +41 -0
- package/dist/code.js +2 -0
- package/dist/collapsible.d.ts +44 -0
- package/dist/collapsible.js +1 -0
- package/dist/color-picker.d.ts +59 -0
- package/dist/color-picker.js +1 -0
- package/dist/command-block.d.ts +39 -0
- package/dist/command-block.js +4 -0
- package/dist/command-palette.d.ts +73 -0
- package/dist/command-palette.js +1 -0
- package/dist/confirm-dialog.d.ts +63 -0
- package/dist/confirm-dialog.js +1 -0
- package/dist/confirm-input.d.ts +32 -0
- package/dist/confirm-input.js +1 -0
- package/dist/console-overlay.d.ts +47 -0
- package/dist/console-overlay.js +1 -0
- package/dist/content-switcher.d.ts +49 -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 +43 -0
- package/dist/data-grid.js +1 -0
- package/dist/date-picker.d.ts +54 -0
- package/dist/date-picker.js +1 -0
- package/dist/definition-list.d.ts +38 -0
- package/dist/definition-list.js +1 -0
- package/dist/dialog.d.ts +94 -0
- package/dist/dialog.js +1 -0
- package/dist/diff-view.d.ts +72 -0
- package/dist/diff-view.js +1 -0
- package/dist/digits.d.ts +26 -0
- package/dist/digits.js +1 -0
- package/dist/directory-tree.d.ts +44 -0
- package/dist/directory-tree.js +1 -0
- package/dist/divider.d.ts +41 -0
- package/dist/divider.js +1 -0
- package/dist/drawer.d.ts +45 -0
- package/dist/drawer.js +1 -0
- package/dist/file-change.d.ts +34 -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 +74 -0
- package/dist/form.js +1 -0
- package/dist/gauge.d.ts +76 -0
- package/dist/gauge.js +1 -0
- package/dist/gradient.d.ts +38 -0
- package/dist/gradient.js +1 -0
- package/dist/grid.d.ts +34 -0
- package/dist/grid.js +1 -0
- package/dist/heading.d.ts +28 -0
- package/dist/heading.js +1 -0
- package/dist/heatmap.d.ts +52 -0
- package/dist/heatmap.js +1 -0
- package/dist/help.d.ts +45 -0
- package/dist/help.js +1 -0
- package/dist/histogram.d.ts +52 -0
- package/dist/histogram.js +1 -0
- package/dist/image.d.ts +23 -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 +51 -0
- package/dist/json.js +1 -0
- package/dist/kbd.d.ts +27 -0
- package/dist/kbd.js +1 -0
- package/dist/line-chart.d.ts +100 -0
- package/dist/line-chart.js +1 -0
- package/dist/link.d.ts +34 -0
- package/dist/link.js +1 -0
- package/dist/loading-indicator.d.ts +25 -0
- package/dist/loading-indicator.js +1 -0
- package/dist/log.d.ts +33 -0
- package/dist/log.js +1 -0
- package/dist/markdown.d.ts +35 -0
- package/dist/markdown.js +1 -0
- package/dist/masked-input.d.ts +42 -0
- package/dist/masked-input.js +1 -0
- package/dist/menu.d.ts +70 -0
- package/dist/menu.js +1 -0
- package/dist/message-bubble.d.ts +36 -0
- package/dist/message-bubble.js +1 -0
- package/dist/model-badge.d.ts +33 -0
- package/dist/model-badge.js +1 -0
- package/dist/model-selector.d.ts +45 -0
- package/dist/model-selector.js +1 -0
- package/dist/multi-progress.d.ts +48 -0
- package/dist/multi-progress.js +1 -0
- package/dist/multi-select.d.ts +55 -0
- package/dist/multi-select.js +1 -0
- package/dist/notification-badge.d.ts +40 -0
- package/dist/notification-badge.js +1 -0
- package/dist/number-input.d.ts +71 -0
- package/dist/number-input.js +1 -0
- package/dist/operation-tree.d.ts +46 -0
- package/dist/operation-tree.js +1 -0
- package/dist/option-list.d.ts +41 -0
- package/dist/option-list.js +1 -0
- package/dist/ordered-list.d.ts +22 -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-D3kZZzjI.d.ts +43 -0
- package/dist/packem_shared/file-picker.d-CH7pccC7.d.ts +92 -0
- package/dist/packem_shared/line-chart-BbhMhcZg.js +1 -0
- package/dist/packem_shared/scroll-bar-box.d-CpTYEcTZ.d.ts +13 -0
- package/dist/packem_shared/scroll-bar.d-B4sD1hBR.d.ts +18 -0
- package/dist/packem_shared/scroll-list.d-CPCF0RwC.d.ts +12 -0
- package/dist/packem_shared/scroll-view.d-GZcgAHl4.d.ts +98 -0
- package/dist/packem_shared/treeViewTheme-Dq3ekRAC.js +1 -0
- package/dist/packem_shared/use-tree-view.d-DeMQ6G2r.d.ts +314 -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 +59 -0
- package/dist/paginator.js +1 -0
- package/dist/paragraph.d.ts +32 -0
- package/dist/paragraph.js +1 -0
- package/dist/path-input.d.ts +56 -0
- package/dist/path-input.js +1 -0
- package/dist/pie-chart.d.ts +44 -0
- package/dist/pie-chart.js +1 -0
- package/dist/placeholder.d.ts +47 -0
- package/dist/placeholder.js +1 -0
- package/dist/popover.d.ts +39 -0
- package/dist/popover.js +1 -0
- package/dist/progress-bar.d.ts +43 -0
- package/dist/progress-bar.js +1 -0
- package/dist/progress-circle.d.ts +50 -0
- package/dist/progress-circle.js +1 -0
- package/dist/qr-code.d.ts +39 -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 +63 -0
- package/dist/radio-group.js +1 -0
- package/dist/scatter-plot.d.ts +24 -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 +46 -0
- package/dist/search-input.js +1 -0
- package/dist/select-input-indicator.d.ts +28 -0
- package/dist/select-input-indicator.js +1 -0
- package/dist/select-input-item.d.ts +35 -0
- package/dist/select-input-item.js +1 -0
- package/dist/select-input.d.ts +97 -0
- package/dist/select-input.js +1 -0
- package/dist/shimmer-text.d.ts +41 -0
- package/dist/shimmer-text.js +1 -0
- package/dist/sidebar.d.ts +40 -0
- package/dist/sidebar.js +1 -0
- package/dist/slider.d.ts +87 -0
- package/dist/slider.js +1 -0
- package/dist/sparkline.d.ts +36 -0
- package/dist/sparkline.js +1 -0
- package/dist/spinner.d.ts +15 -0
- package/dist/spinner.js +1 -0
- package/dist/status-line.d.ts +31 -0
- package/dist/status-line.js +1 -0
- package/dist/status-message.d.ts +18 -0
- package/dist/status-message.js +1 -0
- package/dist/stepper.d.ts +42 -0
- package/dist/stepper.js +1 -0
- package/dist/stopwatch.d.ts +42 -0
- package/dist/stopwatch.js +1 -0
- package/dist/streaming-text.d.ts +39 -0
- package/dist/streaming-text.js +1 -0
- package/dist/switch.d.ts +46 -0
- package/dist/switch.js +1 -0
- package/dist/tab.d.ts +17 -0
- package/dist/tab.js +1 -0
- package/dist/table.d.ts +118 -0
- package/dist/table.js +1 -0
- package/dist/tabs.d.ts +96 -0
- package/dist/tabs.js +1 -0
- package/dist/tag-input.d.ts +59 -0
- package/dist/tag-input.js +1 -0
- package/dist/tag.d.ts +30 -0
- package/dist/tag.js +1 -0
- package/dist/text-input.d.ts +40 -0
- package/dist/text-input.js +1 -0
- package/dist/textarea.d.ts +57 -0
- package/dist/textarea.js +2 -0
- package/dist/thinking-block.d.ts +48 -0
- package/dist/thinking-block.js +1 -0
- package/dist/time-picker.d.ts +57 -0
- package/dist/time-picker.js +1 -0
- package/dist/timer.d.ts +50 -0
- package/dist/timer.js +1 -0
- package/dist/toast.d.ts +41 -0
- package/dist/toast.js +1 -0
- package/dist/token-usage.d.ts +43 -0
- package/dist/token-usage.js +1 -0
- package/dist/tool-call.d.ts +28 -0
- package/dist/tool-call.js +1 -0
- package/dist/tooltip.d.ts +37 -0
- package/dist/tooltip.js +1 -0
- package/dist/transition.d.ts +66 -0
- package/dist/transition.js +1 -0
- package/dist/tree-select.d.ts +55 -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 +27 -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": "gauge",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Gauge",
|
|
6
|
+
"description": "Gauge 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 GaugeThreshold = {\n /**\n * Color used for the arc segment and, when the current value falls into\n * this segment, for the value readout.\n */\n readonly color: LiteralUnion<AnsiColors, string>;\n\n /**\n * Optional label used by the legend under the gauge.\n */\n readonly label?: string;\n\n /**\n * Upper bound of this threshold segment (inclusive). Segments are sorted\n * ascending and painted in order.\n */\n readonly max: number;\n};\n\nexport type GaugeSize = \"large\" | \"medium\" | \"small\";\n\nexport type Props = {\n /**\n * Color of the arc background (the portion beyond `value`).\n * @default \"gray\"\n */\n readonly backgroundColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Format for the numeric readout. Defaults to rounding to an integer.\n */\n readonly formatValue?: (value: number) => string;\n\n /**\n * Optional label rendered above the readout.\n */\n readonly label?: 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 a legend listing thresholds below the gauge.\n * @default false\n */\n readonly showLegend?: boolean;\n\n /**\n * Show a numeric readout in the middle of the gauge.\n * @default true\n */\n readonly showValue?: boolean;\n\n /**\n * Overall dimension preset.\n * @default \"medium\"\n */\n readonly size?: GaugeSize;\n\n /**\n * Color threshold ramp. When provided, segments of the arc are painted in\n * the corresponding colors and the value readout picks up the color for\n * the segment `value` lands in.\n */\n readonly thresholds?: ReadonlyArray<GaugeThreshold>;\n\n /**\n * Current value. Clamped into the `[min, max]` range.\n */\n readonly value: number;\n};\n\ntype Size = { readonly cols: number; readonly rows: number };\n\nconst SIZES: Record<GaugeSize, Size> = {\n large: { cols: 40, rows: 10 },\n medium: { cols: 24, rows: 6 },\n small: { cols: 16, rows: 4 },\n};\n\nconst NEEDLE_RATIO = 0.9;\nconst ARC_ANGLE_STEPS_PER_PIXEL = 2;\n\nconst defaultFormat = (value: number): string => String(Math.round(value));\n\n/**\n * Find the threshold segment that `ratio` lands in. `ratio` is in [0, 1] and\n * maps onto the [min, max] range.\n */\nconst thresholdAtRatio = (thresholds: ReadonlyArray<GaugeThreshold>, min: number, max: number, ratio: number): GaugeThreshold | undefined => {\n const value = min + (max - min) * ratio;\n\n for (const threshold of thresholds) {\n if (value <= threshold.max) {\n return threshold;\n }\n }\n\n return thresholds[thresholds.length - 1];\n};\n\n/**\n * Semi-circular gauge rendered at 2x4 sub-cell resolution. Supports colored\n * threshold segments and an optional needle.\n */\nexport default function Gauge({\n backgroundColor = \"gray\",\n formatValue = defaultFormat,\n label,\n max = 100,\n min = 0,\n showLegend = false,\n showValue = true,\n size = \"medium\",\n thresholds,\n value,\n}: Props): ReactElement {\n const { cols, rows } = SIZES[size];\n const clampedValue = Math.max(min, Math.min(max, value));\n const range = max - min || 1;\n const ratio = (clampedValue - min) / range;\n\n // Sort once per threshold change; reused by both the active-threshold\n // lookup and the draw loop below.\n const sortedThresholds = useMemo(() => {\n if (thresholds === undefined || thresholds.length === 0) {\n return undefined;\n }\n\n return [...thresholds].toSorted((a, b) => a.max - b.max);\n }, [thresholds]);\n\n const activeThreshold = useMemo(() => {\n if (sortedThresholds === undefined) {\n return undefined;\n }\n\n return thresholdAtRatio(sortedThresholds, min, max, ratio);\n }, [sortedThresholds, min, max, ratio]);\n\n const readout = formatValue(clampedValue);\n\n return (\n <Box 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 // Center at bottom middle of the grid.\n const cx = pixelWidth / 2;\n const cy = pixelHeight - 1;\n // Leave 1 pixel of padding around the arc.\n const rx = pixelWidth / 2 - 1;\n const ry = pixelHeight - 1;\n const radius = Math.max(1, Math.min(rx, ry));\n\n // One braille grid per color so different segments keep\n // their colors cleanly (each flush paints its own color).\n const gridsByColor = new Map<string, ReturnType<typeof createBrailleGrid>>();\n\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 const arcSteps = Math.max(32, Math.round(Math.PI * radius) * ARC_ANGLE_STEPS_PER_PIXEL);\n\n for (let step = 0; step <= arcSteps; step += 1) {\n // θ from π (left) to 0 (right)\n const stepRatio = step / arcSteps;\n const angle = Math.PI * (1 - stepRatio);\n const px = Math.round(cx + radius * Math.cos(angle));\n const py = Math.round(cy - radius * Math.sin(angle));\n\n let color: string;\n\n if (stepRatio > ratio) {\n color = backgroundColor;\n } else if (sortedThresholds) {\n const segment = thresholdAtRatio(sortedThresholds, min, max, stepRatio);\n\n color = segment?.color ?? \"cyan\";\n } else {\n color = \"cyan\";\n }\n\n const grid = gridFor(color);\n\n grid.plotPoint(px, py);\n }\n\n // Needle: from center to current position at NEEDLE_RATIO of the radius.\n const needleRadius = radius * NEEDLE_RATIO;\n const needleAngle = Math.PI * (1 - ratio);\n const nx = Math.round(cx + needleRadius * Math.cos(needleAngle));\n const ny = Math.round(cy - needleRadius * Math.sin(needleAngle));\n const needleColor = (activeThreshold?.color ?? \"white\") as string;\n const needleGrid = gridFor(needleColor);\n\n needleGrid.plotLine(Math.round(cx), Math.round(cy), nx, ny);\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={[value, min, max, size, thresholds, backgroundColor]}\n width={cols}\n />\n {/* eslint-disable-next-line @stylistic/multiline-ternary -- prettier formats JSX ternaries on one line */}\n {showValue || label ? (\n <Box flexDirection=\"column\">\n {/* eslint-disable-next-line @stylistic/multiline-ternary -- prettier formats JSX ternaries on one line */}\n {label === undefined ? undefined : (\n <Box justifyContent=\"center\">\n <Text dimColor>{label}</Text>\n </Box>\n )}\n {/* eslint-disable-next-line @stylistic/multiline-ternary -- prettier formats JSX ternaries on one line */}\n {showValue ? (\n <Box justifyContent=\"center\">\n <Text bold color={activeThreshold?.color ?? \"white\"}>\n {readout}\n </Text>\n </Box>\n ) : undefined}\n </Box>\n ) : undefined}\n {/* eslint-disable-next-line @stylistic/multiline-ternary -- prettier formats JSX ternaries on one line */}\n {showLegend && thresholds && thresholds.length > 0 ? (\n <Box gap={2} justifyContent=\"center\" marginTop={1}>\n {thresholds.map((threshold, index) => (\n // eslint-disable-next-line react-x/no-array-index-key -- threshold index is stable for the render\n <Box gap={1} key={index}>\n <Text color={threshold.color}>●</Text>\n <Text dimColor>{threshold.label ?? `≤ ${threshold.max}`}</Text>\n </Box>\n ))}\n </Box>\n ) : undefined}\n </Box>\n );\n}\n\nexport { Gauge };\nexport type { Props as GaugeProps };\n",
|
|
19
|
+
"path": "registry/ui/gauge.tsx",
|
|
20
|
+
"target": "components/ui/gauge.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": "gradient",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Gradient",
|
|
6
|
+
"description": "Gradient component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/ansi",
|
|
12
|
+
"@visulima/colorize",
|
|
13
|
+
"@visulima/tui"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"content": "/* eslint-disable @stylistic/no-extra-parens, react/function-component-definition */\nimport { strip } from \"@visulima/ansi\";\nimport { multilineGradient } from \"@visulima/colorize/gradient\";\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport Transform from \"@visulima/tui/components/transform\";\nimport type { Key, ReactElement, ReactNode } from \"react\";\nimport { Children, cloneElement, isValidElement, useMemo } from \"react\";\n\n/**\n * A color stop for the gradient — hex string, CSS color name, RGB tuple, RGB object, or positioned stop.\n */\ntype GradientStop = Parameters<typeof multilineGradient>[0][number];\n\n/**\n * Built-in gradient presets.\n */\ntype GradientName = \"atlas\" | \"cristal\" | \"fruit\" | \"instagram\" | \"mind\" | \"morning\" | \"pastel\" | \"passion\" | \"rainbow\" | \"retro\" | \"summer\" | \"teen\" | \"vice\";\n\n/**\n * Custom gradient colors — an array of color stops accepted by `@visulima/colorize`'s gradient API.\n */\ntype GradientColors = GradientStop[];\n\nconst presets: Record<GradientName, { colors: GradientStop[]; options?: { hsvSpin?: \"long\" | \"short\"; interpolation?: \"hsv\" | \"rgb\" } }> = {\n atlas: { colors: [\"#feac5e\", \"#c779d0\", \"#4bc0c8\"] },\n cristal: { colors: [\"#bdfff3\", \"#4ac29a\"] },\n fruit: { colors: [\"#ff4e50\", \"#f9d423\"] },\n instagram: { colors: [\"#833ab4\", \"#fd1d1d\", \"#fcb045\"] },\n mind: { colors: [\"#473b7b\", \"#3584a7\", \"#30d2be\"] },\n morning: { colors: [\"#ff5f6d\", \"#ffc371\"], options: { interpolation: \"hsv\" } },\n passion: { colors: [\"#f43b47\", \"#453a94\"] },\n pastel: { colors: [\"#74ebd5\", \"#74ecd5\"], options: { hsvSpin: \"long\", interpolation: \"hsv\" } },\n rainbow: { colors: [\"#ff0000\", \"#ff0100\"], options: { hsvSpin: \"long\", interpolation: \"hsv\" } },\n retro: { colors: [\"#3f51b1\", \"#5a55ae\", \"#7b5fac\", \"#8f6aae\", \"#a86aa4\", \"#cc6b8e\", \"#f18271\", \"#f3a469\", \"#f7c978\"] },\n summer: { colors: [\"#fdbb2d\", \"#22c1c3\"] },\n teen: { colors: [\"#77a1d3\", \"#79cbca\", \"#e684ae\"] },\n vice: { colors: [\"#5ee7df\", \"#b490ca\"], options: { interpolation: \"hsv\" } },\n};\n\nexport type Props = {\n /**\n * The content to colorize.\n *\n * Multiple `<Text>` children are treated as separate nodes, which preserves layout when `<Gradient>` is placed inside a `<Box flexDirection=\"column\">`.\n *\n * If you want a continuous gradient across multiple lines, pass a single string or a single `<Text>` with `\\n`.\n */\n readonly children: ReactNode;\n\n /**\n * [Colors to use to make the gradient.](https://visulima.com/packages/colorize)\n *\n * Mutually exclusive with `name`.\n */\n readonly colors?: GradientColors;\n\n /**\n * The name of a built-in gradient preset.\n *\n * Mutually exclusive with `colors`.\n */\n readonly name?: GradientName;\n};\n\n/**\n * Apply a terminal gradient to child text content using `@visulima/colorize`.\n * @example\n * ```tsx\n * import { Gradient, Text } from \"@visulima/tui/ink\";\n *\n * <Gradient name=\"rainbow\">\n * <Text>Hello, World!</Text>\n * </Gradient>\n * ```\n */\nconst containsBoxDescendant = (nodeChildren: ReactNode): boolean => {\n let hasBox = false;\n\n const search = (value: ReactNode) => {\n // eslint-disable-next-line react-x/no-children-for-each\n Children.forEach(value, (child) => {\n if (hasBox) {\n return;\n }\n\n if (!isValidElement(child)) {\n return;\n }\n\n if (child.type === Box) {\n hasBox = true;\n\n return;\n }\n\n const childProps = child.props as Record<string, unknown>;\n\n if (Object.hasOwn(childProps, \"children\")) {\n search(childProps[\"children\"] as ReactNode);\n }\n });\n };\n\n search(nodeChildren);\n\n return hasBox;\n};\n\nconst hasChildrenProp = (properties: Record<string, unknown>) => Object.hasOwn(properties, \"children\");\nconst isPlainTextNode = (node: ReactNode): node is number | string => typeof node === \"string\" || typeof node === \"number\";\nconst isNonRenderableChild = (node: ReactNode) => node === null || node === undefined || typeof node === \"boolean\";\n\nexport default function Gradient({ children, colors, name }: Props): ReactElement | null {\n if (name && colors) {\n throw new Error(\"The `name` and `colors` props are mutually exclusive\");\n }\n\n if (!name && !colors) {\n throw new Error(\"Either `name` or `colors` prop must be provided\");\n }\n\n const gradientFunction = useMemo(\n () => (name ? multilineGradient(presets[name].colors, presets[name].options) : multilineGradient(colors ?? [])),\n [name, colors],\n );\n const applyGradient = (text: string) => gradientFunction(strip(text));\n // eslint-disable-next-line react-x/no-children-count\n const childrenCount = Children.count(children);\n\n if (isPlainTextNode(children)) {\n return <Transform transform={applyGradient}>{children}</Transform>;\n }\n\n if (childrenCount === 1 && !containsBoxDescendant(children)) {\n return <Transform transform={applyGradient}>{children}</Transform>;\n }\n\n const applyGradientToChildren = (nodeChildren: ReactNode): ReactNode => {\n const nodes: ReactNode[] = [];\n let bufferedText = \"\";\n let nodeIndex = 0;\n\n const createKey = () => {\n const key = `gradient-node-${String(nodeIndex)}`;\n\n nodeIndex += 1;\n\n return key;\n };\n\n const pushTransformed = (node: ReactNode, key: Key) => {\n nodes.push(\n <Transform key={key} transform={applyGradient}>\n {node}\n </Transform>,\n );\n };\n\n const flushText = () => {\n if (bufferedText === \"\") {\n return;\n }\n\n const text = bufferedText;\n\n bufferedText = \"\";\n pushTransformed(<Text>{text}</Text>, createKey());\n };\n\n // eslint-disable-next-line react-x/no-children-for-each\n Children.forEach(nodeChildren, (child) => {\n if (isNonRenderableChild(child)) {\n return;\n }\n\n if (isPlainTextNode(child)) {\n bufferedText += String(child);\n\n return;\n }\n\n flushText();\n\n if (isValidElement(child)) {\n const childKey = child.key ?? createKey();\n const childProps = child.props as Record<string, unknown>;\n\n if (child.type === Text) {\n pushTransformed(child, childKey);\n\n return;\n }\n\n if (child.type === Box) {\n if (hasChildrenProp(childProps)) {\n const childChildren = childProps[\"children\"] as ReactNode;\n\n // eslint-disable-next-line react-x/no-clone-element\n nodes.push(cloneElement(child, { key: childKey }, applyGradientToChildren(childChildren)));\n\n return;\n }\n\n // eslint-disable-next-line react-x/no-clone-element\n nodes.push(cloneElement(child, { key: childKey }));\n\n return;\n }\n\n if (hasChildrenProp(childProps)) {\n const childChildren = childProps[\"children\"] as ReactNode;\n\n if (!containsBoxDescendant(childChildren)) {\n pushTransformed(child, childKey);\n\n return;\n }\n\n // eslint-disable-next-line react-x/no-clone-element\n nodes.push(cloneElement(child, { key: childKey }, applyGradientToChildren(childChildren)));\n\n return;\n }\n\n pushTransformed(child, childKey);\n\n return;\n }\n\n nodes.push(child);\n });\n\n flushText();\n\n return nodes;\n };\n\n return <>{applyGradientToChildren(children)}</>;\n}\n\nexport type { GradientColors, GradientName };\n\nexport { Gradient };\nexport type { Props as GradientProps };\n",
|
|
19
|
+
"path": "registry/ui/gradient.tsx",
|
|
20
|
+
"target": "components/ui/gradient.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
package/dist/r/grid.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "grid",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Grid",
|
|
6
|
+
"description": "Grid 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 */\nimport Box from \"@visulima/tui/components/box\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { Children } from \"react\";\n\nexport type Props = {\n /**\n * The grid cells, filled left-to-right, top-to-bottom.\n */\n readonly children: ReactNode;\n\n /**\n * Horizontal gap between columns, in cells.\n * @default 1\n */\n readonly columnGap?: number;\n\n /**\n * Number of columns. Children wrap into as many rows as needed.\n * @default 2\n */\n readonly columns?: number;\n\n /**\n * Fixed width for every column, in cells. Omit to let columns size to\n * their content.\n */\n readonly columnWidth?: number;\n\n /**\n * Vertical gap between rows, in cells.\n * @default 0\n */\n readonly rowGap?: number;\n};\n\n/**\n * A simple 2D grid: children flow left-to-right into a fixed number of columns,\n * wrapping to new rows. Column and row gaps are configurable, and columns can\n * be given a fixed width for uniform cells.\n */\nexport default function Grid({ children, columnGap = 1, columns = 2, columnWidth, rowGap = 0 }: Props): ReactElement {\n // Children.toArray is the right tool here: it flattens fragments and\n // assigns stable keys before we slice the flat list into grid rows.\n // eslint-disable-next-line react-x/no-children-to-array -- intentional flatten for 2D layout\n const cells = Children.toArray(children);\n const rows: ReactNode[][] = [];\n // Guard against columns <= 0, which would never advance the loop.\n const columnCount = Math.max(1, columns);\n\n for (let index = 0; index < cells.length; index += columnCount) {\n rows.push(cells.slice(index, index + columnCount));\n }\n\n return (\n <Box flexDirection=\"column\" gap={rowGap}>\n {rows.map((row, rowIndex) => (\n // eslint-disable-next-line react-x/no-array-index-key -- row index is stable for the render\n <Box gap={columnGap} key={rowIndex}>\n {row.map((cell, columnIndex) => (\n // eslint-disable-next-line react-x/no-array-index-key -- cell index is stable for the render\n <Box flexShrink={0} key={columnIndex} width={columnWidth}>\n {cell}\n </Box>\n ))}\n </Box>\n ))}\n </Box>\n );\n}\n\nexport { Grid };\nexport type { Props as GridProps };\n",
|
|
17
|
+
"path": "registry/ui/grid.tsx",
|
|
18
|
+
"target": "components/ui/grid.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": "heading",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Heading",
|
|
6
|
+
"description": "Heading 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 HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;\n\nexport type Props = {\n /**\n * Heading content.\n */\n readonly children: ReactNode;\n\n /**\n * Explicit color. By default the color is derived from the level.\n */\n readonly color?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Heading level controls decoration and color.\n * @default 1\n */\n readonly level?: HeadingLevel;\n\n /**\n * When true, renders an underline on a second line.\n */\n readonly underline?: boolean;\n};\n\nconst LEVEL_COLOR: Record<HeadingLevel, string> = {\n 1: \"blueBright\",\n 2: \"cyanBright\",\n 3: \"magentaBright\",\n 4: \"yellowBright\",\n 5: \"green\",\n 6: \"white\",\n};\n\n// Markdown convention: H1 = \"# \", H2 = \"## \", … H6 = \"###### \".\nconst LEVEL_PREFIX: Record<HeadingLevel, string> = {\n 1: \"# \",\n 2: \"## \",\n 3: \"### \",\n 4: \"#### \",\n 5: \"##### \",\n 6: \"###### \",\n};\n\n/**\n * Semantic heading with level-based styling.\n */\nexport default function Heading({ children, color, level = 1, underline = false }: Props): ReactElement {\n const resolvedColor = color ?? LEVEL_COLOR[level];\n\n return (\n <Box flexDirection=\"column\">\n <Text bold={level <= 2} color={resolvedColor} underline={underline}>\n {LEVEL_PREFIX[level]}\n {children}\n </Text>\n </Box>\n );\n}\n\nexport { Heading };\nexport type { Props as HeadingProps };\n",
|
|
19
|
+
"path": "registry/ui/heading.tsx",
|
|
20
|
+
"target": "components/ui/heading.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": "heatmap",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Heatmap",
|
|
6
|
+
"description": "Heatmap 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 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 HeatmapProps = {\n /**\n * Width of each cell in character columns.\n * @default 2\n */\n readonly cellWidth?: number;\n\n /**\n * Glyph used to paint each heatmap cell. Defaults to `█` so the\n * background color fills the cell.\n * @default \"█\" (full block)\n */\n readonly character?: string;\n\n /**\n * Column labels rendered below the grid.\n */\n readonly columnLabels?: ReadonlyArray<string>;\n\n /**\n * 2D matrix, rows × cols.\n */\n readonly data: ReadonlyArray<ReadonlyArray<number>>;\n\n /**\n * Fixed maximum (for consistent scales across multiple heatmaps).\n */\n readonly max?: number;\n\n /**\n * Fixed minimum.\n */\n readonly min?: number;\n\n /**\n * Color palette, lowest-to-highest intensity. Values are mapped into the\n * palette by their position in the [min, max] range.\n * @default [\"#0b1e4f\", \"#1f3b96\", \"#3258c8\", \"#4e84ec\", \"#78b0ff\"]\n */\n readonly palette?: ReadonlyArray<string>;\n\n /**\n * Row labels rendered to the left of the grid.\n */\n readonly rowLabels?: ReadonlyArray<string>;\n\n /**\n * Foreground color override (rarely needed since the character is a full block).\n */\n readonly textColor?: LiteralUnion<AnsiColors, string>;\n};\n\nconst DEFAULT_PALETTE: ReadonlyArray<string> = [\"#0b1e4f\", \"#1f3b96\", \"#3258c8\", \"#4e84ec\", \"#78b0ff\"];\n\n/**\n * 2D heatmap. Each cell's background color is derived from its value's\n * position in the [min, max] range mapped into `palette`.\n */\nexport default function Heatmap({\n cellWidth = 2,\n character = \"█\",\n columnLabels,\n data,\n max: maxOverride,\n min: minOverride,\n palette = DEFAULT_PALETTE,\n rowLabels,\n textColor,\n}: HeatmapProps): ReactElement {\n const { cols, colWidth, extent, rows } = useMemo(() => {\n const rowCount = data.length;\n // eslint-disable-next-line unicorn/no-array-reduce -- finding max row length is naturally a reduce\n const colCount = data.reduce((widest, row) => Math.max(widest, row.length), 0);\n\n let min = minOverride ?? Infinity;\n let max = maxOverride ?? -Infinity;\n\n if (minOverride === undefined || maxOverride === undefined) {\n for (const row of data) {\n for (const value of row) {\n if (minOverride === undefined && value < min) {\n min = value;\n }\n\n if (maxOverride === undefined && value > max) {\n max = value;\n }\n }\n }\n }\n\n return {\n cols: colCount,\n colWidth: cellWidth,\n extent: { max, min },\n rows: rowCount,\n };\n }, [data, minOverride, maxOverride, cellWidth]);\n\n const width = cols * colWidth;\n const height = rows;\n\n return (\n <Box>\n {/* eslint-disable-next-line @stylistic/multiline-ternary -- prettier formats JSX ternaries on one line */}\n {rowLabels === undefined ? undefined : (\n <Box flexDirection=\"column\" marginRight={1}>\n {Array.from({ length: rows }, (_, index) => (\n <Text dimColor key={index}>\n {rowLabels[index] ?? \"\"}\n </Text>\n ))}\n </Box>\n )}\n <Box 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 { max, min } = extent;\n const range = max - min || 1;\n const paletteLast = palette.length - 1;\n\n for (let row = 0; row < rows; row += 1) {\n for (let col = 0; col < cols; col += 1) {\n const value = data[row]?.[col];\n // eslint-disable-next-line @stylistic/operator-linebreak -- prettier places `=` at end of line\n const background =\n value === undefined\n ? undefined\n : palette[Math.max(0, Math.min(paletteLast, Math.round(((value - min) / range) * paletteLast)))];\n\n for (let offset = 0; offset < colWidth; offset += 1) {\n context.setCell(col * colWidth + offset, row, character, {\n background,\n color: textColor,\n });\n }\n }\n }\n }}\n height={height}\n // eslint-disable-next-line react-perf/jsx-no-new-array-as-prop -- version array is the canvas redraw key\n version={[data, palette, cellWidth, minOverride, maxOverride]}\n width={width}\n />\n {/* eslint-disable-next-line @stylistic/multiline-ternary -- prettier formats JSX ternaries on one line */}\n {columnLabels === undefined ? undefined : (\n <Box>\n {columnLabels.map((label, index) => (\n // eslint-disable-next-line react-x/no-array-index-key -- column index is stable for the render\n <Box key={index} width={colWidth}>\n <Text dimColor wrap=\"truncate-end\">\n {label}\n </Text>\n </Box>\n ))}\n </Box>\n )}\n </Box>\n </Box>\n );\n}\n\nexport { Heatmap };\n",
|
|
19
|
+
"path": "registry/ui/heatmap.tsx",
|
|
20
|
+
"target": "components/ui/heatmap.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
package/dist/r/help.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "help",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Help",
|
|
6
|
+
"description": "Help 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 type { KeyBinding } from \"@visulima/tui/hooks/use-key-bindings\";\nimport useWindowSize from \"@visulima/tui/hooks/use-window-size\";\nimport type { ReactElement, ReactNode } from \"react\";\n\n/**\n * Human-readable labels for special key names.\n */\nconst keyLabels: Record<string, string> = {\n backspace: \"backspace\",\n delete: \"del\",\n downArrow: \"\\u2193\",\n end: \"end\",\n escape: \"esc\",\n home: \"home\",\n leftArrow: \"\\u2190\",\n pageDown: \"pgdn\",\n pageUp: \"pgup\",\n return: \"enter\",\n rightArrow: \"\\u2192\",\n tab: \"tab\",\n upArrow: \"\\u2191\",\n};\n\n/**\n * Format a key spec string into a human-readable label.\n * `\"ctrl+c\"` -> `\"ctrl+c\"`, `\"upArrow\"` -> `\"\\u2191\"`, `\"q\"` -> `\"q\"`\n */\nfunction formatKeyLabel(spec: string): string {\n const plusIndex = spec.indexOf(\"+\");\n\n if (plusIndex !== -1) {\n const modifier = spec.slice(0, plusIndex);\n const rest = spec.slice(plusIndex + 1);\n\n return `${modifier}+${keyLabels[rest] ?? rest}`;\n }\n\n return keyLabels[spec] ?? spec;\n}\n\n/**\n * Format all keys of a binding into a single display string.\n * `[\"upArrow\", \"k\"]` -> `\"\\u2191/k\"`\n */\nfunction formatKeys(key: string | ReadonlyArray<string>): string {\n const keys = typeof key === \"string\" ? [key] : key;\n\n return keys.map((k) => formatKeyLabel(k)).join(\"/\");\n}\n\nexport type Props = {\n /**\n * Key bindings to display. Typically the `bindings` array returned by `useKeyBindings`.\n */\n readonly bindings: ReadonlyArray<KeyBinding>;\n\n /**\n * Color for description text.\n * @default undefined (uses dimColor)\n */\n readonly descriptionColor?: string;\n\n /**\n * Color for key labels.\n * @default \"cyan\"\n */\n readonly keyColor?: string;\n\n /**\n * Maximum number of columns in full mode.\n * @default 3\n */\n readonly maxColumns?: number;\n\n /**\n * Display mode.\n * - `\"short\"`: Single-line horizontal bar, truncated to terminal width.\n * - `\"full\"`: Multi-column layout grouped by `group` field.\n * @default \"short\"\n */\n readonly mode?: \"full\" | \"short\";\n\n /**\n * Separator between key-description pairs in short mode.\n * @default \" \\u00b7 \"\n */\n readonly separator?: string;\n};\n\n/**\n * Renders a keybinding help bar from an array of `KeyBinding` definitions.\n *\n * ```tsx\n * <Help bindings={bindings} />\n * <Help bindings={bindings} mode=\"full\" />\n * ```\n */\nexport default function Help({ bindings, descriptionColor, keyColor = \"cyan\", maxColumns = 3, mode = \"short\", separator = \" \\u00B7 \" }: Props): ReactElement {\n const { columns } = useWindowSize();\n\n if (mode === \"full\") {\n return renderFull(bindings, { columns, descriptionColor, keyColor, maxColumns });\n }\n\n return renderShort(bindings, { columns, descriptionColor, keyColor, separator });\n}\n\nfunction renderShort(\n bindings: ReadonlyArray<KeyBinding>,\n options: { columns: number; descriptionColor: string | undefined; keyColor: string; separator: string },\n): ReactElement {\n const items: ReactNode[] = [];\n let totalWidth = 0;\n\n for (const [index, binding] of bindings.entries()) {\n const keys = formatKeys(binding.key);\n const pairText = `${keys} ${binding.description}`;\n const separatorWidth = index > 0 ? options.separator.length : 0;\n const pairWidth = separatorWidth + pairText.length;\n\n // Stop if adding this pair would exceed terminal width\n if (totalWidth + pairWidth > options.columns && items.length > 0) {\n break;\n }\n\n if (index > 0) {\n items.push(\n <Text dimColor key={`sep-${index}`}>\n {options.separator}\n </Text>,\n );\n }\n\n items.push(\n <Box key={`pair-${index}`}>\n <Text color={options.keyColor}>{keys}</Text>\n <Text> </Text>\n {options.descriptionColor ? <Text color={options.descriptionColor}>{binding.description}</Text> : <Text dimColor>{binding.description}</Text>}\n </Box>,\n );\n\n totalWidth += pairWidth;\n }\n\n return <Box flexDirection=\"row\">{items}</Box>;\n}\n\nfunction renderFull(\n bindings: ReadonlyArray<KeyBinding>,\n options: { columns: number; descriptionColor: string | undefined; keyColor: string; maxColumns: number },\n): ReactElement {\n // Group bindings\n const groups = new Map<string, KeyBinding[]>();\n\n for (const binding of bindings) {\n const group = binding.group ?? \"\";\n\n if (!groups.has(group)) {\n groups.set(group, []);\n }\n\n groups.get(group)!.push(binding);\n }\n\n const groupEntries = [...groups.entries()];\n const columnCount = Math.min(options.maxColumns, groupEntries.length || 1);\n const columnWidth = Math.floor(options.columns / columnCount);\n\n const columnNodes: ReactNode[] = [];\n\n for (const [index, [groupName, groupBindings]] of groupEntries.entries()) {\n const rows: ReactNode[] = [];\n\n if (groupName) {\n rows.push(\n <Text bold key=\"title\">\n {groupName}\n </Text>,\n );\n }\n\n for (const [bIndex, binding] of groupBindings.entries()) {\n const keys = formatKeys(binding.key);\n\n rows.push(\n <Box key={`b-${bIndex}`}>\n <Text color={options.keyColor}>{keys}</Text>\n <Text> </Text>\n {options.descriptionColor\n ? (\n <Text color={options.descriptionColor}>{binding.description}</Text>\n )\n : (\n <Text dimColor>{binding.description}</Text>\n )}\n </Box>,\n );\n }\n\n columnNodes.push(\n <Box flexDirection=\"column\" key={`col-${index}`} width={columnWidth}>\n {rows}\n </Box>,\n );\n }\n\n return <Box flexDirection=\"row\">{columnNodes}</Box>;\n}\n\nexport { Help };\nexport type { Props as HelpProps };\n",
|
|
17
|
+
"path": "registry/ui/help.tsx",
|
|
18
|
+
"target": "components/ui/help.tsx",
|
|
19
|
+
"type": "registry:ui"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "histogram",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Histogram",
|
|
6
|
+
"description": "Histogram 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/bar-chart.json"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
{
|
|
20
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport type { ReactElement } from \"react\";\nimport { useMemo } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\nimport BarChart from \"@/components/ui/bar-chart\";\n\nexport type Props = {\n /**\n * Number of equal-width buckets. Ignored if `thresholds` is set.\n * @default 10\n */\n readonly bins?: number;\n\n /**\n * Bar color.\n */\n readonly color?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Raw values to bin.\n */\n readonly data: ReadonlyArray<number>;\n\n /**\n * Plot height in character rows.\n * @default 10\n */\n readonly height?: number;\n\n /**\n * Optional fixed maximum.\n */\n readonly max?: number;\n\n /**\n * Optional fixed minimum.\n */\n readonly min?: number;\n\n /**\n * Render a numeric count above each bucket.\n * @default false\n */\n readonly showValues?: boolean;\n\n /**\n * Explicit bin edges. When provided, `bins`, `min`, and `max` are ignored.\n * Must be strictly increasing.\n */\n readonly thresholds?: ReadonlyArray<number>;\n\n /**\n * Plot width in character columns.\n */\n readonly width?: number;\n};\n\ntype Bucket = {\n readonly count: number;\n readonly label: string;\n};\n\nconst computeThresholds = (min: number, max: number, bins: number): ReadonlyArray<number> => {\n if (bins <= 0 || min >= max) {\n return [min, max];\n }\n\n const step = (max - min) / bins;\n\n return Array.from({ length: bins + 1 }, (_, index) => min + index * step);\n};\n\nconst binData = (data: ReadonlyArray<number>, thresholds: ReadonlyArray<number>): ReadonlyArray<Bucket> => {\n if (thresholds.length < 2) {\n return [];\n }\n\n const counts: number[] = Array.from<number>({ length: thresholds.length - 1 }).fill(0);\n const lowerBound = thresholds[0]!;\n const upperBound = thresholds[thresholds.length - 1]!;\n\n for (const value of data) {\n // Skip values that fall outside the configured range — both\n // underflow and overflow are dropped symmetrically.\n if (value < lowerBound || value > upperBound) {\n continue;\n }\n\n // Find the bucket via linear scan (fine for typical bin counts).\n for (let index = 0; index < thresholds.length - 1; index += 1) {\n const isLastBin = index === thresholds.length - 2;\n const upper = thresholds[index + 1]!;\n const withinBin = value < upper || (isLastBin && value <= upper);\n\n if (withinBin) {\n counts[index] = (counts[index] ?? 0) + 1;\n break;\n }\n }\n }\n\n return counts.map((count, index) => {\n const low = thresholds[index]!;\n const high = thresholds[index + 1]!;\n\n return {\n count,\n label: `${Math.round(low)}-${Math.round(high)}`,\n };\n });\n};\n\n/**\n * Histogram built on top of BarChart. Bins the raw values (via explicit\n * thresholds or equal-width bins) and hands the counts off to BarChart.\n * @returns A `ReactElement` rendering a BarChart of bucket counts.\n */\nexport default function Histogram({\n bins = 10,\n color,\n data,\n height = 10,\n max: maxOverride,\n min: minOverride,\n showValues = false,\n thresholds,\n width,\n}: Props): ReactElement {\n const buckets = useMemo(() => {\n if (thresholds && thresholds.length >= 2) {\n return binData(data, thresholds);\n }\n\n if (data.length === 0) {\n return [];\n }\n\n let min = minOverride ?? Infinity;\n let max = maxOverride ?? -Infinity;\n\n if (minOverride === undefined || maxOverride === undefined) {\n for (const value of data) {\n if (minOverride === undefined && value < min) {\n min = value;\n }\n\n if (maxOverride === undefined && value > max) {\n max = value;\n }\n }\n }\n\n if (min === max) {\n return [{ count: data.length, label: min.toString() }];\n }\n\n return binData(data, computeThresholds(min, max, bins));\n }, [data, thresholds, bins, minOverride, maxOverride]);\n\n return (\n <BarChart\n data={buckets.map((bucket) => {\n return { color, label: bucket.label, value: bucket.count };\n })}\n height={height}\n orientation=\"vertical\"\n showLabels\n showValues={showValues}\n width={width}\n />\n );\n}\n\nexport { Histogram };\nexport type { Props as HistogramProps };\n",
|
|
21
|
+
"path": "registry/ui/histogram.tsx",
|
|
22
|
+
"target": "components/ui/histogram.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": "image",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Image",
|
|
6
|
+
"description": "Image 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 Pixel = LiteralUnion<AnsiColors, string> | undefined;\n\nexport type Props = {\n /**\n * Character drawn for a transparent (undefined) pixel; defaults to a space.\n */\n readonly emptyChar?: string;\n\n /**\n * A row-major grid of colors, one entry per pixel. `undefined` is treated\n * as transparent. Two vertical pixels share one character cell via the\n * upper-half-block technique, so the rendered height is `ceil(rows / 2)`.\n */\n readonly pixels: ReadonlyArray<ReadonlyArray<Pixel>>;\n};\n\nconst UPPER_HALF = \"▀\";\nconst LOWER_HALF = \"▄\";\n\n/**\n * Render a small pixel grid using the upper-half-block technique: each text\n * cell packs two vertical pixels — the top pixel as the glyph's foreground and\n * the bottom pixel as its background — doubling vertical resolution.\n */\nexport default function Image({ emptyChar = \" \", pixels }: Props): ReactElement {\n let width = 0;\n\n for (const row of pixels) {\n width = Math.max(width, row.length);\n }\n\n const cellRows: ReactElement[] = [];\n\n for (let y = 0; y < pixels.length; y += 2) {\n const top = pixels[y] ?? [];\n const bottom = pixels[y + 1] ?? [];\n const spans: ReactElement[] = [];\n\n for (let x = 0; x < width; x += 1) {\n const topColor = top[x];\n const bottomColor = bottom[x];\n\n if (topColor === undefined && bottomColor === undefined) {\n spans.push(<Text key={x}>{emptyChar}</Text>);\n\n continue;\n }\n\n // A transparent top over an opaque bottom must not paint the upper\n // half in the default foreground — draw the lower-half block instead\n // so the top stays truly empty.\n if (topColor === undefined) {\n spans.push(\n <Text color={bottomColor} key={x}>\n {LOWER_HALF}\n </Text>,\n );\n\n continue;\n }\n\n spans.push(\n <Text backgroundColor={bottomColor} color={topColor} key={x}>\n {UPPER_HALF}\n </Text>,\n );\n }\n\n cellRows.push(\n\n <Box key={y}>{spans}</Box>,\n );\n }\n\n return <Box flexDirection=\"column\">{cellRows}</Box>;\n}\n\nexport { Image };\nexport type { Props as ImageProps };\n",
|
|
19
|
+
"path": "registry/ui/image.tsx",
|
|
20
|
+
"target": "components/ui/image.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
package/dist/r/json.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "json",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Json",
|
|
6
|
+
"description": "Json 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, useMemo, useState } from \"react\";\nimport type { LiteralUnion } from \"type-fest\";\n\ntype Color = LiteralUnion<AnsiColors, string>;\n\nexport type JsonTheme = {\n readonly boolean: Color;\n readonly bracket: Color;\n readonly key: Color;\n readonly null: Color;\n readonly number: Color;\n readonly string: Color;\n};\n\nconst DEFAULT_THEME: JsonTheme = {\n boolean: \"yellow\",\n bracket: \"gray\",\n key: \"cyan\",\n null: \"gray\",\n number: \"green\",\n string: \"yellow\",\n};\n\nexport type Props = {\n /**\n * Accent color for the focused row.\n * @default \"blue\"\n */\n readonly accentColor?: Color;\n\n /**\n * Auto-focus for keyboard navigation.\n */\n readonly autoFocus?: boolean;\n\n /**\n * Depth at which nested containers start collapsed. `0` collapses\n * everything below the root; `Infinity` expands all.\n * @default Infinity\n */\n readonly collapseDepth?: number;\n\n /**\n * The value to render.\n */\n readonly data: unknown;\n\n /**\n * When true, arrow keys move a cursor and Space toggles the focused\n * container. When false, the tree renders statically fully expanded down to\n * `collapseDepth`.\n * @default true\n */\n readonly interactive?: boolean;\n\n /**\n * Color theme for the different JSON token kinds.\n */\n readonly theme?: Partial<JsonTheme>;\n};\n\ntype Row = {\n readonly collapsible: boolean;\n readonly depth: number;\n readonly id: string;\n readonly key?: string;\n readonly summary?: string;\n readonly value: unknown;\n};\n\nconst kindOf = (value: unknown): \"array\" | \"boolean\" | \"null\" | \"number\" | \"object\" | \"string\" | \"undefined\" => {\n if (value === null) {\n return \"null\";\n }\n\n if (Array.isArray(value)) {\n return \"array\";\n }\n\n const type = typeof value;\n\n if (type === \"object\" || type === \"boolean\" || type === \"number\" || type === \"string\" || type === \"undefined\") {\n return type;\n }\n\n return \"string\";\n};\n\nconst isContainer = (value: unknown): boolean => {\n const kind = kindOf(value);\n\n return kind === \"array\" || kind === \"object\";\n};\n\nconst summarize = (value: unknown): string => {\n if (Array.isArray(value)) {\n return `[…${value.length}]`;\n }\n\n return `{…${Object.keys(value as object).length}}`;\n};\n\n/** Depth-first flatten of the value into visible rows, honouring collapse. */\nconst flatten = (value: unknown, key: string | undefined, depth: number, id: string, collapsed: ReadonlySet<string>, out: Row[]): void => {\n const collapsible = isContainer(value) && (Array.isArray(value) ? value.length > 0 : Object.keys(value as object).length > 0);\n\n out.push({ collapsible, depth, id, key, summary: collapsible ? summarize(value) : undefined, value });\n\n if (!collapsible || collapsed.has(id)) {\n return;\n }\n\n if (Array.isArray(value)) {\n value.forEach((item, index) => {\n flatten(item, String(index), depth + 1, `${id}.${index}`, collapsed, out);\n });\n\n return;\n }\n\n for (const [childKey, childValue] of Object.entries(value as Record<string, unknown>)) {\n flatten(childValue, childKey, depth + 1, `${id}.${childKey}`, collapsed, out);\n }\n};\n\nfunction wrap(value: number, size: number): number {\n return size === 0 ? 0 : ((value % size) + size) % size;\n}\n\n/**\n * A collapsible JSON viewer. Objects and arrays can be folded; when\n * `interactive`, arrow keys move a cursor and Space toggles the focused\n * container. Values are colored by type.\n */\nexport default function Json({\n accentColor = \"blue\",\n autoFocus = false,\n collapseDepth = Number.POSITIVE_INFINITY,\n data,\n interactive = true,\n theme,\n}: Props): ReactElement {\n const palette: JsonTheme = { ...DEFAULT_THEME, ...theme };\n const { isFocused } = useFocus({ autoFocus, isActive: interactive });\n\n const initialCollapsed = useMemo(() => {\n const set = new Set<string>();\n const walk = (value: unknown, depth: number, id: string): void => {\n if (!isContainer(value)) {\n return;\n }\n\n if (depth >= collapseDepth) {\n set.add(id);\n }\n\n const entries = Array.isArray(value) ? value.map((item, index) => [String(index), item] as const) : Object.entries(value as object);\n\n for (const [childKey, childValue] of entries) {\n walk(childValue, depth + 1, `${id}.${childKey}`);\n }\n };\n\n walk(data, 0, \"$\");\n\n return set;\n }, [collapseDepth, data]);\n\n const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(initialCollapsed);\n const [cursor, setCursor] = useState(0);\n\n const rows = useMemo(() => {\n const out: Row[] = [];\n\n flatten(data, undefined, 0, \"$\", collapsed, out);\n\n return out;\n }, [collapsed, data]);\n\n const toggle = useCallback((id: string) => {\n setCollapsed((current) => {\n const next = new Set(current);\n\n if (next.has(id)) {\n next.delete(id);\n } else {\n next.add(id);\n }\n\n return next;\n });\n }, []);\n\n const inputHandler = useCallback(\n (input: string, key: { downArrow: boolean; leftArrow: boolean; rightArrow: boolean; upArrow: boolean }) => {\n if (key.upArrow) {\n setCursor((index) => wrap(index - 1, rows.length));\n\n return;\n }\n\n if (key.downArrow) {\n setCursor((index) => wrap(index + 1, rows.length));\n\n return;\n }\n\n const row = rows[cursor];\n\n if (!row?.collapsible) {\n return;\n }\n\n const expand = key.rightArrow && collapsed.has(row.id);\n const collapse = key.leftArrow && !collapsed.has(row.id);\n\n if (input === \" \" || expand || collapse) {\n toggle(row.id);\n }\n },\n [collapsed, cursor, rows, toggle],\n );\n\n useInput(inputHandler, { isActive: interactive && isFocused });\n\n const renderValue = (row: Row): ReactElement => {\n if (row.collapsible) {\n const marker = collapsed.has(row.id) ? \"▸\" : \"▾\";\n\n return (\n <Text color={palette.bracket}>\n {marker}\n {\" \"}\n {row.summary}\n </Text>\n );\n }\n\n const kind = kindOf(row.value);\n\n // Empty containers are not collapsible, so they reach here — render them\n // as `[]`/`{}` rather than falling through to the null branch.\n if (kind === \"array\" || kind === \"object\") {\n return <Text color={palette.bracket}>{kind === \"array\" ? \"[]\" : \"{}\"}</Text>;\n }\n\n if (kind === \"string\") {\n return <Text color={palette.string}>{JSON.stringify(row.value)}</Text>;\n }\n\n if (kind === \"number\") {\n return <Text color={palette.number}>{String(row.value)}</Text>;\n }\n\n if (kind === \"boolean\") {\n return <Text color={palette.boolean}>{String(row.value)}</Text>;\n }\n\n return <Text color={palette.null}>{row.value === undefined ? \"undefined\" : \"null\"}</Text>;\n };\n\n return (\n <Box flexDirection=\"column\">\n {rows.map((row, index) => {\n const isActive = interactive && isFocused && index === cursor;\n\n return (\n <Box key={row.id}>\n <Text color={isActive ? accentColor : undefined}>{isActive ? \"❯\" : \" \"}</Text>\n <Text>{\" \".repeat(row.depth)}</Text>\n {row.key === undefined\n ? undefined\n : (\n <Text color={palette.key}>\n {row.key}\n :\n {\" \"}\n </Text>\n )}\n {renderValue(row)}\n </Box>\n );\n })}\n </Box>\n );\n}\n\nexport { Json };\nexport type { Props as JsonProps };\n",
|
|
19
|
+
"path": "registry/ui/json.tsx",
|
|
20
|
+
"target": "components/ui/json.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
package/dist/r/kbd.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "kbd",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Kbd",
|
|
6
|
+
"description": "Kbd 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 * Key cap content.\n */\n readonly children: ReactNode;\n\n /**\n * Background color of the key cap.\n * @default \"gray\"\n */\n readonly color?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Visual style.\n * - `solid` (default): background filled\n * - `outline`: border only\n * - `bare`: no decoration, just dimmed text\n */\n readonly variant?: \"bare\" | \"outline\" | \"solid\";\n};\n\n/**\n * Render a keyboard key (e.g. `<Kbd>Enter</Kbd>`).\n * @returns A `ReactElement` styled as a key cap (solid / outline / bare).\n */\nexport default function Kbd({ children, color = \"gray\", variant = \"solid\" }: Props): ReactElement {\n if (variant === \"solid\") {\n return (\n <Text backgroundColor={color} color=\"black\">\n {\" \"}\n {children}\n{\" \"}\n </Text>\n );\n }\n\n if (variant === \"outline\") {\n return (\n <Box borderColor={color} borderStyle=\"round\" paddingX={1}>\n <Text color={color}>{children}</Text>\n </Box>\n );\n }\n\n return <Text dimColor>{children}</Text>;\n}\n\nexport { Kbd };\nexport type { Props as KbdProps };\n",
|
|
19
|
+
"path": "registry/ui/kbd.tsx",
|
|
20
|
+
"target": "components/ui/kbd.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": "line-chart",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Line Chart",
|
|
6
|
+
"description": "Line 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
|
+
"https://visulima.com/r/chart-utils.json"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
{
|
|
20
|
+
"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\nimport type { Point } from \"@/lib/chart-utils\";\nimport { computeExtents, DEFAULT_CHART_PALETTE, pickSeriesColor, toPoints } from \"@/lib/chart-utils\";\n\nexport type LineSeries = {\n /**\n * Per-series color override. Falls back to the chart-level palette.\n */\n readonly color?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Numeric samples to plot. Pass either a list of y-values (x is the\n * index) or {x, y} tuples.\n */\n readonly data: ReadonlyArray<number> | ReadonlyArray<{ readonly x: number; readonly y: number }>;\n\n /**\n * Optional label shown in the legend.\n */\n readonly label?: string;\n};\n\nexport type LineChartProps = {\n /**\n * Color for axis ticks / labels.\n * @default \"gray\"\n */\n readonly axisColor?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Chart body height in character rows.\n * @default 10\n */\n readonly height?: number;\n\n /**\n * Upper bound for the y-axis. When omitted the chart auto-scales.\n */\n readonly maxY?: number;\n\n /**\n * Lower bound for the y-axis. When omitted the chart auto-scales.\n */\n readonly minY?: number;\n\n /**\n * Fallback palette cycled through when a series does not set `color`.\n * @default [\"cyan\", \"magenta\", \"yellow\", \"green\", \"blue\", \"red\"]\n */\n readonly palette?: ReadonlyArray<LiteralUnion<AnsiColors, string>>;\n\n /**\n * One or more line series.\n */\n readonly series: ReadonlyArray<LineSeries>;\n\n /**\n * Render the legend row under the plot.\n * @default true\n */\n readonly showLegend?: boolean;\n\n /**\n * Chart body width in character columns.\n * @default 40\n */\n readonly width?: number;\n};\n\nconst DEFAULT_PALETTE = DEFAULT_CHART_PALETTE;\n\ntype RenderConfig = {\n readonly axisColor: LiteralUnion<AnsiColors, string>;\n readonly palette: ReadonlyArray<LiteralUnion<AnsiColors, string>>;\n readonly seriesList: ReadonlyArray<{ color: LiteralUnion<AnsiColors, string>; points: ReadonlyArray<Point>; series: LineSeries }>;\n readonly xMax: number;\n readonly xMin: number;\n readonly yMax: number;\n readonly yMin: number;\n};\n\n/**\n * Project an (x, y) data point into pixel-grid coordinates for the given\n * canvas size. Used by every chart that paints on top of a braille grid.\n */\n// eslint-disable-next-line react-refresh/only-export-components -- shared chart helper used by area-chart and scatter-plot\nexport const projectPoint = (\n point: Point,\n config: Pick<RenderConfig, \"xMax\" | \"xMin\" | \"yMax\" | \"yMin\">,\n pixelWidth: number,\n pixelHeight: number,\n): { px: number; py: number } => {\n const xRange = config.xMax - config.xMin || 1;\n const yRange = config.yMax - config.yMin || 1;\n const nx = (point.x - config.xMin) / xRange;\n const ny = (point.y - config.yMin) / yRange;\n\n return {\n px: Math.max(0, Math.min(pixelWidth - 1, Math.round(nx * (pixelWidth - 1)))),\n py: Math.max(0, Math.min(pixelHeight - 1, Math.round((1 - ny) * (pixelHeight - 1)))),\n };\n};\n\n/**\n * Shared drawing path used by LineChart and ScatterPlot. AreaChart wraps\n * this with a fill pass.\n */\n// eslint-disable-next-line react-refresh/only-export-components -- shared chart helper used by area-chart and scatter-plot\nexport const drawSeriesOnCanvas = (context: CanvasContext, mode: \"line\" | \"scatter\", config: RenderConfig): void => {\n context.clear();\n\n const { axisColor, seriesList } = config;\n const pixelWidth = Math.max(1, context.width * 2);\n const pixelHeight = Math.max(1, context.height * 4);\n\n // Dashed baseline at the bottom of the plot in axis color.\n for (let cellX = 0; cellX < context.width; cellX += 2) {\n context.setCell(cellX, context.height - 1, \"·\", { color: axisColor, dim: true });\n }\n\n for (const { color, points } of seriesList) {\n if (points.length === 0) {\n continue;\n }\n\n const grid = createBrailleGrid(context.width, context.height);\n\n if (mode === \"scatter\") {\n for (const point of points) {\n const projected = projectPoint(point, config, pixelWidth, pixelHeight);\n\n grid.plotPoint(projected.px, projected.py);\n }\n } else {\n let previous = projectPoint(points[0]!, config, pixelWidth, pixelHeight);\n\n grid.plotPoint(previous.px, previous.py);\n\n for (let index = 1; index < points.length; index += 1) {\n const current = projectPoint(points[index]!, config, pixelWidth, pixelHeight);\n\n grid.plotLine(previous.px, previous.py, current.px, current.py);\n previous = current;\n }\n }\n\n grid.flush(context, { color });\n }\n};\n\n/**\n * Smooth multi-series line chart rendered at 2x4 sub-cell resolution using\n * Unicode braille patterns. Auto-scales both axes by default.\n */\nexport default function LineChart({\n axisColor = \"gray\",\n height = 10,\n maxY,\n minY,\n palette = DEFAULT_PALETTE,\n series,\n showLegend = true,\n width = 40,\n}: LineChartProps): ReactElement {\n const config = useMemo<RenderConfig>(() => {\n const seriesList = series.map((input, index) => {\n return {\n color: pickSeriesColor(input, index, palette),\n points: toPoints(input.data),\n series: input,\n };\n });\n\n const extents = computeExtents(seriesList);\n\n return {\n axisColor,\n palette,\n seriesList,\n xMax: extents.xMax === Number.NEGATIVE_INFINITY ? 1 : extents.xMax,\n xMin: extents.xMin === Number.POSITIVE_INFINITY ? 0 : extents.xMin,\n yMax: maxY ?? (extents.yMax === Number.NEGATIVE_INFINITY ? 1 : extents.yMax),\n yMin: minY ?? (extents.yMin === Number.POSITIVE_INFINITY ? 0 : extents.yMin),\n };\n }, [series, axisColor, palette, minY, maxY]);\n\n return (\n <Box 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 drawSeriesOnCanvas(context, \"line\", config);\n }}\n height={height}\n // eslint-disable-next-line react-perf/jsx-no-new-array-as-prop -- version array is the canvas redraw key\n version={[series, width, height, minY, maxY, axisColor, palette]}\n width={width}\n />\n {/* eslint-disable-next-line @stylistic/multiline-ternary -- prettier formats JSX ternaries on one line */}\n {showLegend ? (\n <Box gap={2} marginTop={1}>\n {config.seriesList.map(({ color, series: input }, index) => (\n // Composite key: series index plus label so two\n // series sharing a label don't collide.\n // eslint-disable-next-line react-x/no-array-index-key -- series index is stable for the render\n <Box gap={1} key={`${index}:${input.label ?? \"\"}`}>\n <Text color={color}>●</Text>\n <Text>{input.label ?? `Series ${index + 1}`}</Text>\n </Box>\n ))}\n </Box>\n ) : undefined}\n </Box>\n );\n}\n\nexport type { RenderConfig as LineChartRenderConfig };\n\nexport { LineChart };\n",
|
|
21
|
+
"path": "registry/ui/line-chart.tsx",
|
|
22
|
+
"target": "components/ui/line-chart.tsx",
|
|
23
|
+
"type": "registry:ui"
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
package/dist/r/link.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "link",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Link",
|
|
6
|
+
"description": "Link component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/ansi",
|
|
12
|
+
"@visulima/tui"
|
|
13
|
+
],
|
|
14
|
+
"registryDependencies": [],
|
|
15
|
+
"files": [
|
|
16
|
+
{
|
|
17
|
+
"content": "/* eslint-disable react/function-component-definition */\n\n/**\n * Clickable terminal link component for Ink.\n *\n * Based on ink-link by Sindre Sorhus.\n * @see https://github.com/sindresorhus/ink-link\n *\n * MIT License\n * Copyright (c) Sindre Sorhus (https://sindresorhus.com)\n */\nimport { hyperlink } from \"@visulima/ansi\";\nimport Text from \"@visulima/tui/components/text\";\nimport Transform from \"@visulima/tui/components/transform\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { useCallback } from \"react\";\n\ntype Props = {\n readonly children: ReactNode;\n\n /**\n * Determines whether the URL should be printed for unsupported terminals.\n *\n * - `true` (default): appends the URL after the text, e.g. `\"My website https://example.com\"`\n * - `false`: renders just the text with no URL\n * - `function`: custom fallback formatting, e.g. `(text, url) => \\`[\\${text}](\\${url})\\``\n * @default true\n */\n readonly fallback?: ((text: string, url: string) => string) | boolean;\n\n /**\n * The URL to link to.\n */\n readonly url: string;\n};\n\n/**\n * Lightweight check for OSC 8 hyperlink support.\n *\n * Covers the most common terminals. For exhaustive detection,\n * use the `supports-hyperlinks` package externally.\n */\nlet cachedSupportsHyperlinks: boolean | undefined;\n\nconst detectHyperlinkSupport = (): boolean => {\n // Not a TTY — no hyperlink support\n if (!process.stdout.isTTY) {\n return false;\n }\n\n const term = process.env[\"TERM_PROGRAM\"] ?? \"\";\n const termVersion = process.env[\"TERM_PROGRAM_VERSION\"] ?? \"\";\n\n // Known supporting terminals\n if (term === \"iTerm.app\") {\n const major = Number.parseInt(termVersion.split(\".\")[0] ?? \"0\", 10);\n\n return major >= 3;\n }\n\n if (term === \"WezTerm\" || term === \"ghostty\") {\n return true;\n }\n\n if (term === \"vscode\") {\n const parts = termVersion.split(\".\");\n const major = Number.parseInt(parts[0] ?? \"0\", 10);\n const minor = Number.parseInt(parts[1] ?? \"0\", 10);\n\n return major > 1 || (major === 1 && minor >= 72);\n }\n\n // Windows Terminal\n if (process.env[\"WT_SESSION\"]) {\n return true;\n }\n\n // VTE-based terminals (GNOME Terminal, Tilix, etc.)\n const vteVersion = process.env[\"VTE_VERSION\"];\n\n if (vteVersion) {\n const v = Number.parseInt(vteVersion, 10);\n\n return v >= 5000 && v !== 5000;\n }\n\n return false;\n};\n\nconst supportsHyperlinks = (): boolean => {\n // Skip cache when FORCE_HYPERLINK is set (allows runtime override)\n if (process.env[\"FORCE_HYPERLINK\"]) {\n return process.env[\"FORCE_HYPERLINK\"] !== \"0\";\n }\n\n if (cachedSupportsHyperlinks !== undefined) {\n return cachedSupportsHyperlinks;\n }\n\n cachedSupportsHyperlinks = detectHyperlinkSupport();\n\n return cachedSupportsHyperlinks;\n};\n\n/**\n * An Ink component that creates clickable links in the terminal using OSC 8 hyperlink sequences.\n *\n * [Supported terminals.](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda)\n *\n * For unsupported terminals, the link will be printed after the text: `My website https://example.com`.\n *\n * ```tsx\n * import { Link, Text } from \"@visulima/tui/ink\";\n *\n * <Link url=\"https://example.com\">\n * <Text color=\"cyan\">My Website</Text>\n * </Link>\n * ```\n */\nexport default function Link({ children, fallback = true, url }: Props): ReactElement {\n const transform = useCallback(\n (text: string) => {\n // Check if the terminal supports hyperlinks via OSC 8.\n // If FORCE_HYPERLINK is set, always use the hyperlink sequence.\n // Otherwise, check common terminal environment indicators.\n if (supportsHyperlinks()) {\n return hyperlink(text, url);\n }\n\n if (fallback === false) {\n return text;\n }\n\n if (typeof fallback === \"function\") {\n return fallback(text, url);\n }\n\n return `${text} ${url}`;\n },\n [fallback, url],\n );\n\n return (\n <Transform transform={transform}>\n <Text>{children}</Text>\n </Transform>\n );\n}\n\nexport type { Props };\n\nexport { Link };\nexport type { Props as LinkProps };\n",
|
|
18
|
+
"path": "registry/ui/link.tsx",
|
|
19
|
+
"target": "components/ui/link.tsx",
|
|
20
|
+
"type": "registry:ui"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "loading-indicator",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Loading Indicator",
|
|
6
|
+
"description": "Loading Indicator component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/colorize",
|
|
12
|
+
"@visulima/spinner",
|
|
13
|
+
"@visulima/tui",
|
|
14
|
+
"type-fest"
|
|
15
|
+
],
|
|
16
|
+
"registryDependencies": [
|
|
17
|
+
"https://visulima.com/r/spinner.json"
|
|
18
|
+
],
|
|
19
|
+
"files": [
|
|
20
|
+
{
|
|
21
|
+
"content": "/* eslint-disable react/function-component-definition */\nimport type { AnsiColors } from \"@visulima/colorize\";\nimport type { SpinnerName } from \"@visulima/spinner\";\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 Props = {\n /**\n * Optional message rendered after the spinner.\n */\n readonly children?: ReactNode;\n\n /**\n * Color used for the spinner and label.\n * @default \"cyan\"\n */\n readonly color?: LiteralUnion<AnsiColors, string>;\n\n /**\n * Spinner preset.\n * @default \"dots\"\n */\n readonly type?: SpinnerName;\n};\n\n/**\n * Compact spinner + label for loading states.\n */\nexport default function LoadingIndicator({ children, color = \"cyan\", type = \"dots\" }: Props): ReactElement {\n return (\n <Box gap={1}>\n <Text color={color}>\n <Spinner type={type} />\n </Text>\n {children === undefined ? undefined : <Text color={color}>{children}</Text>}\n </Box>\n );\n}\n\nexport { LoadingIndicator };\nexport type { Props as LoadingIndicatorProps };\n",
|
|
22
|
+
"path": "registry/ui/loading-indicator.tsx",
|
|
23
|
+
"target": "components/ui/loading-indicator.tsx",
|
|
24
|
+
"type": "registry:ui"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
package/dist/r/log.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "log",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Log",
|
|
6
|
+
"description": "Log 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\nexport type LogLevel = \"debug\" | \"error\" | \"info\" | \"success\" | \"warn\";\n\nexport type LogEntry = {\n readonly level?: LogLevel;\n readonly message: string;\n readonly timestamp?: string;\n};\n\nconst LEVEL_META: Record<LogLevel, { readonly color: LiteralUnion<AnsiColors, string>; readonly label: string }> = {\n debug: { color: \"gray\", label: \"DBG\" },\n error: { color: \"red\", label: \"ERR\" },\n info: { color: \"blue\", label: \"INF\" },\n success: { color: \"green\", label: \"OK \" },\n warn: { color: \"yellow\", label: \"WRN\" },\n};\n\nexport type Props = {\n /**\n * The log entries, oldest first.\n */\n readonly entries: ReadonlyArray<LogEntry>;\n\n /**\n * Keep only the last N entries (tail). Omit to render all.\n */\n readonly maxLines?: number;\n\n /**\n * Render the `[LEVEL]` badge before each message.\n * @default true\n */\n readonly showLevel?: boolean;\n\n /**\n * Render each entry's timestamp, when present.\n * @default true\n */\n readonly showTimestamp?: boolean;\n};\n\n/**\n * A leveled log view. Renders entries with a colored level badge and optional\n * timestamp; `maxLines` tails the buffer to the most recent entries.\n */\nexport default function Log({ entries, maxLines, showLevel = true, showTimestamp = true }: Props): ReactElement {\n const visible = maxLines !== undefined && entries.length > maxLines ? entries.slice(entries.length - maxLines) : entries;\n\n return (\n <Box flexDirection=\"column\">\n {visible.map((entry, index) => {\n const meta = LEVEL_META[entry.level ?? \"info\"];\n\n return (\n // eslint-disable-next-line react-x/no-array-index-key -- entry index is stable for the render\n <Box key={index}>\n {showTimestamp && entry.timestamp !== undefined\n ? (\n <Box marginRight={1}>\n <Text dimColor>{entry.timestamp}</Text>\n </Box>\n )\n : undefined}\n {showLevel\n ? (\n <Box marginRight={1}>\n <Text bold color={meta.color}>\n {meta.label}\n </Text>\n </Box>\n )\n : undefined}\n <Text>{entry.message}</Text>\n </Box>\n );\n })}\n </Box>\n );\n}\n\nexport { Log };\nexport type { Props as LogProps };\n",
|
|
19
|
+
"path": "registry/ui/log.tsx",
|
|
20
|
+
"target": "components/ui/log.tsx",
|
|
21
|
+
"type": "registry:ui"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "markdown",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Markdown",
|
|
6
|
+
"description": "Markdown component for @visulima/tui.",
|
|
7
|
+
"categories": [
|
|
8
|
+
"ui"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@visulima/tui",
|
|
12
|
+
"marked"
|
|
13
|
+
],
|
|
14
|
+
"registryDependencies": [
|
|
15
|
+
"https://visulima.com/r/code.json",
|
|
16
|
+
"https://visulima.com/r/link.json",
|
|
17
|
+
"https://visulima.com/r/ordered-list.json",
|
|
18
|
+
"https://visulima.com/r/table.json",
|
|
19
|
+
"https://visulima.com/r/unordered-list.json"
|
|
20
|
+
],
|
|
21
|
+
"files": [
|
|
22
|
+
{
|
|
23
|
+
"content": "/* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/no-unnecessary-condition, jsdoc/match-description, jsx-a11y/anchor-is-valid, react-x/no-array-index-key, react/function-component-definition, sonarjs/slow-regex */\n\n/**\n * Markdown rendering component for Ink.\n *\n * Parses Markdown with `marked` and maps tokens to Ink React elements.\n * Code blocks are rendered with syntax highlighting via the Code component.\n */\nimport Box from \"@visulima/tui/components/box\";\nimport Newline from \"@visulima/tui/components/newline\";\nimport Text from \"@visulima/tui/components/text\";\nimport useWindowSize from \"@visulima/tui/hooks/use-window-size\";\nimport type { Token, Tokens } from \"marked\";\nimport { Lexer } from \"marked\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport React, { useMemo } from \"react\";\n\nimport Code from \"@/components/ui/code\";\nimport Link from \"@/components/ui/link\";\nimport type { OrderedListEntry } from \"@/components/ui/ordered-list\";\nimport OrderedList from \"@/components/ui/ordered-list\";\nimport Table from \"@/components/ui/table\";\nimport type { UnorderedListEntry } from \"@/components/ui/unordered-list\";\nimport UnorderedList from \"@/components/ui/unordered-list\";\n\nexport type Props = {\n /**\n * The Markdown source string.\n */\n readonly children: string;\n\n /**\n * Shiki theme for code block syntax highlighting.\n * @default \"github-dark-default\"\n */\n readonly codeTheme?: string;\n\n /**\n * Maximum width for text wrapping. Defaults to terminal width.\n */\n readonly maxWidth?: number;\n\n /**\n * Enable streaming mode for progressive rendering.\n * When true, the component re-renders on every prop change and handles\n * incomplete Markdown gracefully (e.g., unclosed code fences, partial lists).\n * Useful for rendering AI-generated text arriving token-by-token.\n * @default false\n */\n readonly streaming?: boolean;\n};\n\n// Heading colors by depth\nconst HEADING_COLORS: Record<number, string> = {\n 1: \"cyan\",\n 2: \"green\",\n 3: \"yellow\",\n 4: \"blue\",\n 5: \"magenta\",\n 6: \"gray\",\n};\n\ntype InlineToken = Tokens.Strong | Tokens.Em | Tokens.Del | Tokens.Codespan | Tokens.Link | Tokens.Br | Tokens.Image | Tokens.Escape | Tokens.Text;\n\n/**\n * Render inline tokens (text, bold, italic, code, links, etc.)\n */\nfunction renderInlineTokens(tokens: InlineToken[] | Token[] | undefined): ReactNode {\n if (!tokens || tokens.length === 0) {\n return null;\n }\n\n return tokens.map((token, index) => {\n switch (token.type) {\n case \"br\": {\n return <Newline key={index} />;\n }\n\n case \"codespan\": {\n return (\n <Text inverse key={index}>\n {\" \"}\n {token.text}\n{\" \"}\n </Text>\n );\n }\n\n case \"del\": {\n return (\n <Text key={index} strikethrough>\n {renderInlineTokens(token.tokens)}\n </Text>\n );\n }\n\n case \"em\": {\n return (\n <Text italic key={index}>\n {renderInlineTokens(token.tokens)}\n </Text>\n );\n }\n\n case \"escape\": {\n return <React.Fragment key={index}>{token.text}</React.Fragment>;\n }\n\n case \"image\": {\n const img = token as Tokens.Image;\n\n return (\n <Text dimColor key={index}>\n [image:\n{\" \"}\n{img.text || img.href}\n]\n </Text>\n );\n }\n\n case \"link\": {\n const link = token as Tokens.Link;\n\n return (\n <Link key={index} url={link.href}>\n {renderInlineTokens(link.tokens) ?? link.text}\n </Link>\n );\n }\n\n case \"strong\": {\n return (\n <Text bold key={index}>\n {renderInlineTokens(token.tokens)}\n </Text>\n );\n }\n\n case \"text\": {\n return <React.Fragment key={index}>{token.text}</React.Fragment>;\n }\n\n default: {\n // Unknown inline token — render raw text\n const rawText = \"raw\" in token ? (token as { raw: string }).raw : \"\";\n\n return <React.Fragment key={index}>{rawText}</React.Fragment>;\n }\n }\n });\n}\n\n/**\n * Map marked list items to OrderedListEntry or UnorderedListEntry.\n */\nfunction mapListItems(items: Token[]): (OrderedListEntry | UnorderedListEntry)[] {\n return items.map((item: Token) => {\n const listItem = item as Tokens.ListItem;\n const listItemTokens = listItem.tokens ?? [];\n\n // marked list_item has block-level tokens; the inline content is inside the first \"text\" token\n const textToken = listItemTokens.find((t) => t.type === \"text\") as Tokens.Text | undefined;\n const label = renderInlineTokens(textToken?.tokens ?? listItemTokens);\n\n // Check for nested lists within the item's tokens\n const nestedList = listItemTokens.find((t) => t.type === \"list\") as Tokens.List | undefined;\n const children = nestedList ? mapListItems(nestedList.items) : undefined;\n\n return { children, label };\n });\n}\n\n/**\n * Render block-level tokens recursively.\n */\nfunction renderBlockTokens(tokens: Token[], codeTheme: string, maxWidth: number): ReactNode[] {\n const elements: ReactNode[] = [];\n\n for (const [index, token_] of tokens.entries()) {\n const token = token_;\n const marginTop = index > 0 ? 1 : 0;\n\n switch (token.type) {\n case \"blockquote\": {\n const bq = token as Tokens.Blockquote;\n\n elements.push(\n <Box borderLeft borderLeftColor=\"gray\" key={index} marginTop={marginTop} paddingLeft={1}>\n <Box flexDirection=\"column\">{renderBlockTokens(bq.tokens, codeTheme, maxWidth - 3)}</Box>\n </Box>,\n );\n break;\n }\n\n case \"code\": {\n const code = token as Tokens.Code;\n\n elements.push(\n <Box key={index} marginTop={marginTop}>\n <Code code={code.text} language={code.lang} theme={codeTheme} />\n </Box>,\n );\n break;\n }\n\n case \"heading\": {\n const heading = token as Tokens.Heading;\n const color = HEADING_COLORS[heading.depth] ?? \"white\";\n const prefix = `${\"#\".repeat(heading.depth)} `;\n\n elements.push(\n <Box key={index} marginTop={marginTop}>\n <Text bold color={color}>\n {prefix}\n {renderInlineTokens(heading.tokens)}\n </Text>\n </Box>,\n );\n break;\n }\n\n case \"hr\": {\n elements.push(\n <Box key={index} marginTop={marginTop}>\n <Text dimColor>{\"─\".repeat(Math.min(maxWidth, 40))}</Text>\n </Box>,\n );\n break;\n }\n\n case \"html\": {\n const html = token as Tokens.HTML;\n // Strip HTML tags and render as plain text\n const stripped = html.text.replaceAll(/<[^>]*>/g, \"\");\n\n if (stripped.trim()) {\n elements.push(\n <Box key={index} marginTop={marginTop}>\n <Text dimColor>{stripped}</Text>\n </Box>,\n );\n }\n\n break;\n }\n\n case \"list\": {\n const list = token as Tokens.List;\n const items = mapListItems(list.items);\n\n if (list.ordered) {\n elements.push(\n <Box key={index} marginTop={marginTop}>\n <OrderedList items={items} />\n </Box>,\n );\n } else {\n elements.push(\n <Box key={index} marginTop={marginTop}>\n <UnorderedList items={items} />\n </Box>,\n );\n }\n\n break;\n }\n\n case \"paragraph\": {\n const para = token as Tokens.Paragraph;\n\n elements.push(\n <Box key={index} marginTop={marginTop}>\n <Text wrap=\"wrap\">{renderInlineTokens(para.tokens)}</Text>\n </Box>,\n );\n break;\n }\n\n case \"space\": {\n elements.push(<Newline key={index} />);\n break;\n }\n\n case \"table\": {\n const table = token as Tokens.Table;\n const headers: string[] = (table.header ?? []).map((cell) => cell.text);\n const rows: string[][] = (table.rows ?? []).map((row) => row.map((cell) => cell.text));\n const data = rows.map((row: string[]) => {\n const record: Record<string, string> = {};\n\n headers.forEach((header: string, colIndex: number) => {\n record[header] = row[colIndex] ?? \"\";\n });\n\n return record;\n });\n\n elements.push(\n <Box key={index} marginTop={marginTop}>\n <Table data={data} />\n </Box>,\n );\n break;\n }\n\n default: {\n // Unknown block token — render raw text\n const raw = \"raw\" in token ? (token as { raw: string }).raw : \"\";\n const text = \"text\" in token ? (token as { text: string }).text : raw;\n\n if (text) {\n elements.push(\n <Box key={index} marginTop={marginTop}>\n <Text>{text}</Text>\n </Box>,\n );\n }\n\n break;\n }\n }\n }\n\n return elements;\n}\n\n/**\n * Detect if markdown ends with an incomplete block (unclosed code fence, etc.)\n * and append the trailing text as a paragraph so it renders during streaming.\n */\nfunction lexWithStreamingFallback(source: string, streaming: boolean): Token[] {\n const tokens = Lexer.lex(source);\n\n if (!streaming || source.length === 0) {\n return tokens;\n }\n\n // Check if there's unrendered trailing text after the last token.\n // marked's Lexer silently drops text after an unclosed code fence.\n let consumedLength = 0;\n\n for (const token of tokens) {\n consumedLength += token.raw?.length ?? 0;\n }\n\n if (consumedLength < source.length) {\n const trailing = source.slice(consumedLength);\n\n if (trailing.trim().length > 0) {\n // Append trailing text as a paragraph token so it renders\n\n tokens.push({ raw: trailing, text: trailing, tokens: [{ raw: trailing, text: trailing, type: \"text\" }], type: \"paragraph\" });\n }\n }\n\n return tokens;\n}\n\n/**\n * Render Markdown content as Ink terminal UI elements.\n *\n * ```tsx\n * <Markdown>{\"# Hello\\n\\nThis is **bold** and *italic*.\"}</Markdown>\n * <Markdown codeTheme=\"github-dark-default\">{\"```js\\nconst x = 1;\\n```\"}</Markdown>\n * <Markdown streaming>{\"# Streaming\\n\\nText arriving...\"}</Markdown>\n * ```\n */\nexport default function Markdown({ children, codeTheme = \"github-dark-default\", maxWidth: maxWidthProp, streaming = false }: Props): ReactElement {\n const { columns } = useWindowSize();\n const maxWidth = maxWidthProp ?? columns ?? 80;\n\n const tokens = useMemo(() => lexWithStreamingFallback(children, streaming), [children, streaming]);\n const elements = useMemo(() => renderBlockTokens(tokens, codeTheme, maxWidth), [tokens, codeTheme, maxWidth]);\n\n return <Box flexDirection=\"column\">{elements}</Box>;\n}\n\nexport { Markdown };\nexport type { Props as MarkdownProps };\n",
|
|
24
|
+
"path": "registry/ui/markdown.tsx",
|
|
25
|
+
"target": "components/ui/markdown.tsx",
|
|
26
|
+
"type": "registry:ui"
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "masked-input",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Masked Input",
|
|
6
|
+
"description": "Masked Input 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 */\nimport Box from \"@visulima/tui/components/box\";\nimport Text from \"@visulima/tui/components/text\";\nimport useInput from \"@visulima/tui/hooks/use-input\";\nimport type { ReactElement } from \"react\";\nimport { useCallback, useMemo, useState } from \"react\";\n\nexport type Props = {\n /**\n * Initial raw value (digits/characters only, without mask symbols).\n */\n readonly defaultValue?: string;\n\n /**\n * Disable input and dim the display.\n */\n readonly isDisabled?: boolean;\n\n /**\n * Mask definition. Each `#` is replaced with a user character; any other\n * character is rendered literally. Example: `(###) ###-####`.\n */\n readonly mask: string;\n\n /**\n * Called whenever the underlying raw value changes.\n */\n readonly onChange?: (value: string) => void;\n\n /**\n * Called when the user presses Enter with the current raw value.\n */\n readonly onSubmit?: (value: string) => void;\n\n /**\n * Placeholder character used in the mask for empty positions.\n * @default \"_\"\n */\n readonly placeholderChar?: string;\n\n /**\n * Sentinel character in the mask string used to mark editable positions.\n * @default \"#\" (hash)\n */\n readonly token?: string;\n};\n\ntype TokenIndex = {\n readonly maskColumn: number;\n readonly rawPosition: number;\n};\n\n/**\n * Compute the mask column for every raw (editable) position.\n */\nconst buildTokenIndex = (mask: string, token: string): ReadonlyArray<TokenIndex> => {\n const indices: TokenIndex[] = [];\n let rawPosition = 0;\n\n // eslint-disable-next-line @typescript-eslint/no-misused-spread -- mask templates are ASCII-only token characters\n for (const [maskColumn, maskChar] of [...mask].entries()) {\n if (maskChar === token) {\n indices.push({ maskColumn, rawPosition });\n rawPosition += 1;\n }\n }\n\n return indices;\n};\n\nconst formatValue = (raw: string, mask: string, token: string, placeholderChar: string): string => {\n let rawIndex = 0;\n let output = \"\";\n\n for (const maskChar of mask) {\n if (maskChar === token) {\n const rawChar = raw[rawIndex];\n\n if (rawChar === undefined) {\n output += placeholderChar;\n } else {\n output += rawChar;\n rawIndex += 1;\n }\n } else {\n output += maskChar;\n }\n }\n\n return output;\n};\n\n/**\n * Input with a fixed-width mask. The mask token (`#` by default) marks\n * positions where the user can type; every other character is preserved.\n * Supports ←/→ to move within the mask, Home/End to jump, and\n * Backspace/Delete to clear characters.\n */\nexport default function MaskedInput({\n defaultValue = \"\",\n isDisabled = false,\n mask,\n onChange,\n onSubmit,\n placeholderChar = \"_\",\n token = \"#\",\n}: Props): ReactElement {\n const tokenIndex = useMemo(() => buildTokenIndex(mask, token), [mask, token]);\n const maxLength = tokenIndex.length;\n\n const clampedDefault = defaultValue.slice(0, maxLength);\n const [raw, setRaw] = useState(clampedDefault);\n const [cursor, setCursor] = useState(clampedDefault.length);\n\n const emit = useCallback(\n (next: string, nextCursor: number) => {\n setRaw(next);\n setCursor(Math.max(0, Math.min(maxLength, nextCursor)));\n onChange?.(next);\n },\n [maxLength, onChange],\n );\n\n useInput(\n useCallback(\n (input, key) => {\n if (key.return) {\n onSubmit?.(raw);\n\n return;\n }\n\n if (key.leftArrow) {\n setCursor((previous) => Math.max(0, previous - 1));\n\n return;\n }\n\n if (key.rightArrow) {\n setCursor((previous) => Math.min(raw.length, previous + 1));\n\n return;\n }\n\n if (key.home || (key.ctrl && input === \"a\")) {\n setCursor(0);\n\n return;\n }\n\n if (key.end || (key.ctrl && input === \"e\")) {\n setCursor(raw.length);\n\n return;\n }\n\n if (key.backspace) {\n if (cursor === 0) {\n return;\n }\n\n const next = raw.slice(0, cursor - 1) + raw.slice(cursor);\n\n emit(next, cursor - 1);\n\n return;\n }\n\n if (key.delete) {\n if (cursor >= raw.length) {\n return;\n }\n\n const next = raw.slice(0, cursor) + raw.slice(cursor + 1);\n\n emit(next, cursor);\n\n return;\n }\n\n if (input.length !== 1 || raw.length >= maxLength) {\n return;\n }\n\n const next = raw.slice(0, cursor) + input + raw.slice(cursor);\n\n emit(next, cursor + 1);\n },\n [raw, cursor, maxLength, emit, onSubmit],\n ),\n { isActive: !isDisabled },\n );\n\n const display = formatValue(raw, mask, token, placeholderChar);\n // Map the raw cursor position onto a mask column so the caret lands on\n // an editable slot, even when the next character is a literal separator.\n const cursorMaskColumn = tokenIndex[cursor]?.maskColumn ?? mask.length;\n const before = display.slice(0, cursorMaskColumn);\n const atCursor = display.charAt(cursorMaskColumn) || \" \";\n const after = display.slice(cursorMaskColumn + 1);\n\n return (\n <Box>\n <Text dimColor={isDisabled}>{before}</Text>\n <Text dimColor={isDisabled} inverse={!isDisabled}>\n {atCursor}\n </Text>\n <Text dimColor={isDisabled}>{after}</Text>\n </Box>\n );\n}\n\nexport { MaskedInput };\nexport type { Props as MaskedInputProps };\n",
|
|
17
|
+
"path": "registry/ui/masked-input.tsx",
|
|
18
|
+
"target": "components/ui/masked-input.tsx",
|
|
19
|
+
"type": "registry:ui"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|