@trendr/core 0.2.2 → 0.2.4

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