@trendr/core 0.2.10 → 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 +7 -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 +315 -58
- 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,13 +759,23 @@ 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
|
-
// inline mode state: how many scrollback items have been committed,
|
|
664
|
-
//
|
|
764
|
+
// inline mode state: how many scrollback items have been committed, the
|
|
765
|
+
// visible width of each line the live region last emitted (so a later resize
|
|
766
|
+
// can compute how many physical rows they reflowed into), and that emit's
|
|
767
|
+
// text for skipping unchanged frames
|
|
665
768
|
let flushedCount = 0
|
|
666
|
-
let
|
|
769
|
+
let prevLineLens = []
|
|
667
770
|
let prevLiveText = null
|
|
668
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
|
+
|
|
669
779
|
// mirror per-instance layout (incl. scroll contentHeight/childHeights) back
|
|
670
780
|
// onto each instance so useLayout() consumers like List/Menu can window.
|
|
671
781
|
// returns whether anything changed, so the caller can re-resolve once and let
|
|
@@ -699,6 +809,76 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
699
809
|
const element = { type: rootComponent, props: {}, key: null }
|
|
700
810
|
let tree = resolveForFrame(element, null, instances, counters, visited, '')
|
|
701
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
|
+
|
|
702
882
|
const sb = findScrollback(tree)
|
|
703
883
|
const items = sb?.props?.items ?? []
|
|
704
884
|
const renderItem = sb?.props?.render
|
|
@@ -743,16 +923,25 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
743
923
|
const liveText = liveLines.join('\r\n')
|
|
744
924
|
if (!committed && liveText === prevLiveText) return
|
|
745
925
|
|
|
926
|
+
// the cursor rests at the end of the last live line between frames. erase
|
|
927
|
+
// the previous live region before repainting: since it was drawn, a resize
|
|
928
|
+
// may have reflowed each of its lines into ceil(visibleWidth / width) rows,
|
|
929
|
+
// so step back over that real physical height rather than the logical line
|
|
930
|
+
// count - otherwise a shrink leaves the extra wrapped rows stranded as
|
|
931
|
+
// ghost bars. history above is left to the terminal's own reflow
|
|
932
|
+
let phys = 0
|
|
933
|
+
for (const len of prevLineLens) phys += Math.max(1, Math.ceil(len / width))
|
|
934
|
+
|
|
746
935
|
let out_ = ''
|
|
747
|
-
if (
|
|
936
|
+
if (phys > 0) {
|
|
748
937
|
out_ += '\r'
|
|
749
|
-
if (
|
|
938
|
+
if (phys > 1) out_ += ansi.moveUp(phys - 1)
|
|
750
939
|
out_ += ansi.clearDown
|
|
751
940
|
}
|
|
752
941
|
out_ += committed + liveText
|
|
753
942
|
|
|
754
943
|
out.write(ansi.hideCursor + out_)
|
|
755
|
-
|
|
944
|
+
prevLineLens = liveLines.map(l => measureText(l))
|
|
756
945
|
prevLiveText = liveText
|
|
757
946
|
}
|
|
758
947
|
|
|
@@ -815,24 +1004,38 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
815
1004
|
paintTree(tree2, curr, null, null, null)
|
|
816
1005
|
} else {
|
|
817
1006
|
propagateDirty(tree)
|
|
818
|
-
paintTree(tree, curr, null, null, (forceFullPaint || prevHadOverlays) ? null : prev)
|
|
819
|
-
forceFullPaint = false
|
|
1007
|
+
paintTree(tree, curr, null, null, (forceFullPaint || prevHadOverlays || prevHadSelection) ? null : prev)
|
|
820
1008
|
}
|
|
1009
|
+
forceFullPaint = false
|
|
821
1010
|
|
|
822
1011
|
const hasOverlays = overlays.length > 0
|
|
823
1012
|
|
|
824
1013
|
for (const { element: overlayEl, owner, backdrop, fullscreen } of overlays) {
|
|
825
1014
|
if (backdrop) dimBuffer(curr)
|
|
826
1015
|
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
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
|
+
}
|
|
835
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
|
+
}
|
|
836
1039
|
computeLayout(overlayTree, overlayRect)
|
|
837
1040
|
updateOverlayLayouts(overlayTree)
|
|
838
1041
|
clearOverlayRect(overlayTree, curr)
|
|
@@ -841,6 +1044,13 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
841
1044
|
}
|
|
842
1045
|
|
|
843
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
|
|
844
1054
|
|
|
845
1055
|
for (const [key, inst] of instances) {
|
|
846
1056
|
if (!visited.has(key)) {
|
|
@@ -854,7 +1064,9 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
854
1064
|
const { output, changed } = diff(prev, curr)
|
|
855
1065
|
if (changed > 0) {
|
|
856
1066
|
out.write(ansi.hideCursor)
|
|
857
|
-
|
|
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))
|
|
858
1070
|
}
|
|
859
1071
|
|
|
860
1072
|
const now = performance.now()
|
|
@@ -882,11 +1094,25 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
882
1094
|
// no alt screen, no clear, no mouse capture (so the terminal handles scroll
|
|
883
1095
|
// and text selection itself)
|
|
884
1096
|
if (inline) {
|
|
885
|
-
out.write(ansi.hideCursor + (title ? ansi.setTitle(title) : ''))
|
|
1097
|
+
out.write(ansi.hideCursor + enableBracketedPaste + (title ? ansi.setTitle(title) : ''))
|
|
886
1098
|
} else {
|
|
887
|
-
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) : ''))
|
|
888
1100
|
}
|
|
889
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
|
+
})
|
|
890
1116
|
|
|
891
1117
|
if (inline) inlineFrame()
|
|
892
1118
|
else frame()
|
|
@@ -896,11 +1122,24 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
896
1122
|
width = out.columns ?? 80
|
|
897
1123
|
height = out.rows ?? 24
|
|
898
1124
|
if (inline) {
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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
|
+
}
|
|
1135
|
+
// a resize reflows committed scrollback unpredictably, and a relative
|
|
1136
|
+
// erase of the live region cannot reliably track it across terminals.
|
|
1137
|
+
// rebuild instead: wipe the screen and scrollback, reset the commit
|
|
1138
|
+
// cursor, and re-commit the whole transcript cleanly at the new width
|
|
1139
|
+
out.write(ansi.clearScrollback + ansi.clearScreen + ansi.moveTo(1, 1))
|
|
1140
|
+
flushedCount = 0
|
|
1141
|
+
prevLineLens = []
|
|
902
1142
|
prevLiveText = null
|
|
903
|
-
out.write('\r\n')
|
|
904
1143
|
scheduler.forceFrame()
|
|
905
1144
|
return
|
|
906
1145
|
}
|
|
@@ -911,14 +1150,6 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
911
1150
|
}
|
|
912
1151
|
out.on('resize', onResize)
|
|
913
1152
|
|
|
914
|
-
input.onKey((event) => {
|
|
915
|
-
if (event.key === 'c' && event.ctrl) {
|
|
916
|
-
unmount()
|
|
917
|
-
if (onExitCb) onExitCb()
|
|
918
|
-
else process.exit(0)
|
|
919
|
-
}
|
|
920
|
-
})
|
|
921
|
-
|
|
922
1153
|
let unmounted = false
|
|
923
1154
|
|
|
924
1155
|
function unmount() {
|
|
@@ -929,6 +1160,8 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
929
1160
|
input.detach()
|
|
930
1161
|
out.off('resize', onResize)
|
|
931
1162
|
process.off('exit', onExit)
|
|
1163
|
+
process.off('SIGTERM', onSigterm)
|
|
1164
|
+
process.off('SIGHUP', onSighup)
|
|
932
1165
|
|
|
933
1166
|
for (const inst of instances.values()) {
|
|
934
1167
|
disposeScope(inst.scope)
|
|
@@ -936,9 +1169,9 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
936
1169
|
instances.clear()
|
|
937
1170
|
|
|
938
1171
|
if (inline) {
|
|
939
|
-
out.write(ansi.sgrReset + ansi.showCursor + '\r\n')
|
|
1172
|
+
out.write((overlayActive ? ansi.exitAltScreen : '') + ansi.sgrReset + disableBracketedPaste + ansi.showCursor + '\r\n')
|
|
940
1173
|
} else {
|
|
941
|
-
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'))
|
|
942
1175
|
}
|
|
943
1176
|
if (inp.isTTY && inp.setRawMode) inp.setRawMode(false)
|
|
944
1177
|
activeContext = null
|
|
@@ -949,7 +1182,25 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
949
1182
|
unmount()
|
|
950
1183
|
}
|
|
951
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
|
+
|
|
952
1201
|
process.on('exit', onExit)
|
|
1202
|
+
process.on('SIGTERM', onSigterm)
|
|
1203
|
+
process.on('SIGHUP', onSighup)
|
|
953
1204
|
|
|
954
1205
|
function repaint() {
|
|
955
1206
|
if (inline) {
|
|
@@ -964,7 +1215,13 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
964
1215
|
scheduler.forceFrame()
|
|
965
1216
|
}
|
|
966
1217
|
|
|
1218
|
+
function setTheme(patch) {
|
|
1219
|
+
Object.assign(ctx.theme, patch)
|
|
1220
|
+
scheduler.forceFrame()
|
|
1221
|
+
}
|
|
1222
|
+
|
|
967
1223
|
ctx.repaint = repaint
|
|
1224
|
+
ctx.getPaintBuffer = () => (inline ? lastInlineBuf : prev)
|
|
968
1225
|
|
|
969
|
-
return { unmount, repaint, getBuffer:
|
|
1226
|
+
return { unmount, repaint, setTheme, getBuffer: ctx.getPaintBuffer }
|
|
970
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 }
|