@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.
@@ -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
+ }
@@ -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
+ }