@octanejs/opentui 0.0.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/src/driver.ts ADDED
@@ -0,0 +1,538 @@
1
+ import {
2
+ ImageRenderable,
3
+ RootRenderable,
4
+ TextNodeRenderable,
5
+ type BaseRenderable,
6
+ type CliRenderer,
7
+ } from '@opentui/core';
8
+ import type {
9
+ UniversalEventPriority,
10
+ UniversalHostBatch,
11
+ UniversalHostDriver,
12
+ UniversalHostParent,
13
+ UniversalPortalTargetHandle,
14
+ UniversalPreparedHostBatch,
15
+ } from 'octane/universal';
16
+ import { getComponentCatalogue } from './components.js';
17
+ import { OPENTUI_RENDERER_ID } from './config.js';
18
+ import {
19
+ materializeOpenTUIProps,
20
+ setInitialProperties,
21
+ updateProperties,
22
+ type CallbackEnvironment,
23
+ } from './props.js';
24
+ import { textNodeKeys } from './text.js';
25
+
26
+ const OPENTUI_DRIVER_STATE = Symbol('octane.opentui.driver.state');
27
+
28
+ type ParentId = UniversalHostParent | undefined;
29
+
30
+ interface OpenTUIHostRecord {
31
+ readonly id: number;
32
+ readonly type: string;
33
+ instance: BaseRenderable;
34
+ props: Readonly<Record<string, unknown>>;
35
+ appliedProps: Readonly<Record<string, unknown>>;
36
+ parent: ParentId;
37
+ readonly children: number[];
38
+ visible: boolean;
39
+ }
40
+
41
+ interface SimulatedRecord {
42
+ readonly type: string;
43
+ props: Readonly<Record<string, unknown>>;
44
+ parent: ParentId;
45
+ readonly children: number[];
46
+ visible: boolean;
47
+ }
48
+
49
+ interface PortalTargetEntry {
50
+ readonly handle: UniversalPortalTargetHandle;
51
+ readonly target: RootRenderable;
52
+ refCount: number;
53
+ }
54
+
55
+ interface OpenTUIDriverState {
56
+ readonly instances: Map<number, OpenTUIHostRecord>;
57
+ readonly rootChildren: number[];
58
+ readonly portalChildren: Map<string | number, number[]>;
59
+ readonly portalTargets: Map<string | number, PortalTargetEntry>;
60
+ readonly targetsByRoot: WeakMap<RootRenderable, PortalTargetEntry>;
61
+ nextPortalTarget: number;
62
+ }
63
+
64
+ export interface OpenTUIHostEnvironment extends CallbackEnvironment {
65
+ eventScope<T>(priority: UniversalEventPriority, run: () => T): T;
66
+ }
67
+
68
+ export interface OpenTUIHostContainer {
69
+ readonly renderer: typeof OPENTUI_RENDERER_ID;
70
+ readonly cliRenderer: CliRenderer;
71
+ readonly root: RootRenderable;
72
+ readonly environment: OpenTUIHostEnvironment;
73
+ readonly commits: readonly UniversalHostBatch[];
74
+ readonly instanceCount: number;
75
+ readonly [OPENTUI_DRIVER_STATE]: OpenTUIDriverState;
76
+ }
77
+
78
+ export function createOpenTUIContainer(
79
+ cliRenderer: CliRenderer,
80
+ environment: OpenTUIHostEnvironment,
81
+ ): OpenTUIHostContainer {
82
+ const state: OpenTUIDriverState = {
83
+ instances: new Map(),
84
+ rootChildren: [],
85
+ portalChildren: new Map(),
86
+ portalTargets: new Map(),
87
+ targetsByRoot: new WeakMap(),
88
+ nextPortalTarget: 1,
89
+ };
90
+ const commits: UniversalHostBatch[] = [];
91
+ return {
92
+ renderer: OPENTUI_RENDERER_ID,
93
+ cliRenderer,
94
+ root: cliRenderer.root,
95
+ environment,
96
+ commits,
97
+ get instanceCount() {
98
+ return state.instances.size;
99
+ },
100
+ [OPENTUI_DRIVER_STATE]: state,
101
+ };
102
+ }
103
+
104
+ function sameParent(left: ParentId, right: ParentId): boolean {
105
+ if (left === right) return true;
106
+ if (left == null || right == null || typeof left === 'number' || typeof right === 'number') {
107
+ return false;
108
+ }
109
+ return left.id === right.id && left.root === right.root && left.renderer === right.renderer;
110
+ }
111
+
112
+ function childrenFor(
113
+ state: OpenTUIDriverState,
114
+ parent: UniversalHostParent,
115
+ instances: Map<number, { children: number[] }>,
116
+ rootChildren: number[] = state.rootChildren,
117
+ portalState: Map<string | number, number[]> = state.portalChildren,
118
+ ): number[] {
119
+ if (parent === null) return rootChildren;
120
+ if (typeof parent === 'number') {
121
+ const record = instances.get(parent);
122
+ if (record === undefined) throw new Error(`@octanejs/opentui: Unknown parent ${parent}.`);
123
+ return record.children;
124
+ }
125
+ const entry = state.portalTargets.get(parent.id);
126
+ if (entry === undefined || entry.handle !== parent) {
127
+ throw new Error('@octanejs/opentui: Unknown or stale portal target.');
128
+ }
129
+ let children = portalState.get(parent.id);
130
+ if (children === undefined) {
131
+ children = [];
132
+ portalState.set(parent.id, children);
133
+ }
134
+ return children;
135
+ }
136
+
137
+ function physicalParent(
138
+ container: OpenTUIHostContainer,
139
+ parent: UniversalHostParent,
140
+ ): BaseRenderable {
141
+ if (parent === null) return container.root;
142
+ const state = container[OPENTUI_DRIVER_STATE];
143
+ if (typeof parent === 'number') {
144
+ const record = state.instances.get(parent);
145
+ if (record === undefined) throw new Error(`@octanejs/opentui: Unknown parent ${parent}.`);
146
+ return record.instance;
147
+ }
148
+ const entry = state.portalTargets.get(parent.id);
149
+ if (entry === undefined || entry.handle !== parent) {
150
+ throw new Error('@octanejs/opentui: Unknown or stale portal target.');
151
+ }
152
+ return entry.target;
153
+ }
154
+
155
+ function isTextContainer(type: string): boolean {
156
+ return type === 'text' || textNodeKeys.includes(type as (typeof textNodeKeys)[number]);
157
+ }
158
+
159
+ function validatePlacement(
160
+ instances: Map<number, SimulatedRecord>,
161
+ childId: number,
162
+ parent: UniversalHostParent,
163
+ ): void {
164
+ const child = instances.get(childId)!;
165
+ if (child.type !== '#text' && !textNodeKeys.includes(child.type as any)) return;
166
+ if (typeof parent !== 'number' || !isTextContainer(instances.get(parent)?.type ?? '')) {
167
+ const label = child.type === '#text' ? 'Text' : `Component ${JSON.stringify(child.type)}`;
168
+ throw new Error(`@octanejs/opentui: ${label} must be created inside a text node.`);
169
+ }
170
+ }
171
+
172
+ function assertNoCycle(
173
+ instances: Map<number, SimulatedRecord>,
174
+ childId: number,
175
+ parent: UniversalHostParent,
176
+ ): void {
177
+ let candidate: ParentId = parent;
178
+ while (typeof candidate === 'number') {
179
+ if (candidate === childId) {
180
+ throw new Error(`@octanejs/opentui: Cannot place ${childId} below itself.`);
181
+ }
182
+ candidate = instances.get(candidate)?.parent;
183
+ }
184
+ }
185
+
186
+ function materializeProps(
187
+ container: OpenTUIHostContainer,
188
+ props: Readonly<Record<string, unknown>>,
189
+ ): Readonly<Record<string, unknown>> {
190
+ return materializeOpenTUIProps(container.environment, props);
191
+ }
192
+
193
+ function createRecord(
194
+ container: OpenTUIHostContainer,
195
+ id: number,
196
+ type: string,
197
+ props: Readonly<Record<string, unknown>>,
198
+ ): OpenTUIHostRecord {
199
+ const appliedProps = materializeProps(container, props);
200
+ let instance: BaseRenderable;
201
+ if (type === '#text') {
202
+ if (typeof props.value !== 'string') {
203
+ throw new TypeError('@octanejs/opentui: #text hosts require a string value.');
204
+ }
205
+ instance = TextNodeRenderable.fromString(props.value);
206
+ } else {
207
+ const Constructor = getComponentCatalogue()[type];
208
+ if (Constructor === undefined) {
209
+ throw new Error(`@octanejs/opentui: Unknown component type ${JSON.stringify(type)}.`);
210
+ }
211
+ const initialProps =
212
+ type === 'image' && appliedProps.source !== undefined
213
+ ? { ...appliedProps, source: undefined }
214
+ : appliedProps;
215
+ instance = new Constructor(container.root.ctx, {
216
+ id: `${type}-${id}`,
217
+ ...initialProps,
218
+ });
219
+ setInitialProperties(instance, initialProps);
220
+ }
221
+ return {
222
+ id,
223
+ type,
224
+ instance,
225
+ props,
226
+ appliedProps,
227
+ parent: undefined,
228
+ children: [],
229
+ visible: true,
230
+ };
231
+ }
232
+
233
+ function applyDeferredImageSource(record: OpenTUIHostRecord): void {
234
+ if (record.instance instanceof ImageRenderable && record.appliedProps.source !== undefined) {
235
+ record.instance.source = record.appliedProps.source as ImageRenderable['source'];
236
+ }
237
+ }
238
+
239
+ function safeDestroy(instance: BaseRenderable): void {
240
+ try {
241
+ if (instance.parent !== null) instance.parent.remove(instance);
242
+ } finally {
243
+ instance.destroyRecursively();
244
+ }
245
+ }
246
+
247
+ function insertPhysical(
248
+ parent: BaseRenderable,
249
+ child: BaseRenderable,
250
+ before: BaseRenderable | null,
251
+ ): void {
252
+ if (before === null) parent.add(child);
253
+ else parent.insertBefore(child, before);
254
+ }
255
+
256
+ function removeFromCurrentParent(state: OpenTUIDriverState, record: OpenTUIHostRecord): void {
257
+ if (record.parent === undefined) return;
258
+ const siblings = childrenFor(state, record.parent, state.instances);
259
+ const index = siblings.indexOf(record.id);
260
+ if (index !== -1) siblings.splice(index, 1);
261
+ record.parent = undefined;
262
+ }
263
+
264
+ function applyPlacement(
265
+ container: OpenTUIHostContainer,
266
+ parent: UniversalHostParent,
267
+ id: number,
268
+ before: number | null,
269
+ ): void {
270
+ const state = container[OPENTUI_DRIVER_STATE];
271
+ const record = state.instances.get(id)!;
272
+ removeFromCurrentParent(state, record);
273
+ const siblings = childrenFor(state, parent, state.instances);
274
+ const index = before === null ? siblings.length : siblings.indexOf(before);
275
+ if (index === -1) throw new Error(`@octanejs/opentui: Unknown before target ${before}.`);
276
+ const beforeInstance = before === null ? null : state.instances.get(before)!.instance;
277
+ insertPhysical(physicalParent(container, parent), record.instance, beforeInstance);
278
+ siblings.splice(index, 0, id);
279
+ record.parent = parent;
280
+ }
281
+
282
+ function applyRecreation(
283
+ container: OpenTUIHostContainer,
284
+ id: number,
285
+ replacement: OpenTUIHostRecord,
286
+ ): void {
287
+ const state = container[OPENTUI_DRIVER_STATE];
288
+ const current = state.instances.get(id)!;
289
+ const parent = current.parent;
290
+ if (parent !== undefined) {
291
+ insertPhysical(physicalParent(container, parent), replacement.instance, current.instance);
292
+ }
293
+ for (const childId of current.children) {
294
+ replacement.instance.add(state.instances.get(childId)!.instance);
295
+ }
296
+ replacement.parent = current.parent;
297
+ replacement.children.splice(0, replacement.children.length, ...current.children);
298
+ replacement.visible = current.visible;
299
+ replacement.instance.visible = current.visible;
300
+ state.instances.set(id, replacement);
301
+ current.parent = undefined;
302
+ current.children.length = 0;
303
+ safeDestroy(current.instance);
304
+ }
305
+
306
+ function clonePortalChildren(state: OpenTUIDriverState): Map<string | number, number[]> {
307
+ return new Map([...state.portalChildren].map(([id, children]) => [id, [...children]]));
308
+ }
309
+
310
+ function prepareOpenTUIBatch(
311
+ container: OpenTUIHostContainer,
312
+ batch: UniversalHostBatch,
313
+ ): UniversalPreparedHostBatch {
314
+ if (batch.renderer !== OPENTUI_RENDERER_ID) {
315
+ throw new Error(
316
+ `@octanejs/opentui: Renderer mismatch for batch ${JSON.stringify(batch.renderer)}.`,
317
+ );
318
+ }
319
+ const state = container[OPENTUI_DRIVER_STATE];
320
+ const simulated = new Map<number, SimulatedRecord>();
321
+ for (const [id, record] of state.instances) {
322
+ simulated.set(id, {
323
+ type: record.type,
324
+ props: record.props,
325
+ parent: record.parent,
326
+ children: [...record.children],
327
+ visible: record.visible,
328
+ });
329
+ }
330
+ const rootChildren = [...state.rootChildren];
331
+ const portals = clonePortalChildren(state);
332
+ const stagedCreates = new Map<number, OpenTUIHostRecord>();
333
+ const stagedReplacements = new Map<number, OpenTUIHostRecord>();
334
+ const stagedAppliedProps = new WeakMap<object, Readonly<Record<string, unknown>>>();
335
+
336
+ const detachSimulated = (id: number): void => {
337
+ const record = simulated.get(id)!;
338
+ if (record.parent === undefined) return;
339
+ const siblings = childrenFor(state, record.parent, simulated, rootChildren, portals);
340
+ const index = siblings.indexOf(id);
341
+ if (index !== -1) siblings.splice(index, 1);
342
+ record.parent = undefined;
343
+ };
344
+
345
+ try {
346
+ for (const command of batch.commands) {
347
+ if (command.op === 'create') {
348
+ if (simulated.has(command.id)) {
349
+ throw new Error(`@octanejs/opentui: Duplicate instance id ${command.id}.`);
350
+ }
351
+ const record = createRecord(container, command.id, command.type, command.props);
352
+ stagedCreates.set(command.id, record);
353
+ simulated.set(command.id, {
354
+ type: command.type,
355
+ props: command.props,
356
+ parent: undefined,
357
+ children: [],
358
+ visible: true,
359
+ });
360
+ } else if (command.op === 'update') {
361
+ const record = simulated.get(command.id);
362
+ if (record === undefined) {
363
+ throw new Error(`@octanejs/opentui: Unknown update target ${command.id}.`);
364
+ }
365
+ record.props = command.props;
366
+ stagedAppliedProps.set(command, materializeProps(container, command.props));
367
+ } else if (command.op === 'recreate') {
368
+ const record = simulated.get(command.id);
369
+ if (record === undefined || !state.instances.has(command.id)) {
370
+ throw new Error(`@octanejs/opentui: Unknown recreate target ${command.id}.`);
371
+ }
372
+ if (record.type !== command.type) {
373
+ throw new Error(`@octanejs/opentui: Recreate type mismatch for ${command.id}.`);
374
+ }
375
+ const replacement = createRecord(container, command.id, command.type, command.props);
376
+ stagedReplacements.set(command.id, replacement);
377
+ record.props = command.props;
378
+ } else if (command.op === 'visibility') {
379
+ const record = simulated.get(command.id);
380
+ if (record === undefined) {
381
+ throw new Error(`@octanejs/opentui: Unknown visibility target ${command.id}.`);
382
+ }
383
+ record.visible = command.state === 'visible';
384
+ } else if (command.op === 'insert' || command.op === 'move') {
385
+ const record = simulated.get(command.id);
386
+ if (record === undefined) {
387
+ throw new Error(`@octanejs/opentui: Unknown placement target ${command.id}.`);
388
+ }
389
+ assertNoCycle(simulated, command.id, command.parent);
390
+ validatePlacement(simulated, command.id, command.parent);
391
+ detachSimulated(command.id);
392
+ const siblings = childrenFor(state, command.parent, simulated, rootChildren, portals);
393
+ const index = command.before === null ? siblings.length : siblings.indexOf(command.before);
394
+ if (index === -1) {
395
+ throw new Error(`@octanejs/opentui: Unknown before target ${command.before}.`);
396
+ }
397
+ siblings.splice(index, 0, command.id);
398
+ record.parent = command.parent;
399
+ } else if (command.op === 'remove') {
400
+ const record = simulated.get(command.id);
401
+ if (record === undefined || !sameParent(record.parent, command.parent)) {
402
+ throw new Error(`@octanejs/opentui: Instance ${command.id} is not attached there.`);
403
+ }
404
+ detachSimulated(command.id);
405
+ } else if (command.op === 'destroy') {
406
+ const record = simulated.get(command.id);
407
+ if (record === undefined) {
408
+ throw new Error(`@octanejs/opentui: Unknown destroy target ${command.id}.`);
409
+ }
410
+ detachSimulated(command.id);
411
+ for (const child of record.children) simulated.get(child)!.parent = undefined;
412
+ record.children.length = 0;
413
+ simulated.delete(command.id);
414
+ } else {
415
+ throw new Error(`@octanejs/opentui: Unsupported host command ${command.op}.`);
416
+ }
417
+ }
418
+ } catch (error) {
419
+ for (const record of [...stagedCreates.values(), ...stagedReplacements.values()]) {
420
+ safeDestroy(record.instance);
421
+ }
422
+ throw error;
423
+ }
424
+
425
+ let status: 'prepared' | 'applied' | 'aborted' = 'prepared';
426
+ return {
427
+ apply() {
428
+ if (status !== 'prepared') return;
429
+ status = 'applied';
430
+ for (const command of batch.commands) {
431
+ if (command.op === 'create') {
432
+ state.instances.set(command.id, stagedCreates.get(command.id)!);
433
+ } else if (command.op === 'update') {
434
+ const record = state.instances.get(command.id)!;
435
+ const applied = stagedAppliedProps.get(command)!;
436
+ if (record.type === '#text') {
437
+ if (typeof command.props.value !== 'string') {
438
+ throw new TypeError('@octanejs/opentui: #text hosts require a string value.');
439
+ }
440
+ (record.instance as TextNodeRenderable).children = [command.props.value];
441
+ } else {
442
+ updateProperties(record.instance, record.appliedProps, applied);
443
+ }
444
+ record.props = command.props;
445
+ record.appliedProps = applied;
446
+ } else if (command.op === 'recreate') {
447
+ applyRecreation(container, command.id, stagedReplacements.get(command.id)!);
448
+ } else if (command.op === 'visibility') {
449
+ const record = state.instances.get(command.id)!;
450
+ record.visible = command.state === 'visible';
451
+ record.instance.visible = record.visible;
452
+ } else if (command.op === 'insert' || command.op === 'move') {
453
+ applyPlacement(container, command.parent, command.id, command.before);
454
+ } else if (command.op === 'remove') {
455
+ const record = state.instances.get(command.id)!;
456
+ removeFromCurrentParent(state, record);
457
+ if (record.instance.parent !== null) record.instance.parent.remove(record.instance);
458
+ } else if (command.op === 'destroy') {
459
+ const record = state.instances.get(command.id)!;
460
+ removeFromCurrentParent(state, record);
461
+ state.instances.delete(command.id);
462
+ safeDestroy(record.instance);
463
+ }
464
+ }
465
+ for (const record of stagedCreates.values()) {
466
+ if (state.instances.get(record.id) === record) applyDeferredImageSource(record);
467
+ }
468
+ for (const record of stagedReplacements.values()) {
469
+ if (state.instances.get(record.id) === record) applyDeferredImageSource(record);
470
+ }
471
+ (container.commits as UniversalHostBatch[]).push(batch);
472
+ container.cliRenderer.requestRender();
473
+ },
474
+ abort() {
475
+ if (status !== 'prepared') return;
476
+ status = 'aborted';
477
+ for (const record of [...stagedCreates.values(), ...stagedReplacements.values()]) {
478
+ safeDestroy(record.instance);
479
+ }
480
+ stagedCreates.clear();
481
+ stagedReplacements.clear();
482
+ },
483
+ };
484
+ }
485
+
486
+ export function createOpenTUIDriver(): UniversalHostDriver<OpenTUIHostContainer, BaseRenderable> {
487
+ return {
488
+ id: OPENTUI_RENDERER_ID,
489
+ capabilities: { text: 'host', visibility: true },
490
+ portals: {
491
+ prepareTarget(context) {
492
+ if (!(context.target instanceof RootRenderable)) {
493
+ throw new TypeError(
494
+ '@octanejs/opentui: Portal targets must be RootRenderable instances.',
495
+ );
496
+ }
497
+ if (context.target === context.container.root) {
498
+ throw new Error('@octanejs/opentui: The primary root cannot also be a portal target.');
499
+ }
500
+ if (context.target.ctx !== context.container.root.ctx) {
501
+ throw new Error('@octanejs/opentui: Portal targets must belong to the same CliRenderer.');
502
+ }
503
+ const state = context.container[OPENTUI_DRIVER_STATE];
504
+ let entry = state.targetsByRoot.get(context.target);
505
+ if (entry === undefined) {
506
+ const handle = context.createPortalTargetHandle(state.nextPortalTarget++);
507
+ entry = { handle, target: context.target, refCount: 0 };
508
+ state.targetsByRoot.set(context.target, entry);
509
+ state.portalTargets.set(handle.id, entry);
510
+ state.portalChildren.set(handle.id, []);
511
+ }
512
+ entry.refCount++;
513
+ let released = false;
514
+ return {
515
+ handle: entry.handle,
516
+ release() {
517
+ if (released) return;
518
+ released = true;
519
+ entry!.refCount--;
520
+ if (entry!.refCount !== 0) return;
521
+ if ((state.portalChildren.get(entry!.handle.id)?.length ?? 0) !== 0) {
522
+ throw new Error('@octanejs/opentui: Cannot release a portal target with children.');
523
+ }
524
+ state.portalChildren.delete(entry!.handle.id);
525
+ state.portalTargets.delete(entry!.handle.id);
526
+ state.targetsByRoot.delete(entry!.target);
527
+ },
528
+ };
529
+ },
530
+ },
531
+ prepareBatch(container, batch) {
532
+ return prepareOpenTUIBatch(container, batch);
533
+ },
534
+ getPublicInstance(container, id) {
535
+ return container[OPENTUI_DRIVER_STATE].instances.get(id)?.instance ?? null;
536
+ },
537
+ };
538
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,168 @@
1
+ import {
2
+ engine,
3
+ Timeline,
4
+ type KeyEvent,
5
+ type PasteEvent,
6
+ type Selection,
7
+ type TimelineOptions,
8
+ } from '@opentui/core';
9
+ import { useEffect, useEffectEvent, useState } from 'octane/universal';
10
+ import { useAppContext } from './context.js';
11
+ import { splitSlot, subSlot } from './slots.js';
12
+
13
+ export interface UseKeyboardOptions {
14
+ /** Include release events; the callback then receives keyrelease events too. */
15
+ release?: boolean;
16
+ }
17
+
18
+ export function useRenderer() {
19
+ const { renderer } = useAppContext();
20
+ if (renderer === null) throw new Error('OpenTUI renderer not found.');
21
+ return renderer;
22
+ }
23
+
24
+ export function useKeyboard(handler: (key: KeyEvent) => void, options?: UseKeyboardOptions): void;
25
+ export function useKeyboard(handler: (key: KeyEvent) => void, ...args: unknown[]): void {
26
+ const [userArgs, slot] = splitSlot(args);
27
+ const options = (userArgs[0] as UseKeyboardOptions | undefined) ?? {};
28
+ const { keyHandler } = useAppContext();
29
+ const stableHandler = useEffectEvent(handler, subSlot(slot, 'useKeyboard:handler'));
30
+ useEffect(
31
+ () => {
32
+ keyHandler?.on('keypress', stableHandler);
33
+ if (options.release === true) keyHandler?.on('keyrelease', stableHandler);
34
+ return () => {
35
+ keyHandler?.off('keypress', stableHandler);
36
+ if (options.release === true) keyHandler?.off('keyrelease', stableHandler);
37
+ };
38
+ },
39
+ [keyHandler, options.release, stableHandler],
40
+ subSlot(slot, 'useKeyboard:effect'),
41
+ );
42
+ }
43
+
44
+ export function usePaste(handler: (event: PasteEvent) => void): void;
45
+ export function usePaste(handler: (event: PasteEvent) => void, slot?: symbol): void {
46
+ const { keyHandler } = useAppContext();
47
+ const stableHandler = useEffectEvent(handler, subSlot(slot, 'usePaste:handler'));
48
+ useEffect(
49
+ () => {
50
+ keyHandler?.on('paste', stableHandler);
51
+ return () => keyHandler?.off('paste', stableHandler);
52
+ },
53
+ [keyHandler, stableHandler],
54
+ subSlot(slot, 'usePaste:effect'),
55
+ );
56
+ }
57
+
58
+ export function useFocus(handler: () => void): void;
59
+ export function useFocus(handler: () => void, slot?: symbol): void {
60
+ const renderer = useRenderer();
61
+ const stableHandler = useEffectEvent(handler, subSlot(slot, 'useFocus:handler'));
62
+ useEffect(
63
+ () => {
64
+ renderer.on('focus', stableHandler);
65
+ return () => renderer.off('focus', stableHandler);
66
+ },
67
+ [renderer, stableHandler],
68
+ subSlot(slot, 'useFocus:effect'),
69
+ );
70
+ }
71
+
72
+ export function useBlur(handler: () => void): void;
73
+ export function useBlur(handler: () => void, slot?: symbol): void {
74
+ const renderer = useRenderer();
75
+ const stableHandler = useEffectEvent(handler, subSlot(slot, 'useBlur:handler'));
76
+ useEffect(
77
+ () => {
78
+ renderer.on('blur', stableHandler);
79
+ return () => renderer.off('blur', stableHandler);
80
+ },
81
+ [renderer, stableHandler],
82
+ subSlot(slot, 'useBlur:effect'),
83
+ );
84
+ }
85
+
86
+ export function useSelectionHandler(handler: (selection: Selection) => void): void;
87
+ export function useSelectionHandler(handler: (selection: Selection) => void, slot?: symbol): void {
88
+ const renderer = useRenderer();
89
+ const stableHandler = useEffectEvent(handler, subSlot(slot, 'useSelection:handler'));
90
+ useEffect(
91
+ () => {
92
+ renderer.on('selection', stableHandler);
93
+ return () => renderer.off('selection', stableHandler);
94
+ },
95
+ [renderer, stableHandler],
96
+ subSlot(slot, 'useSelection:effect'),
97
+ );
98
+ }
99
+
100
+ export function useOnResize(
101
+ callback: (width: number, height: number) => void,
102
+ ): ReturnType<typeof useRenderer>;
103
+ export function useOnResize(
104
+ callback: (width: number, height: number) => void,
105
+ slot: symbol,
106
+ ): ReturnType<typeof useRenderer>;
107
+ export function useOnResize(
108
+ callback: (width: number, height: number) => void,
109
+ slot?: symbol,
110
+ ): ReturnType<typeof useRenderer> {
111
+ const renderer = useRenderer();
112
+ const stableCallback = useEffectEvent(callback, subSlot(slot, 'useResize:handler'));
113
+ useEffect(
114
+ () => {
115
+ renderer.on('resize', stableCallback);
116
+ return () => {
117
+ renderer.off('resize', stableCallback);
118
+ };
119
+ },
120
+ [renderer, stableCallback],
121
+ subSlot(slot, 'useResize:effect'),
122
+ );
123
+ return renderer;
124
+ }
125
+
126
+ export interface TerminalDimensions {
127
+ width: number;
128
+ height: number;
129
+ }
130
+
131
+ export function useTerminalDimensions(): TerminalDimensions;
132
+ export function useTerminalDimensions(...args: unknown[]): TerminalDimensions {
133
+ const [, slot] = splitSlot(args);
134
+ const renderer = useRenderer();
135
+ const [dimensions, setDimensions] = useState<TerminalDimensions>(
136
+ () => ({ width: renderer.width, height: renderer.height }),
137
+ subSlot(slot, 'useTerminalDimensions:state'),
138
+ );
139
+ const resizeSlot = subSlot(slot, 'useTerminalDimensions:resize');
140
+ const updateDimensions = (width: number, height: number) => setDimensions({ width, height });
141
+ if (resizeSlot === undefined) useOnResize(updateDimensions);
142
+ else useOnResize(updateDimensions, resizeSlot);
143
+ return dimensions;
144
+ }
145
+
146
+ export function useTimeline(options?: TimelineOptions): Timeline;
147
+ export function useTimeline(...args: unknown[]): Timeline {
148
+ const [userArgs, slot] = splitSlot(args);
149
+ const options = (userArgs[0] as TimelineOptions | undefined) ?? {};
150
+ const [timeline] = useState(() => new Timeline(options), subSlot(slot, 'useTimeline:timeline'));
151
+ const [autoplay] = useState(
152
+ () => options.autoplay !== false,
153
+ subSlot(slot, 'useTimeline:autoplay'),
154
+ );
155
+ useEffect(
156
+ () => {
157
+ if (autoplay) timeline.play();
158
+ engine.register(timeline);
159
+ return () => {
160
+ timeline.pause();
161
+ engine.unregister(timeline);
162
+ };
163
+ },
164
+ [autoplay, timeline],
165
+ subSlot(slot, 'useTimeline:effect'),
166
+ );
167
+ return timeline;
168
+ }