@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,667 @@
1
+ /**
2
+ * TUI Framework - FrameBuffer Utilities
3
+ *
4
+ * Creating and manipulating 2D cell buffers.
5
+ * The frameBuffer derived uses these to build what gets rendered.
6
+ */
7
+
8
+ import type { Cell, RGBA, CellAttrs, FrameBuffer, BorderStyle } from '../types'
9
+ import { Attr, BorderChars } from '../types'
10
+ import { Colors, rgbaBlend, rgbaEqual, charWidth, stringWidth } from '../types/color'
11
+
12
+ // =============================================================================
13
+ // CLIP RECT - Production clipping support
14
+ // =============================================================================
15
+
16
+ /**
17
+ * A rectangular clipping region.
18
+ * All coordinates are in terminal cells.
19
+ */
20
+ export interface ClipRect {
21
+ x: number
22
+ y: number
23
+ width: number
24
+ height: number
25
+ }
26
+
27
+ /**
28
+ * Check if a point is inside a clip rect.
29
+ */
30
+ export function isInClipRect(clip: ClipRect | undefined, x: number, y: number): boolean {
31
+ if (!clip) return true
32
+ return x >= clip.x && x < clip.x + clip.width && y >= clip.y && y < clip.y + clip.height
33
+ }
34
+
35
+ /**
36
+ * Compute the intersection of two clip rects.
37
+ * Returns null if they don't overlap.
38
+ */
39
+ export function intersectClipRects(a: ClipRect, b: ClipRect): ClipRect | null {
40
+ const x = Math.max(a.x, b.x)
41
+ const y = Math.max(a.y, b.y)
42
+ const right = Math.min(a.x + a.width, b.x + b.width)
43
+ const bottom = Math.min(a.y + a.height, b.y + b.height)
44
+
45
+ if (right <= x || bottom <= y) return null // No intersection
46
+
47
+ return {
48
+ x,
49
+ y,
50
+ width: right - x,
51
+ height: bottom - y,
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Create a clip rect from component bounds.
57
+ */
58
+ export function createClipRect(x: number, y: number, width: number, height: number): ClipRect {
59
+ return { x, y, width, height }
60
+ }
61
+
62
+ // =============================================================================
63
+ // Buffer Creation
64
+ // =============================================================================
65
+
66
+ /**
67
+ * Create a new FrameBuffer filled with empty cells.
68
+ */
69
+ export function createBuffer(width: number, height: number, bg: RGBA = Colors.BLACK): FrameBuffer {
70
+ const cells: Cell[][] = []
71
+
72
+ for (let y = 0; y < height; y++) {
73
+ const row: Cell[] = []
74
+ for (let x = 0; x < width; x++) {
75
+ row.push({
76
+ char: 32, // space
77
+ fg: Colors.WHITE,
78
+ bg,
79
+ attrs: Attr.NONE,
80
+ })
81
+ }
82
+ cells.push(row)
83
+ }
84
+
85
+ return { width, height, cells }
86
+ }
87
+
88
+ /**
89
+ * Clone a FrameBuffer (deep copy).
90
+ */
91
+ export function cloneBuffer(buffer: FrameBuffer): FrameBuffer {
92
+ const cells: Cell[][] = []
93
+
94
+ for (let y = 0; y < buffer.height; y++) {
95
+ const row: Cell[] = []
96
+ const sourceRow = buffer.cells[y]
97
+ if (!sourceRow) continue
98
+ for (let x = 0; x < buffer.width; x++) {
99
+ const cell = sourceRow[x]
100
+ if (!cell) continue
101
+ row.push({
102
+ char: cell.char,
103
+ fg: { ...cell.fg },
104
+ bg: { ...cell.bg },
105
+ attrs: cell.attrs,
106
+ })
107
+ }
108
+ cells.push(row)
109
+ }
110
+
111
+ return { width: buffer.width, height: buffer.height, cells }
112
+ }
113
+
114
+ // =============================================================================
115
+ // Cell Access
116
+ // =============================================================================
117
+
118
+ /**
119
+ * Get a cell at (x, y), returns undefined if out of bounds.
120
+ */
121
+ export function getCell(buffer: FrameBuffer, x: number, y: number): Cell | undefined {
122
+ if (x < 0 || x >= buffer.width || y < 0 || y >= buffer.height) {
123
+ return undefined
124
+ }
125
+ return buffer.cells[y]?.[x]
126
+ }
127
+
128
+ /**
129
+ * Set a cell at (x, y). Handles alpha blending and optional clipping.
130
+ *
131
+ * @param buffer - The frame buffer
132
+ * @param x - Column position
133
+ * @param y - Row position
134
+ * @param char - Character to write
135
+ * @param fg - Foreground color
136
+ * @param bg - Background color
137
+ * @param attrs - Text attributes
138
+ * @param clip - Optional clipping rectangle
139
+ * @returns true if cell was written, false if clipped/out of bounds
140
+ */
141
+ export function setCell(
142
+ buffer: FrameBuffer,
143
+ x: number,
144
+ y: number,
145
+ char: number | string,
146
+ fg: RGBA,
147
+ bg: RGBA,
148
+ attrs: CellAttrs = Attr.NONE,
149
+ clip?: ClipRect
150
+ ): boolean {
151
+ // Buffer bounds check
152
+ if (x < 0 || x >= buffer.width || y < 0 || y >= buffer.height) return false
153
+
154
+ // Clip rect check
155
+ if (!isInClipRect(clip, x, y)) return false
156
+
157
+ const row = buffer.cells[y]
158
+ if (!row) return false
159
+ const cell = row[x]
160
+ if (!cell) return false
161
+
162
+ const codepoint = typeof char === 'string' ? (char.codePointAt(0) ?? 32) : char
163
+
164
+ // Alpha blend background
165
+ const blendedBg = bg.a === 255 ? bg : rgbaBlend(bg, cell.bg)
166
+
167
+ cell.char = codepoint
168
+ cell.fg = fg
169
+ cell.bg = blendedBg
170
+ cell.attrs = attrs
171
+ return true
172
+ }
173
+
174
+ /**
175
+ * Compare two cells for equality.
176
+ */
177
+ export function cellEqual(a: Cell, b: Cell): boolean {
178
+ return (
179
+ a.char === b.char &&
180
+ a.attrs === b.attrs &&
181
+ rgbaEqual(a.fg, b.fg) &&
182
+ rgbaEqual(a.bg, b.bg)
183
+ )
184
+ }
185
+
186
+ // =============================================================================
187
+ // Drawing Primitives
188
+ // =============================================================================
189
+
190
+ /**
191
+ * Fill a rectangle with a background color.
192
+ * Handles alpha blending and optional clipping.
193
+ */
194
+ export function fillRect(
195
+ buffer: FrameBuffer,
196
+ x: number,
197
+ y: number,
198
+ width: number,
199
+ height: number,
200
+ bg: RGBA,
201
+ clip?: ClipRect
202
+ ): void {
203
+ // Compute visible bounds
204
+ let x1 = Math.max(0, x)
205
+ let y1 = Math.max(0, y)
206
+ let x2 = Math.min(buffer.width, x + width)
207
+ let y2 = Math.min(buffer.height, y + height)
208
+
209
+ // Apply clip rect
210
+ if (clip) {
211
+ x1 = Math.max(x1, clip.x)
212
+ y1 = Math.max(y1, clip.y)
213
+ x2 = Math.min(x2, clip.x + clip.width)
214
+ y2 = Math.min(y2, clip.y + clip.height)
215
+ }
216
+
217
+ for (let py = y1; py < y2; py++) {
218
+ const row = buffer.cells[py]
219
+ if (!row) continue
220
+ for (let px = x1; px < x2; px++) {
221
+ const cell = row[px]
222
+ if (!cell) continue
223
+ cell.bg = bg.a === 255 ? bg : rgbaBlend(bg, cell.bg)
224
+ }
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Per-side border configuration.
230
+ */
231
+ export interface BorderConfig {
232
+ styles: { top: number; right: number; bottom: number; left: number }
233
+ colors: { top: RGBA; right: RGBA; bottom: RGBA; left: RGBA }
234
+ }
235
+
236
+ /**
237
+ * Draw borders with independent per-side styles and colors.
238
+ * Handles:
239
+ * - Independent styles per side (top, right, bottom, left)
240
+ * - Independent colors per side
241
+ * - Proper corner character selection based on meeting sides
242
+ * - Clipping support
243
+ */
244
+ export function drawBorder(
245
+ buffer: FrameBuffer,
246
+ x: number,
247
+ y: number,
248
+ width: number,
249
+ height: number,
250
+ config: BorderConfig,
251
+ bg?: RGBA,
252
+ clip?: ClipRect
253
+ ): void {
254
+ if (width < 1 || height < 1) return
255
+
256
+ const { styles, colors } = config
257
+ const hasTop = styles.top > 0
258
+ const hasRight = styles.right > 0
259
+ const hasBottom = styles.bottom > 0
260
+ const hasLeft = styles.left > 0
261
+
262
+ // Quick exit if no borders
263
+ if (!hasTop && !hasRight && !hasBottom && !hasLeft) return
264
+
265
+ // Helper to get border characters for a style (direct numeric access)
266
+ const getChars = (style: number) => {
267
+ if (style <= 0) return null
268
+ return BorderChars[style]
269
+ }
270
+
271
+ // Char indices: [0]=horizontal, [1]=vertical, [2]=topLeft, [3]=topRight, [4]=bottomRight, [5]=bottomLeft
272
+
273
+ // Draw top edge (horizontal char)
274
+ if (hasTop && width > 2) {
275
+ const chars = getChars(styles.top)
276
+ if (chars) {
277
+ for (let px = x + 1; px < x + width - 1; px++) {
278
+ drawChar(buffer, px, y, chars[0], colors.top, bg, Attr.NONE, clip)
279
+ }
280
+ }
281
+ }
282
+
283
+ // Draw bottom edge (horizontal char)
284
+ if (hasBottom && width > 2) {
285
+ const chars = getChars(styles.bottom)
286
+ if (chars) {
287
+ for (let px = x + 1; px < x + width - 1; px++) {
288
+ drawChar(buffer, px, y + height - 1, chars[0], colors.bottom, bg, Attr.NONE, clip)
289
+ }
290
+ }
291
+ }
292
+
293
+ // Draw left edge (vertical char)
294
+ if (hasLeft && height > 2) {
295
+ const chars = getChars(styles.left)
296
+ if (chars) {
297
+ for (let py = y + 1; py < y + height - 1; py++) {
298
+ drawChar(buffer, x, py, chars[1], colors.left, bg, Attr.NONE, clip)
299
+ }
300
+ }
301
+ }
302
+
303
+ // Draw right edge (vertical char)
304
+ if (hasRight && height > 2) {
305
+ const chars = getChars(styles.right)
306
+ if (chars) {
307
+ for (let py = y + 1; py < y + height - 1; py++) {
308
+ drawChar(buffer, x + width - 1, py, chars[1], colors.right, bg, Attr.NONE, clip)
309
+ }
310
+ }
311
+ }
312
+
313
+ // Corners - only draw corner character when BOTH connecting sides exist
314
+ // Otherwise draw the straight edge character for the side that exists
315
+
316
+ // Top-left corner
317
+ if (hasTop && hasLeft) {
318
+ // Both sides exist - draw corner [2]
319
+ const cornerStyle = styles.top // top takes precedence
320
+ const cornerColor = colors.top
321
+ const chars = getChars(cornerStyle)
322
+ if (chars) {
323
+ drawChar(buffer, x, y, chars[2], cornerColor, bg, Attr.NONE, clip)
324
+ }
325
+ } else if (hasTop) {
326
+ // Only top - draw horizontal [0]
327
+ const chars = getChars(styles.top)
328
+ if (chars) {
329
+ drawChar(buffer, x, y, chars[0], colors.top, bg, Attr.NONE, clip)
330
+ }
331
+ } else if (hasLeft) {
332
+ // Only left - draw vertical [1]
333
+ const chars = getChars(styles.left)
334
+ if (chars) {
335
+ drawChar(buffer, x, y, chars[1], colors.left, bg, Attr.NONE, clip)
336
+ }
337
+ }
338
+
339
+ // Top-right corner
340
+ if (hasTop && hasRight) {
341
+ // Both sides exist - draw corner [3]
342
+ const cornerStyle = styles.top
343
+ const cornerColor = colors.top
344
+ const chars = getChars(cornerStyle)
345
+ if (chars) {
346
+ drawChar(buffer, x + width - 1, y, chars[3], cornerColor, bg, Attr.NONE, clip)
347
+ }
348
+ } else if (hasTop) {
349
+ // Only top - draw horizontal [0]
350
+ const chars = getChars(styles.top)
351
+ if (chars) {
352
+ drawChar(buffer, x + width - 1, y, chars[0], colors.top, bg, Attr.NONE, clip)
353
+ }
354
+ } else if (hasRight) {
355
+ // Only right - draw vertical [1]
356
+ const chars = getChars(styles.right)
357
+ if (chars) {
358
+ drawChar(buffer, x + width - 1, y, chars[1], colors.right, bg, Attr.NONE, clip)
359
+ }
360
+ }
361
+
362
+ // Bottom-left corner
363
+ if (hasBottom && hasLeft) {
364
+ // Both sides exist - draw corner [5]
365
+ const cornerStyle = styles.bottom
366
+ const cornerColor = colors.bottom
367
+ const chars = getChars(cornerStyle)
368
+ if (chars) {
369
+ drawChar(buffer, x, y + height - 1, chars[5], cornerColor, bg, Attr.NONE, clip)
370
+ }
371
+ } else if (hasBottom) {
372
+ // Only bottom - draw horizontal [0]
373
+ const chars = getChars(styles.bottom)
374
+ if (chars) {
375
+ drawChar(buffer, x, y + height - 1, chars[0], colors.bottom, bg, Attr.NONE, clip)
376
+ }
377
+ } else if (hasLeft) {
378
+ // Only left - draw vertical [1]
379
+ const chars = getChars(styles.left)
380
+ if (chars) {
381
+ drawChar(buffer, x, y + height - 1, chars[1], colors.left, bg, Attr.NONE, clip)
382
+ }
383
+ }
384
+
385
+ // Bottom-right corner
386
+ if (hasBottom && hasRight) {
387
+ // Both sides exist - draw corner [4]
388
+ const cornerStyle = styles.bottom
389
+ const cornerColor = colors.bottom
390
+ const chars = getChars(cornerStyle)
391
+ if (chars) {
392
+ drawChar(buffer, x + width - 1, y + height - 1, chars[4], cornerColor, bg, Attr.NONE, clip)
393
+ }
394
+ } else if (hasBottom) {
395
+ // Only bottom - draw horizontal [0]
396
+ const chars = getChars(styles.bottom)
397
+ if (chars) {
398
+ drawChar(buffer, x + width - 1, y + height - 1, chars[0], colors.bottom, bg, Attr.NONE, clip)
399
+ }
400
+ } else if (hasRight) {
401
+ // Only right - draw vertical [1]
402
+ const chars = getChars(styles.right)
403
+ if (chars) {
404
+ drawChar(buffer, x + width - 1, y + height - 1, chars[1], colors.right, bg, Attr.NONE, clip)
405
+ }
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Draw a single character at (x, y).
411
+ * Supports optional clipping.
412
+ */
413
+ export function drawChar(
414
+ buffer: FrameBuffer,
415
+ x: number,
416
+ y: number,
417
+ char: string | number,
418
+ fg: RGBA,
419
+ bg?: RGBA,
420
+ attrs: CellAttrs = Attr.NONE,
421
+ clip?: ClipRect
422
+ ): boolean {
423
+ if (x < 0 || x >= buffer.width || y < 0 || y >= buffer.height) return false
424
+ if (!isInClipRect(clip, x, y)) return false
425
+
426
+ const row = buffer.cells[y]
427
+ if (!row) return false
428
+ const cell = row[x]
429
+ if (!cell) return false
430
+
431
+ const codepoint = typeof char === 'string' ? (char.codePointAt(0) ?? 32) : char
432
+
433
+ cell.char = codepoint
434
+ cell.fg = fg
435
+ cell.attrs = attrs
436
+
437
+ if (bg) {
438
+ cell.bg = bg.a === 255 ? bg : rgbaBlend(bg, cell.bg)
439
+ }
440
+ return true
441
+ }
442
+
443
+ /**
444
+ * Draw text at (x, y).
445
+ * Handles wide characters (emoji, CJK) correctly.
446
+ * Supports optional clipping.
447
+ */
448
+ export function drawText(
449
+ buffer: FrameBuffer,
450
+ x: number,
451
+ y: number,
452
+ text: string,
453
+ fg: RGBA,
454
+ bg?: RGBA,
455
+ attrs: CellAttrs = Attr.NONE,
456
+ clip?: ClipRect
457
+ ): number {
458
+ if (y < 0 || y >= buffer.height) return 0
459
+ if (clip && (y < clip.y || y >= clip.y + clip.height)) return 0
460
+
461
+ const row = buffer.cells[y]
462
+ if (!row) return 0
463
+
464
+ let col = x
465
+ for (const char of text) {
466
+ if (col >= buffer.width) break
467
+ if (clip && col >= clip.x + clip.width) break
468
+
469
+ if (col < 0 || (clip && col < clip.x)) {
470
+ const w = charWidth(char)
471
+ col += w
472
+ continue
473
+ }
474
+
475
+ const width = charWidth(char)
476
+ const codepoint = char.codePointAt(0) ?? 32
477
+
478
+ // Draw the character
479
+ const cell = row[col]
480
+ if (cell) {
481
+ cell.char = codepoint
482
+ cell.fg = fg
483
+ cell.attrs = attrs
484
+ if (bg) {
485
+ cell.bg = bg.a === 255 ? bg : rgbaBlend(bg, cell.bg)
486
+ }
487
+ }
488
+
489
+ // For wide characters, mark continuation cells
490
+ if (width === 2 && col + 1 < buffer.width) {
491
+ if (!clip || col + 1 < clip.x + clip.width) {
492
+ const next = row[col + 1]
493
+ if (next) {
494
+ next.char = 0 // continuation marker
495
+ next.fg = fg
496
+ next.attrs = attrs
497
+ if (bg) {
498
+ next.bg = bg.a === 255 ? bg : rgbaBlend(bg, next.bg)
499
+ }
500
+ }
501
+ }
502
+ }
503
+
504
+ col += width
505
+ }
506
+
507
+ return col - x // Return actual width drawn
508
+ }
509
+
510
+ /**
511
+ * Draw text centered horizontally within a width.
512
+ */
513
+ export function drawTextCentered(
514
+ buffer: FrameBuffer,
515
+ x: number,
516
+ y: number,
517
+ width: number,
518
+ text: string,
519
+ fg: RGBA,
520
+ bg?: RGBA,
521
+ attrs: CellAttrs = Attr.NONE,
522
+ clip?: ClipRect
523
+ ): void {
524
+ const textWidth = stringWidth(text)
525
+ const startX = x + Math.floor((width - textWidth) / 2)
526
+ drawText(buffer, startX, y, text, fg, bg, attrs, clip)
527
+ }
528
+
529
+ /**
530
+ * Draw text right-aligned within a width.
531
+ */
532
+ export function drawTextRight(
533
+ buffer: FrameBuffer,
534
+ x: number,
535
+ y: number,
536
+ width: number,
537
+ text: string,
538
+ fg: RGBA,
539
+ bg?: RGBA,
540
+ attrs: CellAttrs = Attr.NONE,
541
+ clip?: ClipRect
542
+ ): void {
543
+ const textWidth = stringWidth(text)
544
+ const startX = x + width - textWidth
545
+ drawText(buffer, startX, y, text, fg, bg, attrs, clip)
546
+ }
547
+
548
+ // =============================================================================
549
+ // Clipping
550
+ // =============================================================================
551
+
552
+ /**
553
+ * Apply clipping - clear cells outside the clip region to their backgrounds.
554
+ * Used for overflow: hidden
555
+ */
556
+ export function applyClipping(
557
+ buffer: FrameBuffer,
558
+ clipX: number,
559
+ clipY: number,
560
+ clipWidth: number,
561
+ clipHeight: number
562
+ ): void {
563
+ // Everything outside the clip region keeps only its background
564
+ for (let y = 0; y < buffer.height; y++) {
565
+ const row = buffer.cells[y]
566
+ if (!row) continue
567
+ for (let x = 0; x < buffer.width; x++) {
568
+ const inClip =
569
+ x >= clipX &&
570
+ x < clipX + clipWidth &&
571
+ y >= clipY &&
572
+ y < clipY + clipHeight
573
+
574
+ if (!inClip) {
575
+ const cell = row[x]
576
+ if (cell) {
577
+ cell.char = 32 // space
578
+ cell.attrs = Attr.NONE
579
+ }
580
+ }
581
+ }
582
+ }
583
+ }
584
+
585
+ // =============================================================================
586
+ // Progress Bar
587
+ // =============================================================================
588
+
589
+ /**
590
+ * Draw a progress bar.
591
+ */
592
+ export function drawProgressBar(
593
+ buffer: FrameBuffer,
594
+ x: number,
595
+ y: number,
596
+ width: number,
597
+ progress: number, // 0-1
598
+ filledChar: string,
599
+ emptyChar: string,
600
+ filledFg: RGBA,
601
+ emptyFg: RGBA,
602
+ bg?: RGBA
603
+ ): void {
604
+ const filled = Math.round(progress * width)
605
+
606
+ for (let i = 0; i < width; i++) {
607
+ const isFilled = i < filled
608
+ drawChar(
609
+ buffer,
610
+ x + i,
611
+ y,
612
+ isFilled ? filledChar : emptyChar,
613
+ isFilled ? filledFg : emptyFg,
614
+ bg
615
+ )
616
+ }
617
+ }
618
+
619
+ // =============================================================================
620
+ // Scrollbar
621
+ // =============================================================================
622
+
623
+ /**
624
+ * Draw a vertical scrollbar.
625
+ */
626
+ export function drawScrollbarV(
627
+ buffer: FrameBuffer,
628
+ x: number,
629
+ y: number,
630
+ height: number,
631
+ scrollPosition: number, // 0-1
632
+ viewportRatio: number, // visible / total
633
+ trackFg: RGBA,
634
+ thumbFg: RGBA,
635
+ bg?: RGBA
636
+ ): void {
637
+ const thumbHeight = Math.max(1, Math.round(height * viewportRatio))
638
+ const thumbStart = Math.round((height - thumbHeight) * scrollPosition)
639
+
640
+ for (let i = 0; i < height; i++) {
641
+ const isThumb = i >= thumbStart && i < thumbStart + thumbHeight
642
+ drawChar(buffer, x, y + i, '│', isThumb ? thumbFg : trackFg, bg)
643
+ }
644
+ }
645
+
646
+ /**
647
+ * Draw a horizontal scrollbar.
648
+ */
649
+ export function drawScrollbarH(
650
+ buffer: FrameBuffer,
651
+ x: number,
652
+ y: number,
653
+ width: number,
654
+ scrollPosition: number, // 0-1
655
+ viewportRatio: number, // visible / total
656
+ trackFg: RGBA,
657
+ thumbFg: RGBA,
658
+ bg?: RGBA
659
+ ): void {
660
+ const thumbWidth = Math.max(1, Math.round(width * viewportRatio))
661
+ const thumbStart = Math.round((width - thumbWidth) * scrollPosition)
662
+
663
+ for (let i = 0; i < width; i++) {
664
+ const isThumb = i >= thumbStart && i < thumbStart + thumbWidth
665
+ drawChar(buffer, x + i, y, '─', isThumb ? thumbFg : trackFg, bg)
666
+ }
667
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * TUI Framework - Renderer Module
3
+ *
4
+ * The "blind" renderer - knows only about cells, not components.
5
+ */
6
+
7
+ // Buffer utilities
8
+ export {
9
+ createBuffer,
10
+ cloneBuffer,
11
+ getCell,
12
+ setCell,
13
+ cellEqual,
14
+ fillRect,
15
+ drawBorder,
16
+ drawChar,
17
+ drawText,
18
+ drawTextCentered,
19
+ drawTextRight,
20
+ applyClipping,
21
+ drawProgressBar,
22
+ drawScrollbarV,
23
+ drawScrollbarH,
24
+ } from './buffer'
25
+
26
+ // ANSI escape codes
27
+ export * as ansi from './ansi'
28
+
29
+ // Output and differential rendering
30
+ export {
31
+ OutputBuffer,
32
+ DiffRenderer,
33
+ setupInlineMode,
34
+ positionInlineMode,
35
+ positionAppendMode,
36
+ finalizeAppendMode,
37
+ } from './output'
38
+
39
+ // Input parsing
40
+ export { InputBuffer, type ParsedInput } from './input'