@trendr/core 0.1.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/input.js ADDED
@@ -0,0 +1,143 @@
1
+ const SPECIAL_KEYS = {
2
+ '\x1b[A': 'up',
3
+ '\x1b[B': 'down',
4
+ '\x1b[C': 'right',
5
+ '\x1b[D': 'left',
6
+ '\x1b[H': 'home',
7
+ '\x1b[F': 'end',
8
+ '\x1b[2~': 'insert',
9
+ '\x1b[3~': 'delete',
10
+ '\x1b[5~': 'pageup',
11
+ '\x1b[6~': 'pagedown',
12
+ '\x1bOP': 'f1',
13
+ '\x1bOQ': 'f2',
14
+ '\x1bOR': 'f3',
15
+ '\x1bOS': 'f4',
16
+ '\x1b[15~': 'f5',
17
+ '\x1b[17~': 'f6',
18
+ '\x1b[18~': 'f7',
19
+ '\x1b[19~': 'f8',
20
+ '\x1b[20~': 'f9',
21
+ '\x1b[21~': 'f10',
22
+ '\x1b[23~': 'f11',
23
+ '\x1b[24~': 'f12',
24
+ '\r': 'return',
25
+ '\n': 'return',
26
+ '\t': 'tab',
27
+ '\x1b[Z': 'shift-tab',
28
+ '\x7f': 'backspace',
29
+ '\x1b': 'escape',
30
+ ' ': 'space',
31
+ }
32
+
33
+ export function parseKey(data) {
34
+ const raw = typeof data === 'string' ? data : data.toString()
35
+
36
+ if (SPECIAL_KEYS[raw]) {
37
+ return { key: SPECIAL_KEYS[raw], ctrl: false, meta: false, shift: false, raw }
38
+ }
39
+
40
+ if (raw.length === 1) {
41
+ const code = raw.charCodeAt(0)
42
+
43
+ if (code >= 1 && code <= 26) {
44
+ return {
45
+ key: String.fromCharCode(code + 96),
46
+ ctrl: true,
47
+ meta: false,
48
+ shift: false,
49
+ raw,
50
+ }
51
+ }
52
+
53
+ return { key: raw, ctrl: false, meta: false, shift: false, raw }
54
+ }
55
+
56
+ if (raw.startsWith('\x1b') && raw.length === 2) {
57
+ return { key: raw[1], ctrl: false, meta: true, shift: false, raw }
58
+ }
59
+
60
+ return { key: raw, ctrl: false, meta: false, shift: false, raw }
61
+ }
62
+
63
+ export function splitKeys(data) {
64
+ const raw = typeof data === 'string' ? data : data.toString()
65
+ const keys = []
66
+ let i = 0
67
+
68
+ while (i < raw.length) {
69
+ if (raw[i] === '\x1b') {
70
+ if (i + 1 < raw.length && raw[i + 1] === '[') {
71
+ let j = i + 2
72
+ while (j < raw.length && raw[j] >= '0' && raw[j] <= '9') j++
73
+ if (j < raw.length && raw[j] === ';') {
74
+ j++
75
+ while (j < raw.length && raw[j] >= '0' && raw[j] <= '9') j++
76
+ }
77
+ if (j < raw.length) j++
78
+ keys.push(raw.slice(i, j))
79
+ i = j
80
+ } else if (i + 1 < raw.length && raw[i + 1] === 'O') {
81
+ const end = Math.min(i + 3, raw.length)
82
+ keys.push(raw.slice(i, end))
83
+ i = end
84
+ } else if (i + 1 < raw.length) {
85
+ keys.push(raw.slice(i, i + 2))
86
+ i += 2
87
+ } else {
88
+ keys.push(raw[i])
89
+ i++
90
+ }
91
+ } else {
92
+ keys.push(raw[i])
93
+ i++
94
+ }
95
+ }
96
+
97
+ return keys
98
+ }
99
+
100
+ export function createInputHandler(stream) {
101
+ const listeners = new Set()
102
+
103
+ function dispatch(keyStr) {
104
+ const event = parseKey(keyStr)
105
+ event.stopPropagation = () => { event._stopped = true }
106
+ const snapshot = [...listeners].reverse()
107
+ for (const fn of snapshot) {
108
+ fn(event)
109
+ if (event._stopped) break
110
+ }
111
+ }
112
+
113
+ function onData(data) {
114
+ for (const key of splitKeys(data)) {
115
+ dispatch(key)
116
+ }
117
+ }
118
+
119
+ let attached = false
120
+
121
+ function attach() {
122
+ if (attached) return
123
+ attached = true
124
+ stream.on('data', onData)
125
+ }
126
+
127
+ function detach() {
128
+ if (!attached) return
129
+ attached = false
130
+ stream.off('data', onData)
131
+ }
132
+
133
+ function onKey(fn) {
134
+ listeners.add(fn)
135
+ if (listeners.size === 1) attach()
136
+ return () => {
137
+ listeners.delete(fn)
138
+ if (listeners.size === 0) detach()
139
+ }
140
+ }
141
+
142
+ return { onKey, attach, detach }
143
+ }
package/src/layout.js ADDED
@@ -0,0 +1,312 @@
1
+ import { wordWrap, measureText } from './wrap.js'
2
+ import { Fragment } from './element.js'
3
+
4
+ export function computeLayout(node, rect) {
5
+ if (!node) return
6
+
7
+ if (node._resolved) {
8
+ computeLayout(node._resolved, rect)
9
+ node._layout = node._resolved._layout
10
+ node._availableRect = rect
11
+ return
12
+ }
13
+
14
+ if (node.type === Fragment) {
15
+ node._layout = rect
16
+ if (node._resolvedChildren) {
17
+ for (const child of node._resolvedChildren) {
18
+ computeLayout(child, rect)
19
+ }
20
+ }
21
+ return
22
+ }
23
+
24
+ const style = node.props?.style ?? {}
25
+
26
+ const absW = typeof style.width === 'number' ? style.width : null
27
+ const absH = typeof style.height === 'number' ? style.height : null
28
+
29
+ const box = {
30
+ x: rect.x,
31
+ y: rect.y,
32
+ width: clampSize(absW != null ? Math.min(absW, rect.width) : rect.width, style.minWidth, style.maxWidth),
33
+ height: clampSize(absH != null ? Math.min(absH, rect.height) : rect.height, style.minHeight, style.maxHeight),
34
+ }
35
+
36
+ if (node.type === 'text') {
37
+ const text = extractText(node)
38
+ if (text) {
39
+ const lines = wordWrap(text, box.width)
40
+ if (style.height == null) box.height = Math.min(lines.length, rect.height)
41
+ } else {
42
+ if (style.height == null) box.height = 1
43
+ }
44
+ node._layout = box
45
+ return
46
+ }
47
+
48
+ node._layout = box
49
+
50
+ const children = node._resolvedChildren
51
+ if (!children || children.length === 0) return
52
+
53
+ 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)
59
+
60
+ const isRow = style.flexDirection === 'row'
61
+ const gap = style.gap ?? 0
62
+
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
+ })
73
+ }
74
+
75
+ function layoutFlex(children, ctx) {
76
+ const { x, y, width, height, isRow, gap, justifyContent, alignItems } = ctx
77
+ const mainSize = isRow ? width : height
78
+ const crossSize = isRow ? height : width
79
+ const totalGaps = Math.max(0, children.length - 1) * gap
80
+
81
+ let usedMain = totalGaps
82
+ let totalFlex = 0
83
+ const childInfo = []
84
+
85
+ for (const child of children) {
86
+ const cs = childStyle(child)
87
+ const grow = cs.flexGrow ?? cs.flex ?? 0
88
+ const margin = resolveMargin(cs)
89
+ const marginMain = isRow ? margin.left + margin.right : margin.top + margin.bottom
90
+ const marginCross = isRow ? margin.top + margin.bottom : margin.left + margin.right
91
+
92
+ if (grow > 0) {
93
+ const minMain = isRow ? (cs.minWidth ?? 0) : (cs.minHeight ?? 0)
94
+ usedMain += minMain + marginMain
95
+ totalFlex += grow
96
+ childInfo.push({ child, cs, grow, minMain, marginMain, marginCross, margin, measured: null })
97
+ } else {
98
+ const measured = measureChild(child, cs, isRow, width, height)
99
+ const childMain = isRow ? measured.width : measured.height
100
+ usedMain += childMain + marginMain
101
+ childInfo.push({ child, cs, grow: 0, minMain: childMain, marginMain, marginCross, margin, measured })
102
+ }
103
+ }
104
+
105
+ const remaining = Math.max(0, mainSize - usedMain)
106
+
107
+ let mainOffset = 0
108
+ let spaceBetween = 0
109
+
110
+ if (totalFlex === 0) {
111
+ switch (justifyContent) {
112
+ case 'center':
113
+ mainOffset = Math.floor(remaining / 2)
114
+ break
115
+ case 'flex-end':
116
+ mainOffset = remaining
117
+ break
118
+ case 'space-between':
119
+ spaceBetween = children.length > 1 ? remaining / (children.length - 1) : 0
120
+ break
121
+ case 'space-around':
122
+ spaceBetween = remaining / children.length
123
+ mainOffset = Math.floor(spaceBetween / 2)
124
+ break
125
+ }
126
+ }
127
+
128
+ let pos = mainOffset
129
+
130
+ for (const info of childInfo) {
131
+ const { child, cs, grow, minMain, marginMain, marginCross, margin, measured } = info
132
+
133
+ let childMain
134
+ if (grow > 0) {
135
+ const extra = totalFlex > 0 ? Math.floor(remaining * (grow / totalFlex)) : 0
136
+ childMain = minMain + extra
137
+ } else {
138
+ childMain = minMain
139
+ }
140
+
141
+ const mainRemaining = mainSize - pos - marginMain
142
+ if (childMain > mainRemaining) childMain = Math.max(0, mainRemaining)
143
+
144
+ const explicitCross = isRow
145
+ ? resolveSize(cs.height, crossSize)
146
+ : resolveSize(cs.width, crossSize)
147
+ let childCross = crossSize - marginCross
148
+ if (alignItems !== 'stretch' || explicitCross != null) {
149
+ const measuredCross = measured
150
+ ? (isRow ? measured.height : measured.width)
151
+ : childCross
152
+ childCross = Math.min(measuredCross, childCross)
153
+ }
154
+
155
+ const marginBefore = isRow ? margin.left : margin.top
156
+ const marginCrossBefore = isRow ? margin.top : margin.left
157
+
158
+ let crossOffset = marginCrossBefore
159
+ switch (alignItems) {
160
+ case 'center':
161
+ crossOffset = Math.floor((crossSize - marginCross - childCross) / 2) + marginCrossBefore
162
+ break
163
+ case 'flex-end':
164
+ crossOffset = crossSize - marginCross - childCross + marginCrossBefore
165
+ break
166
+ }
167
+
168
+ const childRect = isRow
169
+ ? { x: x + pos + marginBefore, y: y + crossOffset, width: childMain, height: childCross }
170
+ : { x: x + crossOffset, y: y + pos + marginBefore, width: childCross, height: childMain }
171
+
172
+ computeLayout(child, childRect)
173
+
174
+ pos += childMain + marginMain + gap + spaceBetween
175
+ }
176
+ }
177
+
178
+ function measureChild(child, cs, isRow, availW, availH) {
179
+ const leaf = getLeaf(child)
180
+ if (!leaf) return { width: 0, height: 0 }
181
+
182
+ const explicitW = resolveSize(cs.width, availW)
183
+ const explicitH = resolveSize(cs.height, availH)
184
+
185
+ let w, h
186
+
187
+ if (leaf.type === 'text') {
188
+ const text = extractText(leaf)
189
+ if (!text) { w = explicitW ?? 0; h = explicitH ?? 1 }
190
+ else {
191
+ const maxW = explicitW ?? availW
192
+ const lines = wordWrap(text, maxW)
193
+ const textWidth = Math.min(maxW, Math.max(...lines.map(l => measureText(l))))
194
+ w = explicitW ?? textWidth
195
+ h = explicitH ?? lines.length
196
+ }
197
+ } else if (explicitW != null && explicitH != null) {
198
+ w = explicitW
199
+ h = explicitH
200
+ } else {
201
+ const intrinsic = measureIntrinsic(leaf, availW, availH)
202
+ w = explicitW ?? intrinsic.width
203
+ h = explicitH ?? intrinsic.height
204
+ }
205
+
206
+ return {
207
+ width: clampSize(w, cs.minWidth, cs.maxWidth),
208
+ height: clampSize(h, cs.minHeight, cs.maxHeight),
209
+ }
210
+ }
211
+
212
+ function measureIntrinsic(node, availW, availH) {
213
+ if (!node) return { width: 0, height: 0 }
214
+
215
+ const style = node.props?.style ?? {}
216
+ 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 }
219
+ const innerW = availW - chrome.x
220
+ const innerH = availH - chrome.y
221
+
222
+ const children = node._resolvedChildren
223
+ if (!children || children.length === 0) {
224
+ return { width: chrome.x, height: chrome.y }
225
+ }
226
+
227
+ const childIsRow = style.flexDirection === 'row'
228
+ const gap = style.gap ?? 0
229
+
230
+ let mainTotal = 0
231
+ let crossMax = 0
232
+
233
+ for (const child of children) {
234
+ const cs = childStyle(child)
235
+ const grow = cs.flexGrow ?? cs.flex ?? 0
236
+
237
+ const measured = measureChild(child, cs, childIsRow, innerW, innerH)
238
+ const margin = resolveMargin(cs)
239
+ const marginMain = childIsRow ? margin.left + margin.right : margin.top + margin.bottom
240
+ const marginCross = childIsRow ? margin.top + margin.bottom : margin.left + margin.right
241
+
242
+ const childMain = (childIsRow ? measured.width : measured.height) + marginMain
243
+ mainTotal += childMain
244
+
245
+ const childCross = (childIsRow ? measured.height : measured.width) + marginCross
246
+ if (childCross > crossMax) crossMax = childCross
247
+ }
248
+
249
+ mainTotal += Math.max(0, children.length - 1) * gap
250
+
251
+ return childIsRow
252
+ ? { width: mainTotal + chrome.x, height: crossMax + chrome.y }
253
+ : { width: crossMax + chrome.x, height: mainTotal + chrome.y }
254
+ }
255
+
256
+ function getLeaf(node) {
257
+ if (!node) return null
258
+ if (node._resolved) return getLeaf(node._resolved)
259
+ return node
260
+ }
261
+
262
+ function childStyle(child) {
263
+ const leaf = getLeaf(child)
264
+ return leaf?.props?.style ?? {}
265
+ }
266
+
267
+ function resolveSize(value, available) {
268
+ if (value == null) return null
269
+ if (typeof value === 'number') return value
270
+ if (typeof value === 'string' && value.endsWith('%')) {
271
+ return Math.floor(available * parseFloat(value) / 100)
272
+ }
273
+ return null
274
+ }
275
+
276
+ function clampSize(value, min, max) {
277
+ if (min != null && value < min) value = min
278
+ if (max != null && value > max) value = max
279
+ return Math.max(0, Math.floor(value))
280
+ }
281
+
282
+ function resolvePadding(style) {
283
+ const p = style.padding ?? 0
284
+ return {
285
+ top: style.paddingTop ?? style.paddingY ?? p,
286
+ bottom: style.paddingBottom ?? style.paddingY ?? p,
287
+ left: style.paddingLeft ?? style.paddingX ?? p,
288
+ right: style.paddingRight ?? style.paddingX ?? p,
289
+ }
290
+ }
291
+
292
+ function resolveMargin(style) {
293
+ const m = style.margin ?? 0
294
+ return {
295
+ top: style.marginTop ?? style.marginY ?? m,
296
+ bottom: style.marginBottom ?? style.marginY ?? m,
297
+ left: style.marginLeft ?? style.marginX ?? m,
298
+ right: style.marginRight ?? style.marginX ?? m,
299
+ }
300
+ }
301
+
302
+ function extractText(node) {
303
+ if (node == null || node === true || node === false) return ''
304
+ if (typeof node === 'string') return node
305
+ if (typeof node === 'number') return String(node)
306
+ const children = node.props?.children
307
+ if (children == null || children === true || children === false) return ''
308
+ if (typeof children === 'string') return children
309
+ if (typeof children === 'number') return String(children)
310
+ if (Array.isArray(children)) return children.map(c => extractText(c)).join('')
311
+ return ''
312
+ }
package/src/list.js ADDED
@@ -0,0 +1,46 @@
1
+ import { jsx } from '../jsx-runtime.js'
2
+ import { createSignal } from './signal.js'
3
+ import { useInput, useLayout } from './hooks.js'
4
+
5
+ export function List({ items, selected: selectedProp, onSelect, height, renderItem, header, focused = true }) {
6
+ const [selectedInternal, setSelectedInternal] = createSignal(0)
7
+ const layout = useLayout()
8
+
9
+ const selected = selectedProp ?? selectedInternal()
10
+ const setSelected = onSelect ?? setSelectedInternal
11
+
12
+ const rawH = height ?? layout.height
13
+ const visibleH = header && rawH > 0 ? rawH - 1 : rawH
14
+
15
+ useInput(({ key, ctrl }) => {
16
+ if (!focused) return
17
+
18
+ const len = items.length
19
+ if (len === 0) return
20
+
21
+ const h = visibleH || 10
22
+ const half = Math.max(1, Math.floor(h / 2))
23
+
24
+ if (key === 'up' || key === 'k') setSelected(Math.max(0, selected - 1))
25
+ 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))
28
+ else if (ctrl && key === 'u') setSelected(Math.max(0, selected - half))
29
+ else if (ctrl && key === 'd') setSelected(Math.min(len - 1, selected + half))
30
+ else if (key === 'home' || key === 'g') setSelected(0)
31
+ else if (key === 'end' || key === 'G') setSelected(len - 1)
32
+ })
33
+
34
+ const scrollOffset = visibleH > 0 && items.length > visibleH
35
+ ? Math.max(0, Math.min(selected - Math.floor(visibleH / 2), items.length - visibleH))
36
+ : 0
37
+
38
+ const children = []
39
+ if (header) children.push(header)
40
+
41
+ for (let i = scrollOffset; i < items.length; i++) {
42
+ children.push(renderItem(items[i], { selected: i === selected, index: i, focused }))
43
+ }
44
+
45
+ return jsx('box', { style: { flexDirection: 'column', flexGrow: 1 }, children })
46
+ }
package/src/modal.js ADDED
@@ -0,0 +1,44 @@
1
+ import { jsx } from '../jsx-runtime.js'
2
+ import { useInput, useTheme } from './hooks.js'
3
+ import { registerOverlay } from './renderer.js'
4
+
5
+ export function Modal({ open, onClose, title, children, width: w = 40 }) {
6
+ const { accent = 'cyan' } = useTheme()
7
+
8
+ useInput((event) => {
9
+ if (!open) return
10
+ if (event.key === 'escape') {
11
+ onClose?.()
12
+ event.stopPropagation()
13
+ }
14
+ })
15
+
16
+ if (!open) return null
17
+
18
+ const content = jsx('box', {
19
+ style: {
20
+ width: w,
21
+ border: 'round',
22
+ borderColor: accent,
23
+ flexDirection: 'column',
24
+ paddingX: 1,
25
+ },
26
+ children: [
27
+ title && jsx('text', { style: { bold: true, color: accent }, children: title }),
28
+ ...(Array.isArray(children) ? children : [children]),
29
+ ].filter(Boolean),
30
+ })
31
+
32
+ const overlay = jsx('box', {
33
+ style: {
34
+ height: '100%',
35
+ width: '100%',
36
+ justifyContent: 'center',
37
+ alignItems: 'center',
38
+ },
39
+ children: content,
40
+ })
41
+
42
+ registerOverlay(overlay, { backdrop: true })
43
+ return null
44
+ }
@@ -0,0 +1,21 @@
1
+ import { jsx, jsxs } from '../jsx-runtime.js'
2
+ import { useTheme } from './hooks.js'
3
+
4
+ export function ProgressBar({ value = 0, width = 20, color, label }) {
5
+ const { accent = 'cyan' } = useTheme()
6
+ const c = color ?? accent
7
+ 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)
11
+
12
+ const children = [
13
+ jsx('text', { style: { color: c }, children: bar }),
14
+ ]
15
+
16
+ if (label != null) {
17
+ children.push(jsx('text', { style: { color: 'gray', dim: true }, children: ` ${label}` }))
18
+ }
19
+
20
+ return jsxs('box', { style: { flexDirection: 'row' }, children })
21
+ }
package/src/radio.js ADDED
@@ -0,0 +1,52 @@
1
+ import { jsx, jsxs } from '../jsx-runtime.js'
2
+ import { createSignal } from './signal.js'
3
+ import { useInput, useTheme } from './hooks.js'
4
+
5
+ export function Radio({ options, selected, onSelect, focused = false }) {
6
+ const { accent = 'cyan' } = useTheme()
7
+ const [cursor, setCursor] = createSignal(Math.max(0, options.indexOf(selected)))
8
+
9
+ useInput((event) => {
10
+ if (!focused) return
11
+
12
+ const len = options.length
13
+ if (len === 0) return
14
+ const { key } = event
15
+
16
+ if (key === 'up' || key === 'k') {
17
+ if (cursor() > 0) {
18
+ setCursor(c => c - 1)
19
+ event.stopPropagation()
20
+ }
21
+ } else if (key === 'down' || key === 'j') {
22
+ if (cursor() < len - 1) {
23
+ setCursor(c => c + 1)
24
+ event.stopPropagation()
25
+ }
26
+ } else if (key === 'return' || key === 'space') {
27
+ onSelect?.(options[cursor()])
28
+ event.stopPropagation()
29
+ }
30
+ })
31
+
32
+ const c = cursor()
33
+
34
+ const children = options.map((option, i) => {
35
+ const isSelected = option === selected
36
+ const isCursor = focused && i === c
37
+ const icon = isSelected ? '\u25c9' : '\u25cb'
38
+ const bg = isCursor ? accent : null
39
+ const color = isCursor ? 'black' : null
40
+
41
+ return jsxs('box', {
42
+ key: option,
43
+ style: { flexDirection: 'row', bg },
44
+ children: [
45
+ jsx('text', { style: { color, bold: isCursor }, children: icon }),
46
+ jsx('text', { style: { color }, children: ` ${option}` }),
47
+ ],
48
+ })
49
+ })
50
+
51
+ return jsx('box', { style: { flexDirection: 'column' }, children })
52
+ }