@trendr/core 0.2.11 → 0.4.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/README.md +284 -24
- package/index.js +6 -2
- package/package.json +29 -34
- package/src/animation.js +25 -12
- package/src/ansi.js +6 -0
- package/src/buffer.js +63 -50
- package/src/button.js +2 -2
- package/src/checkbox.js +2 -2
- package/src/diff-engine.js +313 -0
- package/src/diff-view.js +238 -0
- package/src/diff.js +2 -2
- package/src/dropdown.js +147 -0
- package/src/focus.js +89 -27
- package/src/hooks.js +43 -25
- package/src/hotkey.js +51 -16
- package/src/input.js +211 -72
- package/src/layout.js +34 -13
- package/src/list.js +29 -16
- package/src/markdown.js +177 -0
- package/src/menu.js +13 -6
- package/src/menubar.js +46 -115
- package/src/miller-nav.js +36 -11
- package/src/modal.js +3 -1
- package/src/pick-list.js +16 -17
- package/src/progress.js +3 -3
- package/src/radio.js +16 -5
- package/src/renderer.js +291 -50
- package/src/scheduler.js +28 -10
- package/src/scroll-box.js +12 -11
- package/src/scrollable-text.js +3 -2
- package/src/select.js +65 -173
- package/src/selection.js +92 -0
- package/src/shimmer.js +2 -2
- package/src/signal.js +20 -3
- package/src/table.js +8 -27
- package/src/tabs.js +10 -7
- package/src/task.js +3 -3
- package/src/text-area.js +96 -41
- package/src/text-input.js +70 -28
- package/src/wrap.js +205 -84
package/src/renderer.js
CHANGED
|
@@ -5,8 +5,8 @@ import { computeLayout, resolveBorderEdges, intrinsicHeight } from './layout.js'
|
|
|
5
5
|
import { Fragment } from './element.js'
|
|
6
6
|
import { createScheduler } from './scheduler.js'
|
|
7
7
|
import { createInputHandler } from './input.js'
|
|
8
|
-
import { setSchedulerHook, setHookRegistrar, createScope, disposeScope, onCleanup, startRenderTracking, stopRenderTracking } from './signal.js'
|
|
9
|
-
import { wordWrap, measureText, sliceVisible } from './wrap.js'
|
|
8
|
+
import { setSchedulerHook, setHookRegistrar, createScope, disposeScope, runInScope, onCleanup, startRenderTracking, stopRenderTracking } from './signal.js'
|
|
9
|
+
import { wordWrap, wordWrapMarked, measureText, sliceVisible, sliceVisibleRange } from './wrap.js'
|
|
10
10
|
import * as ansi from './ansi.js'
|
|
11
11
|
|
|
12
12
|
let activeContext = null
|
|
@@ -20,7 +20,10 @@ export function getContext() {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
const DEFAULT_CURSOR = { blink: false, rate: 530, style: 'block' }
|
|
23
|
-
const DEFAULT_THEME = { accent: 'cyan' }
|
|
23
|
+
const DEFAULT_THEME = { accent: 'cyan', accentText: 'black', muted: 'gray' }
|
|
24
|
+
|
|
25
|
+
const enableBracketedPaste = '\x1b[?2004h'
|
|
26
|
+
const disableBracketedPaste = '\x1b[?2004l'
|
|
24
27
|
|
|
25
28
|
export function getTheme() {
|
|
26
29
|
return activeContext?.theme ?? DEFAULT_THEME
|
|
@@ -43,9 +46,26 @@ export function getInstanceLayout() {
|
|
|
43
46
|
return currentHookOwner.layout
|
|
44
47
|
}
|
|
45
48
|
|
|
46
|
-
export function registerOverlay(element, { backdrop, fullscreen } = {}) {
|
|
49
|
+
export function registerOverlay(element, { backdrop, fullscreen, capture } = {}) {
|
|
47
50
|
if (!currentHookOwner) return
|
|
48
|
-
overlays.push({ element, owner: currentHookOwner, backdrop, fullscreen })
|
|
51
|
+
overlays.push({ element, owner: currentHookOwner, backdrop, fullscreen, capture })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getCurrentHookOwner() {
|
|
55
|
+
return currentHookOwner
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// instances created while an overlay tree resolves are tagged with the overlay's
|
|
59
|
+
// owning instance, so input dispatch can tell whether a handler lives inside a
|
|
60
|
+
// capturing overlay's subtree (following the chain for overlays inside overlays)
|
|
61
|
+
const overlayContexts = new WeakMap()
|
|
62
|
+
let resolvingOverlayOwner = null
|
|
63
|
+
|
|
64
|
+
function captureOwnerFrom(list) {
|
|
65
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
66
|
+
if (list[i].capture) return list[i].owner
|
|
67
|
+
}
|
|
68
|
+
return null
|
|
49
69
|
}
|
|
50
70
|
|
|
51
71
|
const BORDER_CHARS = {
|
|
@@ -280,6 +300,19 @@ function layoutEqual(a, b) {
|
|
|
280
300
|
return a && b && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
|
|
281
301
|
}
|
|
282
302
|
|
|
303
|
+
function applySelectionHighlight(buf, sel) {
|
|
304
|
+
const lastY = Math.min(sel.ey, buf.height - 1)
|
|
305
|
+
for (let y = Math.max(0, sel.sy); y <= lastY; y++) {
|
|
306
|
+
const from = y === sel.sy ? Math.max(0, sel.sx) : 0
|
|
307
|
+
const to = y === sel.ey ? Math.min(sel.ex, buf.width - 1) : buf.width - 1
|
|
308
|
+
const base = y * buf.width
|
|
309
|
+
for (let x = from; x <= to; x++) {
|
|
310
|
+
const c = buf.cells[base + x]
|
|
311
|
+
buf.cells[base + x] = { ch: c.ch, fg: c.fg, bg: c.bg, attrs: c.attrs ^ ansi.INVERSE }
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
283
316
|
function paintTree(node, buf, clip, offset, prevBuf) {
|
|
284
317
|
if (!node) return
|
|
285
318
|
|
|
@@ -325,12 +358,16 @@ function paintTree(node, buf, clip, offset, prevBuf) {
|
|
|
325
358
|
const clip = style.overflow === 'clip'
|
|
326
359
|
const wrap = style.overflow !== 'nowrap' && !truncate && !clip
|
|
327
360
|
|
|
361
|
+
const leftClip = clipped.x - layout.x
|
|
362
|
+
|
|
328
363
|
if (wrap) {
|
|
329
|
-
const lines =
|
|
364
|
+
const { lines, soft } = wordWrapMarked(text, layout.width)
|
|
330
365
|
for (let i = 0; i < lines.length && i < layout.height; i++) {
|
|
331
366
|
const rowY = layout.y + i
|
|
332
367
|
if (rowY < clipped.y || rowY >= clipped.y + clipped.height) continue
|
|
333
|
-
|
|
368
|
+
if (soft[i] && rowY >= 0 && rowY < buf.height) buf.softWrap[rowY] = 1
|
|
369
|
+
const line = leftClip > 0 ? sliceVisibleRange(lines[i], leftClip, Infinity) : lines[i]
|
|
370
|
+
writeText(buf, clipped.x, rowY, line, style.color, style.bg, attrs, clipped.width)
|
|
334
371
|
}
|
|
335
372
|
} else {
|
|
336
373
|
let line = text.replace(/\n/g, ' ')
|
|
@@ -338,7 +375,8 @@ function paintTree(node, buf, clip, offset, prevBuf) {
|
|
|
338
375
|
line = sliceVisible(line, layout.width - 1) + '\u2026'
|
|
339
376
|
}
|
|
340
377
|
if (layout.y >= clipped.y && layout.y < clipped.y + clipped.height) {
|
|
341
|
-
|
|
378
|
+
if (leftClip > 0) line = sliceVisibleRange(line, leftClip, Infinity)
|
|
379
|
+
writeText(buf, clipped.x, layout.y, line, style.color, style.bg, attrs, clipped.width)
|
|
342
380
|
}
|
|
343
381
|
}
|
|
344
382
|
return
|
|
@@ -468,9 +506,22 @@ export function startHookTracking(owner) {
|
|
|
468
506
|
}
|
|
469
507
|
|
|
470
508
|
export function endHookTracking() {
|
|
509
|
+
const owner = currentHookOwner
|
|
510
|
+
const count = hookIndex
|
|
471
511
|
currentHookOwner = null
|
|
472
512
|
hookIndex = 0
|
|
473
513
|
setHookRegistrar(null)
|
|
514
|
+
if (owner) {
|
|
515
|
+
if (owner._hookCount == null) {
|
|
516
|
+
owner._hookCount = count
|
|
517
|
+
} else if (owner._hookCount !== count) {
|
|
518
|
+
const name = owner.fn?.name || 'anonymous component'
|
|
519
|
+
throw new Error(
|
|
520
|
+
`hook count changed between renders in ${name} (${owner._hookCount} then ${count}). ` +
|
|
521
|
+
'hooks must be called unconditionally, in the same order, on every render'
|
|
522
|
+
)
|
|
523
|
+
}
|
|
524
|
+
}
|
|
474
525
|
}
|
|
475
526
|
|
|
476
527
|
export function registerHook(setupFn) {
|
|
@@ -492,25 +543,49 @@ export function registerHook(setupFn) {
|
|
|
492
543
|
}
|
|
493
544
|
|
|
494
545
|
// resolve tree with component instance caching.
|
|
495
|
-
// instances are keyed by component function + occurrence index,
|
|
546
|
+
// instances are keyed by component function identity + occurrence index,
|
|
496
547
|
// so multiple instances of the same component each get their own state.
|
|
497
548
|
|
|
498
|
-
|
|
549
|
+
let nextFnId = 1
|
|
550
|
+
const fnIds = new WeakMap()
|
|
551
|
+
|
|
552
|
+
function getFnId(fn) {
|
|
553
|
+
let id = fnIds.get(fn)
|
|
554
|
+
if (id === undefined) {
|
|
555
|
+
id = nextFnId++
|
|
556
|
+
fnIds.set(fn, id)
|
|
557
|
+
}
|
|
558
|
+
return id
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// structural equality over props, including children element trees. anything
|
|
562
|
+
// that can't be proven equal (fresh closures, exotic values) counts as changed,
|
|
563
|
+
// so unprovable cases fall back to a repaint instead of a stale blit
|
|
564
|
+
function propValueEqual(a, b) {
|
|
499
565
|
if (a === b) return true
|
|
500
|
-
if (
|
|
566
|
+
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
|
|
567
|
+
const aArr = Array.isArray(a)
|
|
568
|
+
if (aArr !== Array.isArray(b)) return false
|
|
569
|
+
if (aArr) {
|
|
570
|
+
if (a.length !== b.length) return false
|
|
571
|
+
for (let i = 0; i < a.length; i++) {
|
|
572
|
+
if (!propValueEqual(a[i], b[i])) return false
|
|
573
|
+
}
|
|
574
|
+
return true
|
|
575
|
+
}
|
|
501
576
|
const keysA = Object.keys(a)
|
|
502
577
|
const keysB = Object.keys(b)
|
|
503
578
|
if (keysA.length !== keysB.length) return false
|
|
504
579
|
for (const k of keysA) {
|
|
505
|
-
if (k
|
|
506
|
-
if (a[k]
|
|
580
|
+
if (!(k in b)) return false
|
|
581
|
+
if (!propValueEqual(a[k], b[k])) return false
|
|
507
582
|
}
|
|
508
583
|
return true
|
|
509
584
|
}
|
|
510
585
|
|
|
511
586
|
function isInstanceClean(instance, newProps) {
|
|
512
587
|
if (!instance._trackedSignals) return false
|
|
513
|
-
if (!
|
|
588
|
+
if (!propValueEqual(instance._lastProps ?? null, newProps ?? null)) return false
|
|
514
589
|
const sigs = instance._trackedSignals
|
|
515
590
|
const vals = instance._signalValues
|
|
516
591
|
for (let i = 0; i < sigs.length; i++) {
|
|
@@ -551,18 +626,26 @@ function resolveForFrame(element, parent, instances, counters, visited, scope) {
|
|
|
551
626
|
|
|
552
627
|
if (typeof element.type === 'function') {
|
|
553
628
|
const fn = element.type
|
|
554
|
-
const
|
|
629
|
+
const fnKey = `${fn.name || 'anon'}#${getFnId(fn)}`
|
|
630
|
+
const counterKey = `${scope}/${fnKey}`
|
|
555
631
|
const count = counters.get(counterKey) ?? 0
|
|
556
632
|
counters.set(counterKey, count + 1)
|
|
557
633
|
|
|
558
|
-
const instanceKey = element.key != null ? `${scope}/${
|
|
634
|
+
const instanceKey = element.key != null ? `${scope}/${fnKey}:key:${element.key}` : `${scope}/${fnKey}:${count}`
|
|
559
635
|
if (visited) visited.add(instanceKey)
|
|
560
636
|
let instance = instances.get(instanceKey)
|
|
561
637
|
|
|
638
|
+
if (instance && instance.fn !== fn) {
|
|
639
|
+
disposeScope(instance.scope)
|
|
640
|
+
instances.delete(instanceKey)
|
|
641
|
+
instance = undefined
|
|
642
|
+
}
|
|
643
|
+
|
|
562
644
|
if (!instance) {
|
|
563
645
|
let result
|
|
564
646
|
instance = { scope: null, fn, hooks: [], node: null, layout: null, _dirty: true }
|
|
565
647
|
instances.set(instanceKey, instance)
|
|
648
|
+
if (resolvingOverlayOwner) overlayContexts.set(instance, resolvingOverlayOwner)
|
|
566
649
|
instance.scope = createScope(() => {
|
|
567
650
|
startHookTracking(instance)
|
|
568
651
|
startRenderTracking()
|
|
@@ -577,12 +660,17 @@ function resolveForFrame(element, parent, instances, counters, visited, scope) {
|
|
|
577
660
|
const clean = isInstanceClean(instance, element.props)
|
|
578
661
|
instance._dirty = !clean
|
|
579
662
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
const result =
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
663
|
+
// re-render inside the instance scope so any effect or cleanup created
|
|
664
|
+
// during a re-render is still disposed with the instance
|
|
665
|
+
const result = runInScope(instance.scope, () => {
|
|
666
|
+
startHookTracking(instance)
|
|
667
|
+
startRenderTracking()
|
|
668
|
+
const r = fn(element.props ?? {})
|
|
669
|
+
const signals = stopRenderTracking()
|
|
670
|
+
endHookTracking()
|
|
671
|
+
snapshotSignals(instance, signals)
|
|
672
|
+
return r
|
|
673
|
+
})
|
|
586
674
|
instance._lastProps = element.props
|
|
587
675
|
|
|
588
676
|
node._resolved = resolveForFrame(result, node, instances, counters, visited, instanceKey)
|
|
@@ -650,8 +738,20 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
650
738
|
let prev = createBuffer(width, height)
|
|
651
739
|
let curr = createBuffer(width, height)
|
|
652
740
|
|
|
653
|
-
const
|
|
654
|
-
const
|
|
741
|
+
const ctx = { stream: out, input: null, stdin: inp, theme: { ...DEFAULT_THEME, ...theme }, captureOwner: null, selection: null }
|
|
742
|
+
const input = createInputHandler(inp, {
|
|
743
|
+
isEligible: (owner) => {
|
|
744
|
+
const cap = ctx.captureOwner
|
|
745
|
+
if (!cap || owner == null) return true
|
|
746
|
+
let i = owner
|
|
747
|
+
while (i) {
|
|
748
|
+
if (i === cap) return true
|
|
749
|
+
i = overlayContexts.get(i) ?? null
|
|
750
|
+
}
|
|
751
|
+
return false
|
|
752
|
+
},
|
|
753
|
+
})
|
|
754
|
+
ctx.input = input
|
|
655
755
|
activeContext = ctx
|
|
656
756
|
|
|
657
757
|
// component instance cache persists across frames
|
|
@@ -659,6 +759,7 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
659
759
|
const instances = new Map()
|
|
660
760
|
let forceFullPaint = false
|
|
661
761
|
let prevHadOverlays = false
|
|
762
|
+
let prevHadSelection = false
|
|
662
763
|
|
|
663
764
|
// inline mode state: how many scrollback items have been committed, the
|
|
664
765
|
// visible width of each line the live region last emitted (so a later resize
|
|
@@ -668,6 +769,13 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
668
769
|
let prevLineLens = []
|
|
669
770
|
let prevLiveText = null
|
|
670
771
|
|
|
772
|
+
// while a modal/overlay is open, inline mode renders it fullscreen on the
|
|
773
|
+
// alternate screen. the terminal saves the main screen on entry and restores
|
|
774
|
+
// it exactly on exit, so the committed transcript and native scrollback are
|
|
775
|
+
// never touched
|
|
776
|
+
let overlayActive = false
|
|
777
|
+
let overlayPrev = null
|
|
778
|
+
|
|
671
779
|
// mirror per-instance layout (incl. scroll contentHeight/childHeights) back
|
|
672
780
|
// onto each instance so useLayout() consumers like List/Menu can window.
|
|
673
781
|
// returns whether anything changed, so the caller can re-resolve once and let
|
|
@@ -701,6 +809,76 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
701
809
|
const element = { type: rootComponent, props: {}, key: null }
|
|
702
810
|
let tree = resolveForFrame(element, null, instances, counters, visited, '')
|
|
703
811
|
|
|
812
|
+
// a registered overlay (e.g. a Modal) takes over the screen. render it on
|
|
813
|
+
// the alternate buffer so the inline transcript is saved/restored by the
|
|
814
|
+
// terminal around it - no scrollback wipe, no relative-erase guesswork.
|
|
815
|
+
// we reconstruct the visible transcript + live region into the alt buffer
|
|
816
|
+
// as a backdrop so the conversation still shows behind the modal
|
|
817
|
+
if (overlays.length > 0) {
|
|
818
|
+
if (!overlayActive) {
|
|
819
|
+
out.write(ansi.altScreen + ansi.hideCursor + ansi.clearScreen)
|
|
820
|
+
overlayActive = true
|
|
821
|
+
overlayPrev = createBuffer(width, height)
|
|
822
|
+
}
|
|
823
|
+
const overlayCurr = createBuffer(width, height)
|
|
824
|
+
|
|
825
|
+
const sbNode = findScrollback(tree)
|
|
826
|
+
const sbItems = sbNode?.props?.items ?? []
|
|
827
|
+
const sbRender = sbNode?.props?.render
|
|
828
|
+
const bg = []
|
|
829
|
+
if (sbRender) {
|
|
830
|
+
for (let i = 0; i < sbItems.length; i++) bg.push(...renderElementToLines(sbRender(sbItems[i], i), width))
|
|
831
|
+
}
|
|
832
|
+
const lh = Math.min(height, Math.max(1, intrinsicHeight(tree, width, height)))
|
|
833
|
+
computeLayout(tree, { x: 0, y: 0, width, height: lh })
|
|
834
|
+
const lbuf = createBuffer(width, lh)
|
|
835
|
+
paintTree(tree, lbuf, null, null, null)
|
|
836
|
+
bg.push(...bufferToLines(lbuf))
|
|
837
|
+
|
|
838
|
+
// show the tail that fits, top-aligned (matches the main screen's view)
|
|
839
|
+
const visible = bg.slice(Math.max(0, bg.length - height))
|
|
840
|
+
for (let i = 0; i < visible.length; i++) writeText(overlayCurr, 0, i, visible[i], null, null, 0)
|
|
841
|
+
|
|
842
|
+
for (const { element: ovEl, owner, backdrop } of overlays) {
|
|
843
|
+
resolvingOverlayOwner = owner
|
|
844
|
+
let ovTree
|
|
845
|
+
try {
|
|
846
|
+
ovTree = resolveForFrame(ovEl, null, instances, counters, visited, '')
|
|
847
|
+
} finally {
|
|
848
|
+
resolvingOverlayOwner = null
|
|
849
|
+
}
|
|
850
|
+
if (!ovTree) continue
|
|
851
|
+
computeLayout(ovTree, { x: 0, y: 0, width, height })
|
|
852
|
+
updateOverlayLayouts(ovTree)
|
|
853
|
+
if (backdrop) dimBuffer(overlayCurr)
|
|
854
|
+
clearOverlayRect(ovTree, overlayCurr)
|
|
855
|
+
paintTree(ovTree, overlayCurr, null, null, null)
|
|
856
|
+
}
|
|
857
|
+
ctx.captureOwner = captureOwnerFrom(overlays)
|
|
858
|
+
|
|
859
|
+
for (const [key, inst] of instances) {
|
|
860
|
+
if (!visited.has(key)) {
|
|
861
|
+
disposeScope(inst.scope)
|
|
862
|
+
instances.delete(key)
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
activeContext = prevCtx
|
|
866
|
+
const { output } = diff(overlayPrev, overlayCurr)
|
|
867
|
+
if (output) out.write(ansi.hideCursor + output)
|
|
868
|
+
overlayPrev = overlayCurr
|
|
869
|
+
return
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
ctx.captureOwner = null
|
|
873
|
+
|
|
874
|
+
if (overlayActive) {
|
|
875
|
+
// modal closed: the terminal restores the saved main screen exactly
|
|
876
|
+
out.write(ansi.exitAltScreen)
|
|
877
|
+
overlayActive = false
|
|
878
|
+
overlayPrev = null
|
|
879
|
+
prevLiveText = null
|
|
880
|
+
}
|
|
881
|
+
|
|
704
882
|
const sb = findScrollback(tree)
|
|
705
883
|
const items = sb?.props?.items ?? []
|
|
706
884
|
const renderItem = sb?.props?.render
|
|
@@ -826,24 +1004,38 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
826
1004
|
paintTree(tree2, curr, null, null, null)
|
|
827
1005
|
} else {
|
|
828
1006
|
propagateDirty(tree)
|
|
829
|
-
paintTree(tree, curr, null, null, (forceFullPaint || prevHadOverlays) ? null : prev)
|
|
830
|
-
forceFullPaint = false
|
|
1007
|
+
paintTree(tree, curr, null, null, (forceFullPaint || prevHadOverlays || prevHadSelection) ? null : prev)
|
|
831
1008
|
}
|
|
1009
|
+
forceFullPaint = false
|
|
832
1010
|
|
|
833
1011
|
const hasOverlays = overlays.length > 0
|
|
834
1012
|
|
|
835
1013
|
for (const { element: overlayEl, owner, backdrop, fullscreen } of overlays) {
|
|
836
1014
|
if (backdrop) dimBuffer(curr)
|
|
837
1015
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
const overlayTree = resolveForFrame(overlayEl, null, instances, counters, visited, '')
|
|
1016
|
+
resolvingOverlayOwner = owner
|
|
1017
|
+
let overlayTree
|
|
1018
|
+
try {
|
|
1019
|
+
overlayTree = resolveForFrame(overlayEl, null, instances, counters, visited, '')
|
|
1020
|
+
} finally {
|
|
1021
|
+
resolvingOverlayOwner = null
|
|
1022
|
+
}
|
|
846
1023
|
if (overlayTree) {
|
|
1024
|
+
let overlayRect
|
|
1025
|
+
if (backdrop || fullscreen) {
|
|
1026
|
+
overlayRect = { x: 0, y: 0, width, height }
|
|
1027
|
+
} else {
|
|
1028
|
+
const anchor = owner.node?._layout ?? owner.layout ?? { x: 0, y: 0, width: 0, height: 0 }
|
|
1029
|
+
const below = height - anchor.y - 1
|
|
1030
|
+
if (below > 0) {
|
|
1031
|
+
overlayRect = { x: anchor.x, y: anchor.y + 1, width: width - anchor.x, height: below }
|
|
1032
|
+
} else {
|
|
1033
|
+
// no room below the anchor - flip the overlay to sit right above it
|
|
1034
|
+
const ovWidth = width - anchor.x
|
|
1035
|
+
const h = Math.min(anchor.y, Math.max(1, intrinsicHeight(overlayTree, ovWidth, anchor.y)))
|
|
1036
|
+
overlayRect = { x: anchor.x, y: anchor.y - h, width: ovWidth, height: h }
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
847
1039
|
computeLayout(overlayTree, overlayRect)
|
|
848
1040
|
updateOverlayLayouts(overlayTree)
|
|
849
1041
|
clearOverlayRect(overlayTree, curr)
|
|
@@ -852,6 +1044,13 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
852
1044
|
}
|
|
853
1045
|
|
|
854
1046
|
prevHadOverlays = hasOverlays
|
|
1047
|
+
ctx.captureOwner = captureOwnerFrom(overlays)
|
|
1048
|
+
|
|
1049
|
+
// selection paints last, over everything, by flipping inverse on the
|
|
1050
|
+
// covered cells. new cell objects are required: blitting shares cell refs
|
|
1051
|
+
// with prev, and diff must see prev unchanged
|
|
1052
|
+
if (ctx.selection) applySelectionHighlight(curr, ctx.selection)
|
|
1053
|
+
prevHadSelection = !!ctx.selection
|
|
855
1054
|
|
|
856
1055
|
for (const [key, inst] of instances) {
|
|
857
1056
|
if (!visited.has(key)) {
|
|
@@ -865,7 +1064,9 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
865
1064
|
const { output, changed } = diff(prev, curr)
|
|
866
1065
|
if (changed > 0) {
|
|
867
1066
|
out.write(ansi.hideCursor)
|
|
868
|
-
|
|
1067
|
+
// diff() returns a view into a shared double buffer that gets reused two
|
|
1068
|
+
// frames later; write a copy so backpressured streams never see it mutate
|
|
1069
|
+
out.write(Buffer.from(output))
|
|
869
1070
|
}
|
|
870
1071
|
|
|
871
1072
|
const now = performance.now()
|
|
@@ -893,11 +1094,25 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
893
1094
|
// no alt screen, no clear, no mouse capture (so the terminal handles scroll
|
|
894
1095
|
// and text selection itself)
|
|
895
1096
|
if (inline) {
|
|
896
|
-
out.write(ansi.hideCursor + (title ? ansi.setTitle(title) : ''))
|
|
1097
|
+
out.write(ansi.hideCursor + enableBracketedPaste + (title ? ansi.setTitle(title) : ''))
|
|
897
1098
|
} else {
|
|
898
|
-
out.write((altScreen ? ansi.altScreen : '') + ansi.hideCursor + ansi.clearScreen + ansi.enableMouse + (title ? ansi.setTitle(title) : ''))
|
|
1099
|
+
out.write((altScreen ? ansi.altScreen : '') + ansi.hideCursor + ansi.clearScreen + ansi.enableMouse + enableBracketedPaste + (title ? ansi.setTitle(title) : ''))
|
|
899
1100
|
}
|
|
900
1101
|
if (inp.isTTY && inp.setRawMode) inp.setRawMode(true)
|
|
1102
|
+
// decode stdin as utf8 at the stream level so multibyte sequences split
|
|
1103
|
+
// across chunks arrive as complete strings, never U+FFFD halves
|
|
1104
|
+
if (typeof inp.setEncoding === 'function') inp.setEncoding('utf8')
|
|
1105
|
+
|
|
1106
|
+
// registered before the first frame resolves components, so component
|
|
1107
|
+
// useInput handlers (registered later) dispatch first and can intercept
|
|
1108
|
+
// ctrl+c via stopPropagation
|
|
1109
|
+
input.onKey((event) => {
|
|
1110
|
+
if (event.key === 'c' && event.ctrl) {
|
|
1111
|
+
unmount()
|
|
1112
|
+
if (onExitCb) onExitCb()
|
|
1113
|
+
else process.exit(0)
|
|
1114
|
+
}
|
|
1115
|
+
})
|
|
901
1116
|
|
|
902
1117
|
if (inline) inlineFrame()
|
|
903
1118
|
else frame()
|
|
@@ -907,12 +1122,20 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
907
1122
|
width = out.columns ?? 80
|
|
908
1123
|
height = out.rows ?? 24
|
|
909
1124
|
if (inline) {
|
|
1125
|
+
width = out.columns ?? 80
|
|
1126
|
+
height = out.rows ?? 24
|
|
1127
|
+
if (overlayActive) {
|
|
1128
|
+
// on the alternate screen: just clear and re-render the modal centered
|
|
1129
|
+
// at the new size. the saved main screen is restored on close
|
|
1130
|
+
overlayPrev = createBuffer(width, height)
|
|
1131
|
+
out.write(ansi.clearScreen)
|
|
1132
|
+
scheduler.forceFrame()
|
|
1133
|
+
return
|
|
1134
|
+
}
|
|
910
1135
|
// a resize reflows committed scrollback unpredictably, and a relative
|
|
911
1136
|
// erase of the live region cannot reliably track it across terminals.
|
|
912
1137
|
// rebuild instead: wipe the screen and scrollback, reset the commit
|
|
913
1138
|
// cursor, and re-commit the whole transcript cleanly at the new width
|
|
914
|
-
width = out.columns ?? 80
|
|
915
|
-
height = out.rows ?? 24
|
|
916
1139
|
out.write(ansi.clearScrollback + ansi.clearScreen + ansi.moveTo(1, 1))
|
|
917
1140
|
flushedCount = 0
|
|
918
1141
|
prevLineLens = []
|
|
@@ -927,14 +1150,6 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
927
1150
|
}
|
|
928
1151
|
out.on('resize', onResize)
|
|
929
1152
|
|
|
930
|
-
input.onKey((event) => {
|
|
931
|
-
if (event.key === 'c' && event.ctrl) {
|
|
932
|
-
unmount()
|
|
933
|
-
if (onExitCb) onExitCb()
|
|
934
|
-
else process.exit(0)
|
|
935
|
-
}
|
|
936
|
-
})
|
|
937
|
-
|
|
938
1153
|
let unmounted = false
|
|
939
1154
|
|
|
940
1155
|
function unmount() {
|
|
@@ -945,6 +1160,8 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
945
1160
|
input.detach()
|
|
946
1161
|
out.off('resize', onResize)
|
|
947
1162
|
process.off('exit', onExit)
|
|
1163
|
+
process.off('SIGTERM', onSigterm)
|
|
1164
|
+
process.off('SIGHUP', onSighup)
|
|
948
1165
|
|
|
949
1166
|
for (const inst of instances.values()) {
|
|
950
1167
|
disposeScope(inst.scope)
|
|
@@ -952,9 +1169,9 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
952
1169
|
instances.clear()
|
|
953
1170
|
|
|
954
1171
|
if (inline) {
|
|
955
|
-
out.write(ansi.sgrReset + ansi.showCursor + '\r\n')
|
|
1172
|
+
out.write((overlayActive ? ansi.exitAltScreen : '') + ansi.sgrReset + disableBracketedPaste + ansi.showCursor + '\r\n')
|
|
956
1173
|
} else {
|
|
957
|
-
out.write(ansi.sgrReset + ansi.disableMouse + ansi.showCursor + (altScreen ? ansi.exitAltScreen : ansi.moveTo(height, 1) + '\n'))
|
|
1174
|
+
out.write(ansi.sgrReset + ansi.disableMouse + disableBracketedPaste + ansi.showCursor + (altScreen ? ansi.exitAltScreen : ansi.moveTo(height, 1) + '\n'))
|
|
958
1175
|
}
|
|
959
1176
|
if (inp.isTTY && inp.setRawMode) inp.setRawMode(false)
|
|
960
1177
|
activeContext = null
|
|
@@ -965,7 +1182,25 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
965
1182
|
unmount()
|
|
966
1183
|
}
|
|
967
1184
|
|
|
1185
|
+
// SIGTERM/SIGHUP terminate without emitting 'exit', which would leave the
|
|
1186
|
+
// terminal in raw mode on the alt screen. restore, then re-raise the signal
|
|
1187
|
+
// so the default exit-code semantics are preserved - unless the app has its
|
|
1188
|
+
// own handler, in which case it owns the decision to exit
|
|
1189
|
+
function makeSignalHandler(sig) {
|
|
1190
|
+
const handler = () => {
|
|
1191
|
+
unmount()
|
|
1192
|
+
process.off(sig, handler)
|
|
1193
|
+
if (process.listenerCount(sig) === 0) process.kill(process.pid, sig)
|
|
1194
|
+
}
|
|
1195
|
+
return handler
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
const onSigterm = makeSignalHandler('SIGTERM')
|
|
1199
|
+
const onSighup = makeSignalHandler('SIGHUP')
|
|
1200
|
+
|
|
968
1201
|
process.on('exit', onExit)
|
|
1202
|
+
process.on('SIGTERM', onSigterm)
|
|
1203
|
+
process.on('SIGHUP', onSighup)
|
|
969
1204
|
|
|
970
1205
|
function repaint() {
|
|
971
1206
|
if (inline) {
|
|
@@ -980,7 +1215,13 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
980
1215
|
scheduler.forceFrame()
|
|
981
1216
|
}
|
|
982
1217
|
|
|
1218
|
+
function setTheme(patch) {
|
|
1219
|
+
Object.assign(ctx.theme, patch)
|
|
1220
|
+
scheduler.forceFrame()
|
|
1221
|
+
}
|
|
1222
|
+
|
|
983
1223
|
ctx.repaint = repaint
|
|
1224
|
+
ctx.getPaintBuffer = () => (inline ? lastInlineBuf : prev)
|
|
984
1225
|
|
|
985
|
-
return { unmount, repaint, getBuffer:
|
|
1226
|
+
return { unmount, repaint, setTheme, getBuffer: ctx.getPaintBuffer }
|
|
986
1227
|
}
|
package/src/scheduler.js
CHANGED
|
@@ -3,9 +3,26 @@ export function createScheduler({ fps = 60, onFrame } = {}) {
|
|
|
3
3
|
let lastFrame = 0
|
|
4
4
|
let queued = false
|
|
5
5
|
let running = false
|
|
6
|
+
let pending = false
|
|
7
|
+
let destroyed = false
|
|
6
8
|
let timer = null
|
|
7
9
|
|
|
10
|
+
function runFrame(now) {
|
|
11
|
+
running = true
|
|
12
|
+
lastFrame = now
|
|
13
|
+
try {
|
|
14
|
+
onFrame()
|
|
15
|
+
} finally {
|
|
16
|
+
running = false
|
|
17
|
+
}
|
|
18
|
+
if (pending) {
|
|
19
|
+
pending = false
|
|
20
|
+
requestFrame()
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
8
24
|
function tick() {
|
|
25
|
+
if (destroyed) return
|
|
9
26
|
queued = false
|
|
10
27
|
timer = null
|
|
11
28
|
|
|
@@ -18,37 +35,38 @@ export function createScheduler({ fps = 60, onFrame } = {}) {
|
|
|
18
35
|
return
|
|
19
36
|
}
|
|
20
37
|
|
|
21
|
-
|
|
22
|
-
lastFrame = now
|
|
23
|
-
onFrame()
|
|
24
|
-
running = false
|
|
38
|
+
runFrame(now)
|
|
25
39
|
}
|
|
26
40
|
|
|
27
41
|
function requestFrame() {
|
|
28
|
-
if (
|
|
42
|
+
if (destroyed) return
|
|
43
|
+
if (running) {
|
|
44
|
+
pending = true
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
if (queued) return
|
|
29
48
|
queued = true
|
|
30
49
|
setImmediate(tick)
|
|
31
50
|
}
|
|
32
51
|
|
|
33
52
|
function forceFrame() {
|
|
34
|
-
if (running) return
|
|
53
|
+
if (destroyed || running) return
|
|
35
54
|
if (timer) {
|
|
36
55
|
clearTimeout(timer)
|
|
37
56
|
timer = null
|
|
38
57
|
}
|
|
39
58
|
queued = false
|
|
40
|
-
|
|
41
|
-
lastFrame = Date.now()
|
|
42
|
-
onFrame()
|
|
43
|
-
running = false
|
|
59
|
+
runFrame(Date.now())
|
|
44
60
|
}
|
|
45
61
|
|
|
46
62
|
function destroy() {
|
|
63
|
+
destroyed = true
|
|
47
64
|
if (timer) {
|
|
48
65
|
clearTimeout(timer)
|
|
49
66
|
timer = null
|
|
50
67
|
}
|
|
51
68
|
queued = false
|
|
69
|
+
pending = false
|
|
52
70
|
}
|
|
53
71
|
|
|
54
72
|
return { requestFrame, forceFrame, destroy }
|
package/src/scroll-box.js
CHANGED
|
@@ -3,7 +3,7 @@ import { createSignal } from './signal.js'
|
|
|
3
3
|
import { useInput, useMouse, useLayout, useTheme, useScrollDrag } from './hooks.js'
|
|
4
4
|
|
|
5
5
|
export function ScrollBox({ children, focused = true, scrollOffset: offsetProp, onScroll, scrollbar = false, gap = 0, thumbChar = '\u2588', trackChar = '\u2502', style: userStyle }) {
|
|
6
|
-
const { accent = 'cyan' } = useTheme()
|
|
6
|
+
const { accent = 'cyan', muted = 'gray' } = useTheme()
|
|
7
7
|
const [offsetInternal, setOffsetInternal] = createSignal(0)
|
|
8
8
|
const layout = useLayout()
|
|
9
9
|
|
|
@@ -24,24 +24,25 @@ export function ScrollBox({ children, focused = true, scrollOffset: offsetProp,
|
|
|
24
24
|
const pageH = visibleH || 10
|
|
25
25
|
const half = Math.max(1, Math.floor(pageH / 2))
|
|
26
26
|
|
|
27
|
-
if (key === 'up' || key === 'k') setOffset(clamp(
|
|
28
|
-
else if (key === 'down' || key === 'j') setOffset(clamp(
|
|
29
|
-
else if (key === 'pageup' || (ctrl && key === 'b')) setOffset(clamp(
|
|
30
|
-
else if (key === 'pagedown' || (ctrl && key === 'f')) setOffset(clamp(
|
|
31
|
-
else if (ctrl && key === 'u') setOffset(clamp(
|
|
32
|
-
else if (ctrl && key === 'd') setOffset(clamp(
|
|
27
|
+
if (key === 'up' || key === 'k') setOffset(clamp(clamped - 1))
|
|
28
|
+
else if (key === 'down' || key === 'j') setOffset(clamp(clamped + 1))
|
|
29
|
+
else if (key === 'pageup' || (ctrl && key === 'b')) setOffset(clamp(clamped - pageH))
|
|
30
|
+
else if (key === 'pagedown' || (ctrl && key === 'f')) setOffset(clamp(clamped + pageH))
|
|
31
|
+
else if (ctrl && key === 'u') setOffset(clamp(clamped - half))
|
|
32
|
+
else if (ctrl && key === 'd') setOffset(clamp(clamped + half))
|
|
33
33
|
else if (key === 'home' || key === 'g') setOffset(0)
|
|
34
34
|
else if (key === 'end' || key === 'G') setOffset(maxOffset)
|
|
35
35
|
})
|
|
36
36
|
|
|
37
37
|
useMouse((event) => {
|
|
38
|
-
|
|
38
|
+
// wheel scrolls by position, not keyboard focus - whatever is under the cursor
|
|
39
39
|
if (event.action !== 'scroll') return
|
|
40
40
|
if (contentH <= visibleH) return
|
|
41
41
|
const { x, y } = event
|
|
42
42
|
if (x < layout.x || x >= layout.x + layout.width || y < layout.y || y >= layout.y + layout.height) return
|
|
43
|
-
if (event.direction
|
|
44
|
-
|
|
43
|
+
if (event.direction !== 'up' && event.direction !== 'down') return
|
|
44
|
+
if (event.direction === 'up') setOffset(clamp(clamped - 3))
|
|
45
|
+
else setOffset(clamp(clamped + 3))
|
|
45
46
|
event.stopPropagation()
|
|
46
47
|
})
|
|
47
48
|
|
|
@@ -87,7 +88,7 @@ export function ScrollBox({ children, focused = true, scrollOffset: offsetProp,
|
|
|
87
88
|
barChildren.push(
|
|
88
89
|
jsx('text', {
|
|
89
90
|
key: i,
|
|
90
|
-
style: { color: isThumb ? (focused ? accent :
|
|
91
|
+
style: { color: isThumb ? (focused ? accent : muted) : muted, dim: !isThumb },
|
|
91
92
|
children: isThumb ? thumbChar : trackChar,
|
|
92
93
|
})
|
|
93
94
|
)
|