react-x11 2.41.1 → 2.43.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.41.1",
3
+ "version": "2.43.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -32,11 +32,18 @@ const ASKED_KEPT = 16;
32
32
  const GESTURE_GAP_MS = 500;
33
33
 
34
34
  /** How far inside the budget a frame of a pane that fell behind has to
35
- * come for its frames to be anchored again (`_answered`): a pane whose
36
- * frames come near the budget keeps one look rather than taking the
37
- * other at every frame. */
35
+ * come for its frames to be shown at their size again (`_answered`): a
36
+ * pane whose frames come near the budget keeps one look rather than taking
37
+ * the other at every frame. */
38
38
  const KEPT_UP = 0.75;
39
39
 
40
+ /** How much of a frame's last pixel is carried over the layer past it
41
+ * (`_edgeProps`): its middle, a hundredth of a pixel wide. A centre the
42
+ * whole pixel wide stretches what the filter makes of it and the pixel
43
+ * before it, a blend of the two ramped across the gap, and Core Animation
44
+ * takes a centre of no width for none. */
45
+ const EDGE_SLIVER = 0.01;
46
+
40
47
  /** One shared surface on glass: a sublayer of a window's root layer, its
41
48
  * contents the IOSurface the pane last presented into it. */
42
49
  class PaneLayer {
@@ -51,15 +58,18 @@ class PaneLayer {
51
58
  });
52
59
  this.destroyed = false;
53
60
  this._rect = null;
54
- // How the frame the layer holds sits in it while one of the size the
55
- // layer was just given is on its way (`_gravityNow`): the gravity set,
56
- // Core Animation's own until one is, the sizes asked of the pane with
57
- // when each was asked, and whether the pane has fallen behind them.
58
- this._gravity = 'resize';
61
+ // How the frame the layer holds is shown while one of the size the
62
+ // layer was just given is on its way (`_anchoredNow`, `_edgeProps`):
63
+ // the sizes asked of the pane with when each was asked, whether the
64
+ // pane has fallen behind them, the size of the frame it holds, and the
65
+ // contents rects last set — Core Animation's own, the whole, until any
66
+ // are.
59
67
  this._contentsScale = null;
60
68
  this._asked = [];
61
69
  this._askedAt = null;
62
70
  this._behind = false;
71
+ this._frame = null;
72
+ this._edgeKey = `${null}|${null}`;
63
73
  // when a frame last answered a size, and how many frames in a row came
64
74
  // later than the budget (`_answered`)
65
75
  this._answeredAt = -Infinity;
@@ -67,22 +77,21 @@ class PaneLayer {
67
77
  }
68
78
 
69
79
  /**
70
- * The gravity a pane's last frame sits in its layer by, until a frame of
71
- * the layer's new size lands. The host gives the layer its new size the
72
- * moment the window lays out, and the pane's frame of that size comes a
73
- * pane frame later: Core Animation's default, `resize`, stretches the
74
- * frame it has to the new bounds meanwhile, and a page's left column was
75
- * drawn scaled a little at every step of a window dragged wider, and then
76
- * at its size again. A pane that keeps up has its frame anchored at the
77
- * top left instead, at its size, what it does not cover yet showing the
78
- * window under it for a pane frame; one that falls behind the budget is
79
- * stretched, which is the better picture for the long wait — a pane
80
- * repainting at three frames a second shows a squeezed page, not a page
81
- * and a growing strip. The budget is the window's live-resize handshake's
80
+ * Whether a pane's last frame is shown at its size in its layer until a
81
+ * frame of the layer's new size lands, or stretched to it. The host gives
82
+ * the layer its new size the moment the window lays out, and the pane's
83
+ * frame of that size comes a pane frame later: Core Animation stretches
84
+ * the frame it has to the new bounds meanwhile, and a page's left column
85
+ * was drawn scaled a little at every step of a window dragged wider, and
86
+ * then at its size again. A pane that keeps up has its frame shown at its
87
+ * size instead, its edge carried over what it does not cover yet
88
+ * (`_edgeProps`); one that falls behind the budget is stretched, which is
89
+ * the better picture for the long wait — a pane repainting at three
90
+ * frames a second shows a squeezed page, not a page and a smeared strip.
91
+ * The budget is the window's live-resize handshake's
82
92
  * (`createRoot({ cocoa: { resizeWait } })`), how long a frame of a new
83
93
  * size may take before the old one is shown in its place: 0 stretches
84
- * always, as before. A bridge that predates `contentsGravity` ignores it,
85
- * and stretches.
94
+ * always, as before.
86
95
  *
87
96
  * Behind is a pane whose frames come later than the budget, not one with
88
97
  * a size waiting that long. `<Frame>` sends a pane the newest size once
@@ -91,38 +100,84 @@ class PaneLayer {
91
100
  * waits until a frame of a later one lands, two pane frames at most. A
92
101
  * page that painted in 27 ms answered every size it was sent in 31, and
93
102
  * still had one 52 ms old at every step of the drag — so its layer was
94
- * stretched at every step and anchored again at every frame, the page
95
- * scaling and coming back thirty times a second. Here a size waiting
96
- * longer than the budget counts only when no frame has answered any
97
- * size in that time, which is a pane that stopped, and a frame that
98
- * lands decides the rest (`_answered`).
103
+ * stretched at every step and shown at its size again at every frame, the
104
+ * page scaling and coming back thirty times a second. Here a size waiting
105
+ * longer than the budget counts only when no frame has answered any size
106
+ * in that time, which is a pane that stopped, and a frame that lands
107
+ * decides the rest (`_answered`).
99
108
  */
100
- _gravityNow() {
109
+ _anchoredNow() {
101
110
  const budget = this.app._resizeWait ?? RESIZE_WAIT_MS;
102
- if (!(budget > 0)) return 'resize';
111
+ if (!(budget > 0)) return false;
103
112
  // a size asked longer ago than the budget, and no frame of any since
104
113
  const oldest = this._asked[0];
105
114
  if (oldest && now() - Math.max(oldest.at, this._answeredAt) > budget) {
106
115
  this._behind = true;
107
116
  }
108
- return this._behind ? 'resize' : 'topLeft';
117
+ return !this._behind;
109
118
  }
110
119
 
111
- /** The props that set the gravity, where it changed. */
112
- _gravityProps() {
113
- const gravity = this._gravityNow();
114
- if (gravity === this._gravity) return null;
115
- this._gravity = gravity;
116
- // a frame anchored and larger than the layer would hang out of it
117
- return { contentsGravity: gravity, masksToBounds: gravity !== 'resize' };
120
+ /**
121
+ * The props that show the frame the layer holds in bounds that are not
122
+ * its size, where they changed. At its size and from the top left, as a
123
+ * page sits in a window: an axis the layer shrank on is cropped
124
+ * (`contentsRect`), and on one it grew on the frame's last pixel is
125
+ * carried over the rest (`contentsCenter`, the part of the contents that
126
+ * stretches while everything else keeps its size) — the last column
127
+ * across a window dragged wider, the last row down one dragged taller,
128
+ * and the corner pixel into the corner. Both rects are the whole when
129
+ * the frame is stretched, and when it is the layer's size.
130
+ *
131
+ * Anchored by gravity alone, the strip the frame did not cover showed
132
+ * whatever was under the pane, which is the `<Frame>`'s background: in
133
+ * the browser example's dark mode a dark band 14 to 38 points wide down
134
+ * the page's right edge, at every step of a fast drag. A page's edge is
135
+ * mostly its background, so its last pixels continue it: Zen Garden's
136
+ * about page has a vertical gradient there, which its last column carries
137
+ * on exactly, where one colour read off the edge put a flat band across
138
+ * it, three times as far from the page. Where the edge cuts through the
139
+ * page's content — the bottom row, in a window dragged taller, usually
140
+ * crosses a line of text — the glyphs run down the strip in thin lines
141
+ * for the pane frame it lasts, where the settled page has its next line
142
+ * of text: as far from it, measured, as the flat colour. A bridge that
143
+ * predates the rects ignores them, and stretches.
144
+ */
145
+ _edgeProps() {
146
+ const frame = this._frame;
147
+ const rect = this._rect;
148
+ let crop = null;
149
+ let edge = null;
150
+ if (frame && rect && this._anchoredNow()) {
151
+ const w = Math.min(frame.width, rect.width);
152
+ const h = Math.min(frame.height, rect.height);
153
+ if (w < frame.width || h < frame.height) {
154
+ crop = [0, 0, w / frame.width, h / frame.height];
155
+ }
156
+ // in the shown part's own unit square; an axis that did not grow is
157
+ // all centre, stretched by one
158
+ const wider = rect.width > w;
159
+ const taller = rect.height > h;
160
+ if (wider || taller) {
161
+ edge = [
162
+ wider ? (w - 0.5) / w : 0,
163
+ taller ? (h - 0.5) / h : 0,
164
+ wider ? EDGE_SLIVER / w : 1,
165
+ taller ? EDGE_SLIVER / h : 1,
166
+ ];
167
+ }
168
+ }
169
+ const key = `${crop}|${edge}`;
170
+ if (key === this._edgeKey) return null;
171
+ this._edgeKey = key;
172
+ return { contentsRect: crop, contentsCenter: edge };
118
173
  }
119
174
 
120
175
  /**
121
176
  * The props that say how many of the pane's pixels make a point of the
122
177
  * layer, where that changed: the window's scale, which the pane draws
123
- * at. A frame stretched to the bounds never asked, and one anchored at
124
- * its size is shown at it — pixels over `contentsScale` points, so at
125
- * the default of 1 a page at 2x came out twice its size.
178
+ * at. A frame stretched to the bounds never asked, and one shown at its
179
+ * size is shown at it — pixels over `contentsScale` points, so at the
180
+ * default of 1 a page at 2x came out twice its size.
126
181
  */
127
182
  _scaleProps() {
128
183
  const scale = this.wnd.scale ?? 1;
@@ -137,7 +192,7 @@ class PaneLayer {
137
192
  * the budget put the pane behind, and one well inside it — `KEPT_UP` of
138
193
  * it — brings it back: a frame the collector held up stretches nothing,
139
194
  * and a pane whose frames come near the budget is not stretched and
140
- * anchored by turns.
195
+ * shown at its size by turns.
141
196
  */
142
197
  _answered(width, height) {
143
198
  const asked = this._asked;
@@ -202,7 +257,7 @@ class PaneLayer {
202
257
  zPosition: 1e7,
203
258
  hidden: false,
204
259
  ...this._scaleProps(),
205
- ...this._gravityProps(),
260
+ ...this._edgeProps(),
206
261
  });
207
262
  } finally {
208
263
  native.txCommit();
@@ -230,22 +285,28 @@ class PaneLayer {
230
285
  */
231
286
  present(iosurfaceId, size = null) {
232
287
  if (this.destroyed) return false;
288
+ const native = this._native;
289
+ // The frame and the rects that show it in one transaction: a frame of
290
+ // a new size shown by the last one's rects is cropped or carried over
291
+ // by the wrong amount for a refresh.
292
+ native.txBegin({ disableActions: true });
233
293
  try {
234
- this._native.setLayerContentsIOSurface(this.layer, iosurfaceId);
235
- } catch (err) {
236
- if (!/IOSurfaceLookup/.test(err?.message ?? '')) throw err;
237
- return false;
238
- }
239
- // how long the pane took to a frame of a size it was asked for, which
240
- // is what the gravity follows (`_gravityNow`)
241
- if (size) {
242
- this._answered(size.width, size.height);
243
- const gravity = this._gravityProps();
244
- if (gravity) {
245
- withoutActions(this._native, () =>
246
- this._native.setLayerProps(this.layer, gravity),
247
- );
294
+ try {
295
+ native.setLayerContentsIOSurface(this.layer, iosurfaceId);
296
+ } catch (err) {
297
+ if (!/IOSurfaceLookup/.test(err?.message ?? '')) throw err;
298
+ return false;
248
299
  }
300
+ // how long the pane took to a frame of a size it was asked for, which
301
+ // is what decides how the next is shown (`_anchoredNow`)
302
+ if (size) {
303
+ this._answered(size.width, size.height);
304
+ this._frame = { width: size.width, height: size.height };
305
+ const edge = this._edgeProps();
306
+ if (edge) native.setLayerProps(this.layer, edge);
307
+ }
308
+ } finally {
309
+ native.txCommit();
249
310
  }
250
311
  return true;
251
312
  }
@@ -39,6 +39,17 @@
39
39
  // clip with round corners that a square ancestor cuts again is a box of its
40
40
  // own inside a box of the square one's.
41
41
  //
42
+ // Or a part is its **shadows** alone (`shadows`): CSS's outer box-shadow,
43
+ // which its element would blur a pixel at a time and the render server
44
+ // draws on the GPU. Each is a layer that casts it — the shape, its corners
45
+ // rounded, moved clear of what shows and its shadow offset back by as much,
46
+ // so that no spread shows the shape itself — in a box that shows everything
47
+ // but the part's own rect, as an outer shadow is never drawn under the box
48
+ // that casts it. Core Animation's `shadowRadius` is the Gaussian's
49
+ // deviation, which CSS makes half the blur radius: a glow of 200 CSS pixels
50
+ // is a radius of 100 points, and measured against `<Html>`'s own shadow a
51
+ // level apart. Their blur, colour and offset animate there too.
52
+ //
42
53
  // A part may be inside another of its element's (`parent`): a spinner in a
43
54
  // card that fades. Its layer is lifted inside the parent's, so it fades,
44
55
  // turns and is cut with it, and the parent's raster leaves it out — the
@@ -134,18 +145,53 @@ const isCurve = (t) =>
134
145
  t[2] >= 0 &&
135
146
  t[2] <= 1);
136
147
 
148
+ const isColor = (c) => Array.isArray(c) && c.length === 4 && c.every(finite);
149
+
150
+ const isPair = (p) => Array.isArray(p) && p.length === 2 && p.every(finite);
151
+
152
+ const isBlur = (v) => finite(v) && v >= 0;
153
+
154
+ // what each animated property's keyframes are
155
+ const VALUE_OF = {
156
+ opacity: finite,
157
+ transform: isMatrix,
158
+ shadowBlur: isBlur,
159
+ shadowColor: isColor,
160
+ shadowOffset: isPair,
161
+ };
162
+
163
+ const SHADOW_PROPERTIES = new Set([
164
+ 'shadowBlur',
165
+ 'shadowColor',
166
+ 'shadowOffset',
167
+ ]);
168
+
169
+ const isShadow = (sh) =>
170
+ sh != null &&
171
+ isRect(sh.rect) &&
172
+ isColor(sh.color) &&
173
+ (sh.radius === undefined || isBlur(sh.radius)) &&
174
+ (sh.x === undefined || finite(sh.x)) &&
175
+ (sh.y === undefined || finite(sh.y)) &&
176
+ (sh.blur === undefined || isBlur(sh.blur));
177
+
178
+ /** Is `sprite` its shadows alone? */
179
+ const castsOnly = (sprite) => Array.isArray(sprite.shadows);
180
+
137
181
  /** Is `a` an animation the bridge will take as it is — so that a bad one
138
182
  * is refused here rather than thrown from inside a frame? */
139
183
  function isAnimation(a) {
140
184
  if (a == null || typeof a.id !== 'string') return false;
141
- if (a.property !== 'opacity' && a.property !== 'transform') return false;
185
+ const value = Object.hasOwn(VALUE_OF, a.property)
186
+ ? VALUE_OF[a.property]
187
+ : null;
188
+ if (!value) return false;
142
189
  if (!finite(a.duration) || !(a.duration > 0)) return false;
143
190
  const values = a.values;
144
191
  if (!Array.isArray(values) || values.length < 2) return false;
145
192
  // an element hands the same keyframes over from frame to frame: they
146
193
  // are read once
147
194
  if (CHECKED.get(values) !== a.property) {
148
- const value = a.property === 'opacity' ? finite : isMatrix;
149
195
  if (!values.every(value)) return false;
150
196
  CHECKED.set(values, a.property);
151
197
  }
@@ -180,13 +226,22 @@ function isAnimation(a) {
180
226
  function isSprite(sprite) {
181
227
  if (sprite == null || typeof sprite.key !== 'string') return false;
182
228
  if (!isRect(sprite.rect)) return false;
183
- if (
229
+ const shadows = sprite.shadows;
230
+ if (shadows !== undefined) {
231
+ // its shadows alone: nothing painted, nothing shown
232
+ if (sprite.paint !== undefined || sprite.contents != null) return false;
233
+ if (!Array.isArray(shadows) || shadows.length === 0) return false;
234
+ if (!shadows.every(isShadow)) return false;
235
+ } else if (
184
236
  sprite.contents != null
185
237
  ? sprite.paint !== undefined
186
238
  : typeof sprite.paint !== 'function'
187
239
  ) {
188
240
  return false;
189
241
  }
242
+ if (sprite.rectRadius !== undefined && !isBlur(sprite.rectRadius)) {
243
+ return false;
244
+ }
190
245
  if (sprite.reach !== undefined && !isRect(sprite.reach)) return false;
191
246
  if (sprite.clip !== undefined && !isRect(sprite.clip)) return false;
192
247
  if (sprite.parent !== undefined && typeof sprite.parent !== 'string') {
@@ -217,6 +272,14 @@ function isSprite(sprite) {
217
272
  if (!Array.isArray(animations) || !animations.every(isAnimation)) {
218
273
  return false;
219
274
  }
275
+ // a shadow's animation is of one of the part's shadows
276
+ for (const a of animations) {
277
+ if (!SHADOW_PROPERTIES.has(a.property)) continue;
278
+ const i = a.shadow ?? 0;
279
+ if (!Number.isInteger(i) || i < 0 || !(i < (shadows?.length ?? 0))) {
280
+ return false;
281
+ }
282
+ }
220
283
  }
221
284
  return true;
222
285
  }
@@ -280,6 +343,63 @@ function unionRect(a, b) {
280
343
  };
281
344
  }
282
345
 
346
+ /**
347
+ * How far past its rect a shadow blurred by `blur` shows: three of its
348
+ * deviations, half the blur each (CSS Backgrounds 3, 7.1.1), past which the
349
+ * tail is a fraction of a level; and a pixel more for the antialiased edge
350
+ * of the shape.
351
+ */
352
+ const shadowReach = (blur) => Math.ceil(1.5 * blur) + 1;
353
+
354
+ // where a shadow part's shadows can reach, by its shadows and keyframes
355
+ const SHADOW_REACH = new WeakMap();
356
+
357
+ /**
358
+ * What a part draws, untransformed: its `reach`, or its `rect` — and for a
359
+ * part that is its shadows, everywhere they can be through every keyframe
360
+ * of their blur and offset, with its own `reach` if it gives one.
361
+ */
362
+ function reachOf(sprite) {
363
+ if (!castsOnly(sprite)) return sprite.reach ?? sprite.rect;
364
+ const shadows = sprite.shadows;
365
+ const animations = sprite.animations ?? [];
366
+ const kept = SHADOW_REACH.get(shadows);
367
+ if (kept && kept.animations === animations && kept.reach === sprite.reach) {
368
+ return kept.box;
369
+ }
370
+ let box = sprite.reach ?? null;
371
+ for (let i = 0; i < shadows.length; i++) {
372
+ const sh = shadows[i];
373
+ let blur = sh.blur ?? 0;
374
+ let x0 = sh.x ?? 0;
375
+ let x1 = x0;
376
+ let y0 = sh.y ?? 0;
377
+ let y1 = y0;
378
+ for (const a of animations) {
379
+ if ((a.shadow ?? 0) !== i) continue;
380
+ if (a.property === 'shadowBlur') {
381
+ for (const v of a.values) blur = Math.max(blur, v);
382
+ } else if (a.property === 'shadowOffset') {
383
+ for (const [x, y] of a.values) {
384
+ x0 = Math.min(x0, x);
385
+ x1 = Math.max(x1, x);
386
+ y0 = Math.min(y0, y);
387
+ y1 = Math.max(y1, y);
388
+ }
389
+ }
390
+ }
391
+ const pad = shadowReach(blur);
392
+ box = unionRect(box, {
393
+ x: sh.rect.x + x0 - pad,
394
+ y: sh.rect.y + y0 - pad,
395
+ width: sh.rect.width + x1 - x0 + 2 * pad,
396
+ height: sh.rect.height + y1 - y0 + 2 * pad,
397
+ });
398
+ }
399
+ SHADOW_REACH.set(shadows, { animations, reach: sprite.reach, box });
400
+ return box;
401
+ }
402
+
283
403
  /**
284
404
  * Everywhere `sprite` can draw while its animations run: its reach through
285
405
  * its own transform and every transform it is animated through, out to
@@ -291,7 +411,7 @@ function unionRect(a, b) {
291
411
  * curve overshoots, offers a `reach` that covers what is between them.
292
412
  */
293
413
  function extentOf(sprite) {
294
- const reach = sprite.reach ?? sprite.rect;
414
+ const reach = reachOf(sprite);
295
415
  const origin = originOf(sprite);
296
416
  const m0 = sprite.transform ?? IDENTITY;
297
417
  // About its origin, where it can be is the same for a part that only
@@ -347,7 +467,7 @@ function extentOf(sprite) {
347
467
  * that the hole it leaves and the layer meet. */
348
468
  function rasterRectOf(sprite) {
349
469
  if (sprite.contents != null) return sprite.rect;
350
- const reach = sprite.reach ?? sprite.rect;
470
+ const reach = reachOf(sprite);
351
471
  const x = Math.floor(reach.x) - RASTER_PAD;
352
472
  const y = Math.floor(reach.y) - RASTER_PAD;
353
473
  return {
@@ -404,6 +524,11 @@ export class SpriteLayers {
404
524
  this.solid = Boolean(forms?.includes?.('matrix3d'));
405
525
  // and a video's frames on a layer need its video surfaces
406
526
  this.videos = canLiftVideo(this.native);
527
+ // and a part that is its shadows, a box masked by a shape: every
528
+ // bridge that lifts anything has both, but a fake one may not
529
+ this.shadows =
530
+ typeof this.native.createShapeLayer === 'function' &&
531
+ typeof this.native.setShapeProps === 'function';
407
532
  this.hosts = new Map(); // element -> Map(key -> state)
408
533
  this._warned = new WeakSet();
409
534
  }
@@ -479,18 +604,27 @@ export class SpriteLayers {
479
604
  let parent = null;
480
605
  if (sprite.parent !== undefined) {
481
606
  parent = now.get(sprite.parent) ?? null;
482
- if (!parent || parent.contents !== null || sprite.contents != null) {
607
+ if (
608
+ !parent ||
609
+ parent.contents !== null ||
610
+ parent.shadow ||
611
+ sprite.contents != null
612
+ ) {
483
613
  continue;
484
614
  }
485
615
  }
486
616
  const contents = sprite.contents ?? null;
487
617
  // a source this bridge cannot show: the element draws it
488
618
  if (contents !== null && !this._canShow(contents)) continue;
619
+ // shadows this bridge cannot mask: the element draws them
620
+ const casts = castsOnly(sprite);
621
+ if (casts && (!this.shadows || parent)) continue;
489
622
  const raster = rasterRectOf(sprite);
490
623
  // a raster's limit; a source's surface is its own size, whatever
491
- // the layer is scaled to
624
+ // the layer is scaled to, and shadows have none
492
625
  if (
493
626
  contents === null &&
627
+ !casts &&
494
628
  (raster.width > MAX_SIDE || raster.height > MAX_SIDE)
495
629
  ) {
496
630
  continue;
@@ -530,7 +664,7 @@ export class SpriteLayers {
530
664
  // for a source, and past the budget of raster kept for such parts.
531
665
  if (!shown) {
532
666
  if (contents !== null) continue;
533
- const px = raster.width * raster.height;
667
+ const px = casts ? 0 : raster.width * raster.height;
534
668
  if (hidden + px > HIDDEN_BUDGET) continue;
535
669
  hidden += px;
536
670
  }
@@ -544,7 +678,8 @@ export class SpriteLayers {
544
678
  (Boolean(state.clip) !== Boolean(cut.clip) ||
545
679
  Boolean(state.outer) !== Boolean(cut.outer) ||
546
680
  state.contents !== contents ||
547
- state.parent !== parent)
681
+ state.parent !== parent ||
682
+ Boolean(state.shadow) !== casts)
548
683
  ) {
549
684
  this._drop(state, root);
550
685
  state = null;
@@ -558,6 +693,7 @@ export class SpriteLayers {
558
693
  contents,
559
694
  parent,
560
695
  Boolean(cut.outer),
696
+ casts,
561
697
  );
562
698
  }
563
699
  state.sprite = sprite;
@@ -661,7 +797,15 @@ export class SpriteLayers {
661
797
  return isVideoFrames(contents) && this.videos;
662
798
  }
663
799
 
664
- _lift(host, key, clipped, contents = null, parent = null, outered = false) {
800
+ _lift(
801
+ host,
802
+ key,
803
+ clipped,
804
+ contents = null,
805
+ parent = null,
806
+ outered = false,
807
+ casts = false,
808
+ ) {
665
809
  const layer = this.native.createLayer();
666
810
  // a part cut to a clip is in a box of the clip's size that masks to it,
667
811
  // and the box is what stands among the layers above the bitmap — or
@@ -714,6 +858,8 @@ export class SpriteLayers {
714
858
  order: null,
715
859
  // what shows itself on the layer, for a part with a source
716
860
  contents,
861
+ // the box the casters of a part that is its shadows are in
862
+ shadow: casts ? this._shadowBox(layer) : null,
717
863
  lift:
718
864
  contents === null
719
865
  ? null
@@ -756,6 +902,7 @@ export class SpriteLayers {
756
902
  // source paints nothing: it shows itself.
757
903
  if (
758
904
  !state.lift &&
905
+ !state.shadow &&
759
906
  (state.painted === UNPAINTED ||
760
907
  sprite.version !== state.painted ||
761
908
  state.rect?.width !== rect.width ||
@@ -862,6 +1009,8 @@ export class SpriteLayers {
862
1009
  state.props = next;
863
1010
  // a source that fills the layer with a layer of its own
864
1011
  state.lift?.layout?.(next.bounds);
1012
+ // shadows, cast in the layer's coordinates
1013
+ if (state.shadow) this._syncShadows(state, sprite, rect);
865
1014
  // after the model went out, in the same transaction
866
1015
  this._syncAnimations(state, sprite);
867
1016
  // the parts inside it, in the element's order, over its raster
@@ -870,6 +1019,97 @@ export class SpriteLayers {
870
1019
  }
871
1020
  }
872
1021
 
1022
+ /** The box a shadow part's casters are in, on its layer, and the mask
1023
+ * that shows everything in it but the part's own rect. */
1024
+ _shadowBox(layer) {
1025
+ const box = this.native.createLayer();
1026
+ const mask = this.native.createShapeLayer();
1027
+ this.native.addSublayer(layer, box);
1028
+ this.native.setLayerProps(box, { mask });
1029
+ return { box, mask, casters: [], shape: '', props: [] };
1030
+ }
1031
+
1032
+ /**
1033
+ * A shadow part's casters, as its shadows say, in the layer's points:
1034
+ * each the shadow's shape, rounded, moved left past the layer's edge —
1035
+ * out of the box's mask, which only shows inside the layer — and its
1036
+ * shadow offset back by as much, so the shape itself never shows,
1037
+ * whatever its spread; blurred by a radius half the CSS blur, which is
1038
+ * the deviation of the Gaussian CSS blurs by (calibrated against
1039
+ * `<Html>`'s own: a level apart). The first shadow is on top: added last.
1040
+ */
1041
+ _syncShadows(state, sprite, rect) {
1042
+ const s = this.scale;
1043
+ const sh = state.shadow;
1044
+ const shadows = sprite.shadows;
1045
+ const w = rect.width / s;
1046
+ const h = rect.height / s;
1047
+ // the box, and its mask: all of the layer but the part's rect
1048
+ const hole = sprite.rect;
1049
+ const hw = hole.width / s;
1050
+ const hh = hole.height / s;
1051
+ const hr = Math.min((sprite.rectRadius ?? 0) / s, hw / 2, hh / 2);
1052
+ const shape = [
1053
+ ['rect', 0, 0, w, h],
1054
+ ['roundRect', (hole.x - rect.x) / s, (hole.y - rect.y) / s, hw, hh, hr],
1055
+ ];
1056
+ const key = JSON.stringify(shape);
1057
+ if (sh.shape !== key) {
1058
+ this.native.setLayerProps(sh.box, { frame: [0, 0, w, h] });
1059
+ this.native.setLayerProps(sh.mask, { frame: [0, 0, w, h] });
1060
+ this.native.setShapeProps(sh.mask, {
1061
+ path: shape,
1062
+ fillRule: 'evenodd',
1063
+ fillColor: [0, 0, 0, 1],
1064
+ });
1065
+ sh.shape = key;
1066
+ }
1067
+ // one caster a shadow, the first added last
1068
+ if (sh.casters.length !== shadows.length) {
1069
+ for (const caster of sh.casters) this.native.removeFromSuperlayer(caster);
1070
+ sh.casters = shadows.map(() => this.native.createLayer());
1071
+ for (let i = shadows.length - 1; i >= 0; i--) {
1072
+ this.native.addSublayer(sh.box, sh.casters[i]);
1073
+ }
1074
+ sh.props = [];
1075
+ }
1076
+ for (let i = 0; i < shadows.length; i++) {
1077
+ const shadow = shadows[i];
1078
+ const r = shadow.rect;
1079
+ const cw = r.width / s;
1080
+ const ch = r.height / s;
1081
+ const aside = this._aside(rect, r);
1082
+ const next = {
1083
+ frame: [(r.x - rect.x) / s - aside, (r.y - rect.y) / s, cw, ch],
1084
+ backgroundColor: [0, 0, 0, 1],
1085
+ cornerRadius: Math.min((shadow.radius ?? 0) / s, cw / 2, ch / 2),
1086
+ shadowColor: shadow.color,
1087
+ shadowOpacity: 1,
1088
+ shadowRadius: (shadow.blur ?? 0) / (2 * s),
1089
+ shadowOffset: [aside + (shadow.x ?? 0) / s, (shadow.y ?? 0) / s],
1090
+ };
1091
+ const was = sh.props[i] ?? {};
1092
+ const out = {};
1093
+ let any = false;
1094
+ for (const k of Object.keys(next)) {
1095
+ const v = next[k];
1096
+ if (Array.isArray(v) ? !sameMatrix(was[k], v) : was[k] !== v) {
1097
+ out[k] = v;
1098
+ any = true;
1099
+ }
1100
+ }
1101
+ if (any) this.native.setLayerProps(sh.casters[i], out);
1102
+ sh.props[i] = next;
1103
+ }
1104
+ }
1105
+
1106
+ /** How far, in points, a caster of a shape at `r` in a layer at `rect`
1107
+ * moves left to be clear of the layer: its right edge a point past the
1108
+ * layer's left. */
1109
+ _aside(rect, r) {
1110
+ return (r.x + r.width - rect.x) / this.scale + 1;
1111
+ }
1112
+
873
1113
  _paint(state, sprite, rect) {
874
1114
  const ctx = state.raster.ensure(
875
1115
  this.promotion,
@@ -909,7 +1149,7 @@ export class SpriteLayers {
909
1149
  const listed = (id) => wanted.some((a) => a.id === id);
910
1150
  for (const [id, run] of state.runs) {
911
1151
  if (listed(id)) continue;
912
- this.native.removeAnimation(state.layer, run.caKey);
1152
+ this.native.removeAnimation(run.layer, run.caKey);
913
1153
  this._ends().delete(run.caId);
914
1154
  state.runs.delete(id);
915
1155
  }
@@ -919,11 +1159,26 @@ export class SpriteLayers {
919
1159
  if (state.runs.has(a.id) || state.done.has(a.id)) continue;
920
1160
  const caId = `rxs${++spriteSeq}`;
921
1161
  const caKey = `sprite:${caId}`;
1162
+ // a shadow's goes on its caster, in the caster's terms
1163
+ let layer = state.layer;
1164
+ let keyPath = a.property;
1165
+ let values = a.values;
1166
+ if (a.property === 'transform') {
1167
+ values = a.values.map((m) => inPoints(m, s));
1168
+ } else if (SHADOW_PROPERTIES.has(a.property)) {
1169
+ const i = a.shadow ?? 0;
1170
+ layer = state.shadow?.casters[i];
1171
+ if (!layer) continue;
1172
+ if (a.property === 'shadowBlur') {
1173
+ keyPath = 'shadowRadius';
1174
+ values = a.values.map((v) => v / (2 * s));
1175
+ } else if (a.property === 'shadowOffset') {
1176
+ const aside = this._aside(state.rect, sprite.shadows[i].rect);
1177
+ values = a.values.map(([x, y]) => [aside + x / s, y / s]);
1178
+ }
1179
+ }
922
1180
  const opts = {
923
- values:
924
- a.property === 'transform'
925
- ? a.values.map((m) => inPoints(m, s))
926
- : a.values,
1181
+ values,
927
1182
  duration: a.duration / 1000,
928
1183
  id: caId,
929
1184
  };
@@ -935,7 +1190,7 @@ export class SpriteLayers {
935
1190
  // from the frame that asked: ahead, or already that far in
936
1191
  if (a.delay) opts.delay = a.delay / 1000;
937
1192
  try {
938
- this.native.addAnimation(state.layer, a.property, opts, caKey);
1193
+ this.native.addAnimation(layer, keyPath, opts, caKey);
939
1194
  } catch (err) {
940
1195
  this._warn(
941
1196
  state.host,
@@ -943,7 +1198,7 @@ export class SpriteLayers {
943
1198
  );
944
1199
  continue;
945
1200
  }
946
- state.runs.set(a.id, { caId, caKey, held: Boolean(a.hold) });
1201
+ state.runs.set(a.id, { caId, caKey, layer, held: Boolean(a.hold) });
947
1202
  this._ends().set(caId, (ev) => this._ended(state, a.id, caId, ev));
948
1203
  }
949
1204
  }
package/src/node.d.ts CHANGED
@@ -220,6 +220,24 @@ export interface Context2D {
220
220
  dw: number,
221
221
  dh: number,
222
222
  ): void;
223
+ /**
224
+ * Draw an image through a projection, in one draw: its pixel (u, v) lands
225
+ * where the 3×3 `matrix` — row-major, `[a, b, c, d, e, f, g, h, i]` — takes
226
+ * it, ((a·u + b·v + c) / w, (d·u + e·v + f) / w) with
227
+ * w = g·u + h·v + i, and the current transform takes that; clipped and
228
+ * faded by `globalAlpha` as `drawImage` is. A plane turned out of the page
229
+ * — CSS `rotateY()` in a `perspective` — is one draw where it would be
230
+ * hundreds of tiles each drawn through the affine matrix nearest it.
231
+ * Answers false, drawing nothing, where a corner of the image has w ≤ 0,
232
+ * behind the viewer.
233
+ *
234
+ * The Wayland context only. A GPU interpolates texture coordinates in
235
+ * perspective; XRender on glamor divides at the corners and folds the
236
+ * image along its diagonal, and CoreGraphics and Direct2D draw an image
237
+ * through an affine matrix alone. Test for it with `'drawImageProjected'
238
+ * in ctx`, and tile where it is absent.
239
+ */
240
+ drawImageProjected?(image: unknown, matrix: readonly number[]): boolean;
223
241
  createLinearGradient(
224
242
  x0: number,
225
243
  y0: number,
@@ -281,6 +299,30 @@ export type SpriteMatrix3D = [
281
299
  * 0..1, or one of Core Animation's curve names. */
282
300
  export type SpriteTiming = [number, number, number, number] | string;
283
301
 
302
+ /** A colour as a sprite's shadows take it: straight sRGB, red, green, blue
303
+ * and alpha each 0 to 1. */
304
+ export type SpriteColor = [number, number, number, number];
305
+
306
+ /**
307
+ * A shadow a part casts, as CSS's outer `box-shadow` casts one: the shape of
308
+ * `rect` with `radius` corners, `x` and `y` away, blurred by `blur` — CSS's
309
+ * blur radius, a Gaussian of half of it — in `color`. Device pixels, window
310
+ * coordinates, untransformed like the part's `rect`. A spread is the
311
+ * element's to fold into `rect` and `radius`, as CSS spells it out.
312
+ */
313
+ export interface SpriteShadow {
314
+ rect: Rect;
315
+ /** The radius of `rect`'s corners: a circular arc at each of the four. 0
316
+ * when left out. */
317
+ radius?: number;
318
+ /** 0 when left out. */
319
+ x?: number;
320
+ y?: number;
321
+ /** 0 when left out: a hard shadow. */
322
+ blur?: number;
323
+ color: SpriteColor;
324
+ }
325
+
284
326
  /**
285
327
  * An animation the render server runs on a sprite: keyframes for one
286
328
  * property, played as CSS plays an animation's iterations.
@@ -290,11 +332,22 @@ export interface SpriteAnimation {
290
332
  * reports. An animation is attached once per id: to change one, give it
291
333
  * a new id. */
292
334
  id: string;
293
- property: 'opacity' | 'transform';
335
+ /** The layer's opacity or transform, or one of its shadows' blur, colour
336
+ * or offset (`shadow`). */
337
+ property:
338
+ 'opacity' | 'transform' | 'shadowBlur' | 'shadowColor' | 'shadowOffset';
339
+ /** Which of the part's `shadows` a shadow's animation is of; 0 when left
340
+ * out. */
341
+ shadow?: number;
294
342
  /** At least two: opacities, or matrices for a transform — sampled densely
295
343
  * where the element interpolates in a way the render server would not.
296
- * A transform's may be `matrix3d`s, in any mix with `matrix`es. */
297
- values: number[] | (SpriteMatrix | SpriteMatrix3D)[];
344
+ * A transform's may be `matrix3d`s, in any mix with `matrix`es. A
345
+ * shadow's are blurs and `[x, y]` offsets in device pixels, or colours. */
346
+ values:
347
+ | number[]
348
+ | (SpriteMatrix | SpriteMatrix3D)[]
349
+ | SpriteColor[]
350
+ | [number, number][];
298
351
  /** One per value, 0 to 1, never decreasing; evenly spaced when left out. */
299
352
  keyTimes?: number[];
300
353
  /** One curve per segment, so one fewer than `values`; linear when left
@@ -356,6 +409,19 @@ export interface Sprite {
356
409
  * the current frame itself (docs/extending.md).
357
410
  */
358
411
  contents?: VideoFrames;
412
+ /**
413
+ * What the part casts, the first on top, as CSS lists them: a part with
414
+ * shadows, and no `paint` or `contents`, is its shadows alone, which the
415
+ * presenter draws on the GPU — a glow its element would blur a pixel at a
416
+ * time — and runs their animations. None of them falls inside `rect`,
417
+ * with `rectRadius` corners, as an outer shadow is never drawn under the
418
+ * box that casts it; where they reach is worked out from them, through
419
+ * every keyframe, so `reach` need not say.
420
+ */
421
+ shadows?: SpriteShadow[];
422
+ /** The radius of `rect`'s corners, in device pixels, where its `shadows`
423
+ * stop. 0 when left out. */
424
+ rectRadius?: number;
359
425
  /** Compared with `===` from frame to frame; a change paints the part
360
426
  * again. Left out, it is painted once. */
361
427
  version?: unknown;
@@ -37,6 +37,12 @@ export class NodeSprites {
37
37
  * milliseconds and `delay` counted from the frame that asks — negative
38
38
  * for one that started before it. An animation is attached once per
39
39
  * `id`: to change one, give it a new id.
40
+ * - `shadows`, in place of `paint`: a part that is CSS's outer box
41
+ * shadows alone, each `{ rect, radius, x, y, blur, color }` in device
42
+ * pixels and straight sRGB, the first on top, none falling inside the
43
+ * part's `rect` (with `rectRadius` corners); their animations are of
44
+ * `shadowBlur`, `shadowColor` and `shadowOffset`, `shadow` saying
45
+ * which.
40
46
  *
41
47
  * Asked every frame, after layout and before the damage is taken, on a
42
48
  * window whose presenter lifts sprites; nowhere else. An element whose
@@ -65,6 +65,8 @@ const MODE_TEXTURE = 1;
65
65
  const MODE_GLYPH = 2;
66
66
  const MODE_RRECT = 3;
67
67
  const MODE_GRADIENT = 4;
68
+ /** A texture read through a projection: `vParams` is (s/w, t/w, 1/w). */
69
+ const MODE_PROJECTIVE = 5;
68
70
 
69
71
  /**
70
72
  * The stencil buffer, shared between two uses: bit 7 is the clip (set where
@@ -114,7 +116,9 @@ const FRAG = `
114
116
  precision mediump float;
115
117
  varying vec2 vUV;
116
118
  varying vec4 vColor;
117
- varying vec4 vParams;
119
+ // highp: in MODE_PROJECTIVE these are texture coordinates over w, divided
120
+ // per fragment, and half floats would put a large texture a pixel out
121
+ varying highp vec4 vParams;
118
122
  uniform int uMode;
119
123
  uniform int uSwizzle;
120
124
  uniform sampler2D uTex;
@@ -156,6 +160,10 @@ void main() {
156
160
  else if (w < 0.0) a = 1.0 - smoothstep(w, -w, d);
157
161
  else a = 1.0 - smoothstep(-0.5, 0.5, d);
158
162
  gl_FragColor = vColor * a;
163
+ } else if (uMode == 5) {
164
+ // Interpolated across the quad in the plane of the screen, s/w, t/w and
165
+ // 1/w are each exact, and so is their quotient: a texture in perspective.
166
+ gl_FragColor = vColor * sampleTex(vParams.xy / vParams.z);
159
167
  } else {
160
168
  // Gradients are evaluated per fragment from its device position, so any
161
169
  // shape — a cover quad over a path included — can be filled with one.
@@ -1450,6 +1458,56 @@ export class WaylandContext2D {
1450
1458
  );
1451
1459
  }
1452
1460
 
1461
+ /**
1462
+ * Draw `img` through a projection, as one quad: its pixel (u, v) lands
1463
+ * where the 3×3 `m` — row-major, `[a, b, c, d, e, f, g, h, i]` — takes it,
1464
+ * ((a·u + b·v + c) / w, (d·u + e·v + f) / w) with w = g·u + h·v + i, and
1465
+ * the current transform takes that. Clipped and faded by `globalAlpha` as
1466
+ * `drawImage` is. Each corner carries its texture coordinates over its w,
1467
+ * which are exact anywhere across the quad, and the fragment divides them
1468
+ * back: the image in perspective, where a quad's own interpolation would
1469
+ * fold it along its diagonal. That fold is what RENDER on glamor draws for
1470
+ * a projective picture transform, and why X11 has no such call.
1471
+ *
1472
+ * Draws nothing and answers false where a corner has w ≤ 0, behind the
1473
+ * viewer: the caller draws the part in front another way.
1474
+ */
1475
+ drawImageProjected(img, m) {
1476
+ this._claim();
1477
+ const src = this._textureFor(img);
1478
+ if (!src) return false;
1479
+ const [a, b, c, d, e, f, g, h, i] = m;
1480
+ const W = src.w;
1481
+ const H = src.h;
1482
+ const ctm = this._m;
1483
+ const pos = new Array(8);
1484
+ const params = new Array(16);
1485
+ const us = [0, W, W, 0];
1486
+ const vs = [0, 0, H, H];
1487
+ for (let k = 0; k < 4; k++) {
1488
+ const u = us[k];
1489
+ const v = vs[k];
1490
+ const w = g * u + h * v + i;
1491
+ if (!(w > 1e-9)) return false;
1492
+ const x = (a * u + b * v + c) / w;
1493
+ const y = (d * u + e * v + f) / w;
1494
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return false;
1495
+ pos[k * 2] = applyX(ctm, x, y);
1496
+ pos[k * 2 + 1] = applyY(ctm, x, y);
1497
+ const s = u / W;
1498
+ // a render target's row 0 is the bottom of what was drawn into it
1499
+ const t = src.flipY ? 1 - v / H : v / H;
1500
+ params[k * 4] = s / w;
1501
+ params[k * 4 + 1] = t / w;
1502
+ params[k * 4 + 2] = 1 / w;
1503
+ params[k * 4 + 3] = 0;
1504
+ }
1505
+ this._setMode(MODE_PROJECTIVE, src.tex, src.swizzle);
1506
+ const alpha = this.globalAlpha;
1507
+ this._quad(pos, ZERO8, [alpha, alpha, alpha, alpha], params);
1508
+ return true;
1509
+ }
1510
+
1453
1511
  /**
1454
1512
  * Write pixels, ignoring the transform and the clip (the canvas rule).
1455
1513
  * `dirtyX/Y/Width/Height` select part of the data, as the spec has it.
@@ -1926,7 +1984,8 @@ export class WaylandContext2D {
1926
1984
  }
1927
1985
  }
1928
1986
 
1929
- /** Two triangles from four corners, in order TL, TR, BR, BL. */
1987
+ /** Two triangles from four corners, in order TL, TR, BR, BL. `params` is
1988
+ * one vec4 for every corner, or sixteen numbers, a vec4 a corner. */
1930
1989
  _quad(pos, uv, color, params) {
1931
1990
  // Buffering is taking the device, not a step before it: the quad is GL
1932
1991
  // work that has been decided on and not yet issued, and the handover in
@@ -1935,9 +1994,11 @@ export class WaylandContext2D {
1935
1994
  this._claim();
1936
1995
  if (this._n + 6 > MAX_VERTS) this._flush();
1937
1996
  const v = this._verts;
1997
+ const each = params.length === 16 ? 4 : 0;
1938
1998
  let o = this._n * STRIDE;
1939
1999
  for (let k = 0; k < 6; k++) {
1940
2000
  const i = TRI[k];
2001
+ const p = i * each;
1941
2002
  v[o++] = pos[i * 2];
1942
2003
  v[o++] = pos[i * 2 + 1];
1943
2004
  v[o++] = uv[i * 2];
@@ -1946,10 +2007,10 @@ export class WaylandContext2D {
1946
2007
  v[o++] = color[1];
1947
2008
  v[o++] = color[2];
1948
2009
  v[o++] = color[3];
1949
- v[o++] = params[0];
1950
- v[o++] = params[1];
1951
- v[o++] = params[2];
1952
- v[o++] = params[3];
2010
+ v[o++] = params[p];
2011
+ v[o++] = params[p + 1];
2012
+ v[o++] = params[p + 2];
2013
+ v[o++] = params[p + 3];
1953
2014
  }
1954
2015
  this._n += 6;
1955
2016
  this.shapeStats.quads++;
@@ -2081,6 +2142,7 @@ export class WaylandContext2D {
2081
2142
 
2082
2143
  const UV_UNIT = Object.freeze([0, 0, 1, 0, 1, 1, 0, 1]);
2083
2144
  const ZERO4 = Object.freeze([0, 0, 0, 0]);
2145
+ const ZERO8 = Object.freeze([0, 0, 0, 0, 0, 0, 0, 0]);
2084
2146
 
2085
2147
  // ---- geometry helpers ------------------------------------------------------------
2086
2148
 
@@ -70,7 +70,8 @@ export function releaseDevice(gl) {
70
70
  * The owner is the only context that can be holding anything (see above), so
71
71
  * flushing it is flushing the device. Call before *reading* a target's pixels
72
72
  * behind the contexts' backs — a framebuffer blit, a scroll copy — the way
73
- * `releaseDevice` is called after writing them.
73
+ * `releaseDevice` is called after writing them, and before deleting a target
74
+ * a buffered draw may still read (`WaylandSurface.destroy`).
74
75
  */
75
76
  export function flushDevice(gl) {
76
77
  const device = shared(gl) ? devices.get(gl) : null;
@@ -25,6 +25,7 @@
25
25
  // back by hand either.
26
26
 
27
27
  import { WaylandContext2D } from './context2d.js';
28
+ import { flushDevice } from './device.js';
28
29
  import { GLTarget } from './target.js';
29
30
 
30
31
  export class WaylandSurface {
@@ -130,6 +131,15 @@ export class WaylandSurface {
130
131
  if (this._destroyed) return;
131
132
  this._destroyed = true;
132
133
  this.app.makeCurrent();
134
+ // A draw that reads this surface may still be buffered in the context
135
+ // that made it — a window's quad, to be drawn in one call with the rest
136
+ // of its batch — and a texture deleted under a draw not yet issued is
137
+ // sampled as GLES samples a texture that is not there: opaque black. A
138
+ // surface painted for one paint, drawn, and destroyed in a `finally`
139
+ // drew as a black box (`<Html>`'s transformed boxes too large to keep).
140
+ // The owner is the only context holding anything (device.js), so
141
+ // flushing it issues every draw that could read this one.
142
+ flushDevice(this.target.gl);
133
143
  this._ctx?.destroy();
134
144
  this._ctx = null;
135
145
  this.target.destroy();