@trendr/core 0.1.0 → 0.2.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/hooks.js CHANGED
@@ -1,5 +1,5 @@
1
- import { onCleanup, createSignal as rawCreateSignal } from './signal.js'
2
- import { getContext, getTheme, registerHook, getInstanceLayout } from './renderer.js'
1
+ import { onCleanup, createSignal as rawCreateSignal, createSignalRaw } from './signal.js'
2
+ import { getContext, getTheme, getCursor, registerHook, getInstanceLayout, getFrameStats } from './renderer.js'
3
3
  import { setTitle } from './ansi.js'
4
4
 
5
5
  export function useState(initial) {
@@ -18,6 +18,18 @@ export function useInput(handler) {
18
18
  ref.current = handler
19
19
  }
20
20
 
21
+ export function useMouse(handler) {
22
+ const ref = registerHook(() => {
23
+ const ctx = getContext()
24
+ if (!ctx) throw new Error('useMouse must be called within a mounted component')
25
+ const state = { current: handler }
26
+ const unsub = ctx.input.onMouse((event) => state.current(event))
27
+ onCleanup(unsub)
28
+ return state
29
+ })
30
+ ref.current = handler
31
+ }
32
+
21
33
  export function useResize(handler) {
22
34
  const ref = registerHook(() => {
23
35
  const ctx = getContext()
@@ -56,8 +68,136 @@ export function useStdout() {
56
68
  return ctx.stream
57
69
  }
58
70
 
71
+ export function useRepaint() {
72
+ const ctx = getContext()
73
+ if (!ctx) throw new Error('useRepaint must be called within a mounted component')
74
+ return ctx.repaint
75
+ }
76
+
77
+ export function useFrameStats() {
78
+ return getFrameStats()
79
+ }
80
+
59
81
  export function useTitle(title) {
60
82
  const ctx = getContext()
61
83
  if (!ctx) throw new Error('useTitle must be called within a mounted component')
62
84
  ctx.stream.write(setTitle(title))
63
85
  }
86
+
87
+ export function useTimeout(fn, ms) {
88
+ const ref = registerHook(() => {
89
+ const state = { current: fn }
90
+ const id = setTimeout(() => state.current(), ms)
91
+ onCleanup(() => clearTimeout(id))
92
+ return state
93
+ })
94
+ ref.current = fn
95
+ }
96
+
97
+ export function useScrollDrag({ barX, barY, thumbHeight, trackHeight, maxOffset, scrollOffset, onScroll }) {
98
+ const drag = registerHook(() => ({ active: false, startY: 0, startOffset: 0 }))
99
+
100
+ useMouse((event) => {
101
+ if (barX == null || thumbHeight <= 0) return
102
+
103
+ if (event.action === 'press' && event.button === 'left' && event.x === barX) {
104
+ if (event.y >= barY && event.y < barY + thumbHeight) {
105
+ drag.active = true
106
+ drag.startY = event.y
107
+ drag.startOffset = scrollOffset
108
+ event.stopPropagation()
109
+ }
110
+ }
111
+
112
+ if (event.action === 'drag' && drag.active) {
113
+ const dy = event.y - drag.startY
114
+ const travel = Math.max(1, trackHeight - thumbHeight)
115
+ const ratio = maxOffset / travel
116
+ const newOffset = Math.max(0, Math.min(maxOffset, Math.round(drag.startOffset + dy * ratio)))
117
+ onScroll(newOffset)
118
+ event.stopPropagation()
119
+ }
120
+
121
+ if (event.action === 'release' && drag.active) {
122
+ drag.active = false
123
+ }
124
+ })
125
+ }
126
+
127
+ export function useCursor(propCursor, focused) {
128
+ const config = getCursor(propCursor)
129
+
130
+ const state = registerHook(() => {
131
+ const [visible, setVisible] = createSignalRaw(true)
132
+ let id = null
133
+
134
+ function start(rate) {
135
+ stop()
136
+ id = setInterval(() => setVisible(v => !v), rate)
137
+ }
138
+
139
+ function stop() {
140
+ if (id !== null) { clearInterval(id); id = null }
141
+ setVisible(true)
142
+ }
143
+
144
+ onCleanup(stop)
145
+ return { visible, setVisible, start, stop, blinking: false, rate: 0 }
146
+ })
147
+
148
+ const shouldBlink = config.blink && focused
149
+ if (shouldBlink && (!state.blinking || state.rate !== config.rate)) {
150
+ state.start(config.rate)
151
+ state.blinking = true
152
+ state.rate = config.rate
153
+ } else if (!shouldBlink && state.blinking) {
154
+ state.stop()
155
+ state.blinking = false
156
+ }
157
+
158
+ if (!shouldBlink) state.setVisible(true)
159
+
160
+ function reset() {
161
+ if (!state.blinking) return
162
+ state.setVisible(true)
163
+ state.start(config.rate)
164
+ }
165
+
166
+ function cursorStyle() {
167
+ if (!focused || !state.visible()) return null
168
+ const s = {}
169
+ if (config.color) s.color = config.color
170
+ if (config.bg) s.bg = config.bg
171
+ if (!config.color && !config.bg) s.inverse = true
172
+ if (config.style === 'underline') { s.underline = true; delete s.inverse }
173
+ return s
174
+ }
175
+
176
+ return { config, visible: state.visible, cursorStyle, reset }
177
+ }
178
+
179
+ export function useAsync(fn, { immediate = false } = {}) {
180
+ const state = registerHook(() => {
181
+ const [status, setStatus] = createSignalRaw('idle')
182
+ const [data, setData] = createSignalRaw(null)
183
+ const [error, setError] = createSignalRaw(null)
184
+ let generation = 0
185
+
186
+ function run(...args) {
187
+ const gen = ++generation
188
+ setStatus('loading')
189
+ setData(null)
190
+ setError(null)
191
+ fn(...args).then(
192
+ result => { if (gen === generation) { setData(result); setStatus('success') } },
193
+ err => { if (gen === generation) { setError(err); setStatus('error') } },
194
+ )
195
+ }
196
+
197
+ if (immediate) run()
198
+
199
+ return { status, data, error, run }
200
+ })
201
+
202
+ return state
203
+ }
package/src/input.js CHANGED
@@ -30,6 +30,28 @@ const SPECIAL_KEYS = {
30
30
  ' ': 'space',
31
31
  }
32
32
 
33
+ const MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/
34
+
35
+ export function parseMouse(raw) {
36
+ const m = MOUSE_RE.exec(raw)
37
+ if (!m) return null
38
+ const cb = parseInt(m[1], 10)
39
+ const x = parseInt(m[2], 10) - 1
40
+ const y = parseInt(m[3], 10) - 1
41
+ const release = m[4] === 'm'
42
+ const button = cb & 3
43
+ const scroll = (cb & 64) !== 0
44
+ const motion = (cb & 32) !== 0
45
+
46
+ if (scroll) {
47
+ return { type: 'mouse', action: 'scroll', direction: button === 0 ? 'up' : 'down', x, y }
48
+ }
49
+
50
+ const buttonName = button === 0 ? 'left' : button === 1 ? 'middle' : 'right'
51
+ const action = release ? 'release' : motion ? 'drag' : 'press'
52
+ return { type: 'mouse', action, button: buttonName, x, y }
53
+ }
54
+
33
55
  export function parseKey(data) {
34
56
  const raw = typeof data === 'string' ? data : data.toString()
35
57
 
@@ -69,6 +91,15 @@ export function splitKeys(data) {
69
91
  if (raw[i] === '\x1b') {
70
92
  if (i + 1 < raw.length && raw[i + 1] === '[') {
71
93
  let j = i + 2
94
+ // sgr mouse: \x1b[<Cb;Cx;CyM or m
95
+ if (j < raw.length && raw[j] === '<') {
96
+ j++
97
+ while (j < raw.length && ((raw[j] >= '0' && raw[j] <= '9') || raw[j] === ';')) j++
98
+ if (j < raw.length) j++
99
+ keys.push(raw.slice(i, j))
100
+ i = j
101
+ continue
102
+ }
72
103
  while (j < raw.length && raw[j] >= '0' && raw[j] <= '9') j++
73
104
  if (j < raw.length && raw[j] === ';') {
74
105
  j++
@@ -98,12 +129,24 @@ export function splitKeys(data) {
98
129
  }
99
130
 
100
131
  export function createInputHandler(stream) {
101
- const listeners = new Set()
132
+ const keyListeners = new Set()
133
+ const mouseListeners = new Set()
102
134
 
103
135
  function dispatch(keyStr) {
136
+ const mouse = parseMouse(keyStr)
137
+ if (mouse) {
138
+ mouse.stopPropagation = () => { mouse._stopped = true }
139
+ const snapshot = [...mouseListeners].reverse()
140
+ for (const fn of snapshot) {
141
+ fn(mouse)
142
+ if (mouse._stopped) break
143
+ }
144
+ return
145
+ }
146
+
104
147
  const event = parseKey(keyStr)
105
148
  event.stopPropagation = () => { event._stopped = true }
106
- const snapshot = [...listeners].reverse()
149
+ const snapshot = [...keyListeners].reverse()
107
150
  for (const fn of snapshot) {
108
151
  fn(event)
109
152
  if (event._stopped) break
@@ -131,13 +174,22 @@ export function createInputHandler(stream) {
131
174
  }
132
175
 
133
176
  function onKey(fn) {
134
- listeners.add(fn)
135
- if (listeners.size === 1) attach()
177
+ keyListeners.add(fn)
178
+ if (keyListeners.size + mouseListeners.size === 1) attach()
179
+ return () => {
180
+ keyListeners.delete(fn)
181
+ if (keyListeners.size + mouseListeners.size === 0) detach()
182
+ }
183
+ }
184
+
185
+ function onMouse(fn) {
186
+ mouseListeners.add(fn)
187
+ if (keyListeners.size + mouseListeners.size === 1) attach()
136
188
  return () => {
137
- listeners.delete(fn)
138
- if (listeners.size === 0) detach()
189
+ mouseListeners.delete(fn)
190
+ if (keyListeners.size + mouseListeners.size === 0) detach()
139
191
  }
140
192
  }
141
193
 
142
- return { onKey, attach, detach }
194
+ return { onKey, onMouse, attach, detach }
143
195
  }
package/src/layout.js CHANGED
@@ -1,6 +1,20 @@
1
1
  import { wordWrap, measureText } from './wrap.js'
2
2
  import { Fragment } from './element.js'
3
3
 
4
+ const ALL_EDGES = { top: true, right: true, bottom: true, left: true }
5
+
6
+ export function resolveBorderEdges(style) {
7
+ if (!style.border) return { top: 0, right: 0, bottom: 0, left: 0 }
8
+ if (!style.borderEdges) return { top: 1, right: 1, bottom: 1, left: 1 }
9
+ const e = style.borderEdges
10
+ return {
11
+ top: e.top ? 1 : 0,
12
+ right: e.right ? 1 : 0,
13
+ bottom: e.bottom ? 1 : 0,
14
+ left: e.left ? 1 : 0,
15
+ }
16
+ }
17
+
4
18
  export function computeLayout(node, rect) {
5
19
  if (!node) return
6
20
 
@@ -51,25 +65,79 @@ export function computeLayout(node, rect) {
51
65
  if (!children || children.length === 0) return
52
66
 
53
67
  const pad = resolvePadding(style)
54
- const border = style.border ? 1 : 0
55
- const innerX = box.x + pad.left + border
56
- const innerY = box.y + pad.top + border
57
- const innerW = Math.max(0, box.width - pad.left - pad.right - border * 2)
58
- const innerH = Math.max(0, box.height - pad.top - pad.bottom - border * 2)
68
+ const be = resolveBorderEdges(style)
69
+ const innerX = box.x + pad.left + be.left
70
+ const innerY = box.y + pad.top + be.top
71
+ const innerW = Math.max(0, box.width - pad.left - pad.right - be.left - be.right)
72
+ const innerH = Math.max(0, box.height - pad.top - pad.bottom - be.top - be.bottom)
73
+
74
+ const flowChildren = []
75
+ const absChildren = []
76
+ for (const child of children) {
77
+ if (childStyle(child).position === 'absolute') absChildren.push(child)
78
+ else flowChildren.push(child)
79
+ }
59
80
 
60
81
  const isRow = style.flexDirection === 'row'
61
82
  const gap = style.gap ?? 0
83
+ const isScroll = style.overflow === 'scroll'
84
+ const flexMain = isScroll ? 100000 : (isRow ? innerW : innerH)
85
+
86
+ if (flowChildren.length > 0) {
87
+ layoutFlex(flowChildren, {
88
+ x: innerX,
89
+ y: innerY,
90
+ width: isRow && isScroll ? flexMain : innerW,
91
+ height: !isRow && isScroll ? flexMain : innerH,
92
+ isRow,
93
+ gap,
94
+ justifyContent: style.justifyContent ?? 'flex-start',
95
+ alignItems: style.alignItems ?? 'stretch',
96
+ })
97
+ }
98
+
99
+ if (isScroll && flowChildren.length > 0) {
100
+ let maxEdge = 0
101
+ for (const child of flowChildren) {
102
+ const cl = getLeaf(child)?._layout
103
+ if (cl) {
104
+ const edge = isRow ? (cl.x + cl.width - innerX) : (cl.y + cl.height - innerY)
105
+ if (edge > maxEdge) maxEdge = edge
106
+ }
107
+ }
108
+ node._contentHeight = maxEdge
109
+ }
110
+
111
+ for (const child of absChildren) {
112
+ layoutAbsolute(child, innerX, innerY, innerW, innerH)
113
+ }
114
+ }
115
+
116
+ function layoutAbsolute(child, areaX, areaY, areaW, areaH) {
117
+ const cs = childStyle(child)
118
+
119
+ let w, h
120
+ if (cs.left != null && cs.right != null) {
121
+ w = Math.max(0, areaW - (cs.left ?? 0) - (cs.right ?? 0))
122
+ } else {
123
+ w = resolveSize(cs.width, areaW) ?? measureChild(child, cs, false, areaW, areaH).width
124
+ }
62
125
 
63
- layoutFlex(children, {
64
- x: innerX,
65
- y: innerY,
66
- width: innerW,
67
- height: innerH,
68
- isRow,
69
- gap,
70
- justifyContent: style.justifyContent ?? 'flex-start',
71
- alignItems: style.alignItems ?? 'stretch',
72
- })
126
+ if (cs.top != null && cs.bottom != null) {
127
+ h = Math.max(0, areaH - (cs.top ?? 0) - (cs.bottom ?? 0))
128
+ } else {
129
+ h = resolveSize(cs.height, areaH) ?? measureChild(child, cs, false, areaW, areaH).height
130
+ }
131
+
132
+ let x = areaX
133
+ if (cs.left != null) x = areaX + cs.left
134
+ else if (cs.right != null) x = areaX + areaW - w - cs.right
135
+
136
+ let y = areaY
137
+ if (cs.top != null) y = areaY + cs.top
138
+ else if (cs.bottom != null) y = areaY + areaH - h - cs.bottom
139
+
140
+ computeLayout(child, { x, y, width: Math.max(0, w), height: Math.max(0, h) })
73
141
  }
74
142
 
75
143
  function layoutFlex(children, ctx) {
@@ -186,8 +254,12 @@ function measureChild(child, cs, isRow, availW, availH) {
186
254
 
187
255
  if (leaf.type === 'text') {
188
256
  const text = extractText(leaf)
257
+ const overflow = cs.overflow
189
258
  if (!text) { w = explicitW ?? 0; h = explicitH ?? 1 }
190
- else {
259
+ else if (overflow === 'nowrap' || overflow === 'truncate') {
260
+ w = explicitW ?? Math.min(availW, measureText(text))
261
+ h = explicitH ?? 1
262
+ } else {
191
263
  const maxW = explicitW ?? availW
192
264
  const lines = wordWrap(text, maxW)
193
265
  const textWidth = Math.min(maxW, Math.max(...lines.map(l => measureText(l))))
@@ -214,8 +286,8 @@ function measureIntrinsic(node, availW, availH) {
214
286
 
215
287
  const style = node.props?.style ?? {}
216
288
  const pad = resolvePadding(style)
217
- const border = style.border ? 1 : 0
218
- const chrome = { x: pad.left + pad.right + border * 2, y: pad.top + pad.bottom + border * 2 }
289
+ const be = resolveBorderEdges(style)
290
+ const chrome = { x: pad.left + pad.right + be.left + be.right, y: pad.top + pad.bottom + be.top + be.bottom }
219
291
  const innerW = availW - chrome.x
220
292
  const innerH = availH - chrome.y
221
293
 
package/src/list.js CHANGED
@@ -1,16 +1,32 @@
1
- import { jsx } from '../jsx-runtime.js'
1
+ import { jsx, jsxs } from '../jsx-runtime.js'
2
2
  import { createSignal } from './signal.js'
3
- import { useInput, useLayout } from './hooks.js'
3
+ import { useInput, useMouse, useLayout, useTheme, useScrollDrag } from './hooks.js'
4
4
 
5
- export function List({ items, selected: selectedProp, onSelect, height, renderItem, header, focused = true }) {
5
+ export function List({ items, selected: selectedProp, onSelect, renderItem, header, headerHeight = 1, focused = true, itemHeight = 1, scrollbar = false, stickyHeader = false, gap = 0 }) {
6
+ const { accent = 'cyan' } = useTheme()
6
7
  const [selectedInternal, setSelectedInternal] = createSignal(0)
7
8
  const layout = useLayout()
8
9
 
9
10
  const selected = selectedProp ?? selectedInternal()
10
11
  const setSelected = onSelect ?? setSelectedInternal
11
12
 
12
- const rawH = height ?? layout.height
13
- const visibleH = header && rawH > 0 ? rawH - 1 : rawH
13
+ const viewH = layout.height
14
+ const contentH = layout.contentHeight ?? 0
15
+ const headerH = header ? headerHeight : 0
16
+ const sticky = header && stickyHeader
17
+
18
+ const innerHeaderH = sticky ? 0 : headerH
19
+
20
+ // when sticky, layout measures the outer wrapper so we can't derive item height from it
21
+ // use itemHeight directly and compute content from item count
22
+ const avgH = !sticky && contentH > 0 && items.length > 0
23
+ ? (contentH - headerH) / items.length
24
+ : itemHeight
25
+
26
+ const scrollViewH = sticky ? viewH - headerH : viewH
27
+ const scrollContentH = sticky ? items.length * avgH : contentH
28
+
29
+ const maxOffset = Math.max(0, scrollContentH - scrollViewH)
14
30
 
15
31
  useInput(({ key, ctrl }) => {
16
32
  if (!focused) return
@@ -18,29 +34,136 @@ export function List({ items, selected: selectedProp, onSelect, height, renderIt
18
34
  const len = items.length
19
35
  if (len === 0) return
20
36
 
21
- const h = visibleH || 10
22
- const half = Math.max(1, Math.floor(h / 2))
37
+ const pageItems = viewH > 0 ? Math.max(1, Math.floor(viewH / avgH)) : 10
38
+ const half = Math.max(1, Math.floor(pageItems / 2))
23
39
 
24
40
  if (key === 'up' || key === 'k') setSelected(Math.max(0, selected - 1))
25
41
  else if (key === 'down' || key === 'j') setSelected(Math.min(len - 1, selected + 1))
26
- else if (key === 'pageup' || (ctrl && key === 'b')) setSelected(Math.max(0, selected - h))
27
- else if (key === 'pagedown' || (ctrl && key === 'f')) setSelected(Math.min(len - 1, selected + h))
42
+ else if (key === 'pageup' || (ctrl && key === 'b')) setSelected(Math.max(0, selected - pageItems))
43
+ else if (key === 'pagedown' || (ctrl && key === 'f')) setSelected(Math.min(len - 1, selected + pageItems))
28
44
  else if (ctrl && key === 'u') setSelected(Math.max(0, selected - half))
29
45
  else if (ctrl && key === 'd') setSelected(Math.min(len - 1, selected + half))
30
46
  else if (key === 'home' || key === 'g') setSelected(0)
31
47
  else if (key === 'end' || key === 'G') setSelected(len - 1)
32
48
  })
33
49
 
34
- const scrollOffset = visibleH > 0 && items.length > visibleH
35
- ? Math.max(0, Math.min(selected - Math.floor(visibleH / 2), items.length - visibleH))
50
+ useMouse((event) => {
51
+ if (!focused) return
52
+ const len = items.length
53
+ if (len === 0) return
54
+ const { x, y } = event
55
+ if (x < layout.x || x >= layout.x + layout.width || y < layout.y || y >= layout.y + layout.height) return
56
+
57
+ if (event.action === 'scroll') {
58
+ if (event.direction === 'up') setSelected(Math.max(0, selected - 1))
59
+ else setSelected(Math.min(len - 1, selected + 1))
60
+ event.stopPropagation()
61
+ return
62
+ }
63
+
64
+ if (event.action === 'press' && event.button === 'left') {
65
+ const headerOffset = sticky ? headerH : (header ? headerH : 0)
66
+ const relY = y - layout.y - headerOffset
67
+ const idx = Math.floor((relY + scrollOffset) / Math.max(1, avgH))
68
+ if (idx >= 0 && idx < len) {
69
+ setSelected(idx)
70
+ event.stopPropagation()
71
+ }
72
+ }
73
+ })
74
+
75
+ const hasBar = scrollbar && scrollViewH > 0 && scrollContentH > scrollViewH
76
+ const barThumbH = hasBar ? Math.max(1, Math.round((scrollViewH / scrollContentH) * scrollViewH)) : 0
77
+
78
+ useScrollDrag({
79
+ barX: hasBar ? layout.x + layout.width - 1 : null,
80
+ barY: layout.y + (sticky ? headerH : 0),
81
+ thumbHeight: barThumbH,
82
+ trackHeight: scrollViewH,
83
+ maxOffset,
84
+ scrollOffset: selected * avgH,
85
+ onScroll: (offset) => {
86
+ const idx = Math.round(offset / Math.max(1, avgH))
87
+ setSelected(Math.max(0, Math.min(items.length - 1, idx)))
88
+ },
89
+ })
90
+
91
+ const itemTop = selected * avgH + innerHeaderH
92
+ const itemBottom = itemTop + avgH
93
+
94
+ let scrollOffset = 0
95
+ if (scrollViewH > 0 && scrollContentH > scrollViewH) {
96
+ const centered = itemTop - Math.floor((scrollViewH - avgH) / 2)
97
+ scrollOffset = Math.max(0, Math.min(maxOffset, centered))
98
+ if (itemTop < scrollOffset) scrollOffset = itemTop
99
+ if (itemBottom > scrollOffset + scrollViewH) scrollOffset = itemBottom - scrollViewH
100
+ scrollOffset = Math.max(0, Math.min(maxOffset, Math.round(scrollOffset)))
101
+ }
102
+
103
+ const scrollChildren = []
104
+ if (header && !sticky) scrollChildren.push(header)
105
+ for (let i = 0; i < items.length; i++) {
106
+ scrollChildren.push(renderItem(items[i], { selected: i === selected, index: i, focused }))
107
+ }
108
+
109
+ const scrollBox = jsx('box', {
110
+ style: {
111
+ flexDirection: 'column',
112
+ flexGrow: 1,
113
+ overflow: 'scroll',
114
+ scrollOffset,
115
+ gap,
116
+ },
117
+ children: scrollChildren,
118
+ })
119
+
120
+ const list = sticky
121
+ ? jsxs('box', {
122
+ style: { flexDirection: 'column', flexGrow: 1 },
123
+ children: [header, scrollBox],
124
+ })
125
+ : scrollBox
126
+
127
+ if (!scrollbar || scrollViewH <= 0 || scrollContentH <= scrollViewH) return list
128
+
129
+ const thumbH = Math.max(1, Math.round((scrollViewH / scrollContentH) * scrollViewH))
130
+ const thumbStart = maxOffset > 0
131
+ ? Math.round((scrollOffset / maxOffset) * (scrollViewH - thumbH))
36
132
  : 0
37
133
 
38
- const children = []
39
- if (header) children.push(header)
134
+ const barH = sticky ? scrollViewH : viewH
135
+ const barChildren = []
136
+ for (let i = 0; i < barH; i++) {
137
+ const isThumb = i >= thumbStart && i < thumbStart + thumbH
138
+ barChildren.push(
139
+ jsx('text', {
140
+ key: i,
141
+ style: { color: isThumb ? (focused ? accent : 'gray') : 'gray', dim: !isThumb },
142
+ children: isThumb ? '\u2588' : '\u2502',
143
+ })
144
+ )
145
+ }
146
+
147
+ const scrollBarCol = jsx('box', {
148
+ style: { width: 1, flexDirection: 'column' },
149
+ children: barChildren,
150
+ })
40
151
 
41
- for (let i = scrollOffset; i < items.length; i++) {
42
- children.push(renderItem(items[i], { selected: i === selected, index: i, focused }))
152
+ if (sticky) {
153
+ return jsxs('box', {
154
+ style: { flexDirection: 'column', flexGrow: 1 },
155
+ children: [
156
+ header,
157
+ jsxs('box', {
158
+ style: { flexDirection: 'row', flexGrow: 1, gap: 1 },
159
+ children: [scrollBox, scrollBarCol],
160
+ }),
161
+ ],
162
+ })
43
163
  }
44
164
 
45
- return jsx('box', { style: { flexDirection: 'column', flexGrow: 1 }, children })
165
+ return jsxs('box', {
166
+ style: { flexDirection: 'row', flexGrow: 1, gap: 1 },
167
+ children: [list, scrollBarCol],
168
+ })
46
169
  }
package/src/progress.js CHANGED
@@ -1,20 +1,68 @@
1
1
  import { jsx, jsxs } from '../jsx-runtime.js'
2
2
  import { useTheme } from './hooks.js'
3
+ import { useLayout } from './hooks.js'
3
4
 
4
- export function ProgressBar({ value = 0, width = 20, color, label }) {
5
+ const BARS = {
6
+ thin: { filled: '\u2501', empty: '\u2501' },
7
+ block: { filled: '\u2588', empty: '\u2591' },
8
+ ascii: { filled: '#', empty: '-', bracket: true },
9
+ braille: {
10
+ filled: '\u28ff', empty: '\u28ff',
11
+ tips: ['\u2801', '\u2803', '\u2807', '\u280f', '\u281f', '\u283f', '\u287f'],
12
+ },
13
+ }
14
+
15
+ function renderBar(variant, value, width) {
16
+ const style = BARS[variant] ?? BARS.thin
17
+
18
+ if (style.bracket) {
19
+ const inner = Math.max(0, width - 2)
20
+ const filled = Math.round(value * inner)
21
+ return { open: '[', filled: style.filled.repeat(filled), empty: style.empty.repeat(inner - filled), close: ']' }
22
+ }
23
+
24
+ if (style.tips) {
25
+ const exact = value * width
26
+ const full = Math.floor(exact)
27
+ const frac = exact - full
28
+ const tipIdx = Math.min(style.tips.length - 1, Math.round(frac * (style.tips.length - 1)))
29
+ const tip = full < width && frac > 0 ? style.tips[tipIdx] : ''
30
+ const emptyCount = Math.max(0, width - full - (tip ? 1 : 0))
31
+ return { filled: style.filled.repeat(full) + tip, empty: style.empty.repeat(emptyCount) }
32
+ }
33
+
34
+ const filled = Math.round(value * width)
35
+ return { filled: style.filled.repeat(filled), empty: style.empty.repeat(width - filled) }
36
+ }
37
+
38
+ export function ProgressBar({ value = 0, variant = 'thin', width, color, label, count, percentage = true }) {
5
39
  const { accent = 'cyan' } = useTheme()
6
40
  const c = color ?? accent
7
41
  const clamped = Math.max(0, Math.min(1, value))
8
- const filled = Math.round(clamped * width)
9
- const empty = width - filled
10
- const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(empty)
42
+ const layout = useLayout()
43
+
44
+ const labelPart = label ? `${label} ` : ''
45
+ const pctPart = percentage ? ` ${Math.round(clamped * 100)}%` : ''
46
+ const countPart = count ? ` (${count})` : ''
47
+ const rightText = pctPart + countPart
48
+ const reservedWidth = labelPart.length + rightText.length
49
+ const barWidth = width ?? Math.max(5, (layout.width || 20) - reservedWidth)
50
+
51
+ const bar = renderBar(variant, clamped, barWidth)
52
+
53
+ const children = []
54
+
55
+ if (label) {
56
+ children.push(jsx('text', { style: { bold: true }, children: labelPart }))
57
+ }
11
58
 
12
- const children = [
13
- jsx('text', { style: { color: c }, children: bar }),
14
- ]
59
+ if (bar.open) children.push(jsx('text', { children: bar.open }))
60
+ children.push(jsx('text', { style: { color: c }, children: bar.filled }))
61
+ children.push(jsx('text', { style: { color: c, dim: true }, children: bar.empty }))
62
+ if (bar.close) children.push(jsx('text', { children: bar.close }))
15
63
 
16
- if (label != null) {
17
- children.push(jsx('text', { style: { color: 'gray', dim: true }, children: ` ${label}` }))
64
+ if (rightText) {
65
+ children.push(jsx('text', { style: { color: 'gray' }, children: rightText }))
18
66
  }
19
67
 
20
68
  return jsxs('box', { style: { flexDirection: 'row' }, children })