@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,471 @@
1
+ /**
2
+ * TUI Framework - Text Utilities
3
+ *
4
+ * Fast, accurate text measurement and manipulation using Bun's native APIs.
5
+ * Handles wide characters, emoji, CJK, and grapheme clusters correctly.
6
+ */
7
+
8
+ import { stringWidth, charWidth, stripAnsi } from '../types/color'
9
+
10
+ // Re-export for convenience
11
+ export { stringWidth, charWidth, stripAnsi }
12
+
13
+ // =============================================================================
14
+ // TEXT MEASUREMENT
15
+ // =============================================================================
16
+
17
+ /**
18
+ * Measure the display width of text in terminal columns.
19
+ * Handles emoji, CJK, combining marks correctly.
20
+ * Strips ANSI codes before measuring.
21
+ */
22
+ export function measureText(text: string): number {
23
+ const clean = stripAnsi(text)
24
+ return stringWidth(clean)
25
+ }
26
+
27
+ // =============================================================================
28
+ // TEXT WRAPPING
29
+ // =============================================================================
30
+
31
+ /**
32
+ * Wrap text to fit within a given width.
33
+ * Uses accurate column counting with proper grapheme handling.
34
+ *
35
+ * @param text - Text to wrap
36
+ * @param width - Maximum width in terminal columns
37
+ * @returns Array of wrapped lines
38
+ */
39
+ export function wrapText(text: string, width: number): string[] {
40
+ if (!text || width <= 0) return []
41
+
42
+ const lines: string[] = []
43
+ const paragraphs = text.split('\n')
44
+
45
+ for (const paragraph of paragraphs) {
46
+ if (paragraph.length === 0) {
47
+ lines.push('')
48
+ continue
49
+ }
50
+
51
+ // Quick path: if paragraph fits, no wrapping needed
52
+ if (measureText(paragraph) <= width) {
53
+ lines.push(paragraph)
54
+ continue
55
+ }
56
+
57
+ // Word wrap with accurate width measurement
58
+ let currentLine = ''
59
+ let currentWidth = 0
60
+ const words = paragraph.split(' ')
61
+
62
+ for (let i = 0; i < words.length; i++) {
63
+ const word = words[i]
64
+ if (!word) continue
65
+
66
+ const wordWidth = measureText(word)
67
+
68
+ // Handle words longer than width - break by character
69
+ if (wordWidth > width) {
70
+ // Flush current line if any
71
+ if (currentLine) {
72
+ lines.push(currentLine)
73
+ currentLine = ''
74
+ currentWidth = 0
75
+ }
76
+
77
+ // Break long word by character width
78
+ let wordPart = ''
79
+ let partWidth = 0
80
+
81
+ for (const char of word) {
82
+ const cw = charWidth(char)
83
+
84
+ if (partWidth + cw > width) {
85
+ if (wordPart) {
86
+ lines.push(wordPart)
87
+ wordPart = ''
88
+ partWidth = 0
89
+ }
90
+ }
91
+
92
+ wordPart += char
93
+ partWidth += cw
94
+ }
95
+
96
+ if (wordPart) {
97
+ currentLine = wordPart
98
+ currentWidth = partWidth
99
+ }
100
+ continue
101
+ }
102
+
103
+ // Check if word fits on current line
104
+ const spaceWidth = currentLine ? 1 : 0
105
+ if (currentWidth + spaceWidth + wordWidth <= width) {
106
+ // Add to current line
107
+ if (currentLine) {
108
+ currentLine += ' '
109
+ currentWidth += 1
110
+ }
111
+ currentLine += word
112
+ currentWidth += wordWidth
113
+ } else {
114
+ // Start new line
115
+ if (currentLine) {
116
+ lines.push(currentLine)
117
+ }
118
+ currentLine = word
119
+ currentWidth = wordWidth
120
+ }
121
+ }
122
+
123
+ // Don't forget last line
124
+ if (currentLine) {
125
+ lines.push(currentLine)
126
+ }
127
+ }
128
+
129
+ return lines
130
+ }
131
+
132
+ /**
133
+ * Wrap text by character (no word boundaries).
134
+ * Useful for CJK text or when strict width is needed.
135
+ */
136
+ export function wrapTextHard(text: string, width: number): string[] {
137
+ if (!text || width <= 0) return []
138
+
139
+ const lines: string[] = []
140
+ const paragraphs = text.split('\n')
141
+
142
+ for (const paragraph of paragraphs) {
143
+ if (paragraph.length === 0) {
144
+ lines.push('')
145
+ continue
146
+ }
147
+
148
+ let currentLine = ''
149
+ let currentWidth = 0
150
+
151
+ for (const char of paragraph) {
152
+ const cw = charWidth(char)
153
+
154
+ if (currentWidth + cw > width) {
155
+ lines.push(currentLine)
156
+ currentLine = char
157
+ currentWidth = cw
158
+ } else {
159
+ currentLine += char
160
+ currentWidth += cw
161
+ }
162
+ }
163
+
164
+ if (currentLine) {
165
+ lines.push(currentLine)
166
+ }
167
+ }
168
+
169
+ return lines
170
+ }
171
+
172
+ // =============================================================================
173
+ // TEXT TRUNCATION
174
+ // =============================================================================
175
+
176
+ /**
177
+ * Truncate text to fit within a given width, adding ellipsis if needed.
178
+ *
179
+ * @param text - Text to truncate
180
+ * @param maxWidth - Maximum width including ellipsis
181
+ * @param ellipsis - Ellipsis string (default: '…')
182
+ */
183
+ export function truncateText(
184
+ text: string,
185
+ maxWidth: number,
186
+ ellipsis: string = '…'
187
+ ): string {
188
+ const clean = stripAnsi(text)
189
+ const textWidth = stringWidth(clean)
190
+
191
+ if (textWidth <= maxWidth) {
192
+ return text
193
+ }
194
+
195
+ const ellipsisWidth = stringWidth(ellipsis)
196
+ const targetWidth = maxWidth - ellipsisWidth
197
+
198
+ if (targetWidth <= 0) {
199
+ return ellipsis.slice(0, maxWidth)
200
+ }
201
+
202
+ // Truncate by display width
203
+ let truncated = ''
204
+ let currentWidth = 0
205
+
206
+ for (const char of clean) {
207
+ const cw = charWidth(char)
208
+ if (currentWidth + cw > targetWidth) {
209
+ break
210
+ }
211
+ truncated += char
212
+ currentWidth += cw
213
+ }
214
+
215
+ return truncated + ellipsis
216
+ }
217
+
218
+ /**
219
+ * Truncate text from the start (keep end).
220
+ */
221
+ export function truncateStart(
222
+ text: string,
223
+ maxWidth: number,
224
+ ellipsis: string = '…'
225
+ ): string {
226
+ const clean = stripAnsi(text)
227
+ const textWidth = stringWidth(clean)
228
+
229
+ if (textWidth <= maxWidth) {
230
+ return text
231
+ }
232
+
233
+ const ellipsisWidth = stringWidth(ellipsis)
234
+ const targetWidth = maxWidth - ellipsisWidth
235
+
236
+ if (targetWidth <= 0) {
237
+ return ellipsis.slice(0, maxWidth)
238
+ }
239
+
240
+ // Build from end
241
+ const chars = [...clean]
242
+ let truncated = ''
243
+ let currentWidth = 0
244
+
245
+ for (let i = chars.length - 1; i >= 0; i--) {
246
+ const char = chars[i]!
247
+ const cw = charWidth(char)
248
+ if (currentWidth + cw > targetWidth) {
249
+ break
250
+ }
251
+ truncated = char + truncated
252
+ currentWidth += cw
253
+ }
254
+
255
+ return ellipsis + truncated
256
+ }
257
+
258
+ /**
259
+ * Truncate text from the middle (keep start and end).
260
+ */
261
+ export function truncateMiddle(
262
+ text: string,
263
+ maxWidth: number,
264
+ ellipsis: string = '…'
265
+ ): string {
266
+ const clean = stripAnsi(text)
267
+ const textWidth = stringWidth(clean)
268
+
269
+ if (textWidth <= maxWidth) {
270
+ return text
271
+ }
272
+
273
+ const ellipsisWidth = stringWidth(ellipsis)
274
+ const targetWidth = maxWidth - ellipsisWidth
275
+
276
+ if (targetWidth <= 0) {
277
+ return ellipsis.slice(0, maxWidth)
278
+ }
279
+
280
+ // Split roughly in half
281
+ const startWidth = Math.ceil(targetWidth / 2)
282
+ const endWidth = Math.floor(targetWidth / 2)
283
+
284
+ // Get start part
285
+ let start = ''
286
+ let sw = 0
287
+ for (const char of clean) {
288
+ const cw = charWidth(char)
289
+ if (sw + cw > startWidth) break
290
+ start += char
291
+ sw += cw
292
+ }
293
+
294
+ // Get end part
295
+ const chars = [...clean]
296
+ let end = ''
297
+ let ew = 0
298
+ for (let i = chars.length - 1; i >= 0; i--) {
299
+ const char = chars[i]!
300
+ const cw = charWidth(char)
301
+ if (ew + cw > endWidth) break
302
+ end = char + end
303
+ ew += cw
304
+ }
305
+
306
+ return start + ellipsis + end
307
+ }
308
+
309
+ // =============================================================================
310
+ // TEXT ALIGNMENT
311
+ // =============================================================================
312
+
313
+ /**
314
+ * Pad text to center it within a given width.
315
+ *
316
+ * @param text - Text to center
317
+ * @param width - Total width
318
+ * @param fillChar - Character to use for padding (default: space)
319
+ */
320
+ export function centerText(text: string, width: number, fillChar: string = ' '): string {
321
+ const textWidth = measureText(text)
322
+
323
+ if (textWidth >= width) {
324
+ return text
325
+ }
326
+
327
+ const totalPadding = width - textWidth
328
+ const leftPadding = Math.floor(totalPadding / 2)
329
+ const rightPadding = totalPadding - leftPadding
330
+
331
+ return fillChar.repeat(leftPadding) + text + fillChar.repeat(rightPadding)
332
+ }
333
+
334
+ /**
335
+ * Pad text to right-align it within a given width.
336
+ */
337
+ export function rightAlignText(text: string, width: number, fillChar: string = ' '): string {
338
+ const textWidth = measureText(text)
339
+
340
+ if (textWidth >= width) {
341
+ return text
342
+ }
343
+
344
+ return fillChar.repeat(width - textWidth) + text
345
+ }
346
+
347
+ /**
348
+ * Pad text to left-align it within a given width.
349
+ */
350
+ export function leftAlignText(text: string, width: number, fillChar: string = ' '): string {
351
+ const textWidth = measureText(text)
352
+
353
+ if (textWidth >= width) {
354
+ return text
355
+ }
356
+
357
+ return text + fillChar.repeat(width - textWidth)
358
+ }
359
+
360
+ // =============================================================================
361
+ // TEXT UTILITIES
362
+ // =============================================================================
363
+
364
+ /**
365
+ * Split text into grapheme clusters (visual characters).
366
+ * Handles emoji, combining marks, etc. correctly.
367
+ */
368
+ export function splitGraphemes(text: string): string[] {
369
+ // Use Intl.Segmenter if available (Bun supports it)
370
+ if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) {
371
+ const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' })
372
+ return [...segmenter.segment(text)].map(s => s.segment)
373
+ }
374
+
375
+ // Fallback to spread operator (works for most cases)
376
+ return [...text]
377
+ }
378
+
379
+ /**
380
+ * Get the character at a specific column position.
381
+ * Handles wide characters correctly.
382
+ *
383
+ * @returns The character at the column, or null if out of bounds
384
+ */
385
+ export function charAtColumn(text: string, column: number): string | null {
386
+ if (column < 0) return null
387
+
388
+ let currentCol = 0
389
+ for (const char of text) {
390
+ const cw = charWidth(char)
391
+ if (column >= currentCol && column < currentCol + cw) {
392
+ return char
393
+ }
394
+ currentCol += cw
395
+ }
396
+
397
+ return null
398
+ }
399
+
400
+ /**
401
+ * Get the column position of a character index.
402
+ *
403
+ * @param text - The text
404
+ * @param index - Character index (0-based)
405
+ * @returns Column position in terminal columns
406
+ */
407
+ export function indexToColumn(text: string, index: number): number {
408
+ const chars = [...text]
409
+ let column = 0
410
+
411
+ for (let i = 0; i < index && i < chars.length; i++) {
412
+ column += charWidth(chars[i]!)
413
+ }
414
+
415
+ return column
416
+ }
417
+
418
+ /**
419
+ * Get the character index at a column position.
420
+ *
421
+ * @param text - The text
422
+ * @param column - Column position
423
+ * @returns Character index (0-based), or -1 if out of bounds
424
+ */
425
+ export function columnToIndex(text: string, column: number): number {
426
+ if (column < 0) return -1
427
+
428
+ let currentCol = 0
429
+ let index = 0
430
+
431
+ for (const char of text) {
432
+ if (currentCol >= column) {
433
+ return index
434
+ }
435
+ currentCol += charWidth(char)
436
+ index++
437
+ }
438
+
439
+ // Column is beyond text end
440
+ return column <= currentCol ? index : -1
441
+ }
442
+
443
+ /**
444
+ * Pad a number with leading zeros.
445
+ */
446
+ export function padNumber(num: number, width: number): string {
447
+ return String(num).padStart(width, '0')
448
+ }
449
+
450
+ /**
451
+ * Format bytes as human-readable string.
452
+ */
453
+ export function formatBytes(bytes: number, decimals: number = 1): string {
454
+ if (bytes === 0) return '0 B'
455
+
456
+ const k = 1024
457
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
458
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
459
+
460
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`
461
+ }
462
+
463
+ /**
464
+ * Format duration as human-readable string.
465
+ */
466
+ export function formatDuration(ms: number): string {
467
+ if (ms < 1000) return `${ms}ms`
468
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
469
+ if (ms < 3600000) return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`
470
+ return `${Math.floor(ms / 3600000)}h ${Math.floor((ms % 3600000) / 60000)}m`
471
+ }