react-x11 2.29.0 → 2.31.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.
package/README.md CHANGED
@@ -256,7 +256,7 @@ drawn tree. See [docs/elements.md](docs/elements.md) for the full reference.
256
256
  | `<text>` | shaped, wrapped text (bidi, ligatures, font fallback); nested `<text>` elements are style spans |
257
257
  | `<textinput>` | single-line editor: caret/selection, clipboard, word select, undo/redo, right-click menu |
258
258
  | `<textarea>` | the same editor, wrapped and multi-line, scrolling with its content |
259
- | `<image>` | PNG/JPEG from `src`, natural-size aware; also pixels the server already has |
259
+ | `<image>` | PNG/JPEG/WebP from `src` (more formats under Bun), natural-size aware; also pixels the server already has |
260
260
  | `<canvas>` | escape hatch: `onDraw={(ctx, {width, height}) => …}` with a canvas-like 2d context (XRender-backed on X11, CoreGraphics on Cocoa) |
261
261
  | `<svg>` | static SVG (paths, shapes, gradients, transforms), sized like `<image>` |
262
262
  | `<glarea>` | an OpenGL surface in the layout — see [3D](#3d) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.29.0",
3
+ "version": "2.31.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -102,6 +102,7 @@
102
102
  },
103
103
  "dependencies": {
104
104
  "ical.js": "^2.2.1",
105
+ "image-in-browser": "^3.6.0",
105
106
  "linebreak": "^1.1.0",
106
107
  "ntk": "^8.17.8",
107
108
  "react-reconciler": "^0.34.0",
package/src/cocoa/app.js CHANGED
@@ -34,7 +34,7 @@ import { CocoaCalendars } from './calendar.js';
34
34
  import { CocoaNotifications } from './notifications.js';
35
35
  import { CocoaPaneHost } from './panehost.js';
36
36
  import { CocoaPermissions } from './permissions.js';
37
- import { CocoaPaneWindow } from './panewindow.js';
37
+ import { CocoaPaneSubwindow, CocoaPaneWindow } from './panewindow.js';
38
38
  import { CocoaColorSampler } from './screencolor.js';
39
39
  import { CocoaFilePanels } from './filepanels.js';
40
40
  import { CocoaFontManager } from './fonts.js';
@@ -331,6 +331,11 @@ export class CocoaApp {
331
331
  // composites (REACT_X11_FRAME is what the Frame host sets on the fork).
332
332
  this._paneMode = options.pane ?? process.env.REACT_X11_FRAME === '1';
333
333
  this._paneSend = null;
334
+ // the pane's own window, the one the host lays out and forwards to
335
+ this._paneWindow = null;
336
+ // What the pane's other windows said before the host was listening
337
+ // (`_postWindow`), until its first pane-rect.
338
+ this._paneOutbox = [];
334
339
 
335
340
  // The X stub: just enough for the modules that carry an X escape hatch
336
341
  // to no-op the way they do against the headless mock.
@@ -447,7 +452,18 @@ export class CocoaApp {
447
452
  // shared IOSurfaces and the HOST composites them (src/cocoa/
448
453
  // panewindow.js). Chosen by the same prop the X11 pane uses.
449
454
  if (this._paneMode && attributes.embeddable) {
450
- return new CocoaPaneWindow(this, attributes);
455
+ const wnd = new CocoaPaneWindow(this, attributes);
456
+ if (!this._paneWindow || this._paneWindow.destroyed) {
457
+ this._paneWindow = wnd;
458
+ }
459
+ return wnd;
460
+ }
461
+ // Any other window a pane makes — a menu, a tooltip, a dialog — is
462
+ // drawn here and shown by the host, which has the NSApplication this
463
+ // process does not (#824). An NSWindow made here exists and is never on
464
+ // glass.
465
+ if (this._paneMode && !attributes.parent) {
466
+ return new CocoaPaneSubwindow(this, attributes);
451
467
  }
452
468
  if (attributes.parent) {
453
469
  // A parented "window" here is a GL child surface — GlAreaNode's
@@ -487,10 +503,88 @@ export class CocoaApp {
487
503
  /**
488
504
  * The Frame host seam (src/frame/index.js): a pane's composited region
489
505
  * in this window. Its presence is what routes <Frame> to the shared-
490
- * memory pane path instead of the X foreign-window embed.
506
+ * memory pane path instead of the X foreign-window embed. `send` is the
507
+ * pane's channel, for the windows the pane makes besides its own: a pane
508
+ * process runs no AppKit, so they are made here (src/cocoa/panehost.js).
491
509
  */
492
- createPaneHost(wnd) {
493
- return new CocoaPaneHost(this, wnd);
510
+ createPaneHost(wnd, { send } = {}) {
511
+ return new CocoaPaneHost(this, wnd, { send });
512
+ }
513
+
514
+ /**
515
+ * Whether this backend drops a control's menu as the platform's own — a
516
+ * bridge with `popUpMenu` (@windowkit/appkit 0.20) — rather than a drawn
517
+ * `<popup>` sized as one. What `<Select>` asks before it opens.
518
+ */
519
+ get nativePopUpMenus() {
520
+ return typeof this._native?.popUpMenu === 'function';
521
+ }
522
+
523
+ /**
524
+ * The platform's own menu, dropped from a control in one of this app's
525
+ * windows as an NSPopUpButton's drops: the item `spec.selected` placed
526
+ * over `spec.frame` and checked, the menu at least the frame's width.
527
+ * `spec` is the bridge's (`items`, `selected`, `fontSize`, `fontFamily`,
528
+ * `appearance`, `rtl`) with `frame` in the window's device pixels, which
529
+ * every rect on this side is. `onAnswer(id | null)` hears the item chosen,
530
+ * or null for a menu dismissed, once; the frame that answer causes goes
531
+ * out with it. Returns a function that cancels the menu.
532
+ *
533
+ * Opened on a microtask, after the input that asked for it has been
534
+ * answered and painted: in pump mode the menu's tracking holds this thread
535
+ * from the call to the choice, and the press that opened it should be on
536
+ * glass before it does. From a worker the call returns at once.
537
+ *
538
+ * A pane has no NSWindow to drop it from, so in a pane the host does,
539
+ * over the pane's place in its window (`CocoaPaneHost.popUpMenu`).
540
+ */
541
+ popUpMenu(wnd, spec, onAnswer) {
542
+ let answered = false;
543
+ const answer = (id) => {
544
+ if (answered) return;
545
+ answered = true;
546
+ onAnswer(id ?? null);
547
+ this._afterInput();
548
+ };
549
+ if (this._paneMode) return this._paneMenu(wnd, spec, answer);
550
+ let cancelled = false;
551
+ let handle = null;
552
+ queueMicrotask(() => {
553
+ if (cancelled || this._closed || !wnd || wnd.destroyed) {
554
+ answer(null);
555
+ return;
556
+ }
557
+ const s = wnd.scale ?? this.scale;
558
+ handle = this._native.popUpMenu(
559
+ wnd._h,
560
+ { ...spec, frame: spec.frame.map((v) => v / s) },
561
+ (id) => answer(id),
562
+ );
563
+ });
564
+ return () => {
565
+ if (answered) return;
566
+ cancelled = true;
567
+ if (handle) this._native.cancelPopUpMenu?.(handle);
568
+ };
569
+ }
570
+
571
+ /** A pane's menu, asked of the host: `pane-menu` with the frame in the
572
+ * window it is in, `pane-menu-answer` back. */
573
+ _paneMenu(wnd, spec, answer) {
574
+ const menu = (this._paneMenuSeq = (this._paneMenuSeq ?? 0) + 1);
575
+ (this._paneMenus ??= new Map()).set(menu, answer);
576
+ this._paneSend?.({
577
+ type: 'pane-menu',
578
+ menu,
579
+ // the pane's own window is the one the host knows as the pane
580
+ ...(wnd && wnd !== this._paneWindow && { window: wnd.windowId }),
581
+ spec,
582
+ });
583
+ return () => {
584
+ if (this._paneMenus?.has(menu)) {
585
+ this._paneSend?.({ type: 'pane-menu-cancel', menu });
586
+ }
587
+ };
494
588
  }
495
589
 
496
590
  /**
@@ -780,18 +874,72 @@ export class CocoaApp {
780
874
  }
781
875
  };
782
876
  channel.onMessage((msg) => {
783
- const wnd = [...this._windows.values()][0];
784
- if (!wnd) return;
877
+ const pane = this._paneWindow ?? [...this._windows.values()][0];
878
+ if (!pane) return;
785
879
  if (msg?.type === 'pane-rect') {
786
- wnd.setPaneSize(msg.width, msg.height, msg.scale);
880
+ pane.setPaneSize(msg.width, msg.height, msg.scale, msg.screen);
881
+ this._flushPaneOutbox();
787
882
  this._afterInput();
883
+ } else if (msg?.type === 'pane-menu-answer') {
884
+ const answer = this._paneMenus?.get(msg.menu);
885
+ this._paneMenus?.delete(msg.menu);
886
+ answer?.(msg.id ?? null);
788
887
  } else if (msg?.type === 'pane-event') {
789
- wnd.emit(msg.name, msg.ev);
888
+ // input on one of the windows the host shows for this pane names
889
+ // it; the pane's own is the one that names none
890
+ const wnd = msg.window != null ? this._windows.get(msg.window) : pane;
891
+ if (!wnd || wnd.destroyed) return;
892
+ if (msg.name === 'resize') wnd._hostGeometry?.(msg.ev);
893
+ // a close request answers with `preventDefault`, which did not
894
+ // survive the channel
895
+ else if (msg.name === 'close')
896
+ wnd.emit('close', { ...msg.ev, preventDefault() {} });
897
+ else wnd.emit(msg.name, msg.ev);
790
898
  this._afterInput();
791
899
  }
792
900
  });
793
901
  }
794
902
 
903
+ /**
904
+ * A message about one of the pane's other windows (`CocoaPaneSubwindow`),
905
+ * held until the host's first pane-rect. The host hears a pane's messages
906
+ * from the moment it has a `<Frame>`, and knows what to do with these
907
+ * only once the pane is laid out in it — a menu or a dialog open in the
908
+ * pane's first commit was made, and announced to nobody. That pane-rect
909
+ * is also where the pane learns where it is on the screen, so a popup
910
+ * anchored before it is placed again before anyone shows it.
911
+ */
912
+ _postWindow(msg) {
913
+ if (this._paneOutbox) this._paneOutbox.push(msg);
914
+ else this._paneSend?.(msg);
915
+ }
916
+
917
+ /**
918
+ * The host is listening, and the pane knows where it is — `setPaneSize`
919
+ * has just moved what is anchored to it. Everything held goes out in
920
+ * order, but a window is announced where it is now: a held move or resize
921
+ * is where it was before, and sent in order it came after a present the
922
+ * host showed at the old place.
923
+ */
924
+ _flushPaneOutbox() {
925
+ const held = this._paneOutbox;
926
+ if (!held) return;
927
+ this._paneOutbox = null;
928
+ for (const msg of held) {
929
+ if (msg.type === 'pane-window') {
930
+ if (msg.op === 'move' || msg.op === 'resize') continue;
931
+ const wnd = msg.op === 'create' && this._windows.get(msg.window);
932
+ if (wnd) {
933
+ msg.x = wnd.x;
934
+ msg.y = wnd.y;
935
+ msg.width = wnd.width;
936
+ msg.height = wnd.height;
937
+ }
938
+ }
939
+ this._paneSend(msg);
940
+ }
941
+ }
942
+
795
943
  _unregisterWindow(wnd) {
796
944
  this._windows.delete(wnd._key);
797
945
  if (this._grabWindow === wnd) this._grabWindow = null;
@@ -3,9 +3,16 @@
3
3
  // contents are whatever IOSurface the pane process last presented. The pane
4
4
  // owns its buffers and its drawing; this side owns the layer, the layout
5
5
  // and the input — CPU offloading, not isolation (docs/frame.md).
6
+ //
7
+ // And the windows the pane makes besides its own — a menu, a dropdown's
8
+ // sheet, a tooltip, a dialog. A pane process runs no AppKit, so each is an
9
+ // NSWindow made here, showing the ring the pane draws it into the same way
10
+ // (`CocoaPaneSubwindow`, src/cocoa/panewindow.js), with its input sent back.
6
11
  import { withoutActions } from './quiet.js';
7
12
 
8
- export class CocoaPaneHost {
13
+ /** One shared surface on glass: a sublayer of a window's root layer, its
14
+ * contents the IOSurface the pane last presented into it. */
15
+ class PaneLayer {
9
16
  constructor(app, wnd) {
10
17
  this.app = app;
11
18
  this.wnd = wnd;
@@ -67,12 +74,14 @@ export class CocoaPaneHost {
67
74
  * error is the bug it says it is.
68
75
  */
69
76
  present(iosurfaceId) {
70
- if (this.destroyed) return;
77
+ if (this.destroyed) return false;
71
78
  try {
72
79
  this._native.setLayerContentsIOSurface(this.layer, iosurfaceId);
73
80
  } catch (err) {
74
81
  if (!/IOSurfaceLookup/.test(err?.message ?? '')) throw err;
82
+ return false;
75
83
  }
84
+ return true;
76
85
  }
77
86
 
78
87
  destroy() {
@@ -83,3 +92,218 @@ export class CocoaPaneHost {
83
92
  );
84
93
  }
85
94
  }
95
+
96
+ /**
97
+ * The input a window shows for the pane hands back, by the names its
98
+ * events go by (`CocoaApp._route`). A managed window's moves, resizes and
99
+ * close button go back as well (`_hostGeometry`).
100
+ */
101
+ const FORWARDED = [
102
+ 'mousedown',
103
+ 'mouseup',
104
+ 'mousemove',
105
+ 'mouseout',
106
+ 'wheel',
107
+ 'keydown',
108
+ 'keyup',
109
+ 'focus',
110
+ 'blur',
111
+ ];
112
+
113
+ /** An event as data: the channel carries structured clones, and a
114
+ * `preventDefault` does not clone. */
115
+ function plain(ev) {
116
+ const out = {};
117
+ for (const key in ev) {
118
+ const value = ev[key];
119
+ if (value === null || typeof value !== 'object') {
120
+ if (typeof value !== 'function') out[key] = value;
121
+ }
122
+ }
123
+ return out;
124
+ }
125
+
126
+ export class CocoaPaneHost extends PaneLayer {
127
+ /**
128
+ * `send` is the pane's channel (`<Frame>`'s session): what the windows
129
+ * shown here for the pane hear goes back down it.
130
+ */
131
+ constructor(app, wnd, { send } = {}) {
132
+ super(app, wnd);
133
+ this._send = send ?? null;
134
+ /** @type {Map<number, object>} the pane's windows, by its id for them */
135
+ this._windows = new Map();
136
+ /** @type {Map<number, () => void>} the pane's open menus, by its id */
137
+ this._menus = new Map();
138
+ }
139
+
140
+ /**
141
+ * A `pane-menu` message: the pane's `<Select>` asked for the platform's
142
+ * menu, which a process with no AppKit cannot drop (`CocoaApp.popUpMenu`).
143
+ * Its frame is in the window it is in — the pane's own, laid out at this
144
+ * layer's rect in the host's window, or one the pane made, shown here —
145
+ * and the answer goes back as `pane-menu-answer`.
146
+ */
147
+ popUpMenu(msg) {
148
+ if (this.destroyed || !msg.spec) return;
149
+ const entry = msg.window != null ? this._windows.get(msg.window) : null;
150
+ const [x, y, width, height] = msg.spec.frame;
151
+ const at = entry ? { x: 0, y: 0 } : (this._rect ?? { x: 0, y: 0 });
152
+ const cancel = this.app.popUpMenu(
153
+ entry ? entry.wnd : this.wnd,
154
+ { ...msg.spec, frame: [x + at.x, y + at.y, width, height] },
155
+ (id) => {
156
+ this._menus.delete(msg.menu);
157
+ this._send?.({ type: 'pane-menu-answer', menu: msg.menu, id });
158
+ },
159
+ );
160
+ this._menus.set(msg.menu, cancel);
161
+ }
162
+
163
+ /** `pane-menu-cancel`: the pane's `<Select>` went, or closed it. */
164
+ cancelMenu(menu) {
165
+ this._menus.get(menu)?.();
166
+ }
167
+
168
+ /**
169
+ * A `pane-window` message: one of the pane's other windows was made,
170
+ * mapped, moved, grabbed with or destroyed. `x`/`y` are the screen
171
+ * position the pane worked out — its anchoring ran against the origin
172
+ * this side sent it — so the window goes where it says.
173
+ */
174
+ paneWindow(msg) {
175
+ if (this.destroyed) return;
176
+ if (msg.op === 'create') {
177
+ this._create(msg);
178
+ return;
179
+ }
180
+ const entry = this._windows.get(msg.window);
181
+ if (!entry) return;
182
+ const { wnd } = entry;
183
+ switch (msg.op) {
184
+ case 'map':
185
+ // on the first frame rather than now: a window mapped before the
186
+ // pane has drawn it is a window of nothing, for a frame or two
187
+ if (entry.presented) wnd.map();
188
+ else entry.mapOnPresent = true;
189
+ break;
190
+ case 'unmap':
191
+ entry.mapOnPresent = false;
192
+ wnd.unmap();
193
+ break;
194
+ case 'move':
195
+ wnd.move(msg.x, msg.y);
196
+ break;
197
+ case 'resize':
198
+ // the layer follows on the present drawn at the new size
199
+ wnd.resize(msg.width, msg.height);
200
+ break;
201
+ case 'grab':
202
+ wnd.grabPointer();
203
+ break;
204
+ case 'ungrab':
205
+ wnd.ungrabPointer();
206
+ break;
207
+ case 'title':
208
+ wnd.setTitle(msg.title);
209
+ break;
210
+ case 'size-hints':
211
+ wnd.setSizeHints(msg.hints);
212
+ break;
213
+ case 'destroy':
214
+ this._drop(entry);
215
+ break;
216
+ default:
217
+ break;
218
+ }
219
+ }
220
+
221
+ /** A pane-present naming one of the pane's other windows. */
222
+ presentWindow(msg) {
223
+ const entry = this._windows.get(msg.window);
224
+ if (!entry || this.destroyed) return;
225
+ entry.layer.setRect({ x: 0, y: 0, width: msg.width, height: msg.height });
226
+ if (!entry.layer.present(msg.id)) return;
227
+ entry.presented = true;
228
+ // a transparent window's shadow is the shape of what it shows
229
+ entry.wnd._shadowAfterFlip?.();
230
+ if (entry.mapOnPresent) {
231
+ entry.mapOnPresent = false;
232
+ entry.wnd.map();
233
+ }
234
+ }
235
+
236
+ /** The cursor a tree names for one of the pane's other windows: the
237
+ * pointer is over the window shown here, so this side sets it. */
238
+ windowCursor(msg) {
239
+ this._windows.get(msg.window)?.wnd.setCursor(msg.cursor ?? 'default');
240
+ }
241
+
242
+ _create(msg) {
243
+ if (this._windows.has(msg.window)) return;
244
+ const wnd = this.app.createWindow({
245
+ overrideRedirect: msg.popup,
246
+ x: msg.x,
247
+ y: msg.y,
248
+ width: msg.width,
249
+ height: msg.height,
250
+ transparent: msg.transparent,
251
+ grabKeyboard: msg.grabKeyboard,
252
+ dragPreview: msg.dragPreview,
253
+ title: msg.title,
254
+ decorations: msg.decorations,
255
+ resizable: msg.resizable,
256
+ sizeHints: msg.sizeHints,
257
+ });
258
+ const entry = {
259
+ id: msg.window,
260
+ wnd,
261
+ layer: new PaneLayer(this.app, wnd),
262
+ presented: false,
263
+ mapOnPresent: false,
264
+ };
265
+ this._windows.set(msg.window, entry);
266
+ const forward = (name, ev) => {
267
+ if (this.destroyed || entry.dropped) return;
268
+ this._send?.({ type: 'pane-event', window: entry.id, name, ev });
269
+ };
270
+ for (const name of FORWARDED) {
271
+ wnd.on(name, (ev) => forward(name, plain(ev)));
272
+ }
273
+ // A popup goes where the pane puts it and no one else moves it. A
274
+ // managed window is the user's to move, resize and close as well, and
275
+ // the pane hears of each — of a resize only the user's, since the
276
+ // pane's own come back as AppKit's echo of the frame it was asked for.
277
+ if (!msg.popup) {
278
+ wnd.on('resize', (ev) =>
279
+ forward('resize', {
280
+ x: ev.x,
281
+ y: ev.y,
282
+ width: ev.width,
283
+ height: ev.height,
284
+ resized: Boolean(ev.resized && wnd.liveResizing),
285
+ }),
286
+ );
287
+ wnd.on('close', () => forward('close', {}));
288
+ }
289
+ }
290
+
291
+ _drop(entry) {
292
+ if (entry.dropped) return;
293
+ entry.dropped = true;
294
+ this._windows.delete(entry.id);
295
+ entry.wnd.ungrabPointer();
296
+ entry.layer.destroy();
297
+ entry.wnd.destroy();
298
+ }
299
+
300
+ /** The pane is going, and every window it had here goes with it — a pane
301
+ * that crashed with a menu open leaves no menu behind. */
302
+ destroy() {
303
+ if (this.destroyed) return;
304
+ for (const cancel of [...this._menus.values()]) cancel();
305
+ this._menus.clear();
306
+ for (const entry of [...this._windows.values()]) this._drop(entry);
307
+ super.destroy();
308
+ }
309
+ }