@trendr/core 0.2.2 → 0.2.4
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 +4 -1
- package/src/layout.js +55 -13
- package/src/list.js +35 -9
- package/src/menubar.js +315 -0
- package/src/miller-nav.js +299 -0
- package/src/renderer.js +59 -22
- package/src/scroll-box.js +7 -5
- package/src/scrollable-text.js +13 -13
- package/src/table.js +3 -3
- package/src/text-area.js +7 -1
- package/src/wrap.js +32 -10
package/index.js
CHANGED
|
@@ -19,6 +19,8 @@ export { ScrollableText } from './src/scrollable-text.js'
|
|
|
19
19
|
export { PickList } from './src/pick-list.js'
|
|
20
20
|
export { ScrollBox } from './src/scroll-box.js'
|
|
21
21
|
export { SplitPane } from './src/split-pane.js'
|
|
22
|
+
export { MillerNav } from './src/miller-nav.js'
|
|
23
|
+
export { MenuBar } from './src/menubar.js'
|
|
22
24
|
export { Task } from './src/task.js'
|
|
23
25
|
export { Shimmer } from './src/shimmer.js'
|
|
24
26
|
export { useFocus } from './src/focus.js'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trendr/core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "direct-mode TUI renderer with JSX, signals, and per-cell diffing",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -43,8 +43,11 @@
|
|
|
43
43
|
"progress": "node esbuild.config.js && node dist/progress.js",
|
|
44
44
|
"shimmer": "node esbuild.config.js && node dist/shimmer.js",
|
|
45
45
|
"spinner": "node esbuild.config.js && node dist/spinner.js",
|
|
46
|
+
"miller-nav": "node esbuild.config.js && node dist/miller-nav.js",
|
|
46
47
|
"pick-list": "node esbuild.config.js && node dist/pick-list.js",
|
|
47
48
|
"table-demo": "node esbuild.config.js && node dist/table-demo.js",
|
|
49
|
+
"menubar": "node esbuild.config.js && node dist/menubar.js",
|
|
50
|
+
"scrollback-demo": "node esbuild.config.js && node dist/scrollback-demo.js",
|
|
48
51
|
"demo": "node esbuild.config.js && node demo/server.js",
|
|
49
52
|
"demo:build": "node esbuild.config.js"
|
|
50
53
|
},
|
package/src/layout.js
CHANGED
|
@@ -98,14 +98,17 @@ export function computeLayout(node, rect) {
|
|
|
98
98
|
|
|
99
99
|
if (isScroll && flowChildren.length > 0) {
|
|
100
100
|
let maxEdge = 0
|
|
101
|
+
const heights = []
|
|
101
102
|
for (const child of flowChildren) {
|
|
102
103
|
const cl = getLeaf(child)?._layout
|
|
103
104
|
if (cl) {
|
|
104
105
|
const edge = isRow ? (cl.x + cl.width - innerX) : (cl.y + cl.height - innerY)
|
|
105
106
|
if (edge > maxEdge) maxEdge = edge
|
|
107
|
+
heights.push(isRow ? cl.width : cl.height)
|
|
106
108
|
}
|
|
107
109
|
}
|
|
108
110
|
node._contentHeight = maxEdge
|
|
111
|
+
node._childHeights = heights
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
for (const child of absChildren) {
|
|
@@ -302,23 +305,62 @@ function measureIntrinsic(node, availW, availH) {
|
|
|
302
305
|
let mainTotal = 0
|
|
303
306
|
let crossMax = 0
|
|
304
307
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
+
if (childIsRow) {
|
|
309
|
+
// two-pass measurement for rows: first measure non-flex children to determine
|
|
310
|
+
// how much width flex children actually get, then re-measure flex children
|
|
311
|
+
// at their real width so wrapping text computes the correct height
|
|
312
|
+
const totalGaps = Math.max(0, children.length - 1) * gap
|
|
313
|
+
let usedByFixed = totalGaps
|
|
314
|
+
let totalFlex = 0
|
|
315
|
+
const infos = []
|
|
316
|
+
|
|
317
|
+
for (const child of children) {
|
|
318
|
+
const cs = childStyle(child)
|
|
319
|
+
const grow = cs.flexGrow ?? cs.flex ?? 0
|
|
320
|
+
const margin = resolveMargin(cs)
|
|
321
|
+
const marginMain = margin.left + margin.right
|
|
322
|
+
const marginCross = margin.top + margin.bottom
|
|
323
|
+
|
|
324
|
+
if (grow > 0) {
|
|
325
|
+
usedByFixed += marginMain
|
|
326
|
+
totalFlex += grow
|
|
327
|
+
infos.push({ child, cs, grow, marginMain, marginCross, measured: null })
|
|
328
|
+
} else {
|
|
329
|
+
const measured = measureChild(child, cs, true, innerW, innerH)
|
|
330
|
+
usedByFixed += measured.width + marginMain
|
|
331
|
+
infos.push({ child, cs, grow: 0, marginMain, marginCross, measured })
|
|
332
|
+
}
|
|
333
|
+
}
|
|
308
334
|
|
|
309
|
-
const
|
|
310
|
-
const margin = resolveMargin(cs)
|
|
311
|
-
const marginMain = childIsRow ? margin.left + margin.right : margin.top + margin.bottom
|
|
312
|
-
const marginCross = childIsRow ? margin.top + margin.bottom : margin.left + margin.right
|
|
335
|
+
const flexSpace = Math.max(0, innerW - usedByFixed)
|
|
313
336
|
|
|
314
|
-
|
|
315
|
-
|
|
337
|
+
for (const info of infos) {
|
|
338
|
+
let measured = info.measured
|
|
339
|
+
if (info.grow > 0) {
|
|
340
|
+
const childW = Math.floor(flexSpace * (info.grow / totalFlex))
|
|
341
|
+
measured = measureChild(info.child, info.cs, true, childW, innerH)
|
|
342
|
+
}
|
|
343
|
+
mainTotal += (measured.width + info.marginMain)
|
|
344
|
+
const childCross = measured.height + info.marginCross
|
|
345
|
+
if (childCross > crossMax) crossMax = childCross
|
|
346
|
+
}
|
|
316
347
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
348
|
+
mainTotal += totalGaps
|
|
349
|
+
} else {
|
|
350
|
+
for (const child of children) {
|
|
351
|
+
const cs = childStyle(child)
|
|
352
|
+
const measured = measureChild(child, cs, false, innerW, innerH)
|
|
353
|
+
const margin = resolveMargin(cs)
|
|
354
|
+
const marginMain = margin.top + margin.bottom
|
|
355
|
+
const marginCross = margin.left + margin.right
|
|
356
|
+
|
|
357
|
+
mainTotal += measured.height + marginMain
|
|
358
|
+
const childCross = measured.width + marginCross
|
|
359
|
+
if (childCross > crossMax) crossMax = childCross
|
|
360
|
+
}
|
|
320
361
|
|
|
321
|
-
|
|
362
|
+
mainTotal += Math.max(0, children.length - 1) * gap
|
|
363
|
+
}
|
|
322
364
|
|
|
323
365
|
return childIsRow
|
|
324
366
|
? { width: mainTotal + chrome.x, height: crossMax + chrome.y }
|
package/src/list.js
CHANGED
|
@@ -18,17 +18,46 @@ export function List({ items, selected: selectedProp, onSelect, renderItem, head
|
|
|
18
18
|
|
|
19
19
|
const innerHeaderH = sticky ? 0 : headerH
|
|
20
20
|
|
|
21
|
-
// when sticky, layout measures the outer wrapper so we can't derive item height from it
|
|
22
|
-
// use itemHeight directly and compute content from item count
|
|
23
21
|
const avgH = !sticky && contentH > 0 && items.length > 0
|
|
24
22
|
? (contentH - headerH) / items.length
|
|
25
23
|
: itemHeight
|
|
26
24
|
|
|
27
25
|
const scrollViewH = sticky ? viewH - headerH : viewH
|
|
28
|
-
const scrollContentH = sticky
|
|
26
|
+
const scrollContentH = sticky
|
|
27
|
+
? (contentH > 0 ? contentH : items.length * itemHeight)
|
|
28
|
+
: contentH
|
|
29
29
|
|
|
30
30
|
const maxOffset = Math.max(0, scrollContentH - scrollViewH)
|
|
31
31
|
|
|
32
|
+
const childHeights = layout.childHeights
|
|
33
|
+
const chOffset = (!sticky && header) ? 1 : 0
|
|
34
|
+
|
|
35
|
+
let itemTop = 0, itemH = avgH
|
|
36
|
+
if (childHeights && childHeights.length > chOffset) {
|
|
37
|
+
for (let i = 0; i < selected + chOffset; i++) {
|
|
38
|
+
itemTop += (childHeights[i] ?? avgH) + gap
|
|
39
|
+
}
|
|
40
|
+
itemH = childHeights[selected + chOffset] ?? avgH
|
|
41
|
+
} else {
|
|
42
|
+
itemTop = selected * avgH + innerHeaderH
|
|
43
|
+
itemH = avgH
|
|
44
|
+
}
|
|
45
|
+
const itemBottom = itemTop + itemH
|
|
46
|
+
|
|
47
|
+
const offsetToIndex = (offset) => {
|
|
48
|
+
if (!childHeights || childHeights.length <= chOffset) {
|
|
49
|
+
return Math.round((offset - innerHeaderH) / Math.max(1, avgH))
|
|
50
|
+
}
|
|
51
|
+
let cum = 0
|
|
52
|
+
for (let j = 0; j < chOffset; j++) cum += (childHeights[j] ?? 0) + gap
|
|
53
|
+
for (let j = 0; j < items.length; j++) {
|
|
54
|
+
const h = childHeights[j + chOffset] ?? avgH
|
|
55
|
+
if (offset < cum + h) return j
|
|
56
|
+
cum += h + gap
|
|
57
|
+
}
|
|
58
|
+
return items.length - 1
|
|
59
|
+
}
|
|
60
|
+
|
|
32
61
|
useInput(({ key, ctrl }) => {
|
|
33
62
|
if (!interactive) return
|
|
34
63
|
|
|
@@ -65,7 +94,7 @@ export function List({ items, selected: selectedProp, onSelect, renderItem, head
|
|
|
65
94
|
if (event.action === 'press' && event.button === 'left') {
|
|
66
95
|
const headerOffset = sticky ? headerH : (header ? headerH : 0)
|
|
67
96
|
const relY = y - layout.y - headerOffset
|
|
68
|
-
const idx =
|
|
97
|
+
const idx = offsetToIndex(relY + scrollOffset)
|
|
69
98
|
if (idx >= 0 && idx < len) {
|
|
70
99
|
setSelected(idx)
|
|
71
100
|
event.stopPropagation()
|
|
@@ -82,16 +111,13 @@ export function List({ items, selected: selectedProp, onSelect, renderItem, head
|
|
|
82
111
|
thumbHeight: barThumbH,
|
|
83
112
|
trackHeight: scrollViewH,
|
|
84
113
|
maxOffset,
|
|
85
|
-
scrollOffset:
|
|
114
|
+
scrollOffset: itemTop,
|
|
86
115
|
onScroll: (offset) => {
|
|
87
|
-
const idx =
|
|
116
|
+
const idx = offsetToIndex(offset)
|
|
88
117
|
setSelected(Math.max(0, Math.min(items.length - 1, idx)))
|
|
89
118
|
},
|
|
90
119
|
})
|
|
91
120
|
|
|
92
|
-
const itemTop = selected * avgH + innerHeaderH
|
|
93
|
-
const itemBottom = itemTop + avgH
|
|
94
|
-
|
|
95
121
|
let scrollOffset = 0
|
|
96
122
|
if (scrollViewH > 0 && scrollContentH > scrollViewH) {
|
|
97
123
|
const margin = scrolloff * avgH
|
package/src/menubar.js
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { jsx, jsxs } from '../jsx-runtime.js'
|
|
2
|
+
import { createSignal } from './signal.js'
|
|
3
|
+
import { useInput, useLayout, useTheme } from './hooks.js'
|
|
4
|
+
import { registerOverlay, getInstanceLayout, getContext, registerHook } from './renderer.js'
|
|
5
|
+
|
|
6
|
+
function MenuDropdown({ items, cursor, scroll, visibleCount, onSelect, onCursorChange, accent, hotkeyColor, direction }) {
|
|
7
|
+
const scrollable = items.length > visibleCount
|
|
8
|
+
const visible = items.slice(scroll, scroll + visibleCount)
|
|
9
|
+
const thumbH = scrollable ? Math.max(1, Math.round((visibleCount / items.length) * visibleCount)) : 0
|
|
10
|
+
const maxSc = items.length - visibleCount
|
|
11
|
+
const thumbStart = scrollable && maxSc > 0 ? Math.round((scroll / maxSc) * (visibleCount - thumbH)) : 0
|
|
12
|
+
const maxLen = items.reduce((m, v) => Math.max(m, (v.label ?? v).length), 0)
|
|
13
|
+
|
|
14
|
+
const dropdownChildren = visible.map((item, vi) => {
|
|
15
|
+
const i = vi + scroll
|
|
16
|
+
const isCursor = i === cursor
|
|
17
|
+
const label = item.label ?? item
|
|
18
|
+
const hotkey = item.hotkey
|
|
19
|
+
|
|
20
|
+
const row = (content) => {
|
|
21
|
+
if (!scrollable) return content
|
|
22
|
+
const barIsThumb = vi >= thumbStart && vi < thumbStart + thumbH
|
|
23
|
+
return jsx('box', {
|
|
24
|
+
key: i,
|
|
25
|
+
style: { flexDirection: 'row' },
|
|
26
|
+
children: [
|
|
27
|
+
content,
|
|
28
|
+
jsx('text', {
|
|
29
|
+
style: { color: barIsThumb ? accent : 'gray', dim: !barIsThumb },
|
|
30
|
+
children: ' ' + (barIsThumb ? '\u2588' : '\u2502'),
|
|
31
|
+
}),
|
|
32
|
+
],
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const labelParts = renderHotkeyLabel(label, hotkey, {
|
|
37
|
+
hotkeyColor: isCursor ? 'black' : hotkeyColor,
|
|
38
|
+
textColor: isCursor ? 'black' : null,
|
|
39
|
+
bold: isCursor,
|
|
40
|
+
hotkeyBold: true,
|
|
41
|
+
hotkeyUnderline: true,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const content = jsx('box', {
|
|
45
|
+
key: scrollable ? undefined : i,
|
|
46
|
+
style: { bg: isCursor ? accent : null, paddingX: 1, flexGrow: 1 },
|
|
47
|
+
children: labelParts,
|
|
48
|
+
})
|
|
49
|
+
return row(content)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const dropdownH = visibleCount + 2
|
|
53
|
+
|
|
54
|
+
return jsx('box', {
|
|
55
|
+
style: {
|
|
56
|
+
flexDirection: 'column',
|
|
57
|
+
border: 'single',
|
|
58
|
+
borderColor: accent,
|
|
59
|
+
height: dropdownH,
|
|
60
|
+
width: maxLen + 4 + (scrollable ? 2 : 0),
|
|
61
|
+
bg: null,
|
|
62
|
+
},
|
|
63
|
+
children: dropdownChildren,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function renderHotkeyLabel(label, hotkey, { hotkeyColor, textColor, bold, hotkeyBold, hotkeyUnderline }) {
|
|
68
|
+
if (!hotkey) {
|
|
69
|
+
return jsx('text', { style: { color: textColor, bold }, children: label })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const idx = label.toLowerCase().indexOf(hotkey.toLowerCase())
|
|
73
|
+
if (idx === -1) {
|
|
74
|
+
return jsxs('box', {
|
|
75
|
+
style: { flexDirection: 'row' },
|
|
76
|
+
children: [
|
|
77
|
+
jsx('text', { style: { color: textColor, bold }, children: label }),
|
|
78
|
+
jsx('text', { style: { color: hotkeyColor, bold: hotkeyBold }, children: ' [' }),
|
|
79
|
+
jsx('text', { style: { color: hotkeyColor, bold: hotkeyBold, underline: hotkeyUnderline }, children: hotkey }),
|
|
80
|
+
jsx('text', { style: { color: hotkeyColor, bold: hotkeyBold }, children: ']' }),
|
|
81
|
+
],
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const parts = []
|
|
86
|
+
if (idx > 0) {
|
|
87
|
+
parts.push(jsx('text', { key: 'pre', style: { color: textColor, bold }, children: label.slice(0, idx) }))
|
|
88
|
+
}
|
|
89
|
+
parts.push(jsx('text', { key: 'hk', style: { color: hotkeyColor, bold: hotkeyBold, underline: hotkeyUnderline }, children: label[idx] }))
|
|
90
|
+
if (idx < label.length - 1) {
|
|
91
|
+
parts.push(jsx('text', { key: 'post', style: { color: textColor, bold }, children: label.slice(idx + 1) }))
|
|
92
|
+
}
|
|
93
|
+
return jsxs('box', { style: { flexDirection: 'row' }, children: parts })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function MenuBar({ items, focused = false, maxVisible = 10, onSelect, hotkeyColor: hotkeyColorProp, style: userStyle }) {
|
|
97
|
+
const { accent = 'cyan' } = useTheme()
|
|
98
|
+
const hotkeyColor = hotkeyColorProp ?? accent
|
|
99
|
+
|
|
100
|
+
const [openIndex, setOpenIndex] = createSignal(-1)
|
|
101
|
+
const [activeIndex, setActiveIndex] = createSignal(0)
|
|
102
|
+
const [cursor, setCursor] = createSignal(0)
|
|
103
|
+
const [scroll, setScroll] = createSignal(0)
|
|
104
|
+
|
|
105
|
+
const layout = useLayout()
|
|
106
|
+
|
|
107
|
+
useInput((event) => {
|
|
108
|
+
if (!focused) return
|
|
109
|
+
const { key } = event
|
|
110
|
+
const isOpen = openIndex() >= 0
|
|
111
|
+
|
|
112
|
+
const navKeys = ['h', 'l', 'left', 'right', 'return', 'space', 'escape']
|
|
113
|
+
if (!isOpen && !navKeys.includes(key)) {
|
|
114
|
+
const match = items.findIndex(m => m.hotkey && m.hotkey.toLowerCase() === key.toLowerCase())
|
|
115
|
+
if (match >= 0) {
|
|
116
|
+
setActiveIndex(match)
|
|
117
|
+
setOpenIndex(match)
|
|
118
|
+
setCursor(0)
|
|
119
|
+
setScroll(0)
|
|
120
|
+
event.stopPropagation()
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (isOpen) {
|
|
126
|
+
const menu = items[openIndex()]
|
|
127
|
+
const children = menu.children ?? []
|
|
128
|
+
const len = children.length
|
|
129
|
+
|
|
130
|
+
if (key === 'j' || key === 'down') {
|
|
131
|
+
setCursor(c => Math.min(len - 1, c + 1))
|
|
132
|
+
event.stopPropagation()
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (key === 'k' || key === 'up') {
|
|
137
|
+
setCursor(c => Math.max(0, c - 1))
|
|
138
|
+
event.stopPropagation()
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (key === 'return' || key === 'space') {
|
|
143
|
+
if (len > 0) {
|
|
144
|
+
const child = children[cursor()]
|
|
145
|
+
if (child) {
|
|
146
|
+
onSelect?.({ menu: menu.label, item: child.label ?? child, value: child.value ?? child.label ?? child })
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
setOpenIndex(-1)
|
|
150
|
+
event.stopPropagation()
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (key === 'escape') {
|
|
155
|
+
setOpenIndex(-1)
|
|
156
|
+
event.stopPropagation()
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const childMatch = children.findIndex(c => c.hotkey && c.hotkey.toLowerCase() === key.toLowerCase())
|
|
161
|
+
if (childMatch >= 0) {
|
|
162
|
+
const child = children[childMatch]
|
|
163
|
+
onSelect?.({ menu: menu.label, item: child.label ?? child, value: child.value ?? child.label ?? child })
|
|
164
|
+
setOpenIndex(-1)
|
|
165
|
+
event.stopPropagation()
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (key === 'h' || key === 'left') {
|
|
171
|
+
const next = (activeIndex() - 1 + items.length) % items.length
|
|
172
|
+
setActiveIndex(next)
|
|
173
|
+
if (isOpen) {
|
|
174
|
+
setOpenIndex(next)
|
|
175
|
+
setCursor(0)
|
|
176
|
+
setScroll(0)
|
|
177
|
+
}
|
|
178
|
+
event.stopPropagation()
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (key === 'l' || key === 'right') {
|
|
183
|
+
const next = (activeIndex() + 1) % items.length
|
|
184
|
+
setActiveIndex(next)
|
|
185
|
+
if (isOpen) {
|
|
186
|
+
setOpenIndex(next)
|
|
187
|
+
setCursor(0)
|
|
188
|
+
setScroll(0)
|
|
189
|
+
}
|
|
190
|
+
event.stopPropagation()
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (key === 'return' || key === 'space') {
|
|
195
|
+
setOpenIndex(activeIndex())
|
|
196
|
+
setCursor(0)
|
|
197
|
+
setScroll(0)
|
|
198
|
+
event.stopPropagation()
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
const isOpen = openIndex() >= 0
|
|
204
|
+
const openMenu = isOpen ? items[openIndex()] : null
|
|
205
|
+
const openChildren = openMenu?.children ?? []
|
|
206
|
+
|
|
207
|
+
if (isOpen && openChildren.length > 0) {
|
|
208
|
+
const instLayout = getInstanceLayout()
|
|
209
|
+
const ctx = getContext()
|
|
210
|
+
const termH = ctx?.stream?.rows ?? 24
|
|
211
|
+
|
|
212
|
+
let itemX = 0
|
|
213
|
+
for (let i = 0; i < openIndex(); i++) {
|
|
214
|
+
const label = items[i].label ?? items[i]
|
|
215
|
+
itemX += label.length + 2
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const anchorY = instLayout.y + 1
|
|
219
|
+
const spaceBelow = termH - anchorY - 2
|
|
220
|
+
const spaceAbove = instLayout.y - 2
|
|
221
|
+
|
|
222
|
+
let direction = 'down'
|
|
223
|
+
let maxRows = Math.min(openChildren.length, maxVisible)
|
|
224
|
+
if (spaceBelow >= maxRows) {
|
|
225
|
+
maxRows = Math.min(maxRows, spaceBelow)
|
|
226
|
+
} else if (spaceAbove > spaceBelow) {
|
|
227
|
+
direction = 'up'
|
|
228
|
+
maxRows = Math.min(maxRows, spaceAbove)
|
|
229
|
+
} else {
|
|
230
|
+
maxRows = Math.min(maxRows, spaceBelow)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const visibleCount = Math.max(1, maxRows)
|
|
234
|
+
|
|
235
|
+
const cur = cursor()
|
|
236
|
+
const sc = scroll()
|
|
237
|
+
let newScroll = sc
|
|
238
|
+
if (cur < sc) newScroll = cur
|
|
239
|
+
else if (cur >= sc + visibleCount) newScroll = cur - visibleCount + 1
|
|
240
|
+
newScroll = Math.max(0, Math.min(newScroll, openChildren.length - visibleCount))
|
|
241
|
+
if (newScroll !== sc) setScroll(newScroll)
|
|
242
|
+
|
|
243
|
+
const dropdown = jsx(MenuDropdown, {
|
|
244
|
+
items: openChildren,
|
|
245
|
+
cursor: cur,
|
|
246
|
+
scroll: newScroll,
|
|
247
|
+
visibleCount,
|
|
248
|
+
onSelect: (item) => {
|
|
249
|
+
onSelect?.({ menu: openMenu.label, item: item.label ?? item, value: item.value ?? item.label ?? item })
|
|
250
|
+
setOpenIndex(-1)
|
|
251
|
+
},
|
|
252
|
+
onCursorChange: (idx) => setCursor(idx),
|
|
253
|
+
accent,
|
|
254
|
+
hotkeyColor,
|
|
255
|
+
direction,
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
const termW = ctx?.stream?.columns ?? 80
|
|
259
|
+
const dropdownH = visibleCount + 2
|
|
260
|
+
const absX = instLayout.x + itemX
|
|
261
|
+
const absY = direction === 'up' ? instLayout.y - dropdownH : instLayout.y + 1
|
|
262
|
+
|
|
263
|
+
const overlay = jsx('box', {
|
|
264
|
+
style: { width: termW, height: termH },
|
|
265
|
+
children: jsx('box', {
|
|
266
|
+
style: {
|
|
267
|
+
position: 'absolute',
|
|
268
|
+
top: Math.max(0, absY),
|
|
269
|
+
left: absX,
|
|
270
|
+
},
|
|
271
|
+
children: dropdown,
|
|
272
|
+
}),
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
registerOverlay(overlay, { fullscreen: true })
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const barChildren = items.map((menu, i) => {
|
|
279
|
+
const isActive = i === activeIndex()
|
|
280
|
+
const isItemOpen = i === openIndex()
|
|
281
|
+
const label = menu.label ?? menu
|
|
282
|
+
const hotkey = menu.hotkey
|
|
283
|
+
|
|
284
|
+
let bg = null
|
|
285
|
+
let color = null
|
|
286
|
+
let bold = false
|
|
287
|
+
|
|
288
|
+
if (isItemOpen && focused) {
|
|
289
|
+
bg = accent
|
|
290
|
+
color = 'black'
|
|
291
|
+
bold = true
|
|
292
|
+
} else if (isActive && focused) {
|
|
293
|
+
bg = accent
|
|
294
|
+
color = 'black'
|
|
295
|
+
bold = true
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return jsx('box', {
|
|
299
|
+
key: i,
|
|
300
|
+
style: { bg, paddingX: 1 },
|
|
301
|
+
children: renderHotkeyLabel(label, hotkey, {
|
|
302
|
+
hotkeyColor: (bg === accent) ? color : hotkeyColor,
|
|
303
|
+
textColor: color,
|
|
304
|
+
bold,
|
|
305
|
+
hotkeyBold: true,
|
|
306
|
+
hotkeyUnderline: true,
|
|
307
|
+
}),
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
return jsxs('box', {
|
|
312
|
+
style: { flexDirection: 'row', ...userStyle },
|
|
313
|
+
children: barChildren,
|
|
314
|
+
})
|
|
315
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { jsx, jsxs } from '../jsx-runtime.js'
|
|
2
|
+
import { createSignal, batch } from './signal.js'
|
|
3
|
+
import { useInput, useMouse, useTheme, useLayout } from './hooks.js'
|
|
4
|
+
import { List } from './list.js'
|
|
5
|
+
|
|
6
|
+
function columnWidth(items, maxChars) {
|
|
7
|
+
if (!items.length) return 0
|
|
8
|
+
let longest = items.reduce((max, item) => {
|
|
9
|
+
const name = typeof item === 'string' ? item : (item.name ?? item.label ?? '')
|
|
10
|
+
return Math.max(max, name.length)
|
|
11
|
+
}, 0)
|
|
12
|
+
if (maxChars) longest = Math.min(longest, maxChars)
|
|
13
|
+
return Math.min(longest + 5, 25)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function defaultRenderItem(accent, item, { selected, focused, column }) {
|
|
17
|
+
const name = typeof item === 'string' ? item : (item.name ?? item.label ?? String(item))
|
|
18
|
+
const bg = selected ? (focused ? accent : '#333333') : null
|
|
19
|
+
const fg = selected && focused ? 'black' : null
|
|
20
|
+
return jsx('box', {
|
|
21
|
+
style: { bg, flexDirection: 'row' },
|
|
22
|
+
children: jsxs('text', {
|
|
23
|
+
style: { color: fg, overflow: 'truncate', flexGrow: 1 },
|
|
24
|
+
children: [' ', name],
|
|
25
|
+
}),
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function MillerNav({
|
|
30
|
+
rootItems,
|
|
31
|
+
getChildren,
|
|
32
|
+
hasChildren: hasChildrenFn,
|
|
33
|
+
renderItem,
|
|
34
|
+
onSelectionChange,
|
|
35
|
+
focused = true,
|
|
36
|
+
interactive = focused,
|
|
37
|
+
scrollbar = false,
|
|
38
|
+
peekColumn = true,
|
|
39
|
+
maxChars = { focused: 20, unfocused: 10 },
|
|
40
|
+
divider = true,
|
|
41
|
+
dividerChar = '\u258f',
|
|
42
|
+
dividerColor = '#333333',
|
|
43
|
+
}) {
|
|
44
|
+
const { accent = 'cyan' } = useTheme()
|
|
45
|
+
const layout = useLayout()
|
|
46
|
+
|
|
47
|
+
const focusedMax = typeof maxChars === 'number' ? maxChars : maxChars.focused ?? 20
|
|
48
|
+
const unfocusedMax = typeof maxChars === 'number' ? maxChars : maxChars.unfocused ?? 10
|
|
49
|
+
|
|
50
|
+
const [stack, setStack] = createSignal([{ parentId: null, items: rootItems, selected: 0 }])
|
|
51
|
+
const [depth, setDepth] = createSignal(0)
|
|
52
|
+
const [focusCol, setFocusCol] = createSignal(0)
|
|
53
|
+
|
|
54
|
+
function activeColumnIndex() {
|
|
55
|
+
return depth() + focusCol()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function activeItem() {
|
|
59
|
+
const s = stack()
|
|
60
|
+
const col = s[activeColumnIndex()]
|
|
61
|
+
if (!col || !col.items.length) return null
|
|
62
|
+
return col.items[col.selected] || null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function breadcrumb() {
|
|
66
|
+
const s = stack()
|
|
67
|
+
const d = depth()
|
|
68
|
+
const fc = focusCol()
|
|
69
|
+
const parts = []
|
|
70
|
+
for (let i = 0; i <= d + fc; i++) {
|
|
71
|
+
const col = s[i]
|
|
72
|
+
if (col && col.items.length) parts.push(col.items[col.selected])
|
|
73
|
+
}
|
|
74
|
+
return parts
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function emitSelection() {
|
|
78
|
+
if (!onSelectionChange) return
|
|
79
|
+
onSelectionChange({ item: activeItem(), breadcrumb: breadcrumb(), column: focusCol() })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function syncView() {
|
|
83
|
+
const s = stack().slice()
|
|
84
|
+
const d = depth()
|
|
85
|
+
const left = s[d]
|
|
86
|
+
|
|
87
|
+
if (left && left.items.length) {
|
|
88
|
+
const sel = left.items[left.selected]
|
|
89
|
+
const children = getChildren(sel)
|
|
90
|
+
const prevRight = s[d + 1]
|
|
91
|
+
const keepSelected = (prevRight && prevRight.parentId === sel)
|
|
92
|
+
? Math.min(prevRight.selected, Math.max(0, children.length - 1))
|
|
93
|
+
: 0
|
|
94
|
+
s[d + 1] = { parentId: sel, items: children, selected: keepSelected }
|
|
95
|
+
s.length = d + 2
|
|
96
|
+
} else {
|
|
97
|
+
s.length = d + 1
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
setStack(s)
|
|
101
|
+
|
|
102
|
+
const right = s[d + 1]
|
|
103
|
+
if (!right || !right.items.length) {
|
|
104
|
+
if (d > 0) {
|
|
105
|
+
setDepth(d - 1)
|
|
106
|
+
setFocusCol(1)
|
|
107
|
+
return syncView()
|
|
108
|
+
}
|
|
109
|
+
setFocusCol(0)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
emitSelection()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
syncView()
|
|
116
|
+
|
|
117
|
+
useInput(({ key, raw, ctrl, stopPropagation }) => {
|
|
118
|
+
if (!interactive) return
|
|
119
|
+
|
|
120
|
+
const s = stack()
|
|
121
|
+
const d = depth()
|
|
122
|
+
const fc = focusCol()
|
|
123
|
+
const colIdx = d + fc
|
|
124
|
+
const col = s[colIdx]
|
|
125
|
+
if (!col || !col.items.length) return
|
|
126
|
+
|
|
127
|
+
function moveSelection(delta) {
|
|
128
|
+
const ns = s.slice()
|
|
129
|
+
const newSel = Math.max(0, Math.min(col.items.length - 1, col.selected + delta))
|
|
130
|
+
ns[colIdx] = { ...col, selected: newSel }
|
|
131
|
+
setStack(ns)
|
|
132
|
+
syncView()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (key === 'j' || key === 'down') { moveSelection(1); stopPropagation() }
|
|
136
|
+
else if (key === 'k' || key === 'up') { moveSelection(-1); stopPropagation() }
|
|
137
|
+
else if (raw === 'g') { moveSelection(-col.items.length); stopPropagation() }
|
|
138
|
+
else if (raw === 'G') { moveSelection(col.items.length); stopPropagation() }
|
|
139
|
+
else if (ctrl && key === 'd') { moveSelection(Math.floor((layout.height || 20) / 2)); stopPropagation() }
|
|
140
|
+
else if (ctrl && key === 'u') { moveSelection(-Math.floor((layout.height || 20) / 2)); stopPropagation() }
|
|
141
|
+
else if (key === 'l' || key === 'right') {
|
|
142
|
+
if (fc === 0) {
|
|
143
|
+
const right = s[d + 1]
|
|
144
|
+
if (right && right.items.length) {
|
|
145
|
+
setFocusCol(1)
|
|
146
|
+
emitSelection()
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
const sel = col.items[col.selected]
|
|
150
|
+
const children = getChildren(sel)
|
|
151
|
+
if (!children.length) { stopPropagation(); return }
|
|
152
|
+
|
|
153
|
+
const ns = s.slice(0, colIdx + 1)
|
|
154
|
+
ns.push({ parentId: sel, items: children, selected: 0 })
|
|
155
|
+
|
|
156
|
+
batch(() => {
|
|
157
|
+
setStack(ns)
|
|
158
|
+
setDepth(colIdx)
|
|
159
|
+
setFocusCol(1)
|
|
160
|
+
})
|
|
161
|
+
syncView()
|
|
162
|
+
}
|
|
163
|
+
stopPropagation()
|
|
164
|
+
}
|
|
165
|
+
else if (key === 'h' || key === 'left') {
|
|
166
|
+
if (fc === 1 && d > 0) {
|
|
167
|
+
batch(() => {
|
|
168
|
+
setDepth(d - 1)
|
|
169
|
+
setFocusCol(1)
|
|
170
|
+
})
|
|
171
|
+
syncView()
|
|
172
|
+
} else if (fc === 1 && d === 0) {
|
|
173
|
+
setFocusCol(0)
|
|
174
|
+
emitSelection()
|
|
175
|
+
}
|
|
176
|
+
stopPropagation()
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
function navigateTo(chain) {
|
|
181
|
+
if (!chain.length) return
|
|
182
|
+
const newStack = []
|
|
183
|
+
let parent = null
|
|
184
|
+
for (let i = 0; i < chain.length; i++) {
|
|
185
|
+
const items = parent === null ? rootItems : getChildren(parent)
|
|
186
|
+
const idx = items.indexOf(chain[i])
|
|
187
|
+
newStack.push({ parentId: parent, items, selected: idx >= 0 ? idx : 0 })
|
|
188
|
+
parent = chain[i]
|
|
189
|
+
}
|
|
190
|
+
const newDepth = Math.max(0, newStack.length - 2)
|
|
191
|
+
const newFocusCol = newStack.length >= 2 ? Math.min(1, newStack.length - 1 - newDepth) : 0
|
|
192
|
+
batch(() => {
|
|
193
|
+
setStack(newStack)
|
|
194
|
+
setDepth(newDepth)
|
|
195
|
+
setFocusCol(newFocusCol)
|
|
196
|
+
})
|
|
197
|
+
syncView()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const render = renderItem || defaultRenderItem.bind(null, accent)
|
|
201
|
+
|
|
202
|
+
function NoteColumn({ items, selected, onSelect, isFocused, maxC, style: extraStyle }) {
|
|
203
|
+
if (!items.length) return null
|
|
204
|
+
return jsx('box', {
|
|
205
|
+
style: { width: columnWidth(items, maxC), ...extraStyle },
|
|
206
|
+
children: jsx(List, {
|
|
207
|
+
items,
|
|
208
|
+
selected,
|
|
209
|
+
onSelect,
|
|
210
|
+
focused: isFocused,
|
|
211
|
+
interactive: false,
|
|
212
|
+
scrollbar,
|
|
213
|
+
renderItem: (item, { selected: isSel }) =>
|
|
214
|
+
render(item, { selected: isSel, focused: isFocused, column: isFocused ? 'active' : 'context' }),
|
|
215
|
+
}),
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const s = stack()
|
|
220
|
+
const d = depth()
|
|
221
|
+
const fc = focusCol()
|
|
222
|
+
const leftCol = s[d]
|
|
223
|
+
const rightCol = s[d + 1]
|
|
224
|
+
|
|
225
|
+
const peekItem = peekColumn && fc === 1 && rightCol?.items?.length
|
|
226
|
+
? rightCol.items[rightCol.selected]
|
|
227
|
+
: null
|
|
228
|
+
const peekItems = peekItem && hasChildrenFn && hasChildrenFn(peekItem)
|
|
229
|
+
? getChildren(peekItem)
|
|
230
|
+
: peekItem && !hasChildrenFn
|
|
231
|
+
? getChildren(peekItem)
|
|
232
|
+
: []
|
|
233
|
+
|
|
234
|
+
const children = []
|
|
235
|
+
|
|
236
|
+
children.push(jsx(NoteColumn, {
|
|
237
|
+
key: 'left',
|
|
238
|
+
items: leftCol?.items || [],
|
|
239
|
+
selected: leftCol?.selected || 0,
|
|
240
|
+
maxC: fc === 0 ? focusedMax : unfocusedMax,
|
|
241
|
+
onSelect: (i) => {
|
|
242
|
+
const ns = s.slice()
|
|
243
|
+
ns[d] = { ...leftCol, selected: i }
|
|
244
|
+
batch(() => {
|
|
245
|
+
setStack(ns)
|
|
246
|
+
setFocusCol(0)
|
|
247
|
+
})
|
|
248
|
+
syncView()
|
|
249
|
+
},
|
|
250
|
+
isFocused: focused && fc === 0,
|
|
251
|
+
}))
|
|
252
|
+
|
|
253
|
+
if (rightCol?.items) {
|
|
254
|
+
children.push(jsx(NoteColumn, {
|
|
255
|
+
key: 'right',
|
|
256
|
+
items: rightCol?.items || [],
|
|
257
|
+
selected: rightCol?.selected || 0,
|
|
258
|
+
maxC: fc === 1 ? focusedMax : unfocusedMax,
|
|
259
|
+
style: { marginLeft: 1 },
|
|
260
|
+
onSelect: (i) => {
|
|
261
|
+
const ns = s.slice()
|
|
262
|
+
ns[d + 1] = { ...rightCol, selected: i }
|
|
263
|
+
batch(() => {
|
|
264
|
+
setStack(ns)
|
|
265
|
+
setFocusCol(1)
|
|
266
|
+
})
|
|
267
|
+
syncView()
|
|
268
|
+
},
|
|
269
|
+
isFocused: focused && fc === 1,
|
|
270
|
+
}))
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (peekItems.length > 0) {
|
|
274
|
+
children.push(jsx(NoteColumn, {
|
|
275
|
+
key: 'peek',
|
|
276
|
+
items: peekItems,
|
|
277
|
+
selected: -1,
|
|
278
|
+
maxC: unfocusedMax,
|
|
279
|
+
style: { marginLeft: 1 },
|
|
280
|
+
onSelect: () => {},
|
|
281
|
+
isFocused: false,
|
|
282
|
+
}))
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (divider) {
|
|
286
|
+
const rows = layout.height || process.stdout.rows
|
|
287
|
+
const bar = Array.from({ length: rows }, () => dividerChar).join('\n')
|
|
288
|
+
children.push(jsx('box', {
|
|
289
|
+
key: 'divider',
|
|
290
|
+
style: { width: 1, marginLeft: 1 },
|
|
291
|
+
children: jsx('text', { style: { color: dividerColor }, children: bar }),
|
|
292
|
+
}))
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return jsx('box', {
|
|
296
|
+
style: { flexDirection: 'row' },
|
|
297
|
+
children,
|
|
298
|
+
})
|
|
299
|
+
}
|
package/src/renderer.js
CHANGED
|
@@ -86,38 +86,57 @@ function paintBorder(buf, rect, borderStyle, fg, edges) {
|
|
|
86
86
|
: BORDER_CHARS.single
|
|
87
87
|
|
|
88
88
|
const { x, y, width, height } = rect
|
|
89
|
-
if (width <
|
|
89
|
+
if (width < 1 || height < 1) return
|
|
90
90
|
|
|
91
91
|
const cell = (ch) => ({ ch, fg: fg ?? null, bg: null, attrs: 0 })
|
|
92
92
|
const { top, right, bottom, left } = edges
|
|
93
93
|
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
const x2 = x + width - 1
|
|
95
|
+
const y2 = y + height - 1
|
|
96
|
+
const singleRow = height === 1
|
|
97
|
+
const singleCol = width === 1
|
|
98
|
+
|
|
99
|
+
// top-left corner
|
|
100
|
+
if (top && left) buf.cells[y * buf.width + x] = cell(singleRow || singleCol ? (singleRow && singleCol ? chars.v : singleRow ? chars.h : chars.v) : chars.tl)
|
|
101
|
+
else if (top && !singleCol) buf.cells[y * buf.width + x] = cell(chars.h)
|
|
96
102
|
else if (left) buf.cells[y * buf.width + x] = cell(chars.v)
|
|
97
103
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
104
|
+
// top-right corner
|
|
105
|
+
if (!singleCol) {
|
|
106
|
+
if (top && right) buf.cells[y * buf.width + x2] = cell(singleRow ? chars.h : chars.tr)
|
|
107
|
+
else if (top) buf.cells[y * buf.width + x2] = cell(chars.h)
|
|
108
|
+
else if (right) buf.cells[y * buf.width + x2] = cell(chars.v)
|
|
109
|
+
}
|
|
101
110
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
111
|
+
// bottom-left corner
|
|
112
|
+
if (!singleRow) {
|
|
113
|
+
if (bottom && left) buf.cells[y2 * buf.width + x] = cell(singleCol ? chars.v : chars.bl)
|
|
114
|
+
else if (bottom && !singleCol) buf.cells[y2 * buf.width + x] = cell(chars.h)
|
|
115
|
+
else if (left) buf.cells[y2 * buf.width + x] = cell(chars.v)
|
|
116
|
+
}
|
|
105
117
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
118
|
+
// bottom-right corner
|
|
119
|
+
if (!singleRow && !singleCol) {
|
|
120
|
+
if (bottom && right) buf.cells[y2 * buf.width + x2] = cell(chars.br)
|
|
121
|
+
else if (bottom) buf.cells[y2 * buf.width + x2] = cell(chars.h)
|
|
122
|
+
else if (right) buf.cells[y2 * buf.width + x2] = cell(chars.v)
|
|
123
|
+
}
|
|
109
124
|
|
|
110
|
-
|
|
125
|
+
// top edge
|
|
126
|
+
if (top) for (let col = x + 1; col < x2; col++)
|
|
111
127
|
buf.cells[y * buf.width + col] = cell(chars.h)
|
|
112
128
|
|
|
113
|
-
|
|
114
|
-
|
|
129
|
+
// bottom edge
|
|
130
|
+
if (bottom && !singleRow) for (let col = x + 1; col < x2; col++)
|
|
131
|
+
buf.cells[y2 * buf.width + col] = cell(chars.h)
|
|
115
132
|
|
|
116
|
-
|
|
133
|
+
// left edge
|
|
134
|
+
if (left) for (let row = y + 1; row < y2; row++)
|
|
117
135
|
buf.cells[row * buf.width + x] = cell(chars.v)
|
|
118
136
|
|
|
119
|
-
|
|
120
|
-
|
|
137
|
+
// right edge
|
|
138
|
+
if (right) for (let row = y + 1; row < y2; row++)
|
|
139
|
+
buf.cells[row * buf.width + x2] = cell(chars.v)
|
|
121
140
|
}
|
|
122
141
|
|
|
123
142
|
function paintJunctions(buf, rect, borderStyle, fg, children, edges) {
|
|
@@ -192,6 +211,19 @@ function findScrollContentHeight(node) {
|
|
|
192
211
|
return null
|
|
193
212
|
}
|
|
194
213
|
|
|
214
|
+
function findScrollChildHeights(node) {
|
|
215
|
+
if (!node) return null
|
|
216
|
+
if (node._childHeights) return node._childHeights
|
|
217
|
+
if (node._resolved) return findScrollChildHeights(node._resolved)
|
|
218
|
+
if (node._resolvedChildren) {
|
|
219
|
+
for (const child of node._resolvedChildren) {
|
|
220
|
+
const h = findScrollChildHeights(child)
|
|
221
|
+
if (h != null) return h
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return null
|
|
225
|
+
}
|
|
226
|
+
|
|
195
227
|
function updateOverlayLayouts(node) {
|
|
196
228
|
if (!node) return
|
|
197
229
|
if (node._instance) {
|
|
@@ -205,6 +237,7 @@ function updateOverlayLayouts(node) {
|
|
|
205
237
|
target.width = rect.width
|
|
206
238
|
target.height = rect.height
|
|
207
239
|
target.contentHeight = ch
|
|
240
|
+
target.childHeights = findScrollChildHeights(node)
|
|
208
241
|
}
|
|
209
242
|
}
|
|
210
243
|
if (node._resolved) updateOverlayLayouts(node._resolved)
|
|
@@ -324,14 +357,16 @@ function paintTree(node, buf, clip, offset, prevBuf) {
|
|
|
324
357
|
const childClip = clip ? clipRect(layout, clip) : layout
|
|
325
358
|
|
|
326
359
|
let childOffset = offset
|
|
360
|
+
let childPrevBuf = prevBuf
|
|
327
361
|
if (style.overflow === 'scroll') {
|
|
328
362
|
const scrollY = style.scrollOffset ?? 0
|
|
329
363
|
childOffset = { x: offset?.x ?? 0, y: (offset?.y ?? 0) - scrollY }
|
|
364
|
+
childPrevBuf = null
|
|
330
365
|
}
|
|
331
366
|
|
|
332
367
|
if (node._resolvedChildren) {
|
|
333
368
|
for (const child of node._resolvedChildren) {
|
|
334
|
-
paintTree(child, buf, childClip, childOffset,
|
|
369
|
+
paintTree(child, buf, childClip, childOffset, childPrevBuf)
|
|
335
370
|
}
|
|
336
371
|
}
|
|
337
372
|
}
|
|
@@ -570,7 +605,7 @@ function resolveForFrame(element, parent, instances, counters, visited, scope) {
|
|
|
570
605
|
return node
|
|
571
606
|
}
|
|
572
607
|
|
|
573
|
-
export function mount(rootComponent, { stream, stdin, title, theme, onExit: onExitCb } = {}) {
|
|
608
|
+
export function mount(rootComponent, { stream, stdin, title, theme, onExit: onExitCb, altScreen = true } = {}) {
|
|
574
609
|
const out = stream ?? process.stdout
|
|
575
610
|
const inp = stdin ?? process.stdin
|
|
576
611
|
|
|
@@ -620,6 +655,7 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
620
655
|
prev.width = rect.width
|
|
621
656
|
prev.height = rect.height
|
|
622
657
|
prev.contentHeight = ch
|
|
658
|
+
prev.childHeights = findScrollChildHeights(inst.node)
|
|
623
659
|
}
|
|
624
660
|
|
|
625
661
|
// layout values changed - re-resolve so components see updated useLayout()
|
|
@@ -639,6 +675,7 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
639
675
|
inst.layout.width = rect.width
|
|
640
676
|
inst.layout.height = rect.height
|
|
641
677
|
inst.layout.contentHeight = ch
|
|
678
|
+
inst.layout.childHeights = findScrollChildHeights(inst.node)
|
|
642
679
|
inst._dirty = true
|
|
643
680
|
}
|
|
644
681
|
propagateDirty(tree2)
|
|
@@ -708,7 +745,7 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
708
745
|
|
|
709
746
|
setSchedulerHook(scheduler.requestFrame)
|
|
710
747
|
|
|
711
|
-
out.write(ansi.altScreen + ansi.hideCursor + ansi.clearScreen + ansi.enableMouse + (title ? ansi.setTitle(title) : ''))
|
|
748
|
+
out.write((altScreen ? ansi.altScreen : '') + ansi.hideCursor + ansi.clearScreen + ansi.enableMouse + (title ? ansi.setTitle(title) : ''))
|
|
712
749
|
if (inp.isTTY && inp.setRawMode) inp.setRawMode(true)
|
|
713
750
|
|
|
714
751
|
frame()
|
|
@@ -748,7 +785,7 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
748
785
|
}
|
|
749
786
|
instances.clear()
|
|
750
787
|
|
|
751
|
-
out.write(ansi.sgrReset + ansi.disableMouse + ansi.showCursor + ansi.exitAltScreen)
|
|
788
|
+
out.write(ansi.sgrReset + ansi.disableMouse + ansi.showCursor + (altScreen ? ansi.exitAltScreen : ansi.moveTo(height, 1) + '\n'))
|
|
752
789
|
if (inp.isTTY && inp.setRawMode) inp.setRawMode(false)
|
|
753
790
|
activeContext = null
|
|
754
791
|
setSchedulerHook(null)
|
package/src/scroll-box.js
CHANGED
|
@@ -2,7 +2,7 @@ import { jsx, jsxs } from '../jsx-runtime.js'
|
|
|
2
2
|
import { createSignal } from './signal.js'
|
|
3
3
|
import { useInput, useMouse, useLayout, useTheme, useScrollDrag } from './hooks.js'
|
|
4
4
|
|
|
5
|
-
export function ScrollBox({ children, focused = true, scrollOffset: offsetProp, onScroll, scrollbar = false, thumbChar = '\u2588', trackChar = '\u2502', style: userStyle }) {
|
|
5
|
+
export function ScrollBox({ children, focused = true, scrollOffset: offsetProp, onScroll, scrollbar = false, gap = 0, thumbChar = '\u2588', trackChar = '\u2502', style: userStyle }) {
|
|
6
6
|
const { accent = 'cyan' } = useTheme()
|
|
7
7
|
const [offsetInternal, setOffsetInternal] = createSignal(0)
|
|
8
8
|
const layout = useLayout()
|
|
@@ -15,6 +15,7 @@ export function ScrollBox({ children, focused = true, scrollOffset: offsetProp,
|
|
|
15
15
|
const maxOffset = Math.max(0, contentH - visibleH)
|
|
16
16
|
|
|
17
17
|
const clamp = (v) => Math.max(0, Math.min(maxOffset, v))
|
|
18
|
+
const clamped = clamp(offset)
|
|
18
19
|
|
|
19
20
|
useInput(({ key, ctrl }) => {
|
|
20
21
|
if (!focused) return
|
|
@@ -46,7 +47,7 @@ export function ScrollBox({ children, focused = true, scrollOffset: offsetProp,
|
|
|
46
47
|
|
|
47
48
|
const hasBar = scrollbar && visibleH > 0 && contentH > visibleH
|
|
48
49
|
const barThumbH = hasBar ? Math.max(1, Math.round((visibleH / contentH) * visibleH)) : 0
|
|
49
|
-
const barThumbStart = hasBar && maxOffset > 0 ? Math.round((
|
|
50
|
+
const barThumbStart = hasBar && maxOffset > 0 ? Math.round((clamped / maxOffset) * (visibleH - barThumbH)) : 0
|
|
50
51
|
|
|
51
52
|
useScrollDrag({
|
|
52
53
|
barX: hasBar ? layout.x + layout.width - 1 : null,
|
|
@@ -54,7 +55,7 @@ export function ScrollBox({ children, focused = true, scrollOffset: offsetProp,
|
|
|
54
55
|
thumbHeight: barThumbH,
|
|
55
56
|
trackHeight: visibleH,
|
|
56
57
|
maxOffset,
|
|
57
|
-
scrollOffset:
|
|
58
|
+
scrollOffset: clamped,
|
|
58
59
|
onScroll: (v) => setOffset(clamp(v)),
|
|
59
60
|
})
|
|
60
61
|
|
|
@@ -64,7 +65,8 @@ export function ScrollBox({ children, focused = true, scrollOffset: offsetProp,
|
|
|
64
65
|
flexGrow: 1,
|
|
65
66
|
...userStyle,
|
|
66
67
|
overflow: 'scroll',
|
|
67
|
-
scrollOffset:
|
|
68
|
+
scrollOffset: clamped,
|
|
69
|
+
gap,
|
|
68
70
|
},
|
|
69
71
|
children,
|
|
70
72
|
})
|
|
@@ -77,7 +79,7 @@ export function ScrollBox({ children, focused = true, scrollOffset: offsetProp,
|
|
|
77
79
|
if (hasOverflow) {
|
|
78
80
|
const thumbH = Math.max(1, Math.round((visibleH / contentH) * visibleH))
|
|
79
81
|
const thumbStart = maxOffset > 0
|
|
80
|
-
? Math.round((
|
|
82
|
+
? Math.round((clamped / maxOffset) * (visibleH - thumbH))
|
|
81
83
|
: 0
|
|
82
84
|
|
|
83
85
|
for (let i = 0; i < visibleH; i++) {
|
package/src/scrollable-text.js
CHANGED
|
@@ -19,6 +19,7 @@ export function ScrollableText({ content = '', focused = true, scrollOffset: off
|
|
|
19
19
|
const maxOffset = Math.max(0, lines.length - (h || 1))
|
|
20
20
|
|
|
21
21
|
const clamp = (v) => Math.max(0, Math.min(maxOffset, v))
|
|
22
|
+
const clamped = clamp(offset)
|
|
22
23
|
|
|
23
24
|
useInput(({ key, ctrl }) => {
|
|
24
25
|
if (!focused) return
|
|
@@ -27,30 +28,29 @@ export function ScrollableText({ content = '', focused = true, scrollOffset: off
|
|
|
27
28
|
const pageH = h || 10
|
|
28
29
|
const half = Math.max(1, Math.floor(pageH / 2))
|
|
29
30
|
|
|
30
|
-
if (key === 'up' || key === 'k') setOffset(clamp(
|
|
31
|
-
else if (key === 'down' || key === 'j') setOffset(clamp(
|
|
32
|
-
else if (key === 'pageup' || (ctrl && key === 'b')) setOffset(clamp(
|
|
33
|
-
else if (key === 'pagedown' || (ctrl && key === 'f')) setOffset(clamp(
|
|
34
|
-
else if (ctrl && key === 'u') setOffset(clamp(
|
|
35
|
-
else if (ctrl && key === 'd') setOffset(clamp(
|
|
31
|
+
if (key === 'up' || key === 'k') setOffset(clamp(clamped - 1))
|
|
32
|
+
else if (key === 'down' || key === 'j') setOffset(clamp(clamped + 1))
|
|
33
|
+
else if (key === 'pageup' || (ctrl && key === 'b')) setOffset(clamp(clamped - pageH))
|
|
34
|
+
else if (key === 'pagedown' || (ctrl && key === 'f')) setOffset(clamp(clamped + pageH))
|
|
35
|
+
else if (ctrl && key === 'u') setOffset(clamp(clamped - half))
|
|
36
|
+
else if (ctrl && key === 'd') setOffset(clamp(clamped + half))
|
|
36
37
|
else if (key === 'home' || key === 'g') setOffset(0)
|
|
37
38
|
else if (key === 'end' || key === 'G') setOffset(maxOffset)
|
|
38
39
|
})
|
|
39
40
|
|
|
40
41
|
useMouse((event) => {
|
|
41
|
-
if (!focused) return
|
|
42
42
|
if (event.action !== 'scroll') return
|
|
43
43
|
if (lines.length <= (h || 1)) return
|
|
44
44
|
const { x, y } = event
|
|
45
45
|
if (x < layout.x || x >= layout.x + layout.width || y < layout.y || y >= layout.y + layout.height) return
|
|
46
|
-
if (event.direction === 'up') setOffset(clamp(
|
|
47
|
-
else setOffset(clamp(
|
|
46
|
+
if (event.direction === 'up') setOffset(clamp(clamped - 3))
|
|
47
|
+
else setOffset(clamp(clamped + 3))
|
|
48
48
|
event.stopPropagation()
|
|
49
49
|
})
|
|
50
50
|
|
|
51
51
|
const hasBar = scrollbar && h > 0 && lines.length > h
|
|
52
52
|
const barThumbH = hasBar ? Math.max(1, Math.round((h / lines.length) * h)) : 0
|
|
53
|
-
const barThumbStart = hasBar && maxOffset > 0 ? Math.round((
|
|
53
|
+
const barThumbStart = hasBar && maxOffset > 0 ? Math.round((clamped / maxOffset) * (h - barThumbH)) : 0
|
|
54
54
|
|
|
55
55
|
useScrollDrag({
|
|
56
56
|
barX: hasBar ? layout.x + layout.width - 1 : null,
|
|
@@ -58,11 +58,11 @@ export function ScrollableText({ content = '', focused = true, scrollOffset: off
|
|
|
58
58
|
thumbHeight: barThumbH,
|
|
59
59
|
trackHeight: h,
|
|
60
60
|
maxOffset,
|
|
61
|
-
scrollOffset:
|
|
61
|
+
scrollOffset: clamped,
|
|
62
62
|
onScroll: (v) => setOffset(clamp(v)),
|
|
63
63
|
})
|
|
64
64
|
|
|
65
|
-
const visible = lines.slice(
|
|
65
|
+
const visible = lines.slice(clamped, h > 0 ? clamped + h : undefined)
|
|
66
66
|
|
|
67
67
|
const textStyle = wrap ? undefined : { overflow: 'truncate' }
|
|
68
68
|
|
|
@@ -75,7 +75,7 @@ export function ScrollableText({ content = '', focused = true, scrollOffset: off
|
|
|
75
75
|
|
|
76
76
|
const thumbH = Math.max(1, Math.round((h / lines.length) * h))
|
|
77
77
|
const thumbStart = maxOffset > 0
|
|
78
|
-
? Math.round((
|
|
78
|
+
? Math.round((clamped / maxOffset) * (h - thumbH))
|
|
79
79
|
: 0
|
|
80
80
|
|
|
81
81
|
const children = visible.map((line, i) => {
|
package/src/table.js
CHANGED
|
@@ -4,11 +4,11 @@ import { List } from './list.js'
|
|
|
4
4
|
|
|
5
5
|
const DEFAULT_SEP = { left: '', fill: '\u2500', right: '' }
|
|
6
6
|
|
|
7
|
-
export function Table({ columns, data, selected, onSelect, focused = true, separator = false, separatorChars, renderItem, scrollbar, stickyHeader = false, gap = 0, itemHeight = 1, scrolloff = 2 }) {
|
|
7
|
+
export function Table({ columns, data, selected, onSelect, focused = true, separator = false, separatorChars, renderItem, scrollbar, stickyHeader = false, gap = 0, itemHeight = 1, scrolloff = 2, columnGap = 1 }) {
|
|
8
8
|
const { accent = 'cyan' } = useTheme()
|
|
9
9
|
|
|
10
10
|
const headerRow = jsxs('box', {
|
|
11
|
-
style: { flexDirection: 'row' },
|
|
11
|
+
style: { flexDirection: 'row', gap: columnGap },
|
|
12
12
|
children: columns.map((col, i) =>
|
|
13
13
|
jsx('text', {
|
|
14
14
|
key: i,
|
|
@@ -44,7 +44,7 @@ export function Table({ columns, data, selected, onSelect, focused = true, separ
|
|
|
44
44
|
|
|
45
45
|
const defaultRenderItem = (row, { selected: isSel }) =>
|
|
46
46
|
jsxs('box', {
|
|
47
|
-
style: { flexDirection: 'row', bg: isSel ? (focused ? accent : 'gray') : null },
|
|
47
|
+
style: { flexDirection: 'row', gap: columnGap, bg: isSel ? (focused ? accent : 'gray') : null },
|
|
48
48
|
children: columns.map((col, i) =>
|
|
49
49
|
jsx('text', {
|
|
50
50
|
key: i,
|
package/src/text-area.js
CHANGED
|
@@ -87,9 +87,15 @@ function ensureVisible(cursorRow, scroll, height, totalLines) {
|
|
|
87
87
|
return scroll
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
export function TextArea({ onSubmit, onCancel, onChange, placeholder, focused = true, maxHeight = 10, clearOnSubmit = true, cursor: cursorProp }) {
|
|
90
|
+
export function TextArea({ onSubmit, onCancel, onChange, placeholder, focused = true, maxHeight = 10, clearOnSubmit = true, cursor: cursorProp, value: valueProp }) {
|
|
91
91
|
const [value, setValue] = createSignal('')
|
|
92
92
|
const [cursor, setCursor] = createSignal(0)
|
|
93
|
+
const _prev = registerHook(() => ({ value: undefined }))
|
|
94
|
+
if (valueProp !== undefined && valueProp !== _prev.value) {
|
|
95
|
+
_prev.value = valueProp
|
|
96
|
+
setValue(valueProp)
|
|
97
|
+
setCursor(valueProp.length)
|
|
98
|
+
}
|
|
93
99
|
const ref = registerHook(() => ({ scroll: 0, goalCol: null }))
|
|
94
100
|
const layout = useLayout()
|
|
95
101
|
const { cursorStyle, reset: resetBlink } = useCursor(cursorProp, focused)
|
package/src/wrap.js
CHANGED
|
@@ -53,24 +53,39 @@ export function sliceVisible(text, maxWidth) {
|
|
|
53
53
|
return result
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
function extractTrailingAnsi(str) {
|
|
57
|
+
let active = ''
|
|
58
|
+
const re = /\x1b\[[0-9;]*m/g
|
|
59
|
+
let m
|
|
60
|
+
while ((m = re.exec(str)) !== null) {
|
|
61
|
+
if (m[0] === '\x1b[0m') active = ''
|
|
62
|
+
else active += m[0]
|
|
63
|
+
}
|
|
64
|
+
return active
|
|
65
|
+
}
|
|
66
|
+
|
|
56
67
|
export function wordWrap(text, maxWidth) {
|
|
57
68
|
if (maxWidth <= 0) return []
|
|
58
69
|
if (!text) return ['']
|
|
59
70
|
|
|
60
71
|
const lines = []
|
|
72
|
+
let carry = ''
|
|
61
73
|
|
|
62
74
|
for (const rawLine of text.split('\n')) {
|
|
63
75
|
if (rawLine.length === 0) {
|
|
64
|
-
lines.push('')
|
|
76
|
+
lines.push(carry || '')
|
|
65
77
|
continue
|
|
66
78
|
}
|
|
67
79
|
|
|
68
|
-
|
|
69
|
-
|
|
80
|
+
const prefixed = carry ? carry + rawLine : rawLine
|
|
81
|
+
|
|
82
|
+
if (measureText(prefixed) <= maxWidth) {
|
|
83
|
+
lines.push(prefixed)
|
|
84
|
+
carry = extractTrailingAnsi(prefixed)
|
|
70
85
|
continue
|
|
71
86
|
}
|
|
72
87
|
|
|
73
|
-
const words =
|
|
88
|
+
const words = prefixed.split(/\s+/)
|
|
74
89
|
let line = ''
|
|
75
90
|
let lineWidth = 0
|
|
76
91
|
|
|
@@ -84,8 +99,9 @@ export function wordWrap(text, maxWidth) {
|
|
|
84
99
|
} else if (lineWidth === 0 && ww > maxWidth) {
|
|
85
100
|
for (const { chunk, width } of visibleChars(word)) {
|
|
86
101
|
if (lineWidth + width > maxWidth) {
|
|
102
|
+
carry = extractTrailingAnsi(line)
|
|
87
103
|
lines.push(line)
|
|
88
|
-
line =
|
|
104
|
+
line = carry
|
|
89
105
|
lineWidth = 0
|
|
90
106
|
}
|
|
91
107
|
line += chunk
|
|
@@ -95,26 +111,32 @@ export function wordWrap(text, maxWidth) {
|
|
|
95
111
|
line += ' ' + word
|
|
96
112
|
lineWidth += 1 + ww
|
|
97
113
|
} else if (ww > maxWidth) {
|
|
98
|
-
if (line)
|
|
99
|
-
|
|
100
|
-
|
|
114
|
+
if (line) {
|
|
115
|
+
carry = extractTrailingAnsi(line)
|
|
116
|
+
lines.push(line)
|
|
117
|
+
line = carry
|
|
118
|
+
lineWidth = 0
|
|
119
|
+
}
|
|
101
120
|
for (const { chunk, width } of visibleChars(word)) {
|
|
102
121
|
if (lineWidth + width > maxWidth) {
|
|
122
|
+
carry = extractTrailingAnsi(line)
|
|
103
123
|
lines.push(line)
|
|
104
|
-
line =
|
|
124
|
+
line = carry
|
|
105
125
|
lineWidth = 0
|
|
106
126
|
}
|
|
107
127
|
line += chunk
|
|
108
128
|
lineWidth += width
|
|
109
129
|
}
|
|
110
130
|
} else {
|
|
131
|
+
carry = extractTrailingAnsi(line)
|
|
111
132
|
lines.push(line)
|
|
112
|
-
line = word
|
|
133
|
+
line = carry + word
|
|
113
134
|
lineWidth = ww
|
|
114
135
|
}
|
|
115
136
|
}
|
|
116
137
|
|
|
117
138
|
lines.push(line)
|
|
139
|
+
carry = extractTrailingAnsi(line)
|
|
118
140
|
}
|
|
119
141
|
|
|
120
142
|
return lines
|