@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 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.2",
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.8.2"
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
+ }
@@ -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
- const content = text.textContent[index] || '' // SlotArray auto-unwraps
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
- // Draw cursor as inverse block
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 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
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)
@@ -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
@@ -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)) {
@@ -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'