@rlabs-inc/tui 0.6.0 → 0.8.1

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
@@ -16,26 +16,24 @@ A blazing-fast, fine-grained reactive terminal UI framework with complete flexbo
16
16
  ## Quick Start
17
17
 
18
18
  ```bash
19
- bun add tui @rlabs-inc/signals
19
+ bun add @rlabs-inc/tui
20
20
  ```
21
21
 
22
22
  ```typescript
23
- import { mount, box, text, derived } from 'tui'
24
- import { signal } from '@rlabs-inc/signals'
23
+ import { mount, box, text, signal, derived } from '@rlabs-inc/tui'
25
24
 
26
25
  // Create reactive state
27
26
  const count = signal(0)
28
27
  const doubled = derived(() => count.value * 2)
29
28
 
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()
29
+ // Mount to terminal - build UI inside mount()
30
+ mount(() => {
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
+ })
39
37
 
40
38
  // Update anywhere - UI reacts automatically
41
39
  setInterval(() => count.value++, 1000)
@@ -85,12 +83,13 @@ User Signals → bind() → Parallel Arrays → layoutDerived → frameBufferDer
85
83
 
86
84
  ```bash
87
85
  # Run examples
88
- bun run examples/hello.ts
89
- bun run examples/showcase.ts
86
+ bun run examples/showcase/01-hello-counter.ts
87
+ bun run examples/showcase/08-todo-app.ts
88
+ bun run examples/showcase/04-dashboard.ts
90
89
 
91
- # Run tests
92
- bun run examples/tests/01-box-basics.ts
90
+ # Run visual tests
93
91
  bun run examples/tests/03-layout-flex.ts
92
+ bun run examples/tests/05-flex-complete.ts
94
93
  ```
95
94
 
96
95
  ## Documentation
@@ -102,7 +101,7 @@ bun run examples/tests/03-layout-flex.ts
102
101
  - [First App Tutorial](./docs/getting-started/first-app.md) - Build a complete app
103
102
 
104
103
  ### User Guides
105
- - [Primitives](./docs/guides/primitives/) - box, text, each, show, when
104
+ - [Primitives](./docs/guides/primitives/box.md) - box, text, each, show, when
106
105
  - [Layout](./docs/guides/layout/flexbox.md) - Flexbox layout system
107
106
  - [Styling](./docs/guides/styling/colors.md) - Colors, themes, borders
108
107
  - [Reactivity](./docs/guides/reactivity/signals.md) - Signals and reactivity
@@ -125,7 +124,7 @@ bun run examples/tests/03-layout-flex.ts
125
124
  |-----------|--------|-------------|
126
125
  | `box` | Complete | Container with flexbox layout |
127
126
  | `text` | Complete | Text display with styling |
128
- | `input` | Planned | Text input field |
127
+ | `input` | Complete | Text input field |
129
128
  | `select` | Planned | Dropdown selection |
130
129
  | `progress` | Planned | Progress bar |
131
130
 
@@ -144,26 +143,28 @@ Reactive control flow for dynamic UIs - no manual effects needed!
144
143
  Renders a list of components that automatically updates when the array changes.
145
144
 
146
145
  ```typescript
147
- import { each, box, text, signal } from '@rlabs-inc/tui'
146
+ import { mount, signal, each, box, text } from '@rlabs-inc/tui'
148
147
 
149
148
  const todos = signal([
150
149
  { id: '1', text: 'Learn TUI', done: false },
151
150
  { id: '2', text: 'Build app', done: false },
152
151
  ])
153
152
 
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
- }
153
+ mount(() => {
154
+ box({
155
+ children: () => {
156
+ each(
157
+ () => todos.value, // Reactive array getter
158
+ (getItem, key) => box({ // Render function: getItem() + stable key
159
+ id: `todo-${key}`, // Use key for stable ID
160
+ children: () => {
161
+ text({ content: () => getItem().text }) // Call getItem() to access value
162
+ }
163
+ }),
164
+ { key: (todo) => todo.id } // Key function for efficient updates
165
+ )
166
+ }
167
+ })
167
168
  })
168
169
 
169
170
  // Add item - UI updates automatically
@@ -178,22 +179,24 @@ todos.value = todos.value.filter(t => t.id !== '1')
178
179
  Shows or hides components based on a reactive condition.
179
180
 
180
181
  ```typescript
181
- import { show, box, text, signal } from '@rlabs-inc/tui'
182
+ import { mount, show, box, text, signal } from '@rlabs-inc/tui'
182
183
 
183
184
  const isLoggedIn = signal(false)
184
185
 
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
- }
186
+ mount(() => {
187
+ box({
188
+ children: () => {
189
+ show(
190
+ () => isLoggedIn.value, // Condition getter
191
+ () => box({ // Render when true
192
+ children: () => {
193
+ text({ content: 'Welcome back!' })
194
+ }
195
+ }),
196
+ () => text({ content: 'Please log in' }) // Optional: render when false
197
+ )
198
+ }
199
+ })
197
200
  })
198
201
 
199
202
  // Toggle - UI switches automatically
@@ -205,7 +208,7 @@ isLoggedIn.value = true
205
208
  Handles async operations with loading, success, and error states.
206
209
 
207
210
  ```typescript
208
- import { when, box, text, signal } from '@rlabs-inc/tui'
211
+ import { mount, when, box, text, signal } from '@rlabs-inc/tui'
209
212
 
210
213
  const userId = signal('123')
211
214
 
@@ -213,25 +216,27 @@ const userId = signal('123')
213
216
  const fetchUser = (id: string) =>
214
217
  fetch(`/api/users/${id}`).then(r => r.json())
215
218
 
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
- }
219
+ mount(() => {
220
+ box({
221
+ children: () => {
222
+ when(
223
+ () => fetchUser(userId.value), // Promise getter (re-runs on userId change)
224
+ {
225
+ pending: () => text({ content: 'Loading...' }),
226
+ then: (user) => box({
227
+ children: () => {
228
+ text({ content: `Name: ${user.name}` })
229
+ text({ content: `Email: ${user.email}` })
230
+ }
231
+ }),
232
+ catch: (error) => text({
233
+ content: `Error: ${error.message}`,
234
+ fg: 'red'
235
+ })
236
+ }
237
+ )
238
+ }
239
+ })
235
240
  })
236
241
 
237
242
  // Change userId - triggers new fetch, shows loading, then result
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.0",
3
+ "version": "0.8.1",
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",
@@ -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) */