@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.
Files changed (69) hide show
  1. package/README.md +21 -1
  2. package/dist/src/dev.d.ts +22 -32
  3. package/dist/src/dev.d.ts.map +1 -1
  4. package/dist/src/dev.js +42 -96
  5. package/dist/src/dev.js.map +1 -1
  6. package/dist/src/hooks/index.d.ts +3 -0
  7. package/dist/src/hooks/index.d.ts.map +1 -1
  8. package/dist/src/hooks/index.js +1 -0
  9. package/dist/src/hooks/index.js.map +1 -1
  10. package/dist/src/hooks/use-mouse.d.ts +10 -1
  11. package/dist/src/hooks/use-mouse.d.ts.map +1 -1
  12. package/dist/src/hooks/use-mouse.js +10 -2
  13. package/dist/src/hooks/use-mouse.js.map +1 -1
  14. package/dist/src/hooks/use-paste.d.ts +13 -3
  15. package/dist/src/hooks/use-paste.d.ts.map +1 -1
  16. package/dist/src/hooks/use-paste.js +15 -5
  17. package/dist/src/hooks/use-paste.js.map +1 -1
  18. package/dist/src/hooks/use-scroll.d.ts +59 -24
  19. package/dist/src/hooks/use-scroll.d.ts.map +1 -1
  20. package/dist/src/hooks/use-scroll.js +238 -79
  21. package/dist/src/hooks/use-scroll.js.map +1 -1
  22. package/dist/src/hooks/use-shortcut.d.ts +16 -0
  23. package/dist/src/hooks/use-shortcut.d.ts.map +1 -0
  24. package/dist/src/hooks/use-shortcut.js +29 -0
  25. package/dist/src/hooks/use-shortcut.js.map +1 -0
  26. package/dist/src/hosts/scroll.d.ts +4 -0
  27. package/dist/src/hosts/scroll.d.ts.map +1 -1
  28. package/dist/src/hosts/scroll.js +18 -2
  29. package/dist/src/hosts/scroll.js.map +1 -1
  30. package/dist/src/index.d.ts +6 -4
  31. package/dist/src/index.d.ts.map +1 -1
  32. package/dist/src/index.js +3 -2
  33. package/dist/src/index.js.map +1 -1
  34. package/dist/src/renderer/input/InputProcessor.d.ts.map +1 -1
  35. package/dist/src/renderer/input/InputProcessor.js +8 -1
  36. package/dist/src/renderer/input/InputProcessor.js.map +1 -1
  37. package/dist/src/renderer/lifecycle/EventBus.d.ts +2 -2
  38. package/dist/src/renderer/lifecycle/EventBus.d.ts.map +1 -1
  39. package/dist/src/renderer/lifecycle/EventBus.js +13 -1
  40. package/dist/src/renderer/lifecycle/EventBus.js.map +1 -1
  41. package/dist/src/renderer-types.d.ts +8 -1
  42. package/dist/src/renderer-types.d.ts.map +1 -1
  43. package/dist/src/renderer.d.ts +13 -2
  44. package/dist/src/renderer.d.ts.map +1 -1
  45. package/dist/src/renderer.js +48 -7
  46. package/dist/src/renderer.js.map +1 -1
  47. package/dist/src/shortcuts.d.ts +15 -0
  48. package/dist/src/shortcuts.d.ts.map +1 -0
  49. package/dist/src/shortcuts.js +149 -0
  50. package/dist/src/shortcuts.js.map +1 -0
  51. package/dist/src/test/mock-streams.d.ts.map +1 -1
  52. package/dist/src/test/mock-streams.js +0 -3
  53. package/dist/src/test/mock-streams.js.map +1 -1
  54. package/dist/tsconfig.tsbuildinfo +1 -1
  55. package/package.json +2 -2
  56. package/src/dev.tsx +59 -107
  57. package/src/hooks/index.ts +3 -0
  58. package/src/hooks/use-mouse.ts +19 -3
  59. package/src/hooks/use-paste.ts +24 -5
  60. package/src/hooks/use-scroll.ts +345 -105
  61. package/src/hooks/use-shortcut.ts +54 -0
  62. package/src/hosts/scroll.ts +21 -2
  63. package/src/index.ts +19 -6
  64. package/src/renderer/input/InputProcessor.ts +8 -1
  65. package/src/renderer/lifecycle/EventBus.ts +14 -4
  66. package/src/renderer-types.ts +9 -1
  67. package/src/renderer.ts +96 -9
  68. package/src/shortcuts.ts +180 -0
  69. package/src/test/mock-streams.ts +0 -3
@@ -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 height (or width for horizontal) */
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 "horizontal" */
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 scroll offset */
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
- /** Scroll by delta pixels */
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 to start */
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: (position: number, itemSize?: number, padding?: number, totalSize?: number) => void
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: controlledContentSize,
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 baseViewportSize = initialViewportSize ?? (axis === "vertical" ? termHeight : termWidth)
263
- const baseContentSize = controlledContentSize ?? initialContentSize ?? 0
264
-
265
- const [internal, dispatch] = useReducer(reduceScroll, {
266
- offset: clampOffset(initialOffset, baseContentSize, baseViewportSize),
267
- contentSize: baseContentSize,
268
- viewportSize: baseViewportSize,
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 stateRef = useRef(internal)
273
- stateRef.current = internal
274
- const viewportSizeRef = useRef(baseViewportSize)
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 accel = useMemo(
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 accumulatorRef = useRef(0)
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
- dispatch({ type: "set-sticky", sticky })
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 scrollBy = useCallback(
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
- accumulatorRef.current += delta
295
- const integerDelta = Math.trunc(accumulatorRef.current)
390
+ if (!enableX) return
391
+ accumulatorXRef.current += delta
392
+ const integerDelta = Math.trunc(accumulatorXRef.current)
296
393
  if (integerDelta !== 0) {
297
- dispatch({ type: "scroll-by", delta: integerDelta })
298
- accumulatorRef.current -= integerDelta
394
+ dispatchX({ type: "scroll-by", delta: integerDelta })
395
+ accumulatorXRef.current -= integerDelta
299
396
  }
300
397
  },
301
- [dispatch],
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
- dispatch({ type: "set-offset", offset: 0 })
306
- accumulatorRef.current = 0
307
- accel.reset()
308
- }, [dispatch, accel])
425
+ if (primaryAxis === "horizontal") {
426
+ scrollToStartX()
427
+ } else {
428
+ scrollToStartY()
429
+ }
430
+ }, [primaryAxis, scrollToStartX, scrollToStartY])
309
431
 
310
- const scrollToEnd = useCallback(() => {
311
- const current = stateRef.current
432
+ const scrollToEndY = useCallback(() => {
433
+ const current = stateYRef.current
312
434
  const maxOffset = Math.max(0, current.contentSize - current.viewportSize)
313
- dispatch({ type: "set-offset", offset: maxOffset })
314
- accumulatorRef.current = 0
315
- accel.reset()
316
- }, [dispatch, accel])
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 (controlledContentSize !== undefined) return
322
- const newSize = axis === "vertical" ? height : width
323
- dispatch({ type: "set-content", size: newSize })
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
- [axis, controlledContentSize],
466
+ [controlledContentHeight, controlledContentWidth, dispatchX, dispatchY, enableX, enableY],
326
467
  )
327
468
 
328
469
  useLayoutEffect(() => {
329
- if (controlledContentSize === undefined) return
330
- dispatch({ type: "set-content", size: controlledContentSize })
331
- }, [controlledContentSize])
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
- const newSize = axis === "vertical" ? height : width
337
- viewportSizeRef.current = newSize
338
- dispatch({ type: "set-viewport", size: newSize })
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
- [axis, dispatch],
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 = accel.tick()
500
+ const multiplier = primaryAxis === "horizontal" && !enableY ? accelX.tick() : accelY.tick()
350
501
  const delta = Math.ceil(arrowSpeed * multiplier)
351
- scrollBy(key.name === "pageup" ? -delta : delta)
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
- const isVertical = axis === "vertical"
358
- const upKey = isVertical ? "up" : "left"
359
- const downKey = isVertical ? "down" : "right"
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 upKey:
363
- scrollBy(-arrowSpeed)
537
+ case "up":
538
+ scrollByY(-arrowSpeed)
539
+ break
540
+ case "down":
541
+ scrollByY(arrowSpeed)
364
542
  break
365
- case downKey:
366
- scrollBy(arrowSpeed)
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
- scrollBy(-Math.floor(viewportSizeRef.current * pageSpeed))
550
+ scrollByY(-Math.floor(viewportYRef.current * pageSpeed))
370
551
  break
371
552
  case "pagedown":
372
- scrollBy(Math.floor(viewportSizeRef.current * pageSpeed))
553
+ scrollByY(Math.floor(viewportYRef.current * pageSpeed))
373
554
  break
374
555
  case "home":
375
- scrollToStart()
556
+ scrollToStartY()
376
557
  break
377
558
  case "end":
378
- scrollToEnd()
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
- accel,
389
- scrollBy,
390
- scrollToStart,
391
- scrollToEnd,
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
- (position: number, itemSize = 1, padding = 0, totalSize?: number) => {
403
- const current = stateRef.current
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 = viewportSizeRef.current
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
- dispatch({ type: "set-offset", offset: newOffset })
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
- dispatch({ type: "set-offset", offset: newOffset })
616
+ ;(useHorizontal ? dispatchX : dispatchY)({ type: "set-offset", offset: newOffset })
423
617
  }
424
618
  },
425
- [dispatch],
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
- dispatch({ type: "set-offset", offset: newOffset })
638
+ if (primaryAxis === "horizontal") {
639
+ setOffsetX(newOffset)
640
+ } else {
641
+ setOffsetY(newOffset)
642
+ }
431
643
  },
432
- [dispatch],
644
+ [primaryAxis, setOffsetX, setOffsetY],
433
645
  )
434
646
 
435
647
  // Calculate derived state
436
- const maxOffset = Math.max(0, internal.contentSize - internal.viewportSize)
437
- const atStart = internal.offset <= 0
438
- const atEnd = internal.offset >= maxOffset
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: internal.offset,
442
- maxOffset,
443
- viewportSize: internal.viewportSize,
444
- viewportMeasured: internal.viewportMeasured,
445
- contentSize: internal.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
- dispatch({ type: "sync-effective-offset", offset: effectiveOffset })
453
- }, [dispatch])
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: internal.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
+ }