@galfus/engine 0.0.0-stage → 0.31.1-alpha

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.
Files changed (80) hide show
  1. package/README.md +190 -2
  2. package/package.json +59 -4
  3. package/src/anchors/cache.ts +101 -0
  4. package/src/anchors/component.ts +123 -0
  5. package/src/anchors/hierarchy.ts +192 -0
  6. package/src/anchors/index.ts +8 -0
  7. package/src/anchors/solver.ts +247 -0
  8. package/src/anchors/strategy.ts +142 -0
  9. package/src/animation/blending.ts +163 -0
  10. package/src/animation/clip.ts +132 -0
  11. package/src/animation/coordinator.ts +568 -0
  12. package/src/animation/index.ts +7 -0
  13. package/src/animation/native-targets.ts +37 -0
  14. package/src/animation/player.ts +287 -0
  15. package/src/audio/index.ts +4 -0
  16. package/src/audio/manager.ts +371 -0
  17. package/src/collision/components.ts +211 -0
  18. package/src/collision/index.ts +16 -0
  19. package/src/collision/query2d.ts +167 -0
  20. package/src/collision/query3d.ts +170 -0
  21. package/src/ecs/component.ts +130 -0
  22. package/src/ecs/entity.ts +75 -0
  23. package/src/ecs/index.ts +9 -0
  24. package/src/ecs/intent.ts +162 -0
  25. package/src/ecs/metadata.ts +44 -0
  26. package/src/ecs/system.ts +224 -0
  27. package/src/ecs/world.ts +291 -0
  28. package/src/index.ts +19 -0
  29. package/src/interaction/focus.ts +327 -0
  30. package/src/interaction/index.ts +9 -0
  31. package/src/interaction/listener.ts +58 -0
  32. package/src/interaction/snapshot2d.ts +210 -0
  33. package/src/interaction/snapshot3d.ts +214 -0
  34. package/src/interaction/system.ts +544 -0
  35. package/src/interaction/types.ts +27 -0
  36. package/src/math/common.ts +67 -0
  37. package/src/math/index.ts +17 -0
  38. package/src/math/mat2d.ts +320 -0
  39. package/src/math/mat4.ts +576 -0
  40. package/src/math/quat.ts +438 -0
  41. package/src/math/vec2.ts +161 -0
  42. package/src/math/vec3.ts +242 -0
  43. package/src/math/vec4.ts +171 -0
  44. package/src/multiverse/coordinator.ts +540 -0
  45. package/src/multiverse/index.ts +13 -0
  46. package/src/multiverse/presentation-graph.ts +306 -0
  47. package/src/multiverse/presentation-links.ts +134 -0
  48. package/src/multiverse/realm.ts +49 -0
  49. package/src/platform/index.ts +2 -0
  50. package/src/platform/platform.ts +149 -0
  51. package/src/platform/window.ts +181 -0
  52. package/src/realm2d/components.ts +254 -0
  53. package/src/realm2d/index.ts +12 -0
  54. package/src/realm2d/presets/index.ts +36 -0
  55. package/src/realm2d/realm.ts +522 -0
  56. package/src/realm2d/sync.ts +284 -0
  57. package/src/realm2d/utils/index.ts +32 -0
  58. package/src/realm3d/components.ts +341 -0
  59. package/src/realm3d/geometry.ts +224 -0
  60. package/src/realm3d/index.ts +12 -0
  61. package/src/realm3d/presets/index.ts +67 -0
  62. package/src/realm3d/realm.ts +588 -0
  63. package/src/realm3d/resource-ids.ts +62 -0
  64. package/src/realm3d/skinning.ts +128 -0
  65. package/src/realm3d/sync.ts +442 -0
  66. package/src/realm3d/utils/index.ts +26 -0
  67. package/src/resources/index.ts +4 -0
  68. package/src/resources/manager.ts +92 -0
  69. package/src/runtime.ts +588 -0
  70. package/src/spatial/dirty_registry.ts +217 -0
  71. package/src/spatial/flags.ts +14 -0
  72. package/src/spatial/index.ts +6 -0
  73. package/src/spatial/metrics.ts +124 -0
  74. package/src/text/editor.ts +224 -0
  75. package/src/text/encoding.ts +38 -0
  76. package/src/text/index.ts +13 -0
  77. package/src/text/input.ts +160 -0
  78. package/src/text/keyboard.ts +50 -0
  79. package/src/text/svg-decorations.ts +147 -0
  80. package/src/types.ts +85 -0
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Generic ECS World orchestrating entities, components, systems, and intents.
3
+ */
4
+
5
+ import { type Component, ComponentStore } from './component';
6
+ import { type EntityId, EntityManager } from './entity';
7
+ import { type IntentFlusher, IntentStore } from './intent';
8
+ import { createMetadata, createName, type MetadataComponent, type NameComponent } from './metadata';
9
+ import {
10
+ type System,
11
+ type SystemContext,
12
+ type SystemDescriptor,
13
+ type SystemKind,
14
+ SystemPipeline,
15
+ type SystemStep
16
+ } from './system';
17
+
18
+ export class EcsWorld implements IntentFlusher {
19
+ readonly entities = new EntityManager();
20
+ readonly components = new ComponentStore();
21
+ readonly intents = new IntentStore();
22
+ readonly systems = new SystemPipeline<EcsWorld>();
23
+
24
+ /**
25
+ * Spawns a new entity and returns its ID.
26
+ */
27
+ createEntity(): EntityId {
28
+ return this.entities.create();
29
+ }
30
+
31
+ /**
32
+ * Destroys an entity and immediately detaches all its attached components.
33
+ */
34
+ destroyEntity(entityId: EntityId): boolean {
35
+ if (this.entities.destroy(entityId)) {
36
+ this.components.removeAllForEntity(entityId);
37
+ return true;
38
+ }
39
+ return false;
40
+ }
41
+
42
+ /**
43
+ * Checks whether the entity is alive in this world.
44
+ */
45
+ isAlive(entityId: EntityId): boolean {
46
+ return this.entities.isAlive(entityId);
47
+ }
48
+
49
+ /**
50
+ * Attaches or updates a component on an entity with strict structural integrity checks.
51
+ */
52
+ attachComponent(entityId: EntityId, component: Component): void {
53
+ if (!this.entities.isAlive(entityId)) {
54
+ throw new Error(`Cannot attach component: Entity ${entityId} is not alive.`);
55
+ }
56
+
57
+ // Structural integrity enforcement (F04, F06, F08)
58
+ if (component.type === 'Mesh3D' || component.type === 'Collider3D') {
59
+ if (!this.components.has(entityId, 'Model3D')) {
60
+ throw new Error(
61
+ `Integrity violation: Entity ${entityId} cannot have ${component.type} attached without a Model3D spatial component.`
62
+ );
63
+ }
64
+ } else if (
65
+ component.type === 'Collider2D' ||
66
+ component.type === 'Sprite2D' ||
67
+ component.type === 'Shape2D' ||
68
+ component.type === 'Svg2D'
69
+ ) {
70
+ if (!this.components.has(entityId, 'Model2D')) {
71
+ throw new Error(
72
+ `Integrity violation: Entity ${entityId} cannot have ${component.type} attached without a Model2D spatial component.`
73
+ );
74
+ }
75
+ }
76
+
77
+ this.components.set(entityId, component);
78
+ }
79
+
80
+ /**
81
+ * Detaches a component from an entity by component type name.
82
+ */
83
+ detachComponent(entityId: EntityId, componentType: string): boolean {
84
+ if (
85
+ (componentType === 'Model2D' && this.components.has(entityId, 'Collider2D')) ||
86
+ (componentType === 'Model3D' && this.components.has(entityId, 'Collider3D'))
87
+ ) {
88
+ throw new Error(`Cannot detach ${componentType} while its collider component exists.`);
89
+ }
90
+ return this.components.delete(entityId, componentType);
91
+ }
92
+
93
+ /**
94
+ * Retrieves a component from an entity.
95
+ */
96
+ getComponent<T extends Component = Component>(
97
+ entityId: EntityId,
98
+ componentType: string
99
+ ): T | undefined {
100
+ return this.components.get<T>(entityId, componentType);
101
+ }
102
+
103
+ /**
104
+ * Checks if an entity possesses a component of the specified type.
105
+ */
106
+ hasComponent(entityId: EntityId, componentType: string): boolean {
107
+ return this.components.has(entityId, componentType);
108
+ }
109
+
110
+ /**
111
+ * Queries all entities possessing all requested component types.
112
+ *
113
+ * @param componentTypes Names of component types that matching entities must possess.
114
+ * @returns Array of matching entity IDs.
115
+ */
116
+ query(...componentTypes: string[]): EntityId[] {
117
+ if (componentTypes.length === 0) {
118
+ return this.entities.getActiveEntities();
119
+ }
120
+
121
+ const result: EntityId[] = [];
122
+ for (const id of this.components.entityIds(componentTypes[0]!)) {
123
+ let matches = true;
124
+ for (let i = 1; i < componentTypes.length; i++) {
125
+ if (!this.components.has(id, componentTypes[i]!)) {
126
+ matches = false;
127
+ break;
128
+ }
129
+ }
130
+ if (matches) result.push(id);
131
+ }
132
+ return result;
133
+ }
134
+
135
+ /**
136
+ * Sets or replaces the human-readable name of an entity.
137
+ */
138
+ setEntityName(entityId: EntityId, name: string): void {
139
+ const existing = this.getComponent<NameComponent>(entityId, 'Name');
140
+ if (existing) {
141
+ existing.name = name;
142
+ } else {
143
+ this.attachComponent(entityId, createName(name));
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Retrieves the human-readable name of an entity if assigned.
149
+ */
150
+ getEntityName(entityId: EntityId): string | undefined {
151
+ return this.getComponent<NameComponent>(entityId, 'Name')?.name;
152
+ }
153
+
154
+ /**
155
+ * Finds the first entity possessing the exact given name.
156
+ */
157
+ findEntityByName(name: string): EntityId | undefined {
158
+ const ids = this.components.getEntitiesWith('Name');
159
+ for (const id of ids) {
160
+ const comp = this.components.get<NameComponent>(id, 'Name');
161
+ if (comp?.name === name) return id;
162
+ }
163
+ return undefined;
164
+ }
165
+
166
+ /**
167
+ * Finds all entities matching a given name or regular expression pattern.
168
+ */
169
+ findEntitiesByName(pattern: string | RegExp): EntityId[] {
170
+ const matches: EntityId[] = [];
171
+ const ids = this.components.getEntitiesWith('Name');
172
+ const isRegex = pattern instanceof RegExp;
173
+
174
+ for (const id of ids) {
175
+ const comp = this.components.get<NameComponent>(id, 'Name');
176
+ if (!comp) continue;
177
+ if (isRegex) {
178
+ pattern.lastIndex = 0;
179
+ if (pattern.test(comp.name)) {
180
+ matches.push(id);
181
+ }
182
+ } else if (comp.name === pattern) {
183
+ matches.push(id);
184
+ }
185
+ }
186
+
187
+ return matches;
188
+ }
189
+
190
+ /**
191
+ * Sets or updates a key-value entry in the entity's MetadataComponent.
192
+ */
193
+ setMetadataKV(entityId: EntityId, key: string, value: unknown): void {
194
+ let metadata = this.getComponent<MetadataComponent>(entityId, 'Metadata');
195
+ if (!metadata) {
196
+ metadata = createMetadata();
197
+ this.attachComponent(entityId, metadata);
198
+ }
199
+ metadata.entries.set(key, value);
200
+ }
201
+
202
+ /**
203
+ * Retrieves a metadata value by key for an entity.
204
+ */
205
+ getMetadataKV<T = unknown>(entityId: EntityId, key: string): T | undefined {
206
+ const metadata = this.getComponent<MetadataComponent>(entityId, 'Metadata');
207
+ return metadata?.entries.get(key) as T | undefined;
208
+ }
209
+
210
+ /**
211
+ * Finds all entities possessing a specific metadata key (and optional matching value).
212
+ */
213
+ findEntitiesByMetadata(key: string, value?: unknown): EntityId[] {
214
+ const matches: EntityId[] = [];
215
+ const ids = this.components.getEntitiesWith('Metadata');
216
+
217
+ for (const id of ids) {
218
+ const comp = this.components.get<MetadataComponent>(id, 'Metadata');
219
+ if (!comp || !comp.entries.has(key)) continue;
220
+
221
+ if (value === undefined || comp.entries.get(key) === value) {
222
+ matches.push(id);
223
+ }
224
+ }
225
+
226
+ return matches;
227
+ }
228
+
229
+ /**
230
+ * Adds a system to a given execution stage with optional priority and kind.
231
+ */
232
+ addSystem(
233
+ stepOrDesc: SystemStep | SystemDescriptor<EcsWorld>,
234
+ system?: System<EcsWorld>,
235
+ options?: { priority?: number; kind?: SystemKind }
236
+ ): this {
237
+ this.systems.add(stepOrDesc, system, options);
238
+ return this;
239
+ }
240
+
241
+ /**
242
+ * Flushes all queued intents in the intent store.
243
+ */
244
+ flushIntents(): void {
245
+ this.intents.flush(this);
246
+ }
247
+
248
+ /**
249
+ * Executes the pre-render phases:
250
+ * 1. Runs `input` stage systems & flushes intents.
251
+ * 2. Runs `update` stage systems & flushes intents.
252
+ * 3. Runs `preRender` stage systems & flushes intents.
253
+ */
254
+ stepPreRender(context: SystemContext): void {
255
+ this.systems.runStep('input', this, context);
256
+ this.flushIntents();
257
+
258
+ this.systems.runStep('update', this, context);
259
+ this.flushIntents();
260
+
261
+ this.systems.runStep('preRender', this, context);
262
+ this.flushIntents();
263
+ }
264
+
265
+ /**
266
+ * Executes the post-render phase (runs after GPU dispatch):
267
+ * 4. Runs `postRender` stage systems & final flush of intents.
268
+ */
269
+ stepPostRender(context: SystemContext): void {
270
+ this.systems.runStep('postRender', this, context);
271
+ this.flushIntents();
272
+ }
273
+
274
+ /**
275
+ * Executes a full frame cycle (used for standalone test stepping):
276
+ */
277
+ step(context: SystemContext): void {
278
+ this.stepPreRender(context);
279
+ this.stepPostRender(context);
280
+ }
281
+
282
+ /**
283
+ * Resets the entire world, clearing entities, components, systems, and intents.
284
+ */
285
+ reset(): void {
286
+ this.components.clear();
287
+ this.entities.clear();
288
+ this.intents.clear();
289
+ this.systems.clear();
290
+ }
291
+ }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @galfus/engine: Unified game engine runtime with Multiverse, Realms, Anchors, ECS, Resources, Audio, Animation, and Zero-Alloc Math.
3
+ */
4
+ export * from './anchors/index';
5
+ export * from './animation/index';
6
+ export * from './audio/index';
7
+ export * from './ecs/index';
8
+ export * from './interaction/index';
9
+ export * from './collision/index';
10
+ export * from './math/index';
11
+ export * from './multiverse/index';
12
+ export * from './platform/index';
13
+ export * from './realm2d/index';
14
+ export * from './realm3d/index';
15
+ export * from './resources/index';
16
+ export * from './runtime';
17
+ export * from './spatial/index';
18
+ export * from './text/index';
19
+ export * from './types';
@@ -0,0 +1,327 @@
1
+ import type { InteractiveListener } from './listener';
2
+ import type { InteractiveTarget } from './types';
3
+ import type { InteractiveSystem } from './system';
4
+ import type { KeyboardEvent, PointerEvent } from '@galfus/transport-types';
5
+
6
+ export type FocusEventType = 'focus' | 'blur';
7
+ export interface FocusEvent {
8
+ readonly type: FocusEventType;
9
+ readonly windowId: number;
10
+ readonly realmId: number;
11
+ readonly target: InteractiveTarget;
12
+ }
13
+ export type FocusCallback = (event: FocusEvent) => void;
14
+
15
+ /** A pointer target that can also receive focus in one or more windows. */
16
+ export class Focusable {
17
+ private readonly callbacks = new Map<FocusEventType, Set<FocusCallback>>();
18
+ private disposed = false;
19
+ private disposing = false;
20
+ private enabled_ = true;
21
+ private tabIndex_: number;
22
+
23
+ constructor(
24
+ readonly listener: InteractiveListener,
25
+ private readonly manager: FocusManager,
26
+ tabIndex: number
27
+ ) {
28
+ this.tabIndex_ = Focusable.validateTabIndex(tabIndex);
29
+ listener.on('down', ({ pointer }) => {
30
+ if (pointer.type === 'button' && pointer.button === 'primary')
31
+ this.requestFocus(pointer.windowId);
32
+ });
33
+ }
34
+
35
+ get realmId(): number {
36
+ return this.listener.realmId;
37
+ }
38
+ get target(): InteractiveTarget {
39
+ return this.listener.target;
40
+ }
41
+ get isDisposed(): boolean {
42
+ return this.disposed;
43
+ }
44
+ get enabled(): boolean {
45
+ return this.enabled_;
46
+ }
47
+ get tabIndex(): number {
48
+ return this.tabIndex_;
49
+ }
50
+
51
+ setEnabled(enabled: boolean): void {
52
+ this.assertLive();
53
+ if (this.enabled_ === enabled) return;
54
+ this.enabled_ = enabled;
55
+ if (!enabled) this.manager.blurTarget(this);
56
+ else this.manager.refreshCapture();
57
+ }
58
+
59
+ setTabIndex(index: number): void {
60
+ this.assertLive();
61
+ this.tabIndex_ = Focusable.validateTabIndex(index);
62
+ this.manager.refreshCapture();
63
+ }
64
+
65
+ requestFocus(windowId?: number): boolean {
66
+ this.assertLive();
67
+ return this.manager.requestFocus(this, windowId);
68
+ }
69
+
70
+ blur(windowId?: number): void {
71
+ this.assertLive();
72
+ this.manager.blurTarget(this, windowId);
73
+ }
74
+
75
+ on(type: FocusEventType, callback: FocusCallback): () => void {
76
+ this.assertLive();
77
+ let callbacks = this.callbacks.get(type);
78
+ if (!callbacks) this.callbacks.set(type, (callbacks = new Set()));
79
+ callbacks.add(callback);
80
+ return () => callbacks.delete(callback);
81
+ }
82
+
83
+ off(type: FocusEventType, callback: FocusCallback): void {
84
+ this.callbacks.get(type)?.delete(callback);
85
+ }
86
+
87
+ /** @internal */
88
+ emit(type: FocusEventType, windowId: number): void {
89
+ const event = { type, windowId, realmId: this.realmId, target: this.target };
90
+ for (const callback of this.callbacks.get(type) ?? []) {
91
+ callback(event);
92
+ if (this.disposed) break;
93
+ }
94
+ }
95
+
96
+ dispose(): void {
97
+ if (this.disposed || this.disposing) return;
98
+ this.disposing = true;
99
+ this.manager.remove(this);
100
+ this.disposed = true;
101
+ this.callbacks.clear();
102
+ this.listener.dispose();
103
+ }
104
+
105
+ private assertLive(): void {
106
+ if (this.disposed) throw new Error('Focusable is disposed.');
107
+ }
108
+
109
+ private static validateTabIndex(index: number): number {
110
+ if (!Number.isSafeInteger(index)) throw new RangeError('tabIndex must be a safe integer.');
111
+ return index;
112
+ }
113
+ }
114
+
115
+ /** Keeps focus per window; only successfully presented direct targets are eligible. */
116
+ export class FocusManager {
117
+ private readonly targets = new Set<Focusable>();
118
+ private readonly focused = new Map<number, Focusable>();
119
+ private readonly composing = new Set<number>();
120
+ private readonly unfocused = new Set<number>();
121
+ private readonly capturedWindows = new Set<number>();
122
+
123
+ constructor(
124
+ private readonly interaction: InteractiveSystem,
125
+ private readonly windowIds: () => readonly number[],
126
+ private readonly captureTab: (windowId: number, enabled: boolean) => void,
127
+ private readonly requestInputFocus: (windowId: number) => void
128
+ ) {}
129
+
130
+ register(listener: InteractiveListener, tabIndex = 0): Focusable {
131
+ const target = new Focusable(listener, this, tabIndex);
132
+ this.targets.add(target);
133
+ this.refreshCapture();
134
+ return target;
135
+ }
136
+
137
+ getFocused(windowId: number): Focusable | undefined {
138
+ return this.focused.get(windowId);
139
+ }
140
+
141
+ requestFocus(target: Focusable, windowId?: number): boolean {
142
+ if (!this.targets.has(target) || !target.enabled || target.isDisposed) return false;
143
+ if (windowId === undefined) {
144
+ let match: number | undefined;
145
+ for (const id of this.windowIds()) {
146
+ if (this.unfocused.has(id)) continue;
147
+ if (this.interaction.presentationOrder(target.listener, id) === undefined) continue;
148
+ if (match !== undefined) return false;
149
+ match = id;
150
+ }
151
+ if (match === undefined) return false;
152
+ windowId = match;
153
+ }
154
+ if (
155
+ this.unfocused.has(windowId) ||
156
+ this.interaction.presentationOrder(target.listener, windowId) === undefined
157
+ )
158
+ return false;
159
+ const previous = this.focused.get(windowId);
160
+ if (previous === target) return true;
161
+ this.requestInputFocus(windowId);
162
+ this.focused.delete(windowId);
163
+ previous?.emit('blur', windowId);
164
+ // A blur callback may have changed focus, removed the target, or changed presentation.
165
+ if (
166
+ this.focused.has(windowId) ||
167
+ this.unfocused.has(windowId) ||
168
+ !this.targets.has(target) ||
169
+ !target.enabled ||
170
+ this.interaction.presentationOrder(target.listener, windowId) === undefined
171
+ )
172
+ return false;
173
+ this.focused.set(windowId, target);
174
+ this.refreshCapture();
175
+ target.emit('focus', windowId);
176
+ return this.focused.get(windowId) === target;
177
+ }
178
+
179
+ advance(windowId: number, reverse = false): boolean {
180
+ const currentFocused = this.focused.get(windowId);
181
+ let activeRealmId = currentFocused?.realmId;
182
+ const candidates: Array<{ target: Focusable; order: number; sequence: number }> = [];
183
+ let sequence = 0;
184
+ for (const target of this.targets) {
185
+ const order =
186
+ target.enabled && target.tabIndex >= 0
187
+ ? this.interaction.presentationOrder(target.listener, windowId)
188
+ : undefined;
189
+ if (order !== undefined) candidates.push({ target, order, sequence });
190
+ sequence++;
191
+ }
192
+ if (!candidates.length) return false;
193
+ candidates.sort((a, b) => {
194
+ const ai = a.target.tabIndex || Infinity,
195
+ bi = b.target.tabIndex || Infinity;
196
+ return ai - bi || a.order - b.order || a.sequence - b.sequence;
197
+ });
198
+ if (activeRealmId === undefined) activeRealmId = candidates[0]!.target.realmId;
199
+ const realmCandidates = candidates.filter(({ target }) => target.realmId === activeRealmId);
200
+ if (!realmCandidates.length) return false;
201
+ const current = realmCandidates.findIndex(({ target }) => target === currentFocused);
202
+ const index =
203
+ current < 0
204
+ ? reverse
205
+ ? realmCandidates.length - 1
206
+ : 0
207
+ : (current + (reverse ? -1 : 1) + realmCandidates.length) % realmCandidates.length;
208
+ return this.requestFocus(realmCandidates[index]!.target, windowId);
209
+ }
210
+
211
+ handleKeyboard(event: KeyboardEvent): void {
212
+ if (event.type === 'imePreedit') {
213
+ if (event.text) this.composing.add(event.windowId);
214
+ else this.composing.delete(event.windowId);
215
+ } else if (event.type === 'imeCommit' || event.type === 'imeDisabled') {
216
+ this.composing.delete(event.windowId);
217
+ } else if (
218
+ event.type === 'input' &&
219
+ event.state === 'pressed' &&
220
+ event.logicalKey === 'Tab' &&
221
+ !event.modifiers.ctrl &&
222
+ !event.modifiers.alt &&
223
+ !event.modifiers.superKey &&
224
+ !this.composing.has(event.windowId)
225
+ ) {
226
+ this.advance(event.windowId, event.modifiers.shift);
227
+ }
228
+ }
229
+
230
+ handlePointer(event: PointerEvent): void {
231
+ if (event.type !== 'button' || event.state !== 'pressed' || event.button !== 'primary') return;
232
+ const hovered = this.interaction.hovered(event.windowId, event.pointerId);
233
+ for (const target of this.targets) if (target.enabled && target.listener === hovered) return;
234
+ this.blurWindow(event.windowId);
235
+ this.refreshCapture();
236
+ }
237
+
238
+ blurTarget(target: Focusable, windowId?: number): void {
239
+ const windows: number[] = [];
240
+ for (const [id, focused] of this.focused)
241
+ if (focused === target && (windowId === undefined || id === windowId)) windows.push(id);
242
+ for (const id of windows) {
243
+ if (this.focused.get(id) !== target) continue;
244
+ this.focused.delete(id);
245
+ target.emit('blur', id);
246
+ }
247
+ this.refreshCapture();
248
+ }
249
+
250
+ remove(target: Focusable): void {
251
+ if (!this.targets.delete(target)) return;
252
+ this.blurTarget(target);
253
+ }
254
+
255
+ windowClosed(windowId: number): void {
256
+ this.capturedWindows.delete(windowId);
257
+ this.unfocused.delete(windowId);
258
+ this.blurWindow(windowId);
259
+ }
260
+
261
+ windowFocusChanged(windowId: number, focused: boolean): void {
262
+ if (focused) this.unfocused.delete(windowId);
263
+ else {
264
+ this.unfocused.add(windowId);
265
+ this.blurWindow(windowId);
266
+ this.refreshCapture();
267
+ }
268
+ }
269
+
270
+ private blurWindow(windowId: number): void {
271
+ this.composing.delete(windowId);
272
+ const target = this.focused.get(windowId);
273
+ if (!target) return;
274
+ this.focused.delete(windowId);
275
+ target.emit('blur', windowId);
276
+ }
277
+
278
+ /** Reconciles focus after a successful presentation. */
279
+ afterDispatch(): void {
280
+ if (!this.targets.size && !this.focused.size && !this.capturedWindows.size) return;
281
+ for (const target of this.targets) if (target.listener.isDisposed) target.dispose();
282
+ for (const [windowId, target] of this.focused)
283
+ if (
284
+ !target.enabled ||
285
+ this.interaction.presentationOrder(target.listener, windowId) === undefined
286
+ )
287
+ this.blurWindow(windowId);
288
+ this.refreshCapture();
289
+ }
290
+
291
+ /** Synchronizes platform Tab capture only when eligibility changes. */
292
+ refreshCapture(): void {
293
+ if (!this.targets.size && !this.focused.size && !this.capturedWindows.size) return;
294
+ const windows = this.windowIds();
295
+ for (const windowId of this.capturedWindows) {
296
+ if (windows.includes(windowId)) continue;
297
+ this.captureTab(windowId, false);
298
+ this.capturedWindows.delete(windowId);
299
+ }
300
+ for (const windowId of windows) {
301
+ let needed = this.focused.has(windowId);
302
+ for (const target of this.targets) {
303
+ if (needed) break;
304
+ if (
305
+ target.enabled &&
306
+ target.tabIndex >= 0 &&
307
+ this.interaction.presentationOrder(target.listener, windowId) !== undefined
308
+ ) {
309
+ needed = true;
310
+ break;
311
+ }
312
+ }
313
+ if (needed === this.capturedWindows.has(windowId)) continue;
314
+ this.captureTab(windowId, needed);
315
+ if (needed) this.capturedWindows.add(windowId);
316
+ else this.capturedWindows.delete(windowId);
317
+ }
318
+ }
319
+
320
+ dispose(): void {
321
+ for (const target of this.targets) target.dispose();
322
+ this.focused.clear();
323
+ this.composing.clear();
324
+ this.unfocused.clear();
325
+ this.capturedWindows.clear();
326
+ }
327
+ }
@@ -0,0 +1,9 @@
1
+ export { InteractiveListener } from './listener';
2
+ export { Focusable } from './focus';
3
+ export type { FocusEvent, FocusEventType, FocusCallback } from './focus';
4
+ export type {
5
+ InteractiveCallback,
6
+ InteractiveEvent,
7
+ InteractiveEventType,
8
+ InteractiveTarget
9
+ } from './types';
@@ -0,0 +1,58 @@
1
+ import type {
2
+ InteractiveCallback,
3
+ InteractiveEvent,
4
+ InteractiveEventType,
5
+ InteractiveTarget
6
+ } from './types';
7
+
8
+ /** Event subscriptions for one registered interactive target. */
9
+ export class InteractiveListener {
10
+ private readonly listeners = new Map<InteractiveEventType, Set<InteractiveCallback>>();
11
+ private disposed = false;
12
+
13
+ constructor(
14
+ readonly realmId: number,
15
+ readonly target: InteractiveTarget,
16
+ private readonly remove: (listener: InteractiveListener) => void
17
+ ) {}
18
+
19
+ /** @internal */
20
+ get isDisposed(): boolean {
21
+ return this.disposed;
22
+ }
23
+
24
+ on(type: InteractiveEventType, callback: InteractiveCallback): () => void {
25
+ if (this.disposed) throw new Error('Interactive listener is disposed.');
26
+ let callbacks = this.listeners.get(type);
27
+ if (!callbacks) this.listeners.set(type, (callbacks = new Set()));
28
+ callbacks.add(callback);
29
+ return () => callbacks.delete(callback);
30
+ }
31
+
32
+ off(type: InteractiveEventType, callback: InteractiveCallback): void {
33
+ this.listeners.get(type)?.delete(callback);
34
+ }
35
+
36
+ /** @internal */
37
+ hasListeners(type: InteractiveEventType): boolean {
38
+ return !this.disposed && (this.listeners.get(type)?.size ?? 0) > 0;
39
+ }
40
+
41
+ /** @internal */
42
+ emit(event: InteractiveEvent): void {
43
+ if (this.disposed) return;
44
+ const callbacks = this.listeners.get(event.type);
45
+ if (!callbacks) return;
46
+ for (const callback of callbacks) {
47
+ callback(event);
48
+ if (this.disposed) break;
49
+ }
50
+ }
51
+
52
+ dispose(): void {
53
+ if (this.disposed) return;
54
+ this.disposed = true;
55
+ this.listeners.clear();
56
+ this.remove(this);
57
+ }
58
+ }