@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,451 @@
1
+ /**
2
+ * TUI Framework - Output & Differential Rendering
3
+ *
4
+ * The DiffRenderer is the "Terminal GPU" - it takes FrameBuffers
5
+ * and efficiently outputs only what changed since the last frame.
6
+ *
7
+ * Key optimizations:
8
+ * - Stateful cell renderer (only emits changed attributes)
9
+ * - Cursor position tracking (skips redundant moves)
10
+ * - Synchronized output (prevents flicker)
11
+ * - Batched writing (single syscall per frame)
12
+ */
13
+
14
+ import type { Cell, RGBA, CellAttrs, FrameBuffer } from '../types'
15
+ import { Attr } from '../types'
16
+ import { rgbaEqual } from '../types/color'
17
+ import { cellEqual } from './buffer'
18
+ import * as ansi from './ansi'
19
+
20
+ // =============================================================================
21
+ // Output Buffer
22
+ // =============================================================================
23
+
24
+ /**
25
+ * Batched output buffer.
26
+ * Accumulates writes and flushes in a single syscall.
27
+ */
28
+ export class OutputBuffer {
29
+ private chunks: string[] = []
30
+ private totalLength = 0
31
+
32
+ write(str: string): void {
33
+ if (str.length === 0) return
34
+ this.chunks.push(str)
35
+ this.totalLength += str.length
36
+ }
37
+
38
+ clear(): void {
39
+ this.chunks = []
40
+ this.totalLength = 0
41
+ }
42
+
43
+ get length(): number {
44
+ return this.totalLength
45
+ }
46
+
47
+ toString(): string {
48
+ return this.chunks.join('')
49
+ }
50
+
51
+ async flush(): Promise<void> {
52
+ if (this.totalLength === 0) return
53
+
54
+ const output = this.chunks.join('')
55
+ this.clear()
56
+
57
+ // Use Bun.write for performance
58
+ await Bun.write(Bun.stdout, output)
59
+ }
60
+
61
+ flushSync(): void {
62
+ if (this.totalLength === 0) return
63
+
64
+ const output = this.chunks.join('')
65
+ this.clear()
66
+
67
+ process.stdout.write(output)
68
+ }
69
+ }
70
+
71
+ // =============================================================================
72
+ // Stateful Cell Renderer
73
+ // =============================================================================
74
+
75
+ /**
76
+ * Renders cells while tracking state to minimize output.
77
+ * Only emits ANSI codes when values actually change.
78
+ */
79
+ class StatefulCellRenderer {
80
+ private lastFg: RGBA | null = null
81
+ private lastBg: RGBA | null = null
82
+ private lastAttrs: CellAttrs = Attr.NONE
83
+ private lastX = -1
84
+ private lastY = -1
85
+
86
+ reset(): void {
87
+ this.lastFg = null
88
+ this.lastBg = null
89
+ this.lastAttrs = Attr.NONE
90
+ this.lastX = -1
91
+ this.lastY = -1
92
+ }
93
+
94
+ render(output: OutputBuffer, x: number, y: number, cell: Cell): void {
95
+ // Move cursor if not sequential
96
+ if (y !== this.lastY || x !== this.lastX + 1) {
97
+ output.write(ansi.moveTo(x + 1, y + 1)) // ANSI is 1-indexed
98
+ }
99
+
100
+ // Attributes changed - need to reset first
101
+ if (cell.attrs !== this.lastAttrs) {
102
+ // Reset then apply new attributes
103
+ output.write(ansi.reset)
104
+ if (cell.attrs !== Attr.NONE) {
105
+ output.write(ansi.attrs(cell.attrs))
106
+ }
107
+ // After reset, colors need to be re-emitted
108
+ this.lastFg = null
109
+ this.lastBg = null
110
+ this.lastAttrs = cell.attrs
111
+ }
112
+
113
+ // Foreground color changed
114
+ if (!this.lastFg || !rgbaEqual(cell.fg, this.lastFg)) {
115
+ output.write(ansi.fg(cell.fg))
116
+ this.lastFg = cell.fg
117
+ }
118
+
119
+ // Background color changed
120
+ if (!this.lastBg || !rgbaEqual(cell.bg, this.lastBg)) {
121
+ output.write(ansi.bg(cell.bg))
122
+ this.lastBg = cell.bg
123
+ }
124
+
125
+ // Output character
126
+ if (cell.char === 0) {
127
+ // Continuation of wide character - skip
128
+ } else {
129
+ output.write(String.fromCodePoint(cell.char))
130
+ }
131
+
132
+ this.lastX = x
133
+ this.lastY = y
134
+ }
135
+
136
+ /**
137
+ * Render a cell for inline mode (no absolute positioning).
138
+ * Writes attributes, colors, and character sequentially.
139
+ */
140
+ renderInline(output: OutputBuffer, cell: Cell): void {
141
+ // Attributes changed - need to reset first
142
+ if (cell.attrs !== this.lastAttrs) {
143
+ output.write(ansi.reset)
144
+ if (cell.attrs !== Attr.NONE) {
145
+ output.write(ansi.attrs(cell.attrs))
146
+ }
147
+ this.lastFg = null
148
+ this.lastBg = null
149
+ this.lastAttrs = cell.attrs
150
+ }
151
+
152
+ // Foreground color changed
153
+ if (!this.lastFg || !rgbaEqual(cell.fg, this.lastFg)) {
154
+ output.write(ansi.fg(cell.fg))
155
+ this.lastFg = cell.fg
156
+ }
157
+
158
+ // Background color changed
159
+ if (!this.lastBg || !rgbaEqual(cell.bg, this.lastBg)) {
160
+ output.write(ansi.bg(cell.bg))
161
+ this.lastBg = cell.bg
162
+ }
163
+
164
+ // Output character (space for null/continuation)
165
+ if (cell.char === 0) {
166
+ output.write(' ') // Continuation - write space to maintain grid
167
+ } else {
168
+ output.write(String.fromCodePoint(cell.char))
169
+ }
170
+ }
171
+ }
172
+
173
+ // =============================================================================
174
+ // Diff Renderer
175
+ // =============================================================================
176
+
177
+ /**
178
+ * Differential renderer - only renders cells that changed.
179
+ * Wraps output in synchronized block to prevent flicker.
180
+ */
181
+ export class DiffRenderer {
182
+ private output = new OutputBuffer()
183
+ private cellRenderer = new StatefulCellRenderer()
184
+ private previousBuffer: FrameBuffer | null = null
185
+
186
+ /**
187
+ * Render a frame buffer, diffing against previous frame.
188
+ * Returns true if anything was rendered.
189
+ */
190
+ render(buffer: FrameBuffer): boolean {
191
+ const prev = this.previousBuffer
192
+ let hasChanges = false
193
+
194
+ // NOTE: Synchronized output (CSI?2026h) disabled - was causing row 0 issues
195
+ // this.output.write(ansi.beginSync)
196
+
197
+ // Reset cell renderer state at start of frame
198
+ this.cellRenderer.reset()
199
+
200
+ // Render changed cells
201
+ for (let y = 0; y < buffer.height; y++) {
202
+ for (let x = 0; x < buffer.width; x++) {
203
+ const cell = buffer.cells[y]![x]
204
+
205
+ // Skip if unchanged from previous frame
206
+ if (prev && y < prev.height && x < prev.width) {
207
+ const prevCell = prev.cells[y]![x]
208
+ if (cellEqual(cell!, prevCell!)) continue
209
+ }
210
+
211
+ hasChanges = true
212
+ this.cellRenderer.render(this.output, x, y, cell!)
213
+ }
214
+ }
215
+
216
+ // End synchronized output
217
+ this.output.write(ansi.endSync)
218
+
219
+ // Flush to terminal
220
+ this.output.flushSync()
221
+
222
+ // Store for next diff
223
+ this.previousBuffer = buffer
224
+
225
+ return hasChanges
226
+ }
227
+
228
+ /**
229
+ * Force full redraw (no diffing).
230
+ */
231
+ renderFull(buffer: FrameBuffer): void {
232
+ // Begin synchronized output
233
+ this.output.write(ansi.beginSync)
234
+
235
+ // Clear and reset
236
+ this.output.write(ansi.moveTo(1, 1))
237
+ this.cellRenderer.reset()
238
+
239
+ // Render all cells
240
+ for (let y = 0; y < buffer.height; y++) {
241
+ for (let x = 0; x < buffer.width; x++) {
242
+ const cell = buffer.cells[y]![x]
243
+ this.cellRenderer.render(this.output, x, y, cell!)
244
+ }
245
+ }
246
+
247
+ // End synchronized output
248
+ this.output.write(ansi.endSync)
249
+
250
+ // Flush to terminal
251
+ this.output.flushSync()
252
+
253
+ // Store for next diff
254
+ this.previousBuffer = buffer
255
+ }
256
+
257
+ /**
258
+ * Clear stored previous buffer (force full render on next call).
259
+ */
260
+ invalidate(): void {
261
+ this.previousBuffer = null
262
+ }
263
+
264
+ /**
265
+ * Get output buffer for additional writes.
266
+ */
267
+ getOutput(): OutputBuffer {
268
+ return this.output
269
+ }
270
+ }
271
+
272
+ // =============================================================================
273
+ // Render Mode Helpers
274
+ // =============================================================================
275
+
276
+ /**
277
+ * Setup for inline render mode.
278
+ * Saves cursor position before first render.
279
+ */
280
+ export function setupInlineMode(output: OutputBuffer): void {
281
+ output.write(ansi.saveCursor)
282
+ }
283
+
284
+ /**
285
+ * Position cursor for inline mode update.
286
+ */
287
+ export function positionInlineMode(output: OutputBuffer): void {
288
+ output.write(ansi.restoreCursor)
289
+ }
290
+
291
+ /**
292
+ * Position cursor for append mode update.
293
+ * Moves up to overwrite previous content.
294
+ */
295
+ export function positionAppendMode(output: OutputBuffer, previousHeight: number): void {
296
+ if (previousHeight > 0) {
297
+ output.write(ansi.moveUp(previousHeight))
298
+ output.write(ansi.carriageReturn)
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Position cursor after append mode render.
304
+ * Moves to line after content.
305
+ */
306
+ export function finalizeAppendMode(output: OutputBuffer, height: number): void {
307
+ output.write(ansi.moveTo(1, height + 1))
308
+ }
309
+
310
+ // =============================================================================
311
+ // Inline Renderer
312
+ // =============================================================================
313
+
314
+ /**
315
+ * Inline renderer matching Ink's log-update approach.
316
+ * Uses our own ansi.ts (zero dependencies).
317
+ */
318
+ export class InlineRenderer {
319
+ private output = new OutputBuffer()
320
+ private previousLineCount = 0
321
+ private previousOutput = ''
322
+
323
+ // Cell rendering state (for ANSI optimization)
324
+ private lastFg: RGBA | null = null
325
+ private lastBg: RGBA | null = null
326
+ private lastAttrs: CellAttrs = Attr.NONE
327
+
328
+ /**
329
+ * Render a frame buffer for inline mode.
330
+ * Follows log-update's algorithm:
331
+ * 1. Build output with trailing newline
332
+ * 2. eraseLines(previousLineCount) + output
333
+ * 3. Track new line count
334
+ *
335
+ * KEY INSIGHT FROM INK:
336
+ * When content height >= terminal rows, eraseLines can't reach content
337
+ * that scrolled off the top into scrollback. In this case, use clearTerminal
338
+ * to wipe everything including scrollback, then redraw.
339
+ */
340
+ render(buffer: FrameBuffer): void {
341
+ // Build the output string
342
+ const output = this.buildOutput(buffer)
343
+
344
+ // Skip if output unchanged
345
+ if (output === this.previousOutput) {
346
+ return
347
+ }
348
+
349
+ // Get terminal viewport height
350
+ const terminalRows = process.stdout.rows || 24
351
+
352
+ // When content height >= terminal rows, eraseLines can't reach content
353
+ // that scrolled off into scrollback. Use clearTerminal instead.
354
+ if (this.previousLineCount >= terminalRows) {
355
+ this.output.write(ansi.clearTerminal + output)
356
+ } else {
357
+ this.output.write(ansi.eraseLines(this.previousLineCount) + output)
358
+ }
359
+ this.output.flushSync()
360
+
361
+ // Track for next render
362
+ this.previousOutput = output
363
+ // buffer.height + 1 because:
364
+ // - We output buffer.height lines of content
365
+ // - Plus a trailing newline that puts cursor on the next line
366
+ // - eraseLines works from cursor position upward, so we need to erase
367
+ // buffer.height lines PLUS the empty line we're currently on
368
+ this.previousLineCount = buffer.height + 1
369
+ }
370
+
371
+ /**
372
+ * Build output string from frame buffer.
373
+ */
374
+ private buildOutput(buffer: FrameBuffer): string {
375
+ const chunks: string[] = []
376
+
377
+ // Reset cell rendering state
378
+ this.lastFg = null
379
+ this.lastBg = null
380
+ this.lastAttrs = Attr.NONE
381
+
382
+ for (let y = 0; y < buffer.height; y++) {
383
+ if (y > 0) {
384
+ chunks.push('\n')
385
+ }
386
+
387
+ for (let x = 0; x < buffer.width; x++) {
388
+ const cell = buffer.cells[y]![x]
389
+ this.renderCell(chunks, cell!)
390
+ }
391
+ }
392
+
393
+ chunks.push(ansi.reset)
394
+ chunks.push('\n') // Trailing newline positions cursor for next eraseLines
395
+
396
+ return chunks.join('')
397
+ }
398
+
399
+ /**
400
+ * Render a single cell to chunks array.
401
+ */
402
+ private renderCell(chunks: string[], cell: Cell): void {
403
+ if (cell.attrs !== this.lastAttrs) {
404
+ chunks.push(ansi.reset)
405
+ if (cell.attrs !== Attr.NONE) {
406
+ chunks.push(ansi.attrs(cell.attrs))
407
+ }
408
+ this.lastFg = null
409
+ this.lastBg = null
410
+ this.lastAttrs = cell.attrs
411
+ }
412
+
413
+ if (!this.lastFg || !rgbaEqual(cell.fg, this.lastFg)) {
414
+ chunks.push(ansi.fg(cell.fg))
415
+ this.lastFg = cell.fg
416
+ }
417
+
418
+ if (!this.lastBg || !rgbaEqual(cell.bg, this.lastBg)) {
419
+ chunks.push(ansi.bg(cell.bg))
420
+ this.lastBg = cell.bg
421
+ }
422
+
423
+ if (cell.char === 0) {
424
+ chunks.push(' ')
425
+ } else {
426
+ chunks.push(String.fromCodePoint(cell.char))
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Clear all rendered content and reset state.
432
+ */
433
+ clear(): void {
434
+ if (this.previousLineCount > 0) {
435
+ this.output.write(ansi.eraseLines(this.previousLineCount))
436
+ this.output.flushSync()
437
+ }
438
+ this.reset()
439
+ }
440
+
441
+ /**
442
+ * Reset the renderer state.
443
+ */
444
+ reset(): void {
445
+ this.previousLineCount = 0
446
+ this.previousOutput = ''
447
+ this.lastFg = null
448
+ this.lastBg = null
449
+ this.lastAttrs = Attr.NONE
450
+ }
451
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * TUI Framework - Cursor State
3
+ *
4
+ * Reactive cursor control for terminal applications.
5
+ * Handles visibility, shape, position, and blinking.
6
+ *
7
+ * Usage:
8
+ * ```ts
9
+ * import { cursor } from './state/cursor'
10
+ *
11
+ * // Show/hide
12
+ * cursor.show()
13
+ * cursor.hide()
14
+ *
15
+ * // Shape
16
+ * cursor.setShape('bar') // bar, block, underline
17
+ * cursor.setShape('block', false) // non-blinking block
18
+ *
19
+ * // Position
20
+ * cursor.moveTo(10, 5)
21
+ * cursor.moveBy(1, 0) // move right 1
22
+ *
23
+ * // Save/restore
24
+ * cursor.save()
25
+ * cursor.restore()
26
+ * ```
27
+ */
28
+
29
+ import { signal, effect } from '@rlabs-inc/signals'
30
+ import type { CursorShape } from '../types'
31
+ import {
32
+ cursorShow,
33
+ cursorHide,
34
+ cursorTo,
35
+ cursorMove,
36
+ cursorSavePosition,
37
+ cursorRestorePosition,
38
+ setCursorShape,
39
+ } from '../renderer/ansi'
40
+
41
+ // =============================================================================
42
+ // CURSOR STATE
43
+ // =============================================================================
44
+
45
+ /** Current cursor visibility */
46
+ export const visible = signal(true)
47
+
48
+ /** Current cursor shape */
49
+ export const shape = signal<CursorShape>('block')
50
+
51
+ /** Cursor blinking */
52
+ export const blinking = signal(true)
53
+
54
+ /** Cursor X position (column, 0-indexed) */
55
+ export const x = signal(0)
56
+
57
+ /** Cursor Y position (row, 0-indexed) */
58
+ export const y = signal(0)
59
+
60
+ // =============================================================================
61
+ // CURSOR CONTROL FUNCTIONS
62
+ // =============================================================================
63
+
64
+ /** Show the cursor */
65
+ export function show(): void {
66
+ visible.value = true
67
+ }
68
+
69
+ /** Hide the cursor */
70
+ export function hide(): void {
71
+ visible.value = false
72
+ }
73
+
74
+ /** Toggle cursor visibility */
75
+ export function toggle(): void {
76
+ visible.value = !visible.value
77
+ }
78
+
79
+ /**
80
+ * Set cursor shape.
81
+ * @param newShape 'block' | 'underline' | 'bar'
82
+ * @param blink Whether cursor should blink (default: true)
83
+ */
84
+ export function setShape(newShape: CursorShape, blink: boolean = true): void {
85
+ shape.value = newShape
86
+ blinking.value = blink
87
+ }
88
+
89
+ /**
90
+ * Move cursor to absolute position.
91
+ * @param col Column (0-indexed)
92
+ * @param row Row (0-indexed)
93
+ */
94
+ export function moveTo(col: number, row: number): void {
95
+ x.value = col
96
+ y.value = row
97
+ }
98
+
99
+ /**
100
+ * Move cursor relative to current position.
101
+ * @param dx Columns to move (negative = left)
102
+ * @param dy Rows to move (negative = up)
103
+ */
104
+ export function moveBy(dx: number, dy: number): void {
105
+ x.value += dx
106
+ y.value += dy
107
+ }
108
+
109
+ /** Save current cursor position */
110
+ export function save(): string {
111
+ return cursorSavePosition
112
+ }
113
+
114
+ /** Restore saved cursor position */
115
+ export function restore(): string {
116
+ return cursorRestorePosition
117
+ }
118
+
119
+ // =============================================================================
120
+ // ANSI OUTPUT GENERATORS
121
+ // =============================================================================
122
+
123
+ /** Get ANSI sequence for current visibility */
124
+ export function getVisibilitySequence(): string {
125
+ return visible.value ? cursorShow : cursorHide
126
+ }
127
+
128
+ /** Get ANSI sequence for current shape */
129
+ export function getShapeSequence(): string {
130
+ return setCursorShape(shape.value, blinking.value)
131
+ }
132
+
133
+ /** Get ANSI sequence to move to current position */
134
+ export function getPositionSequence(): string {
135
+ return cursorTo(x.value, y.value)
136
+ }
137
+
138
+ // =============================================================================
139
+ // REACTIVE CURSOR OBJECT (for convenience)
140
+ // =============================================================================
141
+
142
+ /**
143
+ * Cursor control object with all functions.
144
+ * Import this for a cleaner API:
145
+ *
146
+ * ```ts
147
+ * import { cursor } from './state/cursor'
148
+ * cursor.show()
149
+ * cursor.setShape('bar')
150
+ * ```
151
+ */
152
+ export const cursor = {
153
+ // State (readable)
154
+ get visible() { return visible.value },
155
+ get shape() { return shape.value },
156
+ get blinking() { return blinking.value },
157
+ get x() { return x.value },
158
+ get y() { return y.value },
159
+
160
+ // Control functions
161
+ show,
162
+ hide,
163
+ toggle,
164
+ setShape,
165
+ moveTo,
166
+ moveBy,
167
+ save,
168
+ restore,
169
+
170
+ // ANSI sequences
171
+ getVisibilitySequence,
172
+ getShapeSequence,
173
+ getPositionSequence,
174
+ }
175
+
176
+ export default cursor