@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 +25 -256
- package/index.ts +3 -1
- package/package.json +1 -1
- package/src/api/mount.ts +6 -2
- package/src/pipeline/frameBuffer.ts +20 -0
- package/src/primitives/box.ts +53 -1
- package/src/primitives/index.ts +1 -1
- package/src/primitives/input.ts +19 -0
- package/src/primitives/text.ts +19 -0
- package/src/primitives/types.ts +30 -3
package/README.md
CHANGED
|
@@ -1,274 +1,43 @@
|
|
|
1
|
-
# TUI
|
|
1
|
+
# TUI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A reactive terminal UI framework for TypeScript and Bun.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
Shows or hides components based on a reactive condition.
|
|
11
|
+
## Quick Example
|
|
179
12
|
|
|
180
13
|
```typescript
|
|
181
|
-
import {
|
|
14
|
+
import { signal, box, text, mount, keyboard } from '@rlabs-inc/tui'
|
|
182
15
|
|
|
183
|
-
const
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
{
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
238
|
-
|
|
28
|
+
keyboard.onKey('+', () => { count.value++ })
|
|
29
|
+
keyboard.onKey('q', () => cleanup())
|
|
239
30
|
```
|
|
240
31
|
|
|
241
|
-
|
|
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
|
-
##
|
|
34
|
+
## Core Concepts
|
|
269
35
|
|
|
270
|
-
|
|
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
|
-
|
|
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
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
|
-
|
|
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
|
})
|
package/src/primitives/box.ts
CHANGED
|
@@ -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
|
}
|
package/src/primitives/index.ts
CHANGED
|
@@ -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'
|
package/src/primitives/input.ts
CHANGED
|
@@ -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)
|
package/src/primitives/text.ts
CHANGED
|
@@ -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
|
}
|
package/src/primitives/types.ts
CHANGED
|
@@ -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) */
|