@silvery/examples 0.5.2 → 0.5.4
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
|
@@ -32,7 +32,6 @@ import {
|
|
|
32
32
|
useInput,
|
|
33
33
|
useApp,
|
|
34
34
|
createTerm,
|
|
35
|
-
H1,
|
|
36
35
|
Strong,
|
|
37
36
|
Kbd,
|
|
38
37
|
Muted,
|
|
@@ -180,8 +179,25 @@ function LogRow({ entry, isSelected }: { entry: LogEntry; isSelected: boolean })
|
|
|
180
179
|
const badge = LEVEL_BADGES[entry.level]
|
|
181
180
|
const color = LEVEL_COLORS[entry.level]
|
|
182
181
|
|
|
182
|
+
// When selected, use $primary-fg for all text to ensure contrast against $primary bg.
|
|
183
|
+
// When not selected, use level-specific colors for visual distinction.
|
|
184
|
+
if (isSelected) {
|
|
185
|
+
return (
|
|
186
|
+
<Box paddingX={1} backgroundColor="$primary">
|
|
187
|
+
<Text color="$primary-fg">{formatTime(entry.timestamp)} </Text>
|
|
188
|
+
<Text color="$primary-fg" bold>
|
|
189
|
+
{badge}
|
|
190
|
+
</Text>
|
|
191
|
+
<Text color="$primary-fg"> [{entry.source.padEnd(9)}] </Text>
|
|
192
|
+
<Text color="$primary-fg" bold>
|
|
193
|
+
{entry.message}
|
|
194
|
+
</Text>
|
|
195
|
+
</Box>
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
183
199
|
return (
|
|
184
|
-
<Box paddingX={1}
|
|
200
|
+
<Box paddingX={1}>
|
|
185
201
|
<Muted>{formatTime(entry.timestamp)} </Muted>
|
|
186
202
|
<Strong color={color}>{badge}</Strong>
|
|
187
203
|
<Muted> [{entry.source.padEnd(9)}] </Muted>
|
|
@@ -240,7 +256,6 @@ const rng = seededRandom(12345)
|
|
|
240
256
|
|
|
241
257
|
export function DevTools() {
|
|
242
258
|
const { exit } = useApp()
|
|
243
|
-
const { width } = useBoxRect()
|
|
244
259
|
const [entries, setEntries] = useState<LogEntry[]>(() => generateInitialLogs(INITIAL_COUNT))
|
|
245
260
|
const [cursor, setCursor] = useState(INITIAL_COUNT - 1)
|
|
246
261
|
const [autoScroll, setAutoScroll] = useState(true)
|
|
@@ -320,25 +335,34 @@ export function DevTools() {
|
|
|
320
335
|
)
|
|
321
336
|
|
|
322
337
|
return (
|
|
323
|
-
<Box flexDirection="column" flexGrow={1}>
|
|
338
|
+
<Box flexDirection="column" flexGrow={1} padding={1}>
|
|
324
339
|
{/* Header */}
|
|
325
|
-
<Box
|
|
340
|
+
<Box justifyContent="space-between" backgroundColor="$surfacebg">
|
|
326
341
|
<Box gap={2}>
|
|
327
|
-
<
|
|
342
|
+
<Text bold color="$primary">
|
|
343
|
+
{"▸"} Log Viewer
|
|
344
|
+
</Text>
|
|
328
345
|
<LevelCounts entries={entries} />
|
|
329
346
|
</Box>
|
|
330
347
|
<Box gap={1}>
|
|
331
|
-
<Strong color="$primary">{cursor + 1}</Strong>
|
|
332
|
-
<Muted>/ {entries.length}</Muted>
|
|
333
348
|
{autoScroll && (
|
|
334
|
-
<Text color="$success" bold>
|
|
335
|
-
{" "}
|
|
336
|
-
LIVE
|
|
349
|
+
<Text backgroundColor="$success" color="$success-fg" bold>
|
|
350
|
+
{" LIVE "}
|
|
337
351
|
</Text>
|
|
338
352
|
)}
|
|
353
|
+
<Strong color="$primary">{cursor + 1}</Strong>
|
|
354
|
+
<Muted>/ {entries.length}</Muted>
|
|
339
355
|
</Box>
|
|
340
356
|
</Box>
|
|
341
357
|
|
|
358
|
+
{/* Column headers */}
|
|
359
|
+
<Box paddingX={1}>
|
|
360
|
+
<Muted>{"Time "} </Muted>
|
|
361
|
+
<Muted>{"Lvl"} </Muted>
|
|
362
|
+
<Muted>{"[Source ]"} </Muted>
|
|
363
|
+
<Muted>Message</Muted>
|
|
364
|
+
</Box>
|
|
365
|
+
|
|
342
366
|
<Box paddingX={1}>
|
|
343
367
|
<Divider />
|
|
344
368
|
</Box>
|
|
@@ -347,14 +371,6 @@ export function DevTools() {
|
|
|
347
371
|
<Box flexGrow={1} flexDirection="column">
|
|
348
372
|
<LogListArea entries={entries} cursor={cursor} />
|
|
349
373
|
</Box>
|
|
350
|
-
|
|
351
|
-
{/* Help bar */}
|
|
352
|
-
<Box paddingX={1} justifyContent="space-between">
|
|
353
|
-
<Muted>
|
|
354
|
-
<Kbd>j/k</Kbd> navigate <Kbd>g/G</Kbd> start/end <Kbd>d/i/w/e</Kbd> add log <Kbd>c</Kbd> clear{" "}
|
|
355
|
-
<Kbd>Esc/q</Kbd> quit
|
|
356
|
-
</Muted>
|
|
357
|
-
</Box>
|
|
358
374
|
</Box>
|
|
359
375
|
)
|
|
360
376
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import React, { useState } from "react"
|
|
12
|
-
import { render, Box, Text,
|
|
12
|
+
import { render, Box, Text, useInput, useApp, createTerm, type Key } from "silvery"
|
|
13
13
|
import { ExampleBanner, type ExampleMeta } from "../_banner.js"
|
|
14
14
|
|
|
15
15
|
export const meta: ExampleMeta = {
|
|
@@ -102,15 +102,19 @@ function Tag({ name }: { name: string }) {
|
|
|
102
102
|
|
|
103
103
|
function CardComponent({ card, isSelected }: { card: Card; isSelected: boolean }) {
|
|
104
104
|
return (
|
|
105
|
-
<Box flexDirection="column" borderStyle="round" borderColor={isSelected ? "$primary" : "$border"}
|
|
105
|
+
<Box flexDirection="column" borderStyle="round" borderColor={isSelected ? "$primary" : "$border"}>
|
|
106
106
|
{isSelected ? (
|
|
107
|
-
<
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
<Box backgroundColor="$primary" paddingX={1}>
|
|
108
|
+
<Text color="$primary-fg" bold wrap="truncate">
|
|
109
|
+
{card.title}
|
|
110
|
+
</Text>
|
|
111
|
+
</Box>
|
|
110
112
|
) : (
|
|
111
|
-
<
|
|
113
|
+
<Box paddingX={1}>
|
|
114
|
+
<Text wrap="truncate">{card.title}</Text>
|
|
115
|
+
</Box>
|
|
112
116
|
)}
|
|
113
|
-
<Box gap={1}>
|
|
117
|
+
<Box gap={1} paddingX={1}>
|
|
114
118
|
{card.tags.map((tag) => (
|
|
115
119
|
<Tag key={tag} name={tag} />
|
|
116
120
|
))}
|
|
@@ -129,12 +133,18 @@ function ColumnComponent({
|
|
|
129
133
|
selectedCardIndex: number
|
|
130
134
|
}) {
|
|
131
135
|
return (
|
|
132
|
-
<Box
|
|
136
|
+
<Box
|
|
137
|
+
flexDirection="column"
|
|
138
|
+
flexGrow={1}
|
|
139
|
+
flexBasis={0}
|
|
140
|
+
borderStyle="single"
|
|
141
|
+
borderColor={isSelected ? "$primary" : "$border"}
|
|
142
|
+
>
|
|
133
143
|
<Box backgroundColor={isSelected ? "$primary" : undefined} paddingX={1}>
|
|
134
|
-
<Text bold color={isSelected ? "
|
|
144
|
+
<Text bold color={isSelected ? "$primary-fg" : "$text"}>
|
|
135
145
|
{column.title}
|
|
136
146
|
</Text>
|
|
137
|
-
<Text color={isSelected ? "
|
|
147
|
+
<Text color={isSelected ? "$primary-fg" : "$muted"}> ({column.cards.length})</Text>
|
|
138
148
|
</Box>
|
|
139
149
|
|
|
140
150
|
<Box
|
|
@@ -143,7 +153,6 @@ function ColumnComponent({
|
|
|
143
153
|
overflow="scroll"
|
|
144
154
|
scrollTo={isSelected ? selectedCardIndex : undefined}
|
|
145
155
|
flexGrow={1}
|
|
146
|
-
gap={1}
|
|
147
156
|
>
|
|
148
157
|
{column.cards.map((card, cardIndex) => (
|
|
149
158
|
<CardComponent key={card.id} card={card} isSelected={isSelected && cardIndex === selectedCardIndex} />
|
|
@@ -159,15 +168,6 @@ function ColumnComponent({
|
|
|
159
168
|
)
|
|
160
169
|
}
|
|
161
170
|
|
|
162
|
-
function HelpBar() {
|
|
163
|
-
return (
|
|
164
|
-
<Muted>
|
|
165
|
-
{" "}
|
|
166
|
-
<Kbd>h/l</Kbd> column <Kbd>j/k</Kbd> card <Kbd>{"</"}</Kbd> move <Kbd>Esc/q</Kbd> quit
|
|
167
|
-
</Muted>
|
|
168
|
-
)
|
|
169
|
-
}
|
|
170
|
-
|
|
171
171
|
export function KanbanBoard() {
|
|
172
172
|
const { exit } = useApp()
|
|
173
173
|
const [columns, setColumns] = useState<Column[]>(initialColumns)
|
|
@@ -241,8 +241,6 @@ export function KanbanBoard() {
|
|
|
241
241
|
/>
|
|
242
242
|
))}
|
|
243
243
|
</Box>
|
|
244
|
-
|
|
245
|
-
<HelpBar />
|
|
246
244
|
</Box>
|
|
247
245
|
)
|
|
248
246
|
}
|
|
@@ -76,8 +76,8 @@ function ImperativeAccessDemo() {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
return (
|
|
79
|
-
<Box ref={boxRef} flexDirection="column" borderStyle="double" borderColor="
|
|
80
|
-
<H1 color="
|
|
79
|
+
<Box ref={boxRef} flexDirection="column" borderStyle="double" borderColor="$accent" padding={1}>
|
|
80
|
+
<H1 color="$accent">Imperative Access (BoxHandle)</H1>
|
|
81
81
|
<Muted>Press 'i' to inspect this box</Muted>
|
|
82
82
|
<Box marginTop={1}>
|
|
83
83
|
<Text>{info}</Text>
|
|
@@ -116,13 +116,13 @@ export function LayoutRefApp() {
|
|
|
116
116
|
<Box flexDirection="column" padding={1}>
|
|
117
117
|
{/* Row of resizable panes with onLayout callbacks */}
|
|
118
118
|
<Box flexDirection="row" gap={1} height={8}>
|
|
119
|
-
<ResizablePane title="Pane A" color="
|
|
120
|
-
<ResizablePane title="Pane B" color="
|
|
121
|
-
<ResizablePane title="Pane C" color="
|
|
119
|
+
<ResizablePane title="Pane A" color="$success" onLayoutChange={handleLayoutChange("a")} />
|
|
120
|
+
<ResizablePane title="Pane B" color="$info" onLayoutChange={handleLayoutChange("b")} />
|
|
121
|
+
<ResizablePane title="Pane C" color="$primary" onLayoutChange={handleLayoutChange("c")} />
|
|
122
122
|
</Box>
|
|
123
123
|
|
|
124
124
|
{/* Show layout info from onLayout callbacks */}
|
|
125
|
-
<Box marginTop={1} borderStyle="single" borderColor="
|
|
125
|
+
<Box marginTop={1} borderStyle="single" borderColor="$border" padding={1}>
|
|
126
126
|
<Box flexDirection="column">
|
|
127
127
|
<Text bold dim>
|
|
128
128
|
onLayout Results:
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import React, { useState, useEffect, useMemo } from "react"
|
|
11
11
|
import { Box, Text, ListView } from "silvery"
|
|
12
|
-
import { SearchProvider, SearchBar, useSearch } from "silvery"
|
|
12
|
+
import { SearchProvider, SearchBar, useSearch } from "@silvery/ag-react"
|
|
13
13
|
import { run, useInput, type Key } from "silvery/runtime"
|
|
14
14
|
import type { ExampleMeta } from "../../_banner.js"
|
|
15
15
|
import { SCRIPT } from "../aichat/script.js"
|
|
@@ -68,7 +68,7 @@ function PasteEventCard({ event, isLatest }: { event: PasteEvent; isLatest: bool
|
|
|
68
68
|
marginBottom={0}
|
|
69
69
|
>
|
|
70
70
|
<Box justifyContent="space-between">
|
|
71
|
-
<H1 color={isLatest ? "$primary" :
|
|
71
|
+
<H1 color={isLatest ? "$primary" : undefined}>Paste #{event.id}</H1>
|
|
72
72
|
<Small>{event.timestamp}</Small>
|
|
73
73
|
</Box>
|
|
74
74
|
<Box gap={2}>
|
|
@@ -80,7 +80,7 @@ function PasteEventCard({ event, isLatest }: { event: PasteEvent; isLatest: bool
|
|
|
80
80
|
</Small>
|
|
81
81
|
</Box>
|
|
82
82
|
<Box marginTop={1}>
|
|
83
|
-
<Text color="
|
|
83
|
+
<Text color="$warning">{displayText}</Text>
|
|
84
84
|
</Box>
|
|
85
85
|
</Box>
|
|
86
86
|
)
|
|
@@ -38,7 +38,7 @@ export function ScrollExample() {
|
|
|
38
38
|
})
|
|
39
39
|
|
|
40
40
|
return (
|
|
41
|
-
<Box flexDirection="column" width={60} height={20}>
|
|
41
|
+
<Box flexDirection="column" padding={1} width={60} height={20}>
|
|
42
42
|
<Box
|
|
43
43
|
flexGrow={1}
|
|
44
44
|
flexDirection="column"
|
|
@@ -50,7 +50,7 @@ export function ScrollExample() {
|
|
|
50
50
|
>
|
|
51
51
|
{items.map((item, index) => (
|
|
52
52
|
<Box key={item.id} paddingX={1} backgroundColor={index === selectedIndex ? "$primary" : undefined}>
|
|
53
|
-
<Text color={index === selectedIndex ? "
|
|
53
|
+
<Text color={index === selectedIndex ? "$primary-fg" : undefined} bold={index === selectedIndex}>
|
|
54
54
|
{item.title}
|
|
55
55
|
</Text>
|
|
56
56
|
</Box>
|
|
@@ -162,7 +162,7 @@ function FilteredList({ query, isPending }: { query: string; isPending: boolean
|
|
|
162
162
|
<Box key={item.id} marginBottom={1}>
|
|
163
163
|
<Text bold>{item.name}</Text>
|
|
164
164
|
<Text dim> [{item.category}]</Text>
|
|
165
|
-
<Text color="
|
|
165
|
+
<Text color="$muted"> {item.tags.join(", ")}</Text>
|
|
166
166
|
</Box>
|
|
167
167
|
))}
|
|
168
168
|
{filtered.length === 0 && <Lead>No matches found</Lead>}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selection Model Demo
|
|
3
|
+
*
|
|
4
|
+
* Demonstrates the silvery selection model from docs/design/selection-model.md:
|
|
5
|
+
* - Node selection (click, j/k)
|
|
6
|
+
* - Multi-select (Cmd+click toggle, Shift+j/k extend)
|
|
7
|
+
* - Text editing (Enter → edit, Escape → node mode)
|
|
8
|
+
* - Mode ladder: text ──Esc──► node ──Esc──► board
|
|
9
|
+
* - Live status bar showing Selection state
|
|
10
|
+
*
|
|
11
|
+
* Run: bun examples/apps/selection.tsx
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import React, { useState, useCallback } from "react"
|
|
15
|
+
import { Box, Text, type SilveryMouseEvent } from "silvery"
|
|
16
|
+
import { run, useInput, type Key } from "silvery/runtime"
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// Selection Model (pure functions — the whole design doc in ~60 lines)
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
type ID = string
|
|
23
|
+
|
|
24
|
+
type TextPoint = { nodeId: ID; offset: number }
|
|
25
|
+
|
|
26
|
+
type Selection = {
|
|
27
|
+
nodes: readonly [ID, ...ID[]]
|
|
28
|
+
text?: readonly [TextPoint] | readonly [TextPoint, TextPoint]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const S = {
|
|
32
|
+
// Read
|
|
33
|
+
cursor: (sel: Selection): ID => sel.nodes[0],
|
|
34
|
+
anchor: (sel: Selection): ID => sel.nodes.at(-1)!,
|
|
35
|
+
ids: (sel: Selection): ReadonlySet<ID> => new Set(sel.nodes),
|
|
36
|
+
includes: (sel: Selection, id: ID): boolean => sel.nodes.includes(id),
|
|
37
|
+
isEditing: (sel: Selection): boolean => !!sel.text,
|
|
38
|
+
inputMode: (sel: Selection | undefined): "board" | "node" | "text" => (!sel ? "board" : sel.text ? "text" : "node"),
|
|
39
|
+
|
|
40
|
+
// Node mutations (clear text)
|
|
41
|
+
select: (id: ID): Selection => ({ nodes: [id] }),
|
|
42
|
+
|
|
43
|
+
toggle(sel: Selection, id: ID): Selection | undefined {
|
|
44
|
+
if (sel.nodes.includes(id)) {
|
|
45
|
+
const rest = sel.nodes.filter((n) => n !== id) as unknown as [ID, ...ID[]]
|
|
46
|
+
return rest.length > 0 ? { nodes: rest } : undefined
|
|
47
|
+
}
|
|
48
|
+
return { nodes: [id, ...sel.nodes] }
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
extend(sel: Selection, id: ID, allIds: readonly ID[]): Selection {
|
|
52
|
+
const anchorIdx = allIds.indexOf(S.anchor(sel))
|
|
53
|
+
const targetIdx = allIds.indexOf(id)
|
|
54
|
+
if (anchorIdx < 0 || targetIdx < 0) return sel
|
|
55
|
+
const lo = Math.min(anchorIdx, targetIdx)
|
|
56
|
+
const hi = Math.max(anchorIdx, targetIdx)
|
|
57
|
+
const range = allIds.slice(lo, hi + 1)
|
|
58
|
+
// cursor first, anchor last
|
|
59
|
+
const nodes = targetIdx <= anchorIdx ? (range as [ID, ...ID[]]) : ([...range].reverse() as [ID, ...ID[]])
|
|
60
|
+
return { nodes }
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
areaSelect(_sel: Selection | undefined, hitIds: readonly ID[], mode: "replace" | "xor"): Selection | undefined {
|
|
64
|
+
if (mode === "replace") {
|
|
65
|
+
return hitIds.length > 0 ? { nodes: hitIds as [ID, ...ID[]] } : undefined
|
|
66
|
+
}
|
|
67
|
+
// XOR: toggle each hit against current
|
|
68
|
+
let result = _sel
|
|
69
|
+
for (const id of hitIds) {
|
|
70
|
+
result = result ? S.toggle(result, id) : S.select(id)
|
|
71
|
+
}
|
|
72
|
+
return result
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
clear: (): undefined => undefined,
|
|
76
|
+
|
|
77
|
+
collapseToCursor(sel: Selection): Selection {
|
|
78
|
+
return { nodes: [sel.nodes[0]] }
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
// Text mutations (don't touch nodes)
|
|
82
|
+
edit(sel: Selection, offset: number): Selection {
|
|
83
|
+
return { ...sel, text: [{ nodeId: sel.nodes[0], offset }] }
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
stopEditing(sel: Selection): Selection {
|
|
87
|
+
const { text: _, ...rest } = sel
|
|
88
|
+
return rest as Selection
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ============================================================================
|
|
93
|
+
// Demo Data
|
|
94
|
+
// ============================================================================
|
|
95
|
+
|
|
96
|
+
const ITEMS: { id: ID; label: string }[] = [
|
|
97
|
+
{ id: "inbox", label: "Inbox" },
|
|
98
|
+
{ id: "today", label: "Today" },
|
|
99
|
+
{ id: "next", label: "Next Actions" },
|
|
100
|
+
{ id: "projects", label: "Projects" },
|
|
101
|
+
{ id: "waiting", label: "Waiting For" },
|
|
102
|
+
{ id: "someday", label: "Someday / Maybe" },
|
|
103
|
+
{ id: "reference", label: "Reference" },
|
|
104
|
+
{ id: "calendar", label: "Calendar" },
|
|
105
|
+
{ id: "review", label: "Weekly Review" },
|
|
106
|
+
{ id: "done", label: "Done" },
|
|
107
|
+
]
|
|
108
|
+
const ALL_IDS = ITEMS.map((i) => i.id)
|
|
109
|
+
|
|
110
|
+
// ============================================================================
|
|
111
|
+
// Components
|
|
112
|
+
// ============================================================================
|
|
113
|
+
|
|
114
|
+
function ItemRow({
|
|
115
|
+
item,
|
|
116
|
+
sel,
|
|
117
|
+
onSelect,
|
|
118
|
+
}: {
|
|
119
|
+
item: { id: ID; label: string }
|
|
120
|
+
sel: Selection | undefined
|
|
121
|
+
onSelect: (id: ID, meta: boolean, shift: boolean) => void
|
|
122
|
+
}) {
|
|
123
|
+
const isCursor = sel ? S.cursor(sel) === item.id : false
|
|
124
|
+
const isAnchor = sel ? S.anchor(sel) === item.id : false
|
|
125
|
+
const isSelected = sel ? S.includes(sel, item.id) : false
|
|
126
|
+
const isEditing = isCursor && sel ? S.isEditing(sel) : false
|
|
127
|
+
|
|
128
|
+
const marker = isCursor ? "►" : isSelected ? "●" : " "
|
|
129
|
+
const anchorMark = isAnchor && !isCursor ? " ⚓" : ""
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<Box
|
|
133
|
+
onClick={(e: SilveryMouseEvent) => onSelect(item.id, e.metaKey, e.shiftKey)}
|
|
134
|
+
onDoubleClick={() => onSelect(item.id, false, false)}
|
|
135
|
+
>
|
|
136
|
+
<Text color={isSelected ? "$primary" : "$muted"}>{marker} </Text>
|
|
137
|
+
{isEditing ? (
|
|
138
|
+
<Text backgroundColor="$surface" color="$text" bold>
|
|
139
|
+
{" "}
|
|
140
|
+
{item.label}
|
|
141
|
+
<Text color="$primary">│</Text>{" "}
|
|
142
|
+
</Text>
|
|
143
|
+
) : (
|
|
144
|
+
<Text
|
|
145
|
+
bold={isCursor}
|
|
146
|
+
color={isCursor ? "$primary" : isSelected ? "$primary" : "$text"}
|
|
147
|
+
dim={!isSelected && !isCursor}
|
|
148
|
+
>
|
|
149
|
+
{item.label}
|
|
150
|
+
</Text>
|
|
151
|
+
)}
|
|
152
|
+
<Text color="$muted" dim>
|
|
153
|
+
{anchorMark}
|
|
154
|
+
</Text>
|
|
155
|
+
</Box>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function StatusBar({ sel }: { sel: Selection | undefined }) {
|
|
160
|
+
const mode = S.inputMode(sel)
|
|
161
|
+
const modeColor = mode === "text" ? "$success" : mode === "node" ? "$primary" : "$muted"
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<Box flexDirection="column" borderStyle="single" borderColor="$border" paddingX={1}>
|
|
165
|
+
<Box gap={2}>
|
|
166
|
+
<Text color={modeColor} bold>
|
|
167
|
+
{mode.toUpperCase()}
|
|
168
|
+
</Text>
|
|
169
|
+
{sel && (
|
|
170
|
+
<>
|
|
171
|
+
<Text color="$muted">
|
|
172
|
+
cursor=<Text color="$primary">{S.cursor(sel)}</Text>
|
|
173
|
+
</Text>
|
|
174
|
+
<Text color="$muted">
|
|
175
|
+
anchor=<Text color="$text">{S.anchor(sel)}</Text>
|
|
176
|
+
</Text>
|
|
177
|
+
<Text color="$muted">
|
|
178
|
+
selected=<Text color="$text">{sel.nodes.length}</Text>
|
|
179
|
+
</Text>
|
|
180
|
+
{sel.text && (
|
|
181
|
+
<Text color="$muted">
|
|
182
|
+
text=<Text color="$success">offset {sel.text[0].offset}</Text>
|
|
183
|
+
</Text>
|
|
184
|
+
)}
|
|
185
|
+
</>
|
|
186
|
+
)}
|
|
187
|
+
</Box>
|
|
188
|
+
<Text color="$muted" dim>
|
|
189
|
+
j/k nav · Enter edit · Esc back · Shift+j/k extend · Cmd+click toggle · q quit
|
|
190
|
+
</Text>
|
|
191
|
+
</Box>
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function SelectionDemo() {
|
|
196
|
+
const [sel, setSel] = useState<Selection | undefined>(undefined)
|
|
197
|
+
const [editTexts, setEditTexts] = useState<Record<ID, string>>(() =>
|
|
198
|
+
Object.fromEntries(ITEMS.map((i) => [i.id, i.label])),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
const cursorIndex = sel ? ALL_IDS.indexOf(S.cursor(sel)) : -1
|
|
202
|
+
|
|
203
|
+
const handleSelect = useCallback((id: ID, meta: boolean, shift: boolean) => {
|
|
204
|
+
setSel((prev) => {
|
|
205
|
+
if (meta && prev) return S.toggle(prev, id)
|
|
206
|
+
if (shift && prev) return S.extend(prev, id, ALL_IDS)
|
|
207
|
+
return S.select(id)
|
|
208
|
+
})
|
|
209
|
+
}, [])
|
|
210
|
+
|
|
211
|
+
useInput((input: string, key: Key) => {
|
|
212
|
+
if (input === "q" || (key.escape && !sel)) return "exit"
|
|
213
|
+
|
|
214
|
+
const mode = S.inputMode(sel)
|
|
215
|
+
|
|
216
|
+
// Text mode: handle typing
|
|
217
|
+
if (mode === "text" && sel) {
|
|
218
|
+
if (key.escape) {
|
|
219
|
+
setSel(S.stopEditing(sel))
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
if (key.backspace && sel.text) {
|
|
223
|
+
const nodeId = S.cursor(sel)
|
|
224
|
+
const offset = sel.text[0].offset
|
|
225
|
+
if (offset > 0) {
|
|
226
|
+
setEditTexts((prev) => ({
|
|
227
|
+
...prev,
|
|
228
|
+
[nodeId]: prev[nodeId]!.slice(0, offset - 1) + prev[nodeId]!.slice(offset),
|
|
229
|
+
}))
|
|
230
|
+
setSel(S.edit(sel, offset - 1))
|
|
231
|
+
}
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
if (key.leftArrow && sel.text) {
|
|
235
|
+
setSel(S.edit(sel, Math.max(0, sel.text[0].offset - 1)))
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
if (key.rightArrow && sel.text) {
|
|
239
|
+
const maxLen = editTexts[S.cursor(sel)]?.length ?? 0
|
|
240
|
+
setSel(S.edit(sel, Math.min(maxLen, sel.text[0].offset + 1)))
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
// Printable character
|
|
244
|
+
if (input && !key.ctrl && !key.meta && sel.text) {
|
|
245
|
+
const nodeId = S.cursor(sel)
|
|
246
|
+
const offset = sel.text[0].offset
|
|
247
|
+
setEditTexts((prev) => ({
|
|
248
|
+
...prev,
|
|
249
|
+
[nodeId]: prev[nodeId]!.slice(0, offset) + input + prev[nodeId]!.slice(offset),
|
|
250
|
+
}))
|
|
251
|
+
setSel(S.edit(sel, offset + input.length))
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Node mode
|
|
258
|
+
if (mode === "node" && sel) {
|
|
259
|
+
if (key.escape) {
|
|
260
|
+
// Mode ladder: multi → single → board
|
|
261
|
+
if (sel.nodes.length > 1) {
|
|
262
|
+
setSel(S.collapseToCursor(sel))
|
|
263
|
+
} else {
|
|
264
|
+
setSel(S.clear())
|
|
265
|
+
}
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
if (key.return) {
|
|
269
|
+
const text = editTexts[S.cursor(sel)] ?? ""
|
|
270
|
+
setSel(S.edit(sel, text.length))
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
if (input === "j" || key.downArrow) {
|
|
274
|
+
const next = Math.min(ALL_IDS.length - 1, cursorIndex + 1)
|
|
275
|
+
if (key.shift) {
|
|
276
|
+
setSel(S.extend(sel, ALL_IDS[next]!, ALL_IDS))
|
|
277
|
+
} else {
|
|
278
|
+
setSel(S.select(ALL_IDS[next]!))
|
|
279
|
+
}
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
if (input === "k" || key.upArrow) {
|
|
283
|
+
const next = Math.max(0, cursorIndex - 1)
|
|
284
|
+
if (key.shift) {
|
|
285
|
+
setSel(S.extend(sel, ALL_IDS[next]!, ALL_IDS))
|
|
286
|
+
} else {
|
|
287
|
+
setSel(S.select(ALL_IDS[next]!))
|
|
288
|
+
}
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Board mode — any nav enters node mode
|
|
295
|
+
if (input === "j" || key.downArrow) setSel(S.select(ALL_IDS[0]!))
|
|
296
|
+
if (input === "k" || key.upArrow) setSel(S.select(ALL_IDS.at(-1)!))
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
// Sync edit texts back to items for display
|
|
300
|
+
const displayItems = ITEMS.map((item) => ({ ...item, label: editTexts[item.id] ?? item.label }))
|
|
301
|
+
|
|
302
|
+
return (
|
|
303
|
+
<Box flexDirection="column" padding={1} height="100%">
|
|
304
|
+
<Box marginBottom={1}>
|
|
305
|
+
<Text bold color="$primary">
|
|
306
|
+
Selection Model Demo
|
|
307
|
+
</Text>
|
|
308
|
+
<Text color="$muted"> — silvery reactive selection</Text>
|
|
309
|
+
</Box>
|
|
310
|
+
|
|
311
|
+
<Box flexDirection="column" flexGrow={1} borderStyle="round" borderColor="$border" overflow="hidden">
|
|
312
|
+
{displayItems.map((item) => (
|
|
313
|
+
<ItemRow key={item.id} item={item} sel={sel} onSelect={handleSelect} />
|
|
314
|
+
))}
|
|
315
|
+
</Box>
|
|
316
|
+
|
|
317
|
+
<StatusBar sel={sel} />
|
|
318
|
+
|
|
319
|
+
<Box marginTop={1} flexDirection="column">
|
|
320
|
+
<Text color="$muted" dim>
|
|
321
|
+
nodes=[{sel?.nodes.join(", ") ?? ""}]
|
|
322
|
+
</Text>
|
|
323
|
+
</Box>
|
|
324
|
+
</Box>
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ============================================================================
|
|
329
|
+
// Main
|
|
330
|
+
// ============================================================================
|
|
331
|
+
|
|
332
|
+
export const meta = {
|
|
333
|
+
name: "Selection",
|
|
334
|
+
description: "Reactive selection model — node/text modes, multi-select, mode ladder",
|
|
335
|
+
demo: true,
|
|
336
|
+
features: ["Selection model", "mode ladder", "multi-select", "text editing"],
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (import.meta.main) {
|
|
340
|
+
using handle = await run(<SelectionDemo />, { mode: "fullscreen" })
|
|
341
|
+
await handle.waitUntilExit()
|
|
342
|
+
}
|