@trendr/core 0.2.8 → 0.2.10
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 +2 -0
- package/package.json +2 -2
- package/src/ansi.js +16 -4
- package/src/diff.js +15 -4
- package/src/focus.js +7 -22
- package/src/input.js +36 -14
- package/src/layout.js +4 -0
- package/src/menu.js +87 -0
- package/src/renderer.js +165 -7
- package/src/scrollback.js +9 -0
- package/src/serialize.js +46 -0
- package/src/text-area.js +3 -3
package/index.js
CHANGED
|
@@ -21,12 +21,14 @@ export { ScrollBox } from './src/scroll-box.js'
|
|
|
21
21
|
export { SplitPane } from './src/split-pane.js'
|
|
22
22
|
export { MillerNav } from './src/miller-nav.js'
|
|
23
23
|
export { MenuBar } from './src/menubar.js'
|
|
24
|
+
export { Menu } from './src/menu.js'
|
|
24
25
|
export { Task } from './src/task.js'
|
|
25
26
|
export { Shimmer } from './src/shimmer.js'
|
|
26
27
|
export { useFocus, useFocusTrap } from './src/focus.js'
|
|
27
28
|
export { useHotkey } from './src/hotkey.js'
|
|
28
29
|
export { useToast } from './src/toast.js'
|
|
29
30
|
export { Fragment } from './src/element.js'
|
|
31
|
+
export { Scrollback } from './src/scrollback.js'
|
|
30
32
|
export { altScreen, exitAltScreen, showCursor, hideCursor, clearScreen, clearLine } from './src/ansi.js'
|
|
31
33
|
export { animated, useAnimated, spring, ease, decay } from './src/animation.js'
|
|
32
34
|
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.
|
|
3
|
+
"version": "0.2.10",
|
|
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
|
-
"
|
|
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.
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
const
|
|
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
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
const
|
|
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
|
|
1
|
+
import { createSignalRaw } from './signal.js'
|
|
2
2
|
import { registerHook } from './renderer.js'
|
|
3
3
|
import { useInput } from './hooks.js'
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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/menu.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { jsx } from '../jsx-runtime.js'
|
|
2
|
+
import { createSignal } from './signal.js'
|
|
3
|
+
import { useInput, useTheme } from './hooks.js'
|
|
4
|
+
import { List } from './list.js'
|
|
5
|
+
|
|
6
|
+
// a windowed single-select menu. shows at most maxVisible rows and scrolls
|
|
7
|
+
// with scrolloff as the active item moves. it carries no text input of its
|
|
8
|
+
// own, so it pairs with an external composer: render it after that input and,
|
|
9
|
+
// while focused, it intercepts up/down/enter before the input sees them
|
|
10
|
+
export function Menu({
|
|
11
|
+
items,
|
|
12
|
+
selected: selectedProp,
|
|
13
|
+
onSelect,
|
|
14
|
+
onSubmit,
|
|
15
|
+
onCancel,
|
|
16
|
+
focused = true,
|
|
17
|
+
maxVisible = 5,
|
|
18
|
+
scrolloff = 2,
|
|
19
|
+
itemHeight = 1,
|
|
20
|
+
gap = 0,
|
|
21
|
+
renderItem,
|
|
22
|
+
arrow = '›',
|
|
23
|
+
}) {
|
|
24
|
+
const { accent = 'cyan' } = useTheme()
|
|
25
|
+
const [internal, setInternal] = createSignal(0)
|
|
26
|
+
|
|
27
|
+
const len = items.length
|
|
28
|
+
const raw = selectedProp ?? internal()
|
|
29
|
+
const selected = Math.min(Math.max(0, raw), Math.max(0, len - 1))
|
|
30
|
+
const setSelected = onSelect ?? setInternal
|
|
31
|
+
|
|
32
|
+
if (selected !== raw && len > 0) setSelected(selected)
|
|
33
|
+
|
|
34
|
+
useInput((event) => {
|
|
35
|
+
if (!focused || len === 0) return
|
|
36
|
+
const { key, ctrl, shift, meta } = event
|
|
37
|
+
|
|
38
|
+
if (key === 'up' || (ctrl && key === 'p')) {
|
|
39
|
+
setSelected(Math.max(0, selected - 1))
|
|
40
|
+
event.stopPropagation()
|
|
41
|
+
} else if (key === 'down' || (ctrl && key === 'n')) {
|
|
42
|
+
setSelected(Math.min(len - 1, selected + 1))
|
|
43
|
+
event.stopPropagation()
|
|
44
|
+
} else if (key === 'return' && !shift && !meta) {
|
|
45
|
+
if (onSubmit) onSubmit(items[selected], selected)
|
|
46
|
+
event.stopPropagation()
|
|
47
|
+
} else if (key === 'escape') {
|
|
48
|
+
if (onCancel) {
|
|
49
|
+
onCancel()
|
|
50
|
+
event.stopPropagation()
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
if (len === 0) return jsx('box', { style: { height: 0 } })
|
|
56
|
+
|
|
57
|
+
const visible = Math.min(maxVisible, len)
|
|
58
|
+
const height = visible * itemHeight + Math.max(0, visible - 1) * gap
|
|
59
|
+
|
|
60
|
+
const defaultRender = (item, { active }) => {
|
|
61
|
+
const label = typeof item === 'string' ? item : (item.label ?? item.name ?? String(item))
|
|
62
|
+
return jsx('box', {
|
|
63
|
+
style: { flexDirection: 'row' },
|
|
64
|
+
children: [
|
|
65
|
+
jsx('text', { style: { color: active ? accent : null }, children: active ? `${arrow} ` : ' ' }),
|
|
66
|
+
jsx('text', { style: { color: active ? accent : 'gray' }, children: label }),
|
|
67
|
+
],
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const render = renderItem ?? defaultRender
|
|
72
|
+
|
|
73
|
+
return jsx('box', {
|
|
74
|
+
style: { height, minHeight: height },
|
|
75
|
+
children: jsx(List, {
|
|
76
|
+
items,
|
|
77
|
+
selected,
|
|
78
|
+
onSelect: setSelected,
|
|
79
|
+
focused: false,
|
|
80
|
+
interactive: false,
|
|
81
|
+
itemHeight,
|
|
82
|
+
gap,
|
|
83
|
+
scrolloff,
|
|
84
|
+
renderItem: (item, ctx) => render(item, { active: ctx.selected, index: ctx.index }),
|
|
85
|
+
}),
|
|
86
|
+
})
|
|
87
|
+
}
|
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 {
|
|
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
|
-
|
|
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,104 @@ 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
|
+
// mirror per-instance layout (incl. scroll contentHeight/childHeights) back
|
|
670
|
+
// onto each instance so useLayout() consumers like List/Menu can window.
|
|
671
|
+
// returns whether anything changed, so the caller can re-resolve once and let
|
|
672
|
+
// those components observe the freshly measured values
|
|
673
|
+
function syncInstanceLayouts() {
|
|
674
|
+
let changed = false
|
|
675
|
+
for (const inst of instances.values()) {
|
|
676
|
+
const rect = inst.node?._availableRect ?? inst.node?._layout
|
|
677
|
+
if (!rect) continue
|
|
678
|
+
const ch = findScrollContentHeight(inst.node)
|
|
679
|
+
if (!inst.layout) inst.layout = { x: 0, y: 0, width: 0, height: 0 }
|
|
680
|
+
const p = inst.layout
|
|
681
|
+
if (p.width !== rect.width || p.height !== rect.height || p.contentHeight !== ch) changed = true
|
|
682
|
+
p.x = rect.x
|
|
683
|
+
p.y = rect.y
|
|
684
|
+
p.width = rect.width
|
|
685
|
+
p.height = rect.height
|
|
686
|
+
p.contentHeight = ch
|
|
687
|
+
p.childHeights = findScrollChildHeights(inst.node)
|
|
688
|
+
}
|
|
689
|
+
return changed
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function inlineFrame() {
|
|
693
|
+
const prevCtx = activeContext
|
|
694
|
+
activeContext = ctx
|
|
695
|
+
overlays = []
|
|
696
|
+
|
|
697
|
+
const counters = new Map()
|
|
698
|
+
const visited = new Set()
|
|
699
|
+
const element = { type: rootComponent, props: {}, key: null }
|
|
700
|
+
let tree = resolveForFrame(element, null, instances, counters, visited, '')
|
|
701
|
+
|
|
702
|
+
const sb = findScrollback(tree)
|
|
703
|
+
const items = sb?.props?.items ?? []
|
|
704
|
+
const renderItem = sb?.props?.render
|
|
705
|
+
|
|
706
|
+
let committed = ''
|
|
707
|
+
if (renderItem && items.length > flushedCount) {
|
|
708
|
+
for (let i = flushedCount; i < items.length; i++) {
|
|
709
|
+
const lines = renderElementToLines(renderItem(items[i], i), width)
|
|
710
|
+
for (const ln of lines) committed += ln + '\r\n'
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
// items can only grow while mounted; a shrink means the app reset its log,
|
|
714
|
+
// which we can't un-print, so just resync the counter
|
|
715
|
+
flushedCount = items.length
|
|
716
|
+
|
|
717
|
+
let liveHeight = Math.min(height, Math.max(1, intrinsicHeight(tree, width, height)))
|
|
718
|
+
computeLayout(tree, { x: 0, y: 0, width, height: liveHeight })
|
|
719
|
+
|
|
720
|
+
if (syncInstanceLayouts()) {
|
|
721
|
+
counters.clear()
|
|
722
|
+
visited.clear()
|
|
723
|
+
tree = resolveForFrame(element, null, instances, counters, visited, '')
|
|
724
|
+
liveHeight = Math.min(height, Math.max(1, intrinsicHeight(tree, width, height)))
|
|
725
|
+
computeLayout(tree, { x: 0, y: 0, width, height: liveHeight })
|
|
726
|
+
syncInstanceLayouts()
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
for (const [key, inst] of instances) {
|
|
730
|
+
if (!visited.has(key)) {
|
|
731
|
+
disposeScope(inst.scope)
|
|
732
|
+
instances.delete(key)
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const liveBuf = createBuffer(width, liveHeight)
|
|
737
|
+
paintTree(tree, liveBuf, null, null, null)
|
|
738
|
+
const liveLines = bufferToLines(liveBuf)
|
|
739
|
+
lastInlineBuf = liveBuf
|
|
740
|
+
|
|
741
|
+
activeContext = prevCtx
|
|
742
|
+
|
|
743
|
+
const liveText = liveLines.join('\r\n')
|
|
744
|
+
if (!committed && liveText === prevLiveText) return
|
|
745
|
+
|
|
746
|
+
let out_ = ''
|
|
747
|
+
if (prevLiveLines > 0) {
|
|
748
|
+
out_ += '\r'
|
|
749
|
+
if (prevLiveLines > 1) out_ += ansi.moveUp(prevLiveLines - 1)
|
|
750
|
+
out_ += ansi.clearDown
|
|
751
|
+
}
|
|
752
|
+
out_ += committed + liveText
|
|
753
|
+
|
|
754
|
+
out.write(ansi.hideCursor + out_)
|
|
755
|
+
prevLiveLines = liveLines.length
|
|
756
|
+
prevLiveText = liveText
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
let lastInlineBuf = null
|
|
760
|
+
|
|
629
761
|
function frame() {
|
|
630
762
|
const prevCtx = activeContext
|
|
631
763
|
activeContext = ctx
|
|
@@ -741,20 +873,37 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
741
873
|
|
|
742
874
|
const scheduler = createScheduler({
|
|
743
875
|
fps: 60,
|
|
744
|
-
onFrame: frame,
|
|
876
|
+
onFrame: inline ? inlineFrame : frame,
|
|
745
877
|
})
|
|
746
878
|
|
|
747
879
|
setSchedulerHook(scheduler.requestFrame)
|
|
748
880
|
|
|
749
|
-
|
|
881
|
+
// inline mode stays on the main screen buffer so native scrollback survives:
|
|
882
|
+
// no alt screen, no clear, no mouse capture (so the terminal handles scroll
|
|
883
|
+
// and text selection itself)
|
|
884
|
+
if (inline) {
|
|
885
|
+
out.write(ansi.hideCursor + (title ? ansi.setTitle(title) : ''))
|
|
886
|
+
} else {
|
|
887
|
+
out.write((altScreen ? ansi.altScreen : '') + ansi.hideCursor + ansi.clearScreen + ansi.enableMouse + (title ? ansi.setTitle(title) : ''))
|
|
888
|
+
}
|
|
750
889
|
if (inp.isTTY && inp.setRawMode) inp.setRawMode(true)
|
|
751
890
|
|
|
752
|
-
|
|
891
|
+
if (inline) inlineFrame()
|
|
892
|
+
else frame()
|
|
753
893
|
scheduler.requestFrame()
|
|
754
894
|
|
|
755
895
|
const onResize = () => {
|
|
756
896
|
width = out.columns ?? 80
|
|
757
897
|
height = out.rows ?? 24
|
|
898
|
+
if (inline) {
|
|
899
|
+
// the terminal reflows committed history on its own; drop our live-region
|
|
900
|
+
// geometry and repaint it fresh below wherever the cursor landed
|
|
901
|
+
prevLiveLines = 0
|
|
902
|
+
prevLiveText = null
|
|
903
|
+
out.write('\r\n')
|
|
904
|
+
scheduler.forceFrame()
|
|
905
|
+
return
|
|
906
|
+
}
|
|
758
907
|
prev = createBuffer(width, height)
|
|
759
908
|
curr = createBuffer(width, height)
|
|
760
909
|
out.write(ansi.clearScreen)
|
|
@@ -786,7 +935,11 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
786
935
|
}
|
|
787
936
|
instances.clear()
|
|
788
937
|
|
|
789
|
-
|
|
938
|
+
if (inline) {
|
|
939
|
+
out.write(ansi.sgrReset + ansi.showCursor + '\r\n')
|
|
940
|
+
} else {
|
|
941
|
+
out.write(ansi.sgrReset + ansi.disableMouse + ansi.showCursor + (altScreen ? ansi.exitAltScreen : ansi.moveTo(height, 1) + '\n'))
|
|
942
|
+
}
|
|
790
943
|
if (inp.isTTY && inp.setRawMode) inp.setRawMode(false)
|
|
791
944
|
activeContext = null
|
|
792
945
|
setSchedulerHook(null)
|
|
@@ -799,6 +952,11 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
799
952
|
process.on('exit', onExit)
|
|
800
953
|
|
|
801
954
|
function repaint() {
|
|
955
|
+
if (inline) {
|
|
956
|
+
prevLiveText = null
|
|
957
|
+
scheduler.forceFrame()
|
|
958
|
+
return
|
|
959
|
+
}
|
|
802
960
|
prev = createBuffer(width, height)
|
|
803
961
|
curr = createBuffer(width, height)
|
|
804
962
|
forceFullPaint = true
|
|
@@ -808,5 +966,5 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
808
966
|
|
|
809
967
|
ctx.repaint = repaint
|
|
810
968
|
|
|
811
|
-
return { unmount, repaint, getBuffer: () => prev }
|
|
969
|
+
return { unmount, repaint, getBuffer: () => (inline ? lastInlineBuf : prev) }
|
|
812
970
|
}
|
|
@@ -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
|
package/src/serialize.js
ADDED
|
@@ -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
|
@@ -119,7 +119,7 @@ export function TextArea({ onSubmit, onCancel, onChange, onKeyDown, placeholder,
|
|
|
119
119
|
|
|
120
120
|
resetBlink()
|
|
121
121
|
|
|
122
|
-
const { key, raw, ctrl, meta } = event
|
|
122
|
+
const { key, raw, ctrl, meta, shift } = event
|
|
123
123
|
const v = value()
|
|
124
124
|
const c = cursor()
|
|
125
125
|
|
|
@@ -131,10 +131,10 @@ export function TextArea({ onSubmit, onCancel, onChange, onKeyDown, placeholder,
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
const isSubmitKey = submitOnEnter
|
|
134
|
-
? (key === 'return' && !meta)
|
|
134
|
+
? (key === 'return' && !meta && !shift)
|
|
135
135
|
: (meta && key === '\r')
|
|
136
136
|
const isNewlineKey = submitOnEnter
|
|
137
|
-
? (meta && key === '\r')
|
|
137
|
+
? ((shift && key === 'return') || (meta && key === '\r'))
|
|
138
138
|
: (key === 'return')
|
|
139
139
|
|
|
140
140
|
if (isSubmitKey) {
|