@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,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CommandPalette Component
|
|
3
|
+
*
|
|
4
|
+
* A filterable command list with keyboard navigation. Takes an array of
|
|
5
|
+
* commands with name, description, and optional shortcut. Users can type
|
|
6
|
+
* to filter and navigate with arrow keys / j/k.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* ```tsx
|
|
10
|
+
* const commands = [
|
|
11
|
+
* { name: "Save", description: "Save current file", shortcut: "Ctrl+S" },
|
|
12
|
+
* { name: "Quit", description: "Exit application", shortcut: "Ctrl+Q" },
|
|
13
|
+
* { name: "Help", description: "Show help" },
|
|
14
|
+
* ]
|
|
15
|
+
*
|
|
16
|
+
* <CommandPalette
|
|
17
|
+
* commands={commands}
|
|
18
|
+
* onSelect={(cmd) => exec(cmd.name)}
|
|
19
|
+
* placeholder="Type a command..."
|
|
20
|
+
* />
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
import React, { useCallback, useMemo, useState } from "react"
|
|
24
|
+
import { useInput } from "@silvery/react/hooks/useInput"
|
|
25
|
+
import { Box } from "@silvery/react/components/Box"
|
|
26
|
+
import { Text } from "@silvery/react/components/Text"
|
|
27
|
+
|
|
28
|
+
// =============================================================================
|
|
29
|
+
// Types
|
|
30
|
+
// =============================================================================
|
|
31
|
+
|
|
32
|
+
export interface CommandItem {
|
|
33
|
+
/** Command display name */
|
|
34
|
+
name: string
|
|
35
|
+
/** Command description */
|
|
36
|
+
description?: string
|
|
37
|
+
/** Keyboard shortcut hint */
|
|
38
|
+
shortcut?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CommandPaletteProps {
|
|
42
|
+
/** Available commands */
|
|
43
|
+
commands: CommandItem[]
|
|
44
|
+
/** Called when a command is selected (Enter) */
|
|
45
|
+
onSelect?: (command: CommandItem) => void
|
|
46
|
+
/** Called when the palette is dismissed (Escape) */
|
|
47
|
+
onClose?: () => void
|
|
48
|
+
/** Placeholder text for the filter input (default: "Search commands...") */
|
|
49
|
+
placeholder?: string
|
|
50
|
+
/** Max visible results (default: 10) */
|
|
51
|
+
maxVisible?: number
|
|
52
|
+
/** Whether this component captures input (default: true) */
|
|
53
|
+
isActive?: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// =============================================================================
|
|
57
|
+
// Helpers
|
|
58
|
+
// =============================================================================
|
|
59
|
+
|
|
60
|
+
/** Case-insensitive fuzzy match: all query characters appear in order. */
|
|
61
|
+
function fuzzyMatch(query: string, text: string): boolean {
|
|
62
|
+
const lower = text.toLowerCase()
|
|
63
|
+
const q = query.toLowerCase()
|
|
64
|
+
let qi = 0
|
|
65
|
+
for (let i = 0; i < lower.length && qi < q.length; i++) {
|
|
66
|
+
if (lower[i] === q[qi]) qi++
|
|
67
|
+
}
|
|
68
|
+
return qi === q.length
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// =============================================================================
|
|
72
|
+
// Component
|
|
73
|
+
// =============================================================================
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Filterable command palette with keyboard navigation.
|
|
77
|
+
*
|
|
78
|
+
* Type to filter commands by name, navigate with Up/Down or j/k,
|
|
79
|
+
* confirm with Enter, dismiss with Escape.
|
|
80
|
+
*/
|
|
81
|
+
export function CommandPalette({
|
|
82
|
+
commands,
|
|
83
|
+
onSelect,
|
|
84
|
+
onClose,
|
|
85
|
+
placeholder = "Search commands...",
|
|
86
|
+
maxVisible = 10,
|
|
87
|
+
isActive = true,
|
|
88
|
+
}: CommandPaletteProps): React.ReactElement {
|
|
89
|
+
const [query, setQuery] = useState("")
|
|
90
|
+
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
91
|
+
|
|
92
|
+
const filtered = useMemo(() => {
|
|
93
|
+
if (!query) return commands
|
|
94
|
+
return commands.filter(
|
|
95
|
+
(cmd) => fuzzyMatch(query, cmd.name) || (cmd.description && fuzzyMatch(query, cmd.description)),
|
|
96
|
+
)
|
|
97
|
+
}, [commands, query])
|
|
98
|
+
|
|
99
|
+
const visible = filtered.slice(0, maxVisible)
|
|
100
|
+
|
|
101
|
+
const clampIndex = useCallback((idx: number) => Math.max(0, Math.min(idx, filtered.length - 1)), [filtered.length])
|
|
102
|
+
|
|
103
|
+
useInput(
|
|
104
|
+
(input, key) => {
|
|
105
|
+
// Navigation
|
|
106
|
+
if (key.upArrow) {
|
|
107
|
+
setSelectedIndex((prev) => clampIndex(prev - 1))
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
if (key.downArrow) {
|
|
111
|
+
setSelectedIndex((prev) => clampIndex(prev + 1))
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Select
|
|
116
|
+
if (key.return) {
|
|
117
|
+
const cmd = filtered[selectedIndex]
|
|
118
|
+
if (cmd) onSelect?.(cmd)
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Dismiss
|
|
123
|
+
if (key.escape) {
|
|
124
|
+
onClose?.()
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Backspace
|
|
129
|
+
if (key.backspace || key.delete) {
|
|
130
|
+
setQuery((prev) => {
|
|
131
|
+
const next = prev.slice(0, -1)
|
|
132
|
+
setSelectedIndex(0)
|
|
133
|
+
return next
|
|
134
|
+
})
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Printable character
|
|
139
|
+
if (input && input >= " " && !key.ctrl && !key.meta) {
|
|
140
|
+
setQuery((prev) => {
|
|
141
|
+
setSelectedIndex(0)
|
|
142
|
+
return prev + input
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
{ isActive },
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<Box flexDirection="column" borderStyle="single" borderColor="$border" backgroundColor="$surface-bg" paddingX={1}>
|
|
151
|
+
{/* Search input */}
|
|
152
|
+
<Box>
|
|
153
|
+
<Text color="$primary" bold>
|
|
154
|
+
{">"}{" "}
|
|
155
|
+
</Text>
|
|
156
|
+
<Text>{query || <Text color="$disabledfg">{placeholder}</Text>}</Text>
|
|
157
|
+
</Box>
|
|
158
|
+
<Box>
|
|
159
|
+
<Text color="$border">{"─".repeat(30)}</Text>
|
|
160
|
+
</Box>
|
|
161
|
+
{/* Results */}
|
|
162
|
+
{visible.length === 0 ? (
|
|
163
|
+
<Text color="$disabledfg">No matching commands</Text>
|
|
164
|
+
) : (
|
|
165
|
+
visible.map((cmd, i) => {
|
|
166
|
+
const isSelected = i === selectedIndex
|
|
167
|
+
return (
|
|
168
|
+
<Box key={cmd.name} gap={1}>
|
|
169
|
+
<Text inverse={isSelected} color={isSelected ? "$primary" : "$fg"}>
|
|
170
|
+
{isSelected ? ">" : " "} {cmd.name}
|
|
171
|
+
</Text>
|
|
172
|
+
{cmd.description && <Text color="$muted">{cmd.description}</Text>}
|
|
173
|
+
{cmd.shortcut && (
|
|
174
|
+
<Text color="$disabledfg" bold>
|
|
175
|
+
{cmd.shortcut}
|
|
176
|
+
</Text>
|
|
177
|
+
)}
|
|
178
|
+
</Box>
|
|
179
|
+
)
|
|
180
|
+
})
|
|
181
|
+
)}
|
|
182
|
+
{/* Status */}
|
|
183
|
+
{filtered.length > maxVisible && <Text color="$disabledfg">{filtered.length - maxVisible} more...</Text>}
|
|
184
|
+
</Box>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { ConsoleEntry, PatchedConsole } from "@silvery/term/ansi"
|
|
2
|
+
import type { ReactElement, ReactNode } from "react"
|
|
3
|
+
import { useConsole } from "@silvery/react/hooks/useConsole"
|
|
4
|
+
import { Box } from "@silvery/react/components/Box"
|
|
5
|
+
import { Text } from "@silvery/react/components/Text"
|
|
6
|
+
|
|
7
|
+
interface ConsoleProps {
|
|
8
|
+
/** The patched console to render entries from */
|
|
9
|
+
console: PatchedConsole
|
|
10
|
+
|
|
11
|
+
/** Optional render function for custom entry rendering */
|
|
12
|
+
children?: (entry: ConsoleEntry, index: number) => ReactNode
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Format console entry args into a string.
|
|
17
|
+
* Joins args with spaces, handling objects via JSON.stringify.
|
|
18
|
+
*/
|
|
19
|
+
function formatArgs(args: unknown[]): string {
|
|
20
|
+
return args
|
|
21
|
+
.map((arg) => {
|
|
22
|
+
if (typeof arg === "string") return arg
|
|
23
|
+
if (typeof arg === "number" || typeof arg === "boolean") {
|
|
24
|
+
return String(arg)
|
|
25
|
+
}
|
|
26
|
+
if (arg === null) return "null"
|
|
27
|
+
if (arg === undefined) return "undefined"
|
|
28
|
+
try {
|
|
29
|
+
return JSON.stringify(arg)
|
|
30
|
+
} catch {
|
|
31
|
+
return String(arg)
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
.join(" ")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Renders captured console output from a PatchedConsole.
|
|
39
|
+
*
|
|
40
|
+
* Uses useConsole hook to subscribe to entries and re-renders when new
|
|
41
|
+
* entries arrive. Supports custom rendering via children render prop.
|
|
42
|
+
*
|
|
43
|
+
* @example Default rendering
|
|
44
|
+
* ```tsx
|
|
45
|
+
* import { Console } from '@silvery/react'
|
|
46
|
+
* import { patchConsole } from '@silvery/chalk'
|
|
47
|
+
*
|
|
48
|
+
* using patched = patchConsole(console)
|
|
49
|
+
* <Console console={patched} />
|
|
50
|
+
* ```
|
|
51
|
+
*
|
|
52
|
+
* @example Custom rendering
|
|
53
|
+
* ```tsx
|
|
54
|
+
* <Console console={patched}>
|
|
55
|
+
* {(entry, i) => (
|
|
56
|
+
* <Text key={i} color={entry.stream === 'stderr' ? 'yellow' : 'green'}>
|
|
57
|
+
* [{entry.method}] {entry.args.join(' ')}
|
|
58
|
+
* </Text>
|
|
59
|
+
* )}
|
|
60
|
+
* </Console>
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export function Console({ console: patched, children }: ConsoleProps): ReactElement {
|
|
64
|
+
const entries = useConsole(patched)
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<Box flexDirection="column">
|
|
68
|
+
{entries.map((entry, i) =>
|
|
69
|
+
children ? (
|
|
70
|
+
children(entry, i)
|
|
71
|
+
) : (
|
|
72
|
+
<Text key={i} color={entry.stream === "stderr" ? "red" : undefined}>
|
|
73
|
+
{formatArgs(entry.args)}
|
|
74
|
+
</Text>
|
|
75
|
+
),
|
|
76
|
+
)}
|
|
77
|
+
</Box>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CursorLine Component
|
|
3
|
+
*
|
|
4
|
+
* Renders a single line of text with a visible cursor at a split point.
|
|
5
|
+
* Extracts the duplicated cursor-rendering pattern found across km-tui
|
|
6
|
+
* (inline edit, input box, search bar, etc.) into a reusable primitive.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* ```tsx
|
|
10
|
+
* <CursorLine beforeCursor="hel" afterCursor="lo world" />
|
|
11
|
+
* <CursorLine beforeCursor="full text" afterCursor="" />
|
|
12
|
+
* <CursorLine beforeCursor="" afterCursor="start" cursorStyle="underline" />
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
import React from "react"
|
|
16
|
+
import { Text } from "@silvery/react/components/Text"
|
|
17
|
+
|
|
18
|
+
// =============================================================================
|
|
19
|
+
// Types
|
|
20
|
+
// =============================================================================
|
|
21
|
+
|
|
22
|
+
export interface CursorLineProps {
|
|
23
|
+
/** Text before the cursor position */
|
|
24
|
+
beforeCursor: string
|
|
25
|
+
/** Text after the cursor position (first char gets cursor highlight) */
|
|
26
|
+
afterCursor: string
|
|
27
|
+
/** Text color */
|
|
28
|
+
color?: string
|
|
29
|
+
/** Whether to show the cursor (default: true) */
|
|
30
|
+
showCursor?: boolean
|
|
31
|
+
/** Cursor style: 'block' (inverse) or 'underline' (default: block) */
|
|
32
|
+
cursorStyle?: "block" | "underline"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// =============================================================================
|
|
36
|
+
// Component
|
|
37
|
+
// =============================================================================
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Renders a single line with a visible cursor character.
|
|
41
|
+
*
|
|
42
|
+
* The cursor character is `afterCursor[0]` (or a space when afterCursor is
|
|
43
|
+
* empty, indicating the cursor is at the end of the text). The character is
|
|
44
|
+
* rendered with inverse video (block) or underline styling.
|
|
45
|
+
*/
|
|
46
|
+
export function CursorLine({
|
|
47
|
+
beforeCursor,
|
|
48
|
+
afterCursor,
|
|
49
|
+
color,
|
|
50
|
+
showCursor = true,
|
|
51
|
+
cursorStyle = "block",
|
|
52
|
+
}: CursorLineProps): React.ReactElement {
|
|
53
|
+
if (!showCursor)
|
|
54
|
+
return (
|
|
55
|
+
<Text color={color}>
|
|
56
|
+
{beforeCursor}
|
|
57
|
+
{afterCursor}
|
|
58
|
+
</Text>
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const cursorChar = afterCursor[0] ?? " "
|
|
62
|
+
const rest = afterCursor.slice(1)
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<Text color={color}>
|
|
66
|
+
{beforeCursor}
|
|
67
|
+
{cursorStyle === "block" ? <Text inverse>{cursorChar}</Text> : <Text underline>{cursorChar}</Text>}
|
|
68
|
+
{rest}
|
|
69
|
+
</Text>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Divider Component
|
|
3
|
+
*
|
|
4
|
+
* A horizontal separator line with optional centered title.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* ```tsx
|
|
8
|
+
* <Divider />
|
|
9
|
+
* <Divider title="Section" />
|
|
10
|
+
* <Divider char="=" width={40} />
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
import React from "react"
|
|
14
|
+
import { useContentRect } from "@silvery/react/hooks/useLayout"
|
|
15
|
+
import { Box } from "@silvery/react/components/Box"
|
|
16
|
+
import { Text } from "@silvery/react/components/Text"
|
|
17
|
+
|
|
18
|
+
// =============================================================================
|
|
19
|
+
// Types
|
|
20
|
+
// =============================================================================
|
|
21
|
+
|
|
22
|
+
export interface DividerProps {
|
|
23
|
+
/** Character to repeat (default: "─") */
|
|
24
|
+
char?: string
|
|
25
|
+
/** Title text centered in divider */
|
|
26
|
+
title?: string
|
|
27
|
+
/** Width (default: 100% via useContentRect) */
|
|
28
|
+
width?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// =============================================================================
|
|
32
|
+
// Constants
|
|
33
|
+
// =============================================================================
|
|
34
|
+
|
|
35
|
+
const DEFAULT_CHAR = "─"
|
|
36
|
+
const DEFAULT_WIDTH = 40
|
|
37
|
+
|
|
38
|
+
// =============================================================================
|
|
39
|
+
// Component
|
|
40
|
+
// =============================================================================
|
|
41
|
+
|
|
42
|
+
export function Divider({ char = DEFAULT_CHAR, title, width: widthProp }: DividerProps): React.ReactElement {
|
|
43
|
+
const { width: contentWidth } = useContentRect()
|
|
44
|
+
const totalWidth = widthProp ?? (contentWidth > 0 ? contentWidth : DEFAULT_WIDTH)
|
|
45
|
+
|
|
46
|
+
if (!title) {
|
|
47
|
+
return (
|
|
48
|
+
<Box>
|
|
49
|
+
<Text dimColor>{char.repeat(totalWidth)}</Text>
|
|
50
|
+
</Box>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Title with surrounding lines: "───── Title ─────"
|
|
55
|
+
const titleWithPad = ` ${title} `
|
|
56
|
+
const remaining = Math.max(0, totalWidth - titleWithPad.length)
|
|
57
|
+
const leftLen = Math.floor(remaining / 2)
|
|
58
|
+
const rightLen = remaining - leftLen
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Box>
|
|
62
|
+
<Text dimColor>{char.repeat(leftLen)}</Text>
|
|
63
|
+
<Text bold>{titleWithPad}</Text>
|
|
64
|
+
<Text dimColor>{char.repeat(rightLen)}</Text>
|
|
65
|
+
</Box>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EditContextDisplay Component
|
|
3
|
+
*
|
|
4
|
+
* Pure rendering component for multi-line text display with scrolling.
|
|
5
|
+
* Consumes the output of useEditContext (value + cursor position) and
|
|
6
|
+
* handles word wrapping, viewport scrolling, and cursor highlighting.
|
|
7
|
+
*
|
|
8
|
+
* Unlike TextArea, this component has NO input handling — the command
|
|
9
|
+
* system handles all input via useEditContext's EditTarget. This is the
|
|
10
|
+
* rendering half of the edit context pattern.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* ```tsx
|
|
14
|
+
* const { value, cursor } = useEditContext({ ... })
|
|
15
|
+
* const { width } = useContentRect()
|
|
16
|
+
*
|
|
17
|
+
* <EditContextDisplay
|
|
18
|
+
* value={value}
|
|
19
|
+
* cursor={cursor}
|
|
20
|
+
* height={10}
|
|
21
|
+
* wrapWidth={width}
|
|
22
|
+
* />
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* Scroll logic extracted from TextArea.tsx — same clampScroll pattern
|
|
26
|
+
* that keeps cursor visible within the viewport.
|
|
27
|
+
*/
|
|
28
|
+
import React, { useMemo, useRef } from "react"
|
|
29
|
+
import { cursorToRowCol, getWrappedLines } from "@silvery/tea/text-cursor"
|
|
30
|
+
import { Box } from "@silvery/react/components/Box"
|
|
31
|
+
import { Text } from "@silvery/react/components/Text"
|
|
32
|
+
|
|
33
|
+
// =============================================================================
|
|
34
|
+
// Types
|
|
35
|
+
// =============================================================================
|
|
36
|
+
|
|
37
|
+
export interface EditContextDisplayProps {
|
|
38
|
+
/** Current text value (from useEditContext) */
|
|
39
|
+
value: string
|
|
40
|
+
/** Cursor position as character offset (from useEditContext) */
|
|
41
|
+
cursor: number
|
|
42
|
+
/** Visible height in rows. When omitted, renders all lines (no scrolling). */
|
|
43
|
+
height?: number
|
|
44
|
+
/** Width for word wrapping. When omitted, renders without wrapping. */
|
|
45
|
+
wrapWidth?: number
|
|
46
|
+
/** Cursor style: 'block' (inverse) or 'underline' */
|
|
47
|
+
cursorStyle?: "block" | "underline"
|
|
48
|
+
/** Placeholder text when value is empty */
|
|
49
|
+
placeholder?: string
|
|
50
|
+
/** Whether to show the cursor (default: true) */
|
|
51
|
+
showCursor?: boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// =============================================================================
|
|
55
|
+
// Helpers
|
|
56
|
+
// =============================================================================
|
|
57
|
+
|
|
58
|
+
/** Ensure scroll offset keeps the cursor row visible within the viewport. */
|
|
59
|
+
function clampScroll(cursorRow: number, currentScroll: number, viewportHeight: number): number {
|
|
60
|
+
if (viewportHeight <= 0) return 0
|
|
61
|
+
let scroll = currentScroll
|
|
62
|
+
if (cursorRow < scroll) {
|
|
63
|
+
scroll = cursorRow
|
|
64
|
+
}
|
|
65
|
+
if (cursorRow >= scroll + viewportHeight) {
|
|
66
|
+
scroll = cursorRow - viewportHeight + 1
|
|
67
|
+
}
|
|
68
|
+
return Math.max(0, scroll)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// =============================================================================
|
|
72
|
+
// Component
|
|
73
|
+
// =============================================================================
|
|
74
|
+
|
|
75
|
+
export function EditContextDisplay({
|
|
76
|
+
value,
|
|
77
|
+
cursor,
|
|
78
|
+
height,
|
|
79
|
+
wrapWidth,
|
|
80
|
+
cursorStyle = "block",
|
|
81
|
+
placeholder = "",
|
|
82
|
+
showCursor = true,
|
|
83
|
+
}: EditContextDisplayProps): React.ReactElement {
|
|
84
|
+
// Scroll offset persists across renders via ref. No useState needed because
|
|
85
|
+
// every cursor/value change triggers a re-render from the parent (props change),
|
|
86
|
+
// and we compute the new scroll synchronously during that render.
|
|
87
|
+
const scrollRef = useRef(0)
|
|
88
|
+
|
|
89
|
+
// Effective wrap width: use provided wrapWidth, or a large value for no wrapping
|
|
90
|
+
const effectiveWrapWidth = wrapWidth != null && wrapWidth > 0 ? wrapWidth : 10000
|
|
91
|
+
|
|
92
|
+
// Clamp cursor to valid range
|
|
93
|
+
const clampedCursor = Math.min(Math.max(0, cursor), value.length)
|
|
94
|
+
|
|
95
|
+
// Compute wrapped lines and cursor position
|
|
96
|
+
const wrappedLines = useMemo(() => getWrappedLines(value, effectiveWrapWidth), [value, effectiveWrapWidth])
|
|
97
|
+
|
|
98
|
+
const { row: cursorRow, col: cursorCol } = useMemo(
|
|
99
|
+
() => cursorToRowCol(value, clampedCursor, effectiveWrapWidth),
|
|
100
|
+
[value, clampedCursor, effectiveWrapWidth],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
// Update scroll offset to keep cursor visible (ref-only, no state)
|
|
104
|
+
const hasViewport = height != null && height > 0
|
|
105
|
+
if (hasViewport) {
|
|
106
|
+
scrollRef.current = clampScroll(cursorRow, scrollRef.current, height)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// =========================================================================
|
|
110
|
+
// Placeholder
|
|
111
|
+
// =========================================================================
|
|
112
|
+
|
|
113
|
+
if (!value && placeholder) {
|
|
114
|
+
if (hasViewport) {
|
|
115
|
+
return (
|
|
116
|
+
<Box flexDirection="column" height={height} justifyContent="center" alignItems="center">
|
|
117
|
+
<Text dimColor>{placeholder}</Text>
|
|
118
|
+
</Box>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
return (
|
|
122
|
+
<Box flexDirection="column">
|
|
123
|
+
<Text dimColor>{placeholder}</Text>
|
|
124
|
+
</Box>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// =========================================================================
|
|
129
|
+
// Determine visible lines
|
|
130
|
+
// =========================================================================
|
|
131
|
+
|
|
132
|
+
const currentScroll = hasViewport ? scrollRef.current : 0
|
|
133
|
+
const visibleLines = hasViewport ? wrappedLines.slice(currentScroll, currentScroll + height) : wrappedLines
|
|
134
|
+
|
|
135
|
+
// =========================================================================
|
|
136
|
+
// Render
|
|
137
|
+
// =========================================================================
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<Box key={currentScroll} flexDirection="column" height={hasViewport ? height : undefined}>
|
|
141
|
+
{visibleLines.map((wl, i) => {
|
|
142
|
+
const absoluteRow = currentScroll + i
|
|
143
|
+
const isCursorRow = absoluteRow === cursorRow && showCursor
|
|
144
|
+
|
|
145
|
+
if (!isCursorRow) {
|
|
146
|
+
return <Text key={absoluteRow}>{wl.line || " "}</Text>
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Render line with cursor highlight
|
|
150
|
+
const beforeCursorText = wl.line.slice(0, cursorCol)
|
|
151
|
+
const atCursor = wl.line[cursorCol] ?? " "
|
|
152
|
+
const afterCursorText = wl.line.slice(cursorCol + 1)
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<Text key={absoluteRow}>
|
|
156
|
+
{beforeCursorText}
|
|
157
|
+
{cursorStyle === "block" ? <Text inverse>{atCursor}</Text> : <Text underline>{atCursor}</Text>}
|
|
158
|
+
{afterCursorText}
|
|
159
|
+
</Text>
|
|
160
|
+
)
|
|
161
|
+
})}
|
|
162
|
+
</Box>
|
|
163
|
+
)
|
|
164
|
+
}
|