@trendr/core 0.2.7 → 0.2.9

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/index.js CHANGED
@@ -27,6 +27,7 @@ export { useFocus, useFocusTrap } from './src/focus.js'
27
27
  export { useHotkey } from './src/hotkey.js'
28
28
  export { useToast } from './src/toast.js'
29
29
  export { Fragment } from './src/element.js'
30
+ export { Scrollback } from './src/scrollback.js'
30
31
  export { altScreen, exitAltScreen, showCursor, hideCursor, clearScreen, clearLine } from './src/ansi.js'
31
32
  export { animated, useAnimated, spring, ease, decay } from './src/animation.js'
32
33
  export { linear, easeInQuad, easeOutQuad, easeInOutQuad, easeInCubic, easeOutCubic, easeInOutCubic, easeOutElastic, easeOutBounce } from './src/animation.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trendr/core",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "direct-mode TUI renderer with JSX, signals, and per-cell diffing",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -47,7 +47,7 @@
47
47
  "pick-list": "node esbuild.config.js && node dist/pick-list.js",
48
48
  "table-demo": "node esbuild.config.js && node dist/table-demo.js",
49
49
  "menubar": "node esbuild.config.js && node dist/menubar.js",
50
- "scrollback-demo": "node esbuild.config.js && node dist/scrollback-demo.js",
50
+ "inline-chat": "node esbuild.config.js && node dist/inline-chat.js",
51
51
  "demo": "node esbuild.config.js && node demo/server.js",
52
52
  "demo:build": "node esbuild.config.js"
53
53
  },
package/src/ansi.js CHANGED
@@ -10,6 +10,7 @@ export const hideCursor = `${ESC}?25l`
10
10
  export const showCursor = `${ESC}?25h`
11
11
  export const clearScreen = `${ESC}2J`
12
12
  export const clearLine = `${ESC}2K`
13
+ export const clearDown = `${ESC}0J`
13
14
  export const altScreen = `${ESC}?1049h`
14
15
  export const exitAltScreen = `${ESC}?1049l`
15
16
  export const sgrReset = `${ESC}0m`
@@ -43,14 +44,25 @@ const NAMED_COLORS = {
43
44
  brightBlue: 12, brightMagenta: 13, brightCyan: 14, brightWhite: 15,
44
45
  }
45
46
 
47
+ function expandHex(color) {
48
+ if (color.length === 7) return color
49
+ if (color.length === 4) {
50
+ const r = color[1], g = color[2], b = color[3]
51
+ return `#${r}${r}${g}${g}${b}${b}`
52
+ }
53
+ return null
54
+ }
55
+
46
56
  function parseColor(color, offset) {
47
57
  if (color == null) return null
48
58
  if (typeof color === 'number') return `${offset};5;${color}`
49
59
  if (color in NAMED_COLORS) return `${offset};5;${NAMED_COLORS[color]}`
50
- if (color.startsWith('#') && color.length === 7) {
51
- const r = parseInt(color.slice(1, 3), 16)
52
- const g = parseInt(color.slice(3, 5), 16)
53
- const b = parseInt(color.slice(5, 7), 16)
60
+ if (color.charCodeAt(0) === 35) {
61
+ const hex = expandHex(color)
62
+ if (!hex) return null
63
+ const r = parseInt(hex.slice(1, 3), 16)
64
+ const g = parseInt(hex.slice(3, 5), 16)
65
+ const b = parseInt(hex.slice(5, 7), 16)
54
66
  return `${offset};2;${r};${g};${b}`
55
67
  }
56
68
  return null
package/src/diff.js CHANGED
@@ -56,6 +56,15 @@ function writeSgr(fg, bg, attrs) {
56
56
  buf[pos++] = 0x6d
57
57
  }
58
58
 
59
+ function expandHex(color) {
60
+ if (color.length === 7) return color
61
+ if (color.length === 4) {
62
+ const r = color[1], g = color[2], b = color[3]
63
+ return `#${r}${r}${g}${g}${b}${b}`
64
+ }
65
+ return null
66
+ }
67
+
59
68
  function writeColor(color, offset) {
60
69
  if (typeof color === 'number' || color in NAMED_COLORS) {
61
70
  const idx = typeof color === 'number' ? color : NAMED_COLORS[color]
@@ -65,10 +74,12 @@ function writeColor(color, offset) {
65
74
  buf[pos++] = 53 // '5'
66
75
  buf[pos++] = 0x3b
67
76
  writeNum(idx)
68
- } else if (color.charCodeAt(0) === 35 && color.length === 7) {
69
- const r = parseInt(color.slice(1, 3), 16)
70
- const g = parseInt(color.slice(3, 5), 16)
71
- const b = parseInt(color.slice(5, 7), 16)
77
+ } else if (color.charCodeAt(0) === 35) {
78
+ const hex = expandHex(color)
79
+ if (!hex) return
80
+ const r = parseInt(hex.slice(1, 3), 16)
81
+ const g = parseInt(hex.slice(3, 5), 16)
82
+ const b = parseInt(hex.slice(5, 7), 16)
72
83
  buf[pos++] = 0x3b
73
84
  writeNum(offset)
74
85
  buf[pos++] = 0x3b
package/src/focus.js CHANGED
@@ -1,28 +1,14 @@
1
- import { createSignalRaw, onCleanup } from './signal.js'
1
+ import { createSignalRaw } from './signal.js'
2
2
  import { registerHook } from './renderer.js'
3
3
  import { useInput } from './hooks.js'
4
4
 
5
- let focusTrapStack = []
6
-
5
+ // while active, keeps tab/shift-tab from propagating past the trapping
6
+ // component to focus managers registered before it. handlers fire
7
+ // innermost-first, so any useFocus registered deeper in the tree handles the
8
+ // tab first; this handler then stops propagation so shallower managers don't
9
+ // also react. when traps nest, the innermost active one fires first and halts
10
+ // the rest, so it becomes the effective boundary
7
11
  export function useFocusTrap(active) {
8
- const state = registerHook(() => {
9
- const id = Symbol()
10
- onCleanup(() => {
11
- const idx = focusTrapStack.indexOf(id)
12
- if (idx >= 0) focusTrapStack.splice(idx, 1)
13
- })
14
- return { id, wasActive: false }
15
- })
16
-
17
- if (active && !state.wasActive) {
18
- focusTrapStack.push(state.id)
19
- state.wasActive = true
20
- } else if (!active && state.wasActive) {
21
- const idx = focusTrapStack.indexOf(state.id)
22
- if (idx >= 0) focusTrapStack.splice(idx, 1)
23
- state.wasActive = false
24
- }
25
-
26
12
  useInput((event) => {
27
13
  if (!active) return
28
14
  if (event.key === 'tab' || event.key === 'shift-tab') {
@@ -122,7 +108,6 @@ export function useFocus({ initial, cycle = 'tab' } = {}) {
122
108
  const cur = state.current()
123
109
 
124
110
  if (state.stack.length > 0) return
125
- if (focusTrapStack.length > 0) return
126
111
 
127
112
  if (cycle === 'tab' && (key === 'tab' || key === 'shift-tab')) {
128
113
  const idx = findTopLevel(cur)
package/src/input.js CHANGED
@@ -52,6 +52,31 @@ export function parseMouse(raw) {
52
52
  return { type: 'mouse', action, button: buttonName, x, y }
53
53
  }
54
54
 
55
+ const MODIFY_OTHER_RE = /^\x1b\[27;(\d+);(\d+)~$/
56
+ const CSI_U_RE = /^\x1b\[(\d+)(?:;(\d+))?u$/
57
+
58
+ function codeToKey(code) {
59
+ if (code === 13 || code === 10) return 'return'
60
+ if (code === 9) return 'tab'
61
+ if (code === 27) return 'escape'
62
+ if (code === 32) return 'space'
63
+ if (code === 127 || code === 8) return 'backspace'
64
+ return String.fromCodePoint(code)
65
+ }
66
+
67
+ // modifier param is 1-based with a bitmask in the low bits: shift=1, alt=2,
68
+ // ctrl=4 (so plain=1, shift=2, ctrl=5, etc)
69
+ function modifiedKey(code, mod, raw) {
70
+ const bits = Math.max(0, mod - 1)
71
+ return {
72
+ key: codeToKey(code),
73
+ ctrl: (bits & 4) !== 0,
74
+ meta: (bits & 2) !== 0,
75
+ shift: (bits & 1) !== 0,
76
+ raw,
77
+ }
78
+ }
79
+
55
80
  export function parseKey(data) {
56
81
  const raw = typeof data === 'string' ? data : data.toString()
57
82
 
@@ -59,6 +84,12 @@ export function parseKey(data) {
59
84
  return { key: SPECIAL_KEYS[raw], ctrl: false, meta: false, shift: false, raw }
60
85
  }
61
86
 
87
+ let m = MODIFY_OTHER_RE.exec(raw)
88
+ if (m) return modifiedKey(parseInt(m[2], 10), parseInt(m[1], 10), raw)
89
+
90
+ m = CSI_U_RE.exec(raw)
91
+ if (m) return modifiedKey(parseInt(m[1], 10), m[2] ? parseInt(m[2], 10) : 1, raw)
92
+
62
93
  if (raw.length === 1) {
63
94
  const code = raw.charCodeAt(0)
64
95
 
@@ -90,21 +121,12 @@ export function splitKeys(data) {
90
121
  while (i < raw.length) {
91
122
  if (raw[i] === '\x1b') {
92
123
  if (i + 1 < raw.length && raw[i + 1] === '[') {
124
+ // generic csi: optional private marker, any number of ;-separated
125
+ // numeric params, then a final byte. covers arrows, mouse (\x1b[<..M),
126
+ // csi-u (\x1b[13;2u) and modifyOtherKeys (\x1b[27;2;13~)
93
127
  let j = i + 2
94
- // sgr mouse: \x1b[<Cb;Cx;CyM or m
95
- if (j < raw.length && raw[j] === '<') {
96
- j++
97
- while (j < raw.length && ((raw[j] >= '0' && raw[j] <= '9') || raw[j] === ';')) j++
98
- if (j < raw.length) j++
99
- keys.push(raw.slice(i, j))
100
- i = j
101
- continue
102
- }
103
- while (j < raw.length && raw[j] >= '0' && raw[j] <= '9') j++
104
- if (j < raw.length && raw[j] === ';') {
105
- j++
106
- while (j < raw.length && raw[j] >= '0' && raw[j] <= '9') j++
107
- }
128
+ if (j < raw.length && (raw[j] === '<' || raw[j] === '?')) j++
129
+ while (j < raw.length && ((raw[j] >= '0' && raw[j] <= '9') || raw[j] === ';' || raw[j] === ':')) j++
108
130
  if (j < raw.length) j++
109
131
  keys.push(raw.slice(i, j))
110
132
  i = j
package/src/layout.js CHANGED
@@ -246,6 +246,10 @@ function layoutFlex(children, ctx) {
246
246
  }
247
247
  }
248
248
 
249
+ export function intrinsicHeight(node, availW, availH) {
250
+ return measureChild(node, childStyle(node), false, availW, availH).height
251
+ }
252
+
249
253
  function measureChild(child, cs, isRow, availW, availH) {
250
254
  const leaf = getLeaf(child)
251
255
  if (!leaf) return { width: 0, height: 0 }
package/src/list.js CHANGED
@@ -132,6 +132,10 @@ export function List({ items, selected: selectedProp, onSelect, onCursorChange,
132
132
  if (itemTop - margin < scrollOffset) scrollOffset = itemTop - margin
133
133
  if (itemBottom + margin > scrollOffset + scrollViewH) scrollOffset = itemBottom + margin - scrollViewH
134
134
  scrollOffset = Math.max(0, Math.min(maxOffset, Math.round(scrollOffset)))
135
+ // hard guarantee: selected item must be visible regardless of margin overshoot
136
+ if (itemTop < scrollOffset) scrollOffset = itemTop
137
+ if (itemBottom > scrollOffset + scrollViewH) scrollOffset = itemBottom - scrollViewH
138
+ scrollOffset = Math.max(0, Math.min(maxOffset, Math.round(scrollOffset)))
135
139
  if (scrollOffset !== scrollState()) setScrollState(scrollOffset)
136
140
  }
137
141
 
package/src/renderer.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createBuffer, clearBuffer, fillRect, writeText, dimBuffer, blitRect } from './buffer.js'
2
2
  import { diff } from './diff.js'
3
- import { computeLayout, resolveBorderEdges } from './layout.js'
3
+ import { bufferToLines } from './serialize.js'
4
+ import { computeLayout, resolveBorderEdges, intrinsicHeight } from './layout.js'
4
5
  import { Fragment } from './element.js'
5
6
  import { createScheduler } from './scheduler.js'
6
7
  import { createInputHandler } from './input.js'
@@ -606,7 +607,40 @@ function resolveForFrame(element, parent, instances, counters, visited, scope) {
606
607
  return node
607
608
  }
608
609
 
609
- export function mount(rootComponent, { stream, stdin, title, theme, onExit: onExitCb, altScreen = true } = {}) {
610
+ function findScrollback(node) {
611
+ if (!node) return null
612
+ if (typeof node.type === 'function' && node.type.__scrollback) return node
613
+ if (node._resolved) {
614
+ const found = findScrollback(node._resolved)
615
+ if (found) return found
616
+ }
617
+ if (node._resolvedChildren) {
618
+ for (const child of node._resolvedChildren) {
619
+ const found = findScrollback(child)
620
+ if (found) return found
621
+ }
622
+ }
623
+ return null
624
+ }
625
+
626
+ // render a one-shot element (a committed scrollback item) to ANSI lines. it
627
+ // gets its own throwaway instance map so it never pollutes the live tree's
628
+ // cache, and scopes are disposed immediately since committed content is frozen
629
+ function renderElementToLines(element, width) {
630
+ if (element == null) return []
631
+ const instances = new Map()
632
+ const counters = new Map()
633
+ const tree = resolveForFrame(element, null, instances, counters, null, '')
634
+ const h = Math.max(1, intrinsicHeight(tree, width, 100000))
635
+ computeLayout(tree, { x: 0, y: 0, width, height: h })
636
+ const buf = createBuffer(width, h)
637
+ paintTree(tree, buf, null, null, null)
638
+ const lines = bufferToLines(buf)
639
+ for (const inst of instances.values()) disposeScope(inst.scope)
640
+ return lines
641
+ }
642
+
643
+ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onExitCb, altScreen = true, inline = false } = {}) {
610
644
  const out = stream ?? process.stdout
611
645
  const inp = stdin ?? process.stdin
612
646
 
@@ -626,6 +660,72 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
626
660
  let forceFullPaint = false
627
661
  let prevHadOverlays = false
628
662
 
663
+ // inline mode state: how many scrollback items have been committed, how many
664
+ // lines the live region occupied last emit, and that emit's text for skipping
665
+ let flushedCount = 0
666
+ let prevLiveLines = 0
667
+ let prevLiveText = null
668
+
669
+ function inlineFrame() {
670
+ const prevCtx = activeContext
671
+ activeContext = ctx
672
+ overlays = []
673
+
674
+ const counters = new Map()
675
+ const visited = new Set()
676
+ const element = { type: rootComponent, props: {}, key: null }
677
+ const tree = resolveForFrame(element, null, instances, counters, visited, '')
678
+
679
+ const sb = findScrollback(tree)
680
+ const items = sb?.props?.items ?? []
681
+ const renderItem = sb?.props?.render
682
+
683
+ let committed = ''
684
+ if (renderItem && items.length > flushedCount) {
685
+ for (let i = flushedCount; i < items.length; i++) {
686
+ const lines = renderElementToLines(renderItem(items[i], i), width)
687
+ for (const ln of lines) committed += ln + '\r\n'
688
+ }
689
+ }
690
+ // items can only grow while mounted; a shrink means the app reset its log,
691
+ // which we can't un-print, so just resync the counter
692
+ flushedCount = items.length
693
+
694
+ const liveHeight = Math.min(height, Math.max(1, intrinsicHeight(tree, width, height)))
695
+ computeLayout(tree, { x: 0, y: 0, width, height: liveHeight })
696
+
697
+ for (const [key, inst] of instances) {
698
+ if (!visited.has(key)) {
699
+ disposeScope(inst.scope)
700
+ instances.delete(key)
701
+ }
702
+ }
703
+
704
+ const liveBuf = createBuffer(width, liveHeight)
705
+ paintTree(tree, liveBuf, null, null, null)
706
+ const liveLines = bufferToLines(liveBuf)
707
+ lastInlineBuf = liveBuf
708
+
709
+ activeContext = prevCtx
710
+
711
+ const liveText = liveLines.join('\r\n')
712
+ if (!committed && liveText === prevLiveText) return
713
+
714
+ let out_ = ''
715
+ if (prevLiveLines > 0) {
716
+ out_ += '\r'
717
+ if (prevLiveLines > 1) out_ += ansi.moveUp(prevLiveLines - 1)
718
+ out_ += ansi.clearDown
719
+ }
720
+ out_ += committed + liveText
721
+
722
+ out.write(ansi.hideCursor + out_)
723
+ prevLiveLines = liveLines.length
724
+ prevLiveText = liveText
725
+ }
726
+
727
+ let lastInlineBuf = null
728
+
629
729
  function frame() {
630
730
  const prevCtx = activeContext
631
731
  activeContext = ctx
@@ -741,20 +841,37 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
741
841
 
742
842
  const scheduler = createScheduler({
743
843
  fps: 60,
744
- onFrame: frame,
844
+ onFrame: inline ? inlineFrame : frame,
745
845
  })
746
846
 
747
847
  setSchedulerHook(scheduler.requestFrame)
748
848
 
749
- out.write((altScreen ? ansi.altScreen : '') + ansi.hideCursor + ansi.clearScreen + ansi.enableMouse + (title ? ansi.setTitle(title) : ''))
849
+ // inline mode stays on the main screen buffer so native scrollback survives:
850
+ // no alt screen, no clear, no mouse capture (so the terminal handles scroll
851
+ // and text selection itself)
852
+ if (inline) {
853
+ out.write(ansi.hideCursor + (title ? ansi.setTitle(title) : ''))
854
+ } else {
855
+ out.write((altScreen ? ansi.altScreen : '') + ansi.hideCursor + ansi.clearScreen + ansi.enableMouse + (title ? ansi.setTitle(title) : ''))
856
+ }
750
857
  if (inp.isTTY && inp.setRawMode) inp.setRawMode(true)
751
858
 
752
- frame()
859
+ if (inline) inlineFrame()
860
+ else frame()
753
861
  scheduler.requestFrame()
754
862
 
755
863
  const onResize = () => {
756
864
  width = out.columns ?? 80
757
865
  height = out.rows ?? 24
866
+ if (inline) {
867
+ // the terminal reflows committed history on its own; drop our live-region
868
+ // geometry and repaint it fresh below wherever the cursor landed
869
+ prevLiveLines = 0
870
+ prevLiveText = null
871
+ out.write('\r\n')
872
+ scheduler.forceFrame()
873
+ return
874
+ }
758
875
  prev = createBuffer(width, height)
759
876
  curr = createBuffer(width, height)
760
877
  out.write(ansi.clearScreen)
@@ -786,7 +903,11 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
786
903
  }
787
904
  instances.clear()
788
905
 
789
- out.write(ansi.sgrReset + ansi.disableMouse + ansi.showCursor + (altScreen ? ansi.exitAltScreen : ansi.moveTo(height, 1) + '\n'))
906
+ if (inline) {
907
+ out.write(ansi.sgrReset + ansi.showCursor + '\r\n')
908
+ } else {
909
+ out.write(ansi.sgrReset + ansi.disableMouse + ansi.showCursor + (altScreen ? ansi.exitAltScreen : ansi.moveTo(height, 1) + '\n'))
910
+ }
790
911
  if (inp.isTTY && inp.setRawMode) inp.setRawMode(false)
791
912
  activeContext = null
792
913
  setSchedulerHook(null)
@@ -799,6 +920,11 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
799
920
  process.on('exit', onExit)
800
921
 
801
922
  function repaint() {
923
+ if (inline) {
924
+ prevLiveText = null
925
+ scheduler.forceFrame()
926
+ return
927
+ }
802
928
  prev = createBuffer(width, height)
803
929
  curr = createBuffer(width, height)
804
930
  forceFullPaint = true
@@ -808,5 +934,5 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
808
934
 
809
935
  ctx.repaint = repaint
810
936
 
811
- return { unmount, repaint, getBuffer: () => prev }
937
+ return { unmount, repaint, getBuffer: () => (inline ? lastInlineBuf : prev) }
812
938
  }
@@ -0,0 +1,9 @@
1
+ // marker component for inline mode. everything inside Scrollback is the
2
+ // committed transcript: append-only items that get printed once into native
3
+ // terminal scrollback and never touched again. the renderer reads its props
4
+ // directly during the inline frame, so the component itself renders nothing
5
+ export function Scrollback() {
6
+ return null
7
+ }
8
+
9
+ Scrollback.__scrollback = true
@@ -0,0 +1,46 @@
1
+ import { sgr } from './ansi.js'
2
+
3
+ const RESET = '\x1b[0m'
4
+
5
+ function isBlank(cell) {
6
+ return cell.ch === ' ' && cell.fg == null && cell.bg == null && cell.attrs === 0
7
+ }
8
+
9
+ // turn a cell buffer into one ANSI string per row, trimming trailing blank
10
+ // cells so short lines don't carry filler. used by inline mode to emit both
11
+ // committed scrollback items and the live region
12
+ export function bufferToLines(buf) {
13
+ const { width, height, cells } = buf
14
+ const lines = new Array(height)
15
+
16
+ for (let y = 0; y < height; y++) {
17
+ const row = y * width
18
+
19
+ let last = -1
20
+ for (let x = 0; x < width; x++) {
21
+ const c = cells[row + x]
22
+ if (c.ch !== '' && !isBlank(c)) last = x
23
+ }
24
+
25
+ let line = ''
26
+ let curFg, curBg, curAttrs
27
+ let open = false
28
+
29
+ for (let x = 0; x <= last; x++) {
30
+ const c = cells[row + x]
31
+ if (c.ch === '') continue
32
+ if (!open || c.fg !== curFg || c.bg !== curBg || c.attrs !== curAttrs) {
33
+ line += sgr(c.fg, c.bg, c.attrs)
34
+ curFg = c.fg
35
+ curBg = c.bg
36
+ curAttrs = c.attrs
37
+ open = true
38
+ }
39
+ line += c.ch
40
+ }
41
+
42
+ lines[y] = open ? line + RESET : line
43
+ }
44
+
45
+ return lines
46
+ }
package/src/text-area.js CHANGED
@@ -109,14 +109,17 @@ export function TextArea({ onSubmit, onCancel, onChange, onKeyDown, placeholder,
109
109
  useInput((event) => {
110
110
  if (!focused) return
111
111
 
112
- if (onKeyDown && onKeyDown(event)) {
113
- event.stopPropagation()
114
- return
112
+ if (onKeyDown) {
113
+ event.value = value()
114
+ if (onKeyDown(event)) {
115
+ event.stopPropagation()
116
+ return
117
+ }
115
118
  }
116
119
 
117
120
  resetBlink()
118
121
 
119
- const { key, raw, ctrl, meta } = event
122
+ const { key, raw, ctrl, meta, shift } = event
120
123
  const v = value()
121
124
  const c = cursor()
122
125
 
@@ -128,10 +131,10 @@ export function TextArea({ onSubmit, onCancel, onChange, onKeyDown, placeholder,
128
131
  }
129
132
 
130
133
  const isSubmitKey = submitOnEnter
131
- ? (key === 'return' && !meta)
134
+ ? (key === 'return' && !meta && !shift)
132
135
  : (meta && key === '\r')
133
136
  const isNewlineKey = submitOnEnter
134
- ? (meta && key === '\r')
137
+ ? ((shift && key === 'return') || (meta && key === '\r'))
135
138
  : (key === 'return')
136
139
 
137
140
  if (isSubmitKey) {