@trendr/core 0.1.0 → 0.2.1

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.
@@ -0,0 +1,204 @@
1
+ import { jsx, jsxs } from '../jsx-runtime.js'
2
+ import { createSignal } from './signal.js'
3
+ import { useInput, useLayout, useTheme, useCursor } from './hooks.js'
4
+ import { List } from './list.js'
5
+
6
+ export function PickList({ items, onSelect, onCancel, onChange, focused = true, placeholder = 'search...', filter: filterFn, renderItem, maxVisible = 10, scrollbar = false, scrolloff = 0, itemHeight = 1, itemGap = 0, gap = 0, clearOnSelect = false, style: userStyle, cursor: cursorProp }) {
7
+ const { accent = 'cyan' } = useTheme()
8
+ const defaults = {
9
+ borderColor: accent,
10
+ cursorBg: accent,
11
+ cursorTextColor: 'black',
12
+ color: null,
13
+ }
14
+ const s = { ...defaults, ...userStyle }
15
+
16
+ const [query, setQuery] = createSignal('')
17
+ const [textCursor, setTextCursor] = createSignal(0)
18
+ const [listCursor, setListCursor] = createSignal(0)
19
+ const layout = useLayout()
20
+ const { cursorStyle, reset: resetBlink } = useCursor(cursorProp, focused)
21
+
22
+ const defaultFilter = (q, item) => {
23
+ const label = typeof item === 'string' ? item : (item.label ?? item.name ?? '')
24
+ return label.toLowerCase().includes(q.toLowerCase())
25
+ }
26
+
27
+ const match = filterFn ?? defaultFilter
28
+
29
+ const q = query()
30
+ const filtered = q ? items.filter(item => match(q, item)) : items
31
+
32
+ let cursor = listCursor()
33
+ if (cursor >= filtered.length) cursor = Math.max(0, filtered.length - 1)
34
+ if (cursor !== listCursor()) setListCursor(cursor)
35
+
36
+ function updateText(v, c) {
37
+ setQuery(v)
38
+ setTextCursor(c)
39
+ setListCursor(0)
40
+ if (onChange) onChange(v)
41
+ }
42
+
43
+ useInput((event) => {
44
+ if (!focused) return
45
+
46
+ const { key, raw, ctrl } = event
47
+ const len = filtered.length
48
+
49
+ if (key === 'up' || (ctrl && raw === '\x10')) {
50
+ if (len > 0) setListCursor(c => Math.max(0, c - 1))
51
+ event.stopPropagation()
52
+ return
53
+ }
54
+ if (key === 'down' || (ctrl && raw === '\x0e')) {
55
+ if (len > 0) setListCursor(c => Math.min(len - 1, c + 1))
56
+ event.stopPropagation()
57
+ return
58
+ }
59
+ if (ctrl && key === 'u') { if (len > 0) setListCursor(c => Math.max(0, c - 5)); event.stopPropagation(); return }
60
+ if (ctrl && key === 'd') { if (len > 0) setListCursor(c => Math.min(len - 1, c + 5)); event.stopPropagation(); return }
61
+ if (ctrl && key === 'b') { if (len > 0) setListCursor(c => Math.max(0, c - 10)); event.stopPropagation(); return }
62
+ if (ctrl && key === 'f') { if (len > 0) setListCursor(c => Math.min(len - 1, c + 10)); event.stopPropagation(); return }
63
+ if (key === 'pageup') { if (len > 0) setListCursor(c => Math.max(0, c - 10)); event.stopPropagation(); return }
64
+ if (key === 'pagedown') { if (len > 0) setListCursor(c => Math.min(len - 1, c + 10)); event.stopPropagation(); return }
65
+
66
+ if (key === 'return') {
67
+ if (filtered.length > 0 && onSelect) {
68
+ onSelect(filtered[cursor])
69
+ if (clearOnSelect) updateText('', 0)
70
+ event.stopPropagation()
71
+ }
72
+ return
73
+ }
74
+
75
+ if (key === 'escape') {
76
+ if (onCancel) {
77
+ onCancel()
78
+ event.stopPropagation()
79
+ }
80
+ return
81
+ }
82
+
83
+ resetBlink()
84
+ const v = query()
85
+ const c = textCursor()
86
+
87
+ if (key === 'backspace') {
88
+ if (c > 0) updateText(v.slice(0, c - 1) + v.slice(c), c - 1)
89
+ event.stopPropagation()
90
+ return
91
+ }
92
+
93
+ if (key === 'delete') {
94
+ if (c < v.length) updateText(v.slice(0, c) + v.slice(c + 1), c)
95
+ event.stopPropagation()
96
+ return
97
+ }
98
+
99
+ if (key === 'left') { setTextCursor(Math.max(0, c - 1)); event.stopPropagation(); return }
100
+ if (key === 'right') { setTextCursor(Math.min(v.length, c + 1)); event.stopPropagation(); return }
101
+
102
+ if (key === 'home' || (ctrl && raw === '\x01')) { setTextCursor(0); event.stopPropagation(); return }
103
+ if (key === 'end' || (ctrl && raw === '\x05')) { setTextCursor(v.length); event.stopPropagation(); return }
104
+
105
+ if (ctrl && raw === '\x15') { updateText(v.slice(c), 0); event.stopPropagation(); return }
106
+ if (ctrl && raw === '\x0b') { updateText(v.slice(0, c), c); event.stopPropagation(); return }
107
+
108
+ if (ctrl && raw === '\x17') {
109
+ const before = v.slice(0, c)
110
+ const after = v.slice(c)
111
+ const trimmed = before.replace(/\S+\s*$/, '')
112
+ updateText(trimmed + after, trimmed.length)
113
+ event.stopPropagation()
114
+ return
115
+ }
116
+
117
+ if (!ctrl && raw.length === 1 && raw >= ' ') {
118
+ updateText(v.slice(0, c) + raw + v.slice(c), c + raw.length)
119
+ event.stopPropagation()
120
+ }
121
+ })
122
+
123
+ // text input rendering
124
+ const v = query()
125
+ const tc = textCursor()
126
+ const w = layout.width || 0
127
+ const cs = cursorStyle()
128
+
129
+ let inputEl
130
+ if (!v && placeholder && !focused) {
131
+ inputEl = jsx('text', { style: { color: 'gray' }, children: placeholder })
132
+ } else if (!v && placeholder && focused) {
133
+ inputEl = jsxs('box', {
134
+ style: { flexDirection: 'row', height: 1, minHeight: 1 },
135
+ children: [
136
+ jsx('text', { style: cs ? { ...cs, color: cs.color ?? 'gray' } : { inverse: true, color: 'gray' }, children: placeholder[0] }),
137
+ placeholder.length > 1 && jsx('text', { style: { color: 'gray' }, children: placeholder.slice(1) }),
138
+ ],
139
+ })
140
+ } else {
141
+ const contentWidth = v.length + 1
142
+ const needsScroll = w > 0 && contentWidth > w
143
+
144
+ if (!needsScroll) {
145
+ const cursorChar = v[tc] || ' '
146
+ inputEl = jsxs('box', {
147
+ style: { flexDirection: 'row', height: 1, minHeight: 1 },
148
+ children: [
149
+ v.slice(0, tc) && jsx('text', { children: v.slice(0, tc) }),
150
+ jsx('text', { style: cs ?? {}, children: cursorChar }),
151
+ v.slice(tc + 1) && jsx('text', { children: v.slice(tc + 1) }),
152
+ ],
153
+ })
154
+ } else {
155
+ let scrollStart = 0
156
+ if (tc >= w) scrollStart = tc - w + 1
157
+ const visible = v.slice(scrollStart, scrollStart + w)
158
+ const cursorInView = tc - scrollStart
159
+ const before = visible.slice(0, cursorInView)
160
+ const cursorChar = visible[cursorInView] || ' '
161
+ const after = visible.slice(cursorInView + 1)
162
+
163
+ inputEl = jsxs('box', {
164
+ style: { flexDirection: 'row', height: 1, minHeight: 1 },
165
+ children: [
166
+ before && jsx('text', { children: before }),
167
+ jsx('text', { style: cs ?? {}, children: cursorChar }),
168
+ after && jsx('text', { children: after }),
169
+ ],
170
+ })
171
+ }
172
+ }
173
+
174
+ const defaultRenderItem = (item, { selected: isCursor, focused: isFocused }) => {
175
+ const label = typeof item === 'string' ? item : (item.label ?? item.name ?? String(item))
176
+ return jsx('box', {
177
+ style: { bg: isCursor ? (isFocused ? s.cursorBg : 'gray') : null },
178
+ children: jsx('text', {
179
+ style: { color: isCursor ? s.cursorTextColor : s.color },
180
+ children: ` ${label}`,
181
+ }),
182
+ })
183
+ }
184
+
185
+ const listRenderItem = renderItem ?? defaultRenderItem
186
+
187
+ const list = jsx(List, {
188
+ items: filtered,
189
+ selected: cursor,
190
+ onSelect: setListCursor,
191
+ focused,
192
+ interactive: false,
193
+ itemHeight,
194
+ gap: itemGap,
195
+ scrollbar,
196
+ scrolloff,
197
+ renderItem: listRenderItem,
198
+ })
199
+
200
+ return jsxs('box', {
201
+ style: { flexDirection: 'column', flexGrow: 1, gap },
202
+ children: [inputEl, list],
203
+ })
204
+ }
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 })
package/src/radio.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { jsx, jsxs } from '../jsx-runtime.js'
2
2
  import { createSignal } from './signal.js'
3
- import { useInput, useTheme } from './hooks.js'
3
+ import { useInput, useMouse, useLayout, useTheme } from './hooks.js'
4
4
 
5
5
  export function Radio({ options, selected, onSelect, focused = false }) {
6
6
  const { accent = 'cyan' } = useTheme()
@@ -29,12 +29,26 @@ export function Radio({ options, selected, onSelect, focused = false }) {
29
29
  }
30
30
  })
31
31
 
32
+ const layout = useLayout()
33
+
34
+ useMouse((event) => {
35
+ if (event.action !== 'press' || event.button !== 'left') return
36
+ const { x, y } = event
37
+ if (x < layout.x || x >= layout.x + layout.width || y < layout.y || y >= layout.y + layout.height) return
38
+ const idx = y - layout.y
39
+ if (idx >= 0 && idx < options.length) {
40
+ setCursor(idx)
41
+ onSelect?.(options[idx])
42
+ event.stopPropagation()
43
+ }
44
+ })
45
+
32
46
  const c = cursor()
33
47
 
34
48
  const children = options.map((option, i) => {
35
49
  const isSelected = option === selected
36
50
  const isCursor = focused && i === c
37
- const icon = isSelected ? '\u25c9' : '\u25cb'
51
+ const icon = isSelected ? '\u25cf' : '\u25cb'
38
52
  const bg = isCursor ? accent : null
39
53
  const color = isCursor ? 'black' : null
40
54