@trendr/core 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -7
- package/index.js +2 -0
- package/package.json +3 -1
- package/src/list.js +8 -6
- package/src/modal.js +2 -2
- package/src/pick-list.js +204 -0
- package/src/renderer.js +29 -6
- package/src/table.js +42 -24
- package/src/tabs.js +12 -5
package/README.md
CHANGED
|
@@ -6,14 +6,18 @@
|
|
|
6
6
|
</pre>
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
JSX components, signals, per-cell diffing and flexbox without React and Yoga. Terminals are character grids, not DOM trees. Why reconcile a virtual DOM to write escape sequences?
|
|
9
|
+
JSX components, signals, per-cell diffing and flexbox without React and [Yoga](https://github.com/facebook/yoga/issues/1859). Terminals are character grids, not DOM trees. Why reconcile a virtual DOM to write escape sequences?
|
|
10
10
|
|
|
11
11
|
4-16x faster frame times and 580x less I/O per render than popular TUI frameworks. No dependencies. [benchmarks](bench/README.md)
|
|
12
12
|
|
|
13
|
-
https://github.com/user-attachments/assets/
|
|
13
|
+
https://github.com/user-attachments/assets/6e84bb4b-a99e-46f2-a235-1ee4be62c0ae
|
|
14
14
|
|
|
15
15
|
## Usage
|
|
16
16
|
|
|
17
|
+
```bash
|
|
18
|
+
npm i @trendr/core
|
|
19
|
+
```
|
|
20
|
+
|
|
17
21
|
Requires esbuild (or similar) for JSX transformation.
|
|
18
22
|
|
|
19
23
|
```json
|
|
@@ -208,10 +212,11 @@ useHotkey('alt+enter', () => submit(), { when: () => isFocused })
|
|
|
208
212
|
|
|
209
213
|
### useLayout
|
|
210
214
|
|
|
211
|
-
Returns the component's computed layout rectangle.
|
|
215
|
+
Returns a live reference to the component's computed layout rectangle. Values update in-place each frame, including after terminal resize.
|
|
212
216
|
|
|
213
217
|
```jsx
|
|
214
|
-
const
|
|
218
|
+
const layout = useLayout()
|
|
219
|
+
// layout.x, layout.y, layout.width, layout.height, layout.contentHeight
|
|
215
220
|
```
|
|
216
221
|
|
|
217
222
|
### useResize
|
|
@@ -286,12 +291,14 @@ const stream = useStdout() // the output stream (process.stdout or custom)
|
|
|
286
291
|
Forces a full repaint. Useful after spawning an external process (e.g. `$EDITOR`).
|
|
287
292
|
|
|
288
293
|
```jsx
|
|
294
|
+
import { useRepaint, useStdout, exitAltScreen, showCursor, altScreen, hideCursor } from '@trendr/core'
|
|
295
|
+
|
|
289
296
|
const repaint = useRepaint()
|
|
297
|
+
const stdout = useStdout()
|
|
290
298
|
|
|
291
|
-
|
|
292
|
-
stdout.write('\x1b[?1049l\x1b[?25h')
|
|
299
|
+
stdout.write(exitAltScreen + showCursor)
|
|
293
300
|
execSync(`${process.env.EDITOR} ${file}`, { stdio: 'inherit' })
|
|
294
|
-
stdout.write(
|
|
301
|
+
stdout.write(altScreen + hideCursor)
|
|
295
302
|
repaint()
|
|
296
303
|
```
|
|
297
304
|
|
|
@@ -437,6 +444,8 @@ Scrollable list with keyboard navigation.
|
|
|
437
444
|
onSelect={setIndex}
|
|
438
445
|
focused={fm.is('list')}
|
|
439
446
|
scrollbar={true} // default false
|
|
447
|
+
scrolloff={2} // items of margin from edges when scrolling (default 2)
|
|
448
|
+
interactive={true} // handle keyboard input (default: same as focused)
|
|
440
449
|
header={<text>title</text>}
|
|
441
450
|
headerHeight={1} // default 1, rows the header occupies
|
|
442
451
|
renderItem={(item, { selected, index, focused }) => (
|
|
@@ -463,6 +472,47 @@ Scrollable list with keyboard navigation.
|
|
|
463
472
|
|
|
464
473
|
Keys: j/k or up/down, g/G for top/bottom, ctrl-d/u half page, ctrl-f/b full page, pageup/pagedown.
|
|
465
474
|
|
|
475
|
+
### PickList
|
|
476
|
+
|
|
477
|
+
Used in [pick-list](examples/pick-list.jsx)
|
|
478
|
+
|
|
479
|
+
Filterable list with live search. Text input at the top filters a scrollable list below. Navigate the list with up/down or ctrl-n/ctrl-p while typing.
|
|
480
|
+
|
|
481
|
+
```jsx
|
|
482
|
+
<PickList
|
|
483
|
+
items={data}
|
|
484
|
+
focused={fm.is('search')}
|
|
485
|
+
placeholder="search..."
|
|
486
|
+
onSelect={item => {}} // Enter on highlighted item
|
|
487
|
+
onCancel={() => {}} // Escape
|
|
488
|
+
onChange={query => {}} // every keystroke in the filter
|
|
489
|
+
clearOnSelect={false} // reset filter on select (default false)
|
|
490
|
+
scrollbar={true} // default false
|
|
491
|
+
scrolloff={2} // items of margin from edges (default 2, inherited from List)
|
|
492
|
+
gap={1} // space between input and list (default 0)
|
|
493
|
+
filter={(query, item) => {}} // custom filter (default: case-insensitive includes)
|
|
494
|
+
/>
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
Multi-row items with `renderItem`, `itemHeight`, and `itemGap`:
|
|
498
|
+
|
|
499
|
+
```jsx
|
|
500
|
+
<PickList
|
|
501
|
+
items={packages}
|
|
502
|
+
itemHeight={3}
|
|
503
|
+
itemGap={1}
|
|
504
|
+
renderItem={(pkg, { selected, focused }) => (
|
|
505
|
+
<box style={{ flexDirection: 'column', bg: selected ? accent : null, paddingX: 1 }}>
|
|
506
|
+
<text style={{ bold: true }}>{pkg.name}</text>
|
|
507
|
+
<text style={{ color: 'gray' }}>{pkg.desc}</text>
|
|
508
|
+
<text style={{ color: 'yellow' }}>{pkg.downloads}</text>
|
|
509
|
+
</box>
|
|
510
|
+
)}
|
|
511
|
+
/>
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
Keys: type to filter, up/down or ctrl-n/ctrl-p to navigate, enter to select, escape to cancel. All bash-style editing keys work (ctrl-a/e/u/k/w).
|
|
515
|
+
|
|
466
516
|
### Table
|
|
467
517
|
|
|
468
518
|
Used in [components](examples/components.jsx), [custom-table](examples/custom-table.jsx)
|
package/index.js
CHANGED
|
@@ -16,6 +16,7 @@ export { Modal } from './src/modal.js'
|
|
|
16
16
|
export { Radio } from './src/radio.js'
|
|
17
17
|
export { Button } from './src/button.js'
|
|
18
18
|
export { ScrollableText } from './src/scrollable-text.js'
|
|
19
|
+
export { PickList } from './src/pick-list.js'
|
|
19
20
|
export { ScrollBox } from './src/scroll-box.js'
|
|
20
21
|
export { SplitPane } from './src/split-pane.js'
|
|
21
22
|
export { Task } from './src/task.js'
|
|
@@ -24,5 +25,6 @@ export { useFocus } from './src/focus.js'
|
|
|
24
25
|
export { useHotkey } from './src/hotkey.js'
|
|
25
26
|
export { useToast } from './src/toast.js'
|
|
26
27
|
export { Fragment } from './src/element.js'
|
|
28
|
+
export { altScreen, exitAltScreen, showCursor, hideCursor, clearScreen, clearLine } from './src/ansi.js'
|
|
27
29
|
export { animated, useAnimated, spring, ease, decay } from './src/animation.js'
|
|
28
30
|
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.2",
|
|
4
4
|
"description": "direct-mode TUI renderer with JSX, signals, and per-cell diffing",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -43,6 +43,8 @@
|
|
|
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
|
+
"pick-list": "node esbuild.config.js && node dist/pick-list.js",
|
|
47
|
+
"table-demo": "node esbuild.config.js && node dist/table-demo.js",
|
|
46
48
|
"demo": "node esbuild.config.js && node demo/server.js",
|
|
47
49
|
"demo:build": "node esbuild.config.js"
|
|
48
50
|
},
|
package/src/list.js
CHANGED
|
@@ -2,9 +2,10 @@ 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 List({ items, selected: selectedProp, onSelect, renderItem, header, headerHeight = 1, focused = true, itemHeight = 1, scrollbar = false, stickyHeader = false, gap = 0 }) {
|
|
5
|
+
export function List({ items, selected: selectedProp, onSelect, renderItem, header, headerHeight = 1, focused = true, interactive = focused, itemHeight = 1, scrollbar = false, stickyHeader = false, gap = 0, scrolloff = 2 }) {
|
|
6
6
|
const { accent = 'cyan' } = useTheme()
|
|
7
7
|
const [selectedInternal, setSelectedInternal] = createSignal(0)
|
|
8
|
+
const [scrollState, setScrollState] = createSignal(0)
|
|
8
9
|
const layout = useLayout()
|
|
9
10
|
|
|
10
11
|
const selected = selectedProp ?? selectedInternal()
|
|
@@ -29,7 +30,7 @@ export function List({ items, selected: selectedProp, onSelect, renderItem, head
|
|
|
29
30
|
const maxOffset = Math.max(0, scrollContentH - scrollViewH)
|
|
30
31
|
|
|
31
32
|
useInput(({ key, ctrl }) => {
|
|
32
|
-
if (!
|
|
33
|
+
if (!interactive) return
|
|
33
34
|
|
|
34
35
|
const len = items.length
|
|
35
36
|
if (len === 0) return
|
|
@@ -93,11 +94,12 @@ export function List({ items, selected: selectedProp, onSelect, renderItem, head
|
|
|
93
94
|
|
|
94
95
|
let scrollOffset = 0
|
|
95
96
|
if (scrollViewH > 0 && scrollContentH > scrollViewH) {
|
|
96
|
-
const
|
|
97
|
-
scrollOffset =
|
|
98
|
-
if (itemTop < scrollOffset) scrollOffset = itemTop
|
|
99
|
-
if (itemBottom > scrollOffset + scrollViewH) scrollOffset = itemBottom - scrollViewH
|
|
97
|
+
const margin = scrolloff * avgH
|
|
98
|
+
scrollOffset = scrollState()
|
|
99
|
+
if (itemTop - margin < scrollOffset) scrollOffset = itemTop - margin
|
|
100
|
+
if (itemBottom + margin > scrollOffset + scrollViewH) scrollOffset = itemBottom + margin - scrollViewH
|
|
100
101
|
scrollOffset = Math.max(0, Math.min(maxOffset, Math.round(scrollOffset)))
|
|
102
|
+
if (scrollOffset !== scrollState()) setScrollState(scrollOffset)
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
const scrollChildren = []
|
package/src/modal.js
CHANGED
|
@@ -2,7 +2,7 @@ import { jsx } from '../jsx-runtime.js'
|
|
|
2
2
|
import { useInput, useTheme } from './hooks.js'
|
|
3
3
|
import { registerOverlay } from './renderer.js'
|
|
4
4
|
|
|
5
|
-
export function Modal({ open, onClose, title, children, width: w = 40 }) {
|
|
5
|
+
export function Modal({ open, onClose, title, children, width: w = 40, border = 'round' }) {
|
|
6
6
|
const { accent = 'cyan' } = useTheme()
|
|
7
7
|
|
|
8
8
|
useInput((event) => {
|
|
@@ -18,7 +18,7 @@ export function Modal({ open, onClose, title, children, width: w = 40 }) {
|
|
|
18
18
|
const content = jsx('box', {
|
|
19
19
|
style: {
|
|
20
20
|
width: w,
|
|
21
|
-
border
|
|
21
|
+
border,
|
|
22
22
|
borderColor: accent,
|
|
23
23
|
flexDirection: 'column',
|
|
24
24
|
paddingX: 1,
|
package/src/pick-list.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { jsx, jsxs } from '../jsx-runtime.js'
|
|
2
|
+
import { createSignal } from './signal.js'
|
|
3
|
+
import { useInput, useLayout, useTheme, useCursor } from './hooks.js'
|
|
4
|
+
import { List } from './list.js'
|
|
5
|
+
|
|
6
|
+
export function PickList({ items, onSelect, onCancel, onChange, focused = true, placeholder = 'search...', filter: filterFn, renderItem, maxVisible = 10, scrollbar = false, scrolloff = 0, itemHeight = 1, itemGap = 0, gap = 0, clearOnSelect = false, style: userStyle, cursor: cursorProp }) {
|
|
7
|
+
const { accent = 'cyan' } = useTheme()
|
|
8
|
+
const defaults = {
|
|
9
|
+
borderColor: accent,
|
|
10
|
+
cursorBg: accent,
|
|
11
|
+
cursorTextColor: 'black',
|
|
12
|
+
color: null,
|
|
13
|
+
}
|
|
14
|
+
const s = { ...defaults, ...userStyle }
|
|
15
|
+
|
|
16
|
+
const [query, setQuery] = createSignal('')
|
|
17
|
+
const [textCursor, setTextCursor] = createSignal(0)
|
|
18
|
+
const [listCursor, setListCursor] = createSignal(0)
|
|
19
|
+
const layout = useLayout()
|
|
20
|
+
const { cursorStyle, reset: resetBlink } = useCursor(cursorProp, focused)
|
|
21
|
+
|
|
22
|
+
const defaultFilter = (q, item) => {
|
|
23
|
+
const label = typeof item === 'string' ? item : (item.label ?? item.name ?? '')
|
|
24
|
+
return label.toLowerCase().includes(q.toLowerCase())
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const match = filterFn ?? defaultFilter
|
|
28
|
+
|
|
29
|
+
const q = query()
|
|
30
|
+
const filtered = q ? items.filter(item => match(q, item)) : items
|
|
31
|
+
|
|
32
|
+
let cursor = listCursor()
|
|
33
|
+
if (cursor >= filtered.length) cursor = Math.max(0, filtered.length - 1)
|
|
34
|
+
if (cursor !== listCursor()) setListCursor(cursor)
|
|
35
|
+
|
|
36
|
+
function updateText(v, c) {
|
|
37
|
+
setQuery(v)
|
|
38
|
+
setTextCursor(c)
|
|
39
|
+
setListCursor(0)
|
|
40
|
+
if (onChange) onChange(v)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
useInput((event) => {
|
|
44
|
+
if (!focused) return
|
|
45
|
+
|
|
46
|
+
const { key, raw, ctrl } = event
|
|
47
|
+
const len = filtered.length
|
|
48
|
+
|
|
49
|
+
if (key === 'up' || (ctrl && raw === '\x10')) {
|
|
50
|
+
if (len > 0) setListCursor(c => Math.max(0, c - 1))
|
|
51
|
+
event.stopPropagation()
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
if (key === 'down' || (ctrl && raw === '\x0e')) {
|
|
55
|
+
if (len > 0) setListCursor(c => Math.min(len - 1, c + 1))
|
|
56
|
+
event.stopPropagation()
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
if (ctrl && key === 'u') { if (len > 0) setListCursor(c => Math.max(0, c - 5)); event.stopPropagation(); return }
|
|
60
|
+
if (ctrl && key === 'd') { if (len > 0) setListCursor(c => Math.min(len - 1, c + 5)); event.stopPropagation(); return }
|
|
61
|
+
if (ctrl && key === 'b') { if (len > 0) setListCursor(c => Math.max(0, c - 10)); event.stopPropagation(); return }
|
|
62
|
+
if (ctrl && key === 'f') { if (len > 0) setListCursor(c => Math.min(len - 1, c + 10)); event.stopPropagation(); return }
|
|
63
|
+
if (key === 'pageup') { if (len > 0) setListCursor(c => Math.max(0, c - 10)); event.stopPropagation(); return }
|
|
64
|
+
if (key === 'pagedown') { if (len > 0) setListCursor(c => Math.min(len - 1, c + 10)); event.stopPropagation(); return }
|
|
65
|
+
|
|
66
|
+
if (key === 'return') {
|
|
67
|
+
if (filtered.length > 0 && onSelect) {
|
|
68
|
+
onSelect(filtered[cursor])
|
|
69
|
+
if (clearOnSelect) updateText('', 0)
|
|
70
|
+
event.stopPropagation()
|
|
71
|
+
}
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (key === 'escape') {
|
|
76
|
+
if (onCancel) {
|
|
77
|
+
onCancel()
|
|
78
|
+
event.stopPropagation()
|
|
79
|
+
}
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
resetBlink()
|
|
84
|
+
const v = query()
|
|
85
|
+
const c = textCursor()
|
|
86
|
+
|
|
87
|
+
if (key === 'backspace') {
|
|
88
|
+
if (c > 0) updateText(v.slice(0, c - 1) + v.slice(c), c - 1)
|
|
89
|
+
event.stopPropagation()
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (key === 'delete') {
|
|
94
|
+
if (c < v.length) updateText(v.slice(0, c) + v.slice(c + 1), c)
|
|
95
|
+
event.stopPropagation()
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (key === 'left') { setTextCursor(Math.max(0, c - 1)); event.stopPropagation(); return }
|
|
100
|
+
if (key === 'right') { setTextCursor(Math.min(v.length, c + 1)); event.stopPropagation(); return }
|
|
101
|
+
|
|
102
|
+
if (key === 'home' || (ctrl && raw === '\x01')) { setTextCursor(0); event.stopPropagation(); return }
|
|
103
|
+
if (key === 'end' || (ctrl && raw === '\x05')) { setTextCursor(v.length); event.stopPropagation(); return }
|
|
104
|
+
|
|
105
|
+
if (ctrl && raw === '\x15') { updateText(v.slice(c), 0); event.stopPropagation(); return }
|
|
106
|
+
if (ctrl && raw === '\x0b') { updateText(v.slice(0, c), c); event.stopPropagation(); return }
|
|
107
|
+
|
|
108
|
+
if (ctrl && raw === '\x17') {
|
|
109
|
+
const before = v.slice(0, c)
|
|
110
|
+
const after = v.slice(c)
|
|
111
|
+
const trimmed = before.replace(/\S+\s*$/, '')
|
|
112
|
+
updateText(trimmed + after, trimmed.length)
|
|
113
|
+
event.stopPropagation()
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!ctrl && raw.length === 1 && raw >= ' ') {
|
|
118
|
+
updateText(v.slice(0, c) + raw + v.slice(c), c + raw.length)
|
|
119
|
+
event.stopPropagation()
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// text input rendering
|
|
124
|
+
const v = query()
|
|
125
|
+
const tc = textCursor()
|
|
126
|
+
const w = layout.width || 0
|
|
127
|
+
const cs = cursorStyle()
|
|
128
|
+
|
|
129
|
+
let inputEl
|
|
130
|
+
if (!v && placeholder && !focused) {
|
|
131
|
+
inputEl = jsx('text', { style: { color: 'gray' }, children: placeholder })
|
|
132
|
+
} else if (!v && placeholder && focused) {
|
|
133
|
+
inputEl = jsxs('box', {
|
|
134
|
+
style: { flexDirection: 'row', height: 1, minHeight: 1 },
|
|
135
|
+
children: [
|
|
136
|
+
jsx('text', { style: cs ? { ...cs, color: cs.color ?? 'gray' } : { inverse: true, color: 'gray' }, children: placeholder[0] }),
|
|
137
|
+
placeholder.length > 1 && jsx('text', { style: { color: 'gray' }, children: placeholder.slice(1) }),
|
|
138
|
+
],
|
|
139
|
+
})
|
|
140
|
+
} else {
|
|
141
|
+
const contentWidth = v.length + 1
|
|
142
|
+
const needsScroll = w > 0 && contentWidth > w
|
|
143
|
+
|
|
144
|
+
if (!needsScroll) {
|
|
145
|
+
const cursorChar = v[tc] || ' '
|
|
146
|
+
inputEl = jsxs('box', {
|
|
147
|
+
style: { flexDirection: 'row', height: 1, minHeight: 1 },
|
|
148
|
+
children: [
|
|
149
|
+
v.slice(0, tc) && jsx('text', { children: v.slice(0, tc) }),
|
|
150
|
+
jsx('text', { style: cs ?? {}, children: cursorChar }),
|
|
151
|
+
v.slice(tc + 1) && jsx('text', { children: v.slice(tc + 1) }),
|
|
152
|
+
],
|
|
153
|
+
})
|
|
154
|
+
} else {
|
|
155
|
+
let scrollStart = 0
|
|
156
|
+
if (tc >= w) scrollStart = tc - w + 1
|
|
157
|
+
const visible = v.slice(scrollStart, scrollStart + w)
|
|
158
|
+
const cursorInView = tc - scrollStart
|
|
159
|
+
const before = visible.slice(0, cursorInView)
|
|
160
|
+
const cursorChar = visible[cursorInView] || ' '
|
|
161
|
+
const after = visible.slice(cursorInView + 1)
|
|
162
|
+
|
|
163
|
+
inputEl = jsxs('box', {
|
|
164
|
+
style: { flexDirection: 'row', height: 1, minHeight: 1 },
|
|
165
|
+
children: [
|
|
166
|
+
before && jsx('text', { children: before }),
|
|
167
|
+
jsx('text', { style: cs ?? {}, children: cursorChar }),
|
|
168
|
+
after && jsx('text', { children: after }),
|
|
169
|
+
],
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const defaultRenderItem = (item, { selected: isCursor, focused: isFocused }) => {
|
|
175
|
+
const label = typeof item === 'string' ? item : (item.label ?? item.name ?? String(item))
|
|
176
|
+
return jsx('box', {
|
|
177
|
+
style: { bg: isCursor ? (isFocused ? s.cursorBg : 'gray') : null },
|
|
178
|
+
children: jsx('text', {
|
|
179
|
+
style: { color: isCursor ? s.cursorTextColor : s.color },
|
|
180
|
+
children: ` ${label}`,
|
|
181
|
+
}),
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const listRenderItem = renderItem ?? defaultRenderItem
|
|
186
|
+
|
|
187
|
+
const list = jsx(List, {
|
|
188
|
+
items: filtered,
|
|
189
|
+
selected: cursor,
|
|
190
|
+
onSelect: setListCursor,
|
|
191
|
+
focused,
|
|
192
|
+
interactive: false,
|
|
193
|
+
itemHeight,
|
|
194
|
+
gap: itemGap,
|
|
195
|
+
scrollbar,
|
|
196
|
+
scrolloff,
|
|
197
|
+
renderItem: listRenderItem,
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
return jsxs('box', {
|
|
201
|
+
style: { flexDirection: 'column', flexGrow: 1, gap },
|
|
202
|
+
children: [inputEl, list],
|
|
203
|
+
})
|
|
204
|
+
}
|
package/src/renderer.js
CHANGED
|
@@ -336,16 +336,39 @@ function paintTree(node, buf, clip, offset, prevBuf) {
|
|
|
336
336
|
}
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
-
function extractText(node) {
|
|
339
|
+
function extractText(node, parentCtx) {
|
|
340
340
|
if (node == null || node === true || node === false) return ''
|
|
341
341
|
if (typeof node === 'string') return node
|
|
342
342
|
if (typeof node === 'number') return String(node)
|
|
343
|
+
|
|
343
344
|
const children = node.props?.children
|
|
344
345
|
if (children == null || children === true || children === false) return ''
|
|
345
|
-
if (typeof children === 'string') return children
|
|
346
|
-
if (typeof children === 'number') return String(children)
|
|
347
|
-
|
|
348
|
-
|
|
346
|
+
if (typeof children === 'string' && !node.props?.style) return children
|
|
347
|
+
if (typeof children === 'number' && !node.props?.style) return String(children)
|
|
348
|
+
|
|
349
|
+
const style = node.props?.style
|
|
350
|
+
const ownAttrs = style ? resolveAttrs(style) : 0
|
|
351
|
+
const hasOwnStyle = style && (style.color != null || style.bg != null || ownAttrs)
|
|
352
|
+
|
|
353
|
+
const myCtx = hasOwnStyle ? {
|
|
354
|
+
fg: style.color ?? parentCtx?.fg ?? null,
|
|
355
|
+
bg: style.bg ?? parentCtx?.bg ?? null,
|
|
356
|
+
attrs: ownAttrs || parentCtx?.attrs || 0,
|
|
357
|
+
} : (parentCtx || null)
|
|
358
|
+
|
|
359
|
+
let inner
|
|
360
|
+
if (typeof children === 'string') inner = children
|
|
361
|
+
else if (typeof children === 'number') inner = String(children)
|
|
362
|
+
else if (Array.isArray(children)) inner = children.map(c => extractText(c, myCtx)).join('')
|
|
363
|
+
else inner = ''
|
|
364
|
+
|
|
365
|
+
if (parentCtx !== undefined && hasOwnStyle) {
|
|
366
|
+
const prefix = ansi.sgr(myCtx.fg, myCtx.bg, myCtx.attrs)
|
|
367
|
+
const suffix = parentCtx ? ansi.sgr(parentCtx.fg, parentCtx.bg, parentCtx.attrs) : ansi.sgrReset
|
|
368
|
+
return prefix + inner + suffix
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return inner
|
|
349
372
|
}
|
|
350
373
|
|
|
351
374
|
function flattenChildren(children) {
|
|
@@ -747,5 +770,5 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
747
770
|
|
|
748
771
|
ctx.repaint = repaint
|
|
749
772
|
|
|
750
|
-
return { unmount, repaint }
|
|
773
|
+
return { unmount, repaint, getBuffer: () => prev }
|
|
751
774
|
}
|
package/src/table.js
CHANGED
|
@@ -4,7 +4,7 @@ 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 }) {
|
|
7
|
+
export function Table({ columns, data, selected, onSelect, focused = true, separator = false, separatorChars, renderItem, scrollbar, stickyHeader = false, gap = 0, itemHeight = 1, scrolloff = 2 }) {
|
|
8
8
|
const { accent = 'cyan' } = useTheme()
|
|
9
9
|
|
|
10
10
|
const headerRow = jsxs('box', {
|
|
@@ -15,9 +15,7 @@ export function Table({ columns, data, selected, onSelect, focused = true, separ
|
|
|
15
15
|
style: {
|
|
16
16
|
width: col.width,
|
|
17
17
|
flexGrow: col.flexGrow,
|
|
18
|
-
color: 'gray',
|
|
19
18
|
dim: true,
|
|
20
|
-
bold: true,
|
|
21
19
|
overflow: 'truncate',
|
|
22
20
|
paddingX: col.paddingX ?? 1,
|
|
23
21
|
},
|
|
@@ -26,24 +24,22 @@ export function Table({ columns, data, selected, onSelect, focused = true, separ
|
|
|
26
24
|
),
|
|
27
25
|
})
|
|
28
26
|
|
|
27
|
+
const sepRow = separator
|
|
28
|
+
? (() => {
|
|
29
|
+
const s = { ...DEFAULT_SEP, ...separatorChars }
|
|
30
|
+
return jsxs('box', {
|
|
31
|
+
style: { flexDirection: 'row' },
|
|
32
|
+
children: [
|
|
33
|
+
jsx('text', { style: { dim: true }, children: s.left }),
|
|
34
|
+
jsx('text', { style: { dim: true, flexGrow: 1, overflow: 'nowrap' }, children: s.fill.repeat(1000) }),
|
|
35
|
+
jsx('text', { style: { dim: true }, children: s.right }),
|
|
36
|
+
],
|
|
37
|
+
})
|
|
38
|
+
})()
|
|
39
|
+
: null
|
|
40
|
+
|
|
29
41
|
const header = separator
|
|
30
|
-
? jsxs('box', {
|
|
31
|
-
style: { flexDirection: 'column' },
|
|
32
|
-
children: [
|
|
33
|
-
headerRow,
|
|
34
|
-
(() => {
|
|
35
|
-
const s = { ...DEFAULT_SEP, ...separatorChars }
|
|
36
|
-
return jsxs('box', {
|
|
37
|
-
style: { flexDirection: 'row' },
|
|
38
|
-
children: [
|
|
39
|
-
jsx('text', { style: { color: 'gray', dim: true }, children: s.left }),
|
|
40
|
-
jsx('text', { style: { color: 'gray', dim: true, flexGrow: 1, overflow: 'nowrap' }, children: s.fill.repeat(200) }),
|
|
41
|
-
jsx('text', { style: { color: 'gray', dim: true }, children: s.right }),
|
|
42
|
-
],
|
|
43
|
-
})
|
|
44
|
-
})(),
|
|
45
|
-
],
|
|
46
|
-
})
|
|
42
|
+
? jsxs('box', { style: { flexDirection: 'column' }, children: [headerRow, sepRow] })
|
|
47
43
|
: headerRow
|
|
48
44
|
|
|
49
45
|
const defaultRenderItem = (row, { selected: isSel }) =>
|
|
@@ -64,15 +60,37 @@ export function Table({ columns, data, selected, onSelect, focused = true, separ
|
|
|
64
60
|
),
|
|
65
61
|
})
|
|
66
62
|
|
|
67
|
-
|
|
63
|
+
const list = jsx(List, {
|
|
68
64
|
items: data,
|
|
69
65
|
selected,
|
|
70
66
|
onSelect,
|
|
71
67
|
focused,
|
|
72
|
-
header,
|
|
73
|
-
headerHeight: separator ? 2 : 1,
|
|
74
68
|
scrollbar,
|
|
75
|
-
|
|
69
|
+
gap,
|
|
70
|
+
itemHeight,
|
|
71
|
+
scrolloff,
|
|
76
72
|
renderItem: renderItem || defaultRenderItem,
|
|
77
73
|
})
|
|
74
|
+
|
|
75
|
+
if (!stickyHeader) {
|
|
76
|
+
return jsx(List, {
|
|
77
|
+
items: data,
|
|
78
|
+
selected,
|
|
79
|
+
onSelect,
|
|
80
|
+
focused,
|
|
81
|
+
header,
|
|
82
|
+
headerHeight: separator ? 2 : 1,
|
|
83
|
+
scrollbar,
|
|
84
|
+
stickyHeader: true,
|
|
85
|
+
gap,
|
|
86
|
+
itemHeight,
|
|
87
|
+
scrolloff,
|
|
88
|
+
renderItem: renderItem || defaultRenderItem,
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return jsxs('box', {
|
|
93
|
+
style: { flexDirection: 'column', flexGrow: 1 },
|
|
94
|
+
children: [header, list],
|
|
95
|
+
})
|
|
78
96
|
}
|
package/src/tabs.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { jsx, jsxs } from '../jsx-runtime.js'
|
|
2
|
-
import { useInput } from './hooks.js'
|
|
2
|
+
import { useInput, useTheme } from './hooks.js'
|
|
3
3
|
|
|
4
4
|
export function Tabs({ items, selected, onSelect, focused = true }) {
|
|
5
|
+
const { accent = 'cyan' } = useTheme()
|
|
6
|
+
|
|
5
7
|
useInput(({ key }) => {
|
|
6
8
|
if (!focused) return
|
|
7
9
|
|
|
@@ -17,10 +19,15 @@ export function Tabs({ items, selected, onSelect, focused = true }) {
|
|
|
17
19
|
|
|
18
20
|
const children = items.map(item => {
|
|
19
21
|
const isSelected = item === selected
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
})
|
|
22
|
+
let style
|
|
23
|
+
if (isSelected && focused) {
|
|
24
|
+
style = { bg: accent, color: 'black', bold: true }
|
|
25
|
+
} else if (isSelected) {
|
|
26
|
+
style = { inverse: true, bold: true }
|
|
27
|
+
} else {
|
|
28
|
+
style = { color: 'gray' }
|
|
29
|
+
}
|
|
30
|
+
return jsx('text', { style, children: ` ${item} ` })
|
|
24
31
|
})
|
|
25
32
|
|
|
26
33
|
return jsxs('box', {
|