@trendr/core 0.2.11 → 0.4.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/README.md +284 -24
- package/index.js +6 -2
- package/package.json +29 -34
- package/src/animation.js +25 -12
- package/src/ansi.js +6 -0
- package/src/buffer.js +63 -50
- package/src/button.js +2 -2
- package/src/checkbox.js +2 -2
- package/src/diff-engine.js +313 -0
- package/src/diff-view.js +238 -0
- package/src/diff.js +2 -2
- package/src/dropdown.js +147 -0
- package/src/focus.js +89 -27
- package/src/hooks.js +43 -25
- package/src/hotkey.js +51 -16
- package/src/input.js +211 -72
- package/src/layout.js +34 -13
- package/src/list.js +29 -16
- package/src/markdown.js +177 -0
- package/src/menu.js +13 -6
- package/src/menubar.js +46 -115
- package/src/miller-nav.js +36 -11
- package/src/modal.js +3 -1
- package/src/pick-list.js +16 -17
- package/src/progress.js +3 -3
- package/src/radio.js +16 -5
- package/src/renderer.js +291 -50
- package/src/scheduler.js +28 -10
- package/src/scroll-box.js +12 -11
- package/src/scrollable-text.js +3 -2
- package/src/select.js +65 -173
- package/src/selection.js +92 -0
- package/src/shimmer.js +2 -2
- package/src/signal.js +20 -3
- package/src/table.js +8 -27
- package/src/tabs.js +10 -7
- package/src/task.js +3 -3
- package/src/text-area.js +96 -41
- package/src/text-input.js +70 -28
- package/src/wrap.js +205 -84
package/src/list.js
CHANGED
|
@@ -3,15 +3,20 @@ import { createSignal } from './signal.js'
|
|
|
3
3
|
import { useInput, useMouse, useLayout, useTheme, useScrollDrag } from './hooks.js'
|
|
4
4
|
import { registerHook } from './renderer.js'
|
|
5
5
|
|
|
6
|
-
export function List({ items, selected: selectedProp, onSelect, onCursorChange, renderItem, header, headerHeight = 1, focused = true, interactive = focused, itemHeight = 1, scrollbar = false, stickyHeader = false, gap = 0, scrolloff = 2 }) {
|
|
7
|
-
const { accent = 'cyan' } = useTheme()
|
|
6
|
+
export function List({ items = [], selected: selectedProp, onSelect, onCursorChange, renderItem, header, headerHeight = 1, focused = true, interactive = focused, itemHeight = 1, scrollbar = false, stickyHeader = false, gap = 0, scrolloff = 2 }) {
|
|
7
|
+
const { accent = 'cyan', muted = 'gray' } = useTheme()
|
|
8
8
|
const [selectedInternal, setSelectedInternal] = createSignal(0)
|
|
9
9
|
const [scrollState, setScrollState] = createSignal(0)
|
|
10
10
|
const layout = useLayout()
|
|
11
11
|
const prevCursor = registerHook(() => ({ index: -1 }))
|
|
12
12
|
|
|
13
|
-
const
|
|
14
|
-
const
|
|
13
|
+
const rawSelected = selectedProp ?? selectedInternal()
|
|
14
|
+
const selected = Math.min(Math.max(0, rawSelected), Math.max(0, items.length - 1))
|
|
15
|
+
const setSelected = (idx) => {
|
|
16
|
+
if (selectedProp === undefined) setSelectedInternal(idx)
|
|
17
|
+
if (onSelect) onSelect(idx)
|
|
18
|
+
}
|
|
19
|
+
if (selected !== rawSelected && items.length > 0) setSelected(selected)
|
|
15
20
|
|
|
16
21
|
if (onCursorChange && selected !== prevCursor.index && items.length > 0) {
|
|
17
22
|
prevCursor.index = selected
|
|
@@ -65,23 +70,30 @@ export function List({ items, selected: selectedProp, onSelect, onCursorChange,
|
|
|
65
70
|
return items.length - 1
|
|
66
71
|
}
|
|
67
72
|
|
|
68
|
-
useInput((
|
|
73
|
+
useInput((event) => {
|
|
69
74
|
if (!interactive) return
|
|
70
75
|
|
|
76
|
+
const { key, ctrl } = event
|
|
71
77
|
const len = items.length
|
|
72
78
|
if (len === 0) return
|
|
73
79
|
|
|
74
80
|
const pageItems = viewH > 0 ? Math.max(1, Math.floor(viewH / avgH)) : 10
|
|
75
81
|
const half = Math.max(1, Math.floor(pageItems / 2))
|
|
76
82
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
else if (key === '
|
|
80
|
-
else if (key === '
|
|
81
|
-
else if (ctrl && key === '
|
|
82
|
-
else if (ctrl && key === '
|
|
83
|
-
else if (key === '
|
|
84
|
-
else if (key === '
|
|
83
|
+
let next = null
|
|
84
|
+
if (key === 'up' || key === 'k') next = Math.max(0, selected - 1)
|
|
85
|
+
else if (key === 'down' || key === 'j') next = Math.min(len - 1, selected + 1)
|
|
86
|
+
else if (key === 'pageup' || (ctrl && key === 'b')) next = Math.max(0, selected - pageItems)
|
|
87
|
+
else if (key === 'pagedown' || (ctrl && key === 'f')) next = Math.min(len - 1, selected + pageItems)
|
|
88
|
+
else if (ctrl && key === 'u') next = Math.max(0, selected - half)
|
|
89
|
+
else if (ctrl && key === 'd') next = Math.min(len - 1, selected + half)
|
|
90
|
+
else if (key === 'home' || key === 'g') next = 0
|
|
91
|
+
else if (key === 'end' || key === 'G') next = len - 1
|
|
92
|
+
|
|
93
|
+
if (next !== null) {
|
|
94
|
+
setSelected(next)
|
|
95
|
+
event.stopPropagation()
|
|
96
|
+
}
|
|
85
97
|
})
|
|
86
98
|
|
|
87
99
|
useMouse((event) => {
|
|
@@ -92,6 +104,7 @@ export function List({ items, selected: selectedProp, onSelect, onCursorChange,
|
|
|
92
104
|
if (x < layout.x || x >= layout.x + layout.width || y < layout.y || y >= layout.y + layout.height) return
|
|
93
105
|
|
|
94
106
|
if (event.action === 'scroll') {
|
|
107
|
+
if (event.direction !== 'up' && event.direction !== 'down') return
|
|
95
108
|
if (event.direction === 'up') setSelected(Math.max(0, selected - 1))
|
|
96
109
|
else setSelected(Math.min(len - 1, selected + 1))
|
|
97
110
|
event.stopPropagation()
|
|
@@ -99,8 +112,8 @@ export function List({ items, selected: selectedProp, onSelect, onCursorChange,
|
|
|
99
112
|
}
|
|
100
113
|
|
|
101
114
|
if (event.action === 'press' && event.button === 'left') {
|
|
102
|
-
|
|
103
|
-
const relY = y - layout.y -
|
|
115
|
+
// non-sticky headers are already accounted for by offsetToIndex via chOffset
|
|
116
|
+
const relY = y - layout.y - (sticky ? headerH : 0)
|
|
104
117
|
const idx = offsetToIndex(relY + scrollOffset)
|
|
105
118
|
if (idx >= 0 && idx < len) {
|
|
106
119
|
setSelected(idx)
|
|
@@ -177,7 +190,7 @@ export function List({ items, selected: selectedProp, onSelect, onCursorChange,
|
|
|
177
190
|
barChildren.push(
|
|
178
191
|
jsx('text', {
|
|
179
192
|
key: i,
|
|
180
|
-
style: { color: isThumb ? (focused ? accent :
|
|
193
|
+
style: { color: isThumb ? (focused ? accent : muted) : muted, dim: !isThumb },
|
|
181
194
|
children: isThumb ? '\u2588' : '\u2502',
|
|
182
195
|
})
|
|
183
196
|
)
|
package/src/markdown.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { jsx, jsxs } from '../jsx-runtime.js'
|
|
2
|
+
import { useTheme, useLayout } from './hooks.js'
|
|
3
|
+
import { fgSgr } from './ansi.js'
|
|
4
|
+
|
|
5
|
+
const BOLD_ON = '\x1b[1m'
|
|
6
|
+
const BOLD_OFF = '\x1b[22m'
|
|
7
|
+
const ITALIC_ON = '\x1b[3m'
|
|
8
|
+
const ITALIC_OFF = '\x1b[23m'
|
|
9
|
+
const UNDERLINE_ON = '\x1b[4m'
|
|
10
|
+
const UNDERLINE_OFF = '\x1b[24m'
|
|
11
|
+
const FG_RESET = '\x1b[39m'
|
|
12
|
+
|
|
13
|
+
const FENCE = /^```(\S*)\s*$/
|
|
14
|
+
const HEADING = /^(#{1,6})\s+(.*)$/
|
|
15
|
+
const HR = /^ {0,3}(-{3,}|\*{3,}|_{3,})\s*$/
|
|
16
|
+
const LIST_ITEM = /^(\s*)([-*+]|\d+\.)\s+(.*)$/
|
|
17
|
+
const QUOTE = /^>\s?(.*)$/
|
|
18
|
+
const BLANK = /^\s*$/
|
|
19
|
+
|
|
20
|
+
export function parseBlocks(text) {
|
|
21
|
+
const lines = String(text).split('\n')
|
|
22
|
+
const blocks = []
|
|
23
|
+
let i = 0
|
|
24
|
+
|
|
25
|
+
while (i < lines.length) {
|
|
26
|
+
const line = lines[i]
|
|
27
|
+
|
|
28
|
+
if (BLANK.test(line)) { i++; continue }
|
|
29
|
+
|
|
30
|
+
const fence = line.match(FENCE)
|
|
31
|
+
if (fence) {
|
|
32
|
+
const code = []
|
|
33
|
+
i++
|
|
34
|
+
while (i < lines.length && !/^```\s*$/.test(lines[i])) code.push(lines[i++])
|
|
35
|
+
if (i < lines.length) i++
|
|
36
|
+
blocks.push({ type: 'code', lang: fence[1], lines: code })
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (HR.test(line)) { blocks.push({ type: 'hr' }); i++; continue }
|
|
41
|
+
|
|
42
|
+
const heading = line.match(HEADING)
|
|
43
|
+
if (heading) {
|
|
44
|
+
blocks.push({ type: 'heading', level: heading[1].length, text: heading[2] })
|
|
45
|
+
i++
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (LIST_ITEM.test(line)) {
|
|
50
|
+
const items = []
|
|
51
|
+
while (i < lines.length) {
|
|
52
|
+
const m = lines[i].match(LIST_ITEM)
|
|
53
|
+
if (!m) break
|
|
54
|
+
items.push({ indent: m[1].length, marker: m[2], text: m[3] })
|
|
55
|
+
i++
|
|
56
|
+
}
|
|
57
|
+
blocks.push({ type: 'list', items })
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (QUOTE.test(line)) {
|
|
62
|
+
const quoted = []
|
|
63
|
+
while (i < lines.length) {
|
|
64
|
+
const m = lines[i].match(QUOTE)
|
|
65
|
+
if (!m) break
|
|
66
|
+
quoted.push(m[1])
|
|
67
|
+
i++
|
|
68
|
+
}
|
|
69
|
+
blocks.push({ type: 'quote', lines: quoted })
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const para = []
|
|
74
|
+
while (
|
|
75
|
+
i < lines.length &&
|
|
76
|
+
!BLANK.test(lines[i]) &&
|
|
77
|
+
!FENCE.test(lines[i]) &&
|
|
78
|
+
!HEADING.test(lines[i]) &&
|
|
79
|
+
!HR.test(lines[i]) &&
|
|
80
|
+
!LIST_ITEM.test(lines[i]) &&
|
|
81
|
+
!QUOTE.test(lines[i])
|
|
82
|
+
) {
|
|
83
|
+
para.push(lines[i])
|
|
84
|
+
i++
|
|
85
|
+
}
|
|
86
|
+
blocks.push({ type: 'para', text: para.join(' ') })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return blocks
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function renderInline(s, { accent = 'cyan' } = {}) {
|
|
93
|
+
const codeOn = fgSgr(accent)
|
|
94
|
+
let out = ''
|
|
95
|
+
for (const part of s.split(/(`[^`]+`)/)) {
|
|
96
|
+
if (part.length > 2 && part.startsWith('`') && part.endsWith('`')) {
|
|
97
|
+
out += codeOn + part.slice(1, -1) + FG_RESET
|
|
98
|
+
} else {
|
|
99
|
+
out += part
|
|
100
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, `${UNDERLINE_ON}$1${UNDERLINE_OFF}`)
|
|
101
|
+
.replace(/\*\*([^*]+)\*\*/g, `${BOLD_ON}$1${BOLD_OFF}`)
|
|
102
|
+
.replace(/\*([^*\s][^*]*?)\*/g, `${ITALIC_ON}$1${ITALIC_OFF}`)
|
|
103
|
+
.replace(/(^|\s)_([^_]+)_(?=\s|$|[.,;:!?])/g, `$1${ITALIC_ON}$2${ITALIC_OFF}`)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return out
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function Markdown({ text, children, highlight, codeBg = '#1e1e22', style: userStyle }) {
|
|
110
|
+
const { accent = 'cyan', muted = 'gray' } = useTheme()
|
|
111
|
+
const layout = useLayout()
|
|
112
|
+
const src = text ?? (Array.isArray(children) ? children.join('') : (children ?? ''))
|
|
113
|
+
const colors = { accent, muted }
|
|
114
|
+
|
|
115
|
+
const els = parseBlocks(src).map((block, key) => {
|
|
116
|
+
if (block.type === 'heading') {
|
|
117
|
+
return jsx('text', { key, style: { bold: true, color: accent }, children: renderInline(block.text, colors) })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (block.type === 'para') {
|
|
121
|
+
return jsx('text', { key, children: renderInline(block.text, colors) })
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (block.type === 'hr') {
|
|
125
|
+
return jsx('text', { key, style: { color: muted, dim: true }, children: '─'.repeat(Math.max(1, layout.width || 40)) })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (block.type === 'code') {
|
|
129
|
+
const raw = block.lines.join('\n')
|
|
130
|
+
const shown = highlight ? highlight(raw, block.lang) : raw
|
|
131
|
+
const rows = shown.split('\n').map((line, k) => jsx('text', { key: k, style: { overflow: 'truncate' }, children: line || ' ' }))
|
|
132
|
+
return jsx('box', {
|
|
133
|
+
key,
|
|
134
|
+
style: { flexDirection: 'column', bg: codeBg, paddingX: 1 },
|
|
135
|
+
children: rows,
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (block.type === 'list') {
|
|
140
|
+
const rows = block.items.map((item, k) => {
|
|
141
|
+
const marker = /\d/.test(item.marker[0]) ? `${item.marker} ` : '• '
|
|
142
|
+
return jsxs('box', {
|
|
143
|
+
key: k,
|
|
144
|
+
style: { flexDirection: 'row', paddingLeft: item.indent },
|
|
145
|
+
children: [
|
|
146
|
+
jsx('text', { style: { color: muted }, children: marker }),
|
|
147
|
+
jsx('box', {
|
|
148
|
+
style: { flexDirection: 'column', flexGrow: 1 },
|
|
149
|
+
children: jsx('text', { children: renderInline(item.text, colors) }),
|
|
150
|
+
}),
|
|
151
|
+
],
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
return jsx('box', { key, style: { flexDirection: 'column' }, children: rows })
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (block.type === 'quote') {
|
|
158
|
+
const rows = block.lines.map((line, k) =>
|
|
159
|
+
jsx('text', { key: k, style: { color: muted, italic: true }, children: renderInline(line, colors) }))
|
|
160
|
+
return jsxs('box', {
|
|
161
|
+
key,
|
|
162
|
+
style: { flexDirection: 'row' },
|
|
163
|
+
children: [
|
|
164
|
+
jsx('text', { style: { color: muted }, children: '▎ ' }),
|
|
165
|
+
jsx('box', { style: { flexDirection: 'column', flexGrow: 1 }, children: rows }),
|
|
166
|
+
],
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return null
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
return jsx('box', {
|
|
174
|
+
style: { flexDirection: 'column', gap: 1, ...userStyle },
|
|
175
|
+
children: els,
|
|
176
|
+
})
|
|
177
|
+
}
|
package/src/menu.js
CHANGED
|
@@ -8,7 +8,7 @@ import { List } from './list.js'
|
|
|
8
8
|
// own, so it pairs with an external composer: render it after that input and,
|
|
9
9
|
// while focused, it intercepts up/down/enter before the input sees them
|
|
10
10
|
export function Menu({
|
|
11
|
-
items,
|
|
11
|
+
items = [],
|
|
12
12
|
selected: selectedProp,
|
|
13
13
|
onSelect,
|
|
14
14
|
onSubmit,
|
|
@@ -20,14 +20,18 @@ export function Menu({
|
|
|
20
20
|
gap = 0,
|
|
21
21
|
renderItem,
|
|
22
22
|
arrow = '›',
|
|
23
|
+
vimKeys = false,
|
|
23
24
|
}) {
|
|
24
|
-
const { accent = 'cyan' } = useTheme()
|
|
25
|
+
const { accent = 'cyan', muted = 'gray' } = useTheme()
|
|
25
26
|
const [internal, setInternal] = createSignal(0)
|
|
26
27
|
|
|
27
28
|
const len = items.length
|
|
28
29
|
const raw = selectedProp ?? internal()
|
|
29
30
|
const selected = Math.min(Math.max(0, raw), Math.max(0, len - 1))
|
|
30
|
-
const setSelected =
|
|
31
|
+
const setSelected = (i) => {
|
|
32
|
+
if (selectedProp === undefined) setInternal(i)
|
|
33
|
+
if (onSelect) onSelect(i)
|
|
34
|
+
}
|
|
31
35
|
|
|
32
36
|
if (selected !== raw && len > 0) setSelected(selected)
|
|
33
37
|
|
|
@@ -35,12 +39,15 @@ export function Menu({
|
|
|
35
39
|
if (!focused || len === 0) return
|
|
36
40
|
const { key, ctrl, shift, meta } = event
|
|
37
41
|
|
|
38
|
-
if (key === 'up' || (ctrl && key === 'p')) {
|
|
42
|
+
if (key === 'up' || (ctrl && key === 'p') || (vimKeys && !ctrl && key === 'k')) {
|
|
39
43
|
setSelected(Math.max(0, selected - 1))
|
|
40
44
|
event.stopPropagation()
|
|
41
|
-
} else if (key === 'down' || (ctrl && key === 'n')) {
|
|
45
|
+
} else if (key === 'down' || (ctrl && key === 'n') || (vimKeys && !ctrl && key === 'j')) {
|
|
42
46
|
setSelected(Math.min(len - 1, selected + 1))
|
|
43
47
|
event.stopPropagation()
|
|
48
|
+
} else if (vimKeys && !ctrl && (key === 'g' || key === 'G')) {
|
|
49
|
+
setSelected(key === 'G' ? len - 1 : 0)
|
|
50
|
+
event.stopPropagation()
|
|
44
51
|
} else if (key === 'return' && !shift && !meta) {
|
|
45
52
|
if (onSubmit) onSubmit(items[selected], selected)
|
|
46
53
|
event.stopPropagation()
|
|
@@ -63,7 +70,7 @@ export function Menu({
|
|
|
63
70
|
style: { flexDirection: 'row' },
|
|
64
71
|
children: [
|
|
65
72
|
jsx('text', { style: { color: active ? accent : null }, children: active ? `${arrow} ` : ' ' }),
|
|
66
|
-
jsx('text', { style: { color: active ? accent :
|
|
73
|
+
jsx('text', { style: { color: active ? accent : muted }, children: label }),
|
|
67
74
|
],
|
|
68
75
|
})
|
|
69
76
|
}
|
package/src/menubar.js
CHANGED
|
@@ -1,68 +1,8 @@
|
|
|
1
1
|
import { jsx, jsxs } from '../jsx-runtime.js'
|
|
2
2
|
import { createSignal } from './signal.js'
|
|
3
3
|
import { useInput, useLayout, useTheme } from './hooks.js'
|
|
4
|
-
import {
|
|
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
|
-
}
|
|
4
|
+
import { getInstanceLayout, getContext } from './renderer.js'
|
|
5
|
+
import { Dropdown, followCursor, placeDropdown, overlayDropdown } from './dropdown.js'
|
|
66
6
|
|
|
67
7
|
function renderHotkeyLabel(label, hotkey, { hotkeyColor, textColor, bold, hotkeyBold, hotkeyUnderline }) {
|
|
68
8
|
if (!hotkey) {
|
|
@@ -93,8 +33,8 @@ function renderHotkeyLabel(label, hotkey, { hotkeyColor, textColor, bold, hotkey
|
|
|
93
33
|
return jsxs('box', { style: { flexDirection: 'row' }, children: parts })
|
|
94
34
|
}
|
|
95
35
|
|
|
96
|
-
export function MenuBar({ items, focused = false, maxVisible = 10,
|
|
97
|
-
const { accent = 'cyan' } = useTheme()
|
|
36
|
+
export function MenuBar({ items, focused = false, maxVisible = 10, onSubmit, hotkeyColor: hotkeyColorProp, style: userStyle }) {
|
|
37
|
+
const { accent = 'cyan', accentText = 'black' } = useTheme()
|
|
98
38
|
const hotkeyColor = hotkeyColorProp ?? accent
|
|
99
39
|
|
|
100
40
|
const [openIndex, setOpenIndex] = createSignal(-1)
|
|
@@ -109,8 +49,8 @@ export function MenuBar({ items, focused = false, maxVisible = 10, onSelect, hot
|
|
|
109
49
|
const { key } = event
|
|
110
50
|
const isOpen = openIndex() >= 0
|
|
111
51
|
|
|
112
|
-
|
|
113
|
-
if (!isOpen
|
|
52
|
+
// explicit hotkeys win over nav keys, so a menu bound to h/l/space/etc is still reachable
|
|
53
|
+
if (!isOpen) {
|
|
114
54
|
const match = items.findIndex(m => m.hotkey && m.hotkey.toLowerCase() === key.toLowerCase())
|
|
115
55
|
if (match >= 0) {
|
|
116
56
|
setActiveIndex(match)
|
|
@@ -143,7 +83,7 @@ export function MenuBar({ items, focused = false, maxVisible = 10, onSelect, hot
|
|
|
143
83
|
if (len > 0) {
|
|
144
84
|
const child = children[cursor()]
|
|
145
85
|
if (child) {
|
|
146
|
-
|
|
86
|
+
onSubmit?.({ menu: menu.label, item: child.label ?? child, value: child.value ?? child.label ?? child })
|
|
147
87
|
}
|
|
148
88
|
}
|
|
149
89
|
setOpenIndex(-1)
|
|
@@ -160,7 +100,7 @@ export function MenuBar({ items, focused = false, maxVisible = 10, onSelect, hot
|
|
|
160
100
|
const childMatch = children.findIndex(c => c.hotkey && c.hotkey.toLowerCase() === key.toLowerCase())
|
|
161
101
|
if (childMatch >= 0) {
|
|
162
102
|
const child = children[childMatch]
|
|
163
|
-
|
|
103
|
+
onSubmit?.({ menu: menu.label, item: child.label ?? child, value: child.value ?? child.label ?? child })
|
|
164
104
|
setOpenIndex(-1)
|
|
165
105
|
event.stopPropagation()
|
|
166
106
|
return
|
|
@@ -208,6 +148,7 @@ export function MenuBar({ items, focused = false, maxVisible = 10, onSelect, hot
|
|
|
208
148
|
const instLayout = getInstanceLayout()
|
|
209
149
|
const ctx = getContext()
|
|
210
150
|
const termH = ctx?.stream?.rows ?? 24
|
|
151
|
+
const termW = ctx?.stream?.columns ?? 80
|
|
211
152
|
|
|
212
153
|
let itemX = 0
|
|
213
154
|
for (let i = 0; i < openIndex(); i++) {
|
|
@@ -215,64 +156,54 @@ export function MenuBar({ items, focused = false, maxVisible = 10, onSelect, hot
|
|
|
215
156
|
itemX += label.length + 2
|
|
216
157
|
}
|
|
217
158
|
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
159
|
+
const { direction, visibleCount } = placeDropdown({
|
|
160
|
+
anchorTop: instLayout.y,
|
|
161
|
+
itemCount: openChildren.length,
|
|
162
|
+
maxVisible,
|
|
163
|
+
termH,
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
const cur = cursor()
|
|
167
|
+
const newScroll = followCursor(cur, scroll(), visibleCount, openChildren.length)
|
|
168
|
+
if (newScroll !== scroll()) setScroll(newScroll)
|
|
169
|
+
|
|
170
|
+
const maxLen = openChildren.reduce((m, v) => Math.max(m, (v.label ?? v).length), 0)
|
|
171
|
+
const scrollable = openChildren.length > visibleCount
|
|
172
|
+
const dropWidth = maxLen + 4 + (scrollable ? 2 : 0)
|
|
173
|
+
|
|
174
|
+
const commit = (item) => {
|
|
175
|
+
onSubmit?.({ menu: openMenu.label, item: item.label ?? item, value: item.value ?? item.label ?? item })
|
|
176
|
+
setOpenIndex(-1)
|
|
231
177
|
}
|
|
232
178
|
|
|
233
|
-
const
|
|
179
|
+
const renderRow = (item, { isCursor }) => jsx('box', {
|
|
180
|
+
style: { bg: isCursor ? accent : null, paddingX: 1, flexGrow: 1 },
|
|
181
|
+
children: renderHotkeyLabel(item.label ?? item, item.hotkey, {
|
|
182
|
+
hotkeyColor: isCursor ? accentText : hotkeyColor,
|
|
183
|
+
textColor: isCursor ? accentText : null,
|
|
184
|
+
bold: isCursor,
|
|
185
|
+
hotkeyBold: true,
|
|
186
|
+
hotkeyUnderline: true,
|
|
187
|
+
}),
|
|
188
|
+
})
|
|
234
189
|
|
|
235
|
-
const
|
|
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, {
|
|
190
|
+
const dropdown = jsx(Dropdown, {
|
|
244
191
|
items: openChildren,
|
|
245
192
|
cursor: cur,
|
|
246
193
|
scroll: newScroll,
|
|
247
194
|
visibleCount,
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
},
|
|
195
|
+
width: dropWidth,
|
|
196
|
+
onSubmit: commit,
|
|
197
|
+
onClose: () => setOpenIndex(-1),
|
|
252
198
|
onCursorChange: (idx) => setCursor(idx),
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
direction,
|
|
199
|
+
renderRow,
|
|
200
|
+
style: { border: 'single', borderColor: accent, bg: null, accent },
|
|
256
201
|
})
|
|
257
202
|
|
|
258
|
-
const termW = ctx?.stream?.columns ?? 80
|
|
259
203
|
const dropdownH = visibleCount + 2
|
|
260
204
|
const absX = instLayout.x + itemX
|
|
261
205
|
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 })
|
|
206
|
+
overlayDropdown({ x: absX, y: absY, termW, termH, dropdown })
|
|
276
207
|
}
|
|
277
208
|
|
|
278
209
|
const barChildren = items.map((menu, i) => {
|
|
@@ -287,11 +218,11 @@ export function MenuBar({ items, focused = false, maxVisible = 10, onSelect, hot
|
|
|
287
218
|
|
|
288
219
|
if (isItemOpen && focused) {
|
|
289
220
|
bg = accent
|
|
290
|
-
color =
|
|
221
|
+
color = accentText
|
|
291
222
|
bold = true
|
|
292
223
|
} else if (isActive && focused) {
|
|
293
224
|
bg = accent
|
|
294
|
-
color =
|
|
225
|
+
color = accentText
|
|
295
226
|
bold = true
|
|
296
227
|
}
|
|
297
228
|
|
package/src/miller-nav.js
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import { jsx, jsxs } from '../jsx-runtime.js'
|
|
2
2
|
import { createSignal, batch } from './signal.js'
|
|
3
|
-
import { useInput, useMouse, useTheme, useLayout } from './hooks.js'
|
|
3
|
+
import { useInput, useMouse, useTheme, useLayout, useStdout } from './hooks.js'
|
|
4
|
+
import { registerHook } from './renderer.js'
|
|
4
5
|
import { List } from './list.js'
|
|
5
6
|
|
|
7
|
+
function sameColumn(a, b) {
|
|
8
|
+
if (a === b) return true
|
|
9
|
+
if (!a || !b) return false
|
|
10
|
+
if (a.parentId !== b.parentId || a.selected !== b.selected) return false
|
|
11
|
+
if (a.items === b.items) return true
|
|
12
|
+
if (a.items.length !== b.items.length) return false
|
|
13
|
+
for (let i = 0; i < a.items.length; i++) {
|
|
14
|
+
if (a.items[i] !== b.items[i]) return false
|
|
15
|
+
}
|
|
16
|
+
return true
|
|
17
|
+
}
|
|
18
|
+
|
|
6
19
|
function columnWidth(items, maxChars) {
|
|
7
20
|
if (!items.length) return 0
|
|
8
21
|
let longest = items.reduce((max, item) => {
|
|
@@ -13,10 +26,10 @@ function columnWidth(items, maxChars) {
|
|
|
13
26
|
return Math.min(longest + 5, 25)
|
|
14
27
|
}
|
|
15
28
|
|
|
16
|
-
function defaultRenderItem(accent, item, { selected, focused, column }) {
|
|
29
|
+
function defaultRenderItem({ accent, accentText }, item, { selected, focused, column }) {
|
|
17
30
|
const name = typeof item === 'string' ? item : (item.name ?? item.label ?? String(item))
|
|
18
31
|
const bg = selected ? (focused ? accent : '#333333') : null
|
|
19
|
-
const fg = selected && focused ?
|
|
32
|
+
const fg = selected && focused ? accentText : null
|
|
20
33
|
return jsx('box', {
|
|
21
34
|
style: { bg, flexDirection: 'row' },
|
|
22
35
|
children: jsxs('text', {
|
|
@@ -41,8 +54,9 @@ export function MillerNav({
|
|
|
41
54
|
dividerChar = '\u258f',
|
|
42
55
|
dividerColor = '#333333',
|
|
43
56
|
}) {
|
|
44
|
-
const { accent = 'cyan' } = useTheme()
|
|
57
|
+
const { accent = 'cyan', accentText = 'black' } = useTheme()
|
|
45
58
|
const layout = useLayout()
|
|
59
|
+
const stdout = useStdout()
|
|
46
60
|
|
|
47
61
|
const focusedMax = typeof maxChars === 'number' ? maxChars : maxChars.focused ?? 20
|
|
48
62
|
const unfocusedMax = typeof maxChars === 'number' ? maxChars : maxChars.unfocused ?? 10
|
|
@@ -50,6 +64,7 @@ export function MillerNav({
|
|
|
50
64
|
const [stack, setStack] = createSignal([{ parentId: null, items: rootItems, selected: 0 }])
|
|
51
65
|
const [depth, setDepth] = createSignal(0)
|
|
52
66
|
const [focusCol, setFocusCol] = createSignal(0)
|
|
67
|
+
const prevSel = registerHook(() => ({ item: undefined, column: -1, emitted: false }))
|
|
53
68
|
|
|
54
69
|
function activeColumnIndex() {
|
|
55
70
|
return depth() + focusCol()
|
|
@@ -76,18 +91,25 @@ export function MillerNav({
|
|
|
76
91
|
|
|
77
92
|
function emitSelection() {
|
|
78
93
|
if (!onSelectionChange) return
|
|
79
|
-
|
|
94
|
+
const item = activeItem()
|
|
95
|
+
const column = focusCol()
|
|
96
|
+
if (prevSel.emitted && item === prevSel.item && column === prevSel.column) return
|
|
97
|
+
prevSel.item = item
|
|
98
|
+
prevSel.column = column
|
|
99
|
+
prevSel.emitted = true
|
|
100
|
+
onSelectionChange({ item, breadcrumb: breadcrumb(), column })
|
|
80
101
|
}
|
|
81
102
|
|
|
82
103
|
function syncView() {
|
|
83
|
-
const
|
|
104
|
+
const cur = stack()
|
|
84
105
|
const d = depth()
|
|
85
|
-
const left =
|
|
106
|
+
const left = cur[d]
|
|
86
107
|
|
|
108
|
+
const s = cur.slice()
|
|
87
109
|
if (left && left.items.length) {
|
|
88
110
|
const sel = left.items[left.selected]
|
|
89
111
|
const children = getChildren(sel)
|
|
90
|
-
const prevRight =
|
|
112
|
+
const prevRight = cur[d + 1]
|
|
91
113
|
const keepSelected = (prevRight && prevRight.parentId === sel)
|
|
92
114
|
? Math.min(prevRight.selected, Math.max(0, children.length - 1))
|
|
93
115
|
: 0
|
|
@@ -97,7 +119,10 @@ export function MillerNav({
|
|
|
97
119
|
s.length = d + 1
|
|
98
120
|
}
|
|
99
121
|
|
|
100
|
-
|
|
122
|
+
// signals compare by reference, so an unconditional write of a fresh array
|
|
123
|
+
// would schedule a frame every frame - only write when the view really changed
|
|
124
|
+
const changed = s.length !== cur.length || s.some((col, i) => !sameColumn(col, cur[i]))
|
|
125
|
+
if (changed) setStack(s)
|
|
101
126
|
|
|
102
127
|
const right = s[d + 1]
|
|
103
128
|
if (!right || !right.items.length) {
|
|
@@ -197,7 +222,7 @@ export function MillerNav({
|
|
|
197
222
|
syncView()
|
|
198
223
|
}
|
|
199
224
|
|
|
200
|
-
const render = renderItem || defaultRenderItem.bind(null, accent)
|
|
225
|
+
const render = renderItem || defaultRenderItem.bind(null, { accent, accentText })
|
|
201
226
|
|
|
202
227
|
function NoteColumn({ items, selected, onSelect, isFocused, maxC, style: extraStyle }) {
|
|
203
228
|
if (!items.length) return null
|
|
@@ -283,7 +308,7 @@ export function MillerNav({
|
|
|
283
308
|
}
|
|
284
309
|
|
|
285
310
|
if (divider) {
|
|
286
|
-
const rows = layout.height ||
|
|
311
|
+
const rows = layout.height || stdout.rows || 24
|
|
287
312
|
const bar = Array.from({ length: rows }, () => dividerChar).join('\n')
|
|
288
313
|
children.push(jsx('box', {
|
|
289
314
|
key: 'divider',
|