@rlabs-inc/tui 0.5.0 → 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 CHANGED
@@ -21,8 +21,20 @@ export type { Context } from './src/state/context'
21
21
 
22
22
  // State modules - Input handling
23
23
  export { keyboard, lastKey, lastEvent } from './src/state/keyboard'
24
- export { mouse, hitGrid, lastMouseEvent, mouseX, mouseY, isMouseDown } from './src/state/mouse'
25
- export { focusManager, focusedIndex } from './src/state/focus'
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'
26
38
  export { scroll } from './src/state/scroll'
27
39
  export { globalKeys } from './src/state/global-keys'
28
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.5.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",
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
- process.on('uncaughtException', (err) => {
154
- console.error('[TUI] Uncaught exception:', err)
155
- })
156
- process.on('unhandledRejection', (err) => {
157
- console.error('[TUI] Unhandled rejection:', err)
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!
@@ -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
- const hasAnyBorder = hasBorder(index)
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) {
@@ -217,7 +217,8 @@ export function computeLayoutTitan(
217
217
  if (firstChild[parent] === -1) {
218
218
  firstChild[parent] = i
219
219
  } else {
220
- nextSibling[lastChild[parent]!] = i
220
+ const last = lastChild[parent] ?? -1
221
+ if (last !== -1) nextSibling[last] = i
221
222
  }
222
223
  lastChild[parent] = i
223
224
  } else {
@@ -231,10 +232,10 @@ export function computeLayoutTitan(
231
232
  let head = 0
232
233
  while (head < bfsQueue.length) {
233
234
  const parent = bfsQueue[head++]!
234
- let child = firstChild[parent]!
235
+ let child = firstChild[parent] ?? -1
235
236
  while (child !== -1) {
236
237
  bfsQueue.push(child)
237
- child = nextSibling[child]!
238
+ child = nextSibling[child] ?? -1
238
239
  }
239
240
  }
240
241
 
@@ -319,7 +320,7 @@ export function computeLayoutTitan(
319
320
  const overflow = layout.overflow[i] ?? Overflow.VISIBLE
320
321
  const isScrollable = overflow === Overflow.SCROLL || overflow === Overflow.AUTO
321
322
 
322
- let kid = firstChild[i]!
323
+ let kid = firstChild[i] ?? -1
323
324
  if (kid !== -1 && !isScrollable) {
324
325
  // Normal containers: intrinsic size includes all children
325
326
  const dir = layout.flexDirection[i] ?? FLEX_COLUMN
@@ -368,7 +369,7 @@ export function computeLayoutTitan(
368
369
  sumMain += kidH + kidMarginMain + gap
369
370
  maxCross = Math.max(maxCross, kidW)
370
371
  }
371
- kid = nextSibling[kid]!
372
+ kid = nextSibling[kid] ?? -1
372
373
  }
373
374
 
374
375
  if (childCount > 0) sumMain -= gap
@@ -429,12 +430,12 @@ export function computeLayoutTitan(
429
430
  let childrenMaxCross = 0
430
431
 
431
432
  // Collect flow children
432
- let kid = firstChild[parent]!
433
+ let kid = firstChild[parent] ?? -1
433
434
  while (kid !== -1) {
434
435
  if ((layout.position[kid] ?? POS_RELATIVE) !== POS_ABSOLUTE) {
435
436
  flowKids.push(kid)
436
437
  }
437
- kid = nextSibling[kid]!
438
+ kid = nextSibling[kid] ?? -1
438
439
  }
439
440
 
440
441
  if (flowKids.length === 0) return
@@ -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 (const paragraph of paragraphs) {
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 (paragraphs.indexOf(paragraph) < paragraphs.length - 1) {
57
+ if (i < paragraphs.length - 1) {
57
58
  lines++
58
59
  }
59
60
  }
@@ -38,6 +38,7 @@ import {
38
38
  import { cleanupIndex as cleanupKeyboardListeners } from '../state/keyboard'
39
39
  import { getVariantStyle } from '../state/theme'
40
40
  import { getActiveScope } from './scope'
41
+ import { enumSource } from './utils'
41
42
 
42
43
  // Import arrays
43
44
  import * as core from '../engine/arrays/core'
@@ -118,28 +119,6 @@ function alignSelfToNum(align: string | undefined): number {
118
119
  }
119
120
  }
120
121
 
121
- /**
122
- * Create a slot source for enum props - returns getter for reactive, value for static.
123
- * For use with slotArray.setSource()
124
- * Handles: static values, signals/bindings ({ value: T }), and getter functions (() => T)
125
- */
126
- function enumSource<T extends string>(
127
- prop: T | { value: T } | (() => T) | undefined,
128
- converter: (val: T | undefined) => number
129
- ): number | (() => number) {
130
- // Handle getter function (inline derived)
131
- if (typeof prop === 'function') {
132
- return () => converter(prop())
133
- }
134
- // Handle object with .value (signal/binding/derived)
135
- if (prop !== undefined && typeof prop === 'object' && prop !== null && 'value' in prop) {
136
- const reactiveSource = prop as { value: T }
137
- return () => converter(reactiveSource.value)
138
- }
139
- // Static value
140
- return converter(prop as T | undefined)
141
- }
142
-
143
122
  /** Get static boolean for visible prop */
144
123
  function getStaticBool(prop: unknown, defaultVal: boolean): boolean {
145
124
  if (prop === undefined) return defaultVal
@@ -22,8 +22,7 @@
22
22
 
23
23
  import { effectScope, onScopeDispose } from '@rlabs-inc/signals'
24
24
  import type { EffectScope } from '@rlabs-inc/signals'
25
-
26
- export type Cleanup = () => void
25
+ import type { Cleanup } from './types'
27
26
 
28
27
  // =============================================================================
29
28
  // ACTIVE SCOPE TRACKING
@@ -58,14 +58,15 @@ export function show(
58
58
  // Initial render
59
59
  update(conditionGetter())
60
60
 
61
- // Effect for updates - skip first run (don't read condition to avoid double tracking)
61
+ // Effect for updates - reads condition to establish dependency
62
+ // but skips the update on first run since we already rendered above
62
63
  let initialized = false
63
64
  effect(() => {
65
+ const condition = conditionGetter() // Must read to track dependency!
64
66
  if (!initialized) {
65
67
  initialized = true
66
68
  return
67
69
  }
68
- const condition = conditionGetter()
69
70
  update(condition)
70
71
  })
71
72
 
@@ -34,6 +34,7 @@ import {
34
34
  import { cleanupIndex as cleanupKeyboardListeners } from '../state/keyboard'
35
35
  import { getVariantStyle } from '../state/theme'
36
36
  import { getActiveScope } from './scope'
37
+ import { enumSource } from './utils'
37
38
 
38
39
  // Import arrays
39
40
  import * as core from '../engine/arrays/core'
@@ -79,8 +80,6 @@ function alignSelfToNum(alignSelf: string | undefined): number {
79
80
  }
80
81
  }
81
82
 
82
- // bindEnumProp removed - using enumSource instead
83
-
84
83
  /**
85
84
  * Convert content prop (string | number) to string source for setSource.
86
85
  * Handles: static values, signals, and getters.
@@ -101,28 +100,6 @@ function contentToStringSource(
101
100
  return String(content)
102
101
  }
103
102
 
104
- /**
105
- * Create a slot source for enum props - returns getter for reactive, value for static.
106
- * For use with slotArray.setSource()
107
- * Handles: static values, signals/bindings ({ value: T }), and getter functions (() => T)
108
- */
109
- function enumSource<T extends string>(
110
- prop: T | { value: T } | (() => T) | undefined,
111
- converter: (val: T | undefined) => number
112
- ): number | (() => number) {
113
- // Handle getter function (inline derived)
114
- if (typeof prop === 'function') {
115
- return () => converter(prop())
116
- }
117
- // Handle object with .value (signal/binding/derived)
118
- if (prop !== undefined && typeof prop === 'object' && prop !== null && 'value' in prop) {
119
- const reactiveSource = prop as { value: T }
120
- return () => converter(reactiveSource.value)
121
- }
122
- // Static value
123
- return converter(prop as T | undefined)
124
- }
125
-
126
103
  // =============================================================================
127
104
  // TEXT COMPONENT
128
105
  // =============================================================================
@@ -232,41 +232,6 @@ export interface InputProps extends StyleProps, BorderProps, DimensionProps, Spa
232
232
  onBlur?: () => void
233
233
  }
234
234
 
235
- // =============================================================================
236
- // SELECT PROPS
237
- // =============================================================================
238
-
239
- export interface SelectOption {
240
- value: string
241
- label: string
242
- }
243
-
244
- export interface SelectProps extends StyleProps, BorderProps, DimensionProps {
245
- /** Selected value (two-way bound) */
246
- value: WritableSignal<string> | Binding<string>
247
- /** Available options */
248
- options: SelectOption[]
249
- /** Placeholder when nothing selected */
250
- placeholder?: string
251
- /** Is visible */
252
- visible?: Reactive<boolean>
253
- /** Called when selection changes */
254
- onChange?: (value: string) => void
255
- }
256
-
257
- // =============================================================================
258
- // PROGRESS PROPS
259
- // =============================================================================
260
-
261
- export interface ProgressProps extends StyleProps, DimensionProps {
262
- /** Progress value 0-1 */
263
- value: Reactive<number>
264
- /** Show percentage text */
265
- showPercent?: boolean
266
- /** Is visible */
267
- visible?: Reactive<boolean>
268
- }
269
-
270
235
  // =============================================================================
271
236
  // COMPONENT RETURN TYPE
272
237
  // =============================================================================
@@ -35,3 +35,29 @@ export function getValue<T>(prop: T | { value: T } | (() => T) | undefined, defa
35
35
  if (typeof prop === 'object' && prop !== null && 'value' in prop) return (prop as { value: T }).value
36
36
  return prop as T
37
37
  }
38
+
39
+ // =============================================================================
40
+ // ENUM SOURCE
41
+ // =============================================================================
42
+
43
+ /**
44
+ * Create a slot source for enum props - returns getter for reactive, value for static.
45
+ * For use with slotArray.setSource()
46
+ * Handles: static values, signals/bindings ({ value: T }), and getter functions (() => T)
47
+ */
48
+ export function enumSource<T extends string>(
49
+ prop: T | { value: T } | (() => T) | undefined,
50
+ converter: (val: T | undefined) => number
51
+ ): number | (() => number) {
52
+ // Handle getter function (inline derived)
53
+ if (typeof prop === 'function') {
54
+ return () => converter(prop())
55
+ }
56
+ // Handle object with .value (signal/binding/derived)
57
+ if (prop !== undefined && typeof prop === 'object' && prop !== null && 'value' in prop) {
58
+ const reactiveSource = prop as { value: T }
59
+ return () => converter(reactiveSource.value)
60
+ }
61
+ // Static value
62
+ return converter(prop as T | undefined)
63
+ }
@@ -111,6 +111,11 @@ function getBlinkClock(fps: number): BlinkRegistry {
111
111
  }
112
112
 
113
113
  function subscribeToBlink(fps: number): () => void {
114
+ // Guard against invalid fps (0 or negative would cause Infinity interval)
115
+ if (fps <= 0) {
116
+ return () => {} // No-op unsubscribe
117
+ }
118
+
114
119
  const registry = getBlinkClock(fps)
115
120
  registry.subscribers++
116
121