@silvery/ui 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +71 -0
- package/src/animation/easing.ts +38 -0
- package/src/animation/index.ts +18 -0
- package/src/animation/useAnimation.ts +143 -0
- package/src/animation/useInterval.ts +39 -0
- package/src/animation/useLatest.ts +35 -0
- package/src/animation/useTimeout.ts +65 -0
- package/src/animation/useTransition.ts +110 -0
- package/src/animation.ts +24 -0
- package/src/ansi/index.ts +43 -0
- package/src/canvas/index.ts +169 -0
- package/src/cli/ansi.ts +85 -0
- package/src/cli/index.ts +39 -0
- package/src/cli/multi-progress.ts +340 -0
- package/src/cli/progress-bar.ts +222 -0
- package/src/cli/spinner.ts +275 -0
- package/src/components/Badge.tsx +54 -0
- package/src/components/Breadcrumb.tsx +72 -0
- package/src/components/Button.tsx +73 -0
- package/src/components/CommandPalette.tsx +186 -0
- package/src/components/Console.tsx +79 -0
- package/src/components/CursorLine.tsx +71 -0
- package/src/components/Divider.tsx +67 -0
- package/src/components/EditContextDisplay.tsx +164 -0
- package/src/components/ErrorBoundary.tsx +179 -0
- package/src/components/Form.tsx +86 -0
- package/src/components/GridCell.tsx +42 -0
- package/src/components/HorizontalVirtualList.tsx +375 -0
- package/src/components/ModalDialog.tsx +179 -0
- package/src/components/PickerDialog.tsx +208 -0
- package/src/components/PickerList.tsx +93 -0
- package/src/components/ProgressBar.tsx +126 -0
- package/src/components/Screen.tsx +78 -0
- package/src/components/ScrollbackList.tsx +92 -0
- package/src/components/ScrollbackView.tsx +390 -0
- package/src/components/SelectList.tsx +176 -0
- package/src/components/Skeleton.tsx +87 -0
- package/src/components/Spinner.tsx +64 -0
- package/src/components/SplitView.tsx +199 -0
- package/src/components/Table.tsx +139 -0
- package/src/components/Tabs.tsx +203 -0
- package/src/components/TextArea.tsx +264 -0
- package/src/components/TextInput.tsx +240 -0
- package/src/components/Toast.tsx +216 -0
- package/src/components/Toggle.tsx +73 -0
- package/src/components/Tooltip.tsx +60 -0
- package/src/components/TreeView.tsx +212 -0
- package/src/components/Typography.tsx +233 -0
- package/src/components/VirtualList.tsx +318 -0
- package/src/components/VirtualView.tsx +221 -0
- package/src/components/useReadline.ts +213 -0
- package/src/components/useTextArea.ts +648 -0
- package/src/components.ts +133 -0
- package/src/display/Table.tsx +179 -0
- package/src/display/index.ts +13 -0
- package/src/hooks/useTea.ts +133 -0
- package/src/image/Image.tsx +187 -0
- package/src/image/index.ts +15 -0
- package/src/image/kitty-graphics.ts +161 -0
- package/src/image/sixel-encoder.ts +194 -0
- package/src/images.ts +22 -0
- package/src/index.ts +34 -0
- package/src/input/Select.tsx +155 -0
- package/src/input/TextInput.tsx +227 -0
- package/src/input/index.ts +25 -0
- package/src/progress/als-context.ts +160 -0
- package/src/progress/declarative.ts +519 -0
- package/src/progress/index.ts +54 -0
- package/src/progress/step-node.ts +152 -0
- package/src/progress/steps.ts +425 -0
- package/src/progress/task.ts +138 -0
- package/src/progress/tasks.ts +216 -0
- package/src/react/ProgressBar.tsx +146 -0
- package/src/react/Spinner.tsx +74 -0
- package/src/react/Tasks.tsx +144 -0
- package/src/react/context.tsx +145 -0
- package/src/react/index.ts +30 -0
- package/src/types.ts +252 -0
- package/src/utils/eta.ts +155 -0
- package/src/utils/index.ts +13 -0
- package/src/wrappers/index.ts +36 -0
- package/src/wrappers/with-progress.ts +250 -0
- package/src/wrappers/with-select.ts +194 -0
- package/src/wrappers/with-spinner.ts +108 -0
- package/src/wrappers/with-text-input.ts +388 -0
- package/src/wrappers/wrap-emitter.ts +158 -0
- package/src/wrappers/wrap-generator.ts +143 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* silvery/components -- Rich UI components beyond Ink's built-in set.
|
|
3
|
+
*
|
|
4
|
+
* ```tsx
|
|
5
|
+
* import { VirtualList, Table, SelectList, TextInput, Spinner } from '@silvery/ui/components'
|
|
6
|
+
* ```
|
|
7
|
+
*
|
|
8
|
+
* @packageDocumentation
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// =============================================================================
|
|
12
|
+
// Layout Components
|
|
13
|
+
// =============================================================================
|
|
14
|
+
|
|
15
|
+
export { VirtualList } from "./components/VirtualList"
|
|
16
|
+
export type { VirtualListProps, VirtualListHandle } from "./components/VirtualList"
|
|
17
|
+
|
|
18
|
+
export { HorizontalVirtualList } from "./components/HorizontalVirtualList"
|
|
19
|
+
export type { HorizontalVirtualListProps, HorizontalVirtualListHandle } from "./components/HorizontalVirtualList"
|
|
20
|
+
|
|
21
|
+
export { SplitView } from "./components/SplitView"
|
|
22
|
+
export type { SplitViewProps } from "./components/SplitView"
|
|
23
|
+
export type { LayoutNode as SplitLayoutNode } from "@silvery/term/pane-manager"
|
|
24
|
+
export {
|
|
25
|
+
createLeaf,
|
|
26
|
+
splitPane,
|
|
27
|
+
removePane,
|
|
28
|
+
getPaneIds,
|
|
29
|
+
findAdjacentPane,
|
|
30
|
+
resizeSplit,
|
|
31
|
+
swapPanes,
|
|
32
|
+
getTabOrder as getSplitTabOrder,
|
|
33
|
+
} from "@silvery/term/pane-manager"
|
|
34
|
+
|
|
35
|
+
export { Fill } from "@silvery/react/components/Fill"
|
|
36
|
+
export type { FillProps } from "@silvery/react/components/Fill"
|
|
37
|
+
|
|
38
|
+
export { Link } from "@silvery/react/components/Link"
|
|
39
|
+
export type { LinkProps } from "@silvery/react/components/Link"
|
|
40
|
+
|
|
41
|
+
export { ErrorBoundary } from "./components/ErrorBoundary"
|
|
42
|
+
export type { ErrorBoundaryProps } from "./components/ErrorBoundary"
|
|
43
|
+
|
|
44
|
+
export { Console } from "./components/Console"
|
|
45
|
+
|
|
46
|
+
// =============================================================================
|
|
47
|
+
// Input Components
|
|
48
|
+
// =============================================================================
|
|
49
|
+
|
|
50
|
+
export { TextInput } from "./components/TextInput"
|
|
51
|
+
export type { TextInputProps, TextInputHandle } from "./components/TextInput"
|
|
52
|
+
|
|
53
|
+
export { TextArea } from "./components/TextArea"
|
|
54
|
+
export type { TextAreaProps, TextAreaHandle, TextAreaSelection } from "./components/TextArea"
|
|
55
|
+
|
|
56
|
+
export { useTextArea, clampScroll } from "./components/useTextArea"
|
|
57
|
+
export type { UseTextAreaOptions, UseTextAreaResult } from "./components/useTextArea"
|
|
58
|
+
|
|
59
|
+
export { EditContextDisplay } from "./components/EditContextDisplay"
|
|
60
|
+
export type { EditContextDisplayProps } from "./components/EditContextDisplay"
|
|
61
|
+
|
|
62
|
+
// Display Components
|
|
63
|
+
export { CursorLine } from "./components/CursorLine"
|
|
64
|
+
export type { CursorLineProps } from "./components/CursorLine"
|
|
65
|
+
|
|
66
|
+
// Dialog Components
|
|
67
|
+
export { ModalDialog, formatTitleWithHotkey } from "./components/ModalDialog"
|
|
68
|
+
export type { ModalDialogProps } from "./components/ModalDialog"
|
|
69
|
+
|
|
70
|
+
export { PickerDialog } from "./components/PickerDialog"
|
|
71
|
+
export type { PickerDialogProps } from "./components/PickerDialog"
|
|
72
|
+
|
|
73
|
+
// Focusable Controls
|
|
74
|
+
export { Toggle } from "./components/Toggle"
|
|
75
|
+
export type { ToggleProps } from "./components/Toggle"
|
|
76
|
+
|
|
77
|
+
export { Button } from "./components/Button"
|
|
78
|
+
export type { ButtonProps } from "./components/Button"
|
|
79
|
+
|
|
80
|
+
export { useReadline } from "./components/useReadline"
|
|
81
|
+
export type { ReadlineState, UseReadlineOptions, UseReadlineResult } from "./components/useReadline"
|
|
82
|
+
|
|
83
|
+
// =============================================================================
|
|
84
|
+
// Widget Components
|
|
85
|
+
// =============================================================================
|
|
86
|
+
|
|
87
|
+
export { Spinner } from "./components/Spinner"
|
|
88
|
+
export type { SpinnerProps } from "./components/Spinner"
|
|
89
|
+
|
|
90
|
+
export { ProgressBar } from "./components/ProgressBar"
|
|
91
|
+
export type { ProgressBarProps } from "./components/ProgressBar"
|
|
92
|
+
|
|
93
|
+
export { SelectList } from "./components/SelectList"
|
|
94
|
+
export type { SelectListProps, SelectOption } from "./components/SelectList"
|
|
95
|
+
|
|
96
|
+
export { Table } from "./components/Table"
|
|
97
|
+
export type { TableProps, TableColumn } from "./components/Table"
|
|
98
|
+
|
|
99
|
+
export { Badge } from "./components/Badge"
|
|
100
|
+
export type { BadgeProps } from "./components/Badge"
|
|
101
|
+
|
|
102
|
+
export { Divider } from "./components/Divider"
|
|
103
|
+
export type { DividerProps } from "./components/Divider"
|
|
104
|
+
|
|
105
|
+
// =============================================================================
|
|
106
|
+
// Position Registry (2D Grid Virtualization)
|
|
107
|
+
// =============================================================================
|
|
108
|
+
|
|
109
|
+
export {
|
|
110
|
+
PositionRegistryProvider,
|
|
111
|
+
usePositionRegistry,
|
|
112
|
+
createPositionRegistry,
|
|
113
|
+
} from "@silvery/react/hooks/usePositionRegistry"
|
|
114
|
+
export type { PositionRegistry, ScreenRect } from "@silvery/react/hooks/usePositionRegistry"
|
|
115
|
+
export { useGridPosition } from "@silvery/react/hooks/useGridPosition"
|
|
116
|
+
export { GridCell } from "./components/GridCell"
|
|
117
|
+
export type { GridCellProps } from "./components/GridCell"
|
|
118
|
+
|
|
119
|
+
// =============================================================================
|
|
120
|
+
// Scroll Utilities
|
|
121
|
+
// =============================================================================
|
|
122
|
+
|
|
123
|
+
export { calcEdgeBasedScrollOffset } from "@silvery/term/scroll-utils"
|
|
124
|
+
|
|
125
|
+
export {
|
|
126
|
+
setScrollRegion,
|
|
127
|
+
resetScrollRegion,
|
|
128
|
+
scrollUp,
|
|
129
|
+
scrollDown,
|
|
130
|
+
moveCursor,
|
|
131
|
+
supportsScrollRegions,
|
|
132
|
+
} from "@silvery/term/scroll-region"
|
|
133
|
+
export type { ScrollRegionConfig } from "@silvery/term/scroll-region"
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Table component for silvery/Ink TUI apps
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import React from "react"
|
|
6
|
+
import type { TableProps, TableColumn } from "../types.js"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Unicode box drawing characters for borders
|
|
10
|
+
*/
|
|
11
|
+
const BOX = {
|
|
12
|
+
topLeft: "┌",
|
|
13
|
+
topRight: "┐",
|
|
14
|
+
bottomLeft: "└",
|
|
15
|
+
bottomRight: "┘",
|
|
16
|
+
horizontal: "─",
|
|
17
|
+
vertical: "│",
|
|
18
|
+
leftT: "├",
|
|
19
|
+
rightT: "┤",
|
|
20
|
+
topT: "┬",
|
|
21
|
+
bottomT: "┴",
|
|
22
|
+
cross: "┼",
|
|
23
|
+
} as const
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Data grid display component for React TUI apps
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```tsx
|
|
30
|
+
* import { Table } from "@silvery/ui/display";
|
|
31
|
+
*
|
|
32
|
+
* const columns = [
|
|
33
|
+
* { key: "name", header: "Name", width: 20 },
|
|
34
|
+
* { key: "status", header: "Status", width: 10, align: "center" },
|
|
35
|
+
* { key: "count", header: "Count", width: 8, align: "right" },
|
|
36
|
+
* ];
|
|
37
|
+
*
|
|
38
|
+
* const data = [
|
|
39
|
+
* { name: "Item 1", status: "active", count: 42 },
|
|
40
|
+
* { name: "Item 2", status: "pending", count: 7 },
|
|
41
|
+
* ];
|
|
42
|
+
*
|
|
43
|
+
* function DataView() {
|
|
44
|
+
* return <Table columns={columns} data={data} border />;
|
|
45
|
+
* }
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function Table({ columns, data, border = false }: TableProps): React.ReactElement {
|
|
49
|
+
// Calculate effective column widths
|
|
50
|
+
const effectiveColumns = calculateColumnWidths(columns, data)
|
|
51
|
+
|
|
52
|
+
const lines: string[] = []
|
|
53
|
+
|
|
54
|
+
if (border) {
|
|
55
|
+
// Top border
|
|
56
|
+
lines.push(buildBorderLine(effectiveColumns, "top"))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Header row
|
|
60
|
+
lines.push(buildDataRow(effectiveColumns, getHeaderRow(effectiveColumns), border))
|
|
61
|
+
|
|
62
|
+
if (border) {
|
|
63
|
+
// Separator after header
|
|
64
|
+
lines.push(buildBorderLine(effectiveColumns, "middle"))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Data rows
|
|
68
|
+
for (const row of data) {
|
|
69
|
+
lines.push(buildDataRow(effectiveColumns, row, border))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (border) {
|
|
73
|
+
// Bottom border
|
|
74
|
+
lines.push(buildBorderLine(effectiveColumns, "bottom"))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<span data-table data-border={border}>
|
|
79
|
+
{lines.join("\n")}
|
|
80
|
+
</span>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Calculate effective column widths based on content if not specified
|
|
86
|
+
*/
|
|
87
|
+
function calculateColumnWidths(
|
|
88
|
+
columns: TableColumn[],
|
|
89
|
+
data: Array<Record<string, unknown>>,
|
|
90
|
+
): Array<TableColumn & { effectiveWidth: number }> {
|
|
91
|
+
return columns.map((col) => {
|
|
92
|
+
if (col.width !== undefined) {
|
|
93
|
+
return { ...col, effectiveWidth: col.width }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Calculate width from content
|
|
97
|
+
let maxWidth = col.header.length
|
|
98
|
+
|
|
99
|
+
for (const row of data) {
|
|
100
|
+
const value = String(row[col.key] ?? "")
|
|
101
|
+
maxWidth = Math.max(maxWidth, value.length)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { ...col, effectiveWidth: maxWidth }
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Create header row object from columns
|
|
110
|
+
*/
|
|
111
|
+
function getHeaderRow(columns: Array<TableColumn & { effectiveWidth: number }>): Record<string, unknown> {
|
|
112
|
+
const row: Record<string, unknown> = {}
|
|
113
|
+
for (const col of columns) {
|
|
114
|
+
row[col.key] = col.header
|
|
115
|
+
}
|
|
116
|
+
return row
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build a border line (top, middle, or bottom)
|
|
121
|
+
*/
|
|
122
|
+
function buildBorderLine(
|
|
123
|
+
columns: Array<TableColumn & { effectiveWidth: number }>,
|
|
124
|
+
position: "top" | "middle" | "bottom",
|
|
125
|
+
): string {
|
|
126
|
+
const left = position === "top" ? BOX.topLeft : position === "bottom" ? BOX.bottomLeft : BOX.leftT
|
|
127
|
+
const right = position === "top" ? BOX.topRight : position === "bottom" ? BOX.bottomRight : BOX.rightT
|
|
128
|
+
const join = position === "top" ? BOX.topT : position === "bottom" ? BOX.bottomT : BOX.cross
|
|
129
|
+
|
|
130
|
+
const segments = columns.map((col) => BOX.horizontal.repeat(col.effectiveWidth + 2))
|
|
131
|
+
|
|
132
|
+
return left + segments.join(join) + right
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Build a data row (header or content)
|
|
137
|
+
*/
|
|
138
|
+
function buildDataRow(
|
|
139
|
+
columns: Array<TableColumn & { effectiveWidth: number }>,
|
|
140
|
+
row: Record<string, unknown>,
|
|
141
|
+
border: boolean,
|
|
142
|
+
): string {
|
|
143
|
+
const cells = columns.map((col) => {
|
|
144
|
+
const value = String(row[col.key] ?? "")
|
|
145
|
+
return formatCell(value, col.effectiveWidth, col.align ?? "left")
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
if (border) {
|
|
149
|
+
return BOX.vertical + " " + cells.join(" " + BOX.vertical + " ") + " " + BOX.vertical
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return cells.join(" ")
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Format a cell value with alignment and truncation
|
|
157
|
+
*/
|
|
158
|
+
function formatCell(value: string, width: number, align: "left" | "center" | "right"): string {
|
|
159
|
+
// Truncate if too long
|
|
160
|
+
if (value.length > width) {
|
|
161
|
+
return value.slice(0, width - 1) + "…"
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Pad according to alignment
|
|
165
|
+
const padding = width - value.length
|
|
166
|
+
|
|
167
|
+
switch (align) {
|
|
168
|
+
case "right":
|
|
169
|
+
return " ".repeat(padding) + value
|
|
170
|
+
case "center": {
|
|
171
|
+
const leftPad = Math.floor(padding / 2)
|
|
172
|
+
const rightPad = padding - leftPad
|
|
173
|
+
return " ".repeat(leftPad) + value + " ".repeat(rightPad)
|
|
174
|
+
}
|
|
175
|
+
case "left":
|
|
176
|
+
default:
|
|
177
|
+
return value + " ".repeat(padding)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display components for silvery/Ink TUI apps
|
|
3
|
+
*
|
|
4
|
+
* These are read-only display components (no input handling).
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```tsx
|
|
8
|
+
* import { Table } from "@silvery/ui/display";
|
|
9
|
+
* ```
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export { Table } from "./Table"
|
|
13
|
+
export type { TableColumn, TableProps } from "../types.js"
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useTea — React hook for TEA (The Elm Architecture) state machines.
|
|
3
|
+
*
|
|
4
|
+
* Like useReducer, but the reducer can return [state, effects].
|
|
5
|
+
* Effects are plain data objects executed by runners. Built-in timer
|
|
6
|
+
* runners handle delay/interval/cancel. All timers auto-cleanup on unmount.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```tsx
|
|
10
|
+
* import { useTea } from "silvery"
|
|
11
|
+
* import { fx } from "@silvery/tea/effects"
|
|
12
|
+
*
|
|
13
|
+
* type State = { count: number; running: boolean }
|
|
14
|
+
* type Msg = { type: "start" } | { type: "tick" } | { type: "stop" }
|
|
15
|
+
*
|
|
16
|
+
* function update(state: State, msg: Msg) {
|
|
17
|
+
* switch (msg.type) {
|
|
18
|
+
* case "start":
|
|
19
|
+
* return [{ ...state, running: true }, [fx.interval(100, { type: "tick" }, "counter")]]
|
|
20
|
+
* case "tick":
|
|
21
|
+
* return { ...state, count: state.count + 1 }
|
|
22
|
+
* case "stop":
|
|
23
|
+
* return [{ ...state, running: false }, [fx.cancel("counter")]]
|
|
24
|
+
* }
|
|
25
|
+
* }
|
|
26
|
+
*
|
|
27
|
+
* function Counter() {
|
|
28
|
+
* const [state, send] = useTea({ count: 0, running: false }, update)
|
|
29
|
+
* return <Text>Count: {state.count}</Text>
|
|
30
|
+
* }
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { useCallback, useEffect, useRef, useReducer } from "react"
|
|
35
|
+
import type { EffectLike, EffectRunners, TeaResult } from "@silvery/tea/tea"
|
|
36
|
+
import { collect } from "@silvery/tea/tea"
|
|
37
|
+
import { createTimerRunners, type TimerEffect } from "@silvery/tea/effects"
|
|
38
|
+
|
|
39
|
+
// =============================================================================
|
|
40
|
+
// Hook
|
|
41
|
+
// =============================================================================
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* TEA state machine hook with automatic timer management.
|
|
45
|
+
*
|
|
46
|
+
* The update function can return plain state (no effects) or `[state, effects]`.
|
|
47
|
+
* Timer effects (delay, interval, cancel) are handled automatically.
|
|
48
|
+
* Additional effect runners can be provided for custom effects.
|
|
49
|
+
*
|
|
50
|
+
* All timers are cleaned up automatically on unmount.
|
|
51
|
+
*
|
|
52
|
+
* @param initialState - Initial state value
|
|
53
|
+
* @param update - Pure update function: `(state, msg) => state | [state, effects]`
|
|
54
|
+
* @param customRunners - Optional additional effect runners for non-timer effects
|
|
55
|
+
* @returns `[state, send]` tuple — send dispatches a message through the update function
|
|
56
|
+
*/
|
|
57
|
+
export function useTea<S, Msg, E extends EffectLike = TimerEffect<Msg>>(
|
|
58
|
+
initialState: S | (() => S),
|
|
59
|
+
update: (state: S, msg: Msg) => TeaResult<S, E>,
|
|
60
|
+
customRunners?: EffectRunners<E, Msg>,
|
|
61
|
+
): [S, (msg: Msg) => void] {
|
|
62
|
+
// Create timer runners once (stable across renders)
|
|
63
|
+
const timerRef = useRef<ReturnType<typeof createTimerRunners<Msg>> | null>(null)
|
|
64
|
+
if (timerRef.current === null) {
|
|
65
|
+
timerRef.current = createTimerRunners<Msg>()
|
|
66
|
+
}
|
|
67
|
+
const { runners: timerRunners, cleanup } = timerRef.current
|
|
68
|
+
|
|
69
|
+
// Keep custom runners ref-stable
|
|
70
|
+
const customRunnersRef = useRef(customRunners)
|
|
71
|
+
customRunnersRef.current = customRunners
|
|
72
|
+
|
|
73
|
+
// Pending effects queue — effects from the reducer can't be executed
|
|
74
|
+
// during render, so we queue them and execute in a useEffect.
|
|
75
|
+
const pendingEffectsRef = useRef<E[]>([])
|
|
76
|
+
|
|
77
|
+
// Use React's useReducer for state — it integrates with React's scheduler
|
|
78
|
+
const [state, reactDispatch] = useReducer(
|
|
79
|
+
(prevState: S, msg: Msg): S => {
|
|
80
|
+
const result = update(prevState, msg)
|
|
81
|
+
const [newState, effects] = collect(result)
|
|
82
|
+
if (effects.length > 0) {
|
|
83
|
+
pendingEffectsRef.current.push(...effects)
|
|
84
|
+
}
|
|
85
|
+
return newState
|
|
86
|
+
},
|
|
87
|
+
undefined,
|
|
88
|
+
() => (typeof initialState === "function" ? (initialState as () => S)() : initialState),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
// Execute effects outside of render (React rules)
|
|
92
|
+
const sendRef = useRef<(msg: Msg) => void>(() => {})
|
|
93
|
+
|
|
94
|
+
const executeEffects = useCallback(() => {
|
|
95
|
+
if (pendingEffectsRef.current.length === 0) return
|
|
96
|
+
const effects = pendingEffectsRef.current.splice(0)
|
|
97
|
+
for (const effect of effects) {
|
|
98
|
+
// Try timer runners first
|
|
99
|
+
const timerRunner = timerRunners[effect.type as keyof typeof timerRunners]
|
|
100
|
+
if (timerRunner) {
|
|
101
|
+
;(timerRunner as (e: any, d: (msg: Msg) => void) => void)(effect, sendRef.current)
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
// Try custom runners
|
|
105
|
+
const customRunner = customRunnersRef.current?.[effect.type as E["type"]]
|
|
106
|
+
if (customRunner) {
|
|
107
|
+
;(customRunner as (e: any, d: (msg: Msg) => void) => void)(effect, sendRef.current)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}, [timerRunners])
|
|
111
|
+
|
|
112
|
+
// The send function: dispatch to React, then execute effects
|
|
113
|
+
const send = useCallback(
|
|
114
|
+
(msg: Msg) => {
|
|
115
|
+
reactDispatch(msg)
|
|
116
|
+
// Effects are queued by the reducer — execute them after React processes the update.
|
|
117
|
+
// We use queueMicrotask to ensure effects run after the reducer but before the next paint.
|
|
118
|
+
queueMicrotask(executeEffects)
|
|
119
|
+
},
|
|
120
|
+
[reactDispatch, executeEffects],
|
|
121
|
+
)
|
|
122
|
+
sendRef.current = send
|
|
123
|
+
|
|
124
|
+
// Execute any effects from the initial render
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
executeEffects()
|
|
127
|
+
}, [executeEffects])
|
|
128
|
+
|
|
129
|
+
// Cleanup all timers on unmount
|
|
130
|
+
useEffect(() => cleanup, [cleanup])
|
|
131
|
+
|
|
132
|
+
return [state, send]
|
|
133
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image Component
|
|
3
|
+
*
|
|
4
|
+
* Renders bitmap images in supported terminals using the Kitty graphics
|
|
5
|
+
* protocol (primary) or Sixel (fallback). When neither is supported,
|
|
6
|
+
* displays a text placeholder.
|
|
7
|
+
*
|
|
8
|
+
* Since terminal images are escape-sequence-based and don't fit the cell
|
|
9
|
+
* buffer model, the component reserves visual space with a Box of the
|
|
10
|
+
* requested dimensions and uses `useEffect` to write image data directly
|
|
11
|
+
* to stdout after render.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```tsx
|
|
15
|
+
* import { readFileSync } from "fs"
|
|
16
|
+
* import { Image } from "@silvery/react"
|
|
17
|
+
*
|
|
18
|
+
* const png = readFileSync("photo.png")
|
|
19
|
+
* <Image src={png} width={40} height={20} />
|
|
20
|
+
*
|
|
21
|
+
* // With file path
|
|
22
|
+
* <Image src="/path/to/image.png" width={40} height={20} />
|
|
23
|
+
*
|
|
24
|
+
* // Auto-detect protocol, fall back to text
|
|
25
|
+
* <Image src={png} width={40} height={20} fallback="[photo]" />
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { readFileSync } from "node:fs"
|
|
30
|
+
import { type JSX, useContext, useEffect, useMemo, useRef } from "react"
|
|
31
|
+
import { StdoutContext } from "@silvery/react/context"
|
|
32
|
+
import { useContentRect } from "@silvery/react/hooks/useLayout"
|
|
33
|
+
import { encodeKittyImage, isKittyGraphicsSupported, deleteKittyImage } from "./kitty-graphics"
|
|
34
|
+
import { isSixelSupported } from "./sixel-encoder"
|
|
35
|
+
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// Types
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
export type ImageProtocol = "kitty" | "sixel" | "auto"
|
|
41
|
+
|
|
42
|
+
export interface ImageProps {
|
|
43
|
+
/** PNG image data (Buffer) or file path (string) to a PNG file */
|
|
44
|
+
src: Buffer | string
|
|
45
|
+
/** Width in terminal columns. If omitted, uses available width from layout. */
|
|
46
|
+
width?: number
|
|
47
|
+
/** Height in terminal rows. If omitted, defaults to half the width (rough aspect ratio). */
|
|
48
|
+
height?: number
|
|
49
|
+
/** Text to display when image rendering is not supported. Default: "[image]" */
|
|
50
|
+
fallback?: string
|
|
51
|
+
/** Which protocol to use. Default: "auto" (tries Kitty, then Sixel, then fallback) */
|
|
52
|
+
protocol?: ImageProtocol
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ============================================================================
|
|
56
|
+
// Protocol Detection
|
|
57
|
+
// ============================================================================
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Determine the best available image protocol.
|
|
61
|
+
* Returns null if no image protocol is available.
|
|
62
|
+
*/
|
|
63
|
+
function detectProtocol(preferred: ImageProtocol): "kitty" | "sixel" | null {
|
|
64
|
+
if (preferred === "kitty") {
|
|
65
|
+
return isKittyGraphicsSupported() ? "kitty" : null
|
|
66
|
+
}
|
|
67
|
+
if (preferred === "sixel") {
|
|
68
|
+
return isSixelSupported() ? "sixel" : null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Auto-detect: prefer Kitty, fall back to Sixel
|
|
72
|
+
if (isKittyGraphicsSupported()) return "kitty"
|
|
73
|
+
if (isSixelSupported()) return "sixel"
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ============================================================================
|
|
78
|
+
// Component
|
|
79
|
+
// ============================================================================
|
|
80
|
+
|
|
81
|
+
/** Incrementing image ID counter for Kitty protocol */
|
|
82
|
+
let nextImageId = 1
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Renders a bitmap image in the terminal.
|
|
86
|
+
*
|
|
87
|
+
* The component operates in two phases:
|
|
88
|
+
* 1. **Layout phase**: Renders a Box that reserves the visual space
|
|
89
|
+
* (filled with spaces so the cell buffer has the right dimensions).
|
|
90
|
+
* 2. **Effect phase**: After render, writes the image escape sequence
|
|
91
|
+
* directly to stdout, positioned over the reserved space.
|
|
92
|
+
*
|
|
93
|
+
* When image protocols are not available, the fallback text is shown instead.
|
|
94
|
+
*/
|
|
95
|
+
export function Image({
|
|
96
|
+
src,
|
|
97
|
+
width: requestedWidth,
|
|
98
|
+
height: requestedHeight,
|
|
99
|
+
fallback = "[image]",
|
|
100
|
+
protocol: preferredProtocol = "auto",
|
|
101
|
+
}: ImageProps): JSX.Element {
|
|
102
|
+
const contentRect = useContentRect()
|
|
103
|
+
const stdoutCtx = useContext(StdoutContext)
|
|
104
|
+
const imageIdRef = useRef<number | null>(null)
|
|
105
|
+
|
|
106
|
+
// Resolve image data
|
|
107
|
+
const pngData = useMemo(() => {
|
|
108
|
+
if (Buffer.isBuffer(src)) return src
|
|
109
|
+
// String path — read file synchronously (during render is fine for a path)
|
|
110
|
+
try {
|
|
111
|
+
return readFileSync(src)
|
|
112
|
+
} catch {
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
}, [src])
|
|
116
|
+
|
|
117
|
+
// Determine effective dimensions
|
|
118
|
+
const effectiveWidth = requestedWidth ?? contentRect.width
|
|
119
|
+
const effectiveHeight = requestedHeight ?? Math.max(1, Math.floor(effectiveWidth / 2))
|
|
120
|
+
|
|
121
|
+
// Detect protocol support
|
|
122
|
+
const activeProtocol = useMemo(() => detectProtocol(preferredProtocol), [preferredProtocol])
|
|
123
|
+
|
|
124
|
+
// Assign a stable image ID for Kitty (for cleanup on unmount)
|
|
125
|
+
if (activeProtocol === "kitty" && imageIdRef.current == null) {
|
|
126
|
+
imageIdRef.current = nextImageId++
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Write image escape sequences after render
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (!pngData || !stdoutCtx || !activeProtocol) return
|
|
132
|
+
if (effectiveWidth <= 0 || effectiveHeight <= 0) return
|
|
133
|
+
|
|
134
|
+
const { write } = stdoutCtx
|
|
135
|
+
|
|
136
|
+
if (activeProtocol === "kitty") {
|
|
137
|
+
const seq = encodeKittyImage(pngData, {
|
|
138
|
+
width: effectiveWidth,
|
|
139
|
+
height: effectiveHeight,
|
|
140
|
+
id: imageIdRef.current ?? undefined,
|
|
141
|
+
})
|
|
142
|
+
write(seq)
|
|
143
|
+
} else if (activeProtocol === "sixel") {
|
|
144
|
+
// For Sixel, we would need the decoded pixel data.
|
|
145
|
+
// Since we receive PNG, and decoding PNG requires a library,
|
|
146
|
+
// Sixel rendering from raw PNG is deferred. The Kitty protocol
|
|
147
|
+
// can transmit PNG directly (f=100), but Sixel cannot.
|
|
148
|
+
// For now, Sixel only works if src is already decoded pixel data.
|
|
149
|
+
// This is a known limitation noted in the module docs.
|
|
150
|
+
//
|
|
151
|
+
// If someone passes a Buffer that's already RGBA pixel data
|
|
152
|
+
// (not PNG), this would need a flag. For now, Sixel falls through
|
|
153
|
+
// to fallback when src is PNG.
|
|
154
|
+
}
|
|
155
|
+
}, [pngData, stdoutCtx, activeProtocol, effectiveWidth, effectiveHeight])
|
|
156
|
+
|
|
157
|
+
// Cleanup: delete Kitty image on unmount
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
const id = imageIdRef.current
|
|
160
|
+
if (activeProtocol !== "kitty" || id == null || !stdoutCtx) return
|
|
161
|
+
|
|
162
|
+
return () => {
|
|
163
|
+
stdoutCtx.write(deleteKittyImage(id))
|
|
164
|
+
}
|
|
165
|
+
}, [activeProtocol, stdoutCtx])
|
|
166
|
+
|
|
167
|
+
// If no protocol or no image data, render fallback text
|
|
168
|
+
if (!activeProtocol || !pngData) {
|
|
169
|
+
return (
|
|
170
|
+
<silvery-box width={effectiveWidth} height={effectiveHeight}>
|
|
171
|
+
<silvery-text>{fallback}</silvery-text>
|
|
172
|
+
</silvery-box>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Reserve visual space with an empty box.
|
|
177
|
+
// The image is drawn over this space via stdout escape sequences.
|
|
178
|
+
// Fill with spaces so the cell buffer allocates the right area.
|
|
179
|
+
const spaceLine = " ".repeat(Math.max(0, effectiveWidth))
|
|
180
|
+
const spaceContent = Array.from({ length: Math.max(0, effectiveHeight) }, () => spaceLine).join("\n")
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<silvery-box width={effectiveWidth} height={effectiveHeight}>
|
|
184
|
+
<silvery-text>{spaceContent}</silvery-text>
|
|
185
|
+
</silvery-box>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image rendering support for silvery.
|
|
3
|
+
*
|
|
4
|
+
* Provides encoders for the Kitty graphics protocol and Sixel protocol,
|
|
5
|
+
* plus a React component for rendering images in terminal UIs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { encodeKittyImage, deleteKittyImage, isKittyGraphicsSupported } from "./kitty-graphics"
|
|
9
|
+
export type { KittyImageOptions } from "./kitty-graphics"
|
|
10
|
+
|
|
11
|
+
export { encodeSixel, isSixelSupported } from "./sixel-encoder"
|
|
12
|
+
export type { SixelImageData } from "./sixel-encoder"
|
|
13
|
+
|
|
14
|
+
export { Image } from "./Image"
|
|
15
|
+
export type { ImageProps } from "./Image"
|