@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,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'
|