react-x11 0.0.1 → 1.2.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.
@@ -0,0 +1,507 @@
1
+ // The react-reconciler host config plus the public render entry points.
2
+ // Host instances are the retained nodes from nodes.js; only <window> and
3
+ // <popup> map to real X11 windows (see NEXT_STEPS.md), and those windows
4
+ // are created top-down in the commit phase (WindowNode.realize) so every
5
+ // CreateWindow names its actual parent from the start — createInstance
6
+ // performs no X11 calls, since the render phase is discardable (issue #4).
7
+ import { createRequire } from 'node:module';
8
+ import React from 'react';
9
+ import ReactReconciler from 'react-reconciler';
10
+ import { createClient } from 'ntk';
11
+
12
+ import {
13
+ ConcurrentRoot,
14
+ getCurrentUpdatePriority,
15
+ setCurrentUpdatePriority,
16
+ resolveUpdatePriority,
17
+ } from './priority.js';
18
+ import {
19
+ WindowNode,
20
+ PopupNode,
21
+ BoxNode,
22
+ TextNode,
23
+ TextChunkNode,
24
+ ImageNode,
25
+ CanvasNode,
26
+ ScrollViewNode,
27
+ TextInputNode,
28
+ TextAreaNode,
29
+ flushWindowRestacks,
30
+ } from './nodes.js';
31
+ import { GlAreaNode } from './glnodes.js';
32
+ import { SCENE_KINDS, UNSUPPORTED_KINDS, createSceneNode } from './scene3d.js';
33
+ import {
34
+ MarkdownNode,
35
+ HtmlNode,
36
+ SvgNode,
37
+ SvgChildNode,
38
+ TexNode,
39
+ } from './richnodes.js';
40
+
41
+ const require = createRequire(import.meta.url);
42
+ const packageJson = require('../package.json');
43
+
44
+ const HOST_TYPES = [
45
+ 'window',
46
+ 'popup',
47
+ 'box',
48
+ 'text',
49
+ 'image',
50
+ 'canvas',
51
+ 'scrollview',
52
+ 'textinput',
53
+ 'textarea',
54
+ 'markdown',
55
+ 'html',
56
+ 'svg',
57
+ 'tex',
58
+ 'glarea',
59
+ ];
60
+
61
+ const isEventProp = (name) => /^on[A-Z]/.test(name);
62
+
63
+ // Props forwarded to ntk createWindow. Event handlers are dispatched by the
64
+ // EventManager from current props (never registered at creation, so they
65
+ // cannot go stale) and children are handled by the tree.
66
+ function windowAttributes(props) {
67
+ const attributes = {};
68
+ for (const key of Object.keys(props)) {
69
+ if (key === 'children' || isEventProp(key)) continue;
70
+ attributes[key] = props[key];
71
+ }
72
+ return attributes;
73
+ }
74
+
75
+ const HostConfig = {
76
+ supportsMutation: true,
77
+ supportsPersistence: false,
78
+ supportsHydration: false,
79
+ supportsResources: false,
80
+ supportsSingletons: false,
81
+ supportsTestSelectors: false,
82
+ supportsMicrotasks: true,
83
+ isPrimaryRenderer: true,
84
+ warnsIfNotActing: false,
85
+
86
+ rendererVersion: packageJson.version,
87
+ rendererPackageName: packageJson.name,
88
+ extraDevToolsConfig: null,
89
+
90
+ scheduleTimeout: setTimeout,
91
+ cancelTimeout: clearTimeout,
92
+ noTimeout: -1,
93
+ scheduleMicrotask: queueMicrotask,
94
+
95
+ getRootHostContext() {
96
+ return {
97
+ isInsideText: false,
98
+ isInsideSvg: false,
99
+ isInsideRichText: false,
100
+ isInside3d: false,
101
+ };
102
+ },
103
+
104
+ getChildHostContext(parentHostContext, type) {
105
+ return {
106
+ isInsideText: parentHostContext.isInsideText || type === 'text',
107
+ // <svg> children are declarative SVG elements, not react-x11 nodes
108
+ isInsideSvg: parentHostContext.isInsideSvg || type === 'svg',
109
+ // <markdown>/<html>/<tex> take their content as a string child
110
+ // (react-markdown style); no elements are allowed inside
111
+ isInsideRichText:
112
+ type === 'markdown' || type === 'html' || type === 'tex',
113
+ // inside <glarea> the children are scene nodes, not drawn nodes
114
+ isInside3d: parentHostContext.isInside3d || type === 'glarea',
115
+ };
116
+ },
117
+
118
+ getPublicInstance(instance) {
119
+ // Refs attach in the layout phase, after the mutation phase realized
120
+ // the window, so for windows this is the live ntk window.
121
+ return instance.isWindow ? instance.window || instance : instance;
122
+ },
123
+
124
+ prepareForCommit() {
125
+ return null;
126
+ },
127
+
128
+ // child <window>s that moved in the tree restack here, so a reorder costs
129
+ // one pass instead of one per insertBefore
130
+ resetAfterCommit() {
131
+ flushWindowRestacks();
132
+ },
133
+
134
+ createInstance(type, props, rootContainer, hostContext, internalHandle) {
135
+ if (hostContext.isInsideSvg) {
136
+ // Inside <svg> every element is a declarative SVG element (React-DOM
137
+ // style: <circle cx={12} strokeWidth={2} />); SvgView skips tags it
138
+ // does not support.
139
+ const node = new SvgChildNode(type, props, rootContainer);
140
+ node._reactFiber = internalHandle;
141
+ return node;
142
+ }
143
+ if (hostContext.isInsideRichText) {
144
+ throw new Error(
145
+ `react-x11: <${type}> is not allowed inside <markdown>/<html>/<tex>; ` +
146
+ 'their content is a string child (or the source prop).',
147
+ );
148
+ }
149
+ if (hostContext.isInsideText && type !== 'text') {
150
+ throw new Error(
151
+ `react-x11: <${type}> is not allowed inside <text>; only nested ` +
152
+ '<text> spans and strings are.',
153
+ );
154
+ }
155
+ if (hostContext.isInside3d) {
156
+ const scene = createSceneNode(type, props, rootContainer);
157
+ if (scene) {
158
+ scene._reactFiber = internalHandle;
159
+ return scene;
160
+ }
161
+ if (UNSUPPORTED_KINDS[type]) {
162
+ throw new Error(
163
+ `react-x11: <${type}> cannot work over indirect GLX — ` +
164
+ `${UNSUPPORTED_KINDS[type]}. See docs/glx-plan.md.`,
165
+ );
166
+ }
167
+ throw new Error(
168
+ `react-x11: <${type}> is not a 3D element; inside <glarea> only ` +
169
+ [...SCENE_KINDS].map((t) => `<${t}>`).join(', ') +
170
+ ' are.',
171
+ );
172
+ }
173
+ let node;
174
+ switch (type) {
175
+ case 'window':
176
+ // No X11 calls here: the render phase may be discarded. The real
177
+ // window is created top-down in the commit phase (realize).
178
+ node = new WindowNode(rootContainer, windowAttributes(props), props);
179
+ break;
180
+ case 'popup':
181
+ node = new PopupNode(rootContainer, windowAttributes(props), props);
182
+ break;
183
+ case 'box':
184
+ node = new BoxNode(props, rootContainer);
185
+ break;
186
+ case 'scrollview':
187
+ node = new ScrollViewNode(props, rootContainer);
188
+ break;
189
+ case 'textinput':
190
+ node = new TextInputNode(props, rootContainer);
191
+ break;
192
+ case 'textarea':
193
+ node = new TextAreaNode(props, rootContainer);
194
+ break;
195
+ case 'text':
196
+ node = new TextNode(props, rootContainer, {
197
+ span: hostContext.isInsideText,
198
+ });
199
+ break;
200
+ case 'image':
201
+ node = new ImageNode(props, rootContainer);
202
+ break;
203
+ case 'canvas':
204
+ node = new CanvasNode(props, rootContainer);
205
+ break;
206
+ case 'markdown':
207
+ node = new MarkdownNode(props, rootContainer);
208
+ break;
209
+ case 'html':
210
+ node = new HtmlNode(props, rootContainer);
211
+ break;
212
+ case 'svg':
213
+ node = new SvgNode(props, rootContainer);
214
+ break;
215
+ case 'tex':
216
+ node = new TexNode(props, rootContainer);
217
+ break;
218
+ case 'glarea':
219
+ node = new GlAreaNode(props, rootContainer);
220
+ break;
221
+ default:
222
+ if (SCENE_KINDS.has(type) || UNSUPPORTED_KINDS[type]) {
223
+ throw new Error(
224
+ `react-x11: <${type}> is a 3D element and only works inside ` +
225
+ '<glarea> (or the <Canvas3D> component).',
226
+ );
227
+ }
228
+ throw new Error(
229
+ `react-x11: unknown element type <${type}>. Supported: ` +
230
+ HOST_TYPES.map((t) => `<${t}>`).join(', ') +
231
+ '.',
232
+ );
233
+ }
234
+ node._reactFiber = internalHandle;
235
+ return node;
236
+ },
237
+
238
+ createTextInstance(text, rootContainer, hostContext) {
239
+ if (
240
+ !hostContext.isInsideText &&
241
+ !hostContext.isInsideRichText &&
242
+ !hostContext.isInsideSvg
243
+ ) {
244
+ throw new Error(
245
+ `react-x11: raw text ${JSON.stringify(text)} must be wrapped in a ` +
246
+ '<text> element (or be the string child of <markdown>/<html>/' +
247
+ '<tex>/an SVG <text>).',
248
+ );
249
+ }
250
+ return new TextChunkNode(text, rootContainer);
251
+ },
252
+
253
+ appendInitialChild(parentInstance, child) {
254
+ parentInstance.insertBefore(child, null);
255
+ },
256
+
257
+ finalizeInitialChildren(instance, type, props) {
258
+ // Popups are not attached to the container or realized by a parent
259
+ // window; commitMount realizes them against the screen root. autoFocus
260
+ // and trapFocus need commitMount too — the node has to be in the tree
261
+ // first, so it can find the EventManager that owns focus.
262
+ return (
263
+ type === 'popup' || Boolean(props.autoFocus) || Boolean(props.trapFocus)
264
+ );
265
+ },
266
+
267
+ commitMount(instance, type, props) {
268
+ if (type === 'popup') {
269
+ instance.realize(null);
270
+ }
271
+ if (props.trapFocus) {
272
+ instance._syncFocusScope?.();
273
+ }
274
+ if (props.autoFocus && typeof instance.focus === 'function') {
275
+ instance.focus();
276
+ }
277
+ },
278
+
279
+ appendChild(parentInstance, child) {
280
+ parentInstance.insertBefore(child, null);
281
+ },
282
+
283
+ appendChildToContainer(container, child) {
284
+ if (!child.window) {
285
+ // Top-level window: realize the whole subtree top-down against the
286
+ // screen root.
287
+ child.realize(null);
288
+ }
289
+ },
290
+
291
+ insertBefore(parentInstance, child, beforeChild) {
292
+ parentInstance.insertBefore(child, beforeChild);
293
+ },
294
+
295
+ insertInContainerBefore(container, child) {
296
+ HostConfig.appendChildToContainer(container, child);
297
+ },
298
+
299
+ removeChild(parentInstance, child) {
300
+ parentInstance.removeChild(child);
301
+ },
302
+
303
+ removeChildFromContainer(container, child) {
304
+ child.destroySubtree();
305
+ },
306
+
307
+ clearContainer() {},
308
+
309
+ commitUpdate(instance, type, oldProps, newProps) {
310
+ instance.applyProps(newProps, oldProps);
311
+ },
312
+
313
+ shouldSetTextContent() {
314
+ return false;
315
+ },
316
+
317
+ commitTextUpdate(textInstance, oldText, newText) {
318
+ textInstance.setText(newText);
319
+ },
320
+
321
+ resetTextContent() {},
322
+
323
+ hideInstance(instance) {
324
+ instance.setHidden(true);
325
+ },
326
+
327
+ unhideInstance(instance) {
328
+ instance.setHidden(false);
329
+ },
330
+
331
+ hideTextInstance(textInstance) {
332
+ textInstance.setText('');
333
+ },
334
+
335
+ unhideTextInstance(textInstance, text) {
336
+ textInstance.setText(text);
337
+ },
338
+
339
+ detachDeletedInstance(instance) {
340
+ instance.root?.events?.forget(instance);
341
+ },
342
+
343
+ preparePortalMount() {},
344
+ prepareScopeUpdate() {},
345
+ getInstanceFromScope() {
346
+ return null;
347
+ },
348
+ getInstanceFromNode() {
349
+ return null;
350
+ },
351
+ beforeActiveInstanceBlur() {},
352
+ afterActiveInstanceBlur() {},
353
+
354
+ // Update priority plumbing (React 19 scheduling contract).
355
+ setCurrentUpdatePriority,
356
+ getCurrentUpdatePriority,
357
+ resolveUpdatePriority,
358
+ shouldAttemptEagerTransition() {
359
+ return false;
360
+ },
361
+ trackSchedulerEvent() {},
362
+ resolveEventType() {
363
+ return null;
364
+ },
365
+ resolveEventTimeStamp() {
366
+ return -1.1;
367
+ },
368
+ requestPostPaintCallback() {},
369
+
370
+ // Suspensey commits are not used by this renderer.
371
+ maySuspendCommit() {
372
+ return false;
373
+ },
374
+ maySuspendCommitOnUpdate() {
375
+ return false;
376
+ },
377
+ maySuspendCommitInSyncRender() {
378
+ return false;
379
+ },
380
+ preloadInstance() {
381
+ return true;
382
+ },
383
+ startSuspendingCommit() {},
384
+ suspendInstance() {},
385
+ waitForCommitToBeReady() {
386
+ return null;
387
+ },
388
+
389
+ // Form actions / transitions (unused, required by the contract).
390
+ NotPendingTransition: null,
391
+ HostTransitionContext: React.createContext(null),
392
+ resetFormInstance() {},
393
+
394
+ bindToConsole(methodName, args) {
395
+ return Function.prototype.bind.apply(console[methodName], [
396
+ console,
397
+ ...args,
398
+ ]);
399
+ },
400
+ };
401
+
402
+ export const Renderer = ReactReconciler(HostConfig);
403
+
404
+ if (process.env.REACT_X11_DEVTOOLS) {
405
+ // Install the DevTools hook before any React commit (top-level await:
406
+ // module evaluation finishes before app code can call render) and
407
+ // register the renderer with the standalone DevTools app.
408
+ const devtools = await import('./DevToolsIntegration.js');
409
+ await devtools.prepare();
410
+ devtools.connect(Renderer);
411
+ }
412
+
413
+ if (process.env.REACT_X11_CLICK_TO_COMPONENT || process.env.REACT_X11_EDITOR) {
414
+ // Naming an editor already means you want the feature on — no need to
415
+ // also set REACT_X11_CLICK_TO_COMPONENT=1 just to pick one.
416
+ const clickToComponent = await import('./ClickToComponent.js');
417
+ clickToComponent.install();
418
+ }
419
+
420
+ const roots = new Map();
421
+ let cachedNtkApp = null;
422
+
423
+ async function connectApp() {
424
+ if (cachedNtkApp) return cachedNtkApp;
425
+ try {
426
+ cachedNtkApp = await createClient();
427
+ } catch (err) {
428
+ throw new Error(
429
+ 'react-x11: could not connect to the X server. Is an X server running ' +
430
+ `and DISPLAY set (DISPLAY=${process.env.DISPLAY || '<unset>'})? ` +
431
+ 'Original error: ' +
432
+ err.message,
433
+ );
434
+ }
435
+ return cachedNtkApp;
436
+ }
437
+
438
+ function renderIntoContainer(element, container, callback) {
439
+ let root = roots.get(container);
440
+ if (!root) {
441
+ root = Renderer.createContainer(
442
+ container,
443
+ ConcurrentRoot,
444
+ null,
445
+ false,
446
+ null,
447
+ '',
448
+ (error) => console.error('react-x11: uncaught error', error),
449
+ (error) => console.error('react-x11: caught error', error),
450
+ (error) => console.error('react-x11: recoverable error', error),
451
+ null,
452
+ );
453
+ roots.set(container, root);
454
+ }
455
+
456
+ Renderer.updateContainerSync(element, root, null, () => {
457
+ const publicInstance = Renderer.getPublicRootInstance(root);
458
+ if (callback) {
459
+ callback(publicInstance, container);
460
+ }
461
+ });
462
+ Renderer.flushSyncWork();
463
+ }
464
+
465
+ /**
466
+ * Legacy entry point. Without a container it connects to the X server
467
+ * (returns a promise in that case).
468
+ */
469
+ export function render(element, callback, container) {
470
+ if (!container) {
471
+ return connectApp().then((app) =>
472
+ renderIntoContainer(element, app, callback),
473
+ );
474
+ }
475
+ return renderIntoContainer(element, container, callback);
476
+ }
477
+
478
+ /**
479
+ * Modern entry point:
480
+ *
481
+ * const root = await createRoot(); // connects via DISPLAY
482
+ * root.render(<App />);
483
+ *
484
+ * Pass an ntk App (or a mock) to render into an existing connection.
485
+ */
486
+ export async function createRoot(container) {
487
+ const app = container ?? (await connectApp());
488
+ return {
489
+ app,
490
+ render(element, callback) {
491
+ renderIntoContainer(element, app, callback);
492
+ },
493
+ unmount() {
494
+ unmountComponentAtNode(app);
495
+ },
496
+ };
497
+ }
498
+
499
+ export function unmountComponentAtNode(container) {
500
+ const root = roots.get(container);
501
+ if (root) {
502
+ Renderer.updateContainerSync(null, root, null, () => {
503
+ roots.delete(container);
504
+ });
505
+ Renderer.flushSyncWork();
506
+ }
507
+ }
@@ -0,0 +1,63 @@
1
+ // Widget components built purely on the host primitives — no reconciler
2
+ // support needed. Plain createElement (no JSX) so the library stays
3
+ // build-step-free for consumers.
4
+
5
+ import React from 'react';
6
+ import { labelContent, useControl, useTheme } from './theme.js';
7
+
8
+ const h = React.createElement;
9
+
10
+ /**
11
+ * <Button onPress primary disabled …boxProps>label</Button> — the standard
12
+ * push button the examples kept re-implementing: hover/focus feedback,
13
+ * Space/Enter activation, pointer cursor.
14
+ */
15
+ export function Button({
16
+ children,
17
+ label,
18
+ onPress,
19
+ primary = false,
20
+ disabled = false,
21
+ ...boxProps
22
+ }) {
23
+ const theme = useTheme();
24
+ const { hover, focused, props } = useControl(disabled, onPress);
25
+ const background = disabled
26
+ ? theme.surfaceHover
27
+ : primary
28
+ ? hover
29
+ ? theme.accentHover
30
+ : theme.accent
31
+ : hover
32
+ ? theme.surfaceHover
33
+ : theme.background;
34
+ const color = disabled ? theme.dim : primary ? theme.accentText : theme.text;
35
+ return h(
36
+ 'box',
37
+ {
38
+ flexDirection: 'row',
39
+ alignItems: 'center',
40
+ justifyContent: 'center',
41
+ gap: 8,
42
+ paddingTop: 8,
43
+ paddingBottom: 8,
44
+ paddingLeft: 16,
45
+ paddingRight: 16,
46
+ borderRadius: 4,
47
+ borderWidth: 1,
48
+ borderColor: disabled
49
+ ? theme.border
50
+ : focused
51
+ ? primary
52
+ ? theme.accentHover
53
+ : theme.borderActive
54
+ : primary
55
+ ? theme.accent
56
+ : theme.border,
57
+ backgroundColor: background,
58
+ ...props,
59
+ ...boxProps,
60
+ },
61
+ labelContent(children ?? label, { color }),
62
+ );
63
+ }
@@ -0,0 +1,28 @@
1
+ import { createElement as h } from 'react';
2
+
3
+ /**
4
+ * `<Canvas3D>` — the react-three-fiber-shaped entry point to the 3D scene.
5
+ * A thin wrapper over the `<glarea>` host element: it is the surface that
6
+ * owns the GL context, and the scene lives in its children.
7
+ *
8
+ * ```jsx
9
+ * <Canvas3D flexGrow={1} camera={{ position: [3, 3, 6], fov: 50 }}>
10
+ * <mesh rotation={[0.4, 0.8, 0]}>
11
+ * <boxGeometry args={[1, 1, 1]} />
12
+ * <meshBasicMaterial color="#2980b9" />
13
+ * </mesh>
14
+ * </Canvas3D>
15
+ * ```
16
+ *
17
+ * Props are `<glarea>`'s (layout props, `clearColor`, `frameLoop`, `glx`,
18
+ * `onCreated`, `onDraw`, `onError`) plus `camera`:
19
+ * `{ position, target, up, fov, near, far, orthographic, zoom }`.
20
+ *
21
+ * The name is `Canvas3D`, not r3f's `Canvas`, because react-x11 already has
22
+ * a `<canvas>` host element — the 2D `onDraw` escape hatch.
23
+ */
24
+ export function Canvas3D({ children, ...props }) {
25
+ return h('glarea', props, children);
26
+ }
27
+
28
+ export default Canvas3D;
@@ -0,0 +1,69 @@
1
+ // Widget components built purely on the host primitives — no reconciler
2
+ // support needed. Plain createElement (no JSX) so the library stays
3
+ // build-step-free for consumers.
4
+
5
+ import React from 'react';
6
+ import { labelContent, useControl, useTheme } from './theme.js';
7
+
8
+ const h = React.createElement;
9
+
10
+ /**
11
+ * <Checkbox checked onChange disabled>label</Checkbox> — 16px check well +
12
+ * label row; click or Space toggles (onChange receives the next value).
13
+ */
14
+ export function Checkbox({
15
+ children,
16
+ label,
17
+ checked = false,
18
+ onChange,
19
+ disabled = false,
20
+ ...boxProps
21
+ }) {
22
+ const theme = useTheme();
23
+ const { focused, props } = useControl(disabled, () => onChange?.(!checked));
24
+ const fill = disabled ? theme.dim : theme.accent;
25
+ return h(
26
+ 'box',
27
+ {
28
+ flexDirection: 'row',
29
+ alignItems: 'center',
30
+ gap: 8,
31
+ ...props,
32
+ ...boxProps,
33
+ },
34
+ h(
35
+ 'box',
36
+ {
37
+ width: 16,
38
+ height: 16,
39
+ borderRadius: 3,
40
+ borderWidth: 1,
41
+ borderColor: checked
42
+ ? fill
43
+ : focused
44
+ ? theme.borderActive
45
+ : theme.border,
46
+ backgroundColor: checked ? fill : theme.background,
47
+ alignItems: 'center',
48
+ justifyContent: 'center',
49
+ },
50
+ checked &&
51
+ h('canvas', {
52
+ width: 10,
53
+ height: 8,
54
+ onDraw: (ctx) => {
55
+ ctx.strokeStyle = theme.accentText;
56
+ ctx.lineWidth = 2;
57
+ ctx.beginPath();
58
+ ctx.moveTo(1, 4);
59
+ ctx.lineTo(3.5, 6.5);
60
+ ctx.lineTo(9, 1);
61
+ ctx.stroke();
62
+ },
63
+ }),
64
+ ),
65
+ labelContent(children ?? label, {
66
+ color: disabled ? theme.dim : theme.text,
67
+ }),
68
+ );
69
+ }