react-x11 2.6.0 → 2.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.6.0",
3
+ "version": "2.6.1",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
package/src/cocoa/app.js CHANGED
@@ -723,6 +723,46 @@ export class CocoaApp {
723
723
  }
724
724
  }
725
725
 
726
+ /**
727
+ * `listScreens()` turned into the screen layout `src/screens.js` publishes.
728
+ *
729
+ * **One scale for every screen, and it is the app's.** macOS lays all the
730
+ * displays out in a single global point space, and `app.scale` is this
731
+ * app's points-to-device-pixels factor for the whole of it — window
732
+ * origins, event coordinates, these rects. Converting a 1x external
733
+ * display by *its own* 1 while windows on it still report `points * 2`
734
+ * would put the monitor somewhere no window ever is, and `monitorAt()`
735
+ * would answer with the wrong head. (What backing scale a window on a
736
+ * mixed-DPI desk should raster at is a real and separate question; the
737
+ * layout is not where it is answered.)
738
+ *
739
+ * **A usable rect per monitor.** `NSScreen.visibleFrame` is per screen —
740
+ * that display's own menu bar and Dock taken off — so each monitor carries
741
+ * its `visible` and `usable()` takes it as a rect. Publishing only the
742
+ * primary's, the way `_NET_WORKAREA` forces on X11, applied the primary's
743
+ * *width* as a bound to every other head: a second display wider than the
744
+ * built-in had its right edge pulled in by the difference, and every
745
+ * anchored popup that reached past it was clamped back (issue #453).
746
+ */
747
+ export function screenLayout(screens, scale) {
748
+ const rect = (r) => ({
749
+ x: Math.round(r.x * scale),
750
+ y: Math.round(r.y * scale),
751
+ width: Math.round(r.width * scale),
752
+ height: Math.round(r.height * scale),
753
+ });
754
+ const primary = screens?.[0];
755
+ return {
756
+ monitors: (screens ?? []).map((screen) => ({
757
+ ...rect(screen),
758
+ ...(screen.visible ? { visible: rect(screen.visible) } : null),
759
+ })),
760
+ // Still published for `useScreens().workArea`, which is one rect for
761
+ // the desktop by definition; the primary's is the closest macOS has.
762
+ workArea: primary?.visible ? rect(primary.visible) : null,
763
+ };
764
+ }
765
+
726
766
  /**
727
767
  * Build the app and seed the platform stores the way the mock seeds them —
728
768
  * `beginScale`/`beginScreens`/`beginCompositing` find a session already
@@ -733,25 +773,7 @@ export async function createCocoaApp(options = {}) {
733
773
  const app = new CocoaApp(native, options);
734
774
 
735
775
  setScaleForTests(app, app.scale, 'cocoa');
736
- const s = app.scale;
737
- const monitors = app._screens.map((screen) => ({
738
- x: Math.round(screen.x * s),
739
- y: Math.round(screen.y * s),
740
- width: Math.round(screen.width * s),
741
- height: Math.round(screen.height * s),
742
- }));
743
- const primary = app._screens[0];
744
- setScreensForTests(app, {
745
- monitors,
746
- workArea: primary
747
- ? {
748
- x: Math.round(primary.visible.x * s),
749
- y: Math.round(primary.visible.y * s),
750
- width: Math.round(primary.visible.width * s),
751
- height: Math.round(primary.visible.height * s),
752
- }
753
- : null,
754
- });
776
+ setScreensForTests(app, screenLayout(app._screens, app.scale));
755
777
  setCompositingForTests(app, true);
756
778
 
757
779
  app.start(options.cocoa ?? {});
@@ -92,6 +92,61 @@ const clamp01 = (v) => Math.min(1, Math.max(0, Number(v) || 0));
92
92
  const PICT_OP = Object.freeze({ Src: 1, Over: 3 });
93
93
  const RENDER = Object.freeze({ PictOp: PICT_OP });
94
94
 
95
+ /**
96
+ * The path, recorded alongside the native one, so `stroke` can re-issue it
97
+ * in pieces — `CGContextStrokePath` is QUADRATIC in the number of subpaths
98
+ * in the path it is given (issue #456). Measured here, 13-vertex closed
99
+ * rings scattered over a 1024x1024 surface at a 2px line, one stroke call:
100
+ *
101
+ * 500 rings 20ms | 1000 rings 59ms | 2000 rings 208ms | 4000 rings 986ms
102
+ *
103
+ * Splitting the same geometry into strokes of a few hundred subpaths is
104
+ * linear in it: 14ms, 29ms, 56ms, 113ms. The driver is the subpath count,
105
+ * not the vertex count — one 26,000-vertex subpath strokes in 4ms where
106
+ * two thousand 13-vertex ones take 208. The full table, the two shapes
107
+ * left whole and why, are in docs/macos.md
108
+ * §"Stroking a path with many subpaths".
109
+ *
110
+ * Note the X11 context wants the opposite — there a stroke is an a8
111
+ * coverage mask over the path's bounding box uploaded with one PutImage,
112
+ * so a bigger path is fewer uploads over the same pixels. That is why this
113
+ * lives in the backend: a caller that batches for one backend pessimizes
114
+ * the other, and it cannot know which it is drawing on.
115
+ *
116
+ * The commands are a flat number array — `[op, ...args, op, ...args]` —
117
+ * reused across paths, so a path build is three pushes into a packed
118
+ * double array per point next to the napi call it already makes.
119
+ */
120
+ const P_MOVE = 0;
121
+ const P_LINE = 1;
122
+ const P_CURVE = 2;
123
+ const P_QUAD = 3;
124
+ const P_CLOSE = 4;
125
+ const P_RECT = 5;
126
+ const P_ROUND = 6;
127
+ const P_ARC = 7;
128
+ const P_ELLIPSE = 8;
129
+ /** how many numbers each op carries, and what it costs a chunk's budget */
130
+ const P_ARGS = [2, 2, 6, 4, 0, 4, 8, 6, 4];
131
+ const P_POINTS = [1, 1, 3, 2, 0, 4, 8, 8, 4];
132
+
133
+ /**
134
+ * A chunk closes at the first subpath boundary past either budget. Swept
135
+ * over the shapes above: 512 points is within 5% of the best chunk for
136
+ * every one of them, and the subpath cap catches the degenerate shape the
137
+ * point budget misses — thousands of 3- and 4-point subpaths, where 512
138
+ * points is already 128 strokes' worth of setup.
139
+ */
140
+ const STROKE_CHUNK_POINTS = 512;
141
+ const STROKE_CHUNK_SUBPATHS = 128;
142
+
143
+ /**
144
+ * The off switch, for a process that cannot reach the context — the same
145
+ * line `ctx.strokeChunking = false` draws, and what a bench comparing the
146
+ * two sets. Read once.
147
+ */
148
+ const NO_STROKE_CHUNKING = process.env.REACT_X11_NO_STROKE_CHUNKING === '1';
149
+
95
150
  /**
96
151
  * A solid ink for `drawGlyphs` — ntk's `createSolidPicture` answers an
97
152
  * XRender picture; here it is the colour itself. Premultiplied 0..1 in, as
@@ -136,6 +191,11 @@ export class CocoaContext2D {
136
191
  ctm: [1, 0, 0, 1, 0, 0],
137
192
  };
138
193
  this._onDirty = null;
194
+ // the recorded path, and whether the native one still matches it (a
195
+ // chunked stroke leaves only its last chunk behind)
196
+ this._cmds = [];
197
+ this._pathStale = false;
198
+ this._strokeChunking = !NO_STROKE_CHUNKING;
139
199
  }
140
200
 
141
201
  _s() {
@@ -152,6 +212,10 @@ export class CocoaContext2D {
152
212
  n.ctxSetGlobalAlpha(surface, st.globalAlpha);
153
213
  n.ctxSetLineDash(surface, st.dash, st.dashOffset);
154
214
  this._stack.length = 0;
215
+ // the path went with the surface it was built on; nothing may
216
+ // replay it onto the new one
217
+ this._cmds.length = 0;
218
+ this._pathStale = false;
155
219
  }
156
220
  return surface;
157
221
  }
@@ -218,6 +282,26 @@ export class CocoaContext2D {
218
282
  }
219
283
  }
220
284
 
285
+ /**
286
+ * Whether a stroke of a path with many subpaths may go out as several
287
+ * `CGContextStrokePath` calls — on by default, because the alternative
288
+ * is quadratic (see P_MOVE above) and every path small enough for the
289
+ * difference to be invisible is below the threshold anyway.
290
+ *
291
+ * Set it false for a path whose subpaths OVERLAP and whose seams have to
292
+ * composite exactly: chunked, a pixel the strokes of two subpaths each
293
+ * half cover is inked twice at half coverage rather than once at full,
294
+ * and reads a little lighter. `REACT_X11_NO_STROKE_CHUNKING=1` is the
295
+ * same switch for a whole process.
296
+ */
297
+ get strokeChunking() {
298
+ return this._strokeChunking;
299
+ }
300
+
301
+ set strokeChunking(value) {
302
+ this._strokeChunking = !!value;
303
+ }
304
+
221
305
  get font() {
222
306
  return this._state.font;
223
307
  }
@@ -389,20 +473,96 @@ export class CocoaContext2D {
389
473
 
390
474
  // --- paths ---------------------------------------------------------------
391
475
 
476
+ /**
477
+ * The surface to build or paint the current path on, with the native
478
+ * path restored first if a chunked stroke consumed it. Lazy on purpose:
479
+ * a caller that strokes and then starts a new path — which is every
480
+ * caller in a paint loop — never pays for the rebuild.
481
+ */
482
+ _path() {
483
+ const surface = this._s();
484
+ if (this._pathStale) {
485
+ this._pathStale = false;
486
+ this._native.ctxBeginPath(surface);
487
+ this._emit(surface, 0, this._cmds.length);
488
+ }
489
+ return surface;
490
+ }
491
+
492
+ /** replay recorded commands `[from, to)` into the native path */
493
+ _emit(surface, from, to) {
494
+ const c = this._cmds;
495
+ const n = this._native;
496
+ for (let i = from; i < to;) {
497
+ const op = c[i];
498
+ const a = i + 1;
499
+ if (op === P_MOVE) n.ctxMoveTo(surface, c[a], c[a + 1]);
500
+ else if (op === P_LINE) n.ctxLineTo(surface, c[a], c[a + 1]);
501
+ else if (op === P_CURVE)
502
+ n.ctxCurveTo(
503
+ surface,
504
+ c[a],
505
+ c[a + 1],
506
+ c[a + 2],
507
+ c[a + 3],
508
+ c[a + 4],
509
+ c[a + 5],
510
+ );
511
+ else if (op === P_QUAD)
512
+ n.ctxQuadTo(surface, c[a], c[a + 1], c[a + 2], c[a + 3]);
513
+ else if (op === P_CLOSE) n.ctxClosePath(surface);
514
+ else if (op === P_RECT)
515
+ n.ctxRect(surface, c[a], c[a + 1], c[a + 2], c[a + 3]);
516
+ else if (op === P_ROUND)
517
+ n.ctxRoundRect(
518
+ surface,
519
+ c[a],
520
+ c[a + 1],
521
+ c[a + 2],
522
+ c[a + 3],
523
+ c[a + 4],
524
+ c[a + 5],
525
+ c[a + 6],
526
+ c[a + 7],
527
+ );
528
+ else if (op === P_ARC)
529
+ n.ctxArc(
530
+ surface,
531
+ c[a],
532
+ c[a + 1],
533
+ c[a + 2],
534
+ c[a + 3],
535
+ c[a + 4],
536
+ !!c[a + 5],
537
+ );
538
+ else if (op === P_ELLIPSE)
539
+ n.ctxEllipse(surface, c[a], c[a + 1], c[a + 2], c[a + 3]);
540
+ i += 1 + P_ARGS[op];
541
+ }
542
+ }
543
+
392
544
  beginPath() {
545
+ this._cmds.length = 0;
546
+ this._pathStale = false;
393
547
  this._native.ctxBeginPath(this._s());
394
548
  }
395
549
 
396
550
  moveTo(x, y) {
397
- this._native.ctxMoveTo(this._s(), x, y);
551
+ const surface = this._path();
552
+ this._cmds.push(P_MOVE, x, y);
553
+ this._native.ctxMoveTo(surface, x, y);
398
554
  }
399
555
 
400
556
  lineTo(x, y) {
401
- this._native.ctxLineTo(this._s(), x, y);
557
+ const surface = this._path();
558
+ this._cmds.push(P_LINE, x, y);
559
+ this._native.ctxLineTo(surface, x, y);
402
560
  }
403
561
 
404
562
  rect(x, y, w, h) {
405
- this._native.ctxRect(this._s(), x, y, w, h);
563
+ const surface = this._path();
564
+ this._cmds.push(P_RECT, x, y, w, h);
565
+ this._native.ctxRect(surface, x, y, w, h);
406
566
  }
407
567
 
408
568
  roundRect(x, y, w, h, radii) {
@@ -413,37 +573,45 @@ export class CocoaContext2D {
413
573
  else if (r.length === 3) r = [r[0], r[1], r[2], r[1]];
414
574
  const cap = Math.min(Math.abs(w) / 2, Math.abs(h) / 2);
415
575
  const clamp = (v) => Math.max(0, Math.min(Number(v) || 0, cap));
416
- this._native.ctxRoundRect(
417
- this._s(),
418
- x,
419
- y,
420
- w,
421
- h,
576
+ const surface = this._path();
577
+ const [r0, r1, r2, r3] = [
422
578
  clamp(r[0]),
423
579
  clamp(r[1]),
424
580
  clamp(r[2]),
425
581
  clamp(r[3]),
426
- );
582
+ ];
583
+ this._cmds.push(P_ROUND, x, y, w, h, r0, r1, r2, r3);
584
+ this._native.ctxRoundRect(surface, x, y, w, h, r0, r1, r2, r3);
427
585
  }
428
586
 
429
587
  arc(x, y, radius, start, end, anticlockwise = false) {
430
- this._native.ctxArc(this._s(), x, y, radius, start, end, anticlockwise);
588
+ const surface = this._path();
589
+ this._cmds.push(P_ARC, x, y, radius, start, end, anticlockwise ? 1 : 0);
590
+ this._native.ctxArc(surface, x, y, radius, start, end, anticlockwise);
431
591
  }
432
592
 
433
593
  ellipse(x, y, rx, ry) {
434
- this._native.ctxEllipse(this._s(), x, y, rx, ry);
594
+ const surface = this._path();
595
+ this._cmds.push(P_ELLIPSE, x, y, rx, ry);
596
+ this._native.ctxEllipse(surface, x, y, rx, ry);
435
597
  }
436
598
 
437
599
  bezierCurveTo(c1x, c1y, c2x, c2y, x, y) {
438
- this._native.ctxCurveTo(this._s(), c1x, c1y, c2x, c2y, x, y);
600
+ const surface = this._path();
601
+ this._cmds.push(P_CURVE, c1x, c1y, c2x, c2y, x, y);
602
+ this._native.ctxCurveTo(surface, c1x, c1y, c2x, c2y, x, y);
439
603
  }
440
604
 
441
605
  quadraticCurveTo(cx, cy, x, y) {
442
- this._native.ctxQuadTo(this._s(), cx, cy, x, y);
606
+ const surface = this._path();
607
+ this._cmds.push(P_QUAD, cx, cy, x, y);
608
+ this._native.ctxQuadTo(surface, cx, cy, x, y);
443
609
  }
444
610
 
445
611
  closePath() {
446
- this._native.ctxClosePath(this._s());
612
+ const surface = this._path();
613
+ this._cmds.push(P_CLOSE);
614
+ this._native.ctxClosePath(surface);
447
615
  }
448
616
 
449
617
  // --- painting ------------------------------------------------------------
@@ -477,24 +645,95 @@ export class CocoaContext2D {
477
645
  _replayPath(path) {
478
646
  const cmds = path?._cmds;
479
647
  if (!Array.isArray(cmds)) return false;
480
- const n = this._native;
481
- const s = this._s();
482
- n.ctxBeginPath(s);
648
+ this.beginPath();
483
649
  for (const c of cmds) {
484
- if (c.type === 'M') n.ctxMoveTo(s, c.x, c.y);
485
- else if (c.type === 'L') n.ctxLineTo(s, c.x, c.y);
650
+ if (c.type === 'M') this.moveTo(c.x, c.y);
651
+ else if (c.type === 'L') this.lineTo(c.x, c.y);
486
652
  else if (c.type === 'C')
487
- n.ctxCurveTo(s, c.x1, c.y1, c.x2, c.y2, c.x, c.y);
488
- else if (c.type === 'Q') n.ctxQuadTo(s, c.x1, c.y1, c.x, c.y);
489
- else if (c.type === 'Z') n.ctxClosePath(s);
653
+ this.bezierCurveTo(c.x1, c.y1, c.x2, c.y2, c.x, c.y);
654
+ else if (c.type === 'Q') this.quadraticCurveTo(c.x1, c.y1, c.x, c.y);
655
+ else if (c.type === 'Z') this.closePath();
490
656
  }
491
657
  return true;
492
658
  }
493
659
 
660
+ /**
661
+ * Whether a stroke of the current path may be split into several
662
+ * `CGContextStrokePath` calls. Two things say no:
663
+ *
664
+ * - A hairline. At or below a device-space width of 1 CoreGraphics
665
+ * strokes through a path that is already linear in the subpath count
666
+ * — 2,000 rings cost 7ms whole — and splitting it is a 2x LOSS, the
667
+ * per-call setup with nothing to win back.
668
+ * - Anything that composites. Each chunk paints separately, so where two
669
+ * subpaths' strokes overlap, a translucent ink, a globalAlpha or a
670
+ * shadow blends twice and reads darker where one call blends the union
671
+ * once. An opaque ink is exact everywhere the coverage is full, which
672
+ * is what the geometry that gets big looks like; what is left is the
673
+ * antialiased fringe at those same overlaps, half-covered twice
674
+ * instead of covered once, which reads a little lighter — the one
675
+ * difference `strokeChunking` exists to turn off.
676
+ */
677
+ _chunkableStroke() {
678
+ if (!this._strokeChunking) return false;
679
+ const st = this._state;
680
+ const [a, b, c, d] = st.ctm;
681
+ const scale = Math.sqrt(Math.abs(a * d - b * c));
682
+ if (!(st.lineWidth * scale > 1)) return false;
683
+ if (st.globalAlpha < 1) return false;
684
+ if (parseColor(st.strokeStyle)[3] < 1) return false;
685
+ if (st.shadowBlur > 0 && parseColor(st.shadowColor)[3] > 0) return false;
686
+ return true;
687
+ }
688
+
689
+ /**
690
+ * Stroke the recorded path as a series of chunks, cut at subpath
691
+ * boundaries so every chunk carries the `moveTo` its segments start
692
+ * from. Answers false when there was nothing to split, leaving the
693
+ * native path untouched for the caller's single stroke.
694
+ */
695
+ _strokeChunks(surface) {
696
+ if (!this._chunkableStroke()) return false;
697
+ const cmds = this._cmds;
698
+ const n = this._native;
699
+ let start = 0;
700
+ let points = 0;
701
+ let subpaths = 0;
702
+ let split = false;
703
+ for (let i = 0; i < cmds.length;) {
704
+ const op = cmds[i];
705
+ if (
706
+ op === P_MOVE &&
707
+ i > start &&
708
+ (points >= STROKE_CHUNK_POINTS || subpaths >= STROKE_CHUNK_SUBPATHS)
709
+ ) {
710
+ n.ctxBeginPath(surface);
711
+ this._emit(surface, start, i);
712
+ n.ctxStroke(surface);
713
+ split = true;
714
+ start = i;
715
+ points = 0;
716
+ subpaths = 0;
717
+ }
718
+ if (op === P_MOVE) subpaths++;
719
+ points += P_POINTS[op];
720
+ i += 1 + P_ARGS[op];
721
+ }
722
+ if (!split) return false;
723
+ n.ctxBeginPath(surface);
724
+ this._emit(surface, start, cmds.length);
725
+ n.ctxStroke(surface);
726
+ // the native path is the last chunk now; _path() puts the whole one
727
+ // back if anything asks for it
728
+ this._pathStale = true;
729
+ return true;
730
+ }
731
+
494
732
  fill(pathOrRule, maybeRule) {
495
733
  const hasPath = pathOrRule != null && typeof pathOrRule === 'object';
496
734
  const rule = hasPath ? maybeRule : pathOrRule;
497
735
  if (hasPath && !this._replayPath(pathOrRule)) return;
736
+ this._path();
498
737
  const style = this._state.fillStyle;
499
738
  if (style instanceof LinearGradient) {
500
739
  const { coords, flat } = style._normalized();
@@ -518,7 +757,14 @@ export class CocoaContext2D {
518
757
  return;
519
758
  }
520
759
  this._applyStroke();
521
- this._native.ctxStroke(this._s());
760
+ // one call per chunk where the path has enough subpaths to be worth it
761
+ // — see P_MOVE and _chunkableStroke above — and one for everything
762
+ // else. The chunks are issued from the record, so a stroke that splits
763
+ // never pays for the restore a previous split owed: `_path()` is asked
764
+ // for the surface only on the whole-path route.
765
+ if (!this._strokeChunks(this._s())) {
766
+ this._native.ctxStroke(this._path());
767
+ }
522
768
  this._dirty();
523
769
  }
524
770
 
@@ -530,7 +776,7 @@ export class CocoaContext2D {
530
776
  ) {
531
777
  return;
532
778
  }
533
- this._native.ctxClip(this._s());
779
+ this._native.ctxClip(this._path());
534
780
  }
535
781
 
536
782
  fillRect(x, y, w, h) {
package/src/screens.js CHANGED
@@ -72,12 +72,17 @@
72
72
  * Letting the WM have the last word costs a clamped-to-the-edge window one
73
73
  * correction it would have made anyway.
74
74
  *
75
- * **A per-monitor work area.** `_NET_WORKAREA` is one rect for the whole
76
- * virtual desktop. Deriving a real per-monitor one means reading
75
+ * **A per-monitor work area, on X11.** `_NET_WORKAREA` is one rect for the
76
+ * whole virtual desktop. Deriving a real per-monitor one means reading
77
77
  * `_NET_WM_STRUT_PARTIAL` off every window on the screen and intersecting
78
78
  * the reservations that fall on each head — a full window-tree walk, redone
79
79
  * whenever any panel changes. `available` below is the per-axis
80
80
  * approximation instead, and says so.
81
+ *
82
+ * A backend that *does* know each monitor's usable rect says so directly
83
+ * instead: a `visible` rect on the monitor record, which `usable()` prefers
84
+ * over the whole-desktop compromise. Cocoa's `NSScreen.visibleFrame` is
85
+ * exactly that, so the macOS backend never goes through the approximation.
81
86
  */
82
87
 
83
88
  import { requireExtension } from './extensions.js';
@@ -206,9 +211,36 @@ function monitorAt(monitors, point) {
206
211
  return best;
207
212
  }
208
213
 
214
+ /** The overlap of two rects, or `null` where they do not touch. */
215
+ function intersect(a, b) {
216
+ const x0 = Math.max(a.x, b.x);
217
+ const y0 = Math.max(a.y, b.y);
218
+ const x1 = Math.min(a.x + a.width, b.x + b.width);
219
+ const y1 = Math.min(a.y + a.height, b.y + b.height);
220
+ if (x1 <= x0 || y1 <= y0) return null;
221
+ return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
222
+ }
223
+
209
224
  /**
210
- * The monitor rect clamped per axis by `_NET_WORKAREA` — see the note on
211
- * per-monitor work areas at the top of the file.
225
+ * The usable part of one monitor.
226
+ *
227
+ * Two ways to get there, and the first one is the real answer:
228
+ *
229
+ * - A monitor record may carry its **own** usable rect as `visible`, in the
230
+ * same screen coordinates as the monitor itself. That is what Cocoa's
231
+ * `NSScreen.visibleFrame` is — per screen, minus that screen's menu bar
232
+ * and Dock — and it is taken as a rect, intersected with the monitor in
233
+ * case a backend reports one that overhangs.
234
+ * - Otherwise the monitor rect clamped **per axis** by `_NET_WORKAREA`,
235
+ * which is one rect for the whole virtual desktop and so cannot be
236
+ * positioned against a single head — see the note at the top of the file.
237
+ *
238
+ * The distinction matters the moment the monitors differ in size. A global
239
+ * work area no wider than the primary, applied as a width bound to a wider
240
+ * second display, moves that display's right edge inward by the difference
241
+ * and every anchored popup with it. On X11 the property spans the virtual
242
+ * desktop and the clamp is a no-op on the width, which is why the
243
+ * approximation held there and only there.
212
244
  *
213
245
  * Always **only** a rect. A monitor record carries a name, a primary flag and
214
246
  * physical sizes as well, and spreading it here put all of that inside
@@ -221,6 +253,7 @@ function usable(monitor, work) {
221
253
  width: monitor.width,
222
254
  height: monitor.height,
223
255
  };
256
+ if (monitor.visible) return intersect(rect, monitor.visible) ?? rect;
224
257
  if (!work) return rect;
225
258
  rect.width = Math.min(rect.width, work.width);
226
259
  rect.height = Math.min(rect.height, work.height);
@@ -756,6 +789,8 @@ export function endScreens(app) {
756
789
  * `monitors` entries may carry the RandR fields (`name`, `primary`,
757
790
  * `widthMM`, `heightMM`, `refreshRate`, `rotation`) as well as the rect, so
758
791
  * a test can state a named two-head desktop without a server that has RandR.
792
+ * An entry may also carry its own `visible` rect — the monitor's usable
793
+ * area, which takes precedence over the whole-desktop `workArea`.
759
794
  */
760
795
  export function setScreensForTests(app, { monitors = null, workArea = null }) {
761
796
  let session = sessions.get(app);