@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/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export { createPortal } from 'octane/universal';
2
+ export { baseComponents, componentCatalogue, extend, getComponentCatalogue } from './components.js';
3
+ export { AppContext, useAppContext, type AppContextValue } from './context.js';
4
+ export * from './hooks.js';
5
+ export { act, flushSync, type Act } from './scheduling.js';
6
+ export { createRoot, type Root } from './root.js';
7
+ export * from './slot.js';
8
+ export { TimeToFirstDraw } from './time-to-first-draw.js';
9
+ export type * from './types.js';
@@ -0,0 +1,16 @@
1
+ import type { OpenTUIIntrinsicElements } from './types.js';
2
+ import type { UniversalKey, UniversalRenderable } from 'octane/universal';
3
+
4
+ export type * from './types.js';
5
+
6
+ /** TypeScript resolves this namespace through the renderer-local intrinsic entry point. */
7
+ export namespace JSX {
8
+ export type Element = UniversalRenderable;
9
+ export interface ElementChildrenAttribute {
10
+ children: {};
11
+ }
12
+ export interface IntrinsicAttributes {
13
+ key?: UniversalKey;
14
+ }
15
+ export interface IntrinsicElements extends OpenTUIIntrinsicElements {}
16
+ }
package/src/props.ts ADDED
@@ -0,0 +1,198 @@
1
+ import {
2
+ InputRenderable,
3
+ InputRenderableEvents,
4
+ isRenderable,
5
+ SelectRenderable,
6
+ SelectRenderableEvents,
7
+ TabSelectRenderable,
8
+ TabSelectRenderableEvents,
9
+ TextareaRenderable,
10
+ type BaseRenderable,
11
+ } from '@opentui/core';
12
+ import type { UniversalEventPriority } from 'octane/universal';
13
+
14
+ export interface CallbackEnvironment {
15
+ eventScope<T>(priority: UniversalEventPriority, run: () => T): T;
16
+ }
17
+
18
+ type Props = Readonly<Record<string, unknown>>;
19
+
20
+ const callbackCaches = new WeakMap<
21
+ CallbackEnvironment,
22
+ WeakMap<(...args: any[]) => unknown, Map<string, (...args: any[]) => unknown>>
23
+ >();
24
+
25
+ function callbackPriority(name: string): UniversalEventPriority {
26
+ if (
27
+ name === 'onMouseMove' ||
28
+ name === 'onMouseDrag' ||
29
+ name === 'onMouseOver' ||
30
+ name === 'onMouseOut' ||
31
+ name === 'onMouseScroll'
32
+ ) {
33
+ return 'continuous';
34
+ }
35
+ if (name === 'renderBefore' || name === 'renderAfter' || name === 'onSizeChange') {
36
+ return 'default';
37
+ }
38
+ return 'discrete';
39
+ }
40
+
41
+ function wrapCallback(
42
+ environment: CallbackEnvironment,
43
+ name: string,
44
+ handler: (...args: any[]) => unknown,
45
+ ): (...args: any[]) => unknown {
46
+ let environmentCache = callbackCaches.get(environment);
47
+ if (environmentCache === undefined) {
48
+ environmentCache = new WeakMap();
49
+ callbackCaches.set(environment, environmentCache);
50
+ }
51
+ let handlerCache = environmentCache.get(handler);
52
+ if (handlerCache === undefined) {
53
+ handlerCache = new Map();
54
+ environmentCache.set(handler, handlerCache);
55
+ }
56
+ const cached = handlerCache.get(name);
57
+ if (cached !== undefined) return cached;
58
+ const priority = callbackPriority(name);
59
+ const wrapped = function openTUIHostCallback(this: unknown, ...args: unknown[]): unknown {
60
+ return environment.eventScope(priority, () => handler.apply(this, args));
61
+ };
62
+ handlerCache.set(name, wrapped);
63
+ return wrapped;
64
+ }
65
+
66
+ /** Preserve OpenTUI's multi-argument callback API while entering Octane's scheduler scope. */
67
+ export function materializeOpenTUIProps(environment: CallbackEnvironment, props: Props): Props {
68
+ const output: Record<string, unknown> = {};
69
+ for (const [name, value] of Object.entries(props)) {
70
+ output[name] =
71
+ typeof value === 'function'
72
+ ? wrapCallback(environment, name, value as (...args: any[]) => unknown)
73
+ : value;
74
+ }
75
+ return output;
76
+ }
77
+
78
+ function updateEmitterListener(
79
+ instance: BaseRenderable,
80
+ eventName: string,
81
+ listener: unknown,
82
+ previousListener: unknown,
83
+ ): void {
84
+ if (typeof previousListener === 'function') {
85
+ instance.off(eventName, previousListener as (...args: any[]) => void);
86
+ }
87
+ if (typeof listener === 'function') {
88
+ instance.on(eventName, listener as (...args: any[]) => void);
89
+ }
90
+ }
91
+
92
+ function setStyle(instance: BaseRenderable, styles: unknown, oldStyles: unknown): void {
93
+ if (oldStyles !== null && typeof oldStyles === 'object') {
94
+ for (const styleName of Object.keys(oldStyles)) {
95
+ if (styles === null || typeof styles !== 'object' || !(styleName in styles)) {
96
+ Reflect.set(instance, styleName, null);
97
+ }
98
+ }
99
+ }
100
+ if (styles !== null && typeof styles === 'object') {
101
+ for (const [styleName, value] of Object.entries(styles)) {
102
+ if (!Object.is(value, (oldStyles as Record<string, unknown> | null)?.[styleName])) {
103
+ Reflect.set(instance, styleName, value);
104
+ }
105
+ }
106
+ }
107
+ }
108
+
109
+ function setProperty(
110
+ instance: BaseRenderable,
111
+ propKey: string,
112
+ propValue: unknown,
113
+ oldPropValue?: unknown,
114
+ ): void {
115
+ switch (propKey) {
116
+ case 'onChange':
117
+ if (instance instanceof InputRenderable) {
118
+ updateEmitterListener(instance, InputRenderableEvents.CHANGE, propValue, oldPropValue);
119
+ } else if (instance instanceof SelectRenderable) {
120
+ updateEmitterListener(
121
+ instance,
122
+ SelectRenderableEvents.SELECTION_CHANGED,
123
+ propValue,
124
+ oldPropValue,
125
+ );
126
+ } else if (instance instanceof TabSelectRenderable) {
127
+ updateEmitterListener(
128
+ instance,
129
+ TabSelectRenderableEvents.SELECTION_CHANGED,
130
+ propValue,
131
+ oldPropValue,
132
+ );
133
+ }
134
+ break;
135
+ case 'onInput':
136
+ if (instance instanceof InputRenderable) {
137
+ updateEmitterListener(instance, InputRenderableEvents.INPUT, propValue, oldPropValue);
138
+ }
139
+ break;
140
+ case 'onSubmit':
141
+ if (instance instanceof InputRenderable) {
142
+ updateEmitterListener(instance, InputRenderableEvents.ENTER, propValue, oldPropValue);
143
+ } else if (instance instanceof TextareaRenderable) {
144
+ instance.onSubmit = propValue as (() => void) | undefined;
145
+ }
146
+ break;
147
+ case 'onSelect':
148
+ if (instance instanceof SelectRenderable) {
149
+ updateEmitterListener(
150
+ instance,
151
+ SelectRenderableEvents.ITEM_SELECTED,
152
+ propValue,
153
+ oldPropValue,
154
+ );
155
+ } else if (instance instanceof TabSelectRenderable) {
156
+ updateEmitterListener(
157
+ instance,
158
+ TabSelectRenderableEvents.ITEM_SELECTED,
159
+ propValue,
160
+ oldPropValue,
161
+ );
162
+ }
163
+ break;
164
+ case 'focused':
165
+ if (isRenderable(instance)) {
166
+ if (propValue) instance.focus();
167
+ else instance.blur();
168
+ }
169
+ break;
170
+ case 'style':
171
+ setStyle(instance, propValue, oldPropValue);
172
+ break;
173
+ case 'children':
174
+ break;
175
+ default:
176
+ Reflect.set(instance, propKey, propValue);
177
+ }
178
+ }
179
+
180
+ export function setInitialProperties(instance: BaseRenderable, props: Props): void {
181
+ for (const [name, value] of Object.entries(props)) {
182
+ if (value != null) setProperty(instance, name, value);
183
+ }
184
+ }
185
+
186
+ export function updateProperties(instance: BaseRenderable, previous: Props, next: Props): void {
187
+ for (const [name, value] of Object.entries(previous)) {
188
+ if (value != null && !Object.prototype.hasOwnProperty.call(next, name)) {
189
+ setProperty(instance, name, null, value);
190
+ }
191
+ }
192
+ for (const [name, value] of Object.entries(next)) {
193
+ const previousValue = previous[name];
194
+ if (!Object.is(value, previousValue) && (value != null || previousValue != null)) {
195
+ setProperty(instance, name, value, previousValue);
196
+ }
197
+ }
198
+ }
@@ -0,0 +1,11 @@
1
+ /** Compiler-facing OpenTUI renderer ABI. */
2
+ export * from 'octane/universal';
3
+ export * from './components.js';
4
+ export * from './context.js';
5
+ export * from './driver.js';
6
+ export * from './hooks.js';
7
+ export * from './root.js';
8
+ export * from './scheduling.js';
9
+ export * from './slot.js';
10
+ export * from './time-to-first-draw.js';
11
+ export type * from './types.js';
package/src/root.ts ADDED
@@ -0,0 +1,120 @@
1
+ import { CliRenderEvents, engine, type CliRenderer } from '@opentui/core';
2
+ import {
3
+ createUniversalRoot,
4
+ defineUniversalComponent,
5
+ universalComponent,
6
+ universalContext,
7
+ universalPlan,
8
+ universalTry,
9
+ universalValue,
10
+ type UniversalComponent,
11
+ type UniversalRoot,
12
+ } from 'octane/universal';
13
+ import { AppContext } from './context.js';
14
+ import { OPENTUI_RENDERER_ID } from './config.js';
15
+ import {
16
+ createOpenTUIContainer,
17
+ createOpenTUIDriver,
18
+ type OpenTUIHostEnvironment,
19
+ } from './driver.js';
20
+
21
+ export interface Root {
22
+ render(component: UniversalComponent<void | undefined>): void;
23
+ render<P>(component: UniversalComponent<P>, props: P): void;
24
+ unmount(): void;
25
+ }
26
+
27
+ interface RootProviderProps {
28
+ readonly renderer: CliRenderer;
29
+ readonly component: UniversalComponent<any>;
30
+ readonly componentProps: any;
31
+ }
32
+
33
+ const ERROR_PLAN = universalPlan(OPENTUI_RENDERER_ID, {
34
+ kind: 'host',
35
+ type: 'box',
36
+ props: { style: { flexDirection: 'column', padding: 2 } },
37
+ children: [
38
+ {
39
+ kind: 'host',
40
+ type: 'text',
41
+ props: { fg: 'red' },
42
+ children: [{ kind: 'text', slot: 0 }],
43
+ },
44
+ ],
45
+ });
46
+
47
+ function errorText(error: unknown): string {
48
+ if (error instanceof Error) return error.stack || error.message;
49
+ return String(error);
50
+ }
51
+
52
+ const RootProvider = defineUniversalComponent<RootProviderProps>(
53
+ OPENTUI_RENDERER_ID,
54
+ (props) =>
55
+ universalContext(
56
+ AppContext,
57
+ { keyHandler: props.renderer.keyInput, renderer: props.renderer },
58
+ () =>
59
+ universalTry(
60
+ () => universalComponent(OPENTUI_RENDERER_ID, props.component, props.componentProps),
61
+ null,
62
+ (error) => universalValue(ERROR_PLAN, [errorText(error)]),
63
+ ),
64
+ ),
65
+ { module: '@octanejs/opentui' },
66
+ );
67
+
68
+ const roots = new WeakMap<CliRenderer, Root>();
69
+
70
+ export function createRoot(renderer: CliRenderer): Root {
71
+ const existing = roots.get(renderer);
72
+ if (existing !== undefined) {
73
+ console.warn('@octanejs/opentui: createRoot should only be called once for a renderer.');
74
+ return existing;
75
+ }
76
+
77
+ let hostRoot: UniversalRoot | null = null;
78
+ let unmounted = false;
79
+ const environment: OpenTUIHostEnvironment = {
80
+ eventScope(priority, run) {
81
+ if (hostRoot === null) {
82
+ throw new Error('@octanejs/opentui: A host callback ran before the root was created.');
83
+ }
84
+ return hostRoot.eventScope(priority, run);
85
+ },
86
+ };
87
+ const container = createOpenTUIContainer(renderer, environment);
88
+ hostRoot = createUniversalRoot(container, createOpenTUIDriver());
89
+
90
+ const cleanup = (): void => {
91
+ if (unmounted) return;
92
+ unmounted = true;
93
+ try {
94
+ hostRoot?.unmount();
95
+ } finally {
96
+ hostRoot = null;
97
+ renderer.off(CliRenderEvents.DESTROY, cleanup);
98
+ roots.delete(renderer);
99
+ }
100
+ };
101
+
102
+ const root: Root = {
103
+ render<P>(component: UniversalComponent<P>, props?: P): void {
104
+ if (unmounted || hostRoot === null) {
105
+ throw new Error('@octanejs/opentui: Cannot render into an unmounted root.');
106
+ }
107
+ engine.attach(renderer);
108
+ hostRoot.render(RootProvider, {
109
+ renderer,
110
+ component,
111
+ componentProps: props,
112
+ });
113
+ },
114
+ unmount: cleanup,
115
+ };
116
+
117
+ renderer.once(CliRenderEvents.DESTROY, cleanup);
118
+ roots.set(renderer, root);
119
+ return root;
120
+ }
@@ -0,0 +1,78 @@
1
+ import * as Octane from 'octane';
2
+ import { flushUniversalAct, flushUniversalSync, type UniversalSyncFlusher } from 'octane/universal';
3
+
4
+ export type Act = typeof import('octane').act;
5
+ type FlushSync = typeof import('octane').flushSync;
6
+ const NO_ACT_ERROR = Symbol('octane.opentui.no-act-error');
7
+
8
+ function isThenable<T>(value: T | Promise<T>): value is Promise<T> {
9
+ return (
10
+ value !== null &&
11
+ (typeof value === 'object' || typeof value === 'function') &&
12
+ typeof (value as PromiseLike<T>).then === 'function'
13
+ );
14
+ }
15
+
16
+ function runActCallback<T>(
17
+ callback: () => T | Promise<T>,
18
+ flushOwner?: UniversalSyncFlusher,
19
+ ): T | Promise<T> {
20
+ const result = flushUniversalAct(callback, flushOwner);
21
+ if (!isThenable(result)) return result;
22
+ return Promise.resolve(result).then((value) => flushUniversalAct(() => value, flushOwner));
23
+ }
24
+
25
+ function runClientAct<T>(callback: () => T | Promise<T>, clientAct: Act): Promise<T> {
26
+ const completions: Promise<unknown>[] = [];
27
+ const flushOwner: UniversalSyncFlusher = <Value>(run: () => Value): Value => {
28
+ let value!: Value;
29
+ let error: unknown = NO_ACT_ERROR;
30
+ const completion = clientAct(() => {
31
+ try {
32
+ value = run();
33
+ } catch (caught) {
34
+ error = caught;
35
+ }
36
+ });
37
+ completions.push(completion);
38
+ if (error !== NO_ACT_ERROR) throw error;
39
+ return value;
40
+ };
41
+
42
+ let result: T | Promise<T>;
43
+ try {
44
+ result = flushUniversalAct(callback, flushOwner);
45
+ } catch (error) {
46
+ return Promise.allSettled(completions.splice(0)).then(() => {
47
+ throw error;
48
+ });
49
+ }
50
+ return (async () => {
51
+ try {
52
+ const value = await result;
53
+ await Promise.all(completions.splice(0));
54
+ const settled = flushUniversalAct(() => value, flushOwner);
55
+ await Promise.all(completions.splice(0));
56
+ return settled;
57
+ } catch (error) {
58
+ await Promise.allSettled(completions.splice(0));
59
+ throw error;
60
+ }
61
+ })();
62
+ }
63
+
64
+ export const flushSync: FlushSync = (callback) => {
65
+ const clientFlushSync = Reflect.get(Octane, 'flushSync') as FlushSync | undefined;
66
+ return flushUniversalSync(callback, clientFlushSync);
67
+ };
68
+
69
+ export const act: Act = (callback) => {
70
+ const clientAct = Reflect.get(Octane, 'act') as Act | undefined;
71
+ const clientFlushSync = Reflect.get(Octane, 'flushSync') as FlushSync | undefined;
72
+ if (typeof clientAct === 'function') return runClientAct(callback, clientAct);
73
+ try {
74
+ return Promise.resolve(runActCallback(callback, clientFlushSync));
75
+ } catch (error) {
76
+ return Promise.reject(error);
77
+ }
78
+ };