@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,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI Framework - Production FrameBuffer Derived
|
|
3
|
+
*
|
|
4
|
+
* Transforms layout and component arrays into a renderable FrameBuffer.
|
|
5
|
+
*
|
|
6
|
+
* Production features:
|
|
7
|
+
* - ClipRect-based clipping with intersection
|
|
8
|
+
* - Scroll offset accumulation through parent chain
|
|
9
|
+
* - Per-side border rendering (10 styles)
|
|
10
|
+
* - Color inheritance (walk up parent tree)
|
|
11
|
+
* - Opacity blending
|
|
12
|
+
* - zIndex sorting
|
|
13
|
+
* - Text wrapping and truncation
|
|
14
|
+
*
|
|
15
|
+
* This is a DERIVED - pure computation, returns data without side effects.
|
|
16
|
+
* HitGrid updates are returned as data to be applied by the render effect.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { derived, unwrap } from '@rlabs-inc/signals'
|
|
20
|
+
import type { FrameBuffer, RGBA } from '../types'
|
|
21
|
+
import { ComponentType } from '../types'
|
|
22
|
+
import { Colors, TERMINAL_DEFAULT, rgbaBlend, rgbaLerp } from '../types/color'
|
|
23
|
+
import {
|
|
24
|
+
createBuffer,
|
|
25
|
+
fillRect,
|
|
26
|
+
drawBorder,
|
|
27
|
+
drawText,
|
|
28
|
+
drawTextCentered,
|
|
29
|
+
drawTextRight,
|
|
30
|
+
createClipRect,
|
|
31
|
+
intersectClipRects,
|
|
32
|
+
type ClipRect,
|
|
33
|
+
type BorderConfig,
|
|
34
|
+
} from '../renderer/buffer'
|
|
35
|
+
import { getAllocatedIndices } from '../engine/registry'
|
|
36
|
+
import { wrapText, truncateText } from '../utils/text'
|
|
37
|
+
import {
|
|
38
|
+
getInheritedFg,
|
|
39
|
+
getInheritedBg,
|
|
40
|
+
getBorderColors,
|
|
41
|
+
getBorderStyles,
|
|
42
|
+
hasBorder,
|
|
43
|
+
getEffectiveOpacity,
|
|
44
|
+
} from '../engine/inheritance'
|
|
45
|
+
|
|
46
|
+
// Import arrays
|
|
47
|
+
import * as core from '../engine/arrays/core'
|
|
48
|
+
import * as visual from '../engine/arrays/visual'
|
|
49
|
+
import * as text from '../engine/arrays/text'
|
|
50
|
+
import * as spacing from '../engine/arrays/spacing'
|
|
51
|
+
import * as layout from '../engine/arrays/layout'
|
|
52
|
+
import * as interaction from '../engine/arrays/interaction'
|
|
53
|
+
|
|
54
|
+
// Import layout derived
|
|
55
|
+
import { layoutDerived, terminalWidth, terminalHeight, renderMode } from './layout'
|
|
56
|
+
|
|
57
|
+
// =============================================================================
|
|
58
|
+
// HIT REGION TYPE - returned as data, applied by render effect
|
|
59
|
+
// =============================================================================
|
|
60
|
+
|
|
61
|
+
export interface HitRegion {
|
|
62
|
+
x: number
|
|
63
|
+
y: number
|
|
64
|
+
width: number
|
|
65
|
+
height: number
|
|
66
|
+
componentIndex: number
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface FrameBufferResult {
|
|
70
|
+
buffer: FrameBuffer
|
|
71
|
+
hitRegions: HitRegion[]
|
|
72
|
+
terminalSize: { width: number; height: number }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// =============================================================================
|
|
76
|
+
// FRAME BUFFER DERIVED
|
|
77
|
+
// =============================================================================
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Production frameBuffer derived.
|
|
81
|
+
*
|
|
82
|
+
* Reads from:
|
|
83
|
+
* - layoutDerived (computed positions)
|
|
84
|
+
* - All visual, text, spacing, interaction arrays
|
|
85
|
+
* - terminalWidth, terminalHeight
|
|
86
|
+
*
|
|
87
|
+
* Returns:
|
|
88
|
+
* - FrameBufferResult containing buffer, hitRegions, and terminal size
|
|
89
|
+
* - hitRegions should be applied to HitGrid by the render effect (no side effects here!)
|
|
90
|
+
*/
|
|
91
|
+
export const frameBufferDerived = derived((): FrameBufferResult => {
|
|
92
|
+
const computedLayout = layoutDerived.value
|
|
93
|
+
const tw = terminalWidth.value
|
|
94
|
+
const th = terminalHeight.value
|
|
95
|
+
const mode = renderMode.value
|
|
96
|
+
|
|
97
|
+
// Collect hit regions as DATA - no side effects!
|
|
98
|
+
const hitRegions: HitRegion[] = []
|
|
99
|
+
|
|
100
|
+
// Buffer sizing depends on render mode:
|
|
101
|
+
// - Fullscreen: terminal dimensions (fixed viewport)
|
|
102
|
+
// - Inline/Append: terminal width × content height (content determines size)
|
|
103
|
+
const bufferWidth = tw
|
|
104
|
+
const bufferHeight = mode === 'fullscreen'
|
|
105
|
+
? th
|
|
106
|
+
: Math.max(1, computedLayout.contentHeight) // Use content bounds for inline/append
|
|
107
|
+
|
|
108
|
+
// Create fresh buffer with terminal default background
|
|
109
|
+
const buffer = createBuffer(bufferWidth, bufferHeight, TERMINAL_DEFAULT)
|
|
110
|
+
|
|
111
|
+
const indices = getAllocatedIndices()
|
|
112
|
+
if (indices.size === 0) {
|
|
113
|
+
return { buffer, hitRegions, terminalSize: { width: bufferWidth, height: bufferHeight } }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Find root components and build child index map
|
|
117
|
+
const rootIndices: number[] = []
|
|
118
|
+
const childMap = new Map<number, number[]>()
|
|
119
|
+
|
|
120
|
+
for (const i of indices) {
|
|
121
|
+
if (core.componentType[i] === ComponentType.NONE) continue
|
|
122
|
+
const vis = unwrap(core.visible[i])
|
|
123
|
+
if (vis === 0 || vis === false) continue
|
|
124
|
+
|
|
125
|
+
const parent = unwrap(core.parentIndex[i]) ?? -1
|
|
126
|
+
if (parent === -1) {
|
|
127
|
+
rootIndices.push(i)
|
|
128
|
+
} else {
|
|
129
|
+
const children = childMap.get(parent)
|
|
130
|
+
if (children) {
|
|
131
|
+
children.push(i)
|
|
132
|
+
} else {
|
|
133
|
+
childMap.set(parent, [i])
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Sort roots by zIndex
|
|
139
|
+
rootIndices.sort((a, b) => (unwrap(layout.zIndex[a]) || 0) - (unwrap(layout.zIndex[b]) || 0))
|
|
140
|
+
|
|
141
|
+
// Sort children by zIndex
|
|
142
|
+
for (const children of childMap.values()) {
|
|
143
|
+
children.sort((a, b) => (unwrap(layout.zIndex[a]) || 0) - (unwrap(layout.zIndex[b]) || 0))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Render tree recursively
|
|
147
|
+
for (const rootIdx of rootIndices) {
|
|
148
|
+
renderComponent(
|
|
149
|
+
buffer,
|
|
150
|
+
rootIdx,
|
|
151
|
+
computedLayout,
|
|
152
|
+
childMap,
|
|
153
|
+
hitRegions, // Pass hitRegions array to collect data
|
|
154
|
+
undefined, // No parent clip for roots
|
|
155
|
+
0, // No parent scroll Y
|
|
156
|
+
0 // No parent scroll X
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { buffer, hitRegions, terminalSize: { width: bufferWidth, height: bufferHeight } }
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
// =============================================================================
|
|
164
|
+
// RECURSIVE COMPONENT RENDERER
|
|
165
|
+
// =============================================================================
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Render a component and its children recursively.
|
|
169
|
+
* Handles clipping, scrolling, and proper z-ordering.
|
|
170
|
+
* Collects hit regions as data (no side effects).
|
|
171
|
+
*/
|
|
172
|
+
function renderComponent(
|
|
173
|
+
buffer: FrameBuffer,
|
|
174
|
+
index: number,
|
|
175
|
+
computedLayout: { x: number[]; y: number[]; width: number[]; height: number[]; scrollable: number[] },
|
|
176
|
+
childMap: Map<number, number[]>,
|
|
177
|
+
hitRegions: HitRegion[],
|
|
178
|
+
parentClip: ClipRect | undefined,
|
|
179
|
+
parentScrollY: number,
|
|
180
|
+
parentScrollX: number
|
|
181
|
+
): void {
|
|
182
|
+
// Skip invisible/invalid components
|
|
183
|
+
const vis = unwrap(core.visible[index])
|
|
184
|
+
if (vis === 0 || vis === false) return
|
|
185
|
+
if (core.componentType[index] === ComponentType.NONE) return
|
|
186
|
+
|
|
187
|
+
// Apply parent's scroll offset to this component's position
|
|
188
|
+
const x = Math.floor((computedLayout.x[index] || 0) - parentScrollX)
|
|
189
|
+
const y = Math.floor((computedLayout.y[index] || 0) - parentScrollY)
|
|
190
|
+
const w = Math.floor(computedLayout.width[index] || 0)
|
|
191
|
+
const h = Math.floor(computedLayout.height[index] || 0)
|
|
192
|
+
|
|
193
|
+
if (w <= 0 || h <= 0) return
|
|
194
|
+
|
|
195
|
+
// Create component bounds
|
|
196
|
+
const componentBounds = createClipRect(x, y, w, h)
|
|
197
|
+
|
|
198
|
+
// If parent is clipping, check if this component is visible
|
|
199
|
+
if (parentClip) {
|
|
200
|
+
const intersection = intersectClipRects(componentBounds, parentClip)
|
|
201
|
+
if (!intersection) return // Completely clipped out
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Get effective colors (with inheritance)
|
|
205
|
+
const fg = getInheritedFg(index)
|
|
206
|
+
const bg = getInheritedBg(index)
|
|
207
|
+
const opacity = getEffectiveOpacity(index)
|
|
208
|
+
|
|
209
|
+
// Apply opacity to colors
|
|
210
|
+
const effectiveFg = opacity < 1 ? { ...fg, a: Math.round(fg.a * opacity) } : fg
|
|
211
|
+
const effectiveBg = opacity < 1 ? { ...bg, a: Math.round(bg.a * opacity) } : bg
|
|
212
|
+
|
|
213
|
+
// Fill background if not transparent
|
|
214
|
+
if (effectiveBg.a > 0 && effectiveBg.r !== -1) {
|
|
215
|
+
fillRect(buffer, x, y, w, h, effectiveBg, parentClip)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Collect hit region data (applied by render effect, not here - no side effects!)
|
|
219
|
+
// Children are rendered after parents, so their regions will overwrite in order
|
|
220
|
+
hitRegions.push({ x, y, width: w, height: h, componentIndex: index })
|
|
221
|
+
|
|
222
|
+
// Get border configuration
|
|
223
|
+
const borderStyles = getBorderStyles(index)
|
|
224
|
+
const borderColors = getBorderColors(index)
|
|
225
|
+
const hasAnyBorder = hasBorder(index)
|
|
226
|
+
|
|
227
|
+
// Draw borders
|
|
228
|
+
if (hasAnyBorder && w >= 2 && h >= 2) {
|
|
229
|
+
const config: BorderConfig = {
|
|
230
|
+
styles: borderStyles,
|
|
231
|
+
colors: {
|
|
232
|
+
top: opacity < 1 ? { ...borderColors.top, a: Math.round(borderColors.top.a * opacity) } : borderColors.top,
|
|
233
|
+
right: opacity < 1 ? { ...borderColors.right, a: Math.round(borderColors.right.a * opacity) } : borderColors.right,
|
|
234
|
+
bottom: opacity < 1 ? { ...borderColors.bottom, a: Math.round(borderColors.bottom.a * opacity) } : borderColors.bottom,
|
|
235
|
+
left: opacity < 1 ? { ...borderColors.left, a: Math.round(borderColors.left.a * opacity) } : borderColors.left,
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
drawBorder(buffer, x, y, w, h, config, undefined, parentClip)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Calculate content area (inside borders and padding)
|
|
242
|
+
const padTop = (unwrap(spacing.paddingTop[index]) || 0) + (hasAnyBorder && borderStyles.top > 0 ? 1 : 0)
|
|
243
|
+
const padRight = (unwrap(spacing.paddingRight[index]) || 0) + (hasAnyBorder && borderStyles.right > 0 ? 1 : 0)
|
|
244
|
+
const padBottom = (unwrap(spacing.paddingBottom[index]) || 0) + (hasAnyBorder && borderStyles.bottom > 0 ? 1 : 0)
|
|
245
|
+
const padLeft = (unwrap(spacing.paddingLeft[index]) || 0) + (hasAnyBorder && borderStyles.left > 0 ? 1 : 0)
|
|
246
|
+
|
|
247
|
+
const contentX = x + padLeft
|
|
248
|
+
const contentY = y + padTop
|
|
249
|
+
const contentW = w - padLeft - padRight
|
|
250
|
+
const contentH = h - padTop - padBottom
|
|
251
|
+
|
|
252
|
+
// Create content clip rect (for children and text)
|
|
253
|
+
const contentBounds = createClipRect(contentX, contentY, contentW, contentH)
|
|
254
|
+
const contentClip = parentClip
|
|
255
|
+
? intersectClipRects(contentBounds, parentClip)
|
|
256
|
+
: contentBounds
|
|
257
|
+
|
|
258
|
+
if (!contentClip || contentW <= 0 || contentH <= 0) {
|
|
259
|
+
// No content area visible, but we still rendered the component itself
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Render based on component type
|
|
264
|
+
switch (core.componentType[index]) {
|
|
265
|
+
case ComponentType.BOX:
|
|
266
|
+
// Box just has background and border, already drawn
|
|
267
|
+
// Render children below
|
|
268
|
+
break
|
|
269
|
+
|
|
270
|
+
case ComponentType.TEXT:
|
|
271
|
+
renderText(buffer, index, contentX, contentY, contentW, contentH, effectiveFg, contentClip)
|
|
272
|
+
break
|
|
273
|
+
|
|
274
|
+
case ComponentType.INPUT:
|
|
275
|
+
renderInput(buffer, index, contentX, contentY, contentW, contentH, effectiveFg, contentClip)
|
|
276
|
+
break
|
|
277
|
+
|
|
278
|
+
case ComponentType.PROGRESS:
|
|
279
|
+
renderProgress(buffer, index, x, y, w, h, effectiveFg, parentClip)
|
|
280
|
+
break
|
|
281
|
+
|
|
282
|
+
case ComponentType.SELECT:
|
|
283
|
+
renderSelect(buffer, index, contentX, contentY, contentW, contentH, effectiveFg, contentClip)
|
|
284
|
+
break
|
|
285
|
+
|
|
286
|
+
// CANVAS is handled via canvasCells array if needed
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Render children (only for BOX containers)
|
|
290
|
+
if (core.componentType[index] === ComponentType.BOX) {
|
|
291
|
+
const children = childMap.get(index) || []
|
|
292
|
+
|
|
293
|
+
// Get this component's scroll offset (scrollable comes from layout, offset from interaction)
|
|
294
|
+
const isScrollable = (computedLayout.scrollable[index] ?? 0) === 1
|
|
295
|
+
const scrollY = isScrollable ? (unwrap(interaction.scrollOffsetY[index]) || 0) : 0
|
|
296
|
+
const scrollX = isScrollable ? (unwrap(interaction.scrollOffsetX[index]) || 0) : 0
|
|
297
|
+
|
|
298
|
+
// Accumulated scroll for children
|
|
299
|
+
const childScrollY = parentScrollY + scrollY
|
|
300
|
+
const childScrollX = parentScrollX + scrollX
|
|
301
|
+
|
|
302
|
+
for (const childIdx of children) {
|
|
303
|
+
renderComponent(
|
|
304
|
+
buffer,
|
|
305
|
+
childIdx,
|
|
306
|
+
computedLayout,
|
|
307
|
+
childMap,
|
|
308
|
+
hitRegions,
|
|
309
|
+
contentClip,
|
|
310
|
+
childScrollY,
|
|
311
|
+
childScrollX
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// =============================================================================
|
|
318
|
+
// COMPONENT RENDERERS
|
|
319
|
+
// =============================================================================
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Render text component content with wrapping.
|
|
323
|
+
*/
|
|
324
|
+
function renderText(
|
|
325
|
+
buffer: FrameBuffer,
|
|
326
|
+
index: number,
|
|
327
|
+
x: number,
|
|
328
|
+
y: number,
|
|
329
|
+
w: number,
|
|
330
|
+
h: number,
|
|
331
|
+
fg: RGBA,
|
|
332
|
+
clip: ClipRect
|
|
333
|
+
): void {
|
|
334
|
+
const rawValue = text.textContent[index]
|
|
335
|
+
const unwrapped = unwrap(rawValue)
|
|
336
|
+
const content = unwrapped == null ? '' : String(unwrapped)
|
|
337
|
+
if (!content) return
|
|
338
|
+
|
|
339
|
+
const attrs = unwrap(text.textAttrs[index]) || 0
|
|
340
|
+
const align = unwrap(text.textAlign[index]) || 0
|
|
341
|
+
|
|
342
|
+
// Word wrap the text
|
|
343
|
+
const lines = wrapText(content, w)
|
|
344
|
+
|
|
345
|
+
for (let lineIdx = 0; lineIdx < lines.length && lineIdx < h; lineIdx++) {
|
|
346
|
+
const line = lines[lineIdx] ?? ''
|
|
347
|
+
const lineY = y + lineIdx
|
|
348
|
+
|
|
349
|
+
// Skip if outside clip
|
|
350
|
+
if (lineY < clip.y || lineY >= clip.y + clip.height) continue
|
|
351
|
+
|
|
352
|
+
switch (align) {
|
|
353
|
+
case 0: // left
|
|
354
|
+
drawText(buffer, x, lineY, line, fg, undefined, attrs, clip)
|
|
355
|
+
break
|
|
356
|
+
case 1: // center
|
|
357
|
+
drawTextCentered(buffer, x, lineY, w, line, fg, undefined, attrs, clip)
|
|
358
|
+
break
|
|
359
|
+
case 2: // right
|
|
360
|
+
drawTextRight(buffer, x, lineY, w, line, fg, undefined, attrs, clip)
|
|
361
|
+
break
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Render input component content.
|
|
368
|
+
*/
|
|
369
|
+
function renderInput(
|
|
370
|
+
buffer: FrameBuffer,
|
|
371
|
+
index: number,
|
|
372
|
+
x: number,
|
|
373
|
+
y: number,
|
|
374
|
+
w: number,
|
|
375
|
+
h: number,
|
|
376
|
+
fg: RGBA,
|
|
377
|
+
clip: ClipRect
|
|
378
|
+
): void {
|
|
379
|
+
const content = unwrap(text.textContent[index]) || ''
|
|
380
|
+
const attrs = unwrap(text.textAttrs[index]) || 0
|
|
381
|
+
const cursorPos = unwrap(interaction.cursorPosition[index]) || 0
|
|
382
|
+
|
|
383
|
+
if (w <= 0) return
|
|
384
|
+
|
|
385
|
+
// Calculate visible portion of text (scroll to keep cursor visible)
|
|
386
|
+
let displayText = content
|
|
387
|
+
let displayOffset = 0
|
|
388
|
+
|
|
389
|
+
if (content.length > w) {
|
|
390
|
+
// Scroll to keep cursor in view
|
|
391
|
+
if (cursorPos > w - 1) {
|
|
392
|
+
displayOffset = cursorPos - w + 1
|
|
393
|
+
}
|
|
394
|
+
displayText = content.slice(displayOffset, displayOffset + w)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Draw input text
|
|
398
|
+
drawText(buffer, x, y, displayText, fg, undefined, attrs, clip)
|
|
399
|
+
|
|
400
|
+
// Draw cursor if focused
|
|
401
|
+
if (interaction.focusedIndex.value === index) {
|
|
402
|
+
const cursorX = x + Math.min(cursorPos - displayOffset, w - 1)
|
|
403
|
+
if (cursorX >= clip.x && cursorX < clip.x + clip.width && y >= clip.y && y < clip.y + clip.height) {
|
|
404
|
+
// Draw cursor as inverse block
|
|
405
|
+
const cell = buffer.cells[y]?.[cursorX]
|
|
406
|
+
if (cell) {
|
|
407
|
+
const cursorChar = content[cursorPos] || ' '
|
|
408
|
+
// Swap fg/bg for cursor visibility
|
|
409
|
+
cell.char = cursorChar.codePointAt(0) ?? 32
|
|
410
|
+
cell.fg = getInheritedBg(index)
|
|
411
|
+
cell.bg = fg
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Render progress component.
|
|
419
|
+
*/
|
|
420
|
+
function renderProgress(
|
|
421
|
+
buffer: FrameBuffer,
|
|
422
|
+
index: number,
|
|
423
|
+
x: number,
|
|
424
|
+
y: number,
|
|
425
|
+
w: number,
|
|
426
|
+
h: number,
|
|
427
|
+
fg: RGBA,
|
|
428
|
+
clip?: ClipRect
|
|
429
|
+
): void {
|
|
430
|
+
const valueStr = unwrap(text.textContent[index]) || '0'
|
|
431
|
+
const progress = Math.max(0, Math.min(1, parseFloat(valueStr) || 0))
|
|
432
|
+
const filled = Math.round(progress * w)
|
|
433
|
+
|
|
434
|
+
const dimFg = { ...fg, a: Math.floor(fg.a * 0.3) }
|
|
435
|
+
|
|
436
|
+
for (let px = 0; px < w; px++) {
|
|
437
|
+
const cellX = x + px
|
|
438
|
+
if (clip && (cellX < clip.x || cellX >= clip.x + clip.width)) continue
|
|
439
|
+
|
|
440
|
+
const isFilled = px < filled
|
|
441
|
+
const char = isFilled ? '█' : '░'
|
|
442
|
+
const color = isFilled ? fg : dimFg
|
|
443
|
+
|
|
444
|
+
drawText(buffer, cellX, y, char, color, undefined, 0, clip)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Render select component.
|
|
450
|
+
*/
|
|
451
|
+
function renderSelect(
|
|
452
|
+
buffer: FrameBuffer,
|
|
453
|
+
index: number,
|
|
454
|
+
x: number,
|
|
455
|
+
y: number,
|
|
456
|
+
w: number,
|
|
457
|
+
h: number,
|
|
458
|
+
fg: RGBA,
|
|
459
|
+
clip: ClipRect
|
|
460
|
+
): void {
|
|
461
|
+
const content = unwrap(text.textContent[index]) || ''
|
|
462
|
+
const attrs = unwrap(text.textAttrs[index]) || 0
|
|
463
|
+
|
|
464
|
+
// For now, just show current selection
|
|
465
|
+
const displayText = truncateText(content, w - 2) // Leave room for dropdown indicator
|
|
466
|
+
|
|
467
|
+
drawText(buffer, x, y, displayText, fg, undefined, attrs, clip)
|
|
468
|
+
|
|
469
|
+
// Draw dropdown indicator
|
|
470
|
+
if (w > 2) {
|
|
471
|
+
drawText(buffer, x + w - 2, y, '▼', fg, undefined, 0, clip)
|
|
472
|
+
}
|
|
473
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI Framework - Layout Module
|
|
3
|
+
*
|
|
4
|
+
* THE terminal-native layout system - TITAN ENGINE.
|
|
5
|
+
*
|
|
6
|
+
* Features:
|
|
7
|
+
* - Block layout (vertical stacking)
|
|
8
|
+
* - Complete Flexbox (grow/shrink/wrap/justify/align)
|
|
9
|
+
* - Absolute/Fixed positioning
|
|
10
|
+
* - Beats Yoga by trusting fine-grained reactivity
|
|
11
|
+
*
|
|
12
|
+
* Philosophy:
|
|
13
|
+
* - Read from existing arrays (triggers reactivity)
|
|
14
|
+
* - Compute in minimal passes
|
|
15
|
+
* - Return plain output arrays
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { derived, signal } from '@rlabs-inc/signals'
|
|
19
|
+
import { getAllocatedIndices } from '../../engine/registry'
|
|
20
|
+
import { computeLayoutTitan, resetTitanArrays } from './titan-engine'
|
|
21
|
+
import type { ComputedLayout } from './types'
|
|
22
|
+
import type { RenderMode } from '../../types'
|
|
23
|
+
|
|
24
|
+
// Re-export reset function for memory cleanup
|
|
25
|
+
export { resetTitanArrays }
|
|
26
|
+
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// RENDER MODE
|
|
29
|
+
// =============================================================================
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Current render mode.
|
|
33
|
+
* - fullscreen: Alt screen buffer, fixed terminal dimensions
|
|
34
|
+
* - inline: Normal buffer, content-determined height, terminal scroll works
|
|
35
|
+
* - append: Like inline, but for CLI-style appending output
|
|
36
|
+
*/
|
|
37
|
+
export const renderMode = signal<RenderMode>('fullscreen')
|
|
38
|
+
|
|
39
|
+
// =============================================================================
|
|
40
|
+
// TERMINAL SIZE
|
|
41
|
+
// =============================================================================
|
|
42
|
+
|
|
43
|
+
export const terminalWidth = signal(process.stdout.columns || 80)
|
|
44
|
+
export const terminalHeight = signal(process.stdout.rows || 24)
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Update terminal size from process.stdout.
|
|
48
|
+
* Called on resize events.
|
|
49
|
+
*/
|
|
50
|
+
export function updateTerminalSize(): void {
|
|
51
|
+
const w = process.stdout.columns || 80
|
|
52
|
+
const h = process.stdout.rows || 24
|
|
53
|
+
|
|
54
|
+
if (w !== terminalWidth.value || h !== terminalHeight.value) {
|
|
55
|
+
terminalWidth.value = w
|
|
56
|
+
terminalHeight.value = h
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// =============================================================================
|
|
61
|
+
// LAYOUT DERIVED
|
|
62
|
+
// =============================================================================
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The main layout derived.
|
|
66
|
+
*
|
|
67
|
+
* Reads from all component arrays and produces computed positions/sizes.
|
|
68
|
+
* Automatically re-runs when any dependency changes.
|
|
69
|
+
*
|
|
70
|
+
* This is where the magic happens - reactive layout computation!
|
|
71
|
+
*/
|
|
72
|
+
export const layoutDerived = derived((): ComputedLayout => {
|
|
73
|
+
// Read terminal size (creates dependency)
|
|
74
|
+
const tw = terminalWidth.value
|
|
75
|
+
const th = terminalHeight.value
|
|
76
|
+
|
|
77
|
+
// Read render mode (creates dependency)
|
|
78
|
+
const mode = renderMode.value
|
|
79
|
+
|
|
80
|
+
// Get all allocated indices (creates dependency on component add/remove)
|
|
81
|
+
const indices = getAllocatedIndices()
|
|
82
|
+
|
|
83
|
+
// Constrain height only in fullscreen mode
|
|
84
|
+
// Inline/append modes let content determine its own height
|
|
85
|
+
const constrainHeight = mode === 'fullscreen'
|
|
86
|
+
|
|
87
|
+
// TITAN ENGINE: Read arrays, compute, return.
|
|
88
|
+
// Reactivity tracks dependencies as we read - no manual tracking needed.
|
|
89
|
+
return computeLayoutTitan(tw, th, indices, constrainHeight)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// =============================================================================
|
|
93
|
+
// EXPORTS
|
|
94
|
+
// =============================================================================
|
|
95
|
+
|
|
96
|
+
export type { ComputedLayout } from './types'
|
|
97
|
+
export {
|
|
98
|
+
FlexDirection,
|
|
99
|
+
FlexWrap,
|
|
100
|
+
JustifyContent,
|
|
101
|
+
AlignItems,
|
|
102
|
+
Position,
|
|
103
|
+
Display,
|
|
104
|
+
Overflow,
|
|
105
|
+
} from './types'
|