@silvery/examples 0.5.2 → 0.5.3
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/LICENSE +21 -0
- package/{examples/apps → apps}/aichat/index.tsx +4 -3
- package/{examples/apps → apps}/async-data.tsx +4 -4
- package/apps/components.tsx +658 -0
- package/{examples/apps → apps}/data-explorer.tsx +8 -8
- package/{examples/apps → apps}/dev-tools.tsx +35 -19
- package/{examples/apps → apps}/inline-bench.tsx +3 -1
- package/{examples/apps → apps}/kanban.tsx +20 -22
- package/{examples/apps → apps}/layout-ref.tsx +6 -6
- package/{examples/apps → apps}/panes/index.tsx +1 -1
- package/{examples/apps → apps}/paste-demo.tsx +2 -2
- package/{examples/apps → apps}/scroll.tsx +2 -2
- package/{examples/apps → apps}/search-filter.tsx +1 -1
- package/apps/selection.tsx +342 -0
- package/apps/spatial-focus-demo.tsx +368 -0
- package/{examples/apps → apps}/task-list.tsx +1 -1
- package/apps/terminal-caps-demo.tsx +334 -0
- package/apps/text-selection-demo.tsx +189 -0
- package/apps/textarea.tsx +155 -0
- package/{examples/apps → apps}/theme.tsx +1 -1
- package/apps/vterm-demo/index.tsx +216 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +190 -0
- package/dist/cli.mjs.map +1 -0
- package/layout/dashboard.tsx +953 -0
- package/layout/live-resize.tsx +282 -0
- package/layout/overflow.tsx +51 -0
- package/layout/text-layout.tsx +283 -0
- package/package.json +27 -11
- package/bin/cli.ts +0 -294
- package/examples/apps/components.tsx +0 -463
- package/examples/apps/textarea.tsx +0 -91
- /package/{examples/_banner.tsx → _banner.tsx} +0 -0
- /package/{examples/apps → apps}/aichat/components.tsx +0 -0
- /package/{examples/apps → apps}/aichat/script.ts +0 -0
- /package/{examples/apps → apps}/aichat/state.ts +0 -0
- /package/{examples/apps → apps}/aichat/types.ts +0 -0
- /package/{examples/apps → apps}/app-todo.tsx +0 -0
- /package/{examples/apps → apps}/cli-wizard.tsx +0 -0
- /package/{examples/apps → apps}/clipboard.tsx +0 -0
- /package/{examples/apps → apps}/explorer.tsx +0 -0
- /package/{examples/apps → apps}/gallery.tsx +0 -0
- /package/{examples/apps → apps}/outline.tsx +0 -0
- /package/{examples/apps → apps}/terminal.tsx +0 -0
- /package/{examples/apps → apps}/transform.tsx +0 -0
- /package/{examples/apps → apps}/virtual-10k.tsx +0 -0
- /package/{examples/components → components}/counter.tsx +0 -0
- /package/{examples/components → components}/hello.tsx +0 -0
- /package/{examples/components → components}/progress-bar.tsx +0 -0
- /package/{examples/components → components}/select-list.tsx +0 -0
- /package/{examples/components → components}/spinner.tsx +0 -0
- /package/{examples/components → components}/text-input.tsx +0 -0
- /package/{examples/components → components}/virtual-list.tsx +0 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal Capabilities Demo
|
|
3
|
+
*
|
|
4
|
+
* A "terminal health check" dashboard that probes all supported terminal
|
|
5
|
+
* protocols and displays their status in real time.
|
|
6
|
+
*
|
|
7
|
+
* Probed protocols:
|
|
8
|
+
* - Synchronized Output (Mode 2026)
|
|
9
|
+
* - SGR Mouse (Mode 1006)
|
|
10
|
+
* - Bracketed Paste (Mode 2004)
|
|
11
|
+
* - Focus Reporting (Mode 1004)
|
|
12
|
+
* - Kitty Keyboard (CSI u)
|
|
13
|
+
* - Mode 2031 Color Scheme Detection
|
|
14
|
+
* - DEC 1020-1023 Width Detection (UTF-8, CJK, Emoji, Private-Use)
|
|
15
|
+
* - OSC 66 Text Sizing
|
|
16
|
+
* - OSC 52 Clipboard
|
|
17
|
+
* - OSC 5522 Advanced Clipboard
|
|
18
|
+
* - OSC 8 Hyperlinks
|
|
19
|
+
* - Image Support (Kitty Graphics / Sixel)
|
|
20
|
+
* - DA1/DA2/DA3 Device Attributes
|
|
21
|
+
*
|
|
22
|
+
* Run: bun vendor/silvery/examples/apps/terminal-caps-demo.tsx
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import React, { useState } from "react"
|
|
26
|
+
import { Box, Text, H3, Muted, Kbd, render, useInput, useApp, type Key } from "silvery"
|
|
27
|
+
import {
|
|
28
|
+
detectTerminalCaps,
|
|
29
|
+
type TerminalCaps,
|
|
30
|
+
createWidthDetector,
|
|
31
|
+
type TerminalWidthConfig,
|
|
32
|
+
DEFAULT_WIDTH_CONFIG,
|
|
33
|
+
detectKittyFromStdio,
|
|
34
|
+
} from "@silvery/ag-term"
|
|
35
|
+
import { createColorSchemeDetector, type ColorScheme } from "@silvery/ag-term/ansi"
|
|
36
|
+
import { ExampleBanner, type ExampleMeta } from "../_banner.js"
|
|
37
|
+
|
|
38
|
+
export const meta: ExampleMeta = {
|
|
39
|
+
name: "Terminal Capabilities",
|
|
40
|
+
description: "Probe and display all supported terminal protocols",
|
|
41
|
+
demo: true,
|
|
42
|
+
features: ["detectTerminalCaps()", "Mode 2031", "DEC 1020-1023", "OSC 66", "OSC 52", "OSC 5522", "DA1/DA2/DA3"],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ============================================================================
|
|
46
|
+
// Types
|
|
47
|
+
// ============================================================================
|
|
48
|
+
|
|
49
|
+
type CapStatus = "supported" | "not-supported" | "probing" | "detected"
|
|
50
|
+
|
|
51
|
+
interface CapEntry {
|
|
52
|
+
name: string
|
|
53
|
+
status: CapStatus
|
|
54
|
+
detail?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ============================================================================
|
|
58
|
+
// Status indicator component
|
|
59
|
+
// ============================================================================
|
|
60
|
+
|
|
61
|
+
function StatusIcon({ status }: { status: CapStatus }) {
|
|
62
|
+
switch (status) {
|
|
63
|
+
case "supported":
|
|
64
|
+
return <Text color="$success">{"✓"}</Text>
|
|
65
|
+
case "not-supported":
|
|
66
|
+
return <Text color="$error">{"✗"}</Text>
|
|
67
|
+
case "probing":
|
|
68
|
+
return <Text color="$warning">{"?"}</Text>
|
|
69
|
+
case "detected":
|
|
70
|
+
return <Text color="$warning">{"?"}</Text>
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function CapRow({ entry, width }: { entry: CapEntry; width: number }) {
|
|
75
|
+
const label = entry.detail ? `${entry.name}: ${entry.detail}` : entry.name
|
|
76
|
+
const padded = label.length < width ? label + " ".repeat(width - label.length) : label
|
|
77
|
+
return (
|
|
78
|
+
<Box>
|
|
79
|
+
<StatusIcon status={entry.status} />
|
|
80
|
+
<Text> </Text>
|
|
81
|
+
<Text color={entry.status === "not-supported" ? "$muted" : undefined}>{padded}</Text>
|
|
82
|
+
</Box>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ============================================================================
|
|
87
|
+
// Build capability entries from detected caps
|
|
88
|
+
// ============================================================================
|
|
89
|
+
|
|
90
|
+
function buildStaticEntries(caps: TerminalCaps): CapEntry[] {
|
|
91
|
+
const bool = (supported: boolean): CapStatus => (supported ? "supported" : "not-supported")
|
|
92
|
+
|
|
93
|
+
return [
|
|
94
|
+
{ name: "Synchronized Output (Mode 2026)", status: bool(caps.syncOutput) },
|
|
95
|
+
{ name: "SGR Mouse (Mode 1006)", status: bool(caps.mouse) },
|
|
96
|
+
{ name: "Bracketed Paste (Mode 2004)", status: bool(caps.bracketedPaste) },
|
|
97
|
+
{ name: "Focus Reporting (Mode 1004)", status: bool(caps.bracketedPaste) }, // focus follows paste support heuristic
|
|
98
|
+
{ name: "Kitty Keyboard (CSI u)", status: bool(caps.kittyKeyboard) },
|
|
99
|
+
{ name: "OSC 52 Clipboard", status: bool(caps.osc52) },
|
|
100
|
+
{ name: "OSC 8 Hyperlinks", status: bool(caps.hyperlinks) },
|
|
101
|
+
{
|
|
102
|
+
name: "Image Support",
|
|
103
|
+
status: caps.kittyGraphics || caps.sixel ? "supported" : "not-supported",
|
|
104
|
+
detail: caps.kittyGraphics ? "Kitty" : caps.sixel ? "Sixel" : "none",
|
|
105
|
+
},
|
|
106
|
+
{ name: "Notifications (OSC 9/99)", status: bool(caps.notifications) },
|
|
107
|
+
{ name: "Underline Styles (SGR 4:x)", status: bool(caps.underlineStyles) },
|
|
108
|
+
{ name: "Underline Color (SGR 58)", status: bool(caps.underlineColor) },
|
|
109
|
+
{ name: "Unicode", status: bool(caps.unicode) },
|
|
110
|
+
{ name: "Nerd Font", status: bool(caps.nerdfont) },
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ============================================================================
|
|
115
|
+
// Main app component
|
|
116
|
+
// ============================================================================
|
|
117
|
+
|
|
118
|
+
function TerminalCapsApp({
|
|
119
|
+
initialProbes,
|
|
120
|
+
}: {
|
|
121
|
+
initialProbes?: { colorScheme: ColorScheme; widthConfig: TerminalWidthConfig | null; kittyDetected: boolean | null }
|
|
122
|
+
}) {
|
|
123
|
+
const { exit } = useApp()
|
|
124
|
+
const [caps] = useState<TerminalCaps>(() => detectTerminalCaps())
|
|
125
|
+
const [colorScheme] = useState<ColorScheme>(initialProbes?.colorScheme ?? "unknown")
|
|
126
|
+
const [widthConfig] = useState<TerminalWidthConfig | null>(initialProbes?.widthConfig ?? null)
|
|
127
|
+
const [kittyDetected] = useState<boolean | null>(initialProbes?.kittyDetected ?? null)
|
|
128
|
+
|
|
129
|
+
// Quit on q or Esc
|
|
130
|
+
useInput((input: string, key: Key) => {
|
|
131
|
+
if (input === "q" || key.escape) {
|
|
132
|
+
exit()
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
// Probing is done before render() in main() — no useEffect needed.
|
|
137
|
+
// This avoids stdin conflicts between protocol responses and useInput.
|
|
138
|
+
|
|
139
|
+
// Build the display entries
|
|
140
|
+
const staticEntries = buildStaticEntries(caps)
|
|
141
|
+
|
|
142
|
+
// Override Kitty detection if we have live results
|
|
143
|
+
if (kittyDetected !== null) {
|
|
144
|
+
const kittyEntry = staticEntries.find((e) => e.name.startsWith("Kitty Keyboard"))
|
|
145
|
+
if (kittyEntry) {
|
|
146
|
+
kittyEntry.status = kittyDetected ? "supported" : "not-supported"
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Dynamic probe entries
|
|
151
|
+
const probeEntries: CapEntry[] = [
|
|
152
|
+
{
|
|
153
|
+
name: "Mode 2031 Color Scheme",
|
|
154
|
+
status: colorScheme === "unknown" ? "probing" : "detected",
|
|
155
|
+
detail: colorScheme === "unknown" ? "probing..." : colorScheme,
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
name: "DEC 1020 UTF-8",
|
|
159
|
+
status: widthConfig === null ? "probing" : "detected",
|
|
160
|
+
detail: widthConfig === null ? "probing..." : widthConfig.utf8 ? "enabled" : "disabled",
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: "DEC 1021 CJK Width",
|
|
164
|
+
status: widthConfig === null ? "probing" : "detected",
|
|
165
|
+
detail: widthConfig === null ? "probing..." : String(widthConfig.cjkWidth),
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: "DEC 1022 Emoji Width",
|
|
169
|
+
status: widthConfig === null ? "probing" : "detected",
|
|
170
|
+
detail: widthConfig === null ? "probing..." : String(widthConfig.emojiWidth),
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
name: "DEC 1023 Private Width",
|
|
174
|
+
status: widthConfig === null ? "probing" : "detected",
|
|
175
|
+
detail: widthConfig === null ? "probing..." : String(widthConfig.privateUseWidth),
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: "OSC 66 Text Sizing",
|
|
179
|
+
status: caps.textSizingSupported ? "supported" : "not-supported",
|
|
180
|
+
detail: caps.textSizingSupported ? "supported" : "not supported",
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
name: "OSC 5522 Advanced Clipboard",
|
|
184
|
+
// Kitty 0.28+ supports this; approximate via kitty detection
|
|
185
|
+
status: caps.term === "xterm-kitty" ? "supported" : "not-supported",
|
|
186
|
+
detail: caps.term === "xterm-kitty" ? "Kitty" : "not supported",
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: "DA1/DA2/DA3",
|
|
190
|
+
// All modern terminals respond to DA queries
|
|
191
|
+
status: caps.program !== "" ? "supported" : "not-supported",
|
|
192
|
+
},
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
// Terminal info header
|
|
196
|
+
const termProgram = caps.program || "(unknown)"
|
|
197
|
+
const termType = caps.term || "(unknown)"
|
|
198
|
+
const colorLevel = caps.colorLevel
|
|
199
|
+
|
|
200
|
+
// Column width for alignment
|
|
201
|
+
const colWidth = 38
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<Box flexDirection="column" paddingX={1} paddingY={1}>
|
|
205
|
+
<H3>Terminal Capabilities Probe</H3>
|
|
206
|
+
|
|
207
|
+
{/* Terminal identity */}
|
|
208
|
+
<Box paddingBottom={1}>
|
|
209
|
+
<Muted>
|
|
210
|
+
Terminal: {termProgram} ({termType}) | Colors: {colorLevel} | Background:{" "}
|
|
211
|
+
{caps.darkBackground ? "dark" : "light"}
|
|
212
|
+
</Muted>
|
|
213
|
+
</Box>
|
|
214
|
+
|
|
215
|
+
{/* Two-column layout */}
|
|
216
|
+
<Box>
|
|
217
|
+
{/* Left column: static capabilities */}
|
|
218
|
+
<Box flexDirection="column" width={colWidth + 4}>
|
|
219
|
+
<Text bold color="$primary">
|
|
220
|
+
Static Detection
|
|
221
|
+
</Text>
|
|
222
|
+
<Box height={1} />
|
|
223
|
+
{staticEntries.map((entry) => (
|
|
224
|
+
<CapRow key={entry.name} entry={entry} width={colWidth} />
|
|
225
|
+
))}
|
|
226
|
+
</Box>
|
|
227
|
+
|
|
228
|
+
{/* Right column: runtime probes */}
|
|
229
|
+
<Box flexDirection="column" width={colWidth + 4}>
|
|
230
|
+
<Text bold color="$primary">
|
|
231
|
+
Runtime Probes
|
|
232
|
+
</Text>
|
|
233
|
+
<Box height={1} />
|
|
234
|
+
{probeEntries.map((entry) => (
|
|
235
|
+
<CapRow key={entry.name} entry={entry} width={colWidth} />
|
|
236
|
+
))}
|
|
237
|
+
</Box>
|
|
238
|
+
</Box>
|
|
239
|
+
|
|
240
|
+
{/* Footer */}
|
|
241
|
+
<Box paddingTop={1}>
|
|
242
|
+
<Muted>
|
|
243
|
+
<Kbd>q</Kbd> or <Kbd>Esc</Kbd> to quit
|
|
244
|
+
</Muted>
|
|
245
|
+
</Box>
|
|
246
|
+
</Box>
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ============================================================================
|
|
251
|
+
// Main
|
|
252
|
+
// ============================================================================
|
|
253
|
+
|
|
254
|
+
async function main() {
|
|
255
|
+
// Probe BEFORE render() starts — avoids stdin conflict with useInput.
|
|
256
|
+
// Once render() owns stdin, protocol responses leak as visible text.
|
|
257
|
+
let probeResults: {
|
|
258
|
+
colorScheme: ColorScheme
|
|
259
|
+
widthConfig: TerminalWidthConfig | null
|
|
260
|
+
kittyDetected: boolean | null
|
|
261
|
+
} = { colorScheme: "unknown", widthConfig: null, kittyDetected: null }
|
|
262
|
+
|
|
263
|
+
if (process.stdin.isTTY) {
|
|
264
|
+
process.stdin.setRawMode(true)
|
|
265
|
+
process.stdin.resume()
|
|
266
|
+
|
|
267
|
+
const write = (data: string) => process.stdout.write(data)
|
|
268
|
+
const onData = (handler: (data: string) => void): (() => void) => {
|
|
269
|
+
const h = (chunk: Buffer | string) => handler(typeof chunk === "string" ? chunk : chunk.toString())
|
|
270
|
+
process.stdin.on("data", h)
|
|
271
|
+
return () => process.stdin.removeListener("data", h)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Run all probes in parallel with 500ms timeout
|
|
275
|
+
const [colorResult, widthResult, kittyResult] = await Promise.allSettled([
|
|
276
|
+
new Promise<ColorScheme>((resolve) => {
|
|
277
|
+
const det = createColorSchemeDetector({ write, onData, timeoutMs: 500 })
|
|
278
|
+
det.subscribe((s) => {
|
|
279
|
+
resolve(s)
|
|
280
|
+
det.stop()
|
|
281
|
+
})
|
|
282
|
+
det.start()
|
|
283
|
+
setTimeout(() => {
|
|
284
|
+
resolve(det.scheme)
|
|
285
|
+
det.stop()
|
|
286
|
+
}, 600)
|
|
287
|
+
}),
|
|
288
|
+
createWidthDetector({ write, onData, timeoutMs: 500 })
|
|
289
|
+
.detect()
|
|
290
|
+
.catch(() => null),
|
|
291
|
+
detectKittyFromStdio(process.stdout, process.stdin, 500)
|
|
292
|
+
.then((r) => r.supported)
|
|
293
|
+
.catch(() => false),
|
|
294
|
+
])
|
|
295
|
+
|
|
296
|
+
probeResults = {
|
|
297
|
+
colorScheme: colorResult.status === "fulfilled" ? colorResult.value : "unknown",
|
|
298
|
+
widthConfig: widthResult.status === "fulfilled" ? widthResult.value : null,
|
|
299
|
+
kittyDetected: kittyResult.status === "fulfilled" ? kittyResult.value : false,
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
process.stdin.setRawMode(false)
|
|
303
|
+
process.stdin.pause()
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const { waitUntilExit } = await render(
|
|
307
|
+
<ExampleBanner meta={meta} controls="q/Esc quit">
|
|
308
|
+
<TerminalCapsApp initialProbes={probeResults} />
|
|
309
|
+
</ExampleBanner>,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
await waitUntilExit()
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export { main }
|
|
316
|
+
|
|
317
|
+
if (import.meta.main) {
|
|
318
|
+
main().catch((err) => {
|
|
319
|
+
// Ensure terminal is restored on error
|
|
320
|
+
const stdout = process.stdout
|
|
321
|
+
stdout.write("\x1b[?25h") // show cursor
|
|
322
|
+
stdout.write("\x1b[?1049l") // leave alt screen
|
|
323
|
+
stdout.write("\x1b[0m") // reset styles
|
|
324
|
+
if (process.stdin.isTTY && process.stdin.isRaw) {
|
|
325
|
+
try {
|
|
326
|
+
process.stdin.setRawMode(false)
|
|
327
|
+
} catch {
|
|
328
|
+
/* noop */
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
console.error(err)
|
|
332
|
+
process.exit(1)
|
|
333
|
+
})
|
|
334
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text Selection Demo
|
|
3
|
+
*
|
|
4
|
+
* Demonstrates silvery's real text selection system using the useSelection hook.
|
|
5
|
+
* Shows userSelect modes (text, none, contain) and live selection state readout.
|
|
6
|
+
*
|
|
7
|
+
* Run: bun vendor/silvery/examples/apps/text-selection-demo.tsx
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React from "react"
|
|
11
|
+
import { Box, Text, H1, H2, Small, Muted, Strong, Kbd, HR } from "silvery"
|
|
12
|
+
import { createApp } from "@silvery/create/create-app"
|
|
13
|
+
import { pipe, withReact, withTerminal, withDomEvents } from "@silvery/create/plugins"
|
|
14
|
+
import { ExampleBanner, type ExampleMeta } from "../_banner.js"
|
|
15
|
+
import { useSelection } from "../../packages/ag-react/src/hooks/useSelection"
|
|
16
|
+
|
|
17
|
+
export const meta: ExampleMeta = {
|
|
18
|
+
name: "Text Selection",
|
|
19
|
+
description: "Real selection via useSelection hook, userSelect modes, live state readout",
|
|
20
|
+
demo: true,
|
|
21
|
+
features: ["useSelection()", "userSelect prop", "CapabilityRegistry", "mouse drag selection"],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// Selectable text panel (default — userSelect="text")
|
|
26
|
+
// ============================================================================
|
|
27
|
+
|
|
28
|
+
function SelectableTextPanel(): React.ReactElement {
|
|
29
|
+
return (
|
|
30
|
+
<Box flexDirection="column" borderStyle="round" borderColor="$border" paddingX={1} flexGrow={1}>
|
|
31
|
+
<H2>Selectable Text</H2>
|
|
32
|
+
<Small>userSelect="text" (default)</Small>
|
|
33
|
+
<Box height={1} />
|
|
34
|
+
<Text>Drag your mouse over this text to select it.</Text>
|
|
35
|
+
<Text>Multi-line selections work across paragraphs.</Text>
|
|
36
|
+
<Box height={1} />
|
|
37
|
+
<Text color="$muted">The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs.</Text>
|
|
38
|
+
</Box>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ============================================================================
|
|
43
|
+
// Non-selectable panel (userSelect="none")
|
|
44
|
+
// ============================================================================
|
|
45
|
+
|
|
46
|
+
function NonSelectablePanel(): React.ReactElement {
|
|
47
|
+
return (
|
|
48
|
+
<Box flexDirection="column" borderStyle="round" borderColor="$border" paddingX={1} flexGrow={1} userSelect="none">
|
|
49
|
+
<H2>Non-Selectable</H2>
|
|
50
|
+
<Small>userSelect="none"</Small>
|
|
51
|
+
<Box height={1} />
|
|
52
|
+
<Text>This area cannot be selected by mouse drag.</Text>
|
|
53
|
+
<Text>Click events still work normally here.</Text>
|
|
54
|
+
<Box height={1} />
|
|
55
|
+
<Muted>Hold Alt and drag to override and select anyway.</Muted>
|
|
56
|
+
</Box>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ============================================================================
|
|
61
|
+
// Contained panel (userSelect="contain")
|
|
62
|
+
// ============================================================================
|
|
63
|
+
|
|
64
|
+
function ContainedPanel(): React.ReactElement {
|
|
65
|
+
return (
|
|
66
|
+
<Box flexDirection="column" borderStyle="round" borderColor="$warning" paddingX={1} flexGrow={1}>
|
|
67
|
+
<H2 color="$warning">Contained Selection</H2>
|
|
68
|
+
<Small>userSelect="contain"</Small>
|
|
69
|
+
<Box height={1} />
|
|
70
|
+
<Box flexDirection="column" borderStyle="round" borderColor="$primary" paddingX={1} userSelect="contain">
|
|
71
|
+
<Text bold color="$primary">
|
|
72
|
+
Selection Boundary
|
|
73
|
+
</Text>
|
|
74
|
+
<Box height={1} />
|
|
75
|
+
<Text>Selection cannot escape this container.</Text>
|
|
76
|
+
<Text>Try dragging past the border — it clips.</Text>
|
|
77
|
+
<Box height={1} />
|
|
78
|
+
<Text color="$success">Useful for modals, side panes, overlays.</Text>
|
|
79
|
+
</Box>
|
|
80
|
+
</Box>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ============================================================================
|
|
85
|
+
// Live selection state readout via useSelection()
|
|
86
|
+
// ============================================================================
|
|
87
|
+
|
|
88
|
+
function SelectionStatePanel(): React.ReactElement {
|
|
89
|
+
const selection = useSelection()
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<Box flexDirection="column" borderStyle="round" borderColor="$info" paddingX={1} flexGrow={1}>
|
|
93
|
+
<H2 color="$info">Selection State</H2>
|
|
94
|
+
<Small>Live readout from useSelection()</Small>
|
|
95
|
+
<Box height={1} />
|
|
96
|
+
{!selection ? (
|
|
97
|
+
<Text color="$muted">useSelection() returned undefined — feature not installed</Text>
|
|
98
|
+
) : selection.range ? (
|
|
99
|
+
<>
|
|
100
|
+
<Box gap={1}>
|
|
101
|
+
<Strong>Status:</Strong>
|
|
102
|
+
<Text color="$success">{selection.selecting ? "Selecting..." : "Selected"}</Text>
|
|
103
|
+
</Box>
|
|
104
|
+
<Box gap={1}>
|
|
105
|
+
<Strong>Source:</Strong>
|
|
106
|
+
<Text>{selection.source ?? "unknown"}</Text>
|
|
107
|
+
</Box>
|
|
108
|
+
<Box gap={1}>
|
|
109
|
+
<Strong>Anchor:</Strong>
|
|
110
|
+
<Text>
|
|
111
|
+
({selection.range.anchor.col}, {selection.range.anchor.row})
|
|
112
|
+
</Text>
|
|
113
|
+
</Box>
|
|
114
|
+
<Box gap={1}>
|
|
115
|
+
<Strong>Head:</Strong>
|
|
116
|
+
<Text>
|
|
117
|
+
({selection.range.head.col}, {selection.range.head.row})
|
|
118
|
+
</Text>
|
|
119
|
+
</Box>
|
|
120
|
+
</>
|
|
121
|
+
) : (
|
|
122
|
+
<Text color="$muted">No active selection — drag to select text</Text>
|
|
123
|
+
)}
|
|
124
|
+
</Box>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ============================================================================
|
|
129
|
+
// Status bar
|
|
130
|
+
// ============================================================================
|
|
131
|
+
|
|
132
|
+
function StatusBar(): React.ReactElement {
|
|
133
|
+
return (
|
|
134
|
+
<Box flexDirection="row" gap={2} paddingX={1} flexShrink={0} userSelect="none">
|
|
135
|
+
<Muted>
|
|
136
|
+
<Kbd>Drag</Kbd> select <Kbd>Alt+Drag</Kbd> force select <Kbd>Ctrl+C</Kbd> quit
|
|
137
|
+
</Muted>
|
|
138
|
+
</Box>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ============================================================================
|
|
143
|
+
// Main app
|
|
144
|
+
// ============================================================================
|
|
145
|
+
|
|
146
|
+
function TextSelectionDemo(): React.ReactElement {
|
|
147
|
+
return (
|
|
148
|
+
<Box flexDirection="column" padding={1} gap={1} height="100%">
|
|
149
|
+
<Box>
|
|
150
|
+
<H1 color="$primary">Text Selection Demo</H1>
|
|
151
|
+
<Muted> — real selection via useSelection()</Muted>
|
|
152
|
+
</Box>
|
|
153
|
+
|
|
154
|
+
{/* Top row: selectable + non-selectable */}
|
|
155
|
+
<Box flexDirection="row" gap={1} flexGrow={1}>
|
|
156
|
+
<SelectableTextPanel />
|
|
157
|
+
<NonSelectablePanel />
|
|
158
|
+
</Box>
|
|
159
|
+
|
|
160
|
+
{/* Bottom row: contained + live state readout */}
|
|
161
|
+
<Box flexDirection="row" gap={1} flexGrow={1}>
|
|
162
|
+
<ContainedPanel />
|
|
163
|
+
<SelectionStatePanel />
|
|
164
|
+
</Box>
|
|
165
|
+
|
|
166
|
+
<HR />
|
|
167
|
+
<StatusBar />
|
|
168
|
+
</Box>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ============================================================================
|
|
173
|
+
// Main
|
|
174
|
+
// ============================================================================
|
|
175
|
+
|
|
176
|
+
if (import.meta.main) {
|
|
177
|
+
const app = pipe(
|
|
178
|
+
createApp(() => () => ({})) as any,
|
|
179
|
+
withReact(
|
|
180
|
+
<ExampleBanner meta={meta} controls="Drag select Alt+Drag force select Ctrl+C quit">
|
|
181
|
+
<TextSelectionDemo />
|
|
182
|
+
</ExampleBanner>,
|
|
183
|
+
),
|
|
184
|
+
withTerminal(process as any, { mouse: true }),
|
|
185
|
+
withDomEvents(),
|
|
186
|
+
)
|
|
187
|
+
using handle = await app.run()
|
|
188
|
+
await handle.waitUntilExit()
|
|
189
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TextArea Example — Split-Pane Note Editor
|
|
3
|
+
*
|
|
4
|
+
* A note-taking app demonstrating:
|
|
5
|
+
* - Multi-line text input with word wrapping and pre-filled content
|
|
6
|
+
* - Split-pane layout: editor (2/3) + saved notes sidebar (1/3)
|
|
7
|
+
* - Tab focus cycling between panes
|
|
8
|
+
* - Cursor movement (arrow keys, Home/End, Ctrl+A/E)
|
|
9
|
+
* - Kill operations (Ctrl+K, Ctrl+U)
|
|
10
|
+
* - Word/character stats in the header
|
|
11
|
+
* - Submit with Ctrl+Enter to collect notes
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import React, { useState } from "react"
|
|
15
|
+
import { render, Box, Text, H1, Strong, Muted, TextArea, useInput, useApp, createTerm, type Key } from "silvery"
|
|
16
|
+
import { ExampleBanner, type ExampleMeta } from "../_banner.js"
|
|
17
|
+
|
|
18
|
+
export const meta: ExampleMeta = {
|
|
19
|
+
name: "TextArea",
|
|
20
|
+
description: "Split-pane note editor with word wrap, kill ring, and note collection",
|
|
21
|
+
features: ["TextArea", "Split pane layout", "Ctrl+Enter submit", "Tab focus cycling"],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const INITIAL_CONTENT = `# Release Notes — Silvery v0.1
|
|
25
|
+
|
|
26
|
+
## New Features
|
|
27
|
+
|
|
28
|
+
- **Flexbox layout engine** — CSS-compatible sizing,
|
|
29
|
+
wrapping, and gap support via Flexily
|
|
30
|
+
- **38 built-in color palettes** — from Dracula
|
|
31
|
+
to Solarized, Nord to Catppuccin
|
|
32
|
+
- **Incremental rendering** — only changed cells
|
|
33
|
+
are repainted, no full-screen flicker
|
|
34
|
+
|
|
35
|
+
## Breaking Changes
|
|
36
|
+
|
|
37
|
+
- Dropped Node.js 18 support (now requires >=20)
|
|
38
|
+
- Renamed \`useTerminal()\` to \`useTerm()\`
|
|
39
|
+
|
|
40
|
+
## Performance
|
|
41
|
+
|
|
42
|
+
Benchmark results on an M4 MacBook Pro:
|
|
43
|
+
Initial render: 2.1ms (80x24)
|
|
44
|
+
Incremental: 0.3ms (typical diff)
|
|
45
|
+
Layout: 0.8ms (1000 nodes)
|
|
46
|
+
|
|
47
|
+
Thanks to all contributors!`
|
|
48
|
+
|
|
49
|
+
export function NoteEditor() {
|
|
50
|
+
const { exit } = useApp()
|
|
51
|
+
const [notes, setNotes] = useState<string[]>([])
|
|
52
|
+
const [value, setValue] = useState(INITIAL_CONTENT)
|
|
53
|
+
const [focusIndex, setFocusIndex] = useState(0)
|
|
54
|
+
|
|
55
|
+
useInput((input: string, key: Key) => {
|
|
56
|
+
if (key.escape) {
|
|
57
|
+
exit()
|
|
58
|
+
}
|
|
59
|
+
if (key.tab && !key.shift) {
|
|
60
|
+
setFocusIndex((prev) => (prev + 1) % 2)
|
|
61
|
+
}
|
|
62
|
+
if (key.tab && key.shift) {
|
|
63
|
+
setFocusIndex((prev) => (prev - 1 + 2) % 2)
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
function handleSubmit(text: string) {
|
|
68
|
+
if (text.trim()) {
|
|
69
|
+
setNotes((prev) => [...prev, text.trim()])
|
|
70
|
+
setValue("")
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const lines = value.split("\n").length
|
|
75
|
+
const chars = value.length
|
|
76
|
+
const words = value.split(/\s+/).filter(Boolean).length
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<Box flexDirection="column" flexGrow={1} padding={1}>
|
|
80
|
+
<Box flexDirection="row" gap={1} flexGrow={1}>
|
|
81
|
+
{/* Main editor */}
|
|
82
|
+
<Box
|
|
83
|
+
borderStyle="round"
|
|
84
|
+
borderColor={focusIndex === 0 ? "$primary" : "$border"}
|
|
85
|
+
flexDirection="column"
|
|
86
|
+
flexGrow={3}
|
|
87
|
+
flexBasis={0}
|
|
88
|
+
>
|
|
89
|
+
<Box paddingX={1} justifyContent="space-between">
|
|
90
|
+
<H1>Editor</H1>
|
|
91
|
+
<Muted>
|
|
92
|
+
{lines} lines, {words} words, {chars} chars
|
|
93
|
+
</Muted>
|
|
94
|
+
</Box>
|
|
95
|
+
<Text> </Text>
|
|
96
|
+
<Box paddingX={1} flexGrow={1}>
|
|
97
|
+
<TextArea
|
|
98
|
+
value={value}
|
|
99
|
+
onChange={setValue}
|
|
100
|
+
onSubmit={handleSubmit}
|
|
101
|
+
height={16}
|
|
102
|
+
isActive={focusIndex === 0}
|
|
103
|
+
/>
|
|
104
|
+
</Box>
|
|
105
|
+
</Box>
|
|
106
|
+
|
|
107
|
+
{/* Saved notes sidebar */}
|
|
108
|
+
<Box
|
|
109
|
+
borderStyle="round"
|
|
110
|
+
borderColor={focusIndex === 1 ? "$primary" : "$border"}
|
|
111
|
+
flexDirection="column"
|
|
112
|
+
flexGrow={2}
|
|
113
|
+
flexBasis={0}
|
|
114
|
+
>
|
|
115
|
+
<Box paddingX={1}>
|
|
116
|
+
<H1>Notes</H1>
|
|
117
|
+
<Muted> ({notes.length})</Muted>
|
|
118
|
+
</Box>
|
|
119
|
+
<Text> </Text>
|
|
120
|
+
<Box flexDirection="column" paddingX={1} overflow="scroll" flexGrow={1}>
|
|
121
|
+
{notes.length === 0 ? (
|
|
122
|
+
<Muted>No notes yet.</Muted>
|
|
123
|
+
) : (
|
|
124
|
+
notes.map((note, i) => (
|
|
125
|
+
<Box key={i} flexDirection="column" marginBottom={1}>
|
|
126
|
+
<Text wrap="truncate">
|
|
127
|
+
<Strong color="$success">#{i + 1}</Strong> {note.split("\n")[0]}
|
|
128
|
+
</Text>
|
|
129
|
+
<Muted>
|
|
130
|
+
{note.split("\n").length} lines, {note.length} chars
|
|
131
|
+
</Muted>
|
|
132
|
+
</Box>
|
|
133
|
+
))
|
|
134
|
+
)}
|
|
135
|
+
</Box>
|
|
136
|
+
</Box>
|
|
137
|
+
</Box>
|
|
138
|
+
</Box>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function main() {
|
|
143
|
+
using term = createTerm()
|
|
144
|
+
const { waitUntilExit } = await render(
|
|
145
|
+
<ExampleBanner meta={meta} controls="Tab switch pane Ctrl+Enter submit Esc quit">
|
|
146
|
+
<NoteEditor />
|
|
147
|
+
</ExampleBanner>,
|
|
148
|
+
term,
|
|
149
|
+
)
|
|
150
|
+
await waitUntilExit()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (import.meta.main) {
|
|
154
|
+
main().catch(console.error)
|
|
155
|
+
}
|