@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.
- package/package.json +71 -0
- package/src/animation/easing.ts +38 -0
- package/src/animation/index.ts +18 -0
- package/src/animation/useAnimation.ts +143 -0
- package/src/animation/useInterval.ts +39 -0
- package/src/animation/useLatest.ts +35 -0
- package/src/animation/useTimeout.ts +65 -0
- package/src/animation/useTransition.ts +110 -0
- package/src/animation.ts +24 -0
- package/src/ansi/index.ts +43 -0
- package/src/canvas/index.ts +169 -0
- package/src/cli/ansi.ts +85 -0
- package/src/cli/index.ts +39 -0
- package/src/cli/multi-progress.ts +340 -0
- package/src/cli/progress-bar.ts +222 -0
- package/src/cli/spinner.ts +275 -0
- package/src/components/Badge.tsx +54 -0
- package/src/components/Breadcrumb.tsx +72 -0
- package/src/components/Button.tsx +73 -0
- package/src/components/CommandPalette.tsx +186 -0
- package/src/components/Console.tsx +79 -0
- package/src/components/CursorLine.tsx +71 -0
- package/src/components/Divider.tsx +67 -0
- package/src/components/EditContextDisplay.tsx +164 -0
- package/src/components/ErrorBoundary.tsx +179 -0
- package/src/components/Form.tsx +86 -0
- package/src/components/GridCell.tsx +42 -0
- package/src/components/HorizontalVirtualList.tsx +375 -0
- package/src/components/ModalDialog.tsx +179 -0
- package/src/components/PickerDialog.tsx +208 -0
- package/src/components/PickerList.tsx +93 -0
- package/src/components/ProgressBar.tsx +126 -0
- package/src/components/Screen.tsx +78 -0
- package/src/components/ScrollbackList.tsx +92 -0
- package/src/components/ScrollbackView.tsx +390 -0
- package/src/components/SelectList.tsx +176 -0
- package/src/components/Skeleton.tsx +87 -0
- package/src/components/Spinner.tsx +64 -0
- package/src/components/SplitView.tsx +199 -0
- package/src/components/Table.tsx +139 -0
- package/src/components/Tabs.tsx +203 -0
- package/src/components/TextArea.tsx +264 -0
- package/src/components/TextInput.tsx +240 -0
- package/src/components/Toast.tsx +216 -0
- package/src/components/Toggle.tsx +73 -0
- package/src/components/Tooltip.tsx +60 -0
- package/src/components/TreeView.tsx +212 -0
- package/src/components/Typography.tsx +233 -0
- package/src/components/VirtualList.tsx +318 -0
- package/src/components/VirtualView.tsx +221 -0
- package/src/components/useReadline.ts +213 -0
- package/src/components/useTextArea.ts +648 -0
- package/src/components.ts +133 -0
- package/src/display/Table.tsx +179 -0
- package/src/display/index.ts +13 -0
- package/src/hooks/useTea.ts +133 -0
- package/src/image/Image.tsx +187 -0
- package/src/image/index.ts +15 -0
- package/src/image/kitty-graphics.ts +161 -0
- package/src/image/sixel-encoder.ts +194 -0
- package/src/images.ts +22 -0
- package/src/index.ts +34 -0
- package/src/input/Select.tsx +155 -0
- package/src/input/TextInput.tsx +227 -0
- package/src/input/index.ts +25 -0
- package/src/progress/als-context.ts +160 -0
- package/src/progress/declarative.ts +519 -0
- package/src/progress/index.ts +54 -0
- package/src/progress/step-node.ts +152 -0
- package/src/progress/steps.ts +425 -0
- package/src/progress/task.ts +138 -0
- package/src/progress/tasks.ts +216 -0
- package/src/react/ProgressBar.tsx +146 -0
- package/src/react/Spinner.tsx +74 -0
- package/src/react/Tasks.tsx +144 -0
- package/src/react/context.tsx +145 -0
- package/src/react/index.ts +30 -0
- package/src/types.ts +252 -0
- package/src/utils/eta.ts +155 -0
- package/src/utils/index.ts +13 -0
- package/src/wrappers/index.ts +36 -0
- package/src/wrappers/with-progress.ts +250 -0
- package/src/wrappers/with-select.ts +194 -0
- package/src/wrappers/with-spinner.ts +108 -0
- package/src/wrappers/with-text-input.ts +388 -0
- package/src/wrappers/wrap-emitter.ts +158 -0
- 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
|
+
}
|