@rlabs-inc/tui 0.6.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,274 +1,43 @@
1
- # TUI Framework
1
+ # TUI
2
2
 
3
- **The Terminal UI Framework for TypeScript/Bun**
3
+ A reactive terminal UI framework for TypeScript and Bun.
4
4
 
5
- A blazing-fast, fine-grained reactive terminal UI framework with complete flexbox layout and zero CPU when idle.
6
-
7
- ## Performance
8
-
9
- | Metric | Value |
10
- |--------|-------|
11
- | Single update latency | 0.028ms |
12
- | Updates per second | 41,000+ |
13
- | Layout (10K components) | 0.66ms |
14
- | Memory per component | ~500 bytes |
15
-
16
- ## Quick Start
5
+ ## Install
17
6
 
18
7
  ```bash
19
- bun add tui @rlabs-inc/signals
20
- ```
21
-
22
- ```typescript
23
- import { mount, box, text, derived } from 'tui'
24
- import { signal } from '@rlabs-inc/signals'
25
-
26
- // Create reactive state
27
- const count = signal(0)
28
- const doubled = derived(() => count.value * 2)
29
-
30
- // Build your UI
31
- box({ width: 40, height: 10, children: () => {
32
- text({ content: count }) // signal directly
33
- text({ content: doubled }) // derived directly
34
- text({ content: () => `Count: ${count.value}` }) // () => for formatting
35
- }})
36
-
37
- // Mount to terminal
38
- mount()
39
-
40
- // Update anywhere - UI reacts automatically
41
- setInterval(() => count.value++, 1000)
42
- ```
43
-
44
- ## Features
45
-
46
- ### Complete Flexbox Layout (TITAN Engine)
47
- - Direction: row, column, row-reverse, column-reverse
48
- - Wrap: nowrap, wrap, wrap-reverse
49
- - Justify: flex-start, center, flex-end, space-between, space-around, space-evenly
50
- - Align: stretch, flex-start, center, flex-end
51
- - Grow, shrink, basis, gap
52
- - Min/max constraints
53
- - Percentage dimensions
54
-
55
- ### Fine-Grained Reactivity
56
- - Signals for primitive values
57
- - Derived for computed values
58
- - Effects for side effects
59
- - Bindings for prop connections
60
- - Zero reconciliation - reactivity IS the update mechanism
61
- - **Clean syntax**: Pass signals/deriveds directly, use `() =>` only for inline computations
62
-
63
- ### State Modules
64
- - **keyboard** - Key events, shortcuts, input buffering
65
- - **mouse** - Click, hover, drag, wheel events
66
- - **focus** - Tab navigation, focus trapping
67
- - **scroll** - Scroll state and navigation
68
- - **theme** - 14 color variants, theming
69
-
70
- ## Architecture
71
-
72
- ```
73
- User Signals → bind() → Parallel Arrays → layoutDerived → frameBufferDerived → render
74
- ```
75
-
76
- ### Why So Fast?
77
-
78
- 1. **Parallel Arrays** - ECS-like data layout for cache efficiency
79
- 2. **No Reconciliation** - Fine-grained reactivity replaces diffing
80
- 3. **Single Effect** - One render effect for entire app
81
- 4. **TITAN Layout** - O(n) flexbox in pure TypeScript
82
- 5. **Zero CPU Idle** - Only updates when signals change
83
-
84
- ## Examples
85
-
86
- ```bash
87
- # Run examples
88
- bun run examples/hello.ts
89
- bun run examples/showcase.ts
90
-
91
- # Run tests
92
- bun run examples/tests/01-box-basics.ts
93
- bun run examples/tests/03-layout-flex.ts
94
- ```
95
-
96
- ## Documentation
97
-
98
- ### Getting Started
99
- - [Installation](./docs/getting-started/installation.md) - Requirements and setup
100
- - [Quick Start](./docs/getting-started/quick-start.md) - Hello world in 5 minutes
101
- - [Core Concepts](./docs/getting-started/concepts.md) - Understand the fundamentals
102
- - [First App Tutorial](./docs/getting-started/first-app.md) - Build a complete app
103
-
104
- ### User Guides
105
- - [Primitives](./docs/guides/primitives/) - box, text, each, show, when
106
- - [Layout](./docs/guides/layout/flexbox.md) - Flexbox layout system
107
- - [Styling](./docs/guides/styling/colors.md) - Colors, themes, borders
108
- - [Reactivity](./docs/guides/reactivity/signals.md) - Signals and reactivity
109
- - [Patterns](./docs/guides/patterns/component-patterns.md) - Building components
110
-
111
- ### Reference
112
- - [API Reference](./docs/api/README.md) - Complete API documentation
113
- - [Architecture](./docs/contributing/architecture.md) - Deep dive into internals
114
- - [Examples](./docs/examples/README.md) - 100+ working examples
115
-
116
- ### Contributing
117
- - [Development Setup](./docs/contributing/development.md) - Get started contributing
118
- - [Internals](./docs/contributing/internals/) - Engine deep-dives
119
-
120
- ## Primitives
121
-
122
- ### UI Primitives
123
-
124
- | Primitive | Status | Description |
125
- |-----------|--------|-------------|
126
- | `box` | Complete | Container with flexbox layout |
127
- | `text` | Complete | Text display with styling |
128
- | `input` | Planned | Text input field |
129
- | `select` | Planned | Dropdown selection |
130
- | `progress` | Planned | Progress bar |
131
-
132
- ### Template Primitives
133
-
134
- Reactive control flow for dynamic UIs - no manual effects needed!
135
-
136
- | Primitive | Purpose | Description |
137
- |-----------|---------|-------------|
138
- | `each()` | Lists | Reactive list rendering with keyed reconciliation |
139
- | `show()` | Conditionals | Show/hide components based on condition |
140
- | `when()` | Async | Suspense-like pending/success/error states |
141
-
142
- #### `each()` - Reactive Lists
143
-
144
- Renders a list of components that automatically updates when the array changes.
145
-
146
- ```typescript
147
- import { each, box, text, signal } from '@rlabs-inc/tui'
148
-
149
- const todos = signal([
150
- { id: '1', text: 'Learn TUI', done: false },
151
- { id: '2', text: 'Build app', done: false },
152
- ])
153
-
154
- box({
155
- children: () => {
156
- each(
157
- () => todos.value, // Reactive array getter
158
- (todo) => box({ // Render function per item
159
- id: `todo-${todo.id}`, // Stable ID for reconciliation
160
- children: () => {
161
- text({ content: todo.text }) // Static value from item
162
- }
163
- }),
164
- { key: (todo) => todo.id } // Key function for efficient updates
165
- )
166
- }
167
- })
168
-
169
- // Add item - UI updates automatically
170
- todos.value = [...todos.value, { id: '3', text: 'Deploy', done: false }]
171
-
172
- // Remove item - component is cleaned up automatically
173
- todos.value = todos.value.filter(t => t.id !== '1')
8
+ bun add @rlabs-inc/tui
174
9
  ```
175
10
 
176
- #### `show()` - Conditional Rendering
177
-
178
- Shows or hides components based on a reactive condition.
11
+ ## Quick Example
179
12
 
180
13
  ```typescript
181
- import { show, box, text, signal } from '@rlabs-inc/tui'
14
+ import { signal, box, text, mount, keyboard } from '@rlabs-inc/tui'
182
15
 
183
- const isLoggedIn = signal(false)
184
-
185
- box({
186
- children: () => {
187
- show(
188
- () => isLoggedIn.value, // Condition getter
189
- () => box({ // Render when true
190
- children: () => {
191
- text({ content: 'Welcome back!' })
192
- }
193
- }),
194
- () => text({ content: 'Please log in' }) // Optional: render when false
195
- )
196
- }
197
- })
198
-
199
- // Toggle - UI switches automatically
200
- isLoggedIn.value = true
201
- ```
202
-
203
- #### `when()` - Async/Suspense
204
-
205
- Handles async operations with loading, success, and error states.
206
-
207
- ```typescript
208
- import { when, box, text, signal } from '@rlabs-inc/tui'
209
-
210
- const userId = signal('123')
211
-
212
- // Fetch function that returns a promise
213
- const fetchUser = (id: string) =>
214
- fetch(`/api/users/${id}`).then(r => r.json())
16
+ const count = signal(0)
215
17
 
216
- box({
217
- children: () => {
218
- when(
219
- () => fetchUser(userId.value), // Promise getter (re-runs on userId change)
220
- {
221
- pending: () => text({ content: 'Loading...' }),
222
- then: (user) => box({
223
- children: () => {
224
- text({ content: `Name: ${user.name}` })
225
- text({ content: `Email: ${user.email}` })
226
- }
227
- }),
228
- catch: (error) => text({
229
- content: `Error: ${error.message}`,
230
- fg: 'red'
231
- })
232
- }
233
- )
234
- }
18
+ const cleanup = await mount(() => {
19
+ box({
20
+ padding: 1,
21
+ children: () => {
22
+ text({ content: () => `Count: ${count.value}` })
23
+ text({ content: 'Press + to increment, q to quit' })
24
+ }
25
+ })
235
26
  })
236
27
 
237
- // Change userId - triggers new fetch, shows loading, then result
238
- userId.value = '456'
28
+ keyboard.onKey('+', () => { count.value++ })
29
+ keyboard.onKey('q', () => cleanup())
239
30
  ```
240
31
 
241
- ### How Template Primitives Work
242
-
243
- All template primitives follow the same elegant pattern:
244
-
245
- 1. **Capture parent context** at creation time
246
- 2. **Initial render synchronously** (correct parent hierarchy)
247
- 3. **Internal effect** tracks reactive dependencies
248
- 4. **Reconcile on change** (create new, cleanup removed)
249
-
250
- This means:
251
- - User code stays clean - no manual effects
252
- - Props inside templates are fully reactive
253
- - Cleanup is automatic
254
- - Performance is optimal (only affected components update)
255
-
256
- ## Test Coverage
257
-
258
- - TITAN layout engine: 48 tests
259
- - Parallel arrays: 17 tests
260
- - Focus manager: 29 tests
261
-
262
- ## Requirements
263
-
264
- - Bun 1.0+ (for runtime and build)
265
- - Node.js 18+ (for compatibility)
266
- - Terminal with ANSI support
32
+ Run with `bun run your-file.ts`.
267
33
 
268
- ## License
34
+ ## Core Concepts
269
35
 
270
- MIT
36
+ - **Signals** - Reactive state: `signal(0)` creates a value that triggers updates when changed
37
+ - **Primitives** - UI building blocks: `box`, `text`, `input`
38
+ - **Reactivity** - Use `() =>` functions for dynamic values that update automatically
39
+ - **Keyboard** - `keyboard.onKey('Enter', handler)` for input handling
271
40
 
272
- ---
41
+ ## Next Steps
273
42
 
274
- Built with love by Rusty & Watson in São Paulo, Brazil.
43
+ - [Quick Start Guide](./getting-started/quickstart.md) - Build your first app step by step
package/index.ts CHANGED
@@ -10,7 +10,7 @@ export { mount } from './src/api'
10
10
 
11
11
  // Primitives - UI building blocks
12
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'
13
+ export type { BoxProps, TextProps, InputProps, CursorConfig, CursorStyle, Cleanup, AnimationOptions, MouseProps } from './src/primitives'
14
14
 
15
15
  // Lifecycle hooks - Component mount/destroy callbacks
16
16
  export { onMount, onDestroy } from './src/engine/lifecycle'
@@ -21,6 +21,7 @@ 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 type { KeyHandler, KeyboardEvent as TuiKeyboardEvent, Modifiers } from './src/state/keyboard'
24
25
  export {
25
26
  mouse,
26
27
  hitGrid,
@@ -34,6 +35,7 @@ export {
34
35
  onScroll,
35
36
  onComponent,
36
37
  } from './src/state/mouse'
38
+ export type { MouseEvent as TuiMouseEvent, MouseHandler, MouseHandlers } from './src/state/mouse'
37
39
  export { focusManager, focusedIndex, pushFocusTrap, popFocusTrap, isFocusTrapped, getFocusTrapContainer } from './src/state/focus'
38
40
  export { scroll } from './src/state/scroll'
39
41
  export { globalKeys } from './src/state/global-keys'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rlabs-inc/tui",
3
- "version": "0.6.1",
3
+ "version": "0.8.2",
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
@@ -31,7 +31,7 @@ import {
31
31
  import { AppendRegionRenderer } from '../renderer/append-region'
32
32
  import { HistoryWriter, createRenderToHistory } from './history'
33
33
  import * as ansi from '../renderer/ansi'
34
- import { frameBufferDerived } from '../pipeline/frameBuffer'
34
+ import { frameBufferDerived, _dbg_layout_us, _dbg_buf_us, _dbg_map_us, _dbg_render_us } from '../pipeline/frameBuffer'
35
35
  import { layoutDerived, terminalWidth, terminalHeight, updateTerminalSize, renderMode } from '../pipeline/layout'
36
36
  import { resetRegistry } from '../engine/registry'
37
37
  import { hitGrid, clearHitGrid, mouse } from '../state/mouse'
@@ -219,7 +219,11 @@ export async function mount(
219
219
  const bufferMs = bufferNs / 1_000_000
220
220
  const renderMs = renderNs / 1_000_000
221
221
  const totalMs = totalNs / 1_000_000
222
- process.stdout.write(`\x1b]0;TUI | h:${computedLayout.contentHeight} | layout: ${layoutMs.toFixed(3)}ms | buffer: ${bufferMs.toFixed(3)}ms | render: ${renderMs.toFixed(3)}ms | total: ${totalMs.toFixed(3)}ms\x07`)
222
+ // DEBUG: Internal breakdown + mount.ts comparison
223
+ const buffer_us = bufferNs / 1000
224
+ const internal = _dbg_layout_us + _dbg_buf_us + _dbg_map_us + _dbg_render_us
225
+ const gap = buffer_us - internal
226
+ process.stdout.write(`\x1b]0;l:${_dbg_layout_us.toFixed(0)} b:${_dbg_buf_us.toFixed(0)} m:${_dbg_map_us.toFixed(0)} r:${_dbg_render_us.toFixed(0)} | buf:${buffer_us.toFixed(0)} int:${internal.toFixed(0)} gap:${gap.toFixed(0)} μs\x07`)
223
227
  } catch (err) {
224
228
  console.error('[TUI] Render effect error:', err)
225
229
  }
@@ -87,8 +87,19 @@ export interface FrameBufferResult {
87
87
  * - FrameBufferResult containing buffer, hitRegions, and terminal size
88
88
  * - hitRegions should be applied to HitGrid by the render effect (no side effects here!)
89
89
  */
90
+ // DEBUG: Export timing for mount.ts to display
91
+ export let _dbg_layout_us = 0
92
+ export let _dbg_buf_us = 0
93
+ export let _dbg_map_us = 0
94
+ export let _dbg_render_us = 0
95
+
90
96
  export const frameBufferDerived = derived((): FrameBufferResult => {
97
+ // DEBUG TIMING
98
+ const t0 = Bun.nanoseconds()
99
+
91
100
  const computedLayout = layoutDerived.value
101
+ const t1 = Bun.nanoseconds()
102
+
92
103
  const tw = terminalWidth.value
93
104
  const th = terminalHeight.value
94
105
  const mode = renderMode.value
@@ -106,6 +117,7 @@ export const frameBufferDerived = derived((): FrameBufferResult => {
106
117
 
107
118
  // Create fresh buffer with terminal default background
108
119
  const buffer = createBuffer(bufferWidth, bufferHeight, TERMINAL_DEFAULT)
120
+ const t2 = Bun.nanoseconds()
109
121
 
110
122
  const indices = getAllocatedIndices()
111
123
  if (indices.size === 0) {
@@ -141,6 +153,7 @@ export const frameBufferDerived = derived((): FrameBufferResult => {
141
153
  for (const children of childMap.values()) {
142
154
  children.sort((a, b) => (layout.zIndex[a] || 0) - (layout.zIndex[b] || 0))
143
155
  }
156
+ const t3 = Bun.nanoseconds()
144
157
 
145
158
  // Render tree recursively
146
159
  for (const rootIdx of rootIndices) {
@@ -155,6 +168,13 @@ export const frameBufferDerived = derived((): FrameBufferResult => {
155
168
  0 // No parent scroll X
156
169
  )
157
170
  }
171
+ const t4 = Bun.nanoseconds()
172
+
173
+ // Store timing in exported vars for mount.ts to display
174
+ _dbg_layout_us = (t1 - t0) / 1000
175
+ _dbg_buf_us = (t2 - t1) / 1000
176
+ _dbg_map_us = (t3 - t2) / 1000
177
+ _dbg_render_us = (t4 - t3) / 1000
158
178
 
159
179
  return { buffer, hitRegions, terminalSize: { width: bufferWidth, height: bufferHeight } }
160
180
  })
@@ -35,7 +35,9 @@ import {
35
35
  popCurrentComponent,
36
36
  runMountCallbacks,
37
37
  } from '../engine/lifecycle'
38
- import { cleanupIndex as cleanupKeyboardListeners } from '../state/keyboard'
38
+ import { cleanupIndex as cleanupKeyboardListeners, onFocused } from '../state/keyboard'
39
+ import { registerFocusCallbacks, focus as focusComponent } from '../state/focus'
40
+ import { onComponent as onMouseComponent } from '../state/mouse'
39
41
  import { getVariantStyle } from '../state/theme'
40
42
  import { getActiveScope } from './scope'
41
43
  import { enumSource } from './utils'
@@ -232,6 +234,53 @@ export function box(props: BoxProps = {}): Cleanup {
232
234
  if (props.tabIndex !== undefined) interaction.tabIndex.setSource(index, props.tabIndex)
233
235
  }
234
236
 
237
+ // ==========================================================================
238
+ // FOCUS CALLBACKS & KEYBOARD HANDLER
239
+ // Only registered when focusable AND callbacks provided
240
+ // ==========================================================================
241
+ let unsubKeyboard: (() => void) | undefined
242
+ let unsubFocusCallbacks: (() => void) | undefined
243
+
244
+ if (shouldBeFocusable) {
245
+ // Register keyboard handler (fires only when this box has focus)
246
+ if (props.onKey) {
247
+ unsubKeyboard = onFocused(index, props.onKey)
248
+ }
249
+
250
+ // Register focus/blur callbacks (fires at the source - focus manager)
251
+ if (props.onFocus || props.onBlur) {
252
+ unsubFocusCallbacks = registerFocusCallbacks(index, {
253
+ onFocus: props.onFocus,
254
+ onBlur: props.onBlur,
255
+ })
256
+ }
257
+ }
258
+
259
+ // ==========================================================================
260
+ // MOUSE HANDLERS
261
+ // Registered when focusable (for click-to-focus) OR any mouse callback provided
262
+ // ==========================================================================
263
+ let unsubMouse: (() => void) | undefined
264
+
265
+ const hasMouseHandlers = props.onMouseDown || props.onMouseUp || props.onClick || props.onMouseEnter || props.onMouseLeave || props.onScroll
266
+
267
+ if (shouldBeFocusable || hasMouseHandlers) {
268
+ unsubMouse = onMouseComponent(index, {
269
+ onMouseDown: props.onMouseDown,
270
+ onMouseUp: props.onMouseUp,
271
+ // Click-to-focus: focus this component on click (if focusable), then call user's handler
272
+ onClick: (event) => {
273
+ if (shouldBeFocusable) {
274
+ focusComponent(index)
275
+ }
276
+ return props.onClick?.(event)
277
+ },
278
+ onMouseEnter: props.onMouseEnter,
279
+ onMouseLeave: props.onMouseLeave,
280
+ onScroll: props.onScroll,
281
+ })
282
+ }
283
+
235
284
  // ==========================================================================
236
285
  // VISUAL - Colors and borders (only bind what's passed)
237
286
  // ==========================================================================
@@ -284,6 +333,9 @@ export function box(props: BoxProps = {}): Cleanup {
284
333
 
285
334
  // Cleanup function
286
335
  const cleanup = () => {
336
+ unsubFocusCallbacks?.()
337
+ unsubMouse?.()
338
+ unsubKeyboard?.()
287
339
  cleanupKeyboardListeners(index) // Remove any focused key handlers
288
340
  releaseIndex(index)
289
341
  }
@@ -15,6 +15,6 @@ export { scoped, onCleanup, componentScope, cleanupCollector } from './scope'
15
15
  export { useAnimation, AnimationFrames } from './animation'
16
16
 
17
17
  // Types
18
- export type { BoxProps, TextProps, InputProps, CursorConfig, CursorStyle, BlinkConfig, Cleanup } from './types'
18
+ export type { BoxProps, TextProps, InputProps, CursorConfig, CursorStyle, BlinkConfig, Cleanup, MouseProps } from './types'
19
19
  export type { ComponentScopeResult } from './scope'
20
20
  export type { AnimationOptions } from './animation'
@@ -32,6 +32,7 @@ import {
32
32
  runMountCallbacks,
33
33
  } from '../engine/lifecycle'
34
34
  import { cleanupIndex as cleanupKeyboardListeners, onFocused } from '../state/keyboard'
35
+ import { onComponent as onMouseComponent } from '../state/mouse'
35
36
  import { getVariantStyle, t } from '../state/theme'
36
37
  import { focus as focusComponent, registerFocusCallbacks } from '../state/focus'
37
38
  import { createCursor, disposeCursor } from '../state/drawnCursor'
@@ -322,6 +323,23 @@ export function input(props: InputProps): Cleanup {
322
323
  onBlur: props.onBlur,
323
324
  })
324
325
 
326
+ // ==========================================================================
327
+ // MOUSE HANDLERS
328
+ // Always registered for inputs (click-to-focus) + any user callbacks
329
+ // ==========================================================================
330
+ const unsubMouse = onMouseComponent(index, {
331
+ onMouseDown: props.onMouseDown,
332
+ onMouseUp: props.onMouseUp,
333
+ // Click-to-focus: inputs are always focusable, so click always focuses
334
+ onClick: (event) => {
335
+ focusComponent(index)
336
+ return props.onClick?.(event)
337
+ },
338
+ onMouseEnter: props.onMouseEnter,
339
+ onMouseLeave: props.onMouseLeave,
340
+ onScroll: props.onScroll,
341
+ })
342
+
325
343
  // ==========================================================================
326
344
  // AUTO FOCUS
327
345
  // ==========================================================================
@@ -343,6 +361,7 @@ export function input(props: InputProps): Cleanup {
343
361
 
344
362
  const cleanup = () => {
345
363
  unsubFocusCallbacks()
364
+ unsubMouse()
346
365
  cursor.dispose()
347
366
  unsubKeyboard()
348
367
  cleanupKeyboardListeners(index)
@@ -32,6 +32,7 @@ import {
32
32
  runMountCallbacks,
33
33
  } from '../engine/lifecycle'
34
34
  import { cleanupIndex as cleanupKeyboardListeners } from '../state/keyboard'
35
+ import { onComponent as onMouseComponent } from '../state/mouse'
35
36
  import { getVariantStyle } from '../state/theme'
36
37
  import { getActiveScope } from './scope'
37
38
  import { enumSource } from './utils'
@@ -200,12 +201,30 @@ export function text(props: TextProps): Cleanup {
200
201
  }
201
202
  if (props.opacity !== undefined) visual.opacity.setSource(index, props.opacity)
202
203
 
204
+ // ==========================================================================
205
+ // MOUSE HANDLERS
206
+ // Registered when any mouse callback provided
207
+ // ==========================================================================
208
+ let unsubMouse: (() => void) | undefined
209
+
210
+ if (props.onMouseDown || props.onMouseUp || props.onClick || props.onMouseEnter || props.onMouseLeave || props.onScroll) {
211
+ unsubMouse = onMouseComponent(index, {
212
+ onMouseDown: props.onMouseDown,
213
+ onMouseUp: props.onMouseUp,
214
+ onClick: props.onClick,
215
+ onMouseEnter: props.onMouseEnter,
216
+ onMouseLeave: props.onMouseLeave,
217
+ onScroll: props.onScroll,
218
+ })
219
+ }
220
+
203
221
  // Component setup complete - run lifecycle callbacks
204
222
  popCurrentComponent()
205
223
  runMountCallbacks(index)
206
224
 
207
225
  // Cleanup function
208
226
  const cleanup = () => {
227
+ unsubMouse?.()
209
228
  cleanupKeyboardListeners(index)
210
229
  releaseIndex(index)
211
230
  }
@@ -8,6 +8,8 @@
8
8
  import type { RGBA, CellAttrs, Dimension } from '../types'
9
9
  import type { WritableSignal, Binding, ReadonlyBinding } from '@rlabs-inc/signals'
10
10
  import type { Variant } from '../state/theme'
11
+ import type { KeyHandler } from '../state/keyboard'
12
+ import type { MouseEvent, MouseHandler } from '../state/mouse'
11
13
 
12
14
  // =============================================================================
13
15
  // REACTIVE PROP TYPES
@@ -122,11 +124,26 @@ export interface InteractionProps {
122
124
  tabIndex?: Reactive<number>
123
125
  }
124
126
 
127
+ export interface MouseProps {
128
+ /** Called on mouse down over this component. Return true to consume event. */
129
+ onMouseDown?: (event: MouseEvent) => void | boolean
130
+ /** Called on mouse up over this component. Return true to consume event. */
131
+ onMouseUp?: (event: MouseEvent) => void | boolean
132
+ /** Called on click (down + up on same component). Return true to consume event. */
133
+ onClick?: (event: MouseEvent) => void | boolean
134
+ /** Called when mouse enters this component */
135
+ onMouseEnter?: (event: MouseEvent) => void
136
+ /** Called when mouse leaves this component */
137
+ onMouseLeave?: (event: MouseEvent) => void
138
+ /** Called on scroll over this component. Return true to consume event. */
139
+ onScroll?: (event: MouseEvent) => void | boolean
140
+ }
141
+
125
142
  // =============================================================================
126
143
  // BOX PROPS
127
144
  // =============================================================================
128
145
 
129
- export interface BoxProps extends StyleProps, BorderProps, DimensionProps, SpacingProps, LayoutProps, InteractionProps {
146
+ export interface BoxProps extends StyleProps, BorderProps, DimensionProps, SpacingProps, LayoutProps, InteractionProps, MouseProps {
130
147
  /** Component ID (optional, auto-generated if not provided) */
131
148
  id?: string
132
149
  /** Is visible */
@@ -138,13 +155,23 @@ export interface BoxProps extends StyleProps, BorderProps, DimensionProps, Spaci
138
155
  * Variants: 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info' | 'ghost' | 'outline'
139
156
  */
140
157
  variant?: Variant
158
+ /**
159
+ * Keyboard handler - fires only when this box has focus.
160
+ * Return true to consume the event (prevent propagation).
161
+ * Requires focusable: true or overflow: 'scroll'.
162
+ */
163
+ onKey?: KeyHandler
164
+ /** Called when this box receives focus */
165
+ onFocus?: () => void
166
+ /** Called when this box loses focus */
167
+ onBlur?: () => void
141
168
  }
142
169
 
143
170
  // =============================================================================
144
171
  // TEXT PROPS
145
172
  // =============================================================================
146
173
 
147
- export interface TextProps extends StyleProps, DimensionProps, SpacingProps, LayoutProps {
174
+ export interface TextProps extends StyleProps, DimensionProps, SpacingProps, LayoutProps, MouseProps {
148
175
  /** Component ID (optional, auto-generated if not provided) */
149
176
  id?: string
150
177
  /** Text content (strings and numbers auto-converted) */
@@ -192,7 +219,7 @@ export interface CursorConfig {
192
219
  bg?: Reactive<RGBA>
193
220
  }
194
221
 
195
- export interface InputProps extends StyleProps, BorderProps, DimensionProps, SpacingProps, InteractionProps {
222
+ export interface InputProps extends StyleProps, BorderProps, DimensionProps, SpacingProps, InteractionProps, MouseProps {
196
223
  /** Component ID (optional, auto-generated if not provided) */
197
224
  id?: string
198
225
  /** Current value (two-way bound) */