@trendr/core 0.1.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/LICENSE +21 -0
- package/README.md +526 -0
- package/index.js +22 -0
- package/jsx-runtime.js +9 -0
- package/package.json +35 -0
- package/src/ansi.js +149 -0
- package/src/buffer.js +101 -0
- package/src/button.js +26 -0
- package/src/checkbox.js +28 -0
- package/src/components.js +13 -0
- package/src/diff.js +51 -0
- package/src/element.js +81 -0
- package/src/focus.js +135 -0
- package/src/hooks.js +63 -0
- package/src/hotkey.js +35 -0
- package/src/input.js +143 -0
- package/src/layout.js +312 -0
- package/src/list.js +46 -0
- package/src/modal.js +44 -0
- package/src/progress.js +21 -0
- package/src/radio.js +52 -0
- package/src/renderer.js +478 -0
- package/src/scheduler.js +55 -0
- package/src/scrollable-text.js +75 -0
- package/src/select.js +151 -0
- package/src/signal.js +159 -0
- package/src/spinner.js +21 -0
- package/src/table.js +51 -0
- package/src/tabs.js +30 -0
- package/src/text-area.js +286 -0
- package/src/text-input.js +131 -0
- package/src/toast.js +61 -0
- package/src/wrap.js +133 -0
package/src/renderer.js
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { createBuffer, clearBuffer, fillRect, writeText, dimBuffer } from './buffer.js'
|
|
2
|
+
import { diff } from './diff.js'
|
|
3
|
+
import { computeLayout } from './layout.js'
|
|
4
|
+
import { Fragment } from './element.js'
|
|
5
|
+
import { createScheduler } from './scheduler.js'
|
|
6
|
+
import { createInputHandler } from './input.js'
|
|
7
|
+
import { setSchedulerHook, setHookRegistrar, createScope, disposeScope, onCleanup } from './signal.js'
|
|
8
|
+
import { wordWrap, measureText, sliceVisible } from './wrap.js'
|
|
9
|
+
import * as ansi from './ansi.js'
|
|
10
|
+
|
|
11
|
+
let activeContext = null
|
|
12
|
+
let overlays = []
|
|
13
|
+
|
|
14
|
+
export function getContext() {
|
|
15
|
+
return activeContext
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const DEFAULT_THEME = { accent: 'cyan' }
|
|
19
|
+
|
|
20
|
+
export function getTheme() {
|
|
21
|
+
return activeContext?.theme ?? DEFAULT_THEME
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getInstanceLayout() {
|
|
25
|
+
if (!currentHookOwner) return { x: 0, y: 0, width: 0, height: 0 }
|
|
26
|
+
return currentHookOwner.layout ?? { x: 0, y: 0, width: 0, height: 0 }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function registerOverlay(element, { backdrop, fullscreen } = {}) {
|
|
30
|
+
if (!currentHookOwner) return
|
|
31
|
+
overlays.push({ element, owner: currentHookOwner, backdrop, fullscreen })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const BORDER_CHARS = {
|
|
35
|
+
single: { tl: '\u250c', tr: '\u2510', bl: '\u2514', br: '\u2518', h: '\u2500', v: '\u2502' },
|
|
36
|
+
double: { tl: '\u2554', tr: '\u2557', bl: '\u255a', br: '\u255d', h: '\u2550', v: '\u2551' },
|
|
37
|
+
round: { tl: '\u256d', tr: '\u256e', bl: '\u2570', br: '\u256f', h: '\u2500', v: '\u2502' },
|
|
38
|
+
bold: { tl: '\u250f', tr: '\u2513', bl: '\u2517', br: '\u251b', h: '\u2501', v: '\u2503' },
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resolveAttrs(style) {
|
|
42
|
+
let attrs = 0
|
|
43
|
+
if (style.bold) attrs |= ansi.BOLD
|
|
44
|
+
if (style.dim) attrs |= ansi.DIM
|
|
45
|
+
if (style.italic) attrs |= ansi.ITALIC
|
|
46
|
+
if (style.underline) attrs |= ansi.UNDERLINE
|
|
47
|
+
if (style.inverse) attrs |= ansi.INVERSE
|
|
48
|
+
if (style.strikethrough) attrs |= ansi.STRIKETHROUGH
|
|
49
|
+
return attrs
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function paintBorder(buf, rect, borderStyle, fg) {
|
|
53
|
+
const chars = typeof borderStyle === 'string'
|
|
54
|
+
? (BORDER_CHARS[borderStyle] ?? BORDER_CHARS.single)
|
|
55
|
+
: BORDER_CHARS.single
|
|
56
|
+
|
|
57
|
+
const { x, y, width, height } = rect
|
|
58
|
+
if (width < 2 || height < 2) return
|
|
59
|
+
|
|
60
|
+
const cell = (ch) => ({ ch, fg: fg ?? null, bg: null, attrs: 0 })
|
|
61
|
+
|
|
62
|
+
buf.cells[y * buf.width + x] = cell(chars.tl)
|
|
63
|
+
buf.cells[y * buf.width + x + width - 1] = cell(chars.tr)
|
|
64
|
+
buf.cells[(y + height - 1) * buf.width + x] = cell(chars.bl)
|
|
65
|
+
buf.cells[(y + height - 1) * buf.width + x + width - 1] = cell(chars.br)
|
|
66
|
+
|
|
67
|
+
for (let col = x + 1; col < x + width - 1; col++) {
|
|
68
|
+
buf.cells[y * buf.width + col] = cell(chars.h)
|
|
69
|
+
buf.cells[(y + height - 1) * buf.width + col] = cell(chars.h)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (let row = y + 1; row < y + height - 1; row++) {
|
|
73
|
+
buf.cells[row * buf.width + x] = cell(chars.v)
|
|
74
|
+
buf.cells[row * buf.width + x + width - 1] = cell(chars.v)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function findContentRect(node) {
|
|
79
|
+
if (!node?._layout) return null
|
|
80
|
+
const style = node.props?.style ?? {}
|
|
81
|
+
if (style.border) return node._layout
|
|
82
|
+
if (node._resolvedChildren) {
|
|
83
|
+
for (const child of node._resolvedChildren) {
|
|
84
|
+
const found = findContentRect(child)
|
|
85
|
+
if (found) return found
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (node._resolved) return findContentRect(node._resolved)
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function clearOverlayRect(overlayTree, buf) {
|
|
93
|
+
const rect = findContentRect(overlayTree)
|
|
94
|
+
if (!rect) return
|
|
95
|
+
fillRect(buf, rect.x, rect.y, rect.width, rect.height, ' ', null, null, 0)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function clipRect(a, b) {
|
|
99
|
+
const x = Math.max(a.x, b.x)
|
|
100
|
+
const y = Math.max(a.y, b.y)
|
|
101
|
+
const r = Math.min(a.x + a.width, b.x + b.width)
|
|
102
|
+
const bot = Math.min(a.y + a.height, b.y + b.height)
|
|
103
|
+
return { x, y, width: Math.max(0, r - x), height: Math.max(0, bot - y) }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function paintTree(node, buf, clip) {
|
|
107
|
+
if (!node) return
|
|
108
|
+
|
|
109
|
+
if (node._resolved) {
|
|
110
|
+
paintTree(node._resolved, buf, clip)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (node.type === Fragment) {
|
|
115
|
+
if (node._resolvedChildren) {
|
|
116
|
+
for (const child of node._resolvedChildren) paintTree(child, buf, clip)
|
|
117
|
+
}
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const layout = node._layout
|
|
122
|
+
if (!layout || layout.width <= 0 || layout.height <= 0) return
|
|
123
|
+
|
|
124
|
+
const clipped = clip ? clipRect(layout, clip) : layout
|
|
125
|
+
if (clipped.width <= 0 || clipped.height <= 0) return
|
|
126
|
+
|
|
127
|
+
const style = node.props?.style ?? {}
|
|
128
|
+
const attrs = resolveAttrs(style)
|
|
129
|
+
|
|
130
|
+
if (node.type === 'text') {
|
|
131
|
+
const text = extractText(node)
|
|
132
|
+
if (!text) return
|
|
133
|
+
|
|
134
|
+
const truncate = style.overflow === 'truncate'
|
|
135
|
+
const wrap = style.overflow !== 'nowrap' && !truncate
|
|
136
|
+
|
|
137
|
+
if (wrap) {
|
|
138
|
+
const lines = wordWrap(text, layout.width)
|
|
139
|
+
for (let i = 0; i < lines.length && i < layout.height; i++) {
|
|
140
|
+
const rowY = layout.y + i
|
|
141
|
+
if (rowY < clipped.y || rowY >= clipped.y + clipped.height) continue
|
|
142
|
+
writeText(buf, clipped.x, rowY, lines[i].slice(clipped.x - layout.x), style.color, style.bg, attrs, clipped.width)
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
let line = text.replace(/\n/g, ' ')
|
|
146
|
+
if (truncate && measureText(line) > layout.width && layout.width > 3) {
|
|
147
|
+
line = sliceVisible(line, layout.width - 1) + '\u2026'
|
|
148
|
+
}
|
|
149
|
+
if (layout.y >= clipped.y && layout.y < clipped.y + clipped.height) {
|
|
150
|
+
writeText(buf, clipped.x, layout.y, line.slice(clipped.x - layout.x), style.color, style.bg, attrs, clipped.width)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (style.bg) {
|
|
157
|
+
fillRect(buf, clipped.x, clipped.y, clipped.width, clipped.height, ' ', null, style.bg, 0)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (style.border) {
|
|
161
|
+
paintBorder(buf, layout, style.border, style.borderColor)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const childClip = clip ? clipRect(layout, clip) : layout
|
|
165
|
+
|
|
166
|
+
if (node._resolvedChildren) {
|
|
167
|
+
for (const child of node._resolvedChildren) {
|
|
168
|
+
paintTree(child, buf, childClip)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function extractText(node) {
|
|
174
|
+
if (node == null || node === true || node === false) return ''
|
|
175
|
+
if (typeof node === 'string') return node
|
|
176
|
+
if (typeof node === 'number') return String(node)
|
|
177
|
+
const children = node.props?.children
|
|
178
|
+
if (children == null || children === true || children === false) return ''
|
|
179
|
+
if (typeof children === 'string') return children
|
|
180
|
+
if (typeof children === 'number') return String(children)
|
|
181
|
+
if (Array.isArray(children)) return children.map(c => extractText(c)).join('')
|
|
182
|
+
return ''
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function flattenChildren(children) {
|
|
186
|
+
if (children == null || children === true || children === false) return []
|
|
187
|
+
if (!Array.isArray(children)) return [children]
|
|
188
|
+
const result = []
|
|
189
|
+
for (const child of children) {
|
|
190
|
+
if (child == null || child === true || child === false) continue
|
|
191
|
+
if (Array.isArray(child)) result.push(...flattenChildren(child))
|
|
192
|
+
else result.push(child)
|
|
193
|
+
}
|
|
194
|
+
return result
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// component instances track the split between setup (runs once)
|
|
198
|
+
// and render (runs every frame)
|
|
199
|
+
//
|
|
200
|
+
// on first call, the component body executes fully - hooks register,
|
|
201
|
+
// signals are created, and the returned JSX is captured.
|
|
202
|
+
// the component function is wrapped so that on subsequent calls,
|
|
203
|
+
// only the JSX-producing part re-executes (by re-calling the component),
|
|
204
|
+
// but hooks detect they're already registered and skip.
|
|
205
|
+
|
|
206
|
+
// simpler model: each component is called once during mount.
|
|
207
|
+
// it returns a render function (a closure that produces JSX).
|
|
208
|
+
// on each frame, we call the render functions to get fresh trees.
|
|
209
|
+
//
|
|
210
|
+
// convention: components return either JSX directly (for static content)
|
|
211
|
+
// or we wrap them so the return value is always a function.
|
|
212
|
+
|
|
213
|
+
// actually simplest: components are just functions that return JSX.
|
|
214
|
+
// we call them once at mount time within a scope (hooks register).
|
|
215
|
+
// we store a reference to the component + props + scope.
|
|
216
|
+
// on each frame, we re-call the component function to get fresh JSX.
|
|
217
|
+
// BUT hooks must not re-register. hooks track their own registration
|
|
218
|
+
// using the scope.
|
|
219
|
+
|
|
220
|
+
// ok, final approach. the component model:
|
|
221
|
+
//
|
|
222
|
+
// 1. each component function is called once. this is the "setup" call.
|
|
223
|
+
// during setup, hooks (useInput, useInterval) register side effects
|
|
224
|
+
// in the current scope. the component also returns JSX.
|
|
225
|
+
//
|
|
226
|
+
// 2. we extract the "render" part by having the component return a
|
|
227
|
+
// function. if it returns JSX directly, we treat the whole component
|
|
228
|
+
// as the render function and call it on each frame - but hooks must
|
|
229
|
+
// be idempotent.
|
|
230
|
+
//
|
|
231
|
+
// since requiring users to return functions is a bad API, let's make
|
|
232
|
+
// hooks idempotent. each hook checks a per-scope registry to see
|
|
233
|
+
// if it's already been called with the same identity.
|
|
234
|
+
|
|
235
|
+
let hookIndex = 0
|
|
236
|
+
let currentHookOwner = null
|
|
237
|
+
|
|
238
|
+
export function startHookTracking(owner) {
|
|
239
|
+
currentHookOwner = owner
|
|
240
|
+
hookIndex = 0
|
|
241
|
+
setHookRegistrar(registerHook)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function endHookTracking() {
|
|
245
|
+
currentHookOwner = null
|
|
246
|
+
hookIndex = 0
|
|
247
|
+
setHookRegistrar(null)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function registerHook(setupFn) {
|
|
251
|
+
if (!currentHookOwner) {
|
|
252
|
+
return setupFn()
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const owner = currentHookOwner
|
|
256
|
+
if (!owner.hooks) owner.hooks = []
|
|
257
|
+
const idx = hookIndex++
|
|
258
|
+
|
|
259
|
+
if (idx >= owner.hooks.length) {
|
|
260
|
+
const result = setupFn()
|
|
261
|
+
owner.hooks.push(result)
|
|
262
|
+
return result
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return owner.hooks[idx]
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// resolve tree with component instance caching.
|
|
269
|
+
// instances are keyed by component function + occurrence index,
|
|
270
|
+
// so multiple instances of the same component each get their own state.
|
|
271
|
+
|
|
272
|
+
function resolveForFrame(element, parent, instances, counters, visited) {
|
|
273
|
+
if (element == null || typeof element === 'boolean') return null
|
|
274
|
+
|
|
275
|
+
if (typeof element === 'string' || typeof element === 'number') {
|
|
276
|
+
return {
|
|
277
|
+
type: 'text',
|
|
278
|
+
props: { children: String(element) },
|
|
279
|
+
key: null,
|
|
280
|
+
_parent: parent,
|
|
281
|
+
_layout: null,
|
|
282
|
+
_resolved: null,
|
|
283
|
+
_resolvedChildren: null,
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const node = {
|
|
288
|
+
type: element.type,
|
|
289
|
+
props: element.props ?? {},
|
|
290
|
+
key: element.key,
|
|
291
|
+
_parent: parent,
|
|
292
|
+
_layout: null,
|
|
293
|
+
_resolved: null,
|
|
294
|
+
_resolvedChildren: null,
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (typeof element.type === 'function') {
|
|
298
|
+
const fn = element.type
|
|
299
|
+
const count = counters.get(fn) ?? 0
|
|
300
|
+
counters.set(fn, count + 1)
|
|
301
|
+
|
|
302
|
+
const instanceKey = element.key != null ? `${fn.name}:key:${element.key}` : `${fn.name}:${count}`
|
|
303
|
+
if (visited) visited.add(instanceKey)
|
|
304
|
+
let instance = instances.get(instanceKey)
|
|
305
|
+
|
|
306
|
+
if (!instance) {
|
|
307
|
+
let result
|
|
308
|
+
instance = { scope: null, fn, hooks: [], node: null, layout: null }
|
|
309
|
+
instances.set(instanceKey, instance)
|
|
310
|
+
instance.scope = createScope(() => {
|
|
311
|
+
startHookTracking(instance)
|
|
312
|
+
result = fn(element.props ?? {})
|
|
313
|
+
endHookTracking()
|
|
314
|
+
})
|
|
315
|
+
node._resolved = resolveForFrame(result, node, instances, counters, visited)
|
|
316
|
+
} else {
|
|
317
|
+
startHookTracking(instance)
|
|
318
|
+
const result = fn(element.props ?? {})
|
|
319
|
+
endHookTracking()
|
|
320
|
+
node._resolved = resolveForFrame(result, node, instances, counters, visited)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
instance.node = node
|
|
324
|
+
return node
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (element.type === Fragment) {
|
|
328
|
+
const children = flattenChildren(element.props?.children)
|
|
329
|
+
node._resolvedChildren = children.map(c => resolveForFrame(c, node, instances, counters, visited)).filter(Boolean)
|
|
330
|
+
return node
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const children = flattenChildren(element.props?.children)
|
|
334
|
+
if (children.length > 0) {
|
|
335
|
+
node._resolvedChildren = children.map(c => resolveForFrame(c, node, instances, counters, visited)).filter(Boolean)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return node
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function mount(rootComponent, { stream, stdin, title, theme } = {}) {
|
|
342
|
+
const out = stream ?? process.stdout
|
|
343
|
+
const inp = stdin ?? process.stdin
|
|
344
|
+
|
|
345
|
+
let width = out.columns ?? 80
|
|
346
|
+
let height = out.rows ?? 24
|
|
347
|
+
|
|
348
|
+
let prev = createBuffer(width, height)
|
|
349
|
+
let curr = createBuffer(width, height)
|
|
350
|
+
|
|
351
|
+
const input = createInputHandler(inp)
|
|
352
|
+
const ctx = { stream: out, input, stdin: inp, theme: { ...DEFAULT_THEME, ...theme } }
|
|
353
|
+
activeContext = ctx
|
|
354
|
+
|
|
355
|
+
// component instance cache persists across frames
|
|
356
|
+
// maps instanceKey -> { scope, fn, hooks }
|
|
357
|
+
const instances = new Map()
|
|
358
|
+
|
|
359
|
+
function frame() {
|
|
360
|
+
const prevCtx = activeContext
|
|
361
|
+
activeContext = ctx
|
|
362
|
+
overlays = []
|
|
363
|
+
|
|
364
|
+
clearBuffer(curr)
|
|
365
|
+
|
|
366
|
+
// counters reset each frame so occurrence indices are stable
|
|
367
|
+
const counters = new Map()
|
|
368
|
+
const visited = new Set()
|
|
369
|
+
const element = { type: rootComponent, props: {}, key: null }
|
|
370
|
+
const tree = resolveForFrame(element, null, instances, counters, visited)
|
|
371
|
+
|
|
372
|
+
computeLayout(tree, { x: 0, y: 0, width, height })
|
|
373
|
+
|
|
374
|
+
for (const inst of instances.values()) {
|
|
375
|
+
if (inst.node?._availableRect) inst.layout = inst.node._availableRect
|
|
376
|
+
else if (inst.node?._layout) inst.layout = inst.node._layout
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
paintTree(tree, curr)
|
|
380
|
+
|
|
381
|
+
for (const { element: overlayEl, owner, backdrop, fullscreen } of overlays) {
|
|
382
|
+
if (backdrop) dimBuffer(curr)
|
|
383
|
+
|
|
384
|
+
const overlayTree = resolveForFrame(overlayEl, null, instances, counters, visited)
|
|
385
|
+
if (overlayTree) {
|
|
386
|
+
if (backdrop || fullscreen) {
|
|
387
|
+
computeLayout(overlayTree, { x: 0, y: 0, width, height })
|
|
388
|
+
} else {
|
|
389
|
+
const anchor = owner.node?._layout ?? owner.layout ?? { x: 0, y: 0, width: 0, height: 0 }
|
|
390
|
+
computeLayout(overlayTree, {
|
|
391
|
+
x: anchor.x,
|
|
392
|
+
y: anchor.y + 1,
|
|
393
|
+
width: width - anchor.x,
|
|
394
|
+
height: height - anchor.y - 1,
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
clearOverlayRect(overlayTree, curr)
|
|
398
|
+
paintTree(overlayTree, curr)
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
for (const [key, inst] of instances) {
|
|
403
|
+
if (!visited.has(key)) {
|
|
404
|
+
disposeScope(inst.scope)
|
|
405
|
+
instances.delete(key)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
activeContext = prevCtx
|
|
410
|
+
|
|
411
|
+
const output = diff(prev, curr)
|
|
412
|
+
if (output) out.write(ansi.hideCursor + output)
|
|
413
|
+
|
|
414
|
+
const tmp = prev
|
|
415
|
+
prev = curr
|
|
416
|
+
curr = tmp
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const scheduler = createScheduler({
|
|
420
|
+
fps: 60,
|
|
421
|
+
onFrame: frame,
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
setSchedulerHook(scheduler.requestFrame)
|
|
425
|
+
|
|
426
|
+
out.write(ansi.altScreen + ansi.hideCursor + ansi.clearScreen + (title ? ansi.setTitle(title) : ''))
|
|
427
|
+
if (inp.isTTY && inp.setRawMode) inp.setRawMode(true)
|
|
428
|
+
|
|
429
|
+
frame()
|
|
430
|
+
scheduler.requestFrame()
|
|
431
|
+
|
|
432
|
+
const onResize = () => {
|
|
433
|
+
width = out.columns ?? 80
|
|
434
|
+
height = out.rows ?? 24
|
|
435
|
+
prev = createBuffer(width, height)
|
|
436
|
+
curr = createBuffer(width, height)
|
|
437
|
+
out.write(ansi.clearScreen)
|
|
438
|
+
scheduler.forceFrame()
|
|
439
|
+
}
|
|
440
|
+
out.on('resize', onResize)
|
|
441
|
+
|
|
442
|
+
input.onKey((event) => {
|
|
443
|
+
if (event.key === 'c' && event.ctrl) {
|
|
444
|
+
unmount()
|
|
445
|
+
process.exit(0)
|
|
446
|
+
}
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
let unmounted = false
|
|
450
|
+
|
|
451
|
+
function unmount() {
|
|
452
|
+
if (unmounted) return
|
|
453
|
+
unmounted = true
|
|
454
|
+
|
|
455
|
+
scheduler.destroy()
|
|
456
|
+
input.detach()
|
|
457
|
+
out.off('resize', onResize)
|
|
458
|
+
process.off('exit', onExit)
|
|
459
|
+
|
|
460
|
+
for (const inst of instances.values()) {
|
|
461
|
+
disposeScope(inst.scope)
|
|
462
|
+
}
|
|
463
|
+
instances.clear()
|
|
464
|
+
|
|
465
|
+
out.write(ansi.sgrReset + ansi.showCursor + ansi.exitAltScreen)
|
|
466
|
+
if (inp.isTTY && inp.setRawMode) inp.setRawMode(false)
|
|
467
|
+
activeContext = null
|
|
468
|
+
setSchedulerHook(null)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function onExit() {
|
|
472
|
+
unmount()
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
process.on('exit', onExit)
|
|
476
|
+
|
|
477
|
+
return { unmount }
|
|
478
|
+
}
|
package/src/scheduler.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export function createScheduler({ fps = 60, onFrame } = {}) {
|
|
2
|
+
const interval = Math.floor(1000 / fps)
|
|
3
|
+
let lastFrame = 0
|
|
4
|
+
let queued = false
|
|
5
|
+
let running = false
|
|
6
|
+
let timer = null
|
|
7
|
+
|
|
8
|
+
function tick() {
|
|
9
|
+
queued = false
|
|
10
|
+
timer = null
|
|
11
|
+
|
|
12
|
+
const now = Date.now()
|
|
13
|
+
const elapsed = now - lastFrame
|
|
14
|
+
|
|
15
|
+
if (elapsed < interval) {
|
|
16
|
+
timer = setTimeout(tick, interval - elapsed)
|
|
17
|
+
queued = true
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
running = true
|
|
22
|
+
lastFrame = now
|
|
23
|
+
onFrame()
|
|
24
|
+
running = false
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function requestFrame() {
|
|
28
|
+
if (queued || running) return
|
|
29
|
+
queued = true
|
|
30
|
+
setImmediate(tick)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function forceFrame() {
|
|
34
|
+
if (running) return
|
|
35
|
+
if (timer) {
|
|
36
|
+
clearTimeout(timer)
|
|
37
|
+
timer = null
|
|
38
|
+
}
|
|
39
|
+
queued = false
|
|
40
|
+
running = true
|
|
41
|
+
lastFrame = Date.now()
|
|
42
|
+
onFrame()
|
|
43
|
+
running = false
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function destroy() {
|
|
47
|
+
if (timer) {
|
|
48
|
+
clearTimeout(timer)
|
|
49
|
+
timer = null
|
|
50
|
+
}
|
|
51
|
+
queued = false
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { requestFrame, forceFrame, destroy }
|
|
55
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { jsx } from '../jsx-runtime.js'
|
|
2
|
+
import { createSignal } from './signal.js'
|
|
3
|
+
import { useInput, useLayout, useTheme } from './hooks.js'
|
|
4
|
+
import { wordWrap } from './wrap.js'
|
|
5
|
+
|
|
6
|
+
const TRACK = '\u2502'
|
|
7
|
+
const THUMB = '\u2588'
|
|
8
|
+
|
|
9
|
+
export function ScrollableText({ content = '', focused = true, scrollOffset: offsetProp, onScroll, width: widthProp, scrollbar = false, wrap = true }) {
|
|
10
|
+
const { accent = 'cyan' } = useTheme()
|
|
11
|
+
const [offsetInternal, setOffsetInternal] = createSignal(0)
|
|
12
|
+
const layout = useLayout()
|
|
13
|
+
|
|
14
|
+
const offset = offsetProp ?? offsetInternal()
|
|
15
|
+
const setOffset = onScroll ?? setOffsetInternal
|
|
16
|
+
|
|
17
|
+
const rawW = widthProp ?? layout.width
|
|
18
|
+
const h = layout.height
|
|
19
|
+
const w = scrollbar ? Math.max(0, rawW - 2) : rawW
|
|
20
|
+
|
|
21
|
+
const lines = (wrap && w > 0) ? wordWrap(content, w) : content.split('\n')
|
|
22
|
+
const maxOffset = Math.max(0, lines.length - (h || 1))
|
|
23
|
+
|
|
24
|
+
const clamp = (v) => Math.max(0, Math.min(maxOffset, v))
|
|
25
|
+
|
|
26
|
+
useInput(({ key, ctrl }) => {
|
|
27
|
+
if (!focused) return
|
|
28
|
+
if (lines.length === 0) return
|
|
29
|
+
|
|
30
|
+
const pageH = h || 10
|
|
31
|
+
const half = Math.max(1, Math.floor(pageH / 2))
|
|
32
|
+
|
|
33
|
+
if (key === 'up' || key === 'k') setOffset(clamp(offset - 1))
|
|
34
|
+
else if (key === 'down' || key === 'j') setOffset(clamp(offset + 1))
|
|
35
|
+
else if (key === 'pageup' || (ctrl && key === 'b')) setOffset(clamp(offset - pageH))
|
|
36
|
+
else if (key === 'pagedown' || (ctrl && key === 'f')) setOffset(clamp(offset + pageH))
|
|
37
|
+
else if (ctrl && key === 'u') setOffset(clamp(offset - half))
|
|
38
|
+
else if (ctrl && key === 'd') setOffset(clamp(offset + half))
|
|
39
|
+
else if (key === 'home' || key === 'g') setOffset(0)
|
|
40
|
+
else if (key === 'end' || key === 'G') setOffset(maxOffset)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const visible = lines.slice(offset, h > 0 ? offset + h : undefined)
|
|
44
|
+
|
|
45
|
+
const textStyle = wrap ? undefined : { overflow: 'truncate' }
|
|
46
|
+
|
|
47
|
+
if (!scrollbar || h <= 0 || lines.length <= h) {
|
|
48
|
+
const children = visible.map((line, i) =>
|
|
49
|
+
jsx('text', { key: i, style: textStyle, children: line || ' ' })
|
|
50
|
+
)
|
|
51
|
+
return jsx('box', { style: { flexDirection: 'column', flexGrow: 1 }, children })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const thumbH = Math.max(1, Math.round((h / lines.length) * h))
|
|
55
|
+
const thumbStart = maxOffset > 0
|
|
56
|
+
? Math.round((offset / maxOffset) * (h - thumbH))
|
|
57
|
+
: 0
|
|
58
|
+
|
|
59
|
+
const children = visible.map((line, i) => {
|
|
60
|
+
const isThumb = i >= thumbStart && i < thumbStart + thumbH
|
|
61
|
+
const barChar = isThumb ? THUMB : TRACK
|
|
62
|
+
const barColor = isThumb ? (focused ? accent : 'gray') : 'gray'
|
|
63
|
+
|
|
64
|
+
return jsx('box', {
|
|
65
|
+
key: i,
|
|
66
|
+
style: { flexDirection: 'row', height: 1 },
|
|
67
|
+
children: [
|
|
68
|
+
jsx('text', { style: { flexGrow: 1, ...textStyle }, children: line || ' ' }),
|
|
69
|
+
jsx('text', { style: { color: barColor, dim: !isThumb }, children: ' ' + barChar }),
|
|
70
|
+
],
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
return jsx('box', { style: { flexDirection: 'column', flexGrow: 1 }, children })
|
|
75
|
+
}
|