@trendr/core 0.1.0
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/LICENSE +21 -0
- package/README.md +526 -0
- package/index.js +22 -0
- package/jsx-runtime.js +9 -0
- package/package.json +35 -0
- package/src/ansi.js +149 -0
- package/src/buffer.js +101 -0
- package/src/button.js +26 -0
- package/src/checkbox.js +28 -0
- package/src/components.js +13 -0
- package/src/diff.js +51 -0
- package/src/element.js +81 -0
- package/src/focus.js +135 -0
- package/src/hooks.js +63 -0
- package/src/hotkey.js +35 -0
- package/src/input.js +143 -0
- package/src/layout.js +312 -0
- package/src/list.js +46 -0
- package/src/modal.js +44 -0
- package/src/progress.js +21 -0
- package/src/radio.js +52 -0
- package/src/renderer.js +478 -0
- package/src/scheduler.js +55 -0
- package/src/scrollable-text.js +75 -0
- package/src/select.js +151 -0
- package/src/signal.js +159 -0
- package/src/spinner.js +21 -0
- package/src/table.js +51 -0
- package/src/tabs.js +30 -0
- package/src/text-area.js +286 -0
- package/src/text-input.js +131 -0
- package/src/toast.js +61 -0
- package/src/wrap.js +133 -0
package/src/ansi.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
const ESC = '\x1b['
|
|
2
|
+
|
|
3
|
+
export const moveTo = (row, col) => `${ESC}${row};${col}H`
|
|
4
|
+
export const moveUp = (n = 1) => `${ESC}${n}A`
|
|
5
|
+
export const moveDown = (n = 1) => `${ESC}${n}B`
|
|
6
|
+
export const moveRight = (n = 1) => `${ESC}${n}C`
|
|
7
|
+
export const moveLeft = (n = 1) => `${ESC}${n}D`
|
|
8
|
+
|
|
9
|
+
export const hideCursor = `${ESC}?25l`
|
|
10
|
+
export const showCursor = `${ESC}?25h`
|
|
11
|
+
export const clearScreen = `${ESC}2J`
|
|
12
|
+
export const clearLine = `${ESC}2K`
|
|
13
|
+
export const altScreen = `${ESC}?1049h`
|
|
14
|
+
export const exitAltScreen = `${ESC}?1049l`
|
|
15
|
+
export const sgrReset = `${ESC}0m`
|
|
16
|
+
|
|
17
|
+
export const setTitle = (title) => `\x1b]2;${title}\x07`
|
|
18
|
+
|
|
19
|
+
export const BOLD = 1
|
|
20
|
+
export const DIM = 2
|
|
21
|
+
export const ITALIC = 4
|
|
22
|
+
export const UNDERLINE = 8
|
|
23
|
+
export const INVERSE = 16
|
|
24
|
+
export const STRIKETHROUGH = 32
|
|
25
|
+
|
|
26
|
+
const ATTR_CODES = [
|
|
27
|
+
[BOLD, '1'],
|
|
28
|
+
[DIM, '2'],
|
|
29
|
+
[ITALIC, '3'],
|
|
30
|
+
[UNDERLINE, '4'],
|
|
31
|
+
[INVERSE, '7'],
|
|
32
|
+
[STRIKETHROUGH, '9'],
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
const NAMED_COLORS = {
|
|
36
|
+
black: 0, red: 1, green: 2, yellow: 3,
|
|
37
|
+
blue: 4, magenta: 5, cyan: 6, white: 7,
|
|
38
|
+
gray: 8, grey: 8,
|
|
39
|
+
brightRed: 9, brightGreen: 10, brightYellow: 11,
|
|
40
|
+
brightBlue: 12, brightMagenta: 13, brightCyan: 14, brightWhite: 15,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseColor(color, offset) {
|
|
44
|
+
if (color == null) return null
|
|
45
|
+
if (typeof color === 'number') return `${offset};5;${color}`
|
|
46
|
+
if (color in NAMED_COLORS) return `${offset};5;${NAMED_COLORS[color]}`
|
|
47
|
+
if (color.startsWith('#') && color.length === 7) {
|
|
48
|
+
const r = parseInt(color.slice(1, 3), 16)
|
|
49
|
+
const g = parseInt(color.slice(3, 5), 16)
|
|
50
|
+
const b = parseInt(color.slice(5, 7), 16)
|
|
51
|
+
return `${offset};2;${r};${g};${b}`
|
|
52
|
+
}
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// reverse lookup: 256-color index -> named color string
|
|
57
|
+
// only the 16 standard colors get names, everything else stays numeric
|
|
58
|
+
const INDEX_TO_NAME = Object.entries(NAMED_COLORS)
|
|
59
|
+
.filter(([k]) => k !== 'grey')
|
|
60
|
+
.reduce((map, [name, idx]) => { map[idx] = name; return map }, {})
|
|
61
|
+
|
|
62
|
+
// maps basic ansi fg codes (30-37, 90-97) to 256-color index
|
|
63
|
+
function basicFgToIndex(code) {
|
|
64
|
+
if (code >= 30 && code <= 37) return code - 30
|
|
65
|
+
if (code >= 90 && code <= 97) return code - 90 + 8
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function basicBgToIndex(code) {
|
|
70
|
+
if (code >= 40 && code <= 47) return code - 40
|
|
71
|
+
if (code >= 100 && code <= 107) return code - 100 + 8
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function indexToColor(idx) {
|
|
76
|
+
return INDEX_TO_NAME[idx] ?? idx
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function rgbToHex(r, g, b) {
|
|
80
|
+
return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function parseSgr(params, state) {
|
|
84
|
+
if (!state) state = { fg: null, bg: null, attrs: 0 }
|
|
85
|
+
const codes = params.split(';').map(Number)
|
|
86
|
+
let i = 0
|
|
87
|
+
|
|
88
|
+
while (i < codes.length) {
|
|
89
|
+
const c = codes[i]
|
|
90
|
+
|
|
91
|
+
if (c === 0) {
|
|
92
|
+
state.fg = null
|
|
93
|
+
state.bg = null
|
|
94
|
+
state.attrs = 0
|
|
95
|
+
} else if (c === 1) state.attrs |= BOLD
|
|
96
|
+
else if (c === 2) state.attrs |= DIM
|
|
97
|
+
else if (c === 3) state.attrs |= ITALIC
|
|
98
|
+
else if (c === 4) state.attrs |= UNDERLINE
|
|
99
|
+
else if (c === 7) state.attrs |= INVERSE
|
|
100
|
+
else if (c === 9) state.attrs |= STRIKETHROUGH
|
|
101
|
+
else if (c === 22) state.attrs &= ~(BOLD | DIM)
|
|
102
|
+
else if (c === 23) state.attrs &= ~ITALIC
|
|
103
|
+
else if (c === 24) state.attrs &= ~UNDERLINE
|
|
104
|
+
else if (c === 27) state.attrs &= ~INVERSE
|
|
105
|
+
else if (c === 29) state.attrs &= ~STRIKETHROUGH
|
|
106
|
+
else if (c === 39) state.fg = null
|
|
107
|
+
else if (c === 49) state.bg = null
|
|
108
|
+
else if (c === 38 && codes[i + 1] === 5) {
|
|
109
|
+
state.fg = indexToColor(codes[i + 2])
|
|
110
|
+
i += 2
|
|
111
|
+
} else if (c === 48 && codes[i + 1] === 5) {
|
|
112
|
+
state.bg = indexToColor(codes[i + 2])
|
|
113
|
+
i += 2
|
|
114
|
+
} else if (c === 38 && codes[i + 1] === 2) {
|
|
115
|
+
state.fg = rgbToHex(codes[i + 2], codes[i + 3], codes[i + 4])
|
|
116
|
+
i += 4
|
|
117
|
+
} else if (c === 48 && codes[i + 1] === 2) {
|
|
118
|
+
state.bg = rgbToHex(codes[i + 2], codes[i + 3], codes[i + 4])
|
|
119
|
+
i += 4
|
|
120
|
+
} else {
|
|
121
|
+
const fgIdx = basicFgToIndex(c)
|
|
122
|
+
if (fgIdx != null) state.fg = indexToColor(fgIdx)
|
|
123
|
+
else {
|
|
124
|
+
const bgIdx = basicBgToIndex(c)
|
|
125
|
+
if (bgIdx != null) state.bg = indexToColor(bgIdx)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
i++
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return state
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function sgr(fg, bg, attrs) {
|
|
136
|
+
const parts = ['0']
|
|
137
|
+
|
|
138
|
+
for (const [mask, code] of ATTR_CODES) {
|
|
139
|
+
if (attrs & mask) parts.push(code)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const fgCode = parseColor(fg, 38)
|
|
143
|
+
if (fgCode) parts.push(fgCode)
|
|
144
|
+
|
|
145
|
+
const bgCode = parseColor(bg, 48)
|
|
146
|
+
if (bgCode) parts.push(bgCode)
|
|
147
|
+
|
|
148
|
+
return `${ESC}${parts.join(';')}m`
|
|
149
|
+
}
|
package/src/buffer.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { parseSgr } from './ansi.js'
|
|
2
|
+
|
|
3
|
+
const EMPTY = { ch: ' ', fg: null, bg: null, attrs: 0 }
|
|
4
|
+
|
|
5
|
+
export function createBuffer(width, height) {
|
|
6
|
+
const size = width * height
|
|
7
|
+
const cells = new Array(size)
|
|
8
|
+
for (let i = 0; i < size; i++) cells[i] = EMPTY
|
|
9
|
+
return { width, height, cells }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function clearBuffer(buf) {
|
|
13
|
+
const len = buf.cells.length
|
|
14
|
+
for (let i = 0; i < len; i++) buf.cells[i] = EMPTY
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resizeBuffer(buf, width, height) {
|
|
18
|
+
buf.width = width
|
|
19
|
+
buf.height = height
|
|
20
|
+
buf.cells = new Array(width * height)
|
|
21
|
+
for (let i = 0; i < buf.cells.length; i++) buf.cells[i] = EMPTY
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function setCell(buf, x, y, ch, fg, bg, attrs) {
|
|
25
|
+
if (x < 0 || y < 0 || x >= buf.width || y >= buf.height) return
|
|
26
|
+
buf.cells[y * buf.width + x] = { ch, fg: fg ?? null, bg: bg ?? null, attrs: attrs ?? 0 }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function writeText(buf, x, y, text, fg, bg, attrs, maxWidth) {
|
|
30
|
+
if (y < 0 || y >= buf.height) return
|
|
31
|
+
const max = maxWidth ?? (buf.width - x)
|
|
32
|
+
|
|
33
|
+
if (text.indexOf('\x1b') === -1) {
|
|
34
|
+
const n = Math.min(max, text.length, buf.width - x)
|
|
35
|
+
for (let i = 0; i < n; i++) {
|
|
36
|
+
const cx = x + i
|
|
37
|
+
if (cx < 0 || cx >= buf.width) continue
|
|
38
|
+
const prev = buf.cells[y * buf.width + cx]
|
|
39
|
+
buf.cells[y * buf.width + cx] = {
|
|
40
|
+
ch: text[i],
|
|
41
|
+
fg: fg ?? prev.fg,
|
|
42
|
+
bg: bg ?? prev.bg,
|
|
43
|
+
attrs: attrs || prev.attrs,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let col = 0
|
|
50
|
+
const ansi = { fg: null, bg: null, attrs: 0 }
|
|
51
|
+
let i = 0
|
|
52
|
+
|
|
53
|
+
while (i < text.length && col < max) {
|
|
54
|
+
if (text[i] === '\x1b' && text[i + 1] === '[') {
|
|
55
|
+
const end = text.indexOf('m', i + 2)
|
|
56
|
+
if (end !== -1) {
|
|
57
|
+
parseSgr(text.slice(i + 2, end), ansi)
|
|
58
|
+
i = end + 1
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const cx = x + col
|
|
64
|
+
if (cx >= 0 && cx < buf.width) {
|
|
65
|
+
const prev = buf.cells[y * buf.width + cx]
|
|
66
|
+
buf.cells[y * buf.width + cx] = {
|
|
67
|
+
ch: text[i],
|
|
68
|
+
fg: ansi.fg ?? fg ?? prev.fg,
|
|
69
|
+
bg: ansi.bg ?? bg ?? prev.bg,
|
|
70
|
+
attrs: ansi.attrs || attrs || prev.attrs,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
col++
|
|
74
|
+
i++
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function fillRect(buf, x, y, w, h, ch, fg, bg, attrs) {
|
|
79
|
+
const x2 = Math.min(x + w, buf.width)
|
|
80
|
+
const y2 = Math.min(y + h, buf.height)
|
|
81
|
+
const x1 = Math.max(x, 0)
|
|
82
|
+
const y1 = Math.max(y, 0)
|
|
83
|
+
for (let row = y1; row < y2; row++) {
|
|
84
|
+
for (let col = x1; col < x2; col++) {
|
|
85
|
+
buf.cells[row * buf.width + col] = { ch: ch ?? ' ', fg: fg ?? null, bg: bg ?? null, attrs: attrs ?? 0 }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function dimBuffer(buf) {
|
|
91
|
+
for (let i = 0; i < buf.cells.length; i++) {
|
|
92
|
+
const cell = buf.cells[i]
|
|
93
|
+
if (cell.attrs & 2) continue
|
|
94
|
+
buf.cells[i] = { ch: cell.ch, fg: cell.fg, bg: cell.bg, attrs: cell.attrs | 2 }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function copyBuffer(src, dst) {
|
|
99
|
+
const len = Math.min(src.cells.length, dst.cells.length)
|
|
100
|
+
for (let i = 0; i < len; i++) dst.cells[i] = src.cells[i]
|
|
101
|
+
}
|
package/src/button.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { jsx } from '../jsx-runtime.js'
|
|
2
|
+
import { useInput, useTheme } from './hooks.js'
|
|
3
|
+
|
|
4
|
+
export function Button({ label, onPress, focused = false, variant }) {
|
|
5
|
+
const { accent = 'cyan' } = useTheme()
|
|
6
|
+
|
|
7
|
+
useInput((event) => {
|
|
8
|
+
if (!focused) return
|
|
9
|
+
if (event.key === 'return' || event.key === 'space') {
|
|
10
|
+
onPress?.()
|
|
11
|
+
event.stopPropagation()
|
|
12
|
+
}
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const dim = variant === 'dim'
|
|
16
|
+
|
|
17
|
+
return jsx('text', {
|
|
18
|
+
style: {
|
|
19
|
+
bg: focused ? accent : null,
|
|
20
|
+
color: focused ? 'black' : (dim ? 'gray' : null),
|
|
21
|
+
bold: focused,
|
|
22
|
+
dim: !focused && dim,
|
|
23
|
+
},
|
|
24
|
+
children: focused ? ` ${label} ` : `[${label}]`,
|
|
25
|
+
})
|
|
26
|
+
}
|
package/src/checkbox.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx, jsxs } from '../jsx-runtime.js'
|
|
2
|
+
import { useInput, useTheme } from './hooks.js'
|
|
3
|
+
|
|
4
|
+
export function Checkbox({ checked = false, label, onChange, focused = false }) {
|
|
5
|
+
const { accent = 'cyan' } = useTheme()
|
|
6
|
+
|
|
7
|
+
useInput((event) => {
|
|
8
|
+
if (!focused) return
|
|
9
|
+
if (event.key === 'space' || event.key === 'return') {
|
|
10
|
+
onChange?.(!checked)
|
|
11
|
+
event.stopPropagation()
|
|
12
|
+
}
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const icon = checked ? '[x]' : '[ ]'
|
|
16
|
+
const bg = focused ? accent : null
|
|
17
|
+
const color = focused ? 'black' : null
|
|
18
|
+
|
|
19
|
+
const children = [
|
|
20
|
+
jsx('text', { style: { color, bold: focused }, children: icon }),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
if (label != null) {
|
|
24
|
+
children.push(jsx('text', { style: { color }, children: ` ${label}` }))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return jsxs('box', { style: { flexDirection: 'row', bg }, children })
|
|
28
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { jsx } from '../jsx-runtime.js'
|
|
2
|
+
|
|
3
|
+
export function Box(props) {
|
|
4
|
+
return jsx('box', props)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function Text(props) {
|
|
8
|
+
return jsx('text', props)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function Spacer() {
|
|
12
|
+
return jsx('box', { style: { flexGrow: 1 } })
|
|
13
|
+
}
|
package/src/diff.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { moveTo, sgr, sgrReset } from './ansi.js'
|
|
2
|
+
|
|
3
|
+
function cellEq(a, b) {
|
|
4
|
+
return a === b || (a.ch === b.ch && a.fg === b.fg && a.bg === b.bg && a.attrs === b.attrs)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function diff(prev, curr) {
|
|
8
|
+
const w = curr.width
|
|
9
|
+
const h = curr.height
|
|
10
|
+
const parts = []
|
|
11
|
+
|
|
12
|
+
let lastFg = undefined
|
|
13
|
+
let lastBg = undefined
|
|
14
|
+
let lastAttrs = undefined
|
|
15
|
+
|
|
16
|
+
for (let y = 0; y < h; y++) {
|
|
17
|
+
let x = 0
|
|
18
|
+
while (x < w) {
|
|
19
|
+
const idx = y * w + x
|
|
20
|
+
if (cellEq(prev.cells[idx], curr.cells[idx])) {
|
|
21
|
+
x++
|
|
22
|
+
continue
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
parts.push(moveTo(y + 1, x + 1))
|
|
26
|
+
|
|
27
|
+
while (x < w) {
|
|
28
|
+
const i = y * w + x
|
|
29
|
+
if (cellEq(prev.cells[i], curr.cells[i])) break
|
|
30
|
+
|
|
31
|
+
const c = curr.cells[i]
|
|
32
|
+
|
|
33
|
+
if (c.fg !== lastFg || c.bg !== lastBg || c.attrs !== lastAttrs) {
|
|
34
|
+
parts.push(sgr(c.fg, c.bg, c.attrs))
|
|
35
|
+
lastFg = c.fg
|
|
36
|
+
lastBg = c.bg
|
|
37
|
+
lastAttrs = c.attrs
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
parts.push(c.ch)
|
|
41
|
+
x++
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (parts.length > 0) {
|
|
47
|
+
parts.push(sgrReset)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return parts.join('')
|
|
51
|
+
}
|
package/src/element.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createScope, disposeScope, runInScope } from './signal.js'
|
|
2
|
+
|
|
3
|
+
export const Fragment = Symbol('Fragment')
|
|
4
|
+
|
|
5
|
+
export function flattenChildren(children) {
|
|
6
|
+
if (children == null || children === true || children === false) return []
|
|
7
|
+
if (!Array.isArray(children)) return [children]
|
|
8
|
+
const result = []
|
|
9
|
+
for (const child of children) {
|
|
10
|
+
if (child == null || child === true || child === false) continue
|
|
11
|
+
if (Array.isArray(child)) result.push(...flattenChildren(child))
|
|
12
|
+
else result.push(child)
|
|
13
|
+
}
|
|
14
|
+
return result
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resolveTree(element, parent) {
|
|
18
|
+
if (element == null || typeof element === 'boolean') return null
|
|
19
|
+
|
|
20
|
+
if (typeof element === 'string' || typeof element === 'number') {
|
|
21
|
+
return {
|
|
22
|
+
type: 'text',
|
|
23
|
+
props: { children: String(element) },
|
|
24
|
+
key: null,
|
|
25
|
+
_parent: parent,
|
|
26
|
+
_layout: null,
|
|
27
|
+
_resolved: null,
|
|
28
|
+
_resolvedChildren: null,
|
|
29
|
+
_scope: null,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const node = {
|
|
34
|
+
type: element.type,
|
|
35
|
+
props: element.props ?? {},
|
|
36
|
+
key: element.key,
|
|
37
|
+
_parent: parent,
|
|
38
|
+
_layout: null,
|
|
39
|
+
_resolved: null,
|
|
40
|
+
_resolvedChildren: null,
|
|
41
|
+
_scope: null,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof element.type === 'function') {
|
|
45
|
+
let result
|
|
46
|
+
const scope = createScope(() => {
|
|
47
|
+
result = element.type(element.props ?? {})
|
|
48
|
+
})
|
|
49
|
+
node._scope = scope
|
|
50
|
+
node._resolved = resolveTree(result, node)
|
|
51
|
+
return node
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (element.type === Fragment) {
|
|
55
|
+
const children = flattenChildren(element.props?.children)
|
|
56
|
+
node._resolvedChildren = children.map(c => resolveTree(c, node)).filter(Boolean)
|
|
57
|
+
return node
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const children = flattenChildren(element.props?.children)
|
|
61
|
+
if (children.length > 0) {
|
|
62
|
+
node._resolvedChildren = children.map(c => resolveTree(c, node)).filter(Boolean)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return node
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getLeafNode(node) {
|
|
69
|
+
if (!node) return null
|
|
70
|
+
if (node._resolved) return getLeafNode(node._resolved)
|
|
71
|
+
return node
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function walkTree(node, fn) {
|
|
75
|
+
if (!node) return
|
|
76
|
+
fn(node)
|
|
77
|
+
if (node._resolved) walkTree(node._resolved, fn)
|
|
78
|
+
if (node._resolvedChildren) {
|
|
79
|
+
for (const child of node._resolvedChildren) walkTree(child, fn)
|
|
80
|
+
}
|
|
81
|
+
}
|
package/src/focus.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { createSignalRaw } from './signal.js'
|
|
2
|
+
import { registerHook } from './renderer.js'
|
|
3
|
+
import { useInput } from './hooks.js'
|
|
4
|
+
|
|
5
|
+
export function useFocus({ initial, cycle = 'tab' } = {}) {
|
|
6
|
+
const state = registerHook(() => {
|
|
7
|
+
const [current, setCurrent] = createSignalRaw(initial ?? null)
|
|
8
|
+
return {
|
|
9
|
+
items: [],
|
|
10
|
+
groups: new Map(),
|
|
11
|
+
nameToParent: new Map(),
|
|
12
|
+
current,
|
|
13
|
+
setCurrent,
|
|
14
|
+
stack: [],
|
|
15
|
+
initialized: false,
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
function resolveLeaf(name) {
|
|
20
|
+
const g = state.groups.get(name)
|
|
21
|
+
if (g) return g.items[g.subIdx]
|
|
22
|
+
return name
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function item(name) {
|
|
26
|
+
if (!state.items.find(i => i.name === name)) {
|
|
27
|
+
state.items.push({ name, type: 'item' })
|
|
28
|
+
}
|
|
29
|
+
if (!state.initialized && !state.current()) {
|
|
30
|
+
state.setCurrent(name)
|
|
31
|
+
state.initialized = true
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function group(name, { items: subItems, navigate = 'both', wrap = false } = {}) {
|
|
36
|
+
if (!state.items.find(i => i.name === name)) {
|
|
37
|
+
state.items.push({ name, type: 'group' })
|
|
38
|
+
}
|
|
39
|
+
if (!state.groups.has(name)) {
|
|
40
|
+
state.groups.set(name, { items: subItems, navigate, wrap, subIdx: 0 })
|
|
41
|
+
for (const sub of subItems) {
|
|
42
|
+
state.nameToParent.set(sub, name)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (!state.initialized && !state.current()) {
|
|
46
|
+
state.setCurrent(subItems[0])
|
|
47
|
+
state.initialized = true
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function is(name) {
|
|
52
|
+
const cur = state.current()
|
|
53
|
+
if (cur === name) return true
|
|
54
|
+
const g = state.groups.get(name)
|
|
55
|
+
if (g && g.items.includes(cur)) return true
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function focus(name) {
|
|
60
|
+
const parentGroup = state.nameToParent.get(name)
|
|
61
|
+
if (parentGroup) {
|
|
62
|
+
const g = state.groups.get(parentGroup)
|
|
63
|
+
const idx = g.items.indexOf(name)
|
|
64
|
+
if (idx >= 0) g.subIdx = idx
|
|
65
|
+
}
|
|
66
|
+
state.setCurrent(name)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function push(name) {
|
|
70
|
+
state.stack.push(state.current())
|
|
71
|
+
focus(name)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function pop() {
|
|
75
|
+
if (state.stack.length === 0) return
|
|
76
|
+
focus(state.stack.pop())
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function current() {
|
|
80
|
+
return state.current()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function findTopLevel(cur) {
|
|
84
|
+
const parent = state.nameToParent.get(cur)
|
|
85
|
+
if (parent) return state.items.findIndex(i => i.name === parent)
|
|
86
|
+
return state.items.findIndex(i => i.name === cur)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
useInput((event) => {
|
|
90
|
+
if (state.items.length === 0) return
|
|
91
|
+
|
|
92
|
+
const { key } = event
|
|
93
|
+
const cur = state.current()
|
|
94
|
+
|
|
95
|
+
if (state.stack.length > 0) return
|
|
96
|
+
|
|
97
|
+
if (cycle === 'tab' && (key === 'tab' || key === 'shift-tab')) {
|
|
98
|
+
const idx = findTopLevel(cur)
|
|
99
|
+
if (idx === -1) return
|
|
100
|
+
const dir = key === 'tab' ? 1 : -1
|
|
101
|
+
const next = (idx + dir + state.items.length) % state.items.length
|
|
102
|
+
const nextItem = state.items[next]
|
|
103
|
+
state.setCurrent(resolveLeaf(nextItem.name))
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const parentName = state.nameToParent.get(cur)
|
|
108
|
+
if (!parentName) return
|
|
109
|
+
|
|
110
|
+
const g = state.groups.get(parentName)
|
|
111
|
+
const isNav =
|
|
112
|
+
(g.navigate === 'jk' && (key === 'j' || key === 'k')) ||
|
|
113
|
+
(g.navigate === 'updown' && (key === 'up' || key === 'down')) ||
|
|
114
|
+
(g.navigate === 'both' && (key === 'j' || key === 'k' || key === 'up' || key === 'down'))
|
|
115
|
+
|
|
116
|
+
if (!isNav) return
|
|
117
|
+
|
|
118
|
+
const dir = (key === 'j' || key === 'down') ? 1 : -1
|
|
119
|
+
const idx = g.subIdx
|
|
120
|
+
const len = g.items.length
|
|
121
|
+
|
|
122
|
+
if (g.wrap) {
|
|
123
|
+
const next = (idx + dir + len) % len
|
|
124
|
+
g.subIdx = next
|
|
125
|
+
state.setCurrent(g.items[next])
|
|
126
|
+
} else {
|
|
127
|
+
const next = idx + dir
|
|
128
|
+
if (next < 0 || next >= len) return
|
|
129
|
+
g.subIdx = next
|
|
130
|
+
state.setCurrent(g.items[next])
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
return { item, group, is, focus, push, pop, current }
|
|
135
|
+
}
|
package/src/hooks.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { onCleanup, createSignal as rawCreateSignal } from './signal.js'
|
|
2
|
+
import { getContext, getTheme, registerHook, getInstanceLayout } from './renderer.js'
|
|
3
|
+
import { setTitle } from './ansi.js'
|
|
4
|
+
|
|
5
|
+
export function useState(initial) {
|
|
6
|
+
return registerHook(() => rawCreateSignal(initial))
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function useInput(handler) {
|
|
10
|
+
const ref = registerHook(() => {
|
|
11
|
+
const ctx = getContext()
|
|
12
|
+
if (!ctx) throw new Error('useInput must be called within a mounted component')
|
|
13
|
+
const state = { current: handler }
|
|
14
|
+
const unsub = ctx.input.onKey((event) => state.current(event))
|
|
15
|
+
onCleanup(unsub)
|
|
16
|
+
return state
|
|
17
|
+
})
|
|
18
|
+
ref.current = handler
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useResize(handler) {
|
|
22
|
+
const ref = registerHook(() => {
|
|
23
|
+
const ctx = getContext()
|
|
24
|
+
if (!ctx) throw new Error('useResize must be called within a mounted component')
|
|
25
|
+
const stream = ctx.stream
|
|
26
|
+
const state = { current: handler }
|
|
27
|
+
const onResize = () => state.current({ width: stream.columns, height: stream.rows })
|
|
28
|
+
stream.on('resize', onResize)
|
|
29
|
+
onCleanup(() => stream.off('resize', onResize))
|
|
30
|
+
return state
|
|
31
|
+
})
|
|
32
|
+
ref.current = handler
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function useInterval(fn, ms) {
|
|
36
|
+
const ref = registerHook(() => {
|
|
37
|
+
const state = { current: fn }
|
|
38
|
+
const id = setInterval(() => state.current(), ms)
|
|
39
|
+
onCleanup(() => clearInterval(id))
|
|
40
|
+
return state
|
|
41
|
+
})
|
|
42
|
+
ref.current = fn
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function useLayout() {
|
|
46
|
+
return getInstanceLayout()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useTheme() {
|
|
50
|
+
return getTheme()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function useStdout() {
|
|
54
|
+
const ctx = getContext()
|
|
55
|
+
if (!ctx) throw new Error('useStdout must be called within a mounted component')
|
|
56
|
+
return ctx.stream
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function useTitle(title) {
|
|
60
|
+
const ctx = getContext()
|
|
61
|
+
if (!ctx) throw new Error('useTitle must be called within a mounted component')
|
|
62
|
+
ctx.stream.write(setTitle(title))
|
|
63
|
+
}
|
package/src/hotkey.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useInput } from './hooks.js'
|
|
2
|
+
import { registerHook } from './renderer.js'
|
|
3
|
+
|
|
4
|
+
function parseDescriptor(desc) {
|
|
5
|
+
const parts = desc.toLowerCase().split('+')
|
|
6
|
+
const key = parts.pop()
|
|
7
|
+
const mods = { ctrl: false, meta: false }
|
|
8
|
+
for (const p of parts) {
|
|
9
|
+
if (p === 'ctrl') mods.ctrl = true
|
|
10
|
+
if (p === 'alt' || p === 'meta') mods.meta = true
|
|
11
|
+
}
|
|
12
|
+
return { key, ...mods }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const RAW_KEYS = { return: '\r', enter: '\r' }
|
|
16
|
+
|
|
17
|
+
export function useHotkey(descriptor, handler, { when } = {}) {
|
|
18
|
+
const parsed = registerHook(() => parseDescriptor(descriptor))
|
|
19
|
+
const ref = registerHook(() => ({ handler, when }))
|
|
20
|
+
ref.handler = handler
|
|
21
|
+
ref.when = when
|
|
22
|
+
|
|
23
|
+
useInput((event) => {
|
|
24
|
+
if (ref.when && !ref.when()) return
|
|
25
|
+
|
|
26
|
+
const rawKey = RAW_KEYS[parsed.key] || parsed.key
|
|
27
|
+
const keyMatch = event.key === rawKey || event.key === parsed.key
|
|
28
|
+
if (!keyMatch) return
|
|
29
|
+
if (parsed.ctrl !== event.ctrl) return
|
|
30
|
+
if (parsed.meta !== event.meta) return
|
|
31
|
+
|
|
32
|
+
ref.handler()
|
|
33
|
+
event.stopPropagation()
|
|
34
|
+
})
|
|
35
|
+
}
|