@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.
- package/README.md +420 -56
- package/index.js +9 -1
- package/package.json +23 -3
- package/src/animation.js +204 -0
- package/src/ansi.js +3 -0
- package/src/buffer.js +49 -15
- package/src/button.js +10 -1
- package/src/checkbox.js +12 -3
- package/src/diff.js +115 -12
- package/src/hooks.js +142 -2
- package/src/input.js +59 -7
- package/src/layout.js +90 -18
- package/src/list.js +142 -17
- package/src/modal.js +2 -2
- package/src/pick-list.js +204 -0
- package/src/progress.js +57 -9
- package/src/radio.js +16 -2
- package/src/renderer.js +364 -68
- package/src/scroll-box.js +105 -0
- package/src/scrollable-text.js +28 -6
- package/src/select.js +188 -68
- package/src/shimmer.js +95 -0
- package/src/signal.js +12 -0
- package/src/spinner.js +13 -4
- package/src/split-pane.js +66 -0
- package/src/table.js +68 -23
- package/src/task.js +43 -0
- package/src/text-area.js +8 -5
- package/src/text-input.js +9 -6
- package/src/toast.js +4 -6
- package/src/wrap.js +110 -11
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
|
|
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 = [...
|
|
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
|
-
|
|
135
|
-
if (
|
|
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
|
-
|
|
138
|
-
if (
|
|
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
|
|
55
|
-
const innerX = box.x + pad.left +
|
|
56
|
-
const innerY = box.y + pad.top +
|
|
57
|
-
const innerW = Math.max(0, box.width - pad.left - pad.right -
|
|
58
|
-
const innerH = Math.max(0, box.height - pad.top - pad.bottom -
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
218
|
-
const chrome = { x: pad.left + pad.right +
|
|
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,46 +1,171 @@
|
|
|
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,
|
|
5
|
+
export function List({ items, selected: selectedProp, onSelect, renderItem, header, headerHeight = 1, focused = true, interactive = focused, itemHeight = 1, scrollbar = false, stickyHeader = false, gap = 0, scrolloff = 2 }) {
|
|
6
|
+
const { accent = 'cyan' } = useTheme()
|
|
6
7
|
const [selectedInternal, setSelectedInternal] = createSignal(0)
|
|
8
|
+
const [scrollState, setScrollState] = createSignal(0)
|
|
7
9
|
const layout = useLayout()
|
|
8
10
|
|
|
9
11
|
const selected = selectedProp ?? selectedInternal()
|
|
10
12
|
const setSelected = onSelect ?? setSelectedInternal
|
|
11
13
|
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
+
const viewH = layout.height
|
|
15
|
+
const contentH = layout.contentHeight ?? 0
|
|
16
|
+
const headerH = header ? headerHeight : 0
|
|
17
|
+
const sticky = header && stickyHeader
|
|
18
|
+
|
|
19
|
+
const innerHeaderH = sticky ? 0 : headerH
|
|
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
|
+
const avgH = !sticky && contentH > 0 && items.length > 0
|
|
24
|
+
? (contentH - headerH) / items.length
|
|
25
|
+
: itemHeight
|
|
26
|
+
|
|
27
|
+
const scrollViewH = sticky ? viewH - headerH : viewH
|
|
28
|
+
const scrollContentH = sticky ? items.length * avgH : contentH
|
|
29
|
+
|
|
30
|
+
const maxOffset = Math.max(0, scrollContentH - scrollViewH)
|
|
14
31
|
|
|
15
32
|
useInput(({ key, ctrl }) => {
|
|
16
|
-
if (!
|
|
33
|
+
if (!interactive) return
|
|
17
34
|
|
|
18
35
|
const len = items.length
|
|
19
36
|
if (len === 0) return
|
|
20
37
|
|
|
21
|
-
const
|
|
22
|
-
const half = Math.max(1, Math.floor(
|
|
38
|
+
const pageItems = viewH > 0 ? Math.max(1, Math.floor(viewH / avgH)) : 10
|
|
39
|
+
const half = Math.max(1, Math.floor(pageItems / 2))
|
|
23
40
|
|
|
24
41
|
if (key === 'up' || key === 'k') setSelected(Math.max(0, selected - 1))
|
|
25
42
|
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 -
|
|
27
|
-
else if (key === 'pagedown' || (ctrl && key === 'f')) setSelected(Math.min(len - 1, selected +
|
|
43
|
+
else if (key === 'pageup' || (ctrl && key === 'b')) setSelected(Math.max(0, selected - pageItems))
|
|
44
|
+
else if (key === 'pagedown' || (ctrl && key === 'f')) setSelected(Math.min(len - 1, selected + pageItems))
|
|
28
45
|
else if (ctrl && key === 'u') setSelected(Math.max(0, selected - half))
|
|
29
46
|
else if (ctrl && key === 'd') setSelected(Math.min(len - 1, selected + half))
|
|
30
47
|
else if (key === 'home' || key === 'g') setSelected(0)
|
|
31
48
|
else if (key === 'end' || key === 'G') setSelected(len - 1)
|
|
32
49
|
})
|
|
33
50
|
|
|
34
|
-
|
|
35
|
-
|
|
51
|
+
useMouse((event) => {
|
|
52
|
+
if (!focused) return
|
|
53
|
+
const len = items.length
|
|
54
|
+
if (len === 0) return
|
|
55
|
+
const { x, y } = event
|
|
56
|
+
if (x < layout.x || x >= layout.x + layout.width || y < layout.y || y >= layout.y + layout.height) return
|
|
57
|
+
|
|
58
|
+
if (event.action === 'scroll') {
|
|
59
|
+
if (event.direction === 'up') setSelected(Math.max(0, selected - 1))
|
|
60
|
+
else setSelected(Math.min(len - 1, selected + 1))
|
|
61
|
+
event.stopPropagation()
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (event.action === 'press' && event.button === 'left') {
|
|
66
|
+
const headerOffset = sticky ? headerH : (header ? headerH : 0)
|
|
67
|
+
const relY = y - layout.y - headerOffset
|
|
68
|
+
const idx = Math.floor((relY + scrollOffset) / Math.max(1, avgH))
|
|
69
|
+
if (idx >= 0 && idx < len) {
|
|
70
|
+
setSelected(idx)
|
|
71
|
+
event.stopPropagation()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const hasBar = scrollbar && scrollViewH > 0 && scrollContentH > scrollViewH
|
|
77
|
+
const barThumbH = hasBar ? Math.max(1, Math.round((scrollViewH / scrollContentH) * scrollViewH)) : 0
|
|
78
|
+
|
|
79
|
+
useScrollDrag({
|
|
80
|
+
barX: hasBar ? layout.x + layout.width - 1 : null,
|
|
81
|
+
barY: layout.y + (sticky ? headerH : 0),
|
|
82
|
+
thumbHeight: barThumbH,
|
|
83
|
+
trackHeight: scrollViewH,
|
|
84
|
+
maxOffset,
|
|
85
|
+
scrollOffset: selected * avgH,
|
|
86
|
+
onScroll: (offset) => {
|
|
87
|
+
const idx = Math.round(offset / Math.max(1, avgH))
|
|
88
|
+
setSelected(Math.max(0, Math.min(items.length - 1, idx)))
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const itemTop = selected * avgH + innerHeaderH
|
|
93
|
+
const itemBottom = itemTop + avgH
|
|
94
|
+
|
|
95
|
+
let scrollOffset = 0
|
|
96
|
+
if (scrollViewH > 0 && scrollContentH > scrollViewH) {
|
|
97
|
+
const margin = scrolloff * avgH
|
|
98
|
+
scrollOffset = scrollState()
|
|
99
|
+
if (itemTop - margin < scrollOffset) scrollOffset = itemTop - margin
|
|
100
|
+
if (itemBottom + margin > scrollOffset + scrollViewH) scrollOffset = itemBottom + margin - scrollViewH
|
|
101
|
+
scrollOffset = Math.max(0, Math.min(maxOffset, Math.round(scrollOffset)))
|
|
102
|
+
if (scrollOffset !== scrollState()) setScrollState(scrollOffset)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const scrollChildren = []
|
|
106
|
+
if (header && !sticky) scrollChildren.push(header)
|
|
107
|
+
for (let i = 0; i < items.length; i++) {
|
|
108
|
+
scrollChildren.push(renderItem(items[i], { selected: i === selected, index: i, focused }))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const scrollBox = jsx('box', {
|
|
112
|
+
style: {
|
|
113
|
+
flexDirection: 'column',
|
|
114
|
+
flexGrow: 1,
|
|
115
|
+
overflow: 'scroll',
|
|
116
|
+
scrollOffset,
|
|
117
|
+
gap,
|
|
118
|
+
},
|
|
119
|
+
children: scrollChildren,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const list = sticky
|
|
123
|
+
? jsxs('box', {
|
|
124
|
+
style: { flexDirection: 'column', flexGrow: 1 },
|
|
125
|
+
children: [header, scrollBox],
|
|
126
|
+
})
|
|
127
|
+
: scrollBox
|
|
128
|
+
|
|
129
|
+
if (!scrollbar || scrollViewH <= 0 || scrollContentH <= scrollViewH) return list
|
|
130
|
+
|
|
131
|
+
const thumbH = Math.max(1, Math.round((scrollViewH / scrollContentH) * scrollViewH))
|
|
132
|
+
const thumbStart = maxOffset > 0
|
|
133
|
+
? Math.round((scrollOffset / maxOffset) * (scrollViewH - thumbH))
|
|
36
134
|
: 0
|
|
37
135
|
|
|
38
|
-
const
|
|
39
|
-
|
|
136
|
+
const barH = sticky ? scrollViewH : viewH
|
|
137
|
+
const barChildren = []
|
|
138
|
+
for (let i = 0; i < barH; i++) {
|
|
139
|
+
const isThumb = i >= thumbStart && i < thumbStart + thumbH
|
|
140
|
+
barChildren.push(
|
|
141
|
+
jsx('text', {
|
|
142
|
+
key: i,
|
|
143
|
+
style: { color: isThumb ? (focused ? accent : 'gray') : 'gray', dim: !isThumb },
|
|
144
|
+
children: isThumb ? '\u2588' : '\u2502',
|
|
145
|
+
})
|
|
146
|
+
)
|
|
147
|
+
}
|
|
40
148
|
|
|
41
|
-
|
|
42
|
-
|
|
149
|
+
const scrollBarCol = jsx('box', {
|
|
150
|
+
style: { width: 1, flexDirection: 'column' },
|
|
151
|
+
children: barChildren,
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
if (sticky) {
|
|
155
|
+
return jsxs('box', {
|
|
156
|
+
style: { flexDirection: 'column', flexGrow: 1 },
|
|
157
|
+
children: [
|
|
158
|
+
header,
|
|
159
|
+
jsxs('box', {
|
|
160
|
+
style: { flexDirection: 'row', flexGrow: 1, gap: 1 },
|
|
161
|
+
children: [scrollBox, scrollBarCol],
|
|
162
|
+
}),
|
|
163
|
+
],
|
|
164
|
+
})
|
|
43
165
|
}
|
|
44
166
|
|
|
45
|
-
return
|
|
167
|
+
return jsxs('box', {
|
|
168
|
+
style: { flexDirection: 'row', flexGrow: 1, gap: 1 },
|
|
169
|
+
children: [list, scrollBarCol],
|
|
170
|
+
})
|
|
46
171
|
}
|
package/src/modal.js
CHANGED
|
@@ -2,7 +2,7 @@ import { jsx } from '../jsx-runtime.js'
|
|
|
2
2
|
import { useInput, useTheme } from './hooks.js'
|
|
3
3
|
import { registerOverlay } from './renderer.js'
|
|
4
4
|
|
|
5
|
-
export function Modal({ open, onClose, title, children, width: w = 40 }) {
|
|
5
|
+
export function Modal({ open, onClose, title, children, width: w = 40, border = 'round' }) {
|
|
6
6
|
const { accent = 'cyan' } = useTheme()
|
|
7
7
|
|
|
8
8
|
useInput((event) => {
|
|
@@ -18,7 +18,7 @@ export function Modal({ open, onClose, title, children, width: w = 40 }) {
|
|
|
18
18
|
const content = jsx('box', {
|
|
19
19
|
style: {
|
|
20
20
|
width: w,
|
|
21
|
-
border
|
|
21
|
+
border,
|
|
22
22
|
borderColor: accent,
|
|
23
23
|
flexDirection: 'column',
|
|
24
24
|
paddingX: 1,
|