@trendr/core 0.2.11 → 0.4.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.
- package/README.md +284 -24
- package/index.js +6 -2
- package/package.json +29 -34
- package/src/animation.js +25 -12
- package/src/ansi.js +6 -0
- package/src/buffer.js +63 -50
- package/src/button.js +2 -2
- package/src/checkbox.js +2 -2
- package/src/diff-engine.js +313 -0
- package/src/diff-view.js +238 -0
- package/src/diff.js +2 -2
- package/src/dropdown.js +147 -0
- package/src/focus.js +89 -27
- package/src/hooks.js +43 -25
- package/src/hotkey.js +51 -16
- package/src/input.js +211 -72
- package/src/layout.js +34 -13
- package/src/list.js +29 -16
- package/src/markdown.js +177 -0
- package/src/menu.js +13 -6
- package/src/menubar.js +46 -115
- package/src/miller-nav.js +36 -11
- package/src/modal.js +3 -1
- package/src/pick-list.js +16 -17
- package/src/progress.js +3 -3
- package/src/radio.js +16 -5
- package/src/renderer.js +291 -50
- package/src/scheduler.js +28 -10
- package/src/scroll-box.js +18 -12
- package/src/scrollable-text.js +3 -2
- package/src/select.js +65 -173
- package/src/selection.js +92 -0
- package/src/shimmer.js +2 -2
- package/src/signal.js +20 -3
- package/src/table.js +8 -27
- package/src/tabs.js +10 -7
- package/src/task.js +3 -3
- package/src/text-area.js +96 -41
- package/src/text-input.js +70 -28
- package/src/wrap.js +205 -84
package/src/diff-view.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { jsx, jsxs } from '../jsx-runtime.js'
|
|
2
|
+
import { createSignal } from './signal.js'
|
|
3
|
+
import { useInput, useMouse, useLayout, useScrollDrag } from './hooks.js'
|
|
4
|
+
import { sliceVisibleRange } from './wrap.js'
|
|
5
|
+
import { computeDiff } from './diff-engine.js'
|
|
6
|
+
|
|
7
|
+
const PALETTE = {
|
|
8
|
+
addBg: '#10301a', addGutterBg: '#0a2412', addIntraBg: '#1f6e34', addMarker: '#7ee787',
|
|
9
|
+
delBg: '#3a1417', delGutterBg: '#2c0e11', delIntraBg: '#86262c', delMarker: '#f47067',
|
|
10
|
+
lineNo: '#5c6370',
|
|
11
|
+
hunk: '#7aa2f7',
|
|
12
|
+
meta: '#5c6370',
|
|
13
|
+
fold: '#5c6370', foldBg: '#14161c',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const cpLength = (s) => {
|
|
17
|
+
let n = 0
|
|
18
|
+
for (const _ of s) n++
|
|
19
|
+
return n
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function digits(rows, field) {
|
|
23
|
+
let max = 0
|
|
24
|
+
for (const r of rows) if (r[field] != null && r[field] > max) max = r[field]
|
|
25
|
+
return Math.max(1, String(max).length)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// splits a highlighted line into normal/hot segments around the changed ranges
|
|
29
|
+
function intraSegments(ansiLine, plainLen, ranges) {
|
|
30
|
+
const segs = []
|
|
31
|
+
let pos = 0
|
|
32
|
+
for (const [start, end] of ranges) {
|
|
33
|
+
if (start > pos) segs.push({ text: sliceVisibleRange(ansiLine, pos, start), hot: false })
|
|
34
|
+
segs.push({ text: sliceVisibleRange(ansiLine, start, end), hot: true })
|
|
35
|
+
pos = end
|
|
36
|
+
}
|
|
37
|
+
if (pos < plainLen) segs.push({ text: sliceVisibleRange(ansiLine, pos, plainLen), hot: false })
|
|
38
|
+
return segs
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function Diff({
|
|
42
|
+
before,
|
|
43
|
+
after,
|
|
44
|
+
patch,
|
|
45
|
+
hunks,
|
|
46
|
+
language = 'text',
|
|
47
|
+
filename,
|
|
48
|
+
highlight,
|
|
49
|
+
wordDiff = true,
|
|
50
|
+
context = Infinity,
|
|
51
|
+
lineNumbers = true,
|
|
52
|
+
focused = true,
|
|
53
|
+
scrollOffset: offsetProp,
|
|
54
|
+
onScroll,
|
|
55
|
+
scrollbar = true,
|
|
56
|
+
colors,
|
|
57
|
+
}) {
|
|
58
|
+
const palette = colors ? { ...PALETTE, ...colors } : PALETTE
|
|
59
|
+
const [offsetInternal, setOffsetInternal] = createSignal(0)
|
|
60
|
+
const layout = useLayout()
|
|
61
|
+
|
|
62
|
+
const offset = offsetProp ?? offsetInternal()
|
|
63
|
+
const setOffset = onScroll ?? setOffsetInternal
|
|
64
|
+
|
|
65
|
+
const { rows, stats } = computeDiff({ before, after, patch, hunks, wordDiff, context })
|
|
66
|
+
|
|
67
|
+
const wholeMode = hunks == null && patch == null
|
|
68
|
+
const hiCache = new Map()
|
|
69
|
+
const hiLines = (text) => {
|
|
70
|
+
if (hiCache.has(text)) return hiCache.get(text)
|
|
71
|
+
const lines = (highlight ? highlight(text, language) : text).split('\n')
|
|
72
|
+
hiCache.set(text, lines)
|
|
73
|
+
return lines
|
|
74
|
+
}
|
|
75
|
+
const beforeHi = wholeMode && highlight ? hiLines(before ?? '') : null
|
|
76
|
+
const afterHi = wholeMode && highlight ? hiLines(after ?? '') : null
|
|
77
|
+
|
|
78
|
+
const lineAnsi = (row) => {
|
|
79
|
+
if (!highlight) return row.text
|
|
80
|
+
if (wholeMode) {
|
|
81
|
+
if (row.type === 'del') return beforeHi[row.oldNo - 1] ?? row.text
|
|
82
|
+
if (row.newNo != null) return afterHi[row.newNo - 1] ?? row.text
|
|
83
|
+
return row.text
|
|
84
|
+
}
|
|
85
|
+
return hiLines(row.text)[0] ?? row.text
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const oldW = digits(rows, 'oldNo')
|
|
89
|
+
const newW = digits(rows, 'newNo')
|
|
90
|
+
|
|
91
|
+
const headerH = filename ? 1 : 0
|
|
92
|
+
const h = Math.max(0, layout.height - headerH)
|
|
93
|
+
const maxOffset = Math.max(0, rows.length - h)
|
|
94
|
+
const clamp = (v) => Math.max(0, Math.min(maxOffset, v))
|
|
95
|
+
const clamped = clamp(offset)
|
|
96
|
+
|
|
97
|
+
useInput(({ key, ctrl }) => {
|
|
98
|
+
if (!focused || rows.length === 0) return
|
|
99
|
+
const pageH = h || 10
|
|
100
|
+
const half = Math.max(1, Math.floor(pageH / 2))
|
|
101
|
+
if (key === 'up' || key === 'k') setOffset(clamp(clamped - 1))
|
|
102
|
+
else if (key === 'down' || key === 'j') setOffset(clamp(clamped + 1))
|
|
103
|
+
else if (key === 'pageup' || (ctrl && key === 'b')) setOffset(clamp(clamped - pageH))
|
|
104
|
+
else if (key === 'pagedown' || (ctrl && key === 'f')) setOffset(clamp(clamped + pageH))
|
|
105
|
+
else if (ctrl && key === 'u') setOffset(clamp(clamped - half))
|
|
106
|
+
else if (ctrl && key === 'd') setOffset(clamp(clamped + half))
|
|
107
|
+
else if (key === 'home' || key === 'g') setOffset(0)
|
|
108
|
+
else if (key === 'end' || key === 'G') setOffset(maxOffset)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
useMouse((event) => {
|
|
112
|
+
if (event.action !== 'scroll' || rows.length <= h) return
|
|
113
|
+
const { x, y } = event
|
|
114
|
+
if (x < layout.x || x >= layout.x + layout.width || y < layout.y || y >= layout.y + layout.height) return
|
|
115
|
+
if (event.direction !== 'up' && event.direction !== 'down') return
|
|
116
|
+
setOffset(clamp(clamped + (event.direction === 'up' ? -3 : 3)))
|
|
117
|
+
event.stopPropagation()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const hasBar = scrollbar && h > 0 && rows.length > h
|
|
121
|
+
const barW = hasBar ? 1 : 0
|
|
122
|
+
const thumbH = hasBar ? Math.max(1, Math.round((h / rows.length) * h)) : 0
|
|
123
|
+
const thumbStart = hasBar && maxOffset > 0 ? Math.round((clamped / maxOffset) * (h - thumbH)) : 0
|
|
124
|
+
|
|
125
|
+
useScrollDrag({
|
|
126
|
+
barX: hasBar ? layout.x + layout.width - 1 : null,
|
|
127
|
+
barY: layout.y + headerH + thumbStart,
|
|
128
|
+
thumbHeight: thumbH,
|
|
129
|
+
trackHeight: h,
|
|
130
|
+
maxOffset,
|
|
131
|
+
scrollOffset: clamped,
|
|
132
|
+
onScroll: (v) => setOffset(clamp(v)),
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const renderGutter = (row) => {
|
|
136
|
+
const gutterBg = row.type === 'add' ? palette.addGutterBg : row.type === 'del' ? palette.delGutterBg : null
|
|
137
|
+
const markerColor = row.type === 'add' ? palette.addMarker : row.type === 'del' ? palette.delMarker : palette.lineNo
|
|
138
|
+
const marker = row.type === 'add' ? '+' : row.type === 'del' ? '-' : ' '
|
|
139
|
+
const children = []
|
|
140
|
+
|
|
141
|
+
if (lineNumbers) {
|
|
142
|
+
const oldStr = row.oldNo != null ? String(row.oldNo) : ''
|
|
143
|
+
const newStr = row.newNo != null ? String(row.newNo) : ''
|
|
144
|
+
children.push(jsx('text', {
|
|
145
|
+
style: { color: palette.lineNo, bg: gutterBg, overflow: 'nowrap' },
|
|
146
|
+
children: ` ${oldStr.padStart(oldW)} ${newStr.padStart(newW)} `,
|
|
147
|
+
}))
|
|
148
|
+
}
|
|
149
|
+
children.push(jsx('text', {
|
|
150
|
+
style: { color: markerColor, bg: gutterBg, bold: true, overflow: 'nowrap' },
|
|
151
|
+
children: `${marker} `,
|
|
152
|
+
}))
|
|
153
|
+
return children
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const renderContent = (row) => {
|
|
157
|
+
const ansi = lineAnsi(row)
|
|
158
|
+
if (!row.intra || row.intra.length === 0) {
|
|
159
|
+
return [jsx('text', { style: { overflow: 'nowrap' }, children: ansi || ' ' })]
|
|
160
|
+
}
|
|
161
|
+
const intraBg = row.type === 'add' ? palette.addIntraBg : palette.delIntraBg
|
|
162
|
+
const plainLen = cpLength(row.text)
|
|
163
|
+
return intraSegments(ansi, plainLen, row.intra).map((seg, i) =>
|
|
164
|
+
jsx('text', {
|
|
165
|
+
key: i,
|
|
166
|
+
style: { overflow: 'nowrap', bg: seg.hot ? intraBg : null },
|
|
167
|
+
children: seg.text,
|
|
168
|
+
})
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const renderRow = (row, key) => {
|
|
173
|
+
if (row.type === 'hunk' || row.type === 'meta' || row.type === 'fold') {
|
|
174
|
+
const color = row.type === 'hunk' ? palette.hunk : palette.meta
|
|
175
|
+
const text = row.type === 'fold' ? `··· ${row.text} ···` : row.text
|
|
176
|
+
return jsx('box', {
|
|
177
|
+
key,
|
|
178
|
+
style: { height: 1, paddingX: 1, bg: row.type === 'fold' ? palette.foldBg : null },
|
|
179
|
+
children: jsx('text', { style: { color, dim: row.type !== 'hunk', overflow: 'nowrap' }, children: text }),
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const rowBg = row.type === 'add' ? palette.addBg : row.type === 'del' ? palette.delBg : null
|
|
184
|
+
return jsxs('box', {
|
|
185
|
+
key,
|
|
186
|
+
style: { flexDirection: 'row', height: 1, bg: rowBg },
|
|
187
|
+
children: [...renderGutter(row), ...renderContent(row)],
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const visible = h > 0 ? rows.slice(clamped, clamped + h) : []
|
|
192
|
+
const bodyRows = visible.map((row, i) => renderRow(row, clamped + i))
|
|
193
|
+
|
|
194
|
+
let body
|
|
195
|
+
if (rows.length === 0) {
|
|
196
|
+
body = jsx('box', {
|
|
197
|
+
style: { flexGrow: 1, justifyContent: 'center', alignItems: 'center' },
|
|
198
|
+
children: jsx('text', { style: { color: palette.meta, dim: true }, children: 'No changes' }),
|
|
199
|
+
})
|
|
200
|
+
} else if (hasBar) {
|
|
201
|
+
const bar = []
|
|
202
|
+
for (let i = 0; i < h; i++) {
|
|
203
|
+
const isThumb = i >= thumbStart && i < thumbStart + thumbH
|
|
204
|
+
bar.push(jsx('text', {
|
|
205
|
+
key: i,
|
|
206
|
+
style: { color: isThumb ? palette.lineNo : palette.lineNo, dim: !isThumb },
|
|
207
|
+
children: isThumb ? '█' : '│',
|
|
208
|
+
}))
|
|
209
|
+
}
|
|
210
|
+
body = jsxs('box', {
|
|
211
|
+
style: { flexDirection: 'row', flexGrow: 1 },
|
|
212
|
+
children: [
|
|
213
|
+
jsx('box', { style: { flexDirection: 'column', flexGrow: 1 }, children: bodyRows }),
|
|
214
|
+
jsx('box', { style: { flexDirection: 'column', width: barW }, children: bar }),
|
|
215
|
+
],
|
|
216
|
+
})
|
|
217
|
+
} else {
|
|
218
|
+
body = jsx('box', { style: { flexDirection: 'column', flexGrow: 1 }, children: bodyRows })
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!filename) return body
|
|
222
|
+
|
|
223
|
+
return jsxs('box', {
|
|
224
|
+
style: { flexDirection: 'column', flexGrow: 1 },
|
|
225
|
+
children: [
|
|
226
|
+
jsxs('box', {
|
|
227
|
+
style: { flexDirection: 'row', paddingX: 1, height: 1 },
|
|
228
|
+
children: [
|
|
229
|
+
jsx('text', { style: { bold: true, overflow: 'nowrap' }, children: filename }),
|
|
230
|
+
jsx('box', { style: { flexGrow: 1 } }),
|
|
231
|
+
jsx('text', { style: { color: palette.addMarker, overflow: 'nowrap' }, children: `+${stats.additions}` }),
|
|
232
|
+
jsx('text', { style: { color: palette.delMarker, overflow: 'nowrap' }, children: ` -${stats.deletions}` }),
|
|
233
|
+
],
|
|
234
|
+
}),
|
|
235
|
+
body,
|
|
236
|
+
],
|
|
237
|
+
})
|
|
238
|
+
}
|
package/src/diff.js
CHANGED
|
@@ -102,9 +102,9 @@ function writeReset() {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
function writeChar(ch) {
|
|
105
|
-
ensure(4)
|
|
105
|
+
ensure(ch.length * 4)
|
|
106
106
|
const code = ch.charCodeAt(0)
|
|
107
|
-
if (code < 0x80) {
|
|
107
|
+
if (code < 0x80 && ch.length === 1) {
|
|
108
108
|
buf[pos++] = code
|
|
109
109
|
} else {
|
|
110
110
|
pos += buf.write(ch, pos)
|
package/src/dropdown.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { jsx } from '../jsx-runtime.js'
|
|
2
|
+
import { useMouse, useLayout, useTheme } from './hooks.js'
|
|
3
|
+
import { registerOverlay, registerHook } from './renderer.js'
|
|
4
|
+
|
|
5
|
+
export function followCursor(cursor, scroll, visibleCount, len) {
|
|
6
|
+
let next = scroll
|
|
7
|
+
if (cursor < next) next = cursor
|
|
8
|
+
else if (cursor >= next + visibleCount) next = cursor - visibleCount + 1
|
|
9
|
+
return Math.max(0, Math.min(next, len - visibleCount))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function placeDropdown({ anchorTop, itemCount, maxVisible, termH }) {
|
|
13
|
+
const anchorY = anchorTop + 1
|
|
14
|
+
const spaceBelow = termH - anchorY - 2
|
|
15
|
+
const spaceAbove = anchorTop - 2
|
|
16
|
+
let direction = 'down'
|
|
17
|
+
let maxRows = Math.min(itemCount, maxVisible)
|
|
18
|
+
if (spaceBelow >= maxRows) {
|
|
19
|
+
maxRows = Math.min(maxRows, spaceBelow)
|
|
20
|
+
} else if (spaceAbove > spaceBelow) {
|
|
21
|
+
direction = 'up'
|
|
22
|
+
maxRows = Math.min(maxRows, spaceAbove)
|
|
23
|
+
} else {
|
|
24
|
+
maxRows = Math.min(maxRows, spaceBelow)
|
|
25
|
+
}
|
|
26
|
+
return { direction, visibleCount: Math.max(1, maxRows) }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function overlayDropdown({ x, y, termW, termH, dropdown }) {
|
|
30
|
+
registerOverlay(jsx('box', {
|
|
31
|
+
style: { width: termW, height: termH },
|
|
32
|
+
children: jsx('box', {
|
|
33
|
+
style: { position: 'absolute', top: Math.max(0, y), left: x },
|
|
34
|
+
children: dropdown,
|
|
35
|
+
}),
|
|
36
|
+
}), { fullscreen: true })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function Dropdown({ items, cursor, scroll, visibleCount, width, onSubmit, onClose, onCursorChange, renderRow, style: s }) {
|
|
40
|
+
const { muted = 'gray' } = useTheme()
|
|
41
|
+
const layout = useLayout()
|
|
42
|
+
const drag = registerHook(() => ({ active: false, startY: 0, startCursor: 0 }))
|
|
43
|
+
const scrollable = items.length > visibleCount
|
|
44
|
+
|
|
45
|
+
useMouse((event) => {
|
|
46
|
+
if (event.action === 'release') {
|
|
47
|
+
if (drag.active) drag.active = false
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (event.action === 'drag' && drag.active) {
|
|
52
|
+
const thumbH = Math.max(1, Math.round((visibleCount / items.length) * visibleCount))
|
|
53
|
+
const dy = event.y - drag.startY
|
|
54
|
+
const travel = Math.max(1, visibleCount - thumbH)
|
|
55
|
+
const ratio = (items.length - 1) / travel
|
|
56
|
+
const idx = Math.max(0, Math.min(items.length - 1, Math.round(drag.startCursor + dy * ratio)))
|
|
57
|
+
onCursorChange(idx)
|
|
58
|
+
event.stopPropagation()
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// layout not yet computed - consume event but don't act
|
|
63
|
+
if (layout.width === 0 || layout.height === 0) {
|
|
64
|
+
event.stopPropagation()
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const { x, y } = event
|
|
69
|
+
const boxRight = layout.x + width
|
|
70
|
+
const inside = x >= layout.x && x < boxRight && y >= layout.y && y < layout.y + layout.height
|
|
71
|
+
|
|
72
|
+
if (event.action === 'scroll') {
|
|
73
|
+
if (!inside) return
|
|
74
|
+
if (event.direction !== 'up' && event.direction !== 'down') return
|
|
75
|
+
if (event.direction === 'up') onCursorChange(Math.max(0, cursor - 1))
|
|
76
|
+
else onCursorChange(Math.min(items.length - 1, cursor + 1))
|
|
77
|
+
event.stopPropagation()
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (event.action !== 'press' || event.button !== 'left') return
|
|
82
|
+
|
|
83
|
+
if (!inside) {
|
|
84
|
+
onClose()
|
|
85
|
+
event.stopPropagation()
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (scrollable && x >= boxRight - 4) {
|
|
90
|
+
const maxSc = items.length - visibleCount
|
|
91
|
+
const thumbH = Math.max(1, Math.round((visibleCount / items.length) * visibleCount))
|
|
92
|
+
const thumbStart = maxSc > 0 ? Math.round((scroll / maxSc) * (visibleCount - thumbH)) : 0
|
|
93
|
+
const barY = layout.y + 1 + thumbStart
|
|
94
|
+
if (y >= barY && y < barY + thumbH) {
|
|
95
|
+
drag.active = true
|
|
96
|
+
drag.startY = y
|
|
97
|
+
drag.startCursor = cursor
|
|
98
|
+
}
|
|
99
|
+
event.stopPropagation()
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const relY = y - layout.y - 1
|
|
104
|
+
if (relY >= 0 && relY < visibleCount) {
|
|
105
|
+
const clickedIdx = relY + scroll
|
|
106
|
+
if (clickedIdx >= 0 && clickedIdx < items.length) {
|
|
107
|
+
onSubmit(items[clickedIdx], clickedIdx)
|
|
108
|
+
event.stopPropagation()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const visible = items.slice(scroll, scroll + visibleCount)
|
|
114
|
+
const thumbH = scrollable ? Math.max(1, Math.round((visibleCount / items.length) * visibleCount)) : 0
|
|
115
|
+
const maxSc = items.length - visibleCount
|
|
116
|
+
const thumbStart = scrollable && maxSc > 0 ? Math.round((scroll / maxSc) * (visibleCount - thumbH)) : 0
|
|
117
|
+
|
|
118
|
+
const children = visible.map((item, vi) => {
|
|
119
|
+
const i = vi + scroll
|
|
120
|
+
const content = renderRow(item, { index: i, isCursor: i === cursor })
|
|
121
|
+
if (!scrollable) return jsx('box', { key: i, style: { flexDirection: 'row' }, children: content })
|
|
122
|
+
const barIsThumb = vi >= thumbStart && vi < thumbStart + thumbH
|
|
123
|
+
return jsx('box', {
|
|
124
|
+
key: i,
|
|
125
|
+
style: { flexDirection: 'row' },
|
|
126
|
+
children: [
|
|
127
|
+
content,
|
|
128
|
+
jsx('text', {
|
|
129
|
+
style: { color: barIsThumb ? s.accent : muted, dim: !barIsThumb },
|
|
130
|
+
children: ' ' + (barIsThumb ? '█' : '│'),
|
|
131
|
+
}),
|
|
132
|
+
],
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
return jsx('box', {
|
|
137
|
+
style: {
|
|
138
|
+
flexDirection: 'column',
|
|
139
|
+
border: s.border,
|
|
140
|
+
borderColor: s.borderColor,
|
|
141
|
+
height: visibleCount + 2,
|
|
142
|
+
width,
|
|
143
|
+
bg: s.bg,
|
|
144
|
+
},
|
|
145
|
+
children,
|
|
146
|
+
})
|
|
147
|
+
}
|
package/src/focus.js
CHANGED
|
@@ -27,39 +27,89 @@ export function useFocus({ initial, cycle = 'tab' } = {}) {
|
|
|
27
27
|
current,
|
|
28
28
|
setCurrent,
|
|
29
29
|
stack: [],
|
|
30
|
-
|
|
30
|
+
gen: 0,
|
|
31
31
|
}
|
|
32
32
|
})
|
|
33
33
|
|
|
34
|
+
// items and groups re-register every frame like hooks. entries that were
|
|
35
|
+
// not re-registered during the previous frame are swept here, at the start
|
|
36
|
+
// of the next one, and input handling only ever considers current-gen
|
|
37
|
+
// entries so unmounted items drop out of the tab order immediately
|
|
38
|
+
sweep()
|
|
39
|
+
|
|
40
|
+
function sweep() {
|
|
41
|
+
const prevGen = state.gen
|
|
42
|
+
state.gen = prevGen + 1
|
|
43
|
+
if (prevGen === 0) return
|
|
44
|
+
|
|
45
|
+
const stale = state.items.filter(i => i.gen < prevGen)
|
|
46
|
+
if (stale.length > 0) {
|
|
47
|
+
state.items = state.items.filter(i => i.gen >= prevGen)
|
|
48
|
+
for (const it of stale) {
|
|
49
|
+
const g = state.groups.get(it.name)
|
|
50
|
+
if (g) {
|
|
51
|
+
for (const sub of g.items) state.nameToParent.delete(sub)
|
|
52
|
+
state.groups.delete(it.name)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (state.items.length > 0 && !isRegistered(state.current())) {
|
|
58
|
+
state.setCurrent(resolveLeaf(state.items[0].name) ?? null)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isRegistered(name) {
|
|
63
|
+
if (name == null) return false
|
|
64
|
+
if (state.nameToParent.has(name)) return true
|
|
65
|
+
return state.items.some(i => i.name === name)
|
|
66
|
+
}
|
|
67
|
+
|
|
34
68
|
function resolveLeaf(name) {
|
|
35
69
|
const g = state.groups.get(name)
|
|
36
70
|
if (g) return g.items[g.subIdx]
|
|
37
71
|
return name
|
|
38
72
|
}
|
|
39
73
|
|
|
74
|
+
function liveItems() {
|
|
75
|
+
return state.items.filter(i => i.gen === state.gen)
|
|
76
|
+
}
|
|
77
|
+
|
|
40
78
|
function item(name) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
if (
|
|
45
|
-
state.setCurrent(name)
|
|
46
|
-
state.initialized = true
|
|
47
|
-
}
|
|
79
|
+
const existing = state.items.find(i => i.name === name)
|
|
80
|
+
if (existing) existing.gen = state.gen
|
|
81
|
+
else state.items.push({ name, type: 'item', gen: state.gen })
|
|
82
|
+
if (state.current() == null) state.setCurrent(name)
|
|
48
83
|
}
|
|
49
84
|
|
|
50
|
-
function group(name, { items: subItems, navigate = 'both', wrap = false } = {}) {
|
|
51
|
-
|
|
52
|
-
|
|
85
|
+
function group(name, { items: subItems = [], navigate = 'both', wrap = false } = {}) {
|
|
86
|
+
const existing = state.items.find(i => i.name === name)
|
|
87
|
+
if (existing) existing.gen = state.gen
|
|
88
|
+
else state.items.push({ name, type: 'group', gen: state.gen })
|
|
89
|
+
|
|
90
|
+
let g = state.groups.get(name)
|
|
91
|
+
if (!g) {
|
|
92
|
+
g = { items: [], navigate, wrap, subIdx: 0 }
|
|
93
|
+
state.groups.set(name, g)
|
|
53
94
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
95
|
+
|
|
96
|
+
const prevFocused = g.items[g.subIdx]
|
|
97
|
+
for (const sub of g.items) {
|
|
98
|
+
if (!subItems.includes(sub)) state.nameToParent.delete(sub)
|
|
59
99
|
}
|
|
60
|
-
|
|
100
|
+
g.items = subItems.slice()
|
|
101
|
+
g.navigate = navigate
|
|
102
|
+
g.wrap = wrap
|
|
103
|
+
for (const sub of subItems) state.nameToParent.set(sub, name)
|
|
104
|
+
|
|
105
|
+
const keep = subItems.indexOf(prevFocused)
|
|
106
|
+
g.subIdx = keep >= 0 ? keep : Math.min(g.subIdx, Math.max(0, subItems.length - 1))
|
|
107
|
+
|
|
108
|
+
const cur = state.current()
|
|
109
|
+
if (cur == null && subItems.length > 0) {
|
|
61
110
|
state.setCurrent(subItems[0])
|
|
62
|
-
|
|
111
|
+
} else if (cur === prevFocused && keep === -1 && subItems.length > 0) {
|
|
112
|
+
state.setCurrent(subItems[g.subIdx])
|
|
63
113
|
}
|
|
64
114
|
}
|
|
65
115
|
|
|
@@ -95,14 +145,15 @@ export function useFocus({ initial, cycle = 'tab' } = {}) {
|
|
|
95
145
|
return state.current()
|
|
96
146
|
}
|
|
97
147
|
|
|
98
|
-
function findTopLevel(cur) {
|
|
148
|
+
function findTopLevel(cur, items) {
|
|
99
149
|
const parent = state.nameToParent.get(cur)
|
|
100
|
-
if (parent) return
|
|
101
|
-
return
|
|
150
|
+
if (parent) return items.findIndex(i => i.name === parent)
|
|
151
|
+
return items.findIndex(i => i.name === cur)
|
|
102
152
|
}
|
|
103
153
|
|
|
104
154
|
useInput((event) => {
|
|
105
|
-
|
|
155
|
+
const items = liveItems()
|
|
156
|
+
if (items.length === 0) return
|
|
106
157
|
|
|
107
158
|
const { key } = event
|
|
108
159
|
const cur = state.current()
|
|
@@ -110,12 +161,20 @@ export function useFocus({ initial, cycle = 'tab' } = {}) {
|
|
|
110
161
|
if (state.stack.length > 0) return
|
|
111
162
|
|
|
112
163
|
if (cycle === 'tab' && (key === 'tab' || key === 'shift-tab')) {
|
|
113
|
-
const idx = findTopLevel(cur)
|
|
114
|
-
if (idx === -1) return
|
|
115
164
|
const dir = key === 'tab' ? 1 : -1
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
|
|
165
|
+
const len = items.length
|
|
166
|
+
const idx = findTopLevel(cur, items)
|
|
167
|
+
const start = idx === -1 ? (dir === 1 ? -1 : 0) : idx
|
|
168
|
+
for (let step = 1; step <= len; step++) {
|
|
169
|
+
const next = ((start + dir * step) % len + len) % len
|
|
170
|
+
const leaf = resolveLeaf(items[next].name)
|
|
171
|
+
if (leaf === undefined) continue
|
|
172
|
+
if (leaf !== cur) {
|
|
173
|
+
state.setCurrent(leaf)
|
|
174
|
+
event.stopPropagation()
|
|
175
|
+
}
|
|
176
|
+
return
|
|
177
|
+
}
|
|
119
178
|
return
|
|
120
179
|
}
|
|
121
180
|
|
|
@@ -136,13 +195,16 @@ export function useFocus({ initial, cycle = 'tab' } = {}) {
|
|
|
136
195
|
|
|
137
196
|
if (g.wrap) {
|
|
138
197
|
const next = (idx + dir + len) % len
|
|
198
|
+
if (next === idx) return
|
|
139
199
|
g.subIdx = next
|
|
140
200
|
state.setCurrent(g.items[next])
|
|
201
|
+
event.stopPropagation()
|
|
141
202
|
} else {
|
|
142
203
|
const next = idx + dir
|
|
143
204
|
if (next < 0 || next >= len) return
|
|
144
205
|
g.subIdx = next
|
|
145
206
|
state.setCurrent(g.items[next])
|
|
207
|
+
event.stopPropagation()
|
|
146
208
|
}
|
|
147
209
|
})
|
|
148
210
|
|
package/src/hooks.js
CHANGED
|
@@ -1,17 +1,13 @@
|
|
|
1
|
-
import { onCleanup,
|
|
2
|
-
import { getContext, getTheme, getCursor, registerHook, getInstanceLayout, getFrameStats } from './renderer.js'
|
|
1
|
+
import { onCleanup, createSignalRaw } from './signal.js'
|
|
2
|
+
import { getContext, getTheme, getCursor, registerHook, getInstanceLayout, getFrameStats, getCurrentHookOwner } from './renderer.js'
|
|
3
3
|
import { setTitle } from './ansi.js'
|
|
4
4
|
|
|
5
|
-
export function useState(initial) {
|
|
6
|
-
return registerHook(() => rawCreateSignal(initial))
|
|
7
|
-
}
|
|
8
|
-
|
|
9
5
|
export function useInput(handler) {
|
|
10
6
|
const ref = registerHook(() => {
|
|
11
7
|
const ctx = getContext()
|
|
12
8
|
if (!ctx) throw new Error('useInput must be called within a mounted component')
|
|
13
9
|
const state = { current: handler }
|
|
14
|
-
const unsub = ctx.input.onKey((event) => state.current(event))
|
|
10
|
+
const unsub = ctx.input.onKey((event) => state.current(event), getCurrentHookOwner())
|
|
15
11
|
onCleanup(unsub)
|
|
16
12
|
return state
|
|
17
13
|
})
|
|
@@ -23,7 +19,7 @@ export function useMouse(handler) {
|
|
|
23
19
|
const ctx = getContext()
|
|
24
20
|
if (!ctx) throw new Error('useMouse must be called within a mounted component')
|
|
25
21
|
const state = { current: handler }
|
|
26
|
-
const unsub = ctx.input.onMouse((event) => state.current(event))
|
|
22
|
+
const unsub = ctx.input.onMouse((event) => state.current(event), getCurrentHookOwner())
|
|
27
23
|
onCleanup(unsub)
|
|
28
24
|
return state
|
|
29
25
|
})
|
|
@@ -45,13 +41,19 @@ export function useResize(handler) {
|
|
|
45
41
|
}
|
|
46
42
|
|
|
47
43
|
export function useInterval(fn, ms) {
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
44
|
+
const state = registerHook(() => {
|
|
45
|
+
const s = { current: fn, ms: undefined, id: null }
|
|
46
|
+
onCleanup(() => {
|
|
47
|
+
if (s.id !== null) clearInterval(s.id)
|
|
48
|
+
})
|
|
49
|
+
return s
|
|
53
50
|
})
|
|
54
|
-
|
|
51
|
+
state.current = fn
|
|
52
|
+
if (state.ms !== ms) {
|
|
53
|
+
if (state.id !== null) clearInterval(state.id)
|
|
54
|
+
state.ms = ms
|
|
55
|
+
state.id = ms == null ? null : setInterval(() => state.current(), ms)
|
|
56
|
+
}
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
export function useLayout() {
|
|
@@ -81,17 +83,30 @@ export function useFrameStats() {
|
|
|
81
83
|
export function useTitle(title) {
|
|
82
84
|
const ctx = getContext()
|
|
83
85
|
if (!ctx) throw new Error('useTitle must be called within a mounted component')
|
|
84
|
-
|
|
86
|
+
const state = registerHook(() => ({ last: undefined }))
|
|
87
|
+
if (state.last !== title) {
|
|
88
|
+
state.last = title
|
|
89
|
+
ctx.stream.write(setTitle(title))
|
|
90
|
+
}
|
|
85
91
|
}
|
|
86
92
|
|
|
87
93
|
export function useTimeout(fn, ms) {
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
94
|
+
const state = registerHook(() => {
|
|
95
|
+
const s = { current: fn, ms: undefined, id: null }
|
|
96
|
+
onCleanup(() => {
|
|
97
|
+
if (s.id !== null) clearTimeout(s.id)
|
|
98
|
+
})
|
|
99
|
+
return s
|
|
93
100
|
})
|
|
94
|
-
|
|
101
|
+
state.current = fn
|
|
102
|
+
if (state.ms !== ms) {
|
|
103
|
+
if (state.id !== null) clearTimeout(state.id)
|
|
104
|
+
state.ms = ms
|
|
105
|
+
state.id = ms == null ? null : setTimeout(() => {
|
|
106
|
+
state.id = null
|
|
107
|
+
state.current()
|
|
108
|
+
}, ms)
|
|
109
|
+
}
|
|
95
110
|
}
|
|
96
111
|
|
|
97
112
|
export function useScrollDrag({ barX, barY, thumbHeight, trackHeight, maxOffset, scrollOffset, onScroll }) {
|
|
@@ -183,21 +198,24 @@ export function useAsync(fn, { immediate = false } = {}) {
|
|
|
183
198
|
const [error, setError] = createSignalRaw(null)
|
|
184
199
|
let generation = 0
|
|
185
200
|
|
|
186
|
-
|
|
201
|
+
const s = { status, data, error, fn }
|
|
202
|
+
|
|
203
|
+
s.run = (...args) => {
|
|
187
204
|
const gen = ++generation
|
|
188
205
|
setStatus('loading')
|
|
189
206
|
setData(null)
|
|
190
207
|
setError(null)
|
|
191
|
-
fn(...args).then(
|
|
208
|
+
s.fn(...args).then(
|
|
192
209
|
result => { if (gen === generation) { setData(result); setStatus('success') } },
|
|
193
210
|
err => { if (gen === generation) { setError(err); setStatus('error') } },
|
|
194
211
|
)
|
|
195
212
|
}
|
|
196
213
|
|
|
197
|
-
if (immediate) run()
|
|
214
|
+
if (immediate) s.run()
|
|
198
215
|
|
|
199
|
-
return
|
|
216
|
+
return s
|
|
200
217
|
})
|
|
201
218
|
|
|
219
|
+
state.fn = fn
|
|
202
220
|
return state
|
|
203
221
|
}
|