@effect-tui/react 0.12.3 → 0.13.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/README.md +21 -1
- package/dist/src/dev.d.ts +22 -32
- package/dist/src/dev.d.ts.map +1 -1
- package/dist/src/dev.js +42 -96
- package/dist/src/dev.js.map +1 -1
- package/dist/src/hooks/index.d.ts +3 -0
- package/dist/src/hooks/index.d.ts.map +1 -1
- package/dist/src/hooks/index.js +1 -0
- package/dist/src/hooks/index.js.map +1 -1
- package/dist/src/hooks/use-mouse.d.ts +10 -1
- package/dist/src/hooks/use-mouse.d.ts.map +1 -1
- package/dist/src/hooks/use-mouse.js +10 -2
- package/dist/src/hooks/use-mouse.js.map +1 -1
- package/dist/src/hooks/use-paste.d.ts +13 -3
- package/dist/src/hooks/use-paste.d.ts.map +1 -1
- package/dist/src/hooks/use-paste.js +15 -5
- package/dist/src/hooks/use-paste.js.map +1 -1
- package/dist/src/hooks/use-scroll.d.ts +59 -24
- package/dist/src/hooks/use-scroll.d.ts.map +1 -1
- package/dist/src/hooks/use-scroll.js +238 -79
- package/dist/src/hooks/use-scroll.js.map +1 -1
- package/dist/src/hooks/use-shortcut.d.ts +16 -0
- package/dist/src/hooks/use-shortcut.d.ts.map +1 -0
- package/dist/src/hooks/use-shortcut.js +29 -0
- package/dist/src/hooks/use-shortcut.js.map +1 -0
- package/dist/src/hosts/scroll.d.ts +4 -0
- package/dist/src/hosts/scroll.d.ts.map +1 -1
- package/dist/src/hosts/scroll.js +18 -2
- package/dist/src/hosts/scroll.js.map +1 -1
- package/dist/src/index.d.ts +6 -4
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/renderer/input/InputProcessor.d.ts.map +1 -1
- package/dist/src/renderer/input/InputProcessor.js +8 -1
- package/dist/src/renderer/input/InputProcessor.js.map +1 -1
- package/dist/src/renderer/lifecycle/EventBus.d.ts +2 -2
- package/dist/src/renderer/lifecycle/EventBus.d.ts.map +1 -1
- package/dist/src/renderer/lifecycle/EventBus.js +13 -1
- package/dist/src/renderer/lifecycle/EventBus.js.map +1 -1
- package/dist/src/renderer-types.d.ts +8 -1
- package/dist/src/renderer-types.d.ts.map +1 -1
- package/dist/src/renderer.d.ts +13 -2
- package/dist/src/renderer.d.ts.map +1 -1
- package/dist/src/renderer.js +48 -7
- package/dist/src/renderer.js.map +1 -1
- package/dist/src/shortcuts.d.ts +15 -0
- package/dist/src/shortcuts.d.ts.map +1 -0
- package/dist/src/shortcuts.js +149 -0
- package/dist/src/shortcuts.js.map +1 -0
- package/dist/src/test/mock-streams.d.ts.map +1 -1
- package/dist/src/test/mock-streams.js +0 -3
- package/dist/src/test/mock-streams.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/dev.tsx +59 -107
- package/src/hooks/index.ts +3 -0
- package/src/hooks/use-mouse.ts +19 -3
- package/src/hooks/use-paste.ts +24 -5
- package/src/hooks/use-scroll.ts +345 -105
- package/src/hooks/use-shortcut.ts +54 -0
- package/src/hosts/scroll.ts +21 -2
- package/src/index.ts +19 -6
- package/src/renderer/input/InputProcessor.ts +8 -1
- package/src/renderer/lifecycle/EventBus.ts +14 -4
- package/src/renderer-types.ts +9 -1
- package/src/renderer.ts +96 -9
- package/src/shortcuts.ts +180 -0
- package/src/test/mock-streams.ts +0 -3
package/src/hooks/use-scroll.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import type { KeyMsg } from "@effect-tui/core"
|
|
4
4
|
import { useCallback, useLayoutEffect, useMemo, useReducer, useRef } from "react"
|
|
5
|
+
import type { ScrollProps } from "../hosts/scroll.js"
|
|
5
6
|
import { useTerminalSize } from "../renderer.js"
|
|
6
7
|
import { useKeyboard } from "./use-keyboard.js"
|
|
7
8
|
|
|
@@ -85,33 +86,65 @@ class MacOSScrollAccel implements ScrollAcceleration {
|
|
|
85
86
|
// ============================================================================
|
|
86
87
|
|
|
87
88
|
export interface ScrollState {
|
|
88
|
-
/** Current scroll offset (pixels from start) */
|
|
89
|
+
/** Current scroll offset (pixels from start) for the primary axis */
|
|
89
90
|
offset: number
|
|
90
|
-
/** Maximum scroll offset */
|
|
91
|
+
/** Maximum scroll offset for the primary axis */
|
|
91
92
|
maxOffset: number
|
|
92
|
-
/** Viewport
|
|
93
|
+
/** Viewport size for the primary axis */
|
|
93
94
|
viewportSize: number
|
|
94
|
-
/** Whether viewport size has been measured by the host */
|
|
95
|
+
/** Whether viewport size has been measured by the host (primary axis) */
|
|
95
96
|
viewportMeasured: boolean
|
|
96
|
-
/** Total content size */
|
|
97
|
+
/** Total content size for the primary axis */
|
|
97
98
|
contentSize: number
|
|
98
|
-
/** Whether we're at the start edge */
|
|
99
|
+
/** Whether we're at the start edge (primary axis) */
|
|
99
100
|
atStart: boolean
|
|
100
|
-
/** Whether we're at the end edge */
|
|
101
|
+
/** Whether we're at the end edge (primary axis) */
|
|
101
102
|
atEnd: boolean
|
|
103
|
+
/** Current horizontal offset (pixels from start) */
|
|
104
|
+
offsetX: number
|
|
105
|
+
/** Maximum horizontal offset */
|
|
106
|
+
maxOffsetX: number
|
|
107
|
+
/** Horizontal viewport size */
|
|
108
|
+
viewportSizeX: number
|
|
109
|
+
/** Whether horizontal viewport size has been measured */
|
|
110
|
+
viewportMeasuredX: boolean
|
|
111
|
+
/** Total horizontal content size */
|
|
112
|
+
contentSizeX: number
|
|
113
|
+
/** Whether we're at the horizontal start edge */
|
|
114
|
+
atStartX: boolean
|
|
115
|
+
/** Whether we're at the horizontal end edge */
|
|
116
|
+
atEndX: boolean
|
|
102
117
|
}
|
|
103
118
|
|
|
104
119
|
export interface UseScrollOptions {
|
|
105
|
-
/** Scroll axis: "vertical" (default) or "
|
|
106
|
-
axis?: "vertical" | "horizontal"
|
|
107
|
-
/** Controlled content size (skips host measurement when provided) */
|
|
120
|
+
/** Scroll axis: "vertical" (default), "horizontal", or "both" */
|
|
121
|
+
axis?: "vertical" | "horizontal" | "both"
|
|
122
|
+
/** Controlled content size for the primary axis (skips host measurement when provided) */
|
|
108
123
|
contentSize?: number
|
|
124
|
+
/** Controlled content width (skips host measurement when provided) */
|
|
125
|
+
contentWidth?: number
|
|
126
|
+
/** Controlled content height (skips host measurement when provided) */
|
|
127
|
+
contentHeight?: number
|
|
109
128
|
/** @internal Initial viewport size override (useful for tests) */
|
|
110
129
|
initialViewportSize?: number
|
|
130
|
+
/** @internal Initial viewport width override (useful for tests) */
|
|
131
|
+
initialViewportWidth?: number
|
|
132
|
+
/** @internal Initial viewport height override (useful for tests) */
|
|
133
|
+
initialViewportHeight?: number
|
|
111
134
|
/** @internal Initial content size override (useful for tests) */
|
|
112
135
|
initialContentSize?: number
|
|
113
|
-
/** Initial
|
|
136
|
+
/** @internal Initial content width override (useful for tests) */
|
|
137
|
+
initialContentWidth?: number
|
|
138
|
+
/** @internal Initial content height override (useful for tests) */
|
|
139
|
+
initialContentHeight?: number
|
|
140
|
+
/** Initial scroll offset for the primary axis */
|
|
114
141
|
initialOffset?: number
|
|
142
|
+
/** Initial horizontal scroll offset */
|
|
143
|
+
initialOffsetX?: number
|
|
144
|
+
/** Alignment when content is smaller than viewport */
|
|
145
|
+
align?: "start" | "end"
|
|
146
|
+
/** Whether to show scrollbars */
|
|
147
|
+
showScrollbar?: boolean
|
|
115
148
|
/** Enable keyboard navigation (default: true) */
|
|
116
149
|
enableKeyboard?: boolean
|
|
117
150
|
/** Enable mouse wheel (default: true) */
|
|
@@ -129,14 +162,22 @@ export interface UseScrollOptions {
|
|
|
129
162
|
export interface UseScrollReturn {
|
|
130
163
|
/** Current scroll state */
|
|
131
164
|
state: ScrollState
|
|
132
|
-
/** Set scroll offset directly */
|
|
165
|
+
/** Set scroll offset directly (primary axis) */
|
|
133
166
|
setOffset: (offset: number) => void
|
|
134
|
-
/**
|
|
167
|
+
/** Set horizontal scroll offset directly */
|
|
168
|
+
setOffsetX: (offsetX: number) => void
|
|
169
|
+
/** Scroll by delta pixels (primary axis) */
|
|
135
170
|
scrollBy: (delta: number) => void
|
|
136
|
-
/** Scroll
|
|
171
|
+
/** Scroll by delta pixels horizontally */
|
|
172
|
+
scrollByX: (delta: number) => void
|
|
173
|
+
/** Scroll to start (primary axis) */
|
|
137
174
|
scrollToStart: () => void
|
|
138
|
-
/** Scroll to end */
|
|
175
|
+
/** Scroll to end (primary axis) */
|
|
139
176
|
scrollToEnd: () => void
|
|
177
|
+
/** Scroll to horizontal start */
|
|
178
|
+
scrollToStartX: () => void
|
|
179
|
+
/** Scroll to horizontal end */
|
|
180
|
+
scrollToEndX: () => void
|
|
140
181
|
/**
|
|
141
182
|
* Scroll to make a position visible in the viewport.
|
|
142
183
|
* Useful for keeping a selected item in view.
|
|
@@ -144,17 +185,17 @@ export interface UseScrollReturn {
|
|
|
144
185
|
* @param itemSize - Size of each item (default: 1 for row-based lists)
|
|
145
186
|
* @param padding - Extra padding around the item (default: 0)
|
|
146
187
|
* @param totalSize - Optional known total content size (avoids stale state issues)
|
|
188
|
+
* @param axis - Axis to scroll (defaults to vertical)
|
|
147
189
|
*/
|
|
148
|
-
scrollToVisible: (
|
|
190
|
+
scrollToVisible: (
|
|
191
|
+
position: number,
|
|
192
|
+
itemSize?: number,
|
|
193
|
+
padding?: number,
|
|
194
|
+
totalSize?: number,
|
|
195
|
+
axis?: "vertical" | "horizontal",
|
|
196
|
+
) => void
|
|
149
197
|
/** Props to spread on <scroll> element */
|
|
150
|
-
scrollProps:
|
|
151
|
-
offset: number
|
|
152
|
-
axis: "vertical" | "horizontal"
|
|
153
|
-
onContentSize: (width: number, height: number) => void
|
|
154
|
-
onViewportSize: (width: number, height: number) => void
|
|
155
|
-
onEffectiveOffset?: (offset: number) => void
|
|
156
|
-
onRect?: (x: number, y: number, w: number, h: number) => void
|
|
157
|
-
}
|
|
198
|
+
scrollProps: ScrollProps
|
|
158
199
|
}
|
|
159
200
|
|
|
160
201
|
type ScrollInternalState = {
|
|
@@ -246,10 +287,19 @@ const reduceScroll = (state: ScrollInternalState, action: ScrollAction): ScrollI
|
|
|
246
287
|
export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
247
288
|
const {
|
|
248
289
|
axis = "vertical",
|
|
249
|
-
contentSize
|
|
290
|
+
contentSize,
|
|
291
|
+
contentWidth,
|
|
292
|
+
contentHeight,
|
|
250
293
|
initialViewportSize,
|
|
294
|
+
initialViewportWidth,
|
|
295
|
+
initialViewportHeight,
|
|
251
296
|
initialContentSize,
|
|
297
|
+
initialContentWidth,
|
|
298
|
+
initialContentHeight,
|
|
252
299
|
initialOffset = 0,
|
|
300
|
+
initialOffsetX = 0,
|
|
301
|
+
align = "start",
|
|
302
|
+
showScrollbar = true,
|
|
253
303
|
enableKeyboard = true,
|
|
254
304
|
enableMouseWheel = true,
|
|
255
305
|
enableAcceleration = true,
|
|
@@ -259,85 +309,186 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
259
309
|
} = options
|
|
260
310
|
|
|
261
311
|
const { width: termWidth, height: termHeight } = useTerminalSize()
|
|
262
|
-
const
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
312
|
+
const enableY = axis === "vertical" || axis === "both"
|
|
313
|
+
const enableX = axis === "horizontal" || axis === "both"
|
|
314
|
+
const primaryAxis: "vertical" | "horizontal" = axis === "horizontal" ? "horizontal" : "vertical"
|
|
315
|
+
|
|
316
|
+
const controlledContentHeight = contentHeight ?? (axis !== "horizontal" ? contentSize : undefined)
|
|
317
|
+
const controlledContentWidth = contentWidth ?? (axis === "horizontal" ? contentSize : undefined)
|
|
318
|
+
|
|
319
|
+
const baseViewportHeight =
|
|
320
|
+
initialViewportHeight ?? (axis !== "horizontal" ? initialViewportSize : undefined) ?? termHeight
|
|
321
|
+
const baseViewportWidth =
|
|
322
|
+
initialViewportWidth ?? (axis === "horizontal" ? initialViewportSize : undefined) ?? termWidth
|
|
323
|
+
|
|
324
|
+
const baseContentHeight =
|
|
325
|
+
controlledContentHeight ?? initialContentHeight ?? (axis !== "horizontal" ? initialContentSize : undefined) ?? 0
|
|
326
|
+
const baseContentWidth =
|
|
327
|
+
controlledContentWidth ?? initialContentWidth ?? (axis === "horizontal" ? initialContentSize : undefined) ?? 0
|
|
328
|
+
|
|
329
|
+
const initialOffsetY = axis === "horizontal" ? 0 : initialOffset
|
|
330
|
+
const initialOffsetXResolved = axis === "horizontal" ? initialOffset : initialOffsetX
|
|
331
|
+
|
|
332
|
+
const [internalY, dispatchY] = useReducer(reduceScroll, {
|
|
333
|
+
offset: clampOffset(initialOffsetY, baseContentHeight, baseViewportHeight),
|
|
334
|
+
contentSize: baseContentHeight,
|
|
335
|
+
viewportSize: baseViewportHeight,
|
|
269
336
|
viewportMeasured: false,
|
|
270
337
|
sticky,
|
|
271
338
|
})
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
339
|
+
const [internalX, dispatchX] = useReducer(reduceScroll, {
|
|
340
|
+
offset: clampOffset(initialOffsetXResolved, baseContentWidth, baseViewportWidth),
|
|
341
|
+
contentSize: baseContentWidth,
|
|
342
|
+
viewportSize: baseViewportWidth,
|
|
343
|
+
viewportMeasured: false,
|
|
344
|
+
sticky,
|
|
345
|
+
})
|
|
346
|
+
const stateYRef = useRef(internalY)
|
|
347
|
+
const stateXRef = useRef(internalX)
|
|
348
|
+
stateYRef.current = internalY
|
|
349
|
+
stateXRef.current = internalX
|
|
350
|
+
const viewportYRef = useRef(baseViewportHeight)
|
|
351
|
+
const viewportXRef = useRef(baseViewportWidth)
|
|
275
352
|
|
|
276
353
|
// Scroll acceleration
|
|
277
|
-
const
|
|
354
|
+
const accelY = useMemo<ScrollAcceleration>(
|
|
355
|
+
() => (enableAcceleration ? new MacOSScrollAccel() : { tick: () => 1, reset: () => {} }),
|
|
356
|
+
[enableAcceleration],
|
|
357
|
+
)
|
|
358
|
+
const accelX = useMemo<ScrollAcceleration>(
|
|
278
359
|
() => (enableAcceleration ? new MacOSScrollAccel() : { tick: () => 1, reset: () => {} }),
|
|
279
360
|
[enableAcceleration],
|
|
280
361
|
)
|
|
281
362
|
|
|
282
363
|
// Fractional accumulator for smooth sub-pixel scrolling
|
|
283
|
-
const
|
|
364
|
+
const accumulatorYRef = useRef(0)
|
|
365
|
+
const accumulatorXRef = useRef(0)
|
|
284
366
|
|
|
285
367
|
// Keep sticky state in sync when options change
|
|
286
368
|
useLayoutEffect(() => {
|
|
287
|
-
|
|
369
|
+
dispatchY({ type: "set-sticky", sticky })
|
|
370
|
+
dispatchX({ type: "set-sticky", sticky })
|
|
288
371
|
}, [sticky])
|
|
289
372
|
|
|
290
373
|
// Scroll by delta with accumulator for fractional scrolling
|
|
291
374
|
// Uses functional state updates to avoid stale closures during rapid scrolling
|
|
292
|
-
const
|
|
375
|
+
const scrollByY = useCallback(
|
|
376
|
+
(delta: number) => {
|
|
377
|
+
if (!enableY) return
|
|
378
|
+
accumulatorYRef.current += delta
|
|
379
|
+
const integerDelta = Math.trunc(accumulatorYRef.current)
|
|
380
|
+
if (integerDelta !== 0) {
|
|
381
|
+
dispatchY({ type: "scroll-by", delta: integerDelta })
|
|
382
|
+
accumulatorYRef.current -= integerDelta
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
[dispatchY, enableY],
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
const scrollByX = useCallback(
|
|
293
389
|
(delta: number) => {
|
|
294
|
-
|
|
295
|
-
|
|
390
|
+
if (!enableX) return
|
|
391
|
+
accumulatorXRef.current += delta
|
|
392
|
+
const integerDelta = Math.trunc(accumulatorXRef.current)
|
|
296
393
|
if (integerDelta !== 0) {
|
|
297
|
-
|
|
298
|
-
|
|
394
|
+
dispatchX({ type: "scroll-by", delta: integerDelta })
|
|
395
|
+
accumulatorXRef.current -= integerDelta
|
|
299
396
|
}
|
|
300
397
|
},
|
|
301
|
-
[
|
|
398
|
+
[dispatchX, enableX],
|
|
302
399
|
)
|
|
303
400
|
|
|
401
|
+
const scrollBy = useCallback(
|
|
402
|
+
(delta: number) => {
|
|
403
|
+
if (primaryAxis === "horizontal") {
|
|
404
|
+
scrollByX(delta)
|
|
405
|
+
} else {
|
|
406
|
+
scrollByY(delta)
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
[primaryAxis, scrollByX, scrollByY],
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
const scrollToStartY = useCallback(() => {
|
|
413
|
+
dispatchY({ type: "set-offset", offset: 0 })
|
|
414
|
+
accumulatorYRef.current = 0
|
|
415
|
+
accelY.reset()
|
|
416
|
+
}, [dispatchY, accelY])
|
|
417
|
+
|
|
418
|
+
const scrollToStartX = useCallback(() => {
|
|
419
|
+
dispatchX({ type: "set-offset", offset: 0 })
|
|
420
|
+
accumulatorXRef.current = 0
|
|
421
|
+
accelX.reset()
|
|
422
|
+
}, [dispatchX, accelX])
|
|
423
|
+
|
|
304
424
|
const scrollToStart = useCallback(() => {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
425
|
+
if (primaryAxis === "horizontal") {
|
|
426
|
+
scrollToStartX()
|
|
427
|
+
} else {
|
|
428
|
+
scrollToStartY()
|
|
429
|
+
}
|
|
430
|
+
}, [primaryAxis, scrollToStartX, scrollToStartY])
|
|
309
431
|
|
|
310
|
-
const
|
|
311
|
-
const current =
|
|
432
|
+
const scrollToEndY = useCallback(() => {
|
|
433
|
+
const current = stateYRef.current
|
|
312
434
|
const maxOffset = Math.max(0, current.contentSize - current.viewportSize)
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
}, [
|
|
435
|
+
dispatchY({ type: "set-offset", offset: maxOffset })
|
|
436
|
+
accumulatorYRef.current = 0
|
|
437
|
+
accelY.reset()
|
|
438
|
+
}, [dispatchY, accelY])
|
|
439
|
+
|
|
440
|
+
const scrollToEndX = useCallback(() => {
|
|
441
|
+
const current = stateXRef.current
|
|
442
|
+
const maxOffset = Math.max(0, current.contentSize - current.viewportSize)
|
|
443
|
+
dispatchX({ type: "set-offset", offset: maxOffset })
|
|
444
|
+
accumulatorXRef.current = 0
|
|
445
|
+
accelX.reset()
|
|
446
|
+
}, [dispatchX, accelX])
|
|
447
|
+
|
|
448
|
+
const scrollToEnd = useCallback(() => {
|
|
449
|
+
if (primaryAxis === "horizontal") {
|
|
450
|
+
scrollToEndX()
|
|
451
|
+
} else {
|
|
452
|
+
scrollToEndY()
|
|
453
|
+
}
|
|
454
|
+
}, [primaryAxis, scrollToEndX, scrollToEndY])
|
|
317
455
|
|
|
318
456
|
// Handle content size changes (for sticky scroll)
|
|
319
457
|
const handleContentSize = useCallback(
|
|
320
458
|
(width: number, height: number) => {
|
|
321
|
-
if (
|
|
322
|
-
|
|
323
|
-
|
|
459
|
+
if (enableY && controlledContentHeight === undefined) {
|
|
460
|
+
dispatchY({ type: "set-content", size: height })
|
|
461
|
+
}
|
|
462
|
+
if (enableX && controlledContentWidth === undefined) {
|
|
463
|
+
dispatchX({ type: "set-content", size: width })
|
|
464
|
+
}
|
|
324
465
|
},
|
|
325
|
-
[
|
|
466
|
+
[controlledContentHeight, controlledContentWidth, dispatchX, dispatchY, enableX, enableY],
|
|
326
467
|
)
|
|
327
468
|
|
|
328
469
|
useLayoutEffect(() => {
|
|
329
|
-
if (
|
|
330
|
-
|
|
331
|
-
}, [
|
|
470
|
+
if (!enableY || controlledContentHeight === undefined) return
|
|
471
|
+
dispatchY({ type: "set-content", size: controlledContentHeight })
|
|
472
|
+
}, [controlledContentHeight, dispatchY, enableY])
|
|
473
|
+
|
|
474
|
+
useLayoutEffect(() => {
|
|
475
|
+
if (!enableX || controlledContentWidth === undefined) return
|
|
476
|
+
dispatchX({ type: "set-content", size: controlledContentWidth })
|
|
477
|
+
}, [controlledContentWidth, dispatchX, enableX])
|
|
332
478
|
|
|
333
479
|
// Handle viewport size changes (reported by scroll component)
|
|
334
480
|
const handleViewportSize = useCallback(
|
|
335
481
|
(width: number, height: number) => {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
482
|
+
if (enableY) {
|
|
483
|
+
viewportYRef.current = height
|
|
484
|
+
dispatchY({ type: "set-viewport", size: height })
|
|
485
|
+
}
|
|
486
|
+
if (enableX) {
|
|
487
|
+
viewportXRef.current = width
|
|
488
|
+
dispatchX({ type: "set-viewport", size: width })
|
|
489
|
+
}
|
|
339
490
|
},
|
|
340
|
-
[
|
|
491
|
+
[dispatchX, dispatchY, enableX, enableY],
|
|
341
492
|
)
|
|
342
493
|
|
|
343
494
|
// Keyboard handler
|
|
@@ -346,36 +497,66 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
346
497
|
// Mouse wheel comes as pageup/pagedown with meta=true
|
|
347
498
|
// Handle separately from keyboard since enableKeyboard shouldn't disable mouse
|
|
348
499
|
if (key.meta && enableMouseWheel && (key.name === "pageup" || key.name === "pagedown")) {
|
|
349
|
-
const multiplier =
|
|
500
|
+
const multiplier = primaryAxis === "horizontal" && !enableY ? accelX.tick() : accelY.tick()
|
|
350
501
|
const delta = Math.ceil(arrowSpeed * multiplier)
|
|
351
|
-
|
|
502
|
+
if (axis === "horizontal") {
|
|
503
|
+
scrollByX(key.name === "pageup" ? -delta : delta)
|
|
504
|
+
} else {
|
|
505
|
+
scrollByY(key.name === "pageup" ? -delta : delta)
|
|
506
|
+
}
|
|
352
507
|
return
|
|
353
508
|
}
|
|
354
509
|
|
|
355
510
|
if (!enableKeyboard) return
|
|
356
511
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
512
|
+
if (axis === "horizontal") {
|
|
513
|
+
switch (key.name) {
|
|
514
|
+
case "left":
|
|
515
|
+
scrollByX(-arrowSpeed)
|
|
516
|
+
break
|
|
517
|
+
case "right":
|
|
518
|
+
scrollByX(arrowSpeed)
|
|
519
|
+
break
|
|
520
|
+
case "pageup":
|
|
521
|
+
scrollByX(-Math.floor(viewportXRef.current * pageSpeed))
|
|
522
|
+
break
|
|
523
|
+
case "pagedown":
|
|
524
|
+
scrollByX(Math.floor(viewportXRef.current * pageSpeed))
|
|
525
|
+
break
|
|
526
|
+
case "home":
|
|
527
|
+
scrollToStartX()
|
|
528
|
+
break
|
|
529
|
+
case "end":
|
|
530
|
+
scrollToEndX()
|
|
531
|
+
break
|
|
532
|
+
}
|
|
533
|
+
return
|
|
534
|
+
}
|
|
360
535
|
|
|
361
536
|
switch (key.name) {
|
|
362
|
-
case
|
|
363
|
-
|
|
537
|
+
case "up":
|
|
538
|
+
scrollByY(-arrowSpeed)
|
|
539
|
+
break
|
|
540
|
+
case "down":
|
|
541
|
+
scrollByY(arrowSpeed)
|
|
364
542
|
break
|
|
365
|
-
case
|
|
366
|
-
|
|
543
|
+
case "left":
|
|
544
|
+
if (axis === "both") scrollByX(-arrowSpeed)
|
|
545
|
+
break
|
|
546
|
+
case "right":
|
|
547
|
+
if (axis === "both") scrollByX(arrowSpeed)
|
|
367
548
|
break
|
|
368
549
|
case "pageup":
|
|
369
|
-
|
|
550
|
+
scrollByY(-Math.floor(viewportYRef.current * pageSpeed))
|
|
370
551
|
break
|
|
371
552
|
case "pagedown":
|
|
372
|
-
|
|
553
|
+
scrollByY(Math.floor(viewportYRef.current * pageSpeed))
|
|
373
554
|
break
|
|
374
555
|
case "home":
|
|
375
|
-
|
|
556
|
+
scrollToStartY()
|
|
376
557
|
break
|
|
377
558
|
case "end":
|
|
378
|
-
|
|
559
|
+
scrollToEndY()
|
|
379
560
|
break
|
|
380
561
|
}
|
|
381
562
|
},
|
|
@@ -385,10 +566,16 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
385
566
|
arrowSpeed,
|
|
386
567
|
pageSpeed,
|
|
387
568
|
enableMouseWheel,
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
569
|
+
accelX,
|
|
570
|
+
accelY,
|
|
571
|
+
enableY,
|
|
572
|
+
primaryAxis,
|
|
573
|
+
scrollByX,
|
|
574
|
+
scrollByY,
|
|
575
|
+
scrollToEndX,
|
|
576
|
+
scrollToEndY,
|
|
577
|
+
scrollToStartX,
|
|
578
|
+
scrollToStartY,
|
|
392
579
|
],
|
|
393
580
|
)
|
|
394
581
|
|
|
@@ -399,10 +586,17 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
399
586
|
// so selection effects can re-run after measurement updates.
|
|
400
587
|
// Bypasses clampOffset because it uses totalSize for accurate clamping
|
|
401
588
|
const scrollToVisible = useCallback(
|
|
402
|
-
(
|
|
403
|
-
|
|
589
|
+
(
|
|
590
|
+
position: number,
|
|
591
|
+
itemSize = 1,
|
|
592
|
+
padding = 0,
|
|
593
|
+
totalSize?: number,
|
|
594
|
+
axisOverride: "vertical" | "horizontal" = "vertical",
|
|
595
|
+
) => {
|
|
596
|
+
const useHorizontal = axisOverride === "horizontal"
|
|
597
|
+
const current = useHorizontal ? stateXRef.current : stateYRef.current
|
|
404
598
|
const currentOffset = current.offset
|
|
405
|
-
const currentViewportSize =
|
|
599
|
+
const currentViewportSize = useHorizontal ? viewportXRef.current : viewportYRef.current
|
|
406
600
|
const itemStart = position * itemSize
|
|
407
601
|
const itemEnd = itemStart + itemSize
|
|
408
602
|
// Use provided totalSize if available (more accurate than potentially stale contentSize)
|
|
@@ -413,59 +607,105 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
413
607
|
// If item is above viewport, scroll up to show it
|
|
414
608
|
if (isAbove) {
|
|
415
609
|
const newOffset = Math.max(0, itemStart - padding)
|
|
416
|
-
|
|
610
|
+
;(useHorizontal ? dispatchX : dispatchY)({ type: "set-offset", offset: newOffset })
|
|
417
611
|
}
|
|
418
612
|
// If item is below viewport, scroll down to show it
|
|
419
613
|
else if (isBelow) {
|
|
420
614
|
const currentMaxOffset = Math.max(0, effectiveContentSize - currentViewportSize)
|
|
421
615
|
const newOffset = Math.min(currentMaxOffset, itemEnd - currentViewportSize + padding)
|
|
422
|
-
|
|
616
|
+
;(useHorizontal ? dispatchX : dispatchY)({ type: "set-offset", offset: newOffset })
|
|
423
617
|
}
|
|
424
618
|
},
|
|
425
|
-
[
|
|
619
|
+
[dispatchX, dispatchY],
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
const setOffsetY = useCallback(
|
|
623
|
+
(newOffset: number) => {
|
|
624
|
+
dispatchY({ type: "set-offset", offset: newOffset })
|
|
625
|
+
},
|
|
626
|
+
[dispatchY],
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
const setOffsetX = useCallback(
|
|
630
|
+
(newOffset: number) => {
|
|
631
|
+
dispatchX({ type: "set-offset", offset: newOffset })
|
|
632
|
+
},
|
|
633
|
+
[dispatchX],
|
|
426
634
|
)
|
|
427
635
|
|
|
428
636
|
const setOffset = useCallback(
|
|
429
637
|
(newOffset: number) => {
|
|
430
|
-
|
|
638
|
+
if (primaryAxis === "horizontal") {
|
|
639
|
+
setOffsetX(newOffset)
|
|
640
|
+
} else {
|
|
641
|
+
setOffsetY(newOffset)
|
|
642
|
+
}
|
|
431
643
|
},
|
|
432
|
-
[
|
|
644
|
+
[primaryAxis, setOffsetX, setOffsetY],
|
|
433
645
|
)
|
|
434
646
|
|
|
435
647
|
// Calculate derived state
|
|
436
|
-
const
|
|
437
|
-
const
|
|
438
|
-
const
|
|
648
|
+
const maxOffsetY = Math.max(0, internalY.contentSize - internalY.viewportSize)
|
|
649
|
+
const maxOffsetX = Math.max(0, internalX.contentSize - internalX.viewportSize)
|
|
650
|
+
const atStartY = internalY.offset <= 0
|
|
651
|
+
const atEndY = internalY.offset >= maxOffsetY
|
|
652
|
+
const atStartX = internalX.offset <= 0
|
|
653
|
+
const atEndX = internalX.offset >= maxOffsetX
|
|
654
|
+
|
|
655
|
+
const primaryState = primaryAxis === "horizontal" ? internalX : internalY
|
|
656
|
+
const primaryMaxOffset = primaryAxis === "horizontal" ? maxOffsetX : maxOffsetY
|
|
657
|
+
const primaryAtStart = primaryAxis === "horizontal" ? atStartX : atStartY
|
|
658
|
+
const primaryAtEnd = primaryAxis === "horizontal" ? atEndX : atEndY
|
|
439
659
|
|
|
440
660
|
const state: ScrollState = {
|
|
441
|
-
offset:
|
|
442
|
-
maxOffset,
|
|
443
|
-
viewportSize:
|
|
444
|
-
viewportMeasured:
|
|
445
|
-
contentSize:
|
|
446
|
-
atStart,
|
|
447
|
-
atEnd,
|
|
661
|
+
offset: primaryState.offset,
|
|
662
|
+
maxOffset: primaryMaxOffset,
|
|
663
|
+
viewportSize: primaryState.viewportSize,
|
|
664
|
+
viewportMeasured: primaryState.viewportMeasured,
|
|
665
|
+
contentSize: primaryState.contentSize,
|
|
666
|
+
atStart: primaryAtStart,
|
|
667
|
+
atEnd: primaryAtEnd,
|
|
668
|
+
offsetX: internalX.offset,
|
|
669
|
+
maxOffsetX,
|
|
670
|
+
viewportSizeX: internalX.viewportSize,
|
|
671
|
+
viewportMeasuredX: internalX.viewportMeasured,
|
|
672
|
+
contentSizeX: internalX.contentSize,
|
|
673
|
+
atStartX,
|
|
674
|
+
atEndX,
|
|
448
675
|
}
|
|
449
676
|
|
|
450
677
|
// Handle effective offset sync from host (when sticky adjusts the offset)
|
|
451
678
|
const handleEffectiveOffset = useCallback((effectiveOffset: number) => {
|
|
452
|
-
|
|
453
|
-
}, [
|
|
679
|
+
dispatchY({ type: "sync-effective-offset", offset: effectiveOffset })
|
|
680
|
+
}, [dispatchY])
|
|
681
|
+
|
|
682
|
+
const handleEffectiveOffsetX = useCallback((effectiveOffsetX: number) => {
|
|
683
|
+
dispatchX({ type: "sync-effective-offset", offset: effectiveOffsetX })
|
|
684
|
+
}, [dispatchX])
|
|
454
685
|
|
|
455
|
-
const scrollProps = {
|
|
456
|
-
offset:
|
|
686
|
+
const scrollProps: ScrollProps = {
|
|
687
|
+
offset: enableY ? internalY.offset : 0,
|
|
688
|
+
offsetX: enableX ? internalX.offset : 0,
|
|
457
689
|
axis,
|
|
690
|
+
align,
|
|
691
|
+
sticky,
|
|
692
|
+
showScrollbar,
|
|
458
693
|
onContentSize: handleContentSize,
|
|
459
694
|
onViewportSize: handleViewportSize,
|
|
460
|
-
onEffectiveOffset: handleEffectiveOffset,
|
|
695
|
+
onEffectiveOffset: enableY ? handleEffectiveOffset : undefined,
|
|
696
|
+
onEffectiveOffsetX: enableX ? handleEffectiveOffsetX : undefined,
|
|
461
697
|
}
|
|
462
698
|
|
|
463
699
|
return {
|
|
464
700
|
state,
|
|
465
701
|
setOffset,
|
|
702
|
+
setOffsetX,
|
|
466
703
|
scrollBy,
|
|
704
|
+
scrollByX,
|
|
467
705
|
scrollToStart,
|
|
468
706
|
scrollToEnd,
|
|
707
|
+
scrollToStartX,
|
|
708
|
+
scrollToEndX,
|
|
469
709
|
scrollToVisible,
|
|
470
710
|
scrollProps,
|
|
471
711
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { KeyMsg } from "@effect-tui/core"
|
|
2
|
+
import { useRef } from "react"
|
|
3
|
+
import { isKey, type Shortcut, type ShortcutMatchOptions } from "../shortcuts.js"
|
|
4
|
+
import { useKeyboard } from "./use-keyboard.js"
|
|
5
|
+
|
|
6
|
+
export type ShortcutHandler = (key: KeyMsg) => void
|
|
7
|
+
|
|
8
|
+
export type ShortcutMap = Record<string, ShortcutHandler>
|
|
9
|
+
|
|
10
|
+
export type UseShortcutOptions = {
|
|
11
|
+
/** Which phase to listen for; defaults to "press" and treats missing phase as "press". */
|
|
12
|
+
phase?: "press" | "repeat" | "release" | "any"
|
|
13
|
+
/** Require that unspecified modifiers are not pressed (default: true). */
|
|
14
|
+
exact?: boolean
|
|
15
|
+
/**
|
|
16
|
+
* If true, call preventDefault when available before invoking handler.
|
|
17
|
+
* This stops further renderer-level handlers.
|
|
18
|
+
*/
|
|
19
|
+
stopPropagation?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type ParsedEntry = {
|
|
23
|
+
shortcut: Shortcut
|
|
24
|
+
handler: ShortcutHandler
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function useShortcut(shortcuts: ShortcutMap, options: UseShortcutOptions = {}): void {
|
|
28
|
+
const entriesRef = useRef<ParsedEntry[]>([])
|
|
29
|
+
const matchOptionsRef = useRef<ShortcutMatchOptions>({ exact: options.exact ?? true })
|
|
30
|
+
const stopRef = useRef(options.stopPropagation ?? false)
|
|
31
|
+
|
|
32
|
+
entriesRef.current = Object.entries(shortcuts).map(([shortcut, handler]) => ({
|
|
33
|
+
shortcut,
|
|
34
|
+
handler,
|
|
35
|
+
}))
|
|
36
|
+
matchOptionsRef.current = { exact: options.exact ?? true }
|
|
37
|
+
stopRef.current = options.stopPropagation ?? false
|
|
38
|
+
|
|
39
|
+
useKeyboard(
|
|
40
|
+
(key) => {
|
|
41
|
+
const entries = entriesRef.current
|
|
42
|
+
const matchOptions = matchOptionsRef.current
|
|
43
|
+
const stop = stopRef.current
|
|
44
|
+
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
if (!isKey(key, entry.shortcut, matchOptions)) continue
|
|
47
|
+
if (stop && key.preventDefault) key.preventDefault()
|
|
48
|
+
entry.handler(key)
|
|
49
|
+
if (stop) break
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
{ phase: options.phase },
|
|
53
|
+
)
|
|
54
|
+
}
|