@rlabs-inc/tui 0.3.2 → 0.6.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/index.ts +23 -4
- package/package.json +2 -2
- package/src/api/mount.ts +17 -7
- package/src/engine/arrays/interaction.ts +54 -0
- package/src/engine/lifecycle.ts +186 -0
- package/src/engine/registry.ts +5 -0
- package/src/pipeline/frameBuffer.ts +36 -10
- package/src/pipeline/layout/titan-engine.ts +45 -9
- package/src/pipeline/layout/utils/text-measure.ts +3 -2
- package/src/primitives/box.ts +13 -22
- package/src/primitives/each.ts +8 -0
- package/src/primitives/index.ts +2 -1
- package/src/primitives/input.ts +360 -0
- package/src/primitives/scope.ts +1 -2
- package/src/primitives/show.ts +7 -5
- package/src/primitives/text.ts +14 -25
- package/src/primitives/types.ts +50 -36
- package/src/primitives/utils.ts +26 -0
- package/src/primitives/when.ts +5 -2
- package/src/state/context.ts +212 -0
- package/src/state/drawnCursor.ts +326 -0
- package/src/state/focus.ts +84 -13
- package/src/state/index.ts +9 -0
- package/src/state/keyboard.ts +6 -0
- package/src/state/theme.ts +260 -10
- package/src/types/color.ts +115 -0
package/index.ts
CHANGED
|
@@ -9,13 +9,32 @@
|
|
|
9
9
|
export { mount } from './src/api'
|
|
10
10
|
|
|
11
11
|
// Primitives - UI building blocks
|
|
12
|
-
export { box, text, each, show, when, scoped, onCleanup, useAnimation, AnimationFrames } from './src/primitives'
|
|
13
|
-
export type { BoxProps, TextProps, Cleanup, AnimationOptions } from './src/primitives'
|
|
12
|
+
export { box, text, input, each, show, when, scoped, onCleanup, useAnimation, AnimationFrames } from './src/primitives'
|
|
13
|
+
export type { BoxProps, TextProps, InputProps, CursorConfig, CursorStyle, Cleanup, AnimationOptions } from './src/primitives'
|
|
14
|
+
|
|
15
|
+
// Lifecycle hooks - Component mount/destroy callbacks
|
|
16
|
+
export { onMount, onDestroy } from './src/engine/lifecycle'
|
|
17
|
+
|
|
18
|
+
// Context - Reactive dependency injection
|
|
19
|
+
export { createContext, provide, useContext, hasContext, clearContext } from './src/state/context'
|
|
20
|
+
export type { Context } from './src/state/context'
|
|
14
21
|
|
|
15
22
|
// State modules - Input handling
|
|
16
23
|
export { keyboard, lastKey, lastEvent } from './src/state/keyboard'
|
|
17
|
-
export {
|
|
18
|
-
|
|
24
|
+
export {
|
|
25
|
+
mouse,
|
|
26
|
+
hitGrid,
|
|
27
|
+
lastMouseEvent,
|
|
28
|
+
mouseX,
|
|
29
|
+
mouseY,
|
|
30
|
+
isMouseDown,
|
|
31
|
+
onMouseDown,
|
|
32
|
+
onMouseUp,
|
|
33
|
+
onClick,
|
|
34
|
+
onScroll,
|
|
35
|
+
onComponent,
|
|
36
|
+
} from './src/state/mouse'
|
|
37
|
+
export { focusManager, focusedIndex, pushFocusTrap, popFocusTrap, isFocusTrapped, getFocusTrapContainer } from './src/state/focus'
|
|
19
38
|
export { scroll } from './src/state/scroll'
|
|
20
39
|
export { globalKeys } from './src/state/global-keys'
|
|
21
40
|
export { cursor } from './src/state/cursor'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rlabs-inc/tui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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.9.0"
|
|
58
58
|
}
|
|
59
59
|
}
|
package/src/api/mount.ts
CHANGED
|
@@ -37,6 +37,13 @@ import { resetRegistry } from '../engine/registry'
|
|
|
37
37
|
import { hitGrid, clearHitGrid, mouse } from '../state/mouse'
|
|
38
38
|
import { globalKeys } from '../state/global-keys'
|
|
39
39
|
|
|
40
|
+
// =============================================================================
|
|
41
|
+
// MODULE STATE
|
|
42
|
+
// =============================================================================
|
|
43
|
+
|
|
44
|
+
// Track if global error handlers have been registered (only once per process)
|
|
45
|
+
let globalErrorHandlersRegistered = false
|
|
46
|
+
|
|
40
47
|
// =============================================================================
|
|
41
48
|
// MOUNT
|
|
42
49
|
// =============================================================================
|
|
@@ -149,13 +156,16 @@ export async function mount(
|
|
|
149
156
|
// Create the component tree
|
|
150
157
|
root()
|
|
151
158
|
|
|
152
|
-
// Global error handlers for debugging
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
+
// Global error handlers for debugging (register only once per process)
|
|
160
|
+
if (!globalErrorHandlersRegistered) {
|
|
161
|
+
globalErrorHandlersRegistered = true
|
|
162
|
+
process.on('uncaughtException', (err) => {
|
|
163
|
+
console.error('[TUI] Uncaught exception:', err)
|
|
164
|
+
})
|
|
165
|
+
process.on('unhandledRejection', (err) => {
|
|
166
|
+
console.error('[TUI] Unhandled rejection:', err)
|
|
167
|
+
})
|
|
168
|
+
}
|
|
159
169
|
|
|
160
170
|
// THE ONE RENDER EFFECT
|
|
161
171
|
// This is where the magic happens - reactive rendering!
|
|
@@ -65,6 +65,48 @@ export const selectionStart: SlotArray<number> = slotArray<number>(-1)
|
|
|
65
65
|
/** Selection end position */
|
|
66
66
|
export const selectionEnd: SlotArray<number> = slotArray<number>(-1)
|
|
67
67
|
|
|
68
|
+
// =============================================================================
|
|
69
|
+
// CURSOR STYLING (for customizable cursor appearance)
|
|
70
|
+
// =============================================================================
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Cursor character codepoint.
|
|
74
|
+
* 0 = use inverse block (default), >0 = custom character
|
|
75
|
+
* Presets: bar=0x2502 (│), underline=0x5F (_)
|
|
76
|
+
*/
|
|
77
|
+
export const cursorChar: SlotArray<number> = slotArray<number>(0)
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Cursor alternate character for blink "off" phase.
|
|
81
|
+
* 0 = space (invisible), >0 = custom character
|
|
82
|
+
*/
|
|
83
|
+
export const cursorAltChar: SlotArray<number> = slotArray<number>(0)
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Cursor blink rate in FPS.
|
|
87
|
+
* 0 = no blink, >0 = blink at this FPS (default would be 2 = 500ms cycle)
|
|
88
|
+
*/
|
|
89
|
+
export const cursorBlinkFps: SlotArray<number> = slotArray<number>(0)
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Custom cursor foreground color (packed RGBA or 0 for default).
|
|
93
|
+
* When 0, uses inverted colors from component's bg.
|
|
94
|
+
*/
|
|
95
|
+
export const cursorFg: SlotArray<number> = slotArray<number>(0)
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Custom cursor background color (packed RGBA or 0 for default).
|
|
99
|
+
* When 0, uses component's fg color.
|
|
100
|
+
*/
|
|
101
|
+
export const cursorBg: SlotArray<number> = slotArray<number>(0)
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Cursor visibility state for blink animation.
|
|
105
|
+
* 1 = visible (default), 0 = hidden
|
|
106
|
+
* Managed by input component's animation, read by frameBuffer.
|
|
107
|
+
*/
|
|
108
|
+
export const cursorVisible: SlotArray<number> = slotArray<number>(1)
|
|
109
|
+
|
|
68
110
|
// =============================================================================
|
|
69
111
|
// CAPACITY MANAGEMENT
|
|
70
112
|
// =============================================================================
|
|
@@ -81,6 +123,12 @@ export function ensureCapacity(index: number): void {
|
|
|
81
123
|
cursorPosition.ensureCapacity(index)
|
|
82
124
|
selectionStart.ensureCapacity(index)
|
|
83
125
|
selectionEnd.ensureCapacity(index)
|
|
126
|
+
cursorChar.ensureCapacity(index)
|
|
127
|
+
cursorAltChar.ensureCapacity(index)
|
|
128
|
+
cursorBlinkFps.ensureCapacity(index)
|
|
129
|
+
cursorFg.ensureCapacity(index)
|
|
130
|
+
cursorBg.ensureCapacity(index)
|
|
131
|
+
cursorVisible.ensureCapacity(index)
|
|
84
132
|
}
|
|
85
133
|
|
|
86
134
|
/** Clear slot at index (reset to default) */
|
|
@@ -95,4 +143,10 @@ export function clearAtIndex(index: number): void {
|
|
|
95
143
|
cursorPosition.clear(index)
|
|
96
144
|
selectionStart.clear(index)
|
|
97
145
|
selectionEnd.clear(index)
|
|
146
|
+
cursorChar.clear(index)
|
|
147
|
+
cursorAltChar.clear(index)
|
|
148
|
+
cursorBlinkFps.clear(index)
|
|
149
|
+
cursorFg.clear(index)
|
|
150
|
+
cursorBg.clear(index)
|
|
151
|
+
cursorVisible.clear(index)
|
|
98
152
|
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI Framework - Component Lifecycle Hooks
|
|
3
|
+
*
|
|
4
|
+
* Provides onMount and onDestroy hooks for components.
|
|
5
|
+
* Zero overhead when not used - callbacks only stored for components that opt-in.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```ts
|
|
9
|
+
* function Timer() {
|
|
10
|
+
* const interval = setInterval(() => tick(), 1000)
|
|
11
|
+
* onDestroy(() => clearInterval(interval))
|
|
12
|
+
* return text({ content: 'Timer running...' })
|
|
13
|
+
* }
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// =============================================================================
|
|
18
|
+
// Current Component Tracking
|
|
19
|
+
// =============================================================================
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Stack of component indices currently being created.
|
|
23
|
+
* Needed because children are created synchronously inside parent's children() callback.
|
|
24
|
+
*/
|
|
25
|
+
const componentStack: number[] = []
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Push a component index onto the creation stack.
|
|
29
|
+
* Called by primitives (box, text) at the start of creation.
|
|
30
|
+
*/
|
|
31
|
+
export function pushCurrentComponent(index: number): void {
|
|
32
|
+
componentStack.push(index)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Pop a component index from the creation stack.
|
|
37
|
+
* Called by primitives (box, text) after setup is complete.
|
|
38
|
+
*/
|
|
39
|
+
export function popCurrentComponent(): void {
|
|
40
|
+
componentStack.pop()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get the current component index (the one being created).
|
|
45
|
+
* Returns -1 if not inside a component creation.
|
|
46
|
+
*/
|
|
47
|
+
export function getCurrentComponentIndex(): number {
|
|
48
|
+
return componentStack.length > 0 ? componentStack[componentStack.length - 1]! : -1
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// =============================================================================
|
|
52
|
+
// Lifecycle Callbacks Storage
|
|
53
|
+
// =============================================================================
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Mount callbacks by component index.
|
|
57
|
+
* Called after component is fully set up in arrays.
|
|
58
|
+
*/
|
|
59
|
+
const mountCallbacks = new Map<number, Array<() => void>>()
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Destroy callbacks by component index.
|
|
63
|
+
* Called when component is released.
|
|
64
|
+
*/
|
|
65
|
+
const destroyCallbacks = new Map<number, Array<() => void>>()
|
|
66
|
+
|
|
67
|
+
// =============================================================================
|
|
68
|
+
// Lifecycle Hook APIs
|
|
69
|
+
// =============================================================================
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Register a callback to run after the current component is mounted.
|
|
73
|
+
* The callback runs synchronously after the component setup is complete.
|
|
74
|
+
*
|
|
75
|
+
* @param fn - Callback to run on mount
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* ```ts
|
|
79
|
+
* function MyComponent() {
|
|
80
|
+
* onMount(() => {
|
|
81
|
+
* console.log('Component mounted!')
|
|
82
|
+
* fetchInitialData()
|
|
83
|
+
* })
|
|
84
|
+
* return box({ ... })
|
|
85
|
+
* }
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
export function onMount(fn: () => void): void {
|
|
89
|
+
const index = getCurrentComponentIndex()
|
|
90
|
+
if (index === -1) {
|
|
91
|
+
console.warn('onMount called outside of component creation')
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let callbacks = mountCallbacks.get(index)
|
|
96
|
+
if (!callbacks) {
|
|
97
|
+
callbacks = []
|
|
98
|
+
mountCallbacks.set(index, callbacks)
|
|
99
|
+
}
|
|
100
|
+
callbacks.push(fn)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Register a callback to run when the current component is destroyed.
|
|
105
|
+
* Use for cleanup: clearing intervals, removing event listeners, etc.
|
|
106
|
+
*
|
|
107
|
+
* @param fn - Cleanup callback
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* ```ts
|
|
111
|
+
* function Timer() {
|
|
112
|
+
* const interval = setInterval(() => tick(), 1000)
|
|
113
|
+
* onDestroy(() => clearInterval(interval))
|
|
114
|
+
* return text({ content: 'Timer' })
|
|
115
|
+
* }
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
118
|
+
export function onDestroy(fn: () => void): void {
|
|
119
|
+
const index = getCurrentComponentIndex()
|
|
120
|
+
if (index === -1) {
|
|
121
|
+
console.warn('onDestroy called outside of component creation')
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let callbacks = destroyCallbacks.get(index)
|
|
126
|
+
if (!callbacks) {
|
|
127
|
+
callbacks = []
|
|
128
|
+
destroyCallbacks.set(index, callbacks)
|
|
129
|
+
}
|
|
130
|
+
callbacks.push(fn)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// =============================================================================
|
|
134
|
+
// Internal: Run Lifecycle Callbacks
|
|
135
|
+
// =============================================================================
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Run all mount callbacks for a component.
|
|
139
|
+
* Called by primitives after setup is complete.
|
|
140
|
+
*/
|
|
141
|
+
export function runMountCallbacks(index: number): void {
|
|
142
|
+
const callbacks = mountCallbacks.get(index)
|
|
143
|
+
if (callbacks) {
|
|
144
|
+
for (const fn of callbacks) {
|
|
145
|
+
try {
|
|
146
|
+
fn()
|
|
147
|
+
} catch (err) {
|
|
148
|
+
console.error(`Error in onMount callback for component ${index}:`, err)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Don't delete - keep for potential re-mount scenarios
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Run all destroy callbacks for a component.
|
|
157
|
+
* Called by releaseIndex before cleanup.
|
|
158
|
+
*/
|
|
159
|
+
export function runDestroyCallbacks(index: number): void {
|
|
160
|
+
const callbacks = destroyCallbacks.get(index)
|
|
161
|
+
if (callbacks) {
|
|
162
|
+
for (const fn of callbacks) {
|
|
163
|
+
try {
|
|
164
|
+
fn()
|
|
165
|
+
} catch (err) {
|
|
166
|
+
console.error(`Error in onDestroy callback for component ${index}:`, err)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// Clean up storage
|
|
170
|
+
destroyCallbacks.delete(index)
|
|
171
|
+
mountCallbacks.delete(index)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// =============================================================================
|
|
176
|
+
// Reset (for testing)
|
|
177
|
+
// =============================================================================
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Reset all lifecycle state (for testing)
|
|
181
|
+
*/
|
|
182
|
+
export function resetLifecycle(): void {
|
|
183
|
+
componentStack.length = 0
|
|
184
|
+
mountCallbacks.clear()
|
|
185
|
+
destroyCallbacks.clear()
|
|
186
|
+
}
|
package/src/engine/registry.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { ReactiveSet } from '@rlabs-inc/signals'
|
|
|
14
14
|
import { ensureAllCapacity, clearAllAtIndex, resetAllArrays } from './arrays'
|
|
15
15
|
import { parentIndex as parentIndexArray } from './arrays/core'
|
|
16
16
|
import { resetTitanArrays } from '../pipeline/layout/titan-engine'
|
|
17
|
+
import { runDestroyCallbacks, resetLifecycle } from './lifecycle'
|
|
17
18
|
|
|
18
19
|
// =============================================================================
|
|
19
20
|
// Registry State
|
|
@@ -123,6 +124,9 @@ export function releaseIndex(index: number): void {
|
|
|
123
124
|
releaseIndex(childIndex)
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
// Run destroy callbacks before cleanup
|
|
128
|
+
runDestroyCallbacks(index)
|
|
129
|
+
|
|
126
130
|
// Clean up mappings
|
|
127
131
|
idToIndex.delete(id)
|
|
128
132
|
indexToId.delete(index)
|
|
@@ -192,4 +196,5 @@ export function resetRegistry(): void {
|
|
|
192
196
|
nextIndex = 0
|
|
193
197
|
idCounter = 0
|
|
194
198
|
parentStack.length = 0
|
|
199
|
+
resetLifecycle()
|
|
195
200
|
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* HitGrid updates are returned as data to be applied by the render effect.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { derived, neverEquals } from '@rlabs-inc/signals'
|
|
19
|
+
import { derived, neverEquals, signal } from '@rlabs-inc/signals'
|
|
20
20
|
import type { FrameBuffer, RGBA } from '../types'
|
|
21
21
|
import { ComponentType } from '../types'
|
|
22
22
|
import { Colors, TERMINAL_DEFAULT, rgbaBlend, rgbaLerp } from '../types/color'
|
|
@@ -39,7 +39,6 @@ import {
|
|
|
39
39
|
getInheritedBg,
|
|
40
40
|
getBorderColors,
|
|
41
41
|
getBorderStyles,
|
|
42
|
-
hasBorder,
|
|
43
42
|
getEffectiveOpacity,
|
|
44
43
|
} from '../engine/inheritance'
|
|
45
44
|
|
|
@@ -222,7 +221,8 @@ function renderComponent(
|
|
|
222
221
|
// Get border configuration
|
|
223
222
|
const borderStyles = getBorderStyles(index)
|
|
224
223
|
const borderColors = getBorderColors(index)
|
|
225
|
-
|
|
224
|
+
// Inline hasAnyBorder check - avoids redundant getBorderStyles call (was 12 array reads, now 8)
|
|
225
|
+
const hasAnyBorder = borderStyles.top > 0 || borderStyles.right > 0 || borderStyles.bottom > 0 || borderStyles.left > 0
|
|
226
226
|
|
|
227
227
|
// Draw borders
|
|
228
228
|
if (hasAnyBorder && w >= 2 && h >= 2) {
|
|
@@ -376,7 +376,9 @@ function renderInput(
|
|
|
376
376
|
fg: RGBA,
|
|
377
377
|
clip: ClipRect
|
|
378
378
|
): void {
|
|
379
|
-
|
|
379
|
+
// Read through slotArray proxy - same pattern as renderText
|
|
380
|
+
const rawValue = text.textContent[index]
|
|
381
|
+
const content = rawValue == null ? '' : String(rawValue)
|
|
380
382
|
const attrs = text.textAttrs[index] || 0
|
|
381
383
|
const cursorPos = interaction.cursorPosition[index] || 0
|
|
382
384
|
|
|
@@ -401,14 +403,38 @@ function renderInput(
|
|
|
401
403
|
if (interaction.focusedIndex.value === index) {
|
|
402
404
|
const cursorX = x + Math.min(cursorPos - displayOffset, w - 1)
|
|
403
405
|
if (cursorX >= clip.x && cursorX < clip.x + clip.width && y >= clip.y && y < clip.y + clip.height) {
|
|
404
|
-
//
|
|
406
|
+
// Read cursor configuration from arrays
|
|
407
|
+
const cursorCharCode = interaction.cursorChar[index] ?? 0
|
|
408
|
+
const cursorAltCharCode = interaction.cursorAltChar[index] ?? 0
|
|
409
|
+
const cursorVisible = interaction.cursorVisible[index] ?? 1
|
|
410
|
+
|
|
405
411
|
const cell = buffer.cells[y]?.[cursorX]
|
|
406
412
|
if (cell) {
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
413
|
+
const charUnderCursor = content[cursorPos] || ' '
|
|
414
|
+
|
|
415
|
+
if (cursorVisible === 0) {
|
|
416
|
+
// Blink "off" phase
|
|
417
|
+
if (cursorAltCharCode > 0) {
|
|
418
|
+
// Custom alt character for "off" phase
|
|
419
|
+
cell.char = cursorAltCharCode
|
|
420
|
+
cell.fg = fg
|
|
421
|
+
cell.bg = getInheritedBg(index)
|
|
422
|
+
}
|
|
423
|
+
// else: leave cell unchanged (original text shows through)
|
|
424
|
+
} else {
|
|
425
|
+
// Cursor visible
|
|
426
|
+
if (cursorCharCode === 0) {
|
|
427
|
+
// Block cursor (inverse) - swap fg/bg
|
|
428
|
+
cell.char = charUnderCursor.codePointAt(0) ?? 32
|
|
429
|
+
cell.fg = getInheritedBg(index)
|
|
430
|
+
cell.bg = fg
|
|
431
|
+
} else {
|
|
432
|
+
// Custom cursor character (bar, underline, or user-defined)
|
|
433
|
+
cell.char = cursorCharCode
|
|
434
|
+
cell.fg = fg
|
|
435
|
+
cell.bg = getInheritedBg(index)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
412
438
|
}
|
|
413
439
|
}
|
|
414
440
|
}
|
|
@@ -127,9 +127,10 @@ const lineMainUsed: number[] = []
|
|
|
127
127
|
// =============================================================================
|
|
128
128
|
// INTRINSIC CACHE - Skip recomputation when inputs unchanged
|
|
129
129
|
// =============================================================================
|
|
130
|
-
// For TEXT components: cache based on text content hash + available width
|
|
130
|
+
// For TEXT components: cache based on text content hash + available width + length
|
|
131
131
|
// For BOX components: cache based on children intrinsics + layout props
|
|
132
132
|
const cachedTextHash: bigint[] = []
|
|
133
|
+
const cachedTextLength: number[] = [] // Length check prevents hash collisions
|
|
133
134
|
const cachedAvailW: number[] = []
|
|
134
135
|
const cachedIntrinsicW: number[] = []
|
|
135
136
|
const cachedIntrinsicH: number[] = []
|
|
@@ -166,6 +167,7 @@ export function resetTitanArrays(): void {
|
|
|
166
167
|
lineMainUsed.length = 0
|
|
167
168
|
// Intrinsic cache
|
|
168
169
|
cachedTextHash.length = 0
|
|
170
|
+
cachedTextLength.length = 0
|
|
169
171
|
cachedAvailW.length = 0
|
|
170
172
|
cachedIntrinsicW.length = 0
|
|
171
173
|
cachedIntrinsicH.length = 0
|
|
@@ -215,7 +217,8 @@ export function computeLayoutTitan(
|
|
|
215
217
|
if (firstChild[parent] === -1) {
|
|
216
218
|
firstChild[parent] = i
|
|
217
219
|
} else {
|
|
218
|
-
|
|
220
|
+
const last = lastChild[parent] ?? -1
|
|
221
|
+
if (last !== -1) nextSibling[last] = i
|
|
219
222
|
}
|
|
220
223
|
lastChild[parent] = i
|
|
221
224
|
} else {
|
|
@@ -229,10 +232,10 @@ export function computeLayoutTitan(
|
|
|
229
232
|
let head = 0
|
|
230
233
|
while (head < bfsQueue.length) {
|
|
231
234
|
const parent = bfsQueue[head++]!
|
|
232
|
-
let child = firstChild[parent]
|
|
235
|
+
let child = firstChild[parent] ?? -1
|
|
233
236
|
while (child !== -1) {
|
|
234
237
|
bfsQueue.push(child)
|
|
235
|
-
child = nextSibling[child]
|
|
238
|
+
child = nextSibling[child] ?? -1
|
|
236
239
|
}
|
|
237
240
|
}
|
|
238
241
|
|
|
@@ -269,8 +272,9 @@ export function computeLayoutTitan(
|
|
|
269
272
|
|
|
270
273
|
// CACHE CHECK: Hash text content, compare with cached
|
|
271
274
|
// Only recompute stringWidth/measureTextHeight if content or availableW changed
|
|
275
|
+
// Length check prevents hash collisions (two strings with same hash must also have same length)
|
|
272
276
|
const textHash = BigInt(Bun.hash(str))
|
|
273
|
-
if (textHash === cachedTextHash[i] && availableW === cachedAvailW[i]) {
|
|
277
|
+
if (textHash === cachedTextHash[i] && availableW === cachedAvailW[i] && str.length === cachedTextLength[i]) {
|
|
274
278
|
// Cache hit - reuse cached intrinsics (skip expensive computation!)
|
|
275
279
|
intrinsicW[i] = cachedIntrinsicW[i]!
|
|
276
280
|
intrinsicH[i] = cachedIntrinsicH[i]!
|
|
@@ -279,6 +283,7 @@ export function computeLayoutTitan(
|
|
|
279
283
|
intrinsicW[i] = stringWidth(str)
|
|
280
284
|
intrinsicH[i] = measureTextHeight(str, availableW)
|
|
281
285
|
cachedTextHash[i] = textHash
|
|
286
|
+
cachedTextLength[i] = str.length
|
|
282
287
|
cachedAvailW[i] = availableW
|
|
283
288
|
cachedIntrinsicW[i] = intrinsicW[i]
|
|
284
289
|
cachedIntrinsicH[i] = intrinsicH[i]
|
|
@@ -288,6 +293,26 @@ export function computeLayoutTitan(
|
|
|
288
293
|
intrinsicH[i] = 0
|
|
289
294
|
}
|
|
290
295
|
}
|
|
296
|
+
} else if (type === ComponentType.INPUT) {
|
|
297
|
+
// INPUT: Single-line, intrinsic width from content, height always 1
|
|
298
|
+
const content = text.textContent[i] // SlotArray auto-unwraps & tracks
|
|
299
|
+
const str = content != null ? String(content) : ''
|
|
300
|
+
|
|
301
|
+
// Get borders and padding for this input
|
|
302
|
+
const borderStyle = visual.borderStyle[i] ?? 0
|
|
303
|
+
const borderT = borderStyle > 0 || (visual.borderTop[i] ?? 0) > 0 ? 1 : 0
|
|
304
|
+
const borderR = borderStyle > 0 || (visual.borderRight[i] ?? 0) > 0 ? 1 : 0
|
|
305
|
+
const borderB = borderStyle > 0 || (visual.borderBottom[i] ?? 0) > 0 ? 1 : 0
|
|
306
|
+
const borderL = borderStyle > 0 || (visual.borderLeft[i] ?? 0) > 0 ? 1 : 0
|
|
307
|
+
const padT = spacing.paddingTop[i] ?? 0
|
|
308
|
+
const padR = spacing.paddingRight[i] ?? 0
|
|
309
|
+
const padB = spacing.paddingBottom[i] ?? 0
|
|
310
|
+
const padL = spacing.paddingLeft[i] ?? 0
|
|
311
|
+
|
|
312
|
+
// Intrinsic width: text width + padding + borders
|
|
313
|
+
intrinsicW[i] = stringWidth(str) + padL + padR + borderL + borderR
|
|
314
|
+
// Intrinsic height: 1 line + padding + borders
|
|
315
|
+
intrinsicH[i] = 1 + padT + padB + borderT + borderB
|
|
291
316
|
} else {
|
|
292
317
|
// BOX/Container - calculate intrinsic from children + padding + borders
|
|
293
318
|
// EXCEPTION: Scrollable containers should have minimal intrinsic height
|
|
@@ -295,7 +320,7 @@ export function computeLayoutTitan(
|
|
|
295
320
|
const overflow = layout.overflow[i] ?? Overflow.VISIBLE
|
|
296
321
|
const isScrollable = overflow === Overflow.SCROLL || overflow === Overflow.AUTO
|
|
297
322
|
|
|
298
|
-
let kid = firstChild[i]
|
|
323
|
+
let kid = firstChild[i] ?? -1
|
|
299
324
|
if (kid !== -1 && !isScrollable) {
|
|
300
325
|
// Normal containers: intrinsic size includes all children
|
|
301
326
|
const dir = layout.flexDirection[i] ?? FLEX_COLUMN
|
|
@@ -344,7 +369,7 @@ export function computeLayoutTitan(
|
|
|
344
369
|
sumMain += kidH + kidMarginMain + gap
|
|
345
370
|
maxCross = Math.max(maxCross, kidW)
|
|
346
371
|
}
|
|
347
|
-
kid = nextSibling[kid]
|
|
372
|
+
kid = nextSibling[kid] ?? -1
|
|
348
373
|
}
|
|
349
374
|
|
|
350
375
|
if (childCount > 0) sumMain -= gap
|
|
@@ -405,12 +430,12 @@ export function computeLayoutTitan(
|
|
|
405
430
|
let childrenMaxCross = 0
|
|
406
431
|
|
|
407
432
|
// Collect flow children
|
|
408
|
-
let kid = firstChild[parent]
|
|
433
|
+
let kid = firstChild[parent] ?? -1
|
|
409
434
|
while (kid !== -1) {
|
|
410
435
|
if ((layout.position[kid] ?? POS_RELATIVE) !== POS_ABSOLUTE) {
|
|
411
436
|
flowKids.push(kid)
|
|
412
437
|
}
|
|
413
|
-
kid = nextSibling[kid]
|
|
438
|
+
kid = nextSibling[kid] ?? -1
|
|
414
439
|
}
|
|
415
440
|
|
|
416
441
|
if (flowKids.length === 0) return
|
|
@@ -655,6 +680,17 @@ export function computeLayoutTitan(
|
|
|
655
680
|
}
|
|
656
681
|
}
|
|
657
682
|
|
|
683
|
+
// INPUT: Single-line, always height 1 (content scrolls horizontally)
|
|
684
|
+
if (core.componentType[fkid] === ComponentType.INPUT) {
|
|
685
|
+
// Add border height if borders are present
|
|
686
|
+
const borderStyle = visual.borderStyle[fkid] ?? 0
|
|
687
|
+
const borderT = borderStyle > 0 || (visual.borderTop[fkid] ?? 0) > 0 ? 1 : 0
|
|
688
|
+
const borderB = borderStyle > 0 || (visual.borderBottom[fkid] ?? 0) > 0 ? 1 : 0
|
|
689
|
+
const padT = spacing.paddingTop[fkid] ?? 0
|
|
690
|
+
const padB = spacing.paddingBottom[fkid] ?? 0
|
|
691
|
+
outH[fkid] = 1 + borderT + borderB + padT + padB
|
|
692
|
+
}
|
|
693
|
+
|
|
658
694
|
// Track max extent inline (zero overhead) - include margins
|
|
659
695
|
if (isRow) {
|
|
660
696
|
childrenMaxMain = Math.max(childrenMaxMain, mainOffset + mLeft + outW[fkid]! + mRight)
|
|
@@ -33,7 +33,8 @@ export function measureTextHeight(content: string, maxWidth: number): number {
|
|
|
33
33
|
// Split by existing newlines first
|
|
34
34
|
const paragraphs = content.split('\n')
|
|
35
35
|
|
|
36
|
-
for (
|
|
36
|
+
for (let i = 0; i < paragraphs.length; i++) {
|
|
37
|
+
const paragraph = paragraphs[i]!
|
|
37
38
|
if (paragraph === '') {
|
|
38
39
|
lines++
|
|
39
40
|
continue
|
|
@@ -53,7 +54,7 @@ export function measureTextHeight(content: string, maxWidth: number): number {
|
|
|
53
54
|
|
|
54
55
|
// Reset for next paragraph
|
|
55
56
|
currentLineWidth = 0
|
|
56
|
-
if (
|
|
57
|
+
if (i < paragraphs.length - 1) {
|
|
57
58
|
lines++
|
|
58
59
|
}
|
|
59
60
|
}
|
package/src/primitives/box.ts
CHANGED
|
@@ -30,9 +30,15 @@ import {
|
|
|
30
30
|
pushParentContext,
|
|
31
31
|
popParentContext,
|
|
32
32
|
} from '../engine/registry'
|
|
33
|
+
import {
|
|
34
|
+
pushCurrentComponent,
|
|
35
|
+
popCurrentComponent,
|
|
36
|
+
runMountCallbacks,
|
|
37
|
+
} from '../engine/lifecycle'
|
|
33
38
|
import { cleanupIndex as cleanupKeyboardListeners } from '../state/keyboard'
|
|
34
39
|
import { getVariantStyle } from '../state/theme'
|
|
35
40
|
import { getActiveScope } from './scope'
|
|
41
|
+
import { enumSource } from './utils'
|
|
36
42
|
|
|
37
43
|
// Import arrays
|
|
38
44
|
import * as core from '../engine/arrays/core'
|
|
@@ -113,28 +119,6 @@ function alignSelfToNum(align: string | undefined): number {
|
|
|
113
119
|
}
|
|
114
120
|
}
|
|
115
121
|
|
|
116
|
-
/**
|
|
117
|
-
* Create a slot source for enum props - returns getter for reactive, value for static.
|
|
118
|
-
* For use with slotArray.setSource()
|
|
119
|
-
* Handles: static values, signals/bindings ({ value: T }), and getter functions (() => T)
|
|
120
|
-
*/
|
|
121
|
-
function enumSource<T extends string>(
|
|
122
|
-
prop: T | { value: T } | (() => T) | undefined,
|
|
123
|
-
converter: (val: T | undefined) => number
|
|
124
|
-
): number | (() => number) {
|
|
125
|
-
// Handle getter function (inline derived)
|
|
126
|
-
if (typeof prop === 'function') {
|
|
127
|
-
return () => converter(prop())
|
|
128
|
-
}
|
|
129
|
-
// Handle object with .value (signal/binding/derived)
|
|
130
|
-
if (prop !== undefined && typeof prop === 'object' && prop !== null && 'value' in prop) {
|
|
131
|
-
const reactiveSource = prop as { value: T }
|
|
132
|
-
return () => converter(reactiveSource.value)
|
|
133
|
-
}
|
|
134
|
-
// Static value
|
|
135
|
-
return converter(prop as T | undefined)
|
|
136
|
-
}
|
|
137
|
-
|
|
138
122
|
/** Get static boolean for visible prop */
|
|
139
123
|
function getStaticBool(prop: unknown, defaultVal: boolean): boolean {
|
|
140
124
|
if (prop === undefined) return defaultVal
|
|
@@ -163,6 +147,9 @@ function getStaticBool(prop: unknown, defaultVal: boolean): boolean {
|
|
|
163
147
|
export function box(props: BoxProps = {}): Cleanup {
|
|
164
148
|
const index = allocateIndex(props.id)
|
|
165
149
|
|
|
150
|
+
// Track current component for lifecycle hooks
|
|
151
|
+
pushCurrentComponent(index)
|
|
152
|
+
|
|
166
153
|
// ==========================================================================
|
|
167
154
|
// CORE - Always needed
|
|
168
155
|
// ==========================================================================
|
|
@@ -291,6 +278,10 @@ export function box(props: BoxProps = {}): Cleanup {
|
|
|
291
278
|
}
|
|
292
279
|
}
|
|
293
280
|
|
|
281
|
+
// Component setup complete - run lifecycle callbacks
|
|
282
|
+
popCurrentComponent()
|
|
283
|
+
runMountCallbacks(index)
|
|
284
|
+
|
|
294
285
|
// Cleanup function
|
|
295
286
|
const cleanup = () => {
|
|
296
287
|
cleanupKeyboardListeners(index) // Remove any focused key handlers
|