@rlabs-inc/tui 0.3.2 → 0.5.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 +9 -2
- package/package.json +2 -2
- 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 +34 -8
- package/src/pipeline/layout/titan-engine.ts +37 -2
- package/src/primitives/box.ts +12 -0
- package/src/primitives/each.ts +8 -0
- package/src/primitives/index.ts +2 -1
- package/src/primitives/input.ts +360 -0
- package/src/primitives/show.ts +6 -5
- package/src/primitives/text.ts +13 -1
- package/src/primitives/types.ts +50 -1
- package/src/primitives/when.ts +5 -2
- package/src/state/context.ts +212 -0
- package/src/state/drawnCursor.ts +321 -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,8 +9,15 @@
|
|
|
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'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rlabs-inc/tui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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
|
}
|
|
@@ -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'
|
|
@@ -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
|
|
@@ -269,8 +271,9 @@ export function computeLayoutTitan(
|
|
|
269
271
|
|
|
270
272
|
// CACHE CHECK: Hash text content, compare with cached
|
|
271
273
|
// Only recompute stringWidth/measureTextHeight if content or availableW changed
|
|
274
|
+
// Length check prevents hash collisions (two strings with same hash must also have same length)
|
|
272
275
|
const textHash = BigInt(Bun.hash(str))
|
|
273
|
-
if (textHash === cachedTextHash[i] && availableW === cachedAvailW[i]) {
|
|
276
|
+
if (textHash === cachedTextHash[i] && availableW === cachedAvailW[i] && str.length === cachedTextLength[i]) {
|
|
274
277
|
// Cache hit - reuse cached intrinsics (skip expensive computation!)
|
|
275
278
|
intrinsicW[i] = cachedIntrinsicW[i]!
|
|
276
279
|
intrinsicH[i] = cachedIntrinsicH[i]!
|
|
@@ -279,6 +282,7 @@ export function computeLayoutTitan(
|
|
|
279
282
|
intrinsicW[i] = stringWidth(str)
|
|
280
283
|
intrinsicH[i] = measureTextHeight(str, availableW)
|
|
281
284
|
cachedTextHash[i] = textHash
|
|
285
|
+
cachedTextLength[i] = str.length
|
|
282
286
|
cachedAvailW[i] = availableW
|
|
283
287
|
cachedIntrinsicW[i] = intrinsicW[i]
|
|
284
288
|
cachedIntrinsicH[i] = intrinsicH[i]
|
|
@@ -288,6 +292,26 @@ export function computeLayoutTitan(
|
|
|
288
292
|
intrinsicH[i] = 0
|
|
289
293
|
}
|
|
290
294
|
}
|
|
295
|
+
} else if (type === ComponentType.INPUT) {
|
|
296
|
+
// INPUT: Single-line, intrinsic width from content, height always 1
|
|
297
|
+
const content = text.textContent[i] // SlotArray auto-unwraps & tracks
|
|
298
|
+
const str = content != null ? String(content) : ''
|
|
299
|
+
|
|
300
|
+
// Get borders and padding for this input
|
|
301
|
+
const borderStyle = visual.borderStyle[i] ?? 0
|
|
302
|
+
const borderT = borderStyle > 0 || (visual.borderTop[i] ?? 0) > 0 ? 1 : 0
|
|
303
|
+
const borderR = borderStyle > 0 || (visual.borderRight[i] ?? 0) > 0 ? 1 : 0
|
|
304
|
+
const borderB = borderStyle > 0 || (visual.borderBottom[i] ?? 0) > 0 ? 1 : 0
|
|
305
|
+
const borderL = borderStyle > 0 || (visual.borderLeft[i] ?? 0) > 0 ? 1 : 0
|
|
306
|
+
const padT = spacing.paddingTop[i] ?? 0
|
|
307
|
+
const padR = spacing.paddingRight[i] ?? 0
|
|
308
|
+
const padB = spacing.paddingBottom[i] ?? 0
|
|
309
|
+
const padL = spacing.paddingLeft[i] ?? 0
|
|
310
|
+
|
|
311
|
+
// Intrinsic width: text width + padding + borders
|
|
312
|
+
intrinsicW[i] = stringWidth(str) + padL + padR + borderL + borderR
|
|
313
|
+
// Intrinsic height: 1 line + padding + borders
|
|
314
|
+
intrinsicH[i] = 1 + padT + padB + borderT + borderB
|
|
291
315
|
} else {
|
|
292
316
|
// BOX/Container - calculate intrinsic from children + padding + borders
|
|
293
317
|
// EXCEPTION: Scrollable containers should have minimal intrinsic height
|
|
@@ -655,6 +679,17 @@ export function computeLayoutTitan(
|
|
|
655
679
|
}
|
|
656
680
|
}
|
|
657
681
|
|
|
682
|
+
// INPUT: Single-line, always height 1 (content scrolls horizontally)
|
|
683
|
+
if (core.componentType[fkid] === ComponentType.INPUT) {
|
|
684
|
+
// Add border height if borders are present
|
|
685
|
+
const borderStyle = visual.borderStyle[fkid] ?? 0
|
|
686
|
+
const borderT = borderStyle > 0 || (visual.borderTop[fkid] ?? 0) > 0 ? 1 : 0
|
|
687
|
+
const borderB = borderStyle > 0 || (visual.borderBottom[fkid] ?? 0) > 0 ? 1 : 0
|
|
688
|
+
const padT = spacing.paddingTop[fkid] ?? 0
|
|
689
|
+
const padB = spacing.paddingBottom[fkid] ?? 0
|
|
690
|
+
outH[fkid] = 1 + borderT + borderB + padT + padB
|
|
691
|
+
}
|
|
692
|
+
|
|
658
693
|
// Track max extent inline (zero overhead) - include margins
|
|
659
694
|
if (isRow) {
|
|
660
695
|
childrenMaxMain = Math.max(childrenMaxMain, mainOffset + mLeft + outW[fkid]! + mRight)
|
package/src/primitives/box.ts
CHANGED
|
@@ -30,6 +30,11 @@ 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'
|
|
@@ -163,6 +168,9 @@ function getStaticBool(prop: unknown, defaultVal: boolean): boolean {
|
|
|
163
168
|
export function box(props: BoxProps = {}): Cleanup {
|
|
164
169
|
const index = allocateIndex(props.id)
|
|
165
170
|
|
|
171
|
+
// Track current component for lifecycle hooks
|
|
172
|
+
pushCurrentComponent(index)
|
|
173
|
+
|
|
166
174
|
// ==========================================================================
|
|
167
175
|
// CORE - Always needed
|
|
168
176
|
// ==========================================================================
|
|
@@ -291,6 +299,10 @@ export function box(props: BoxProps = {}): Cleanup {
|
|
|
291
299
|
}
|
|
292
300
|
}
|
|
293
301
|
|
|
302
|
+
// Component setup complete - run lifecycle callbacks
|
|
303
|
+
popCurrentComponent()
|
|
304
|
+
runMountCallbacks(index)
|
|
305
|
+
|
|
294
306
|
// Cleanup function
|
|
295
307
|
const cleanup = () => {
|
|
296
308
|
cleanupKeyboardListeners(index) // Remove any focused key handlers
|
package/src/primitives/each.ts
CHANGED
|
@@ -50,6 +50,14 @@ export function each<T>(
|
|
|
50
50
|
for (let i = 0; i < items.length; i++) {
|
|
51
51
|
const item = items[i]!
|
|
52
52
|
const key = options.key(item)
|
|
53
|
+
|
|
54
|
+
// Warn about duplicate keys in the same render pass
|
|
55
|
+
if (currentKeys.has(key)) {
|
|
56
|
+
console.warn(
|
|
57
|
+
`[TUI each()] Duplicate key detected: "${key}". ` +
|
|
58
|
+
`Keys must be unique. This may cause unexpected behavior.`
|
|
59
|
+
)
|
|
60
|
+
}
|
|
53
61
|
currentKeys.add(key)
|
|
54
62
|
|
|
55
63
|
if (!itemSignals.has(key)) {
|
package/src/primitives/index.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
export { box } from './box'
|
|
9
9
|
export { text } from './text'
|
|
10
|
+
export { input } from './input'
|
|
10
11
|
export { each } from './each'
|
|
11
12
|
export { show } from './show'
|
|
12
13
|
export { when } from './when'
|
|
@@ -14,6 +15,6 @@ export { scoped, onCleanup, componentScope, cleanupCollector } from './scope'
|
|
|
14
15
|
export { useAnimation, AnimationFrames } from './animation'
|
|
15
16
|
|
|
16
17
|
// Types
|
|
17
|
-
export type { BoxProps, TextProps, Cleanup } from './types'
|
|
18
|
+
export type { BoxProps, TextProps, InputProps, CursorConfig, CursorStyle, BlinkConfig, Cleanup } from './types'
|
|
18
19
|
export type { ComponentScopeResult } from './scope'
|
|
19
20
|
export type { AnimationOptions } from './animation'
|