react-x11 2.15.2 → 2.16.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.
Files changed (64) hide show
  1. package/README.md +37 -0
  2. package/package.json +4 -3
  3. package/src/Reconciler.js +85 -22
  4. package/src/anchor.js +60 -18
  5. package/src/application.js +25 -1
  6. package/src/capabilities.js +349 -0
  7. package/src/cocoa/app.js +28 -9
  8. package/src/cocoa/context2d.js +139 -6
  9. package/src/cocoa/fonts.js +78 -0
  10. package/src/cocoa/presenter.js +17 -0
  11. package/src/cocoa/promotion.js +20 -0
  12. package/src/cocoa/relaunch.js +8 -3
  13. package/src/cocoa/symbols.js +64 -0
  14. package/src/cocoa/threaded.js +24 -4
  15. package/src/cocoa/window.js +362 -139
  16. package/src/components/ProgressBar.js +1 -1
  17. package/src/components/Slider.js +72 -39
  18. package/src/components/anchor.js +7 -2
  19. package/src/components/index.js +1 -0
  20. package/src/components/theme.js +32 -28
  21. package/src/dbusmenuexport.js +243 -0
  22. package/src/desktopcapabilityhooks.js +160 -0
  23. package/src/filedialoghooks.js +3 -5
  24. package/src/frame/childmain.js +8 -20
  25. package/src/frame/env.js +2 -10
  26. package/src/globalmenu.js +3 -205
  27. package/src/icontheme.js +240 -0
  28. package/src/imagesource.js +98 -3
  29. package/src/index.d.ts +1 -0
  30. package/src/index.js +11 -2
  31. package/src/launcher.js +235 -32
  32. package/src/launcherhooks.js +47 -28
  33. package/src/node.d.ts +7 -0
  34. package/src/nodes/animation.js +17 -47
  35. package/src/nodes/cascade.js +17 -2
  36. package/src/nodes/image.js +65 -2
  37. package/src/nodes/kinds.js +12 -0
  38. package/src/nodes/layout.js +5 -1
  39. package/src/nodes/node.js +17 -3
  40. package/src/nodes/paint.js +117 -0
  41. package/src/nodes/scope.js +259 -0
  42. package/src/nodes/scrollable.js +53 -6
  43. package/src/nodes/text.js +2 -0
  44. package/src/nodes/textarea.js +1 -1
  45. package/src/nodes/textinput.js +1 -1
  46. package/src/nodes/window/anchoring.js +45 -18
  47. package/src/nodes/window/flush.js +6 -5
  48. package/src/nodes/window/popup.js +10 -0
  49. package/src/nodes/window/size.js +40 -2
  50. package/src/nodes/window/window.js +41 -14
  51. package/src/registry.js +2 -1
  52. package/src/settings.js +332 -0
  53. package/src/statusnotifier.js +752 -0
  54. package/src/styles.js +212 -8
  55. package/src/symbols.js +200 -0
  56. package/src/testing/mock-app.js +10 -0
  57. package/src/trayhooks.js +193 -29
  58. package/src/types/capabilities.d.ts +139 -0
  59. package/src/types/components.d.ts +33 -0
  60. package/src/types/elements.d.ts +57 -6
  61. package/src/types/launcher.d.ts +50 -4
  62. package/src/types/style.d.ts +57 -0
  63. package/src/types/system.d.ts +104 -0
  64. package/src/types/tray.d.ts +64 -6
package/README.md CHANGED
@@ -609,6 +609,43 @@ The full threat model, including what react-x11 does and does not defend
609
609
  against, is [docs/security.md](docs/security.md). To report something,
610
610
  [SECURITY.md](SECURITY.md).
611
611
 
612
+ ## Similar projects
613
+
614
+ - [GPUIX](https://github.com/remorses/gpuix) — React for
615
+ [GPUI](https://github.com/zed-industries/zed/tree/main/crates/gpui), Zed's
616
+ GPU UI framework. Each commit crosses a napi-rs addon as one batch of
617
+ mutations to a retained Rust tree, which GPUI lays out with Taffy's flexbox
618
+ and paints through Metal, DirectX or Vulkan, on macOS, Windows and Linux.
619
+ - [react-native-gpui](https://github.com/natew/react-native-gpui) — React
620
+ Native's component API (`View`, `Text`, `Pressable`, `TextInput`,
621
+ `FlatList`) on GPUI as well, hosted the other way round: a Rust binary
622
+ embeds the system's JavaScriptCore and runs the bundled app, and commits
623
+ reach GPUI in memory. macOS on Apple silicon only, for now.
624
+ - [GTKX](https://github.com/gtkx-org/gtkx) — React for GNOME: every GObject
625
+ is a JSX element, so the tree is the platform's own GTK4 and Adwaita
626
+ widgets. The TypeScript bindings are generated from GObject-Introspection
627
+ and call into GTK through a Rust core on Node.js. Linux-only by design.
628
+ - React Native for [Windows](https://github.com/microsoft/react-native-windows)
629
+ and [macOS](https://github.com/microsoft/react-native-macos) — Microsoft's
630
+ desktop platforms for React Native. On Windows the new architecture's
631
+ renderer targets Composition from the start, in Windows App SDK apps by
632
+ default, with XAML islands for advanced controls; on macOS a fork of React
633
+ Native maps its UIKit views onto AppKit. [docs/windows.md](docs/windows.md)
634
+ cites its threading as prior art: JS on a thread of its own, and every
635
+ mount posted to the UI thread that alone touches the visuals.
636
+ - [React Native Skia](https://github.com/Shopify/react-native-skia) — 2D
637
+ graphics for React Native, with a React renderer of its own: the
638
+ components inside a `<Canvas>` become a Skia display list, cheap to
639
+ animate with Reanimated. On iOS, Android, macOS and tvOS, in the browser
640
+ through CanvasKit, and headless on Node. Its experimental Graphite builds
641
+ render through Dawn and can share a GPU device with React Native WebGPU.
642
+ - [React Native WebGPU](https://github.com/wcandillon/react-native-webgpu) —
643
+ not a renderer but a GPU surface for React Native: the WebGPU API
644
+ (`navigator.gpu` and a `<Canvas>` view) on iOS, Android, macOS and
645
+ visionOS, implemented over Dawn, Chrome's WebGPU implementation. It runs
646
+ three.js's WebGPU renderer and React Three Fiber. The nearest thing in
647
+ react-x11 is [`<glarea>`](#3d), which speaks GL instead.
648
+
612
649
  ## See also
613
650
 
614
651
  - [awesome-react-renderer](https://github.com/chentsulin/awesome-react-renderer)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.15.2",
3
+ "version": "2.16.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -33,6 +33,7 @@
33
33
  "examples:attention": "tsx examples/attention.jsx",
34
34
  "examples:badge": "tsx examples/badge.jsx",
35
35
  "examples:tray": "tsx examples/tray.jsx",
36
+ "examples:desktop": "tsx examples/desktop.jsx",
36
37
  "examples:tooltips": "tsx examples/tooltips.jsx",
37
38
  "examples:chat": "tsx examples/chat.jsx",
38
39
  "examples:clipboard": "tsx examples/clipboard.jsx",
@@ -101,12 +102,12 @@
101
102
  "dependencies": {
102
103
  "ical.js": "^2.2.1",
103
104
  "linebreak": "^1.1.0",
104
- "ntk": "^8.7.0",
105
+ "ntk": "^8.9.0",
105
106
  "react-reconciler": "^0.34.0",
106
107
  "yoga-layout": "^3.2.1"
107
108
  },
108
109
  "optionalDependencies": {
109
- "@windowkit/appkit": "^0.10.0",
110
+ "@windowkit/appkit": "^0.12.0",
110
111
  "@windowkit/wayland": "^3.1.1",
111
112
  "dbus-native": "^0.15.1",
112
113
  "x11-dri": "^0.9.0"
package/src/Reconciler.js CHANGED
@@ -24,6 +24,13 @@ import {
24
24
  } from './nodes/window/window.js';
25
25
  import { PopupNode } from './nodes/window/popup.js';
26
26
  import { BoxNode } from './nodes/box.js';
27
+ import { THEME_SCOPE } from './nodes/kinds.js';
28
+ import {
29
+ ThemeBoxNode,
30
+ ThemeScopeNode,
31
+ attachTopLevel,
32
+ detachTopLevel,
33
+ } from './nodes/scope.js';
27
34
  import { TextNode, TextChunkNode, setTextStripBelow } from './nodes/text.js';
28
35
  import { ImageNode } from './nodes/image.js';
29
36
  import { CanvasNode } from './nodes/canvas.js';
@@ -60,7 +67,11 @@ import { watchAppearance } from './appearance.js';
60
67
  import { setDesktopIntegration } from './desktopintegration.js';
61
68
  import { ForeignNode } from './foreignnodes.js';
62
69
  import { GlAreaNode } from './glnodes.js';
63
- import { createRegisteredNode, registeredElements } from './registry.js';
70
+ import {
71
+ createRegisteredNode,
72
+ elementDefinition,
73
+ registeredElements,
74
+ } from './registry.js';
64
75
  import { SvgNode, SvgChildNode } from './svgnodes.js';
65
76
  import { loadLayout } from './yoga.js';
66
77
 
@@ -97,6 +108,36 @@ export const HOST_TYPES = [
97
108
  'foreign',
98
109
  ];
99
110
 
111
+ // The host context of the root's own children. `atRoot` is what lets
112
+ // `createInstance` see that an element has no window above it — there is no
113
+ // other moment to know it: a node is built bottom-up, before it has a parent
114
+ // to ask, and appended to the container only in the commit.
115
+ const ROOT_CONTEXT = Object.freeze({
116
+ isInsideText: false,
117
+ isInsideSvg: false,
118
+ atRoot: true,
119
+ inWindow: false,
120
+ });
121
+
122
+ // What the root of the tree can hold: the elements that are windows of their
123
+ // own, and the provider's node, which holds more of them. Everything else
124
+ // draws into a window, so it has to be inside one.
125
+ const ROOT_TYPES = new Set(['window', 'popup', THEME_SCOPE]);
126
+
127
+ /**
128
+ * A drawn element with no window above it. Without this it reached
129
+ * `appendChildToContainer` and failed there as `child.realize is not a
130
+ * function` — or, under a `<ThemeProvider>`, as a `<window>` nested in a
131
+ * `<box>` the app never wrote (#584).
132
+ */
133
+ function notAtRoot(type) {
134
+ return new Error(
135
+ `react-x11: <${type}> must be inside a <window> — it is at the root of ` +
136
+ 'the tree, which holds only <window> and <popup> elements. Render it ' +
137
+ `inside one: <window><${type} …/></window>.`,
138
+ );
139
+ }
140
+
100
141
  const HostConfig = {
101
142
  supportsMutation: true,
102
143
  supportsPersistence: false,
@@ -135,10 +176,7 @@ const HostConfig = {
135
176
  scheduleMicrotask: queueMicrotask,
136
177
 
137
178
  getRootHostContext() {
138
- return {
139
- isInsideText: false,
140
- isInsideSvg: false,
141
- };
179
+ return ROOT_CONTEXT;
142
180
  },
143
181
 
144
182
  getChildHostContext(parentHostContext, type) {
@@ -150,6 +188,12 @@ const HostConfig = {
150
188
  isInsideText: parentHostContext.isInsideText || type === 'text',
151
189
  // <svg> children are declarative SVG elements, not react-x11 nodes
152
190
  isInsideSvg: parentHostContext.isInsideSvg || type === 'svg',
191
+ // Still at the root under a `<ThemeProvider>` written there, which
192
+ // draws nothing; under anything else, inside a window.
193
+ atRoot: parentHostContext.atRoot && type === THEME_SCOPE,
194
+ // Directly inside a window, where a provider can hand a nested window
195
+ // on to it.
196
+ inWindow: type === 'window' || type === 'popup',
153
197
  };
154
198
  },
155
199
 
@@ -195,6 +239,17 @@ const HostConfig = {
195
239
  '<text> spans and strings are.',
196
240
  );
197
241
  }
242
+ // Asked here, in the render phase, rather than when the node reaches the
243
+ // container: an error boundary above can catch it, and nothing has been
244
+ // built yet. A name nobody knows gets the unknown-element error below,
245
+ // which lists what there is.
246
+ if (
247
+ hostContext.atRoot &&
248
+ !ROOT_TYPES.has(type) &&
249
+ (HOST_TYPES.includes(type) || elementDefinition(type))
250
+ ) {
251
+ throw notAtRoot(type);
252
+ }
198
253
  let node;
199
254
  switch (type) {
200
255
  case 'window':
@@ -216,6 +271,18 @@ const HostConfig = {
216
271
  case 'box':
217
272
  node = new BoxNode(props, rootContainer);
218
273
  break;
274
+ case THEME_SCOPE:
275
+ // `<ThemeProvider>`'s node (nodes/kinds.js). Above the windows it
276
+ // draws nothing and hands its palette to the windows under it; inside
277
+ // a window it is the box that fills its parent it has always been, so
278
+ // `style` means what it says and a `$token` beneath resolves — and
279
+ // directly inside one, a box that passes nested windows on to it.
280
+ node = hostContext.atRoot
281
+ ? new ThemeScopeNode(props, rootContainer)
282
+ : hostContext.inWindow
283
+ ? new ThemeBoxNode(props, rootContainer)
284
+ : new BoxNode(props, rootContainer);
285
+ break;
219
286
  case 'textinput':
220
287
  node = new TextInputNode(props, rootContainer);
221
288
  break;
@@ -278,6 +345,13 @@ const HostConfig = {
278
345
  },
279
346
 
280
347
  createTextInstance(text, rootContainer, hostContext) {
348
+ if (hostContext.atRoot) {
349
+ throw new Error(
350
+ `react-x11: raw text ${JSON.stringify(text)} must be inside a ` +
351
+ '<window>, in a <text> element — it is at the root of the tree, ' +
352
+ 'which holds only <window> and <popup> elements.',
353
+ );
354
+ }
281
355
  if (!hostContext.isInsideText && !hostContext.isInsideSvg) {
282
356
  throw new Error(
283
357
  `react-x11: raw text ${JSON.stringify(text)} must be wrapped in a ` +
@@ -338,19 +412,11 @@ const HostConfig = {
338
412
  parentInstance.insertBefore(child, null);
339
413
  },
340
414
 
415
+ // The root holds windows, popups and theme scopes (`createInstance` turned
416
+ // everything else away), and nodes/scope.js is what knows how to put each
417
+ // of them there — a scope's windows can arrive after the scope did.
341
418
  appendChildToContainer(container, child) {
342
- if (!child.window) {
343
- // Top-level window: realize the whole subtree top-down against the
344
- // screen root.
345
- child.realize(null);
346
- }
347
- // React's getPublicRootInstance answers from the root fiber's first
348
- // child, and only when that child is a host component. `render()` wraps
349
- // the tree in a context provider, which is not one, so it would answer
350
- // null — the container keeps the list instead. Same answer as before:
351
- // the first top-level node the tree put here.
352
- (container._rootChildren ??= []).push(child);
353
- a11yHooks.rootMounted?.(child);
419
+ attachTopLevel(container, child);
354
420
  },
355
421
 
356
422
  insertBefore(parentInstance, child, beforeChild) {
@@ -358,7 +424,7 @@ const HostConfig = {
358
424
  },
359
425
 
360
426
  insertInContainerBefore(container, child) {
361
- HostConfig.appendChildToContainer(container, child);
427
+ attachTopLevel(container, child);
362
428
  },
363
429
 
364
430
  removeChild(parentInstance, child) {
@@ -366,11 +432,8 @@ const HostConfig = {
366
432
  },
367
433
 
368
434
  removeChildFromContainer(container, child) {
369
- const roots = container._rootChildren;
370
- const at = roots ? roots.indexOf(child) : -1;
371
- if (at !== -1) roots.splice(at, 1);
372
435
  // before the destroy, while the subtree is still walkable
373
- a11yHooks.rootUnmounted?.(child);
436
+ detachTopLevel(container, child);
374
437
  child.destroySubtree();
375
438
  },
376
439
 
package/src/anchor.js CHANGED
@@ -204,44 +204,86 @@ export function anchorRect(node, options = {}) {
204
204
  // popup's `x`/`y` props. The math between runs in device pixels, because
205
205
  // `abs`, the window origin and the monitor area are (src/scale.js).
206
206
  const s = node.scale ?? 1;
207
+ // The anchor is the sub-rect where there is one, all the way through:
208
+ // the side that flips, the edge that aligns, and — since a popup with no
209
+ // size of its own is as wide as the thing it hangs off — the default
210
+ // width.
211
+ const anchor = subRect(node, deviceAt(options.at, s));
212
+ // the rect the *alignment* reads, which is the anchor's own unless the
213
+ // caller split the two axes — one origin serves both, since both nodes are
214
+ // in the same window
215
+ const cross = options.alignTo ? (subRect(options.alignTo) ?? anchor) : anchor;
216
+ const origin = windowOrigin(node);
217
+ const onScreen = (r) => ({
218
+ x: origin.x + r.x,
219
+ y: origin.y + r.y,
220
+ width: r.width,
221
+ height: r.height,
222
+ });
223
+ return placeAgainst(
224
+ onScreen(anchor),
225
+ onScreen(cross),
226
+ deviceAnchorArea(node),
227
+ s,
228
+ { ...options, direction: options.direction ?? node.direction },
229
+ );
230
+ }
231
+
232
+ /**
233
+ * The same placement against a rect **on the screen**, with no node behind
234
+ * it: the frame of the tray item a click reports, or a point where the
235
+ * pointer was. `rect` is logical screen pixels, the unit a popup's `x`/`y`
236
+ * are in, and `{ x, y }` alone is a point; the result is logical too.
237
+ *
238
+ * `scale` is the display's, since there is no node to ask, and `direction`
239
+ * decides what `'start'` and `'end'` mean — a node's anchor takes both from
240
+ * the node. The popup is kept on the monitor the rect is on.
241
+ */
242
+ export function anchorScreenRect(app, rect, options = {}) {
243
+ if (typeof rect?.x !== 'number' || typeof rect?.y !== 'number') return null;
244
+ const s = options.scale ?? 1;
245
+ const anchor = {
246
+ x: rect.x * s,
247
+ y: rect.y * s,
248
+ width: (rect.width ?? 0) * s,
249
+ height: (rect.height ?? 0) * s,
250
+ };
251
+ const area = app ? availableArea(app, { x: anchor.x, y: anchor.y }) : null;
252
+ return placeAgainst(anchor, anchor, area, s, options);
253
+ }
254
+
255
+ /**
256
+ * The placement both anchors share, in device pixels: `anchor` is the rect
257
+ * the popup hangs off, `cross` the one it aligns to, both on the screen, and
258
+ * `area` what it is kept inside — null where there is nothing to ask, and it
259
+ * then neither flips nor clamps.
260
+ */
261
+ function placeAgainst(anchor, cross, area, s, options) {
207
262
  const {
208
263
  placement = 'bottom',
209
264
  align = 'start',
210
265
  alignOffset: logicalAlignOffset = 0,
211
266
  offset: logicalOffset = 2,
212
- at,
213
- alignTo,
214
267
  // A popup that covers its own anchor — a native popup button's menu,
215
268
  // which opens with the chosen row over the control — has no other side
216
269
  // to flip to: it is clamped into the screen instead, as AppKit clamps.
217
270
  flip = true,
218
- direction = node.direction,
271
+ direction,
219
272
  } = options;
220
273
  const alignOffset = logicalAlignOffset * s;
221
274
  const offset = logicalOffset * s;
222
275
  const rtl = direction === 'rtl';
223
276
 
224
- // The anchor is the sub-rect where there is one, all the way through:
225
- // the side that flips, the edge that aligns, and — since a popup with no
226
- // size of its own is as wide as the thing it hangs off — the default
227
- // width.
228
- const anchor = subRect(node, deviceAt(at, s));
229
277
  const width = options.width !== undefined ? options.width * s : anchor.width;
230
278
  const height = options.height !== undefined ? options.height * s : 0;
231
279
 
232
- const origin = windowOrigin(node);
233
- const ax = origin.x + anchor.x;
234
- const ay = origin.y + anchor.y;
280
+ const ax = anchor.x;
281
+ const ay = anchor.y;
235
282
  const aw = anchor.width;
236
283
  const ah = anchor.height;
237
- // the rect the *alignment* reads, which is the anchor's own unless the
238
- // caller split the two axes — one origin serves both, since both nodes are
239
- // in the same window
240
- const cross = alignTo ? (subRect(alignTo) ?? anchor) : anchor;
241
- const cx = origin.x + cross.x;
242
- const cy = origin.y + cross.y;
284
+ const cx = cross.x;
285
+ const cy = cross.y;
243
286
 
244
- const area = deviceAnchorArea(node);
245
287
  const left = area?.x ?? 0;
246
288
  const top = area?.y ?? 0;
247
289
  const right = area ? area.x + area.width : null;
@@ -346,6 +346,18 @@ function environmentContext() {
346
346
  * that was mounted after the launch it is asking about.
347
347
  */
348
348
  let current = null;
349
+ /**
350
+ * The role of this process's registration, which is **not** the same question
351
+ * as `current`.
352
+ *
353
+ * `current` is the *primary* registration and is deliberately null for a
354
+ * second copy of an app, because a secondary must not own the badge or the
355
+ * launcher entry — the primary does. But "no registration at all" and "a
356
+ * registration that lost the race" are different facts, and a feature probe
357
+ * that cannot tell them apart tells an app author to call a function they
358
+ * already called. See `capabilities.js`.
359
+ */
360
+ let currentRole = null;
349
361
 
350
362
  /**
351
363
  * The schemes the registration declared, kept beside it for the transports
@@ -675,6 +687,7 @@ export async function registerApplication(options = {}) {
675
687
  await registration.remove().catch(() => {});
676
688
  await forward(ref, dbus, { appId, objectPath, uris });
677
689
  await ref.release();
690
+ currentRole = 'secondary';
678
691
  const secondary = {
679
692
  role: 'secondary',
680
693
  appId,
@@ -711,7 +724,10 @@ export async function registerApplication(options = {}) {
711
724
  async release() {
712
725
  if (released) return;
713
726
  released = true;
714
- if (current === primaryRegistration) current = null;
727
+ if (current === primaryRegistration) {
728
+ current = null;
729
+ currentRole = null;
730
+ }
715
731
  await ref.bus.releaseName(appId).catch(() => {});
716
732
  await registration?.remove().catch(() => {});
717
733
  registration = null;
@@ -721,6 +737,7 @@ export async function registerApplication(options = {}) {
721
737
  primaryRegistration[Symbol.asyncDispose] = () =>
722
738
  primaryRegistration.release();
723
739
  current = primaryRegistration;
740
+ currentRole = 'primary';
724
741
  return primaryRegistration;
725
742
  }
726
743
 
@@ -774,6 +791,12 @@ async function forward(ref, dbus, { appId, objectPath, uris }) {
774
791
  }
775
792
  }
776
793
 
794
+ /** `'primary' | 'secondary' | null`. Not public — `capabilities.js` uses it to
795
+ * tell "never registered" from "registered, but another copy is the app". */
796
+ export function currentRegistrationRole() {
797
+ return currentRole;
798
+ }
799
+
777
800
  /** This process's registration, or `null`. Not public; the docs use `role`. */
778
801
  export function currentRegistration() {
779
802
  return current;
@@ -782,6 +805,7 @@ export function currentRegistration() {
782
805
  /** Test seam, not public: forget every handler, buffer and registration. */
783
806
  export function _resetApplicationState() {
784
807
  current = null;
808
+ currentRole = null;
785
809
  currentSchemes = null;
786
810
  openHandlers.clear();
787
811
  activateHandlers.clear();