@rlabs-inc/tui 0.3.2 → 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 +23 -4
- package/package.json +2 -2
- package/src/api/mount.ts +17 -7
- package/src/engine/arrays/interaction.ts +54 -0
- package/src/engine/lifecycle.ts +186 -0
- package/src/engine/registry.ts +5 -0
- package/src/pipeline/frameBuffer.ts +36 -10
- package/src/pipeline/layout/titan-engine.ts +45 -9
- package/src/pipeline/layout/utils/text-measure.ts +3 -2
- package/src/primitives/box.ts +13 -22
- package/src/primitives/each.ts +8 -0
- package/src/primitives/index.ts +2 -1
- package/src/primitives/input.ts +360 -0
- package/src/primitives/scope.ts +1 -2
- package/src/primitives/show.ts +7 -5
- package/src/primitives/text.ts +14 -25
- package/src/primitives/types.ts +50 -36
- package/src/primitives/utils.ts +26 -0
- package/src/primitives/when.ts +5 -2
- package/src/state/context.ts +212 -0
- package/src/state/drawnCursor.ts +326 -0
- package/src/state/focus.ts +84 -13
- package/src/state/index.ts +9 -0
- package/src/state/keyboard.ts +6 -0
- package/src/state/theme.ts +260 -10
- package/src/types/color.ts +115 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI Framework - Reactive Context System
|
|
3
|
+
*
|
|
4
|
+
* Provides React-like context for passing data deep without prop drilling.
|
|
5
|
+
* Uses ReactiveMap for automatic reactive subscriptions - no explicit
|
|
6
|
+
* subscribe/unsubscribe needed!
|
|
7
|
+
*
|
|
8
|
+
* How it works:
|
|
9
|
+
* - ReactiveMap has per-key fine-grained reactivity
|
|
10
|
+
* - Reading from the map inside a derived/effect auto-subscribes
|
|
11
|
+
* - Writing to the map auto-notifies all readers of that key
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* ```ts
|
|
15
|
+
* // Create a context
|
|
16
|
+
* const ThemeContext = createContext<Theme>(defaultTheme)
|
|
17
|
+
*
|
|
18
|
+
* // Provider sets it
|
|
19
|
+
* provide(ThemeContext, darkTheme)
|
|
20
|
+
*
|
|
21
|
+
* // Consumer reads it - automatically reactive!
|
|
22
|
+
* function ThemedBox() {
|
|
23
|
+
* const theme = useContext(ThemeContext)
|
|
24
|
+
* return box({ bg: theme.background })
|
|
25
|
+
* }
|
|
26
|
+
*
|
|
27
|
+
* // Update context - all consumers re-render
|
|
28
|
+
* provide(ThemeContext, lightTheme)
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { ReactiveMap, signal, type WritableSignal } from '@rlabs-inc/signals'
|
|
33
|
+
|
|
34
|
+
// =============================================================================
|
|
35
|
+
// Context Types
|
|
36
|
+
// =============================================================================
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A context identifier with type information.
|
|
40
|
+
* The symbol ensures uniqueness, the type parameter ensures type safety.
|
|
41
|
+
*/
|
|
42
|
+
export interface Context<T> {
|
|
43
|
+
/** Unique identifier for this context */
|
|
44
|
+
readonly id: symbol
|
|
45
|
+
/** Default value if no provider exists */
|
|
46
|
+
readonly defaultValue: T
|
|
47
|
+
/** Display name for debugging */
|
|
48
|
+
readonly displayName?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// =============================================================================
|
|
52
|
+
// Context Storage
|
|
53
|
+
// =============================================================================
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Global reactive storage for all context values.
|
|
57
|
+
*
|
|
58
|
+
* Using ReactiveMap means:
|
|
59
|
+
* - get(key) inside derived/effect = auto-subscribe to that key
|
|
60
|
+
* - set(key, value) = auto-notify all subscribers of that key
|
|
61
|
+
*
|
|
62
|
+
* This is the magic that makes context automatically reactive!
|
|
63
|
+
*/
|
|
64
|
+
const contextValues = new ReactiveMap<symbol, unknown>()
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Signal wrappers for contexts that need .value access pattern.
|
|
68
|
+
* When a user provides a signal, we store it directly.
|
|
69
|
+
* When they provide a static value, we wrap it in a signal.
|
|
70
|
+
*/
|
|
71
|
+
const contextSignals = new ReactiveMap<symbol, WritableSignal<unknown>>()
|
|
72
|
+
|
|
73
|
+
// =============================================================================
|
|
74
|
+
// Context API
|
|
75
|
+
// =============================================================================
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Create a new context with a default value.
|
|
79
|
+
*
|
|
80
|
+
* @param defaultValue - Value used when no provider exists
|
|
81
|
+
* @param displayName - Optional name for debugging
|
|
82
|
+
* @returns A Context object to use with provide() and useContext()
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* ```ts
|
|
86
|
+
* const UserContext = createContext<User | null>(null)
|
|
87
|
+
* const ThemeContext = createContext(defaultTheme, 'Theme')
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
export function createContext<T>(defaultValue: T, displayName?: string): Context<T> {
|
|
91
|
+
return {
|
|
92
|
+
id: Symbol(displayName ?? 'Context'),
|
|
93
|
+
defaultValue,
|
|
94
|
+
displayName,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Provide a value for a context.
|
|
100
|
+
*
|
|
101
|
+
* Can be called multiple times to update the context value.
|
|
102
|
+
* All consumers using useContext() will automatically update.
|
|
103
|
+
*
|
|
104
|
+
* @param context - The context to provide
|
|
105
|
+
* @param value - The value to provide (static or signal)
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* ```ts
|
|
109
|
+
* // Static value
|
|
110
|
+
* provide(ThemeContext, darkTheme)
|
|
111
|
+
*
|
|
112
|
+
* // Signal - consumers react to signal changes too!
|
|
113
|
+
* const theme = signal(darkTheme)
|
|
114
|
+
* provide(ThemeContext, theme)
|
|
115
|
+
* theme.value = lightTheme // All consumers update!
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
118
|
+
export function provide<T>(context: Context<T>, value: T | WritableSignal<T>): void {
|
|
119
|
+
// Check if value is a signal from @rlabs-inc/signals
|
|
120
|
+
// Signals have a Symbol('signal.source') property that plain objects don't have
|
|
121
|
+
// This is more reliable than just checking for 'value' since regular objects
|
|
122
|
+
// might have a 'value' property but won't have the internal signal symbol
|
|
123
|
+
if (
|
|
124
|
+
value !== null &&
|
|
125
|
+
typeof value === 'object' &&
|
|
126
|
+
'value' in value &&
|
|
127
|
+
Object.getOwnPropertySymbols(value).some((sym) => sym.description === 'signal.source')
|
|
128
|
+
) {
|
|
129
|
+
// It's a signal - store it directly for reactive access
|
|
130
|
+
contextSignals.set(context.id, value as WritableSignal<unknown>)
|
|
131
|
+
contextValues.set(context.id, value)
|
|
132
|
+
} else {
|
|
133
|
+
// Static value - wrap in a signal for consistent access
|
|
134
|
+
const existing = contextSignals.get(context.id)
|
|
135
|
+
if (existing) {
|
|
136
|
+
// Update existing signal
|
|
137
|
+
existing.value = value
|
|
138
|
+
} else {
|
|
139
|
+
// Create new signal
|
|
140
|
+
const sig = signal(value)
|
|
141
|
+
contextSignals.set(context.id, sig as WritableSignal<unknown>)
|
|
142
|
+
}
|
|
143
|
+
contextValues.set(context.id, value)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get the current value of a context.
|
|
149
|
+
*
|
|
150
|
+
* This is automatically reactive when used inside:
|
|
151
|
+
* - derived(() => ...)
|
|
152
|
+
* - effect(() => ...)
|
|
153
|
+
* - Component prop getters: () => useContext(...)
|
|
154
|
+
*
|
|
155
|
+
* @param context - The context to read
|
|
156
|
+
* @returns The current context value, or defaultValue if no provider
|
|
157
|
+
*
|
|
158
|
+
* @example
|
|
159
|
+
* ```ts
|
|
160
|
+
* function MyComponent() {
|
|
161
|
+
* const theme = useContext(ThemeContext)
|
|
162
|
+
* return box({ bg: theme.background })
|
|
163
|
+
* }
|
|
164
|
+
* ```
|
|
165
|
+
*/
|
|
166
|
+
export function useContext<T>(context: Context<T>): T {
|
|
167
|
+
// Check for signal wrapper first (for .value reactivity)
|
|
168
|
+
const sig = contextSignals.get(context.id)
|
|
169
|
+
if (sig !== undefined) {
|
|
170
|
+
return sig.value as T
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Fall back to direct value lookup (still reactive via ReactiveMap!)
|
|
174
|
+
const value = contextValues.get(context.id)
|
|
175
|
+
if (value !== undefined) {
|
|
176
|
+
return value as T
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return context.defaultValue
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Check if a context has been provided.
|
|
184
|
+
*
|
|
185
|
+
* @param context - The context to check
|
|
186
|
+
* @returns true if provide() has been called for this context
|
|
187
|
+
*/
|
|
188
|
+
export function hasContext<T>(context: Context<T>): boolean {
|
|
189
|
+
return contextValues.has(context.id)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Clear a context, returning to default value.
|
|
194
|
+
*
|
|
195
|
+
* @param context - The context to clear
|
|
196
|
+
*/
|
|
197
|
+
export function clearContext<T>(context: Context<T>): void {
|
|
198
|
+
contextValues.delete(context.id)
|
|
199
|
+
contextSignals.delete(context.id)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// =============================================================================
|
|
203
|
+
// Reset (for testing)
|
|
204
|
+
// =============================================================================
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Reset all context state (for testing)
|
|
208
|
+
*/
|
|
209
|
+
export function resetContexts(): void {
|
|
210
|
+
contextValues.clear()
|
|
211
|
+
contextSignals.clear()
|
|
212
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI Framework - Drawn Cursor State
|
|
3
|
+
*
|
|
4
|
+
* Manages cursor state for input components (input, textarea, custom editors).
|
|
5
|
+
* Unlike the terminal native cursor (cursor.ts), this cursor is drawn into
|
|
6
|
+
* the frameBuffer and supports full customization.
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - Style presets (block, bar, underline) or custom characters
|
|
10
|
+
* - Blink animation with configurable FPS (default: 2 FPS)
|
|
11
|
+
* - Custom colors (fg/bg) or inherit from component
|
|
12
|
+
* - Alt character for blink "off" phase
|
|
13
|
+
* - Integrates with animation module for efficient shared clocks
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { createCursor, disposeCursor } from '../state/drawnCursor'
|
|
18
|
+
*
|
|
19
|
+
* // In input component:
|
|
20
|
+
* const cursor = createCursor(index, {
|
|
21
|
+
* style: 'bar',
|
|
22
|
+
* blink: true,
|
|
23
|
+
* fps: 2,
|
|
24
|
+
* })
|
|
25
|
+
*
|
|
26
|
+
* // Update position
|
|
27
|
+
* cursor.setPosition(5)
|
|
28
|
+
*
|
|
29
|
+
* // Cleanup on unmount
|
|
30
|
+
* disposeCursor(index)
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { signal } from '@rlabs-inc/signals'
|
|
35
|
+
import * as interaction from '../engine/arrays/interaction'
|
|
36
|
+
import { registerFocusCallbacks } from './focus'
|
|
37
|
+
import { onDestroy } from '../engine/lifecycle'
|
|
38
|
+
|
|
39
|
+
// =============================================================================
|
|
40
|
+
// TYPES
|
|
41
|
+
// =============================================================================
|
|
42
|
+
|
|
43
|
+
export type DrawnCursorStyle = 'block' | 'bar' | 'underline'
|
|
44
|
+
|
|
45
|
+
export interface DrawnCursorConfig {
|
|
46
|
+
/** Cursor style preset */
|
|
47
|
+
style?: DrawnCursorStyle
|
|
48
|
+
/** Custom cursor character (overrides style) */
|
|
49
|
+
char?: string
|
|
50
|
+
/** Enable blink animation (default: true) */
|
|
51
|
+
blink?: boolean
|
|
52
|
+
/** Blink FPS (default: 2, which gives 500ms on/off cycle) */
|
|
53
|
+
fps?: number
|
|
54
|
+
/** Alt character for blink "off" phase (default: invisible/space) */
|
|
55
|
+
altChar?: string
|
|
56
|
+
/** Initial cursor position */
|
|
57
|
+
position?: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface DrawnCursor {
|
|
61
|
+
/** Set cursor position in text */
|
|
62
|
+
setPosition: (pos: number) => void
|
|
63
|
+
/** Get current position */
|
|
64
|
+
getPosition: () => number
|
|
65
|
+
/** Manually show cursor (override blink) */
|
|
66
|
+
show: () => void
|
|
67
|
+
/** Manually hide cursor (override blink) */
|
|
68
|
+
hide: () => void
|
|
69
|
+
/** Check if cursor is currently visible */
|
|
70
|
+
isVisible: () => boolean
|
|
71
|
+
/** Cleanup - stops animation, clears arrays */
|
|
72
|
+
dispose: () => void
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// =============================================================================
|
|
76
|
+
// CURSOR CHARACTER CODEPOINTS
|
|
77
|
+
// =============================================================================
|
|
78
|
+
|
|
79
|
+
const CURSOR_CHARS: Record<DrawnCursorStyle, number> = {
|
|
80
|
+
block: 0, // 0 = special case: inverse block (swap fg/bg)
|
|
81
|
+
bar: 0x2502, // │ vertical line
|
|
82
|
+
underline: 0x5F, // _ underscore
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// =============================================================================
|
|
86
|
+
// BLINK ANIMATION REGISTRY
|
|
87
|
+
// Shared clocks per FPS - same pattern as animation.ts
|
|
88
|
+
// =============================================================================
|
|
89
|
+
|
|
90
|
+
interface BlinkRegistry {
|
|
91
|
+
phase: ReturnType<typeof signal<boolean>>
|
|
92
|
+
interval: ReturnType<typeof setInterval> | null
|
|
93
|
+
subscribers: number
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const blinkRegistry = new Map<number, BlinkRegistry>()
|
|
97
|
+
|
|
98
|
+
function getBlinkClock(fps: number): BlinkRegistry {
|
|
99
|
+
let registry = blinkRegistry.get(fps)
|
|
100
|
+
|
|
101
|
+
if (!registry) {
|
|
102
|
+
registry = {
|
|
103
|
+
phase: signal(true), // true = visible
|
|
104
|
+
interval: null,
|
|
105
|
+
subscribers: 0,
|
|
106
|
+
}
|
|
107
|
+
blinkRegistry.set(fps, registry)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return registry
|
|
111
|
+
}
|
|
112
|
+
|
|
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
|
+
|
|
119
|
+
const registry = getBlinkClock(fps)
|
|
120
|
+
registry.subscribers++
|
|
121
|
+
|
|
122
|
+
// Start interval if first subscriber
|
|
123
|
+
if (registry.subscribers === 1 && !registry.interval) {
|
|
124
|
+
const ms = Math.floor(1000 / fps / 2) // Divide by 2 for on/off cycle
|
|
125
|
+
registry.interval = setInterval(() => {
|
|
126
|
+
registry.phase.value = !registry.phase.value
|
|
127
|
+
}, ms)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return () => {
|
|
131
|
+
registry.subscribers = Math.max(0, registry.subscribers - 1)
|
|
132
|
+
|
|
133
|
+
// Stop interval if no more subscribers
|
|
134
|
+
if (registry.subscribers === 0 && registry.interval) {
|
|
135
|
+
clearInterval(registry.interval)
|
|
136
|
+
registry.interval = null
|
|
137
|
+
registry.phase.value = true // Reset to visible
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// =============================================================================
|
|
143
|
+
// ACTIVE CURSORS REGISTRY
|
|
144
|
+
// =============================================================================
|
|
145
|
+
|
|
146
|
+
const activeCursors = new Map<number, {
|
|
147
|
+
unsubscribeBlink: (() => void) | null
|
|
148
|
+
unsubscribeFocus: () => void
|
|
149
|
+
manualVisible: ReturnType<typeof signal<boolean | null>>
|
|
150
|
+
}>()
|
|
151
|
+
|
|
152
|
+
// =============================================================================
|
|
153
|
+
// CREATE CURSOR
|
|
154
|
+
// =============================================================================
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Create a drawn cursor for a component.
|
|
158
|
+
*
|
|
159
|
+
* @param index Component index
|
|
160
|
+
* @param config Cursor configuration
|
|
161
|
+
* @returns Cursor control object
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* ```ts
|
|
165
|
+
* const cursor = createCursor(index, {
|
|
166
|
+
* style: 'bar', // 'block' | 'bar' | 'underline'
|
|
167
|
+
* blink: true, // default: true
|
|
168
|
+
* fps: 2, // default: 2 (500ms cycle)
|
|
169
|
+
* })
|
|
170
|
+
*
|
|
171
|
+
* cursor.setPosition(5)
|
|
172
|
+
* ```
|
|
173
|
+
*/
|
|
174
|
+
export function createCursor(index: number, config: DrawnCursorConfig = {}): DrawnCursor {
|
|
175
|
+
const {
|
|
176
|
+
style = 'block',
|
|
177
|
+
char,
|
|
178
|
+
blink = true,
|
|
179
|
+
fps = 2,
|
|
180
|
+
altChar,
|
|
181
|
+
position = 0,
|
|
182
|
+
} = config
|
|
183
|
+
|
|
184
|
+
// Determine cursor character codepoint
|
|
185
|
+
let charCode: number
|
|
186
|
+
if (char) {
|
|
187
|
+
charCode = char.codePointAt(0) ?? 0
|
|
188
|
+
} else {
|
|
189
|
+
charCode = CURSOR_CHARS[style]
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Determine alt character for blink "off" phase
|
|
193
|
+
const altCharCode = altChar ? (altChar.codePointAt(0) ?? 0) : 0
|
|
194
|
+
|
|
195
|
+
// Set cursor arrays
|
|
196
|
+
interaction.cursorChar.setSource(index, charCode)
|
|
197
|
+
interaction.cursorAltChar.setSource(index, altCharCode)
|
|
198
|
+
interaction.cursorBlinkFps.setSource(index, blink ? fps : 0)
|
|
199
|
+
interaction.cursorPosition.setSource(index, position)
|
|
200
|
+
|
|
201
|
+
// Manual visibility override (null = use blink, true/false = override)
|
|
202
|
+
const manualVisible = signal<boolean | null>(null)
|
|
203
|
+
|
|
204
|
+
// Blink subscription (managed by focus callbacks, not effect)
|
|
205
|
+
let unsubscribeBlink: (() => void) | null = null
|
|
206
|
+
const shouldBlink = blink && fps > 0
|
|
207
|
+
|
|
208
|
+
// Register focus callbacks to start/stop blink (imperative, at the source)
|
|
209
|
+
const unsubscribeFocus = registerFocusCallbacks(index, {
|
|
210
|
+
onFocus: () => {
|
|
211
|
+
if (shouldBlink && !unsubscribeBlink) {
|
|
212
|
+
unsubscribeBlink = subscribeToBlink(fps)
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
onBlur: () => {
|
|
216
|
+
if (unsubscribeBlink) {
|
|
217
|
+
unsubscribeBlink()
|
|
218
|
+
unsubscribeBlink = null
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
// Cursor visibility as derived getter (reactive, no effect)
|
|
224
|
+
interaction.cursorVisible.setSource(index, () => {
|
|
225
|
+
// Manual override takes precedence
|
|
226
|
+
if (manualVisible.value !== null) {
|
|
227
|
+
return manualVisible.value ? 1 : 0
|
|
228
|
+
}
|
|
229
|
+
// Not focused = always visible (cursor shows but doesn't blink)
|
|
230
|
+
if (interaction.focusedIndex.value !== index) {
|
|
231
|
+
return 1
|
|
232
|
+
}
|
|
233
|
+
// Focused + blink disabled = always visible
|
|
234
|
+
if (!shouldBlink) {
|
|
235
|
+
return 1
|
|
236
|
+
}
|
|
237
|
+
// Focused + blink enabled = follow blink clock
|
|
238
|
+
return getBlinkClock(fps).phase.value ? 1 : 0
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
// Store in registry for cleanup
|
|
242
|
+
activeCursors.set(index, {
|
|
243
|
+
unsubscribeBlink,
|
|
244
|
+
unsubscribeFocus,
|
|
245
|
+
manualVisible,
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
// Register safety-net cleanup with lifecycle system.
|
|
249
|
+
// This ensures cursor is disposed even if component forgets to call dispose()
|
|
250
|
+
// or an error path skips cleanup. disposeCursor() is idempotent (safe to call twice).
|
|
251
|
+
onDestroy(() => {
|
|
252
|
+
disposeCursor(index)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
// Return control object
|
|
256
|
+
return {
|
|
257
|
+
setPosition(pos: number) {
|
|
258
|
+
interaction.cursorPosition.setSource(index, pos)
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
getPosition() {
|
|
262
|
+
return interaction.cursorPosition[index] || 0
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
show() {
|
|
266
|
+
manualVisible.value = true
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
hide() {
|
|
270
|
+
manualVisible.value = false
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
isVisible() {
|
|
274
|
+
return (interaction.cursorVisible[index] ?? 1) === 1
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
dispose() {
|
|
278
|
+
disposeCursor(index)
|
|
279
|
+
},
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// =============================================================================
|
|
284
|
+
// DISPOSE CURSOR
|
|
285
|
+
// =============================================================================
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Dispose a cursor and clean up its resources.
|
|
289
|
+
*
|
|
290
|
+
* @param index Component index
|
|
291
|
+
*/
|
|
292
|
+
export function disposeCursor(index: number): void {
|
|
293
|
+
const cursor = activeCursors.get(index)
|
|
294
|
+
|
|
295
|
+
if (cursor) {
|
|
296
|
+
// Unsubscribe from focus callbacks
|
|
297
|
+
cursor.unsubscribeFocus()
|
|
298
|
+
|
|
299
|
+
// Unsubscribe from blink (if active)
|
|
300
|
+
cursor.unsubscribeBlink?.()
|
|
301
|
+
|
|
302
|
+
// Remove from registry
|
|
303
|
+
activeCursors.delete(index)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Clear cursor arrays
|
|
307
|
+
interaction.cursorChar.clear(index)
|
|
308
|
+
interaction.cursorAltChar.clear(index)
|
|
309
|
+
interaction.cursorBlinkFps.clear(index)
|
|
310
|
+
interaction.cursorVisible.clear(index)
|
|
311
|
+
// Note: cursorPosition is managed by component, don't clear here
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// =============================================================================
|
|
315
|
+
// UTILITY EXPORTS
|
|
316
|
+
// =============================================================================
|
|
317
|
+
|
|
318
|
+
/** Get cursor style codepoint */
|
|
319
|
+
export function getCursorCharCode(style: DrawnCursorStyle): number {
|
|
320
|
+
return CURSOR_CHARS[style]
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Check if a component has an active cursor */
|
|
324
|
+
export function hasCursor(index: number): boolean {
|
|
325
|
+
return activeCursors.has(index)
|
|
326
|
+
}
|
package/src/state/focus.ts
CHANGED
|
@@ -11,11 +11,70 @@
|
|
|
11
11
|
import { signal, derived, unwrap } from '@rlabs-inc/signals'
|
|
12
12
|
import { focusable, tabIndex, focusedIndex as _focusedIndex } from '../engine/arrays/interaction'
|
|
13
13
|
import { visible } from '../engine/arrays/core'
|
|
14
|
-
import { getAllocatedIndices } from '../engine/registry'
|
|
14
|
+
import { getAllocatedIndices, getId } from '../engine/registry'
|
|
15
15
|
|
|
16
16
|
// Re-export the focusedIndex from interaction arrays
|
|
17
17
|
export const focusedIndex = _focusedIndex
|
|
18
18
|
|
|
19
|
+
// =============================================================================
|
|
20
|
+
// FOCUS CALLBACKS (event integration at the source)
|
|
21
|
+
// Supports MULTIPLE callback registrations per index (e.g., cursor blink + user callback)
|
|
22
|
+
// =============================================================================
|
|
23
|
+
|
|
24
|
+
interface FocusCallbacks {
|
|
25
|
+
onFocus?: () => void
|
|
26
|
+
onBlur?: () => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Store arrays of callbacks - multiple registrations allowed per index
|
|
30
|
+
const focusCallbackRegistry = new Map<number, FocusCallbacks[]>()
|
|
31
|
+
|
|
32
|
+
/** Register focus callbacks for a component - returns unsubscribe function */
|
|
33
|
+
export function registerFocusCallbacks(index: number, callbacks: FocusCallbacks): () => void {
|
|
34
|
+
let list = focusCallbackRegistry.get(index)
|
|
35
|
+
if (!list) {
|
|
36
|
+
list = []
|
|
37
|
+
focusCallbackRegistry.set(index, list)
|
|
38
|
+
}
|
|
39
|
+
list.push(callbacks)
|
|
40
|
+
|
|
41
|
+
return () => {
|
|
42
|
+
const arr = focusCallbackRegistry.get(index)
|
|
43
|
+
if (arr) {
|
|
44
|
+
const idx = arr.indexOf(callbacks)
|
|
45
|
+
if (idx >= 0) arr.splice(idx, 1)
|
|
46
|
+
if (arr.length === 0) focusCallbackRegistry.delete(index)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Internal: Set focus and fire callbacks at the source */
|
|
52
|
+
function setFocusWithCallbacks(newIndex: number): void {
|
|
53
|
+
const oldIndex = focusedIndex.value
|
|
54
|
+
|
|
55
|
+
// No change, no callbacks
|
|
56
|
+
if (oldIndex === newIndex) return
|
|
57
|
+
|
|
58
|
+
// Fire onBlur for all callbacks on old focus
|
|
59
|
+
if (oldIndex >= 0) {
|
|
60
|
+
const callbacks = focusCallbackRegistry.get(oldIndex)
|
|
61
|
+
if (callbacks) {
|
|
62
|
+
for (const cb of callbacks) cb.onBlur?.()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Update reactive state
|
|
67
|
+
focusedIndex.value = newIndex
|
|
68
|
+
|
|
69
|
+
// Fire onFocus for all callbacks on new focus
|
|
70
|
+
if (newIndex >= 0) {
|
|
71
|
+
const callbacks = focusCallbackRegistry.get(newIndex)
|
|
72
|
+
if (callbacks) {
|
|
73
|
+
for (const cb of callbacks) cb.onFocus?.()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
19
78
|
// =============================================================================
|
|
20
79
|
// FOCUS TRAP (for modals/dialogs)
|
|
21
80
|
// =============================================================================
|
|
@@ -46,14 +105,20 @@ export function getFocusTrapContainer(): number | undefined {
|
|
|
46
105
|
// FOCUS HISTORY (for restoration)
|
|
47
106
|
// =============================================================================
|
|
48
107
|
|
|
49
|
-
|
|
108
|
+
interface FocusHistoryEntry {
|
|
109
|
+
index: number
|
|
110
|
+
id: string | undefined
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const focusHistory: FocusHistoryEntry[] = []
|
|
50
114
|
const MAX_HISTORY = 10
|
|
51
115
|
|
|
52
116
|
/** Save current focus to history */
|
|
53
117
|
export function saveFocusToHistory(): void {
|
|
54
118
|
const current = focusedIndex.value
|
|
55
119
|
if (current >= 0) {
|
|
56
|
-
|
|
120
|
+
const id = getId(current)
|
|
121
|
+
focusHistory.push({ index: current, id })
|
|
57
122
|
if (focusHistory.length > MAX_HISTORY) {
|
|
58
123
|
focusHistory.shift()
|
|
59
124
|
}
|
|
@@ -63,13 +128,15 @@ export function saveFocusToHistory(): void {
|
|
|
63
128
|
/** Restore focus from history */
|
|
64
129
|
export function restoreFocusFromHistory(): boolean {
|
|
65
130
|
while (focusHistory.length > 0) {
|
|
66
|
-
const
|
|
131
|
+
const entry = focusHistory.pop()!
|
|
132
|
+
// Verify the index hasn't been recycled for a different component
|
|
133
|
+
if (getId(entry.index) !== entry.id) continue
|
|
67
134
|
// Check if component is still valid and focusable
|
|
68
135
|
// Match TITAN's logic: undefined means visible, only 0/false means hidden
|
|
69
|
-
const isVisible = unwrap(visible[index])
|
|
136
|
+
const isVisible = unwrap(visible[entry.index])
|
|
70
137
|
const isActuallyVisible = isVisible !== 0 && isVisible !== false
|
|
71
|
-
if (unwrap(focusable[index]) && isActuallyVisible) {
|
|
72
|
-
|
|
138
|
+
if (unwrap(focusable[entry.index]) && isActuallyVisible) {
|
|
139
|
+
setFocusWithCallbacks(entry.index)
|
|
73
140
|
return true
|
|
74
141
|
}
|
|
75
142
|
}
|
|
@@ -99,7 +166,7 @@ export function getFocusableIndices(): number[] {
|
|
|
99
166
|
result.sort((a, b) => {
|
|
100
167
|
const tabA = unwrap(tabIndex[a]) ?? 0
|
|
101
168
|
const tabB = unwrap(tabIndex[b]) ?? 0
|
|
102
|
-
if (tabA !== tabB) return tabA -
|
|
169
|
+
if (tabA !== tabB) return tabA < tabB ? -1 : 1
|
|
103
170
|
return a - b // Stable sort by index
|
|
104
171
|
})
|
|
105
172
|
|
|
@@ -152,7 +219,7 @@ export function focusNext(): boolean {
|
|
|
152
219
|
const next = findNextFocusable(current, 1)
|
|
153
220
|
if (next !== -1 && next !== current) {
|
|
154
221
|
saveFocusToHistory()
|
|
155
|
-
|
|
222
|
+
setFocusWithCallbacks(next)
|
|
156
223
|
return true
|
|
157
224
|
}
|
|
158
225
|
return false
|
|
@@ -163,7 +230,7 @@ export function focusPrevious(): boolean {
|
|
|
163
230
|
const prev = findNextFocusable(focusedIndex.value, -1)
|
|
164
231
|
if (prev !== -1 && prev !== focusedIndex.value) {
|
|
165
232
|
saveFocusToHistory()
|
|
166
|
-
|
|
233
|
+
setFocusWithCallbacks(prev)
|
|
167
234
|
return true
|
|
168
235
|
}
|
|
169
236
|
return false
|
|
@@ -177,7 +244,7 @@ export function focus(index: number): boolean {
|
|
|
177
244
|
if (unwrap(focusable[index]) && isActuallyVisible) {
|
|
178
245
|
if (focusedIndex.value !== index) {
|
|
179
246
|
saveFocusToHistory()
|
|
180
|
-
|
|
247
|
+
setFocusWithCallbacks(index)
|
|
181
248
|
}
|
|
182
249
|
return true
|
|
183
250
|
}
|
|
@@ -188,7 +255,7 @@ export function focus(index: number): boolean {
|
|
|
188
255
|
export function blur(): void {
|
|
189
256
|
if (focusedIndex.value >= 0) {
|
|
190
257
|
saveFocusToHistory()
|
|
191
|
-
|
|
258
|
+
setFocusWithCallbacks(-1)
|
|
192
259
|
}
|
|
193
260
|
}
|
|
194
261
|
|
|
@@ -216,9 +283,10 @@ export function focusLast(): boolean {
|
|
|
216
283
|
|
|
217
284
|
/** Reset all focus state (for testing) */
|
|
218
285
|
export function resetFocusState(): void {
|
|
219
|
-
|
|
286
|
+
setFocusWithCallbacks(-1)
|
|
220
287
|
focusTrapStack.length = 0
|
|
221
288
|
focusHistory.length = 0
|
|
289
|
+
focusCallbackRegistry.clear()
|
|
222
290
|
}
|
|
223
291
|
|
|
224
292
|
// =============================================================================
|
|
@@ -249,4 +317,7 @@ export const focusManager = {
|
|
|
249
317
|
// History
|
|
250
318
|
saveFocusToHistory,
|
|
251
319
|
restoreFocusFromHistory,
|
|
320
|
+
|
|
321
|
+
// Callbacks (event integration at source)
|
|
322
|
+
registerFocusCallbacks,
|
|
252
323
|
}
|