@rlabs-inc/tui 0.1.0 → 0.2.1
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 +126 -13
- package/index.ts +11 -5
- package/package.json +2 -2
- package/src/api/history.ts +451 -0
- package/src/api/mount.ts +66 -31
- package/src/engine/arrays/core.ts +13 -21
- package/src/engine/arrays/dimensions.ts +22 -32
- package/src/engine/arrays/index.ts +88 -86
- package/src/engine/arrays/interaction.ts +34 -48
- package/src/engine/arrays/layout.ts +67 -92
- package/src/engine/arrays/spacing.ts +37 -52
- package/src/engine/arrays/text.ts +23 -31
- package/src/engine/arrays/visual.ts +56 -75
- package/src/engine/inheritance.ts +18 -18
- package/src/engine/registry.ts +15 -0
- package/src/pipeline/frameBuffer.ts +26 -26
- package/src/pipeline/layout/index.ts +2 -2
- package/src/pipeline/layout/titan-engine.ts +112 -84
- package/src/primitives/animation.ts +194 -0
- package/src/primitives/box.ts +74 -86
- package/src/primitives/each.ts +87 -0
- package/src/primitives/index.ts +7 -0
- package/src/primitives/scope.ts +215 -0
- package/src/primitives/show.ts +77 -0
- package/src/primitives/text.ts +63 -59
- package/src/primitives/types.ts +1 -1
- package/src/primitives/when.ts +102 -0
- package/src/renderer/append-region.ts +159 -0
- package/src/renderer/index.ts +4 -2
- package/src/renderer/output.ts +11 -34
- package/src/state/focus.ts +16 -5
- package/src/state/global-keys.ts +184 -0
- package/src/state/index.ts +44 -8
- package/src/state/input.ts +534 -0
- package/src/state/keyboard.ts +98 -674
- package/src/state/mouse.ts +163 -340
- package/src/state/scroll.ts +7 -9
- package/src/types/index.ts +23 -2
- package/src/renderer/input.ts +0 -518
package/README.md
CHANGED
|
@@ -57,17 +57,6 @@ setInterval(() => count.value++, 1000)
|
|
|
57
57
|
- Bindings for prop connections
|
|
58
58
|
- Zero reconciliation - reactivity IS the update mechanism
|
|
59
59
|
|
|
60
|
-
### Svelte-like .tui Files (Compiler)
|
|
61
|
-
```html
|
|
62
|
-
<script>
|
|
63
|
-
const name = signal('World')
|
|
64
|
-
</script>
|
|
65
|
-
|
|
66
|
-
<box width={40} height={3}>
|
|
67
|
-
<text content={`Hello, ${name.value}!`} />
|
|
68
|
-
</box>
|
|
69
|
-
```
|
|
70
|
-
|
|
71
60
|
### State Modules
|
|
72
61
|
- **keyboard** - Key events, shortcuts, input buffering
|
|
73
62
|
- **mouse** - Click, hover, drag, wheel events
|
|
@@ -110,6 +99,8 @@ bun run examples/tests/03-layout-flex.ts
|
|
|
110
99
|
|
|
111
100
|
## Primitives
|
|
112
101
|
|
|
102
|
+
### UI Primitives
|
|
103
|
+
|
|
113
104
|
| Primitive | Status | Description |
|
|
114
105
|
|-----------|--------|-------------|
|
|
115
106
|
| `box` | Complete | Container with flexbox layout |
|
|
@@ -118,13 +109,135 @@ bun run examples/tests/03-layout-flex.ts
|
|
|
118
109
|
| `select` | Planned | Dropdown selection |
|
|
119
110
|
| `progress` | Planned | Progress bar |
|
|
120
111
|
|
|
112
|
+
### Template Primitives
|
|
113
|
+
|
|
114
|
+
Reactive control flow for dynamic UIs - no manual effects needed!
|
|
115
|
+
|
|
116
|
+
| Primitive | Purpose | Description |
|
|
117
|
+
|-----------|---------|-------------|
|
|
118
|
+
| `each()` | Lists | Reactive list rendering with keyed reconciliation |
|
|
119
|
+
| `show()` | Conditionals | Show/hide components based on condition |
|
|
120
|
+
| `when()` | Async | Suspense-like pending/success/error states |
|
|
121
|
+
|
|
122
|
+
#### `each()` - Reactive Lists
|
|
123
|
+
|
|
124
|
+
Renders a list of components that automatically updates when the array changes.
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
import { each, box, text, signal } from '@rlabs-inc/tui'
|
|
128
|
+
|
|
129
|
+
const todos = signal([
|
|
130
|
+
{ id: '1', text: 'Learn TUI', done: false },
|
|
131
|
+
{ id: '2', text: 'Build app', done: false },
|
|
132
|
+
])
|
|
133
|
+
|
|
134
|
+
box({
|
|
135
|
+
children: () => {
|
|
136
|
+
each(
|
|
137
|
+
() => todos.value, // Reactive array getter
|
|
138
|
+
(todo) => box({ // Render function per item
|
|
139
|
+
id: `todo-${todo.id}`, // Stable ID for reconciliation
|
|
140
|
+
children: () => {
|
|
141
|
+
text({ content: () => todo.text }) // Props can be reactive too!
|
|
142
|
+
}
|
|
143
|
+
}),
|
|
144
|
+
{ key: (todo) => todo.id } // Key function for efficient updates
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// Add item - UI updates automatically
|
|
150
|
+
todos.value = [...todos.value, { id: '3', text: 'Deploy', done: false }]
|
|
151
|
+
|
|
152
|
+
// Remove item - component is cleaned up automatically
|
|
153
|
+
todos.value = todos.value.filter(t => t.id !== '1')
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
#### `show()` - Conditional Rendering
|
|
157
|
+
|
|
158
|
+
Shows or hides components based on a reactive condition.
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
import { show, box, text, signal } from '@rlabs-inc/tui'
|
|
162
|
+
|
|
163
|
+
const isLoggedIn = signal(false)
|
|
164
|
+
|
|
165
|
+
box({
|
|
166
|
+
children: () => {
|
|
167
|
+
show(
|
|
168
|
+
() => isLoggedIn.value, // Condition getter
|
|
169
|
+
() => box({ // Render when true
|
|
170
|
+
children: () => {
|
|
171
|
+
text({ content: 'Welcome back!' })
|
|
172
|
+
}
|
|
173
|
+
}),
|
|
174
|
+
() => text({ content: 'Please log in' }) // Optional: render when false
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// Toggle - UI switches automatically
|
|
180
|
+
isLoggedIn.value = true
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
#### `when()` - Async/Suspense
|
|
184
|
+
|
|
185
|
+
Handles async operations with loading, success, and error states.
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
import { when, box, text, signal } from '@rlabs-inc/tui'
|
|
189
|
+
|
|
190
|
+
const userId = signal('123')
|
|
191
|
+
|
|
192
|
+
// Fetch function that returns a promise
|
|
193
|
+
const fetchUser = (id: string) =>
|
|
194
|
+
fetch(`/api/users/${id}`).then(r => r.json())
|
|
195
|
+
|
|
196
|
+
box({
|
|
197
|
+
children: () => {
|
|
198
|
+
when(
|
|
199
|
+
() => fetchUser(userId.value), // Promise getter (re-runs on userId change)
|
|
200
|
+
{
|
|
201
|
+
pending: () => text({ content: 'Loading...' }),
|
|
202
|
+
then: (user) => box({
|
|
203
|
+
children: () => {
|
|
204
|
+
text({ content: `Name: ${user.name}` })
|
|
205
|
+
text({ content: `Email: ${user.email}` })
|
|
206
|
+
}
|
|
207
|
+
}),
|
|
208
|
+
catch: (error) => text({
|
|
209
|
+
content: `Error: ${error.message}`,
|
|
210
|
+
fg: 'red'
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
// Change userId - triggers new fetch, shows loading, then result
|
|
218
|
+
userId.value = '456'
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### How Template Primitives Work
|
|
222
|
+
|
|
223
|
+
All template primitives follow the same elegant pattern:
|
|
224
|
+
|
|
225
|
+
1. **Capture parent context** at creation time
|
|
226
|
+
2. **Initial render synchronously** (correct parent hierarchy)
|
|
227
|
+
3. **Internal effect** tracks reactive dependencies
|
|
228
|
+
4. **Reconcile on change** (create new, cleanup removed)
|
|
229
|
+
|
|
230
|
+
This means:
|
|
231
|
+
- User code stays clean - no manual effects
|
|
232
|
+
- Props inside templates are fully reactive
|
|
233
|
+
- Cleanup is automatic
|
|
234
|
+
- Performance is optimal (only affected components update)
|
|
235
|
+
|
|
121
236
|
## Test Coverage
|
|
122
237
|
|
|
123
|
-
- **130 tests** passing
|
|
124
238
|
- TITAN layout engine: 48 tests
|
|
125
239
|
- Parallel arrays: 17 tests
|
|
126
240
|
- Focus manager: 29 tests
|
|
127
|
-
- Compiler: 36 tests (unit + integration)
|
|
128
241
|
|
|
129
242
|
## Requirements
|
|
130
243
|
|
package/index.ts
CHANGED
|
@@ -9,21 +9,24 @@
|
|
|
9
9
|
export { mount } from './src/api'
|
|
10
10
|
|
|
11
11
|
// Primitives - UI building blocks
|
|
12
|
-
export { box, text } from './src/primitives'
|
|
13
|
-
export type { BoxProps, TextProps, Cleanup } from './src/primitives'
|
|
12
|
+
export { box, text, each, show, when, scoped, onCleanup, useAnimation, AnimationFrames } from './src/primitives'
|
|
13
|
+
export type { BoxProps, TextProps, Cleanup, AnimationOptions } from './src/primitives'
|
|
14
14
|
|
|
15
15
|
// State modules - Input handling
|
|
16
|
-
export { keyboard } from './src/state/keyboard'
|
|
16
|
+
export { keyboard, lastKey, lastEvent } from './src/state/keyboard'
|
|
17
|
+
export { mouse, hitGrid, lastMouseEvent, mouseX, mouseY, isMouseDown } from './src/state/mouse'
|
|
17
18
|
export { focusManager, focusedIndex } from './src/state/focus'
|
|
18
19
|
export { scroll } from './src/state/scroll'
|
|
19
|
-
export {
|
|
20
|
+
export { globalKeys } from './src/state/global-keys'
|
|
21
|
+
export { cursor } from './src/state/cursor'
|
|
20
22
|
|
|
21
23
|
// Types
|
|
22
24
|
export * from './src/types'
|
|
23
25
|
export * from './src/types/color'
|
|
24
26
|
|
|
25
27
|
// Signals re-export for convenience
|
|
26
|
-
export { signal, state, derived, effect, bind, signals } from '@rlabs-inc/signals'
|
|
28
|
+
export { signal, state, derived, effect, bind, signals, batch, reactiveProps } from '@rlabs-inc/signals'
|
|
29
|
+
export type { PropInput, PropsInput, ReactiveProps } from '@rlabs-inc/signals'
|
|
27
30
|
|
|
28
31
|
// Theme
|
|
29
32
|
export {
|
|
@@ -43,3 +46,6 @@ export * from './src/renderer'
|
|
|
43
46
|
|
|
44
47
|
// Engine (advanced)
|
|
45
48
|
export * from './src/engine'
|
|
49
|
+
|
|
50
|
+
// Layout (advanced - for debugging)
|
|
51
|
+
export { layoutDerived } from './src/pipeline/layout'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rlabs-inc/tui",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "The Terminal UI Framework for TypeScript/Bun - Blazing-fast, fine-grained reactive terminal UI with complete flexbox layout",
|
|
5
5
|
"module": "index.ts",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -54,6 +54,6 @@
|
|
|
54
54
|
"typescript": "^5.0.0"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@rlabs-inc/signals": "^1.
|
|
57
|
+
"@rlabs-inc/signals": "^1.8.2"
|
|
58
58
|
}
|
|
59
59
|
}
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI Framework - History Rendering for Append Mode
|
|
3
|
+
*
|
|
4
|
+
* Provides utilities for rendering content to terminal history (scrollback).
|
|
5
|
+
* Used in append mode where completed content is frozen to history while
|
|
6
|
+
* active content remains reactive.
|
|
7
|
+
*
|
|
8
|
+
* Key concepts:
|
|
9
|
+
* - History content is written once via FileSink (Bun's efficient stdout writer)
|
|
10
|
+
* - Uses the same component API as active rendering
|
|
11
|
+
* - Isolated rendering: creates temporary components, renders, cleans up
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { batch } from '@rlabs-inc/signals'
|
|
15
|
+
import type { FrameBuffer, RGBA } from '../types'
|
|
16
|
+
import { ComponentType } from '../types'
|
|
17
|
+
import { Colors, TERMINAL_DEFAULT } from '../types/color'
|
|
18
|
+
import {
|
|
19
|
+
createBuffer,
|
|
20
|
+
fillRect,
|
|
21
|
+
drawBorder,
|
|
22
|
+
drawText,
|
|
23
|
+
drawTextCentered,
|
|
24
|
+
drawTextRight,
|
|
25
|
+
createClipRect,
|
|
26
|
+
intersectClipRects,
|
|
27
|
+
type ClipRect,
|
|
28
|
+
type BorderConfig,
|
|
29
|
+
} from '../renderer/buffer'
|
|
30
|
+
import {
|
|
31
|
+
getAllocatedIndices,
|
|
32
|
+
getCapacity,
|
|
33
|
+
releaseIndex,
|
|
34
|
+
} from '../engine/registry'
|
|
35
|
+
import { wrapText, truncateText } from '../utils/text'
|
|
36
|
+
import {
|
|
37
|
+
getInheritedFg,
|
|
38
|
+
getInheritedBg,
|
|
39
|
+
getBorderColors,
|
|
40
|
+
getBorderStyles,
|
|
41
|
+
hasBorder,
|
|
42
|
+
getEffectiveOpacity,
|
|
43
|
+
} from '../engine/inheritance'
|
|
44
|
+
import { computeLayoutTitan } from '../pipeline/layout/titan-engine'
|
|
45
|
+
import { terminalWidth, terminalHeight } from '../pipeline/layout'
|
|
46
|
+
import * as ansi from '../renderer/ansi'
|
|
47
|
+
|
|
48
|
+
// Import arrays
|
|
49
|
+
import * as core from '../engine/arrays/core'
|
|
50
|
+
import * as visual from '../engine/arrays/visual'
|
|
51
|
+
import * as text from '../engine/arrays/text'
|
|
52
|
+
import * as spacing from '../engine/arrays/spacing'
|
|
53
|
+
import * as layout from '../engine/arrays/layout'
|
|
54
|
+
import * as interaction from '../engine/arrays/interaction'
|
|
55
|
+
import { Attr } from '../types'
|
|
56
|
+
import { rgbaEqual } from '../types/color'
|
|
57
|
+
|
|
58
|
+
// =============================================================================
|
|
59
|
+
// FILESINK WRITER
|
|
60
|
+
// =============================================================================
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Writer for appending to terminal stdout using Bun's FileSink API.
|
|
64
|
+
* Buffers writes and flushes efficiently.
|
|
65
|
+
*/
|
|
66
|
+
export class HistoryWriter {
|
|
67
|
+
private writer: ReturnType<ReturnType<typeof Bun.file>['writer']>
|
|
68
|
+
private hasContent = false
|
|
69
|
+
|
|
70
|
+
constructor() {
|
|
71
|
+
// Create writer for stdout (file descriptor 1)
|
|
72
|
+
const stdoutFile = Bun.file(1)
|
|
73
|
+
this.writer = stdoutFile.writer({ highWaterMark: 1024 * 1024 }) // 1MB buffer
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
write(content: string): void {
|
|
77
|
+
if (content.length === 0) return
|
|
78
|
+
this.writer.write(content)
|
|
79
|
+
this.hasContent = true
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
flush(): void {
|
|
83
|
+
if (this.hasContent) {
|
|
84
|
+
this.writer.flush()
|
|
85
|
+
this.hasContent = false
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
end(): void {
|
|
90
|
+
this.writer.end()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// =============================================================================
|
|
95
|
+
// BUFFER TO ANSI CONVERSION
|
|
96
|
+
// =============================================================================
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Convert a FrameBuffer to ANSI escape sequence string.
|
|
100
|
+
*/
|
|
101
|
+
function bufferToAnsi(buffer: FrameBuffer): string {
|
|
102
|
+
if (buffer.height === 0) return ''
|
|
103
|
+
|
|
104
|
+
const chunks: string[] = []
|
|
105
|
+
|
|
106
|
+
// Track last colors/attrs for optimization
|
|
107
|
+
let lastFg: RGBA | null = null
|
|
108
|
+
let lastBg: RGBA | null = null
|
|
109
|
+
let lastAttrs: number = Attr.NONE
|
|
110
|
+
|
|
111
|
+
for (let y = 0; y < buffer.height; y++) {
|
|
112
|
+
for (let x = 0; x < buffer.width; x++) {
|
|
113
|
+
const cell = buffer.cells[y]![x]!
|
|
114
|
+
|
|
115
|
+
// Attributes changed - reset first
|
|
116
|
+
if (cell.attrs !== lastAttrs) {
|
|
117
|
+
chunks.push(ansi.reset)
|
|
118
|
+
if (cell.attrs !== Attr.NONE) {
|
|
119
|
+
chunks.push(ansi.attrs(cell.attrs))
|
|
120
|
+
}
|
|
121
|
+
lastFg = null
|
|
122
|
+
lastBg = null
|
|
123
|
+
lastAttrs = cell.attrs
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Foreground color changed
|
|
127
|
+
if (!lastFg || !rgbaEqual(cell.fg, lastFg)) {
|
|
128
|
+
chunks.push(ansi.fg(cell.fg))
|
|
129
|
+
lastFg = cell.fg
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Background color changed
|
|
133
|
+
if (!lastBg || !rgbaEqual(cell.bg, lastBg)) {
|
|
134
|
+
chunks.push(ansi.bg(cell.bg))
|
|
135
|
+
lastBg = cell.bg
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Output character
|
|
139
|
+
if (cell.char === 0) {
|
|
140
|
+
chunks.push(' ')
|
|
141
|
+
} else {
|
|
142
|
+
chunks.push(String.fromCodePoint(cell.char))
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
chunks.push('\n')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
chunks.push(ansi.reset)
|
|
149
|
+
|
|
150
|
+
return chunks.join('')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// =============================================================================
|
|
154
|
+
// ISOLATED FRAME BUFFER COMPUTATION
|
|
155
|
+
// =============================================================================
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Compute a frame buffer for a specific set of component indices.
|
|
159
|
+
* Used for isolated history rendering.
|
|
160
|
+
*/
|
|
161
|
+
function computeBufferForIndices(
|
|
162
|
+
indices: Set<number>,
|
|
163
|
+
layoutResult: ReturnType<typeof computeLayoutTitan>,
|
|
164
|
+
tw: number
|
|
165
|
+
): FrameBuffer {
|
|
166
|
+
const bufferWidth = tw
|
|
167
|
+
const bufferHeight = Math.max(1, layoutResult.contentHeight)
|
|
168
|
+
|
|
169
|
+
// Create fresh buffer
|
|
170
|
+
const buffer = createBuffer(bufferWidth, bufferHeight, TERMINAL_DEFAULT)
|
|
171
|
+
|
|
172
|
+
if (indices.size === 0) {
|
|
173
|
+
return buffer
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Find root components and build child index map (only for our indices)
|
|
177
|
+
const rootIndices: number[] = []
|
|
178
|
+
const childMap = new Map<number, number[]>()
|
|
179
|
+
|
|
180
|
+
for (const i of indices) {
|
|
181
|
+
if (core.componentType[i] === ComponentType.NONE) continue
|
|
182
|
+
const vis = core.visible[i]
|
|
183
|
+
if (vis === 0 || vis === false) continue
|
|
184
|
+
|
|
185
|
+
const parent = core.parentIndex[i] ?? -1
|
|
186
|
+
if (parent === -1 || !indices.has(parent)) {
|
|
187
|
+
// Root if no parent or parent not in our set
|
|
188
|
+
rootIndices.push(i)
|
|
189
|
+
} else {
|
|
190
|
+
const children = childMap.get(parent)
|
|
191
|
+
if (children) {
|
|
192
|
+
children.push(i)
|
|
193
|
+
} else {
|
|
194
|
+
childMap.set(parent, [i])
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Sort by zIndex
|
|
200
|
+
rootIndices.sort((a, b) => (layout.zIndex[a] || 0) - (layout.zIndex[b] || 0))
|
|
201
|
+
for (const children of childMap.values()) {
|
|
202
|
+
children.sort((a, b) => (layout.zIndex[a] || 0) - (layout.zIndex[b] || 0))
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Render tree recursively
|
|
206
|
+
for (const rootIdx of rootIndices) {
|
|
207
|
+
renderComponentToBuffer(
|
|
208
|
+
buffer,
|
|
209
|
+
rootIdx,
|
|
210
|
+
layoutResult,
|
|
211
|
+
childMap,
|
|
212
|
+
undefined,
|
|
213
|
+
0,
|
|
214
|
+
0
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return buffer
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Render a component and its children to a buffer.
|
|
223
|
+
*/
|
|
224
|
+
function renderComponentToBuffer(
|
|
225
|
+
buffer: FrameBuffer,
|
|
226
|
+
index: number,
|
|
227
|
+
computedLayout: { x: number[]; y: number[]; width: number[]; height: number[]; scrollable: number[] },
|
|
228
|
+
childMap: Map<number, number[]>,
|
|
229
|
+
parentClip: ClipRect | undefined,
|
|
230
|
+
parentScrollY: number,
|
|
231
|
+
parentScrollX: number
|
|
232
|
+
): void {
|
|
233
|
+
const vis = core.visible[index]
|
|
234
|
+
if (vis === 0 || vis === false) return
|
|
235
|
+
if (core.componentType[index] === ComponentType.NONE) return
|
|
236
|
+
|
|
237
|
+
const x = Math.floor((computedLayout.x[index] || 0) - parentScrollX)
|
|
238
|
+
const y = Math.floor((computedLayout.y[index] || 0) - parentScrollY)
|
|
239
|
+
const w = Math.floor(computedLayout.width[index] || 0)
|
|
240
|
+
const h = Math.floor(computedLayout.height[index] || 0)
|
|
241
|
+
|
|
242
|
+
if (w <= 0 || h <= 0) return
|
|
243
|
+
|
|
244
|
+
const componentBounds = createClipRect(x, y, w, h)
|
|
245
|
+
|
|
246
|
+
if (parentClip) {
|
|
247
|
+
const intersection = intersectClipRects(componentBounds, parentClip)
|
|
248
|
+
if (!intersection) return
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Get colors
|
|
252
|
+
const fg = getInheritedFg(index)
|
|
253
|
+
const bg = getInheritedBg(index)
|
|
254
|
+
const opacity = getEffectiveOpacity(index)
|
|
255
|
+
|
|
256
|
+
const effectiveFg = opacity < 1 ? { ...fg, a: Math.round(fg.a * opacity) } : fg
|
|
257
|
+
const effectiveBg = opacity < 1 ? { ...bg, a: Math.round(bg.a * opacity) } : bg
|
|
258
|
+
|
|
259
|
+
// Fill background
|
|
260
|
+
if (effectiveBg.a > 0 && effectiveBg.r !== -1) {
|
|
261
|
+
fillRect(buffer, x, y, w, h, effectiveBg, parentClip)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Borders
|
|
265
|
+
const borderStyles = getBorderStyles(index)
|
|
266
|
+
const borderColors = getBorderColors(index)
|
|
267
|
+
const hasAnyBorder = hasBorder(index)
|
|
268
|
+
|
|
269
|
+
if (hasAnyBorder && w >= 2 && h >= 2) {
|
|
270
|
+
const config: BorderConfig = {
|
|
271
|
+
styles: borderStyles,
|
|
272
|
+
colors: {
|
|
273
|
+
top: opacity < 1 ? { ...borderColors.top, a: Math.round(borderColors.top.a * opacity) } : borderColors.top,
|
|
274
|
+
right: opacity < 1 ? { ...borderColors.right, a: Math.round(borderColors.right.a * opacity) } : borderColors.right,
|
|
275
|
+
bottom: opacity < 1 ? { ...borderColors.bottom, a: Math.round(borderColors.bottom.a * opacity) } : borderColors.bottom,
|
|
276
|
+
left: opacity < 1 ? { ...borderColors.left, a: Math.round(borderColors.left.a * opacity) } : borderColors.left,
|
|
277
|
+
},
|
|
278
|
+
}
|
|
279
|
+
drawBorder(buffer, x, y, w, h, config, undefined, parentClip)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Content area
|
|
283
|
+
const padTop = (spacing.paddingTop[index] || 0) + (hasAnyBorder && borderStyles.top > 0 ? 1 : 0)
|
|
284
|
+
const padRight = (spacing.paddingRight[index] || 0) + (hasAnyBorder && borderStyles.right > 0 ? 1 : 0)
|
|
285
|
+
const padBottom = (spacing.paddingBottom[index] || 0) + (hasAnyBorder && borderStyles.bottom > 0 ? 1 : 0)
|
|
286
|
+
const padLeft = (spacing.paddingLeft[index] || 0) + (hasAnyBorder && borderStyles.left > 0 ? 1 : 0)
|
|
287
|
+
|
|
288
|
+
const contentX = x + padLeft
|
|
289
|
+
const contentY = y + padTop
|
|
290
|
+
const contentW = w - padLeft - padRight
|
|
291
|
+
const contentH = h - padTop - padBottom
|
|
292
|
+
|
|
293
|
+
const contentBounds = createClipRect(contentX, contentY, contentW, contentH)
|
|
294
|
+
const contentClip = parentClip
|
|
295
|
+
? intersectClipRects(contentBounds, parentClip)
|
|
296
|
+
: contentBounds
|
|
297
|
+
|
|
298
|
+
if (!contentClip || contentW <= 0 || contentH <= 0) {
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Render by type
|
|
303
|
+
switch (core.componentType[index]) {
|
|
304
|
+
case ComponentType.BOX:
|
|
305
|
+
break
|
|
306
|
+
|
|
307
|
+
case ComponentType.TEXT:
|
|
308
|
+
renderTextToBuffer(buffer, index, contentX, contentY, contentW, contentH, effectiveFg, contentClip)
|
|
309
|
+
break
|
|
310
|
+
|
|
311
|
+
// Other types can be added as needed
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Render children
|
|
315
|
+
if (core.componentType[index] === ComponentType.BOX) {
|
|
316
|
+
const children = childMap.get(index) || []
|
|
317
|
+
const isScrollable = (computedLayout.scrollable[index] ?? 0) === 1
|
|
318
|
+
const scrollY = isScrollable ? (interaction.scrollOffsetY[index] || 0) : 0
|
|
319
|
+
const scrollX = isScrollable ? (interaction.scrollOffsetX[index] || 0) : 0
|
|
320
|
+
|
|
321
|
+
for (const childIdx of children) {
|
|
322
|
+
renderComponentToBuffer(
|
|
323
|
+
buffer,
|
|
324
|
+
childIdx,
|
|
325
|
+
computedLayout,
|
|
326
|
+
childMap,
|
|
327
|
+
contentClip,
|
|
328
|
+
parentScrollY + scrollY,
|
|
329
|
+
parentScrollX + scrollX
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Render text component to buffer.
|
|
337
|
+
*/
|
|
338
|
+
function renderTextToBuffer(
|
|
339
|
+
buffer: FrameBuffer,
|
|
340
|
+
index: number,
|
|
341
|
+
x: number,
|
|
342
|
+
y: number,
|
|
343
|
+
w: number,
|
|
344
|
+
h: number,
|
|
345
|
+
fg: RGBA,
|
|
346
|
+
clip: ClipRect
|
|
347
|
+
): void {
|
|
348
|
+
const rawValue = text.textContent[index]
|
|
349
|
+
const content = rawValue == null ? '' : String(rawValue)
|
|
350
|
+
if (!content) return
|
|
351
|
+
|
|
352
|
+
const attrs = text.textAttrs[index] || 0
|
|
353
|
+
const align = text.textAlign[index] || 0
|
|
354
|
+
|
|
355
|
+
const lines = wrapText(content, w)
|
|
356
|
+
|
|
357
|
+
for (let lineIdx = 0; lineIdx < lines.length && lineIdx < h; lineIdx++) {
|
|
358
|
+
const line = lines[lineIdx] ?? ''
|
|
359
|
+
const lineY = y + lineIdx
|
|
360
|
+
|
|
361
|
+
if (lineY < clip.y || lineY >= clip.y + clip.height) continue
|
|
362
|
+
|
|
363
|
+
switch (align) {
|
|
364
|
+
case 0:
|
|
365
|
+
drawText(buffer, x, lineY, line, fg, undefined, attrs, clip)
|
|
366
|
+
break
|
|
367
|
+
case 1:
|
|
368
|
+
drawTextCentered(buffer, x, lineY, w, line, fg, undefined, attrs, clip)
|
|
369
|
+
break
|
|
370
|
+
case 2:
|
|
371
|
+
drawTextRight(buffer, x, lineY, w, line, fg, undefined, attrs, clip)
|
|
372
|
+
break
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// =============================================================================
|
|
378
|
+
// RENDER TO HISTORY
|
|
379
|
+
// =============================================================================
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Create a renderToHistory function bound to a HistoryWriter and AppendRegionRenderer.
|
|
383
|
+
*
|
|
384
|
+
* The renderer is needed to coordinate:
|
|
385
|
+
* 1. Erase the active area BEFORE writing history
|
|
386
|
+
* 2. History is written (becomes permanent scrollback)
|
|
387
|
+
* 3. Next render will start fresh below the history
|
|
388
|
+
*
|
|
389
|
+
* Usage:
|
|
390
|
+
* ```ts
|
|
391
|
+
* const renderToHistory = createRenderToHistory(historyWriter, appendRegionRenderer)
|
|
392
|
+
*
|
|
393
|
+
* // When freezing content:
|
|
394
|
+
* renderToHistory(() => {
|
|
395
|
+
* Message({ content: 'Hello!' })
|
|
396
|
+
* })
|
|
397
|
+
* ```
|
|
398
|
+
*/
|
|
399
|
+
export function createRenderToHistory(
|
|
400
|
+
historyWriter: HistoryWriter,
|
|
401
|
+
appendRegionRenderer: { eraseActive: () => void }
|
|
402
|
+
) {
|
|
403
|
+
return function renderToHistory(componentFn: () => void): void {
|
|
404
|
+
// CRITICAL: Wrap in batch() to prevent reactive updates during this operation.
|
|
405
|
+
// Without batch, the ReactiveSet triggers updates when we allocate/release indices,
|
|
406
|
+
// causing the render effect to run mid-operation and duplicate content.
|
|
407
|
+
batch(() => {
|
|
408
|
+
// Save current allocated indices BEFORE creating history components
|
|
409
|
+
const beforeIndices = new Set(getAllocatedIndices())
|
|
410
|
+
|
|
411
|
+
// Run component function - creates new components
|
|
412
|
+
componentFn()
|
|
413
|
+
|
|
414
|
+
// Find NEW indices (ones that didn't exist before)
|
|
415
|
+
const historyIndices = new Set<number>()
|
|
416
|
+
for (const idx of getAllocatedIndices()) {
|
|
417
|
+
if (!beforeIndices.has(idx)) {
|
|
418
|
+
historyIndices.add(idx)
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (historyIndices.size === 0) {
|
|
423
|
+
return
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Get terminal width for layout
|
|
427
|
+
const tw = terminalWidth.value
|
|
428
|
+
const th = terminalHeight.value
|
|
429
|
+
|
|
430
|
+
// Compute layout for just history components
|
|
431
|
+
const layoutResult = computeLayoutTitan(tw, th, historyIndices, false)
|
|
432
|
+
|
|
433
|
+
// Build frame buffer for history components
|
|
434
|
+
const buffer = computeBufferForIndices(historyIndices, layoutResult, tw)
|
|
435
|
+
|
|
436
|
+
// STEP 2: Convert to ANSI and write to history
|
|
437
|
+
// This becomes permanent terminal scrollback
|
|
438
|
+
const output = bufferToAnsi(buffer)
|
|
439
|
+
historyWriter.write(output)
|
|
440
|
+
historyWriter.flush()
|
|
441
|
+
|
|
442
|
+
// Cleanup: release all history components
|
|
443
|
+
// Batched, so render effect won't run until after release completes.
|
|
444
|
+
// The renderer's previousHeight is already 0 from eraseActive(), so next render
|
|
445
|
+
// will simply render the active area fresh below our history output
|
|
446
|
+
for (const idx of historyIndices) {
|
|
447
|
+
releaseIndex(idx)
|
|
448
|
+
}
|
|
449
|
+
})
|
|
450
|
+
}
|
|
451
|
+
}
|