@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.
Files changed (44) hide show
  1. package/README.md +141 -0
  2. package/index.ts +45 -0
  3. package/package.json +59 -0
  4. package/src/api/index.ts +7 -0
  5. package/src/api/mount.ts +230 -0
  6. package/src/engine/arrays/core.ts +60 -0
  7. package/src/engine/arrays/dimensions.ts +68 -0
  8. package/src/engine/arrays/index.ts +166 -0
  9. package/src/engine/arrays/interaction.ts +112 -0
  10. package/src/engine/arrays/layout.ts +175 -0
  11. package/src/engine/arrays/spacing.ts +100 -0
  12. package/src/engine/arrays/text.ts +55 -0
  13. package/src/engine/arrays/visual.ts +140 -0
  14. package/src/engine/index.ts +25 -0
  15. package/src/engine/inheritance.ts +138 -0
  16. package/src/engine/registry.ts +180 -0
  17. package/src/pipeline/frameBuffer.ts +473 -0
  18. package/src/pipeline/layout/index.ts +105 -0
  19. package/src/pipeline/layout/titan-engine.ts +798 -0
  20. package/src/pipeline/layout/types.ts +194 -0
  21. package/src/pipeline/layout/utils/hierarchy.ts +202 -0
  22. package/src/pipeline/layout/utils/math.ts +134 -0
  23. package/src/pipeline/layout/utils/text-measure.ts +160 -0
  24. package/src/pipeline/layout.ts +30 -0
  25. package/src/primitives/box.ts +312 -0
  26. package/src/primitives/index.ts +12 -0
  27. package/src/primitives/text.ts +199 -0
  28. package/src/primitives/types.ts +222 -0
  29. package/src/primitives/utils.ts +37 -0
  30. package/src/renderer/ansi.ts +625 -0
  31. package/src/renderer/buffer.ts +667 -0
  32. package/src/renderer/index.ts +40 -0
  33. package/src/renderer/input.ts +518 -0
  34. package/src/renderer/output.ts +451 -0
  35. package/src/state/cursor.ts +176 -0
  36. package/src/state/focus.ts +241 -0
  37. package/src/state/index.ts +43 -0
  38. package/src/state/keyboard.ts +771 -0
  39. package/src/state/mouse.ts +524 -0
  40. package/src/state/scroll.ts +341 -0
  41. package/src/state/theme.ts +687 -0
  42. package/src/types/color.ts +401 -0
  43. package/src/types/index.ts +316 -0
  44. 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
+ }