@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/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 selected = selectedProp ?? selectedInternal()
14
- const setSelected = onSelect ?? setSelectedInternal
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(({ key, ctrl }) => {
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
- if (key === 'up' || key === 'k') setSelected(Math.max(0, selected - 1))
78
- else if (key === 'down' || key === 'j') setSelected(Math.min(len - 1, selected + 1))
79
- else if (key === 'pageup' || (ctrl && key === 'b')) setSelected(Math.max(0, selected - pageItems))
80
- else if (key === 'pagedown' || (ctrl && key === 'f')) setSelected(Math.min(len - 1, selected + pageItems))
81
- else if (ctrl && key === 'u') setSelected(Math.max(0, selected - half))
82
- else if (ctrl && key === 'd') setSelected(Math.min(len - 1, selected + half))
83
- else if (key === 'home' || key === 'g') setSelected(0)
84
- else if (key === 'end' || key === 'G') setSelected(len - 1)
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
- const headerOffset = sticky ? headerH : (header ? headerH : 0)
103
- const relY = y - layout.y - headerOffset
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 : 'gray') : 'gray', dim: !isThumb },
193
+ style: { color: isThumb ? (focused ? accent : muted) : muted, dim: !isThumb },
181
194
  children: isThumb ? '\u2588' : '\u2502',
182
195
  })
183
196
  )
@@ -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 = onSelect ?? setInternal
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 : 'gray' }, children: label }),
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 { 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
- }
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, onSelect, hotkeyColor: hotkeyColorProp, style: userStyle }) {
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
- const navKeys = ['h', 'l', 'left', 'right', 'return', 'space', 'escape']
113
- if (!isOpen && !navKeys.includes(key)) {
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
- onSelect?.({ menu: menu.label, item: child.label ?? child, value: child.value ?? child.label ?? child })
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
- onSelect?.({ menu: menu.label, item: child.label ?? child, value: child.value ?? child.label ?? child })
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 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)
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 visibleCount = Math.max(1, maxRows)
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 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, {
190
+ const dropdown = jsx(Dropdown, {
244
191
  items: openChildren,
245
192
  cursor: cur,
246
193
  scroll: newScroll,
247
194
  visibleCount,
248
- onSelect: (item) => {
249
- onSelect?.({ menu: openMenu.label, item: item.label ?? item, value: item.value ?? item.label ?? item })
250
- setOpenIndex(-1)
251
- },
195
+ width: dropWidth,
196
+ onSubmit: commit,
197
+ onClose: () => setOpenIndex(-1),
252
198
  onCursorChange: (idx) => setCursor(idx),
253
- accent,
254
- hotkeyColor,
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 = 'black'
221
+ color = accentText
291
222
  bold = true
292
223
  } else if (isActive && focused) {
293
224
  bg = accent
294
- color = 'black'
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 ? 'black' : null
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
- onSelectionChange({ item: activeItem(), breadcrumb: breadcrumb(), column: focusCol() })
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 s = stack().slice()
104
+ const cur = stack()
84
105
  const d = depth()
85
- const left = s[d]
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 = s[d + 1]
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
- setStack(s)
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 || process.stdout.rows
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',