@silvery/ui 0.3.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 (87) hide show
  1. package/package.json +71 -0
  2. package/src/animation/easing.ts +38 -0
  3. package/src/animation/index.ts +18 -0
  4. package/src/animation/useAnimation.ts +143 -0
  5. package/src/animation/useInterval.ts +39 -0
  6. package/src/animation/useLatest.ts +35 -0
  7. package/src/animation/useTimeout.ts +65 -0
  8. package/src/animation/useTransition.ts +110 -0
  9. package/src/animation.ts +24 -0
  10. package/src/ansi/index.ts +43 -0
  11. package/src/canvas/index.ts +169 -0
  12. package/src/cli/ansi.ts +85 -0
  13. package/src/cli/index.ts +39 -0
  14. package/src/cli/multi-progress.ts +340 -0
  15. package/src/cli/progress-bar.ts +222 -0
  16. package/src/cli/spinner.ts +275 -0
  17. package/src/components/Badge.tsx +54 -0
  18. package/src/components/Breadcrumb.tsx +72 -0
  19. package/src/components/Button.tsx +73 -0
  20. package/src/components/CommandPalette.tsx +186 -0
  21. package/src/components/Console.tsx +79 -0
  22. package/src/components/CursorLine.tsx +71 -0
  23. package/src/components/Divider.tsx +67 -0
  24. package/src/components/EditContextDisplay.tsx +164 -0
  25. package/src/components/ErrorBoundary.tsx +179 -0
  26. package/src/components/Form.tsx +86 -0
  27. package/src/components/GridCell.tsx +42 -0
  28. package/src/components/HorizontalVirtualList.tsx +375 -0
  29. package/src/components/ModalDialog.tsx +179 -0
  30. package/src/components/PickerDialog.tsx +208 -0
  31. package/src/components/PickerList.tsx +93 -0
  32. package/src/components/ProgressBar.tsx +126 -0
  33. package/src/components/Screen.tsx +78 -0
  34. package/src/components/ScrollbackList.tsx +92 -0
  35. package/src/components/ScrollbackView.tsx +390 -0
  36. package/src/components/SelectList.tsx +176 -0
  37. package/src/components/Skeleton.tsx +87 -0
  38. package/src/components/Spinner.tsx +64 -0
  39. package/src/components/SplitView.tsx +199 -0
  40. package/src/components/Table.tsx +139 -0
  41. package/src/components/Tabs.tsx +203 -0
  42. package/src/components/TextArea.tsx +264 -0
  43. package/src/components/TextInput.tsx +240 -0
  44. package/src/components/Toast.tsx +216 -0
  45. package/src/components/Toggle.tsx +73 -0
  46. package/src/components/Tooltip.tsx +60 -0
  47. package/src/components/TreeView.tsx +212 -0
  48. package/src/components/Typography.tsx +233 -0
  49. package/src/components/VirtualList.tsx +318 -0
  50. package/src/components/VirtualView.tsx +221 -0
  51. package/src/components/useReadline.ts +213 -0
  52. package/src/components/useTextArea.ts +648 -0
  53. package/src/components.ts +133 -0
  54. package/src/display/Table.tsx +179 -0
  55. package/src/display/index.ts +13 -0
  56. package/src/hooks/useTea.ts +133 -0
  57. package/src/image/Image.tsx +187 -0
  58. package/src/image/index.ts +15 -0
  59. package/src/image/kitty-graphics.ts +161 -0
  60. package/src/image/sixel-encoder.ts +194 -0
  61. package/src/images.ts +22 -0
  62. package/src/index.ts +34 -0
  63. package/src/input/Select.tsx +155 -0
  64. package/src/input/TextInput.tsx +227 -0
  65. package/src/input/index.ts +25 -0
  66. package/src/progress/als-context.ts +160 -0
  67. package/src/progress/declarative.ts +519 -0
  68. package/src/progress/index.ts +54 -0
  69. package/src/progress/step-node.ts +152 -0
  70. package/src/progress/steps.ts +425 -0
  71. package/src/progress/task.ts +138 -0
  72. package/src/progress/tasks.ts +216 -0
  73. package/src/react/ProgressBar.tsx +146 -0
  74. package/src/react/Spinner.tsx +74 -0
  75. package/src/react/Tasks.tsx +144 -0
  76. package/src/react/context.tsx +145 -0
  77. package/src/react/index.ts +30 -0
  78. package/src/types.ts +252 -0
  79. package/src/utils/eta.ts +155 -0
  80. package/src/utils/index.ts +13 -0
  81. package/src/wrappers/index.ts +36 -0
  82. package/src/wrappers/with-progress.ts +250 -0
  83. package/src/wrappers/with-select.ts +194 -0
  84. package/src/wrappers/with-spinner.ts +108 -0
  85. package/src/wrappers/with-text-input.ts +388 -0
  86. package/src/wrappers/wrap-emitter.ts +158 -0
  87. package/src/wrappers/wrap-generator.ts +143 -0
@@ -0,0 +1,648 @@
1
+ /**
2
+ * useTextArea Hook
3
+ *
4
+ * Headless multi-line text editing hook with word wrapping, scrolling,
5
+ * cursor movement, selection, and kill ring. Extracted from TextArea
6
+ * so custom UIs can reuse the editing logic without the default rendering.
7
+ *
8
+ * TextArea itself uses this hook internally.
9
+ *
10
+ * Usage:
11
+ * ```tsx
12
+ * function CustomEditor() {
13
+ * const { width } = useContentRect()
14
+ * const ta = useTextArea({ height: 10, wrapWidth: width })
15
+ *
16
+ * // ta.value, ta.cursor, ta.wrappedLines, ta.selection, etc.
17
+ * // ta.clear(), ta.setValue(), ta.getSelection()
18
+ * // Input handling is done automatically via useInput.
19
+ *
20
+ * return <MyCustomRendering {...ta} />
21
+ * }
22
+ * ```
23
+ *
24
+ * Supported shortcuts (same as TextArea component):
25
+ * - Arrow keys: Move cursor (clears selection)
26
+ * - Shift+Arrow: Extend selection
27
+ * - Shift+Home/End: Select to line boundaries
28
+ * - Ctrl+Shift+Arrow: Word-wise selection
29
+ * - Ctrl+A: Select all text
30
+ * - Ctrl+E: End of line
31
+ * - Home/End: Beginning/end of line
32
+ * - Alt+B/F: Move by word (wraps across lines)
33
+ * - Ctrl+W, Alt+Backspace: Delete word backwards (kill ring)
34
+ * - Alt+D: Delete word forwards (kill ring)
35
+ * - Ctrl+K: Kill to end of line (kill ring)
36
+ * - Ctrl+U: Kill to beginning of line (kill ring)
37
+ * - Ctrl+Y: Yank (paste from kill ring)
38
+ * - Alt+Y: Cycle through kill ring (after Ctrl+Y)
39
+ * - Ctrl+T: Transpose characters
40
+ * - PageUp/PageDown: Scroll by viewport height
41
+ * - Backspace/Delete: Delete characters (or selected text)
42
+ * - Enter: Insert newline (replaces selection, or submit with submitKey)
43
+ * - Typing with selection: Replaces selected text
44
+ */
45
+ import { useCallback, useMemo, useRef, useState } from "react"
46
+ import { useInput } from "@silvery/react/hooks/useInput"
47
+ import {
48
+ addToKillRing,
49
+ findNextWordEnd,
50
+ findPrevWordStart,
51
+ handleReadlineKey,
52
+ type YankState,
53
+ } from "@silvery/react/hooks/readline-ops"
54
+ import { cursorToRowCol, getWrappedLines } from "@silvery/tea/text-cursor"
55
+ import type { WrappedLine } from "@silvery/tea/text-cursor"
56
+
57
+ // =============================================================================
58
+ // Types
59
+ // =============================================================================
60
+
61
+ /** Selection range as [start, end) character offsets */
62
+ export interface TextAreaSelection {
63
+ start: number
64
+ end: number
65
+ }
66
+
67
+ export interface UseTextAreaOptions {
68
+ /** Current value (controlled) */
69
+ value?: string
70
+ /** Initial value (uncontrolled) */
71
+ defaultValue?: string
72
+ /** Called when value changes */
73
+ onChange?: (value: string) => void
74
+ /** Called on submit (Ctrl+Enter by default, or Enter if submitKey="enter") */
75
+ onSubmit?: (value: string) => void
76
+ /** Key to trigger submit: "ctrl+enter" (default), "enter", or "meta+enter" */
77
+ submitKey?: "ctrl+enter" | "enter" | "meta+enter"
78
+ /** Whether input is active (receives keystrokes) */
79
+ isActive?: boolean
80
+ /** Visible height in rows (for scroll clamping and PageUp/PageDown) */
81
+ height: number
82
+ /** Wrap width in columns (typically from useContentRect) */
83
+ wrapWidth: number
84
+ /** Number of context lines to keep visible above/below cursor when scrolling (default: 1) */
85
+ scrollMargin?: number
86
+ /** When true, ignore all input */
87
+ disabled?: boolean
88
+ /** Maximum number of characters allowed */
89
+ maxLength?: number
90
+ }
91
+
92
+ export interface UseTextAreaResult {
93
+ /** Current text value */
94
+ value: string
95
+ /** Cursor position (character offset) */
96
+ cursor: number
97
+ /** Cursor row in wrapped lines (0-indexed) */
98
+ cursorRow: number
99
+ /** Cursor column in current wrapped line (0-indexed) */
100
+ cursorCol: number
101
+ /** Visible cursor row relative to scroll offset */
102
+ visibleCursorRow: number
103
+ /** Current scroll offset (first visible row) */
104
+ scrollOffset: number
105
+ /** Wrapped lines for the current value and width */
106
+ wrappedLines: WrappedLine[]
107
+ /** Visible lines (wrappedLines sliced by scrollOffset and height) */
108
+ visibleLines: WrappedLine[]
109
+ /** Current selection range, or null if no selection */
110
+ selection: TextAreaSelection | null
111
+ /** Selection anchor position (where shift-select started), or null */
112
+ selectionAnchor: number | null
113
+ /** Clear the input */
114
+ clear: () => void
115
+ /** Set value programmatically (cursor moves to end) */
116
+ setValue: (value: string) => void
117
+ /** Get the current selection range, or null if no selection */
118
+ getSelection: () => TextAreaSelection | null
119
+ }
120
+
121
+ // =============================================================================
122
+ // Helpers
123
+ // =============================================================================
124
+
125
+ /** Ensure scroll offset keeps the cursor row visible with margin context lines */
126
+ export function clampScroll(
127
+ cursorRow: number,
128
+ currentScroll: number,
129
+ viewportHeight: number,
130
+ totalLines: number,
131
+ margin: number,
132
+ ): number {
133
+ if (viewportHeight <= 0) return 0
134
+ // Effective margin: disabled when content fits in viewport, and clamped so
135
+ // the cursor can still reach the first/last row.
136
+ const effectiveMargin = totalLines <= viewportHeight ? 0 : Math.min(margin, Math.floor((viewportHeight - 1) / 2))
137
+ let scroll = currentScroll
138
+ if (cursorRow < scroll + effectiveMargin) {
139
+ scroll = cursorRow - effectiveMargin
140
+ }
141
+ if (cursorRow >= scroll + viewportHeight - effectiveMargin) {
142
+ scroll = cursorRow - viewportHeight + 1 + effectiveMargin
143
+ }
144
+ return Math.max(0, Math.min(scroll, Math.max(0, totalLines - viewportHeight)))
145
+ }
146
+
147
+ // =============================================================================
148
+ // Hook
149
+ // =============================================================================
150
+
151
+ export function useTextArea({
152
+ value: controlledValue,
153
+ defaultValue = "",
154
+ onChange,
155
+ onSubmit,
156
+ submitKey = "ctrl+enter",
157
+ isActive = true,
158
+ height,
159
+ wrapWidth: rawWrapWidth,
160
+ scrollMargin = 1,
161
+ disabled,
162
+ maxLength,
163
+ }: UseTextAreaOptions): UseTextAreaResult {
164
+ const isControlled = controlledValue !== undefined
165
+ const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue)
166
+ const [cursor, setCursor] = useState(defaultValue.length)
167
+ const [scrollOffset, setScrollOffset] = useState(0)
168
+ const stickyXRef = useRef<number | null>(null)
169
+
170
+ const yankStateRef = useRef<YankState | null>(null)
171
+
172
+ // Selection: anchor is where the selection started, cursor is the moving end.
173
+ // When anchor is non-null, the selected range is [min(anchor, cursor), max(anchor, cursor)).
174
+ const [selectionAnchor, setSelectionAnchor] = useState<number | null>(null)
175
+ const selectionAnchorRef = useRef<number | null>(null)
176
+ selectionAnchorRef.current = selectionAnchor
177
+
178
+ const value = isControlled ? (controlledValue ?? "") : uncontrolledValue
179
+ const wrapWidth = Math.max(1, rawWrapWidth)
180
+
181
+ // Clamp cursor when controlled value shrinks (e.g., parent resets to "").
182
+ const clampedCursor = Math.min(cursor, value.length)
183
+ if (clampedCursor !== cursor) {
184
+ setCursor(clampedCursor)
185
+ }
186
+
187
+ // Mutable ref for synchronous reads in the event handler.
188
+ const stateRef = useRef({ value, cursor: clampedCursor })
189
+ stateRef.current.value = value
190
+ stateRef.current.cursor = clampedCursor
191
+
192
+ const scrollRef = useRef(scrollOffset)
193
+ scrollRef.current = scrollOffset
194
+
195
+ const setCursorAndScroll = useCallback(
196
+ (newCursor: number, text: string) => {
197
+ stateRef.current.cursor = newCursor
198
+ setCursor(newCursor)
199
+ const { row } = cursorToRowCol(text, newCursor, wrapWidth)
200
+ const totalLines = getWrappedLines(text, wrapWidth).length
201
+ const newScroll = clampScroll(row, scrollRef.current, height, totalLines, scrollMargin)
202
+ if (newScroll !== scrollRef.current) {
203
+ setScrollOffset(newScroll)
204
+ }
205
+ },
206
+ [wrapWidth, height, scrollMargin],
207
+ )
208
+
209
+ const updateValue = useCallback(
210
+ (newValue: string, newCursor: number) => {
211
+ // Enforce maxLength: allow deletions (shorter) but reject insertions beyond limit
212
+ if (maxLength !== undefined && newValue.length > maxLength && newValue.length > stateRef.current.value.length) {
213
+ return
214
+ }
215
+ stateRef.current.value = newValue
216
+ stateRef.current.cursor = newCursor
217
+ if (!isControlled) {
218
+ setUncontrolledValue(newValue)
219
+ }
220
+ setCursorAndScroll(newCursor, newValue)
221
+ onChange?.(newValue)
222
+ yankStateRef.current = null
223
+ },
224
+ [isControlled, maxLength, onChange, setCursorAndScroll],
225
+ )
226
+
227
+ /** Get the selection range as [start, end), or null if no selection */
228
+ const getSelectionRange = useCallback((): TextAreaSelection | null => {
229
+ const anchor = selectionAnchorRef.current
230
+ if (anchor === null) return null
231
+ const cur = stateRef.current.cursor
232
+ if (anchor === cur) return null
233
+ return { start: Math.min(anchor, cur), end: Math.max(anchor, cur) }
234
+ }, [])
235
+
236
+ /** Delete the selected text and return the new value and cursor position */
237
+ const deleteSelection = useCallback((): { newValue: string; newCursor: number } | null => {
238
+ const sel = getSelectionRange()
239
+ if (!sel) return null
240
+ const v = stateRef.current.value
241
+ return { newValue: v.slice(0, sel.start) + v.slice(sel.end), newCursor: sel.start }
242
+ }, [getSelectionRange])
243
+
244
+ /** Move cursor with shift: extends selection. Without shift: clears selection. */
245
+ const moveCursor = useCallback(
246
+ (newCursor: number, text: string, shift: boolean) => {
247
+ if (shift) {
248
+ // Start or extend selection
249
+ if (selectionAnchorRef.current === null) {
250
+ const anchor = stateRef.current.cursor
251
+ setSelectionAnchor(anchor)
252
+ selectionAnchorRef.current = anchor
253
+ }
254
+ } else {
255
+ // Clear selection
256
+ if (selectionAnchorRef.current !== null) {
257
+ setSelectionAnchor(null)
258
+ selectionAnchorRef.current = null
259
+ }
260
+ }
261
+ setCursorAndScroll(newCursor, text)
262
+ },
263
+ [setCursorAndScroll],
264
+ )
265
+
266
+ /** Replace selection (if any) with new text, or insert at cursor */
267
+ const replaceSelectionWith = useCallback(
268
+ (insertText: string) => {
269
+ const sel = getSelectionRange()
270
+ const { value } = stateRef.current
271
+ if (sel) {
272
+ const newValue = value.slice(0, sel.start) + insertText + value.slice(sel.end)
273
+ const newCursor = sel.start + insertText.length
274
+ setSelectionAnchor(null)
275
+ selectionAnchorRef.current = null
276
+ updateValue(newValue, newCursor)
277
+ return true
278
+ }
279
+ return false
280
+ },
281
+ [getSelectionRange, updateValue],
282
+ )
283
+
284
+ const wrappedLines = useMemo(() => getWrappedLines(value, wrapWidth), [value, wrapWidth])
285
+
286
+ const { row: cursorRow, col: cursorCol } = useMemo(
287
+ () => cursorToRowCol(value, clampedCursor, wrapWidth),
288
+ [value, clampedCursor, wrapWidth],
289
+ )
290
+
291
+ // =========================================================================
292
+ // Input handling
293
+ // =========================================================================
294
+
295
+ useInput(
296
+ (input, key) => {
297
+ if (disabled) return
298
+
299
+ const { value, cursor } = stateRef.current
300
+ const lines = getWrappedLines(value, wrapWidth)
301
+ const { row: cRow, col: cCol } = cursorToRowCol(value, cursor, wrapWidth)
302
+ const hasSelection = selectionAnchorRef.current !== null && selectionAnchorRef.current !== cursor
303
+
304
+ // Helper: clear selection state
305
+ const clearSelection = () => {
306
+ if (selectionAnchorRef.current !== null) {
307
+ setSelectionAnchor(null)
308
+ selectionAnchorRef.current = null
309
+ }
310
+ }
311
+
312
+ // =================================================================
313
+ // Submit
314
+ // =================================================================
315
+ if (submitKey === "ctrl+enter" && key.return && key.ctrl) {
316
+ onSubmit?.(value)
317
+ return
318
+ }
319
+ if (submitKey === "enter" && key.return && !key.ctrl) {
320
+ onSubmit?.(value)
321
+ return
322
+ }
323
+ if (submitKey === "meta+enter" && key.return && key.meta) {
324
+ onSubmit?.(value)
325
+ return
326
+ }
327
+
328
+ // =================================================================
329
+ // Ctrl+A: Select all
330
+ // =================================================================
331
+ if (key.ctrl && input === "a") {
332
+ stickyXRef.current = null
333
+ setSelectionAnchor(0)
334
+ selectionAnchorRef.current = 0
335
+ setCursorAndScroll(value.length, value)
336
+ yankStateRef.current = null
337
+ return
338
+ }
339
+
340
+ // =================================================================
341
+ // Enter (newline) — replaces selection if active
342
+ // =================================================================
343
+ if (key.return && submitKey !== "enter") {
344
+ stickyXRef.current = null
345
+ if (hasSelection) {
346
+ replaceSelectionWith("\n")
347
+ } else {
348
+ updateValue(value.slice(0, cursor) + "\n" + value.slice(cursor), cursor + 1)
349
+ }
350
+ return
351
+ }
352
+
353
+ // =================================================================
354
+ // Shift+Arrow: extend selection
355
+ // Ctrl+Shift+Arrow: word-wise selection
356
+ // =================================================================
357
+
358
+ // Shift+Left / Ctrl+Shift+Left
359
+ if (key.leftArrow && key.shift) {
360
+ stickyXRef.current = null
361
+ const target = key.ctrl ? findPrevWordStart(value, cursor) : Math.max(0, cursor - 1)
362
+ moveCursor(target, value, true)
363
+ yankStateRef.current = null
364
+ return
365
+ }
366
+
367
+ // Shift+Right / Ctrl+Shift+Right
368
+ if (key.rightArrow && key.shift) {
369
+ stickyXRef.current = null
370
+ const target = key.ctrl ? findNextWordEnd(value, cursor) : Math.min(value.length, cursor + 1)
371
+ moveCursor(target, value, true)
372
+ yankStateRef.current = null
373
+ return
374
+ }
375
+
376
+ // Shift+Up
377
+ if (key.upArrow && key.shift) {
378
+ if (cRow > 0) {
379
+ const targetX = stickyXRef.current ?? cCol
380
+ stickyXRef.current = targetX
381
+ const targetLine = lines[cRow - 1]
382
+ if (targetLine) {
383
+ moveCursor(targetLine.startOffset + Math.min(targetX, targetLine.line.length), value, true)
384
+ }
385
+ }
386
+ yankStateRef.current = null
387
+ return
388
+ }
389
+
390
+ // Shift+Down
391
+ if (key.downArrow && key.shift) {
392
+ if (cRow < lines.length - 1) {
393
+ const targetX = stickyXRef.current ?? cCol
394
+ stickyXRef.current = targetX
395
+ const targetLine = lines[cRow + 1]
396
+ if (targetLine) {
397
+ moveCursor(targetLine.startOffset + Math.min(targetX, targetLine.line.length), value, true)
398
+ }
399
+ }
400
+ yankStateRef.current = null
401
+ return
402
+ }
403
+
404
+ // Shift+Home
405
+ if (key.home && key.shift) {
406
+ stickyXRef.current = null
407
+ const currentLine = lines[cRow]
408
+ if (currentLine) moveCursor(currentLine.startOffset, value, true)
409
+ yankStateRef.current = null
410
+ return
411
+ }
412
+
413
+ // Shift+End
414
+ if (key.end && key.shift) {
415
+ stickyXRef.current = null
416
+ const currentLine = lines[cRow]
417
+ if (currentLine) moveCursor(currentLine.startOffset + currentLine.line.length, value, true)
418
+ yankStateRef.current = null
419
+ return
420
+ }
421
+
422
+ // =================================================================
423
+ // Multi-line: Up/Down with stickyX (non-shift: collapse selection)
424
+ // =================================================================
425
+ if (key.upArrow) {
426
+ if (cRow > 0) {
427
+ const targetX = stickyXRef.current ?? cCol
428
+ stickyXRef.current = targetX
429
+ const targetLine = lines[cRow - 1]
430
+ if (targetLine) {
431
+ moveCursor(targetLine.startOffset + Math.min(targetX, targetLine.line.length), value, false)
432
+ }
433
+ } else {
434
+ clearSelection()
435
+ }
436
+ yankStateRef.current = null
437
+ return
438
+ }
439
+
440
+ if (key.downArrow) {
441
+ if (cRow < lines.length - 1) {
442
+ const targetX = stickyXRef.current ?? cCol
443
+ stickyXRef.current = targetX
444
+ const targetLine = lines[cRow + 1]
445
+ if (targetLine) {
446
+ moveCursor(targetLine.startOffset + Math.min(targetX, targetLine.line.length), value, false)
447
+ }
448
+ } else {
449
+ clearSelection()
450
+ }
451
+ yankStateRef.current = null
452
+ return
453
+ }
454
+
455
+ // =================================================================
456
+ // Multi-line: Ctrl+Home/End (document start/end)
457
+ // =================================================================
458
+ if (key.ctrl && key.home) {
459
+ stickyXRef.current = null
460
+ moveCursor(0, value, false)
461
+ yankStateRef.current = null
462
+ return
463
+ }
464
+
465
+ if (key.ctrl && key.end) {
466
+ stickyXRef.current = null
467
+ moveCursor(value.length, value, false)
468
+ yankStateRef.current = null
469
+ return
470
+ }
471
+
472
+ // =================================================================
473
+ // Multi-line: Home/End (beginning/end of wrapped line)
474
+ // Note: Ctrl+A is now select-all, Ctrl+E still goes to end of line
475
+ // =================================================================
476
+ if (key.home) {
477
+ stickyXRef.current = null
478
+ const currentLine = lines[cRow]
479
+ if (currentLine) moveCursor(currentLine.startOffset, value, false)
480
+ yankStateRef.current = null
481
+ return
482
+ }
483
+
484
+ if (key.end || (key.ctrl && input === "e")) {
485
+ stickyXRef.current = null
486
+ const currentLine = lines[cRow]
487
+ if (currentLine) moveCursor(currentLine.startOffset + currentLine.line.length, value, false)
488
+ yankStateRef.current = null
489
+ return
490
+ }
491
+
492
+ // =================================================================
493
+ // Multi-line: PageUp/PageDown
494
+ // =================================================================
495
+ if (key.pageUp) {
496
+ stickyXRef.current = null
497
+ const targetLine = lines[Math.max(0, cRow - height)]
498
+ if (targetLine) {
499
+ moveCursor(targetLine.startOffset + Math.min(cCol, targetLine.line.length), value, false)
500
+ }
501
+ yankStateRef.current = null
502
+ return
503
+ }
504
+
505
+ if (key.pageDown) {
506
+ stickyXRef.current = null
507
+ const targetLine = lines[Math.min(lines.length - 1, cRow + height)]
508
+ if (targetLine) {
509
+ moveCursor(targetLine.startOffset + Math.min(cCol, targetLine.line.length), value, false)
510
+ }
511
+ yankStateRef.current = null
512
+ return
513
+ }
514
+
515
+ // =================================================================
516
+ // Multi-line: Ctrl+K/U (kill to end/beginning of wrapped line)
517
+ // =================================================================
518
+ if (key.ctrl && input === "k") {
519
+ stickyXRef.current = null
520
+ clearSelection()
521
+ const currentLine = lines[cRow]
522
+ if (!currentLine) return
523
+ const lineEnd = currentLine.startOffset + currentLine.line.length
524
+ if (cursor < lineEnd) {
525
+ addToKillRing(value.slice(cursor, lineEnd))
526
+ updateValue(value.slice(0, cursor) + value.slice(lineEnd), cursor)
527
+ } else if (cursor < value.length) {
528
+ addToKillRing(value.slice(cursor, cursor + 1))
529
+ updateValue(value.slice(0, cursor) + value.slice(cursor + 1), cursor)
530
+ }
531
+ return
532
+ }
533
+
534
+ if (key.ctrl && input === "u") {
535
+ stickyXRef.current = null
536
+ clearSelection()
537
+ const currentLine = lines[cRow]
538
+ if (!currentLine) return
539
+ const lineStart = currentLine.startOffset
540
+ if (cursor > lineStart) {
541
+ addToKillRing(value.slice(lineStart, cursor))
542
+ updateValue(value.slice(0, lineStart) + value.slice(cursor), lineStart)
543
+ }
544
+ return
545
+ }
546
+
547
+ // =================================================================
548
+ // Backspace/Delete with selection: delete selection
549
+ // =================================================================
550
+ if ((key.backspace || key.delete) && hasSelection) {
551
+ stickyXRef.current = null
552
+ const del = deleteSelection()
553
+ if (del) {
554
+ clearSelection()
555
+ updateValue(del.newValue, del.newCursor)
556
+ }
557
+ yankStateRef.current = null
558
+ return
559
+ }
560
+
561
+ // =================================================================
562
+ // Character input with selection: replace selection
563
+ // =================================================================
564
+ if (hasSelection && input.length >= 1 && input >= " ") {
565
+ stickyXRef.current = null
566
+ replaceSelectionWith(input)
567
+ yankStateRef.current = null
568
+ return
569
+ }
570
+
571
+ // =================================================================
572
+ // Shared readline operations (cursor, word, kill ring, yank, etc.)
573
+ // Non-shift movement/editing clears selection.
574
+ // =================================================================
575
+ const result = handleReadlineKey(input, key, value, cursor, yankStateRef.current)
576
+ if (result) {
577
+ stickyXRef.current = null
578
+ // Any readline operation clears selection
579
+ clearSelection()
580
+ if (result.value === value && result.cursor === cursor) {
581
+ yankStateRef.current = result.yankState
582
+ return
583
+ }
584
+ if (result.value !== value) {
585
+ // Enforce maxLength for readline insertions
586
+ if (maxLength !== undefined && result.value.length > maxLength && result.value.length > value.length) {
587
+ yankStateRef.current = result.yankState
588
+ return
589
+ }
590
+ stateRef.current.value = result.value
591
+ stateRef.current.cursor = result.cursor
592
+ if (!isControlled) setUncontrolledValue(result.value)
593
+ setCursorAndScroll(result.cursor, result.value)
594
+ onChange?.(result.value)
595
+ } else {
596
+ setCursorAndScroll(result.cursor, value)
597
+ }
598
+ yankStateRef.current = result.yankState
599
+ }
600
+ },
601
+ { isActive },
602
+ )
603
+
604
+ // =========================================================================
605
+ // Computed output
606
+ // =========================================================================
607
+
608
+ const visibleCursorRow = cursorRow - scrollOffset
609
+ const selection =
610
+ selectionAnchor !== null && selectionAnchor !== clampedCursor
611
+ ? {
612
+ start: Math.min(selectionAnchor, clampedCursor),
613
+ end: Math.max(selectionAnchor, clampedCursor),
614
+ }
615
+ : null
616
+
617
+ const visibleLines = wrappedLines.slice(scrollOffset, scrollOffset + height)
618
+
619
+ const clear = useCallback(() => {
620
+ updateValue("", 0)
621
+ setScrollOffset(0)
622
+ setSelectionAnchor(null)
623
+ }, [updateValue])
624
+
625
+ const setValue = useCallback(
626
+ (v: string) => {
627
+ updateValue(v, v.length)
628
+ setSelectionAnchor(null)
629
+ },
630
+ [updateValue],
631
+ )
632
+
633
+ return {
634
+ value,
635
+ cursor: clampedCursor,
636
+ cursorRow,
637
+ cursorCol,
638
+ visibleCursorRow,
639
+ scrollOffset,
640
+ wrappedLines,
641
+ visibleLines,
642
+ selection,
643
+ selectionAnchor,
644
+ clear,
645
+ setValue,
646
+ getSelection: getSelectionRange,
647
+ }
648
+ }