react-x11 2.28.0 → 2.30.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.
@@ -14,6 +14,11 @@
14
14
  // via emit. Geometry and input arrive as channel messages (the host owns
15
15
  // layout and hit-testing — this is CPU offloading, not isolation), and what
16
16
  // goes out is pane-present, and pane-cursor for the cursor the host shows.
17
+ //
18
+ // Every other window the pane's tree makes — a menu, a dropdown's sheet, a
19
+ // tooltip, a dialog — is a `CocoaPaneSubwindow`: the same ring and the same
20
+ // presents, naming the window, and an NSWindow the host makes for it
21
+ // (src/cocoa/panehost.js), because nothing in a pane process runs AppKit.
17
22
  import { BackendContext2D } from '../backend/context2d.js';
18
23
 
19
24
  let nextPaneId = 1;
@@ -31,13 +36,15 @@ export class CocoaPaneWindow {
31
36
  this.id = this.windowId;
32
37
  this.windowNumber = this.windowId;
33
38
  this.scale = app.scale ?? 2;
34
- this.width = Math.max(
35
- 1,
36
- Math.round((attributes.width ?? 400) * this.scale),
37
- );
39
+ // Device pixels, as every window's attributes are (`windowAttributes`
40
+ // multiplies the props through once): the size the pane is born at,
41
+ // until the host's first pane-rect says what it is. Multiplied again
42
+ // here, a pane was born twice its size, and a dialog it opened in its
43
+ // first commit was centred over a window that was never there.
44
+ this.width = Math.max(1, Math.round(attributes.width ?? 400 * this.scale));
38
45
  this.height = Math.max(
39
46
  1,
40
- Math.round((attributes.height ?? 300) * this.scale),
47
+ Math.round(attributes.height ?? 300 * this.scale),
41
48
  );
42
49
  this._listeners = new Map();
43
50
  this._surface = null;
@@ -52,27 +59,51 @@ export class CocoaPaneWindow {
52
59
  this._seq = 0;
53
60
  this._presentedAt = -Infinity;
54
61
  this._reactX11Node = null;
62
+ // Where the pane's corner is on the screen, in device pixels, as the
63
+ // host last said (`setPaneSize`): what a popup the pane anchors to one
64
+ // of its nodes is placed against (`windowOrigin`, src/anchor.js). The
65
+ // pane has no window of its own to ask, so until the host says, a menu
66
+ // dropped from the screen's corner.
67
+ this.x = 0;
68
+ this.y = 0;
69
+ this._screenOrigin = { x: 0, y: 0 };
70
+ // what the app's window map keys this window by, and the id the host
71
+ // names it by in a pane-event (`CocoaApp.attachPaneChannel`)
72
+ this._key = this.windowId;
55
73
  app._registerWindow(this);
56
74
  }
57
75
 
58
76
  // --- the channel-facing half --------------------------------------------
59
77
 
60
- /** The host's layout answer: logical size plus the display scale. */
61
- setPaneSize(width, height, scale) {
78
+ /**
79
+ * The host's layout answer: logical size plus the display scale, and
80
+ * where the pane is on the screen — `screen`, device pixels, absent from
81
+ * a host that does not say. A move alone is a `resize` that `moved`, the
82
+ * event a window's own move is, so what is anchored here follows it.
83
+ */
84
+ setPaneSize(width, height, scale, screen) {
62
85
  if (this.destroyed) return;
63
86
  if (scale) this.scale = scale;
64
87
  const w = Math.max(1, Math.round(width * this.scale));
65
88
  const h = Math.max(1, Math.round(height * this.scale));
66
- if (w === this.width && h === this.height) return;
89
+ const moved =
90
+ screen != null && (screen.x !== this.x || screen.y !== this.y);
91
+ const resized = w !== this.width || h !== this.height;
92
+ if (!moved && !resized) return;
93
+ if (moved) {
94
+ this.x = screen.x;
95
+ this.y = screen.y;
96
+ this._screenOrigin = { x: screen.x, y: screen.y };
97
+ }
67
98
  this.width = w;
68
99
  this.height = h;
69
100
  this.emit('resize', {
70
101
  width: w,
71
102
  height: h,
72
- x: 0,
73
- y: 0,
74
- moved: false,
75
- resized: true,
103
+ x: this.x,
104
+ y: this.y,
105
+ moved,
106
+ resized,
76
107
  });
77
108
  }
78
109
 
@@ -103,7 +134,13 @@ export class CocoaPaneWindow {
103
134
  */
104
135
  setCursor(name) {
105
136
  if (this.destroyed) return;
106
- this.app._paneSend?.({ type: 'pane-cursor', cursor: name ?? null });
137
+ this._post({ type: 'pane-cursor', cursor: name ?? null });
138
+ }
139
+
140
+ /** A message about this window, to the host. The pane's own names none:
141
+ * the host knows it as the pane (`CocoaPaneSubwindow` names itself). */
142
+ _post(msg) {
143
+ this.app._paneSend?.(msg);
107
144
  }
108
145
 
109
146
  requestAnimationFrame(cb) {
@@ -264,7 +301,7 @@ export class CocoaPaneWindow {
264
301
  const shown = this._ring[this._drawIndex];
265
302
  this._native.surfaceUnlock(shown.handle);
266
303
  this._flushDamage = null;
267
- this.app._paneSend?.({
304
+ this._post({
268
305
  type: 'pane-present',
269
306
  seq: ++this._seq,
270
307
  id: shown.iosurfaceId,
@@ -304,3 +341,156 @@ export class CocoaPaneWindow {
304
341
  this._releaseRing();
305
342
  }
306
343
  }
344
+
345
+ /**
346
+ * Any other window a pane's tree makes: a `<popup>` — a menu, a dropdown's
347
+ * sheet, a tooltip, the edit menu — or a managed one, a dialog (#824).
348
+ *
349
+ * A pane process runs no AppKit (`CocoaApp.start`): no NSApplication, no
350
+ * event pump. An NSWindow made here exists and is never shown, so a
351
+ * `<Select>` in a pane took the press, opened, and showed nothing. This
352
+ * window paints into a shared ring exactly as the pane does, and the host
353
+ * makes the NSWindow and shows the ring in it (`CocoaPaneHost`,
354
+ * src/cocoa/panehost.js) — the split the pane already has: the pane draws,
355
+ * the host owns glass and input.
356
+ *
357
+ * Its place is the pane's to decide and the host's to take as given: `x`
358
+ * and `y` are the screen position the pane's own anchoring worked out,
359
+ * against the origin the host sends with every pane-rect. What goes out is
360
+ * the window's life as `pane-window` messages and its frames as
361
+ * `pane-present`s naming it; what comes back is the input on the host's
362
+ * window, as `pane-event`s naming it, and for a managed window the moves
363
+ * and the resizes the user made and the close button.
364
+ */
365
+ export class CocoaPaneSubwindow extends CocoaPaneWindow {
366
+ constructor(app, attributes = {}) {
367
+ super(app, attributes);
368
+ this.attributes = attributes;
369
+ this._popup = attributes.overrideRedirect === true;
370
+ this.mapped = false;
371
+ // device pixels, as every window's attributes are — unlike the pane's
372
+ // own, whose size is the host's to say
373
+ const size = this.snapSize(attributes.width ?? 1, attributes.height ?? 1);
374
+ this.width = size.width;
375
+ this.height = size.height;
376
+ this.x = Math.round(attributes.x ?? 0);
377
+ this.y = Math.round(attributes.y ?? 0);
378
+ this._screenOrigin = { x: this.x, y: this.y };
379
+ this._send({
380
+ op: 'create',
381
+ x: this.x,
382
+ y: this.y,
383
+ width: this.width,
384
+ height: this.height,
385
+ popup: this._popup,
386
+ // an ARGB visual is a transparent window here, as in `CocoaWindow`
387
+ transparent:
388
+ attributes.visual !== undefined || Boolean(attributes.transparent),
389
+ grabKeyboard: attributes.grabKeyboard === true,
390
+ dragPreview: Boolean(attributes.dragPreview),
391
+ title: attributes.title ?? '',
392
+ decorations: attributes.decorations,
393
+ resizable: attributes.resizable,
394
+ sizeHints: attributes.sizeHints,
395
+ });
396
+ }
397
+
398
+ /** Named, and held until the host is listening (`CocoaApp._postWindow`). */
399
+ _post(msg) {
400
+ this.app._postWindow({ ...msg, window: this.windowId });
401
+ }
402
+
403
+ _send(msg) {
404
+ if (this.destroyed) return;
405
+ this._post({ type: 'pane-window', ...msg });
406
+ }
407
+
408
+ /** `CocoaWindow`'s rule, so the size this window records is the size the
409
+ * host's NSWindow will take: whole points, rounded up. */
410
+ snapSize(width, height) {
411
+ const s = this.scale;
412
+ const up = (v) =>
413
+ Math.max(1, Math.ceil(Math.max(1, Math.round(v)) / s) * s);
414
+ return { width: up(width), height: up(height) };
415
+ }
416
+
417
+ map() {
418
+ this.mapped = true;
419
+ this._send({ op: 'map' });
420
+ }
421
+
422
+ unmap() {
423
+ this.mapped = false;
424
+ this._send({ op: 'unmap' });
425
+ }
426
+
427
+ move(x, y) {
428
+ this.x = Math.round(x);
429
+ this.y = Math.round(y);
430
+ this._screenOrigin = { x: this.x, y: this.y };
431
+ this._send({ op: 'move', x: this.x, y: this.y });
432
+ }
433
+
434
+ resize(width, height) {
435
+ const size = this.snapSize(width, height);
436
+ this.width = size.width;
437
+ this.height = size.height;
438
+ this._send({ op: 'resize', width: this.width, height: this.height });
439
+ }
440
+
441
+ setTitle(title) {
442
+ this._send({ op: 'title', title: String(title ?? '') });
443
+ }
444
+
445
+ setSizeHints(hints = {}) {
446
+ this._send({ op: 'size-hints', hints: { ...hints } });
447
+ }
448
+
449
+ /** The grab is the host's: a press anywhere in its windows comes here,
450
+ * outside this window, which is the dismissal (`CocoaApp._grabTarget`). */
451
+ grabPointer(options, cb) {
452
+ this._send({ op: 'grab' });
453
+ cb?.(null, 0);
454
+ }
455
+
456
+ ungrabPointer() {
457
+ this._send({ op: 'ungrab' });
458
+ }
459
+
460
+ /**
461
+ * Where the host's window went for a reason of its own — the user moved a
462
+ * dialog, or resized it — which a `resize` here says as a window's own
463
+ * move or resize does. A size comes only from the user (`resized`): the
464
+ * pane's own resizes come back as the host's echo, and one taken as news
465
+ * would read as the user taking an `'auto'` window over.
466
+ */
467
+ _hostGeometry(ev) {
468
+ if (this.destroyed) return;
469
+ const moved = ev.x !== this.x || ev.y !== this.y;
470
+ const resized =
471
+ Boolean(ev.resized) &&
472
+ (ev.width !== this.width || ev.height !== this.height);
473
+ if (!moved && !resized) return;
474
+ this.x = ev.x;
475
+ this.y = ev.y;
476
+ this._screenOrigin = { x: ev.x, y: ev.y };
477
+ if (resized) {
478
+ this.width = ev.width;
479
+ this.height = ev.height;
480
+ }
481
+ this.emit('resize', {
482
+ width: this.width,
483
+ height: this.height,
484
+ x: this.x,
485
+ y: this.y,
486
+ moved,
487
+ resized,
488
+ });
489
+ }
490
+
491
+ destroy() {
492
+ if (this.destroyed) return;
493
+ this._send({ op: 'destroy' });
494
+ super.destroy();
495
+ }
496
+ }
@@ -45,6 +45,7 @@ import {
45
45
  propBoxProps,
46
46
  stylePaintsPlain,
47
47
  } from './presenter.js';
48
+ import { SpriteLayers } from './sprites.js';
48
49
 
49
50
  const ORIGIN = Object.freeze({ x: 0, y: 0 });
50
51
 
@@ -61,6 +62,17 @@ const IDLE_GRACE_MS = 1000;
61
62
  // the repaint they save (the layer presenter's rule, per raster).
62
63
  const MAX_DIRTY_RECTS = 16;
63
64
 
65
+ /** Paint order, for entries carrying a `_paintKey`: a key the walk would
66
+ * not reach sorts last. */
67
+ function byPaintKey(a, b) {
68
+ if (!a.key || !b.key) return (a.key ? 1 : 0) - (b.key ? 1 : 0);
69
+ const n = Math.min(a.key.length, b.key.length);
70
+ for (let i = 0; i < n; i++) {
71
+ if (a.key[i] !== b.key[i]) return a.key[i] - b.key[i];
72
+ }
73
+ return a.key.length - b.key.length;
74
+ }
75
+
64
76
  const rectsOverlap = (a, b) =>
65
77
  a.x < b.x + b.width &&
66
78
  b.x < a.x + a.width &&
@@ -243,6 +255,9 @@ export class CocoaPromotion {
243
255
  layerOf: (node) => this.promoted.get(node)?.visual.layer ?? null,
244
256
  onIdle: (node) => this._idle(node),
245
257
  });
258
+ // the parts of elements' drawing they offer (`Node.sprites()`), each on
259
+ // a layer of its own under the same rules (src/cocoa/sprites.js)
260
+ this.sprites = new SpriteLayers(this);
246
261
  this._claiming = false;
247
262
  }
248
263
 
@@ -390,8 +405,19 @@ export class CocoaPromotion {
390
405
  */
391
406
  frame(root, layoutRan) {
392
407
  if (layoutRan) this.layoutGen++;
393
- if (this.promoted.size === 0 && this.candidates.size === 0) return;
408
+ // what elements offer, asked before anything is decided: an answer is
409
+ // about the element's own drawing, not about the layers
410
+ const offers = this.sprites.ask(root);
411
+ if (
412
+ this.promoted.size === 0 &&
413
+ this.candidates.size === 0 &&
414
+ offers.size === 0 &&
415
+ this.sprites.hosts.size === 0
416
+ ) {
417
+ return;
418
+ }
394
419
  const native = this.native;
420
+ let lifted = null;
395
421
  native.txBegin({ disableActions: true });
396
422
  try {
397
423
  // Later-painted first: a node painted after this one that is coming
@@ -416,13 +442,26 @@ export class CocoaPromotion {
416
442
  }
417
443
  }
418
444
  }
445
+ // Then the parts, against the layers this frame keeps: a node
446
+ // promoted over a part is a layer above it, not ink on it.
447
+ lifted = this.sprites.decide(root, offers);
448
+ // One order for every layer above the bitmap, nodes and parts alike.
419
449
  const order = this._inPaintOrder([...this.promoted.keys()]);
450
+ for (const state of this.sprites.entries()) {
451
+ order.push({ state, key: state.order });
452
+ }
453
+ order.sort(byPaintKey);
420
454
  for (let i = 0; i < order.length; i++) {
421
- this._sync(order[i].node, i, root, layoutRan);
455
+ const { node, state } = order[i];
456
+ if (node) this._sync(node, i, root, layoutRan);
457
+ else this.sprites.sync(state, i);
422
458
  }
423
459
  } finally {
424
460
  native.txCommit();
425
461
  }
462
+ // An element told which of its parts are on layers now may claim,
463
+ // restyle or ask for frames; the frame has not taken its damage yet.
464
+ if (lifted?.size) this.sprites.notify(lifted);
426
465
  }
427
466
 
428
467
  _mayStay(node, key) {
@@ -528,17 +567,22 @@ export class CocoaPromotion {
528
567
 
529
568
  _inPaintOrder(nodes) {
530
569
  const keyed = nodes.map((node) => ({ node, key: this._paintKey(node) }));
531
- keyed.sort((a, b) => {
532
- if (!a.key || !b.key) return (a.key ? 1 : 0) - (b.key ? 1 : 0);
533
- const n = Math.min(a.key.length, b.key.length);
534
- for (let i = 0; i < n; i++) {
535
- if (a.key[i] !== b.key[i]) return a.key[i] - b.key[i];
536
- }
537
- return a.key.length - b.key.length;
538
- });
570
+ keyed.sort(byPaintKey);
539
571
  return keyed;
540
572
  }
541
573
 
574
+ /**
575
+ * Where an element offering parts stands in the paint order, or null when
576
+ * none of them may go on a layer: hidden, inside a `<glarea>`, or itself
577
+ * faded — its group on the bitmap would leave a part's layer out of it. A
578
+ * faded ancestor is `_clear`'s to find, as it is for a node.
579
+ */
580
+ _spritePlace(host) {
581
+ if (host.destroyed || host.hidden) return null;
582
+ if (insideGlArea(host) || fadesAsGroup(host)) return null;
583
+ return this._paintKey(host);
584
+ }
585
+
542
586
  /**
543
587
  * Is nothing painted over this node, is all of it visible, and is it in
544
588
  * no faded group? Walked up the chain: at every level, what the parent
@@ -547,15 +591,34 @@ export class CocoaPromotion {
547
591
  * node's reach, the parent's clip, if it has one, must hold the whole of
548
592
  * it, and the parent must not fade: its group is drawn on the bitmap or on
549
593
  * its own layer, and either way a layer of the node's would be outside it.
594
+ *
595
+ * With `cut`, the reach is an element's part (src/cocoa/sprites.js),
596
+ * whose layer can be cut to a rectangle: a clipping ancestor with square
597
+ * corners narrows `cut.clip` to its padding box — its border is drawn
598
+ * over what it holds anyway — instead of having to hold the part whole,
599
+ * and every test from there up is asked of what shows through the clip.
600
+ * `cut.bounds` comes back as that. A clip that leaves nothing showing is
601
+ * a refusal.
550
602
  */
551
- _clear(node) {
603
+ _clear(node, bounds = node._subtreeBounds(), cut = null) {
552
604
  // the exact reach, not `paintBounds()`: that one carries the damage
553
605
  // model's pixel of slop, and a section laid out flush under a card
554
- // would read as reaching into it
555
- const bounds = node._subtreeBounds();
606
+ // would read as reaching into it. An element's part is asked about at
607
+ // everywhere it can draw (src/cocoa/sprites.js).
556
608
  for (let n = node; !n.isWindow; n = n.parent) {
557
609
  const parent = n.parent;
558
610
  if (!parent) return false;
611
+ if (
612
+ cut &&
613
+ !parent.isWindow &&
614
+ parent.clipsChildren?.() &&
615
+ !(parent.style?.borderRadius > 0)
616
+ ) {
617
+ const inner = insetRect(parent.abs, borderReach(parent));
618
+ cut.clip = cut.clip ? intersectRects(cut.clip, inner) : inner;
619
+ bounds = cut.clip && intersectRects(bounds, cut.clip);
620
+ if (!bounds) return false;
621
+ }
559
622
  const order = parent.paintOrder();
560
623
  for (let j = order.indexOf(n) + 1; j < order.length; j++) {
561
624
  if (this._reaches(order[j], bounds)) return false;
@@ -592,6 +655,7 @@ export class CocoaPromotion {
592
655
  }
593
656
  }
594
657
  }
658
+ if (cut) cut.bounds = bounds;
595
659
  return true;
596
660
  }
597
661
 
@@ -733,6 +797,7 @@ export class CocoaPromotion {
733
797
 
734
798
  /** Everything off the root layer and freed: the window is going. */
735
799
  destroy() {
800
+ this.sprites.destroy();
736
801
  for (const timer of this.idle.values()) clearTimeout(timer);
737
802
  this.idle.clear();
738
803
  this.releasing.clear();