@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,524 @@
1
+ /**
2
+ * TUI Framework - Mouse State Module
3
+ *
4
+ * Robust mouse handling based on terminalKit/OpenTUI patterns:
5
+ * - SGR mouse protocol (extended coordinates > 223)
6
+ * - Basic X10 mouse protocol fallback
7
+ * - HitGrid for O(1) coordinate-to-component lookup
8
+ * - Event dispatching with hover/press tracking
9
+ *
10
+ * API:
11
+ * mouse.enable() - Enable mouse tracking
12
+ * mouse.disable() - Disable mouse tracking
13
+ * mouse.onMouseDown(fn) - Global mouse down handler
14
+ * mouse.onMouseUp(fn) - Global mouse up handler
15
+ * mouse.onClick(fn) - Global click handler
16
+ * mouse.onScroll(fn) - Global scroll handler
17
+ * mouse.onComponent(idx, handlers) - Per-component handlers
18
+ */
19
+
20
+ import { signal } from '@rlabs-inc/signals'
21
+ import * as interaction from '../engine/arrays/interaction'
22
+ import { unwrap } from '@rlabs-inc/signals'
23
+
24
+ // =============================================================================
25
+ // TYPES
26
+ // =============================================================================
27
+
28
+ export type MouseAction = 'down' | 'up' | 'move' | 'drag' | 'scroll'
29
+
30
+ export enum MouseButton {
31
+ LEFT = 0,
32
+ MIDDLE = 1,
33
+ RIGHT = 2,
34
+ NONE = 3,
35
+ }
36
+
37
+ export interface ScrollInfo {
38
+ direction: 'up' | 'down' | 'left' | 'right'
39
+ delta: number
40
+ }
41
+
42
+ export interface MouseEvent {
43
+ /** Event type */
44
+ action: MouseAction
45
+ /** Button number (0=left, 1=middle, 2=right) */
46
+ button: MouseButton
47
+ /** X coordinate (0-based) */
48
+ x: number
49
+ /** Y coordinate (0-based) */
50
+ y: number
51
+ /** Modifier keys */
52
+ shiftKey: boolean
53
+ altKey: boolean
54
+ ctrlKey: boolean
55
+ /** Scroll info (if action is 'scroll') */
56
+ scroll?: ScrollInfo
57
+ /** Component index at (x, y) from HitGrid */
58
+ componentIndex: number
59
+ }
60
+
61
+ export interface MouseHandlers {
62
+ onMouseDown?: (event: MouseEvent) => void | boolean
63
+ onMouseUp?: (event: MouseEvent) => void | boolean
64
+ onClick?: (event: MouseEvent) => void | boolean
65
+ onMouseEnter?: (event: MouseEvent) => void
66
+ onMouseLeave?: (event: MouseEvent) => void
67
+ onScroll?: (event: MouseEvent) => void | boolean
68
+ }
69
+
70
+ export type MouseHandler = (event: MouseEvent) => void | boolean
71
+
72
+ // =============================================================================
73
+ // HIT GRID - O(1) Coordinate to Component Lookup
74
+ // =============================================================================
75
+
76
+ export class HitGrid {
77
+ private grid: Int16Array
78
+ private _width: number
79
+ private _height: number
80
+
81
+ constructor(width: number, height: number) {
82
+ this._width = width
83
+ this._height = height
84
+ this.grid = new Int16Array(width * height).fill(-1)
85
+ }
86
+
87
+ get width(): number { return this._width }
88
+ get height(): number { return this._height }
89
+
90
+ /** Get component index at (x, y), or -1 if none */
91
+ get(x: number, y: number): number {
92
+ if (x < 0 || x >= this._width || y < 0 || y >= this._height) return -1
93
+ return this.grid[y * this._width + x]!
94
+ }
95
+
96
+ /** Set component index at (x, y) */
97
+ set(x: number, y: number, componentIndex: number): void {
98
+ if (x < 0 || x >= this._width || y < 0 || y >= this._height) return
99
+ this.grid[y * this._width + x] = componentIndex
100
+ }
101
+
102
+ /** Fill a rectangle with component index */
103
+ fillRect(x: number, y: number, width: number, height: number, componentIndex: number): void {
104
+ const x1 = Math.max(0, x)
105
+ const y1 = Math.max(0, y)
106
+ const x2 = Math.min(this._width, x + width)
107
+ const y2 = Math.min(this._height, y + height)
108
+
109
+ for (let py = y1; py < y2; py++) {
110
+ for (let px = x1; px < x2; px++) {
111
+ this.grid[py * this._width + px] = componentIndex
112
+ }
113
+ }
114
+ }
115
+
116
+ /** Clear entire grid */
117
+ clear(): void {
118
+ this.grid.fill(-1)
119
+ }
120
+
121
+ /** Resize grid (clears all data) */
122
+ resize(width: number, height: number): void {
123
+ this._width = width
124
+ this._height = height
125
+ this.grid = new Int16Array(width * height).fill(-1)
126
+ }
127
+ }
128
+
129
+ // =============================================================================
130
+ // MOUSE PARSER (from OpenTUI)
131
+ // =============================================================================
132
+
133
+ const SCROLL_DIRECTIONS: Record<number, 'up' | 'down' | 'left' | 'right'> = {
134
+ 0: 'up',
135
+ 1: 'down',
136
+ 2: 'left',
137
+ 3: 'right',
138
+ }
139
+
140
+ function parseMouseEvent(data: Buffer, hitGrid: HitGrid): MouseEvent | null {
141
+ const str = data.toString()
142
+
143
+ // Parse SGR mouse mode: \x1b[<b;x;yM or \x1b[<b;x;ym
144
+ const sgrMatch = str.match(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/)
145
+ if (sgrMatch) {
146
+ const [, buttonCode, xStr, yStr, pressRelease] = sgrMatch
147
+ const rawButtonCode = parseInt(buttonCode!)
148
+ const x = parseInt(xStr!) - 1 // Convert to 0-based
149
+ const y = parseInt(yStr!) - 1
150
+
151
+ const button = (rawButtonCode & 3) as MouseButton
152
+ const isScroll = (rawButtonCode & 64) !== 0
153
+ const isMotion = (rawButtonCode & 32) !== 0
154
+
155
+ const modifiers = {
156
+ shiftKey: (rawButtonCode & 4) !== 0,
157
+ altKey: (rawButtonCode & 8) !== 0,
158
+ ctrlKey: (rawButtonCode & 16) !== 0,
159
+ }
160
+
161
+ let action: MouseAction
162
+ let scrollInfo: ScrollInfo | undefined
163
+
164
+ if (isScroll && pressRelease === 'M') {
165
+ action = 'scroll'
166
+ scrollInfo = {
167
+ direction: SCROLL_DIRECTIONS[button] ?? 'up',
168
+ delta: 1,
169
+ }
170
+ } else if (isMotion) {
171
+ action = button === MouseButton.NONE ? 'move' : 'drag'
172
+ } else {
173
+ action = pressRelease === 'M' ? 'down' : 'up'
174
+ }
175
+
176
+ return {
177
+ action,
178
+ button: button === MouseButton.NONE ? MouseButton.LEFT : button,
179
+ x,
180
+ y,
181
+ ...modifiers,
182
+ scroll: scrollInfo,
183
+ componentIndex: hitGrid.get(x, y),
184
+ }
185
+ }
186
+
187
+ // Parse basic X10 mouse mode: \x1b[M followed by 3 bytes
188
+ if (str.startsWith('\x1b[M') && str.length >= 6) {
189
+ const buttonByte = str.charCodeAt(3) - 32
190
+ const x = str.charCodeAt(4) - 33 // Convert to 0-based
191
+ const y = str.charCodeAt(5) - 33
192
+
193
+ const button = (buttonByte & 3) as MouseButton
194
+ const isScroll = (buttonByte & 64) !== 0
195
+
196
+ const modifiers = {
197
+ shiftKey: (buttonByte & 4) !== 0,
198
+ altKey: (buttonByte & 8) !== 0,
199
+ ctrlKey: (buttonByte & 16) !== 0,
200
+ }
201
+
202
+ let action: MouseAction
203
+ let scrollInfo: ScrollInfo | undefined
204
+
205
+ if (isScroll) {
206
+ action = 'scroll'
207
+ scrollInfo = {
208
+ direction: SCROLL_DIRECTIONS[button] ?? 'up',
209
+ delta: 1,
210
+ }
211
+ } else {
212
+ action = button === MouseButton.NONE ? 'up' : 'down'
213
+ }
214
+
215
+ return {
216
+ action,
217
+ button: button === MouseButton.NONE ? MouseButton.LEFT : button,
218
+ x,
219
+ y,
220
+ ...modifiers,
221
+ scroll: scrollInfo,
222
+ componentIndex: hitGrid.get(x, y),
223
+ }
224
+ }
225
+
226
+ return null
227
+ }
228
+
229
+ // =============================================================================
230
+ // MOUSE EVENT DISPATCHER
231
+ // =============================================================================
232
+
233
+ class MouseEventDispatcher {
234
+ private hitGrid: HitGrid
235
+ private handlers = new Map<number, MouseHandlers>()
236
+ private globalHandlers: {
237
+ onMouseDown: Set<MouseHandler>
238
+ onMouseUp: Set<MouseHandler>
239
+ onClick: Set<MouseHandler>
240
+ onScroll: Set<MouseHandler>
241
+ } = {
242
+ onMouseDown: new Set(),
243
+ onMouseUp: new Set(),
244
+ onClick: new Set(),
245
+ onScroll: new Set(),
246
+ }
247
+
248
+ // Track state for hover and click detection
249
+ private hoveredComponent = -1
250
+ private pressedComponent = -1
251
+ private pressedButton = MouseButton.NONE
252
+
253
+ constructor(hitGrid: HitGrid) {
254
+ this.hitGrid = hitGrid
255
+ }
256
+
257
+ /** Register handlers for a component */
258
+ register(index: number, handlers: MouseHandlers): () => void {
259
+ this.handlers.set(index, handlers)
260
+ return () => this.handlers.delete(index)
261
+ }
262
+
263
+ /** Add global handler */
264
+ onMouseDown(handler: MouseHandler): () => void {
265
+ this.globalHandlers.onMouseDown.add(handler)
266
+ return () => this.globalHandlers.onMouseDown.delete(handler)
267
+ }
268
+
269
+ onMouseUp(handler: MouseHandler): () => void {
270
+ this.globalHandlers.onMouseUp.add(handler)
271
+ return () => this.globalHandlers.onMouseUp.delete(handler)
272
+ }
273
+
274
+ onClick(handler: MouseHandler): () => void {
275
+ this.globalHandlers.onClick.add(handler)
276
+ return () => this.globalHandlers.onClick.delete(handler)
277
+ }
278
+
279
+ onScroll(handler: MouseHandler): () => void {
280
+ this.globalHandlers.onScroll.add(handler)
281
+ return () => this.globalHandlers.onScroll.delete(handler)
282
+ }
283
+
284
+ /** Dispatch a mouse event */
285
+ dispatch(event: MouseEvent): boolean {
286
+ const componentIndex = event.componentIndex
287
+ const handlers = componentIndex >= 0 ? this.handlers.get(componentIndex) : undefined
288
+
289
+ // Handle hover (enter/leave)
290
+ if (componentIndex !== this.hoveredComponent) {
291
+ // Leave previous
292
+ if (this.hoveredComponent >= 0) {
293
+ const prevHandlers = this.handlers.get(this.hoveredComponent)
294
+ if (prevHandlers?.onMouseLeave) {
295
+ prevHandlers.onMouseLeave({ ...event, componentIndex: this.hoveredComponent })
296
+ }
297
+ // Update hovered array
298
+ if (interaction.hovered[this.hoveredComponent]) {
299
+ interaction.hovered[this.hoveredComponent]!.value = 0
300
+ }
301
+ }
302
+
303
+ // Enter new
304
+ if (componentIndex >= 0) {
305
+ if (handlers?.onMouseEnter) {
306
+ handlers.onMouseEnter(event)
307
+ }
308
+ // Update hovered array
309
+ if (interaction.hovered[componentIndex]) {
310
+ interaction.hovered[componentIndex]!.value = 1
311
+ }
312
+ }
313
+
314
+ this.hoveredComponent = componentIndex
315
+ }
316
+
317
+ // Handle scroll
318
+ if (event.action === 'scroll') {
319
+ // First try component handler
320
+ if (handlers?.onScroll && handlers.onScroll(event) === true) {
321
+ return true
322
+ }
323
+ // Then global handlers
324
+ for (const handler of this.globalHandlers.onScroll) {
325
+ if (handler(event) === true) return true
326
+ }
327
+ return false
328
+ }
329
+
330
+ // Handle down
331
+ if (event.action === 'down') {
332
+ this.pressedComponent = componentIndex
333
+ this.pressedButton = event.button
334
+
335
+ // Update pressed array
336
+ if (componentIndex >= 0 && interaction.pressed[componentIndex]) {
337
+ interaction.pressed[componentIndex]!.value = 1
338
+ }
339
+
340
+ if (handlers?.onMouseDown && handlers.onMouseDown(event) === true) {
341
+ return true
342
+ }
343
+ for (const handler of this.globalHandlers.onMouseDown) {
344
+ if (handler(event) === true) return true
345
+ }
346
+ }
347
+
348
+ // Handle up
349
+ if (event.action === 'up') {
350
+ // Clear pressed state
351
+ if (this.pressedComponent >= 0 && interaction.pressed[this.pressedComponent]) {
352
+ interaction.pressed[this.pressedComponent]!.value = 0
353
+ }
354
+
355
+ if (handlers?.onMouseUp && handlers.onMouseUp(event) === true) {
356
+ return true
357
+ }
358
+ for (const handler of this.globalHandlers.onMouseUp) {
359
+ if (handler(event) === true) return true
360
+ }
361
+
362
+ // Detect click (press and release on same component)
363
+ if (this.pressedComponent === componentIndex && this.pressedButton === event.button) {
364
+ if (handlers?.onClick && handlers.onClick(event) === true) {
365
+ return true
366
+ }
367
+ for (const handler of this.globalHandlers.onClick) {
368
+ if (handler(event) === true) return true
369
+ }
370
+ }
371
+
372
+ this.pressedComponent = -1
373
+ this.pressedButton = MouseButton.NONE
374
+ }
375
+
376
+ return false
377
+ }
378
+ }
379
+
380
+ // =============================================================================
381
+ // SINGLETON INSTANCES
382
+ // =============================================================================
383
+
384
+ // Default to 80x24, will be resized on mount
385
+ export const hitGrid = new HitGrid(80, 24)
386
+ const dispatcher = new MouseEventDispatcher(hitGrid)
387
+
388
+ // Reactive state
389
+ export const lastMouseEvent = signal<MouseEvent | null>(null)
390
+ export const mouseX = signal(0)
391
+ export const mouseY = signal(0)
392
+ export const isMouseDown = signal(false)
393
+
394
+ // =============================================================================
395
+ // ENABLE/DISABLE MOUSE TRACKING
396
+ // =============================================================================
397
+
398
+ let enabled = false
399
+ let inputHandler: ((data: Buffer) => void) | null = null
400
+
401
+ /** ANSI escape codes for mouse protocols */
402
+ const ENABLE_MOUSE = '\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h'
403
+ const DISABLE_MOUSE = '\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l'
404
+
405
+ /** Enable mouse tracking */
406
+ export function enable(): void {
407
+ if (enabled) return
408
+ enabled = true
409
+
410
+ process.stdout.write(ENABLE_MOUSE)
411
+
412
+ inputHandler = (data: Buffer) => {
413
+ const event = parseMouseEvent(data, hitGrid)
414
+ if (!event) return
415
+
416
+ // Update reactive state
417
+ lastMouseEvent.value = event
418
+ mouseX.value = event.x
419
+ mouseY.value = event.y
420
+ isMouseDown.value = event.action === 'down' || (event.action !== 'up' && isMouseDown.value)
421
+
422
+ // Dispatch to handlers
423
+ dispatcher.dispatch(event)
424
+ }
425
+
426
+ // Note: This will be hooked up by the mount system
427
+ // process.stdin.on('data', inputHandler)
428
+ }
429
+
430
+ /** Disable mouse tracking */
431
+ export function disable(): void {
432
+ if (!enabled) return
433
+ enabled = false
434
+
435
+ process.stdout.write(DISABLE_MOUSE)
436
+
437
+ if (inputHandler) {
438
+ // process.stdin.removeListener('data', inputHandler)
439
+ inputHandler = null
440
+ }
441
+ }
442
+
443
+ /** Process raw input data (called by keyboard module which owns stdin) - LEGACY */
444
+ export function processInput(data: Buffer): boolean {
445
+ if (!enabled || !inputHandler) return false
446
+
447
+ const event = parseMouseEvent(data, hitGrid)
448
+ if (!event) return false
449
+
450
+ return processMouseEvent(event)
451
+ }
452
+
453
+ /** Process a parsed mouse event (called by unified keyboard input buffer) */
454
+ export function processMouseEvent(event: MouseEvent): boolean {
455
+ if (!enabled) return false
456
+
457
+ // Fill in componentIndex from HitGrid
458
+ event.componentIndex = hitGrid.get(event.x, event.y)
459
+
460
+ // Update reactive state
461
+ lastMouseEvent.value = event
462
+ mouseX.value = event.x
463
+ mouseY.value = event.y
464
+ isMouseDown.value = event.action === 'down' || (event.action !== 'up' && isMouseDown.value)
465
+
466
+ // Dispatch to handlers
467
+ dispatcher.dispatch(event)
468
+ return true
469
+ }
470
+
471
+ /** Check if data is a mouse sequence - LEGACY, no longer needed with unified parsing */
472
+ export function isMouseSequence(data: Buffer): boolean {
473
+ const str = data.toString()
474
+ return str.startsWith('\x1b[<') || str.startsWith('\x1b[M')
475
+ }
476
+
477
+ // =============================================================================
478
+ // PUBLIC API
479
+ // =============================================================================
480
+
481
+ /** Register handlers for a component */
482
+ export function onComponent(index: number, handlers: MouseHandlers): () => void {
483
+ return dispatcher.register(index, handlers)
484
+ }
485
+
486
+ /** Resize the hit grid (call on terminal resize) */
487
+ export function resize(width: number, height: number): void {
488
+ hitGrid.resize(width, height)
489
+ }
490
+
491
+ /** Clear the hit grid (call before each render) */
492
+ export function clearHitGrid(): void {
493
+ hitGrid.clear()
494
+ }
495
+
496
+ // =============================================================================
497
+ // MOUSE OBJECT (convenient namespace)
498
+ // =============================================================================
499
+
500
+ export const mouse = {
501
+ // State
502
+ get lastEvent() { return lastMouseEvent.value },
503
+ get x() { return mouseX.value },
504
+ get y() { return mouseY.value },
505
+ get isDown() { return isMouseDown.value },
506
+
507
+ // HitGrid
508
+ hitGrid,
509
+ clearHitGrid,
510
+ resize,
511
+
512
+ // Enable/disable
513
+ enable,
514
+ disable,
515
+ processInput,
516
+ isMouseSequence,
517
+
518
+ // Handlers
519
+ onMouseDown: (handler: MouseHandler) => dispatcher.onMouseDown(handler),
520
+ onMouseUp: (handler: MouseHandler) => dispatcher.onMouseUp(handler),
521
+ onClick: (handler: MouseHandler) => dispatcher.onClick(handler),
522
+ onScroll: (handler: MouseHandler) => dispatcher.onScroll(handler),
523
+ onComponent,
524
+ }