@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.
- package/README.md +141 -0
- package/index.ts +45 -0
- package/package.json +59 -0
- package/src/api/index.ts +7 -0
- package/src/api/mount.ts +230 -0
- package/src/engine/arrays/core.ts +60 -0
- package/src/engine/arrays/dimensions.ts +68 -0
- package/src/engine/arrays/index.ts +166 -0
- package/src/engine/arrays/interaction.ts +112 -0
- package/src/engine/arrays/layout.ts +175 -0
- package/src/engine/arrays/spacing.ts +100 -0
- package/src/engine/arrays/text.ts +55 -0
- package/src/engine/arrays/visual.ts +140 -0
- package/src/engine/index.ts +25 -0
- package/src/engine/inheritance.ts +138 -0
- package/src/engine/registry.ts +180 -0
- package/src/pipeline/frameBuffer.ts +473 -0
- package/src/pipeline/layout/index.ts +105 -0
- package/src/pipeline/layout/titan-engine.ts +798 -0
- package/src/pipeline/layout/types.ts +194 -0
- package/src/pipeline/layout/utils/hierarchy.ts +202 -0
- package/src/pipeline/layout/utils/math.ts +134 -0
- package/src/pipeline/layout/utils/text-measure.ts +160 -0
- package/src/pipeline/layout.ts +30 -0
- package/src/primitives/box.ts +312 -0
- package/src/primitives/index.ts +12 -0
- package/src/primitives/text.ts +199 -0
- package/src/primitives/types.ts +222 -0
- package/src/primitives/utils.ts +37 -0
- package/src/renderer/ansi.ts +625 -0
- package/src/renderer/buffer.ts +667 -0
- package/src/renderer/index.ts +40 -0
- package/src/renderer/input.ts +518 -0
- package/src/renderer/output.ts +451 -0
- package/src/state/cursor.ts +176 -0
- package/src/state/focus.ts +241 -0
- package/src/state/index.ts +43 -0
- package/src/state/keyboard.ts +771 -0
- package/src/state/mouse.ts +524 -0
- package/src/state/scroll.ts +341 -0
- package/src/state/theme.ts +687 -0
- package/src/types/color.ts +401 -0
- package/src/types/index.ts +316 -0
- 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
|
+
}
|