react-x11 2.41.0 → 2.42.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.0",
3
+ "version": "2.42.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -104,14 +104,14 @@
104
104
  "ical.js": "^2.2.1",
105
105
  "image-in-browser": "^3.6.0",
106
106
  "linebreak": "^1.1.0",
107
- "ntk": "^8.17.8",
107
+ "ntk": "^8.23.0",
108
108
  "react-reconciler": "^0.34.0",
109
109
  "yoga-layout": "^3.2.1"
110
110
  },
111
111
  "optionalDependencies": {
112
112
  "@windowkit/appkit": "^0.25.0",
113
113
  "@windowkit/wayland": "^3.1.1",
114
- "@windowkit/win32": "^0.0.7",
114
+ "@windowkit/win32": "^0.0.9",
115
115
  "dbus-native": "^0.16.0",
116
116
  "x11-dri": "^0.9.0"
117
117
  },
@@ -31,6 +31,19 @@ const ASKED_KEPT = 16;
31
31
  * drag itself. */
32
32
  const GESTURE_GAP_MS = 500;
33
33
 
34
+ /** How far inside the budget a frame of a pane that fell behind has to
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
+ const KEPT_UP = 0.75;
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
+
34
47
  /** One shared surface on glass: a sublayer of a window's root layer, its
35
48
  * contents the IOSurface the pane last presented into it. */
36
49
  class PaneLayer {
@@ -45,59 +58,126 @@ class PaneLayer {
45
58
  });
46
59
  this.destroyed = false;
47
60
  this._rect = null;
48
- // How the frame the layer holds sits in it while one of the size the
49
- // layer was just given is on its way (`_gravityNow`): the gravity set,
50
- // Core Animation's own until one is, the sizes asked of the pane with
51
- // when each was asked, and whether the pane has fallen behind them.
52
- 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.
53
67
  this._contentsScale = null;
54
68
  this._asked = [];
55
69
  this._askedAt = null;
56
70
  this._behind = false;
71
+ this._frame = null;
72
+ this._edgeKey = `${null}|${null}`;
73
+ // when a frame last answered a size, and how many frames in a row came
74
+ // later than the budget (`_answered`)
75
+ this._answeredAt = -Infinity;
76
+ this._late = 0;
57
77
  }
58
78
 
59
79
  /**
60
- * The gravity a pane's last frame sits in its layer by, until a frame of
61
- * the layer's new size lands. The host gives the layer its new size the
62
- * moment the window lays out, and the pane's frame of that size comes a
63
- * pane frame later: Core Animation's default, `resize`, stretches the
64
- * frame it has to the new bounds meanwhile, and a page's left column was
65
- * drawn scaled a little at every step of a window dragged wider, and then
66
- * at its size again. A pane that keeps up has its frame anchored at the
67
- * top left instead, at its size, what it does not cover yet showing the
68
- * window under it for a pane frame; one that falls behind the budget is
69
- * stretched, which is the better picture for the long wait — a pane
70
- * repainting at three frames a second shows a squeezed page, not a page
71
- * 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
72
92
  * (`createRoot({ cocoa: { resizeWait } })`), how long a frame of a new
73
93
  * size may take before the old one is shown in its place: 0 stretches
74
- * always, as before. A bridge that predates `contentsGravity` ignores it,
75
- * and stretches.
94
+ * always, as before.
95
+ *
96
+ * Behind is a pane whose frames come later than the budget, not one with
97
+ * a size waiting that long. `<Frame>` sends a pane the newest size once
98
+ * it has painted the last, so the sizes a drag passes through while a
99
+ * frame is being painted are never sent, and never answered: a size
100
+ * waits until a frame of a later one lands, two pane frames at most. A
101
+ * page that painted in 27 ms answered every size it was sent in 31, and
102
+ * still had one 52 ms old at every step of the drag — so its layer was
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`).
76
108
  */
77
- _gravityNow() {
109
+ _anchoredNow() {
78
110
  const budget = this.app._resizeWait ?? RESIZE_WAIT_MS;
79
- if (!(budget > 0)) return 'resize';
80
- // a size asked longer ago than the budget, and no frame of it yet
111
+ if (!(budget > 0)) return false;
112
+ // a size asked longer ago than the budget, and no frame of any since
81
113
  const oldest = this._asked[0];
82
- if (oldest && now() - oldest.at > budget) this._behind = true;
83
- return this._behind ? 'resize' : 'topLeft';
114
+ if (oldest && now() - Math.max(oldest.at, this._answeredAt) > budget) {
115
+ this._behind = true;
116
+ }
117
+ return !this._behind;
84
118
  }
85
119
 
86
- /** The props that set the gravity, where it changed. */
87
- _gravityProps() {
88
- const gravity = this._gravityNow();
89
- if (gravity === this._gravity) return null;
90
- this._gravity = gravity;
91
- // a frame anchored and larger than the layer would hang out of it
92
- 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 };
93
173
  }
94
174
 
95
175
  /**
96
176
  * The props that say how many of the pane's pixels make a point of the
97
177
  * layer, where that changed: the window's scale, which the pane draws
98
- * at. A frame stretched to the bounds never asked, and one anchored at
99
- * its size is shown at it — pixels over `contentsScale` points, so at
100
- * 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.
101
181
  */
102
182
  _scaleProps() {
103
183
  const scale = this.wnd.scale ?? 1;
@@ -106,20 +186,35 @@ class PaneLayer {
106
186
  return { contentsScale: scale };
107
187
  }
108
188
 
109
- /** A frame of `width` by `height` device px landed: what it answers of
110
- * the sizes asked, and how long it took. */
189
+ /**
190
+ * A frame of `width` by `height` device px landed: what it answers of
191
+ * the sizes asked, and how long it took. Two frames in a row later than
192
+ * the budget put the pane behind, and one well inside it — `KEPT_UP` of
193
+ * it — brings it back: a frame the collector held up stretches nothing,
194
+ * and a pane whose frames come near the budget is not stretched and
195
+ * shown at its size by turns.
196
+ */
111
197
  _answered(width, height) {
112
198
  const asked = this._asked;
113
199
  // the pane rounds the logical size it was sent back to device pixels
114
200
  const slack = Math.max(2, this.wnd.scale ?? 1);
115
- const at = asked.findIndex(
201
+ // the last time it was asked: a drag back and forth asks a size twice
202
+ const at = asked.findLastIndex(
116
203
  (a) =>
117
204
  Math.abs(a.width - width) <= slack &&
118
205
  Math.abs(a.height - height) <= slack,
119
206
  );
120
207
  if (at < 0) return;
121
- this._behind =
122
- now() - asked[at].at > (this.app._resizeWait ?? RESIZE_WAIT_MS);
208
+ const budget = this.app._resizeWait ?? RESIZE_WAIT_MS;
209
+ const took = now() - asked[at].at;
210
+ this._answeredAt = now();
211
+ if (took > budget) {
212
+ this._late += 1;
213
+ if (this._late >= 2) this._behind = true;
214
+ } else {
215
+ this._late = 0;
216
+ if (took <= budget * KEPT_UP) this._behind = false;
217
+ }
123
218
  asked.splice(0, at + 1);
124
219
  }
125
220
 
@@ -144,6 +239,7 @@ class PaneLayer {
144
239
  if (at - (this._askedAt ?? -Infinity) >= GESTURE_GAP_MS) {
145
240
  this._asked = [];
146
241
  this._behind = false;
242
+ this._late = 0;
147
243
  }
148
244
  this._askedAt = at;
149
245
  if (this._asked.length === ASKED_KEPT) this._asked.shift();
@@ -161,7 +257,7 @@ class PaneLayer {
161
257
  zPosition: 1e7,
162
258
  hidden: false,
163
259
  ...this._scaleProps(),
164
- ...this._gravityProps(),
260
+ ...this._edgeProps(),
165
261
  });
166
262
  } finally {
167
263
  native.txCommit();
@@ -189,22 +285,28 @@ class PaneLayer {
189
285
  */
190
286
  present(iosurfaceId, size = null) {
191
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 });
192
293
  try {
193
- this._native.setLayerContentsIOSurface(this.layer, iosurfaceId);
194
- } catch (err) {
195
- if (!/IOSurfaceLookup/.test(err?.message ?? '')) throw err;
196
- return false;
197
- }
198
- // how long the pane took to a frame of a size it was asked for, which
199
- // is what the gravity follows (`_gravityNow`)
200
- if (size) {
201
- this._answered(size.width, size.height);
202
- const gravity = this._gravityProps();
203
- if (gravity) {
204
- withoutActions(this._native, () =>
205
- this._native.setLayerProps(this.layer, gravity),
206
- );
294
+ try {
295
+ native.setLayerContentsIOSurface(this.layer, iosurfaceId);
296
+ } catch (err) {
297
+ if (!/IOSurfaceLookup/.test(err?.message ?? '')) throw err;
298
+ return false;
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);
207
307
  }
308
+ } finally {
309
+ native.txCommit();
208
310
  }
209
311
  return true;
210
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
@@ -281,6 +281,30 @@ export type SpriteMatrix3D = [
281
281
  * 0..1, or one of Core Animation's curve names. */
282
282
  export type SpriteTiming = [number, number, number, number] | string;
283
283
 
284
+ /** A colour as a sprite's shadows take it: straight sRGB, red, green, blue
285
+ * and alpha each 0 to 1. */
286
+ export type SpriteColor = [number, number, number, number];
287
+
288
+ /**
289
+ * A shadow a part casts, as CSS's outer `box-shadow` casts one: the shape of
290
+ * `rect` with `radius` corners, `x` and `y` away, blurred by `blur` — CSS's
291
+ * blur radius, a Gaussian of half of it — in `color`. Device pixels, window
292
+ * coordinates, untransformed like the part's `rect`. A spread is the
293
+ * element's to fold into `rect` and `radius`, as CSS spells it out.
294
+ */
295
+ export interface SpriteShadow {
296
+ rect: Rect;
297
+ /** The radius of `rect`'s corners: a circular arc at each of the four. 0
298
+ * when left out. */
299
+ radius?: number;
300
+ /** 0 when left out. */
301
+ x?: number;
302
+ y?: number;
303
+ /** 0 when left out: a hard shadow. */
304
+ blur?: number;
305
+ color: SpriteColor;
306
+ }
307
+
284
308
  /**
285
309
  * An animation the render server runs on a sprite: keyframes for one
286
310
  * property, played as CSS plays an animation's iterations.
@@ -290,11 +314,22 @@ export interface SpriteAnimation {
290
314
  * reports. An animation is attached once per id: to change one, give it
291
315
  * a new id. */
292
316
  id: string;
293
- property: 'opacity' | 'transform';
317
+ /** The layer's opacity or transform, or one of its shadows' blur, colour
318
+ * or offset (`shadow`). */
319
+ property:
320
+ 'opacity' | 'transform' | 'shadowBlur' | 'shadowColor' | 'shadowOffset';
321
+ /** Which of the part's `shadows` a shadow's animation is of; 0 when left
322
+ * out. */
323
+ shadow?: number;
294
324
  /** At least two: opacities, or matrices for a transform — sampled densely
295
325
  * 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)[];
326
+ * A transform's may be `matrix3d`s, in any mix with `matrix`es. A
327
+ * shadow's are blurs and `[x, y]` offsets in device pixels, or colours. */
328
+ values:
329
+ | number[]
330
+ | (SpriteMatrix | SpriteMatrix3D)[]
331
+ | SpriteColor[]
332
+ | [number, number][];
298
333
  /** One per value, 0 to 1, never decreasing; evenly spaced when left out. */
299
334
  keyTimes?: number[];
300
335
  /** One curve per segment, so one fewer than `values`; linear when left
@@ -356,6 +391,19 @@ export interface Sprite {
356
391
  * the current frame itself (docs/extending.md).
357
392
  */
358
393
  contents?: VideoFrames;
394
+ /**
395
+ * What the part casts, the first on top, as CSS lists them: a part with
396
+ * shadows, and no `paint` or `contents`, is its shadows alone, which the
397
+ * presenter draws on the GPU — a glow its element would blur a pixel at a
398
+ * time — and runs their animations. None of them falls inside `rect`,
399
+ * with `rectRadius` corners, as an outer shadow is never drawn under the
400
+ * box that casts it; where they reach is worked out from them, through
401
+ * every keyframe, so `reach` need not say.
402
+ */
403
+ shadows?: SpriteShadow[];
404
+ /** The radius of `rect`'s corners, in device pixels, where its `shadows`
405
+ * stop. 0 when left out. */
406
+ rectRadius?: number;
359
407
  /** Compared with `===` from frame to frame; a change paints the part
360
408
  * again. Left out, it is painted once. */
361
409
  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
package/src/offscreen.js CHANGED
@@ -5,13 +5,14 @@
5
5
  // An app that makes its own surfaces answers `createSurface(options)` — the
6
6
  // Cocoa app over a CG bitmap (src/cocoa/surface.js), the Windows app over a
7
7
  // Direct2D bitmap, the Wayland app over a GL render target
8
- // (src/wayland/surface.js) — and an X connection has no such method and gets
9
- // ntk's pixmap, from ntk's root, which only an X connection loads
10
- // (src/ntkroot.js). The result is whichever implementation answered, not an
11
- // instance of this class: the contract is the shape — `width`/`height`,
12
- // `getContext('2d')`, `render`, `clear`, `copyWithin`, `destroy`, and
13
- // `ctx.drawImage(surface, …)` — (docs/extending.md "Scrolling the pixels, not
14
- // just the offset"), and nothing needs `instanceof`.
8
+ // (src/wayland/surface.js), and ntk's own app over an X connection with its
9
+ // pixmap (ntk >= 8.21). An app without the method — an older ntk deduped
10
+ // into the tree — gets that pixmap from ntk's root, which only an X
11
+ // connection loads (src/ntkroot.js). The result is whichever implementation
12
+ // answered, not an instance of this class: the contract is the shape —
13
+ // `width`/`height`, `getContext('2d')`, `render`, `clear`, `copyWithin`,
14
+ // `destroy`, and `ctx.drawImage(surface, …)` — (docs/extending.md "Scrolling
15
+ // the pixels, not just the offset"), and nothing needs `instanceof`.
15
16
  //
16
17
  // Part of that shape is that a context may be **held**: ntk tells a caller
17
18
  // doing many draws to take `getContext('2d')` once rather than one per frame,
@@ -407,20 +407,68 @@ class Win32TextLayout {
407
407
  * where CoreText cannot and the Cocoa engine breaks at `linebreak`'s
408
408
  * opportunities itself. */
409
409
  get minWidth() {
410
+ // asked of the bridge the first time, where it left it out of the
411
+ // metrics: the line breaker over the whole text again, which a layout
412
+ // made to be drawn is never asked for
413
+ if (this._metrics.minWidth === undefined) {
414
+ this._metrics.minWidth =
415
+ this._handle != null && this._native.layoutMinWidth
416
+ ? (this._native.layoutMinWidth(this._handle) ?? 0)
417
+ : 0;
418
+ }
410
419
  return this._metrics.minWidth;
411
420
  }
412
421
 
422
+ /**
423
+ * Keep the first `cap` lines of what was laid out: they are all this
424
+ * layout reports, answers for and draws (`_keptEnd`, `_keptHeight`). A
425
+ * capped paragraph is laid out past its cap to find where the cap falls,
426
+ * and keeping that layout's first lines is the same picture as laying the
427
+ * kept text out again, for one native layout where it took two.
428
+ */
429
+ _keep(cap) {
430
+ const lines = this._metrics.lines.slice(0, cap);
431
+ const last = lines[lines.length - 1];
432
+ if (!last) return;
433
+ let width = 0;
434
+ for (const line of lines)
435
+ width = Math.max(width, Math.ceil(line.width - 1e-3));
436
+ this._metrics = {
437
+ ...this._metrics,
438
+ lines,
439
+ width,
440
+ height: Math.ceil(last.y + last.height),
441
+ minWidth: undefined,
442
+ };
443
+ this._keptEnd = last.end;
444
+ this._keptHeight = last.y + last.height;
445
+ }
446
+
413
447
  draw(ctx, x, y) {
448
+ if (this._keptHeight === undefined) {
449
+ ctx._drawLayout(this, x, y);
450
+ return;
451
+ }
452
+ // the lines past the cap are in the layout, and stay out of the picture
453
+ ctx.save();
454
+ ctx.beginPath();
455
+ ctx.rect(x - 1e4, y, 2e4 + this._metrics.width, this._keptHeight);
456
+ ctx.clip();
414
457
  ctx._drawLayout(this, x, y);
458
+ ctx.restore();
415
459
  }
416
460
 
417
461
  indexAt(x, y) {
418
462
  const unit = this._native.layoutIndexAt(this._handle, x, y);
419
- return this._cpOf(unit);
463
+ return this._cpOf(Math.min(unit, this._keptEnd ?? unit));
420
464
  }
421
465
 
422
466
  caretPosition(cp) {
423
- return this._native.layoutCaret(this._handle, this._cuOf(cp));
467
+ const unit = this._cuOf(cp);
468
+ return this._native.layoutCaret(
469
+ this._handle,
470
+ Math.min(unit, this._keptEnd ?? unit),
471
+ );
424
472
  }
425
473
 
426
474
  /**
@@ -567,6 +615,20 @@ export class Win32FontManager {
567
615
  // family|weight|italic|size -> the bridge's handle, or null when there is
568
616
  // no such face
569
617
  this._handles = new Map();
618
+ // what this bridge's layouts take, asked once (`textFeatures`, from
619
+ // @windowkit/win32 0.0.9): an older one answers nothing and takes neither
620
+ this._features = native.textFeatures?.() ?? {};
621
+ }
622
+
623
+ /**
624
+ * Which of ntk's `justify` modes `layout` takes. DirectWrite justifies
625
+ * every line but a paragraph's last, which is `justify: true`, and has no
626
+ * way to justify a last line: `'rest'` says so, and a caller justifies the
627
+ * other two itself. <Html>, which spaced every justified line through
628
+ * letter spacing on this engine, lays a justified paragraph out once.
629
+ */
630
+ get justifies() {
631
+ return this._features.justify === true ? 'rest' : false;
570
632
  }
571
633
 
572
634
  /**
@@ -759,7 +821,9 @@ export class Win32FontManager {
759
821
  return this._laidOut(sources, base, { ...options, maxWidth: least });
760
822
  }
761
823
  let laid = sources;
762
- let layout = this._laidOut(laid, base, options);
824
+ let layout =
825
+ this._prefixLaidOut(sources, base, options, cap) ??
826
+ this._laidOut(laid, base, options);
763
827
  // With no width the bridge lays a paragraph out in a box a million
764
828
  // pixels wide, and aligns it there: a centred line's `x` was half a
765
829
  // million, an RTL one's a million, and <Html> measured a centred
@@ -776,7 +840,11 @@ export class Win32FontManager {
776
840
  layout = this._laidOut(laid, base, { ...options, maxWidth: widest });
777
841
  }
778
842
  layout.truncated = layout.lines.length > cap;
779
- if (layout.truncated) {
843
+ if (layout.truncated && !elides) {
844
+ // what was laid out, a prefix or the whole, keeps its first lines
845
+ laid = spansTo(sources, layout._text.length);
846
+ layout._keep(cap);
847
+ } else if (layout.truncated) {
780
848
  const last = layout.lines[cap - 1];
781
849
  const text = layout._text;
782
850
  layout.destroy();
@@ -800,6 +868,43 @@ export class Win32FontManager {
800
868
  return layout;
801
869
  }
802
870
 
871
+ /**
872
+ * A capped paragraph laid out only as far as the cap needs: a prefix of
873
+ * its text that breaks into more lines than `cap`, or null where the
874
+ * whole text is no longer than a prefix would be. The first lines of a
875
+ * prefix are the whole text's first lines wherever it reaches past them —
876
+ * a line's break waits only on the word after it, which then starts a
877
+ * line of the prefix — and the prefix ends at a break, its last word
878
+ * whole. <Html> lays a paragraph beside a float out a line at a time,
879
+ * each `maxLines: 1` over the rest of the paragraph; laid out whole, each
880
+ * line cost the whole rest of it, twice, and a paragraph cost the square
881
+ * of its length.
882
+ */
883
+ _prefixLaidOut(sources, base, options, cap) {
884
+ const { maxWidth } = options;
885
+ if (!(cap < Infinity) || !(maxWidth > 0) || !Number.isFinite(maxWidth)) {
886
+ return null;
887
+ }
888
+ const text = sources.map((span) => String(span.text)).join('');
889
+ let size = Infinity;
890
+ for (const span of sources)
891
+ size = Math.min(size, sizeOf(span.size ?? base.size));
892
+ // a third of an em a character is narrower than nearly any text is set
893
+ // in, so a prefix that long nearly always reaches past the lines wanted;
894
+ // where it does not, one twice as long is tried
895
+ let take = Math.ceil(maxWidth / (Math.max(1, size) * 0.3)) * cap + 16;
896
+ while (take < text.length) {
897
+ let end = take;
898
+ while (end < text.length && !BREAKING_SPACE.test(text[end])) end++;
899
+ if (end >= text.length) return null;
900
+ const layout = this._laidOut(spansTo(sources, end), base, options);
901
+ if (layout.lines.length > cap) return layout;
902
+ layout.destroy();
903
+ take *= 2;
904
+ }
905
+ return null;
906
+ }
907
+
803
908
  /**
804
909
  * Where the last line kept of an eliding paragraph ends: as far into
805
910
  * `[start, end)` as fits beside the mark in `maxWidth`, white space before
@@ -899,11 +1004,17 @@ export class Win32FontManager {
899
1004
  align: options.align,
900
1005
  lineHeight: options.lineHeight,
901
1006
  rtl: options.direction === 'rtl',
1007
+ // the one mode DirectWrite has (`justifies`); 'last' and 'all' are
1008
+ // the caller's
1009
+ justify: options.justify === true || undefined,
902
1010
  },
903
1011
  ranges,
904
1012
  );
905
1013
 
906
- const raw = this._native.layoutMetrics(handle);
1014
+ const raw = this._native.layoutMetrics(
1015
+ handle,
1016
+ this._features.lazyMinWidth ? { minWidth: false } : undefined,
1017
+ );
907
1018
  if (DEBUG) {
908
1019
  // The family is in here because the one way this goes quietly wrong is
909
1020
  // `resolveStack` not recognising a name and answering `sans-serif`: the