@rlabs-inc/tui 0.1.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 +141 -0
- package/index.ts +45 -0
- package/package.json +59 -0
- package/src/api/index.ts +7 -0
- package/src/api/mount.ts +230 -0
- package/src/engine/arrays/core.ts +60 -0
- package/src/engine/arrays/dimensions.ts +68 -0
- package/src/engine/arrays/index.ts +166 -0
- package/src/engine/arrays/interaction.ts +112 -0
- package/src/engine/arrays/layout.ts +175 -0
- package/src/engine/arrays/spacing.ts +100 -0
- package/src/engine/arrays/text.ts +55 -0
- package/src/engine/arrays/visual.ts +140 -0
- package/src/engine/index.ts +25 -0
- package/src/engine/inheritance.ts +138 -0
- package/src/engine/registry.ts +180 -0
- package/src/pipeline/frameBuffer.ts +473 -0
- package/src/pipeline/layout/index.ts +105 -0
- package/src/pipeline/layout/titan-engine.ts +798 -0
- package/src/pipeline/layout/types.ts +194 -0
- package/src/pipeline/layout/utils/hierarchy.ts +202 -0
- package/src/pipeline/layout/utils/math.ts +134 -0
- package/src/pipeline/layout/utils/text-measure.ts +160 -0
- package/src/pipeline/layout.ts +30 -0
- package/src/primitives/box.ts +312 -0
- package/src/primitives/index.ts +12 -0
- package/src/primitives/text.ts +199 -0
- package/src/primitives/types.ts +222 -0
- package/src/primitives/utils.ts +37 -0
- package/src/renderer/ansi.ts +625 -0
- package/src/renderer/buffer.ts +667 -0
- package/src/renderer/index.ts +40 -0
- package/src/renderer/input.ts +518 -0
- package/src/renderer/output.ts +451 -0
- package/src/state/cursor.ts +176 -0
- package/src/state/focus.ts +241 -0
- package/src/state/index.ts +43 -0
- package/src/state/keyboard.ts +771 -0
- package/src/state/mouse.ts +524 -0
- package/src/state/scroll.ts +341 -0
- package/src/state/theme.ts +687 -0
- package/src/types/color.ts +401 -0
- package/src/types/index.ts +316 -0
- package/src/utils/text.ts +471 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI Framework - Scroll State Module
|
|
3
|
+
*
|
|
4
|
+
* Manages scrolling behavior:
|
|
5
|
+
* - Per-component scroll offset (user state via interaction arrays)
|
|
6
|
+
* - Scroll bounds from layout (computed by TITAN)
|
|
7
|
+
* - Scroll-into-view for focused elements
|
|
8
|
+
* - Mouse wheel and keyboard arrow scrolling
|
|
9
|
+
*
|
|
10
|
+
* Built-in behaviors:
|
|
11
|
+
* - Arrow keys scroll focused scrollable container
|
|
12
|
+
* - Mouse wheel scrolls element under cursor (fallback to focused)
|
|
13
|
+
* - Page Up/Down for large jumps
|
|
14
|
+
* - Home/End for start/end
|
|
15
|
+
*
|
|
16
|
+
* Architecture:
|
|
17
|
+
* - scrollOffsetX/Y = user state (interaction arrays)
|
|
18
|
+
* - scrollable/maxScrollX/Y = computed by TITAN (read from layoutDerived)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { unwrap } from '@rlabs-inc/signals'
|
|
22
|
+
import * as interaction from '../engine/arrays/interaction'
|
|
23
|
+
import { focusedIndex } from '../engine/arrays/interaction'
|
|
24
|
+
import { layoutDerived } from '../pipeline/layout'
|
|
25
|
+
import { hitGrid } from './mouse'
|
|
26
|
+
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// SCROLL CONSTANTS
|
|
29
|
+
// =============================================================================
|
|
30
|
+
|
|
31
|
+
/** Default scroll amount for arrow keys (lines) */
|
|
32
|
+
export const LINE_SCROLL = 1
|
|
33
|
+
|
|
34
|
+
/** Default scroll amount for mouse wheel */
|
|
35
|
+
export const WHEEL_SCROLL = 3
|
|
36
|
+
|
|
37
|
+
/** Default scroll amount for Page Up/Down */
|
|
38
|
+
export const PAGE_SCROLL_FACTOR = 0.9 // 90% of viewport
|
|
39
|
+
|
|
40
|
+
// =============================================================================
|
|
41
|
+
// SCROLL STATE ACCESS
|
|
42
|
+
// =============================================================================
|
|
43
|
+
|
|
44
|
+
/** Check if a component is scrollable (reads from computed layout) */
|
|
45
|
+
export function isScrollable(index: number): boolean {
|
|
46
|
+
const computed = layoutDerived.value
|
|
47
|
+
return (computed.scrollable[index] ?? 0) === 1
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Get current scroll offset for a component (user state) */
|
|
51
|
+
export function getScrollOffset(index: number): { x: number; y: number } {
|
|
52
|
+
return {
|
|
53
|
+
x: unwrap(interaction.scrollOffsetX[index]) ?? 0,
|
|
54
|
+
y: unwrap(interaction.scrollOffsetY[index]) ?? 0,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Get maximum scroll values for a component (reads from computed layout) */
|
|
59
|
+
export function getMaxScroll(index: number): { x: number; y: number } {
|
|
60
|
+
const computed = layoutDerived.value
|
|
61
|
+
return {
|
|
62
|
+
x: computed.maxScrollX[index] ?? 0,
|
|
63
|
+
y: computed.maxScrollY[index] ?? 0,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// =============================================================================
|
|
68
|
+
// SCROLL OPERATIONS
|
|
69
|
+
// =============================================================================
|
|
70
|
+
|
|
71
|
+
/** Set scroll offset for a component (clamped to valid range) */
|
|
72
|
+
export function setScrollOffset(index: number, x: number, y: number): void {
|
|
73
|
+
if (!isScrollable(index)) return
|
|
74
|
+
|
|
75
|
+
const max = getMaxScroll(index)
|
|
76
|
+
|
|
77
|
+
// Clamp values
|
|
78
|
+
const clampedX = Math.max(0, Math.min(x, max.x))
|
|
79
|
+
const clampedY = Math.max(0, Math.min(y, max.y))
|
|
80
|
+
|
|
81
|
+
if (interaction.scrollOffsetX[index]) {
|
|
82
|
+
interaction.scrollOffsetX[index]!.value = clampedX
|
|
83
|
+
}
|
|
84
|
+
if (interaction.scrollOffsetY[index]) {
|
|
85
|
+
interaction.scrollOffsetY[index]!.value = clampedY
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Scroll by a delta amount */
|
|
90
|
+
export function scrollBy(index: number, deltaX: number, deltaY: number): boolean {
|
|
91
|
+
if (!isScrollable(index)) return false
|
|
92
|
+
|
|
93
|
+
const current = getScrollOffset(index)
|
|
94
|
+
const max = getMaxScroll(index)
|
|
95
|
+
|
|
96
|
+
const newX = Math.max(0, Math.min(current.x + deltaX, max.x))
|
|
97
|
+
const newY = Math.max(0, Math.min(current.y + deltaY, max.y))
|
|
98
|
+
|
|
99
|
+
// Check if we actually scrolled
|
|
100
|
+
if (newX === current.x && newY === current.y) {
|
|
101
|
+
return false // Already at boundary
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
setScrollOffset(index, newX, newY)
|
|
105
|
+
return true
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Scroll to top */
|
|
109
|
+
export function scrollToTop(index: number): void {
|
|
110
|
+
setScrollOffset(index, getScrollOffset(index).x, 0)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Scroll to bottom */
|
|
114
|
+
export function scrollToBottom(index: number): void {
|
|
115
|
+
setScrollOffset(index, getScrollOffset(index).x, getMaxScroll(index).y)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Scroll to start (horizontal) */
|
|
119
|
+
export function scrollToStart(index: number): void {
|
|
120
|
+
setScrollOffset(index, 0, getScrollOffset(index).y)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Scroll to end (horizontal) */
|
|
124
|
+
export function scrollToEnd(index: number): void {
|
|
125
|
+
setScrollOffset(index, getMaxScroll(index).x, getScrollOffset(index).y)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// =============================================================================
|
|
129
|
+
// SCROLL CHAINING
|
|
130
|
+
// =============================================================================
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Scroll with chaining - if at boundary, try parent
|
|
134
|
+
* Returns true if any scrolling occurred
|
|
135
|
+
*/
|
|
136
|
+
export function scrollByWithChaining(
|
|
137
|
+
index: number,
|
|
138
|
+
deltaX: number,
|
|
139
|
+
deltaY: number,
|
|
140
|
+
getParent?: (i: number) => number
|
|
141
|
+
): boolean {
|
|
142
|
+
// Try to scroll this component
|
|
143
|
+
if (scrollBy(index, deltaX, deltaY)) {
|
|
144
|
+
return true
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// If at boundary and we have a parent getter, try parent
|
|
148
|
+
if (getParent) {
|
|
149
|
+
const parent = getParent(index)
|
|
150
|
+
if (parent >= 0 && isScrollable(parent)) {
|
|
151
|
+
return scrollByWithChaining(parent, deltaX, deltaY, getParent)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// =============================================================================
|
|
159
|
+
// FIND SCROLLABLE
|
|
160
|
+
// =============================================================================
|
|
161
|
+
|
|
162
|
+
/** Find the scrollable container at coordinates (uses HitGrid) */
|
|
163
|
+
export function findScrollableAt(x: number, y: number): number {
|
|
164
|
+
const componentIndex = hitGrid.get(x, y)
|
|
165
|
+
if (componentIndex >= 0 && isScrollable(componentIndex)) {
|
|
166
|
+
return componentIndex
|
|
167
|
+
}
|
|
168
|
+
// Could walk up parent chain here, but we'd need parent info
|
|
169
|
+
return -1
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Get the focused scrollable, or -1 */
|
|
173
|
+
export function getFocusedScrollable(): number {
|
|
174
|
+
const focused = focusedIndex.value
|
|
175
|
+
if (focused >= 0 && isScrollable(focused)) {
|
|
176
|
+
return focused
|
|
177
|
+
}
|
|
178
|
+
return -1
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// =============================================================================
|
|
182
|
+
// KEYBOARD SCROLL HANDLERS
|
|
183
|
+
// =============================================================================
|
|
184
|
+
|
|
185
|
+
/** Handle arrow key scroll (for focused scrollable) */
|
|
186
|
+
export function handleArrowScroll(
|
|
187
|
+
direction: 'up' | 'down' | 'left' | 'right'
|
|
188
|
+
): boolean {
|
|
189
|
+
const scrollable = getFocusedScrollable()
|
|
190
|
+
if (scrollable < 0) return false
|
|
191
|
+
|
|
192
|
+
switch (direction) {
|
|
193
|
+
case 'up':
|
|
194
|
+
return scrollBy(scrollable, 0, -LINE_SCROLL)
|
|
195
|
+
case 'down':
|
|
196
|
+
return scrollBy(scrollable, 0, LINE_SCROLL)
|
|
197
|
+
case 'left':
|
|
198
|
+
return scrollBy(scrollable, -LINE_SCROLL, 0)
|
|
199
|
+
case 'right':
|
|
200
|
+
return scrollBy(scrollable, LINE_SCROLL, 0)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Handle Page Up/Down */
|
|
205
|
+
export function handlePageScroll(direction: 'up' | 'down'): boolean {
|
|
206
|
+
const scrollable = getFocusedScrollable()
|
|
207
|
+
if (scrollable < 0) return false
|
|
208
|
+
|
|
209
|
+
// Get viewport height from somewhere (would need layout info)
|
|
210
|
+
// For now, use a fixed amount
|
|
211
|
+
const pageAmount = 10 // lines
|
|
212
|
+
|
|
213
|
+
if (direction === 'up') {
|
|
214
|
+
return scrollBy(scrollable, 0, -pageAmount)
|
|
215
|
+
} else {
|
|
216
|
+
return scrollBy(scrollable, 0, pageAmount)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Handle Home/End for scroll */
|
|
221
|
+
export function handleHomeEnd(key: 'home' | 'end'): boolean {
|
|
222
|
+
const scrollable = getFocusedScrollable()
|
|
223
|
+
if (scrollable < 0) return false
|
|
224
|
+
|
|
225
|
+
if (key === 'home') {
|
|
226
|
+
scrollToTop(scrollable)
|
|
227
|
+
} else {
|
|
228
|
+
scrollToBottom(scrollable)
|
|
229
|
+
}
|
|
230
|
+
return true
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// =============================================================================
|
|
234
|
+
// MOUSE WHEEL HANDLER
|
|
235
|
+
// =============================================================================
|
|
236
|
+
|
|
237
|
+
/** Handle mouse wheel scroll */
|
|
238
|
+
export function handleWheelScroll(
|
|
239
|
+
x: number,
|
|
240
|
+
y: number,
|
|
241
|
+
direction: 'up' | 'down' | 'left' | 'right'
|
|
242
|
+
): boolean {
|
|
243
|
+
// First try element under cursor
|
|
244
|
+
let scrollable = findScrollableAt(x, y)
|
|
245
|
+
|
|
246
|
+
// Fallback to focused scrollable
|
|
247
|
+
if (scrollable < 0) {
|
|
248
|
+
scrollable = getFocusedScrollable()
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (scrollable < 0) return false
|
|
252
|
+
|
|
253
|
+
switch (direction) {
|
|
254
|
+
case 'up':
|
|
255
|
+
return scrollBy(scrollable, 0, -WHEEL_SCROLL)
|
|
256
|
+
case 'down':
|
|
257
|
+
return scrollBy(scrollable, 0, WHEEL_SCROLL)
|
|
258
|
+
case 'left':
|
|
259
|
+
return scrollBy(scrollable, -WHEEL_SCROLL, 0)
|
|
260
|
+
case 'right':
|
|
261
|
+
return scrollBy(scrollable, WHEEL_SCROLL, 0)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// =============================================================================
|
|
266
|
+
// SCROLL INTO VIEW
|
|
267
|
+
// =============================================================================
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Scroll to make a child component visible within a scrollable parent
|
|
271
|
+
* This is called when focus changes to ensure focused element is visible
|
|
272
|
+
*/
|
|
273
|
+
export function scrollIntoView(
|
|
274
|
+
childIndex: number,
|
|
275
|
+
scrollableIndex: number,
|
|
276
|
+
childY: number,
|
|
277
|
+
childHeight: number,
|
|
278
|
+
viewportHeight: number
|
|
279
|
+
): void {
|
|
280
|
+
if (!isScrollable(scrollableIndex)) return
|
|
281
|
+
|
|
282
|
+
const current = getScrollOffset(scrollableIndex)
|
|
283
|
+
const viewportTop = current.y
|
|
284
|
+
const viewportBottom = viewportTop + viewportHeight
|
|
285
|
+
|
|
286
|
+
// Check if child is already visible
|
|
287
|
+
const childTop = childY
|
|
288
|
+
const childBottom = childY + childHeight
|
|
289
|
+
|
|
290
|
+
if (childTop >= viewportTop && childBottom <= viewportBottom) {
|
|
291
|
+
// Already visible
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Scroll to make visible (minimal scroll)
|
|
296
|
+
if (childTop < viewportTop) {
|
|
297
|
+
// Child is above viewport - scroll up
|
|
298
|
+
setScrollOffset(scrollableIndex, current.x, childTop)
|
|
299
|
+
} else if (childBottom > viewportBottom) {
|
|
300
|
+
// Child is below viewport - scroll down
|
|
301
|
+
setScrollOffset(scrollableIndex, current.x, childBottom - viewportHeight)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// =============================================================================
|
|
306
|
+
// EXPORT
|
|
307
|
+
// =============================================================================
|
|
308
|
+
|
|
309
|
+
export const scroll = {
|
|
310
|
+
// Constants
|
|
311
|
+
LINE_SCROLL,
|
|
312
|
+
WHEEL_SCROLL,
|
|
313
|
+
PAGE_SCROLL_FACTOR,
|
|
314
|
+
|
|
315
|
+
// State access
|
|
316
|
+
isScrollable,
|
|
317
|
+
getScrollOffset,
|
|
318
|
+
getMaxScroll,
|
|
319
|
+
|
|
320
|
+
// Operations
|
|
321
|
+
setScrollOffset,
|
|
322
|
+
scrollBy,
|
|
323
|
+
scrollToTop,
|
|
324
|
+
scrollToBottom,
|
|
325
|
+
scrollToStart,
|
|
326
|
+
scrollToEnd,
|
|
327
|
+
scrollByWithChaining,
|
|
328
|
+
|
|
329
|
+
// Finders
|
|
330
|
+
findScrollableAt,
|
|
331
|
+
getFocusedScrollable,
|
|
332
|
+
|
|
333
|
+
// Handlers
|
|
334
|
+
handleArrowScroll,
|
|
335
|
+
handlePageScroll,
|
|
336
|
+
handleHomeEnd,
|
|
337
|
+
handleWheelScroll,
|
|
338
|
+
|
|
339
|
+
// Scroll into view
|
|
340
|
+
scrollIntoView,
|
|
341
|
+
}
|