@trendr/core 0.2.11 → 0.4.1

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/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 = wordWrap(text, layout.width)
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
- writeText(buf, clipped.x, rowY, lines[i].slice(clipped.x - layout.x), style.color, style.bg, attrs, clipped.width)
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
- writeText(buf, clipped.x, layout.y, line.slice(clipped.x - layout.x), style.color, style.bg, attrs, clipped.width)
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
- function shallowPropsEqual(a, b) {
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 (!a || !b) return false
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 === 'children') continue
506
- if (a[k] !== b[k]) return false
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 (!shallowPropsEqual(instance._lastProps, newProps)) return false
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 counterKey = `${scope}/${fn.name}`
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}/${fn.name}:key:${element.key}` : `${scope}/${fn.name}:${count}`
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
- startHookTracking(instance)
581
- startRenderTracking()
582
- const result = fn(element.props ?? {})
583
- const signals = stopRenderTracking()
584
- endHookTracking()
585
- snapshotSignals(instance, signals)
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 input = createInputHandler(inp)
654
- const ctx = { stream: out, input, stdin: inp, theme: { ...DEFAULT_THEME, ...theme } }
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
- const overlayRect = (backdrop || fullscreen)
839
- ? { x: 0, y: 0, width, height }
840
- : (() => {
841
- const anchor = owner.node?._layout ?? owner.layout ?? { x: 0, y: 0, width: 0, height: 0 }
842
- return { x: anchor.x, y: anchor.y + 1, width: width - anchor.x, height: height - anchor.y - 1 }
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
- out.write(output)
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: () => (inline ? lastInlineBuf : prev) }
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
- running = true
22
- lastFrame = now
23
- onFrame()
24
- running = false
38
+ runFrame(now)
25
39
  }
26
40
 
27
41
  function requestFrame() {
28
- if (queued || running) return
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
- running = true
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 }