react-x11 2.19.0 → 2.21.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.19.0",
3
+ "version": "2.21.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -108,7 +108,7 @@
108
108
  "yoga-layout": "^3.2.1"
109
109
  },
110
110
  "optionalDependencies": {
111
- "@windowkit/appkit": "^0.12.0",
111
+ "@windowkit/appkit": "^0.13.0",
112
112
  "@windowkit/wayland": "^3.1.1",
113
113
  "@windowkit/win32": "^0.0.3",
114
114
  "dbus-native": "^0.15.1",
@@ -5,7 +5,7 @@
5
5
  // fonts.layout(spans, base, { maxWidth, align, lineHeight, maxLines,
6
6
  // overflow, direction })
7
7
  // -> { width, height, lines, draw(ctx, x, y),
8
- // indexAt(x, y), caretPosition(cp) }
8
+ // indexAt(x, y), caretPosition(cp), coverage({ pad }) }
9
9
  // fonts.match(family, { weight, style }) -> face
10
10
  // fonts.fallbackFor(codepoint, family, { weight, style }) -> face | null
11
11
  //
@@ -144,6 +144,25 @@ class CocoaTextLayout {
144
144
  caretPosition(cp) {
145
145
  return this._native.layoutCaret(this._handle, this._cuOf(cp));
146
146
  }
147
+
148
+ /**
149
+ * How much of each device pixel the glyphs cover, one byte a pixel,
150
+ * without drawing them anywhere (#673) — for text that is drawn where a 2D
151
+ * context is not, a GL surface's labels. The raster is the layout's box in
152
+ * whole pixels with `pad` round it, the layout's origin at (pad, pad).
153
+ *
154
+ * The bridge's `layoutCoverage`: each glyph's CoreText outline where the
155
+ * run put it, unrounded, filled once by the signed-area accumulation ntk
156
+ * rasterizes with, so the X11 engine and this one give the same bytes for
157
+ * the same outlines — and none of the font smoothing the screen gets. Not
158
+ * CoreText's glyph rasterizer, which shrinks a small glyph's counters even
159
+ * with smoothing off. Null on a bridge that predates it (`@windowkit/appkit`
160
+ * before 0.13.0), so a caller keeps its readback.
161
+ */
162
+ coverage({ pad = 0 } = {}) {
163
+ if (typeof this._native.layoutCoverage !== 'function') return null;
164
+ return this._native.layoutCoverage(this._handle, pad) ?? null;
165
+ }
147
166
  }
148
167
 
149
168
  /**
package/src/glnodes.js CHANGED
@@ -379,8 +379,10 @@ export class GlAreaNode extends Node {
379
379
  // `_assignAbs` keeps the object when nothing changed
380
380
  const still = this.abs === was;
381
381
  const { x, y } = this.abs;
382
+ const dx = x - was.x;
383
+ const dy = y - was.y;
382
384
  for (const child of this.children) {
383
- if (child.isWindow) continue;
385
+ if (child.isWindow || child._followParent(dx, dy)) continue;
384
386
  if (still) this._absolutizeChild(child, x, y, overlay);
385
387
  else child.absolutize(x, y);
386
388
  }
package/src/node.d.ts CHANGED
@@ -545,8 +545,21 @@ export declare class Node {
545
545
  * coordinates and inside this node. The return says whether the frame is
546
546
  * still a blit candidate; the gates that matter close at frame time, and
547
547
  * every one of them falls back to repainting `rect`.
548
+ *
549
+ * `riders` are other nodes whose pixels in `rect` are part of what moves —
550
+ * a layer of widgets the element lays out over its own drawing and pans
551
+ * with it. Each is to move by exactly (dx, dy) in this frame's layout pass
552
+ * and change nothing else; then it does not decline the blit, and only
553
+ * what it leaves or reaches outside `rect` is repainted, so keep what it
554
+ * can paint inside the element (a box that clips it). Anything else it
555
+ * does is claimed as usual, which declines the blit.
548
556
  */
549
- scrollContents(rect: Rect, dx: number, dy: number): boolean;
557
+ scrollContents(
558
+ rect: Rect,
559
+ dx: number,
560
+ dy: number,
561
+ riders?: Iterable<Node>,
562
+ ): boolean;
550
563
  /**
551
564
  * Did anything this node draws change? True damages the whole node, false
552
565
  * contributes no damage at all, and the default answers true for any prop
@@ -5,9 +5,54 @@
5
5
  import { Yoga } from '../yoga.js';
6
6
  import { callHandler } from '../errors.js';
7
7
  import { DAMAGE_SLOP, layoutDiff } from './damage.js';
8
- import { insetRect } from './rects.js';
8
+ import { insetRect, intersectRects, outerPixels } from './rects.js';
9
9
  import { DEV } from './util.js';
10
10
 
11
+ /** The parts of `rect` outside `hole`: up to four bands, full width above
12
+ * and below, the rest beside. */
13
+ function outside(rect, hole) {
14
+ const cut = intersectRects(rect, hole);
15
+ if (!cut) return [rect];
16
+ const out = [];
17
+ const right = rect.x + rect.width;
18
+ const bottom = rect.y + rect.height;
19
+ if (cut.y > rect.y) {
20
+ out.push({
21
+ x: rect.x,
22
+ y: rect.y,
23
+ width: rect.width,
24
+ height: cut.y - rect.y,
25
+ });
26
+ }
27
+ const cutBottom = cut.y + cut.height;
28
+ if (cutBottom < bottom) {
29
+ out.push({
30
+ x: rect.x,
31
+ y: cutBottom,
32
+ width: rect.width,
33
+ height: bottom - cutBottom,
34
+ });
35
+ }
36
+ if (cut.x > rect.x) {
37
+ out.push({
38
+ x: rect.x,
39
+ y: cut.y,
40
+ width: cut.x - rect.x,
41
+ height: cut.height,
42
+ });
43
+ }
44
+ const cutRight = cut.x + cut.width;
45
+ if (cutRight < right) {
46
+ out.push({
47
+ x: cutRight,
48
+ y: cut.y,
49
+ width: right - cutRight,
50
+ height: cut.height,
51
+ });
52
+ }
53
+ return out;
54
+ }
55
+
11
56
  export const MEASURE_MODES = [];
12
57
  MEASURE_MODES[Yoga.MEASURE_MODE_UNDEFINED] = 'unconstrained';
13
58
  MEASURE_MODES[Yoga.MEASURE_MODE_EXACTLY] = 'exactly';
@@ -198,20 +243,200 @@ export class NodeLayout {
198
243
  // its own — counts as on screen too
199
244
  this._placed = true;
200
245
  if (!this.yoga) return;
201
- this._assignAbs(
202
- originX + this.yoga.getComputedLeft(),
203
- originY + this.yoga.getComputedTop(),
204
- this.yoga.getComputedWidth(),
205
- this.yoga.getComputedHeight(),
206
- );
207
- if (this.props.onLayout) this._reportLayout();
208
- if (this._host !== null) {
209
- this._absolutizeHostChildren();
246
+ const x = originX + this.yoga.getComputedLeft();
247
+ const y = originY + this.yoga.getComputedTop();
248
+ const width = this.yoga.getComputedWidth();
249
+ const height = this.yoga.getComputedHeight();
250
+ const wasX = this.abs.x;
251
+ const wasY = this.abs.y;
252
+ const move = this._beginRigidMove(x, y, width, height);
253
+ try {
254
+ this._assignAbs(x, y, width, height);
255
+ if (this.props.onLayout) this._reportLayout();
256
+ if (this._host !== null) {
257
+ this._absolutizeHostChildren();
258
+ } else {
259
+ const dx = this.abs.x - wasX;
260
+ const dy = this.abs.y - wasY;
261
+ for (const child of this.children) {
262
+ if (child.isWindow || child._followParent(dx, dy)) continue;
263
+ child.absolutize(this.abs.x, this.abs.y);
264
+ }
265
+ }
266
+ } finally {
267
+ if (move) layoutDiff.shift = null;
268
+ }
269
+ if (move) this._claimRigidMove(move);
270
+ }
271
+
272
+ /**
273
+ * A child of a node that moved this pass, which yoga did not lay out
274
+ * again: its box and everything under it sit exactly where the last pass
275
+ * put them relative to their parents, so the whole subtree moves by
276
+ * however far its parent did (`dx`, `dy`) — `_shiftAbs`, the scroll fast
277
+ * path's translation (issue #405), instead of the walk. Answers whether
278
+ * it was handled; false means walk it.
279
+ *
280
+ * The witness is yoga's has-new-layout flag, the one that fast path
281
+ * reads: a pass sets it on every node it lays out and on every child of
282
+ * one, cached or not, so a child it is clear on was not reached, nor was
283
+ * anything under it. Cleared here when it is found set, just before the
284
+ * walk that reads what the pass left — no pass runs in between — so the
285
+ * next move of the same subtree finds it clear. Nothing else pays for
286
+ * it: a child of a node that did not move is walked as it always was,
287
+ * and a scroll pane always is, since its flag is its own (and a scroll
288
+ * moves its children with no pass at all).
289
+ *
290
+ * Walking such a subtree read four numbers out of yoga per node and wrote
291
+ * them back unchanged: the bodies of a graph's cards, fifty widgets deep,
292
+ * on every step of a pan that moved the one box they sit in.
293
+ *
294
+ * A move it rode — the diff's `shift`, where the pixels are carried —
295
+ * claims nothing, as the walk would not have. A move of its own claims
296
+ * where the subtree's pixels were and where they are, which is what the
297
+ * walk's claims came to.
298
+ */
299
+ _followParent(dx, dy) {
300
+ if (dx === 0 && dy === 0) return false;
301
+ const yoga = this.yoga;
302
+ if (!yoga || !this._placed || this.isScroller?.()) return false;
303
+ if (yoga.hasNewLayout()) {
304
+ yoga.markLayoutSeen();
305
+ return false;
306
+ }
307
+ const sink = layoutDiff.sink;
308
+ const shift = layoutDiff.shift;
309
+ if (
310
+ sink &&
311
+ !(shift && shift.x === dx && shift.y === dy) &&
312
+ !this.hidden &&
313
+ this.style?.display !== 'none'
314
+ ) {
315
+ const reach = this.paintBounds();
316
+ // where the walk's `_assignAbs` puts the near end: after the shift a
317
+ // scroll blit is about to make, when one is riding (issue #398)
318
+ sink(
319
+ shift
320
+ ? {
321
+ x: reach.x + shift.x,
322
+ y: reach.y + shift.y,
323
+ width: reach.width,
324
+ height: reach.height,
325
+ }
326
+ : reach,
327
+ this,
328
+ );
329
+ sink(
330
+ {
331
+ x: reach.x + dx,
332
+ y: reach.y + dy,
333
+ width: reach.width,
334
+ height: reach.height,
335
+ },
336
+ this,
337
+ );
338
+ }
339
+ this._shiftAbs(dx, dy);
340
+ return true;
341
+ }
342
+
343
+ /**
344
+ * The start of a move that changed nothing but where this subtree sits:
345
+ * the same size, somewhere else, in a bounded frame's layout diff that no
346
+ * shift is riding already. The walk below then runs with the diff's
347
+ * `shift` set to the move, so every descendant that lands where it was
348
+ * plus the move claims nothing — and one that lands anywhere else claims
349
+ * both ends of its own, as under a scroll blit (issue #398).
350
+ *
351
+ * What the subtree is owed instead is two claims: where its pixels were,
352
+ * and where they went (`_claimRigidMove`). Walked node by node, a moved
353
+ * card of fifty widgets claimed a hundred rects, the frame's cap merged
354
+ * them into their box, and the box of pieces reaching past every clipping
355
+ * ancestor was the whole window — a panned layer of such cards made every
356
+ * step of the pan a full repaint, header and all.
357
+ *
358
+ * Null when the move is not of that kind, or no diff is listening; the
359
+ * walk is then the plain one.
360
+ */
361
+ _beginRigidMove(x, y, width, height) {
362
+ if (!layoutDiff.sink || layoutDiff.shift) return null;
363
+ const old = this.abs;
364
+ if (width !== old.width || height !== old.height) return null;
365
+ if (x === old.x && y === old.y) return null;
366
+ if (!(old.width > 0 && old.height > 0)) return null;
367
+ if (this.hidden || this.style?.display === 'none') return null;
368
+ // a placement moves it again after the walk (src/nodes/position.js)
369
+ if (this.root?._placedNodes?.has(this)) return null;
370
+ const shift = { x: x - old.x, y: y - old.y };
371
+ // before anything under it moves: every pixel it had
372
+ const was = this.paintBounds();
373
+ layoutDiff.shift = shift;
374
+ // Carried by an element's blit (`scrollContents`'s riders), when the
375
+ // move is the blit's own: then the pixels inside its rect are moved
376
+ // for it, and only what lies outside is owed.
377
+ const contents = this._ridesBlitOf?._pendingBlitContents;
378
+ const carried =
379
+ contents && contents.dx === shift.x && contents.dy === shift.y
380
+ ? contents.rect
381
+ : null;
382
+ return { sink: layoutDiff.sink, was, shift, carried };
383
+ }
384
+
385
+ /**
386
+ * Both ends of a rigid move, each clipped to what the clipping ancestors
387
+ * let reach the surface. The far end is the near one moved: every
388
+ * descendant that rode the move is inside it, and one that did not has
389
+ * claimed where it went itself.
390
+ *
391
+ * Named for the node that moved, which is what sends each claim to the
392
+ * lists that paint it (`_paneReach`): the panes alone inside a
393
+ * `<glarea>`, both for a subtree holding one.
394
+ */
395
+ _claimRigidMove({ sink, was, shift, carried }) {
396
+ const root = this.root;
397
+ const to = {
398
+ x: was.x + shift.x,
399
+ y: was.y + shift.y,
400
+ width: was.width,
401
+ height: was.height,
402
+ };
403
+ if (carried) {
404
+ // Straight to the window's list rather than through the diff, whose
405
+ // claims read as "this frame is not a pure scroll": what is outside
406
+ // the rect is beside it, and the blit stands.
407
+ const cap = root._damageRectCap();
408
+ for (const end of [was, to]) {
409
+ const clipped = this._clippedByAncestors(end);
410
+ if (!clipped) continue;
411
+ for (const piece of outside(clipped, carried)) {
412
+ root._claimLayoutMove(piece, this, cap);
413
+ }
414
+ }
210
415
  return;
211
416
  }
212
- for (const child of this.children) {
213
- if (!child.isWindow) child.absolutize(this.abs.x, this.abs.y);
417
+ const from = this._clippedByAncestors(was);
418
+ if (from) sink(from, this);
419
+ const at = this._clippedByAncestors(to);
420
+ if (at) sink(at, this);
421
+ }
422
+
423
+ /**
424
+ * `rect` less whatever the ancestors that clip their children cut away,
425
+ * up to the window: pixels outside one never reach the surface, so a
426
+ * claim of them repaints what cannot change. Null when nothing is left.
427
+ * Each clip is the whole pixels the ancestor's box touches — its clip
428
+ * antialiases a fractional edge over the pixel it cuts, and on a box on
429
+ * whole pixels cuts exactly; a clip it skips is one nothing inside
430
+ * reaches (`_childrenCanOverflow`).
431
+ */
432
+ _clippedByAncestors(rect) {
433
+ let out = rect;
434
+ for (let n = this.parent; n && out; n = n.parent) {
435
+ if (n.isWindow) break;
436
+ if (!n.clipsChildren()) continue;
437
+ out = intersectRects(out, outerPixels(n.abs));
214
438
  }
439
+ return out;
215
440
  }
216
441
 
217
442
  /**
package/src/nodes/node.js CHANGED
@@ -140,6 +140,9 @@ export class Node {
140
140
  // palette with one against the app's own — and none of those is a
141
141
  // *change* the user saw, so no transition may start from it.
142
142
  this._placed = false;
143
+ // the element whose pending `scrollContents` carries this node's pixels
144
+ // this frame (src/nodes/scrollblit.js), or null
145
+ this._ridesBlitOf = null;
143
146
  // the loops this node's style declares, whether or not they are running
144
147
  this._loops = null;
145
148
  // `resolvedTextStyle()`'s cache: this node's own text style over what it
@@ -623,8 +626,12 @@ export class Node {
623
626
  // subtree here, before and after, would be the repaint the overlay
624
627
  // exists to skip — and the pass claims both ends of the move itself
625
628
  // whenever the overlay cannot move the pixels.
629
+ //
630
+ // …and so is a node an element's pan carries (`scrollContents`'s
631
+ // riders): its pixels move with the region the frame blits, and the
632
+ // pass claims what they leave outside it (src/nodes/layout.js).
626
633
  if (
627
- this.parent?.isGlArea &&
634
+ (this.parent?.isGlArea || this._ridesBlitOf !== null) &&
628
635
  moved &&
629
636
  !this.paintChanged(newProps, prev)
630
637
  ) {
@@ -34,6 +34,19 @@ export function innerPixels(rect) {
34
34
  return { x, y, width: right - x, height: bottom - y };
35
35
  }
36
36
 
37
+ /** The whole pixels `rect` touches — a fractional edge taken whole, which is
38
+ * where antialiasing can put ink. */
39
+ export function outerPixels(rect) {
40
+ const x = Math.floor(rect.x);
41
+ const y = Math.floor(rect.y);
42
+ return {
43
+ x,
44
+ y,
45
+ width: Math.ceil(rect.x + rect.width) - x,
46
+ height: Math.ceil(rect.y + rect.height) - y,
47
+ };
48
+ }
49
+
37
50
  /** The overlap of two rects, or null when they have none. */
38
51
  export function intersectRects(a, b) {
39
52
  const x = Math.max(a.x, b.x);
@@ -144,14 +144,28 @@ export const Scrollable = (Base) =>
144
144
  absolutize(originX, originY) {
145
145
  this._placed = true;
146
146
  if (!this.yoga) return;
147
- this._assignAbs(
148
- originX + this.yoga.getComputedLeft(),
149
- originY + this.yoga.getComputedTop(),
150
- this.yoga.getComputedWidth(),
151
- this.yoga.getComputedHeight(),
152
- );
153
- if (this.props.onLayout) this._reportLayout();
154
- this._absolutizeChildren(this.abs.x, this.abs.y);
147
+ const x = originX + this.yoga.getComputedLeft();
148
+ const y = originY + this.yoga.getComputedTop();
149
+ const width = this.yoga.getComputedWidth();
150
+ const height = this.yoga.getComputedHeight();
151
+ // a box that moved and changed nothing else claims where it was and
152
+ // where it went, once — not once per node inside it (`Node`'s)
153
+ const wasX = this.abs.x;
154
+ const wasY = this.abs.y;
155
+ const move = this._beginRigidMove(x, y, width, height);
156
+ try {
157
+ this._assignAbs(x, y, width, height);
158
+ if (this.props.onLayout) this._reportLayout();
159
+ this._absolutizeChildren(
160
+ this.abs.x,
161
+ this.abs.y,
162
+ this.abs.x - wasX,
163
+ this.abs.y - wasY,
164
+ );
165
+ } finally {
166
+ if (move) layoutDiff.shift = null;
167
+ }
168
+ if (move) this._claimRigidMove(move);
155
169
  }
156
170
 
157
171
  /**
@@ -159,8 +173,11 @@ export const Scrollable = (Base) =>
159
173
  * Split out of `absolutize` because a `<window>` writes its own `abs`
160
174
  * during flush and then walks its children from (0, 0) — the same walk,
161
175
  * reached by a different route.
176
+ *
177
+ * `dx`/`dy` is how far this box moved in this pass, which is how far a
178
+ * child yoga did not lay out again moves with it (`_followParent`).
162
179
  */
163
- _absolutizeChildren(originX, originY) {
180
+ _absolutizeChildren(originX, originY, dx = 0, dy = 0) {
164
181
  if (!this.isScroller()) {
165
182
  // a layout host's children go where its algorithm put them
166
183
  if (this._host !== null) {
@@ -168,7 +185,8 @@ export const Scrollable = (Base) =>
168
185
  return;
169
186
  }
170
187
  for (const child of this.children) {
171
- if (!child.isWindow) child.absolutize(originX, originY);
188
+ if (child.isWindow || child._followParent(dx, dy)) continue;
189
+ child.absolutize(originX, originY);
172
190
  }
173
191
  return;
174
192
  }
@@ -20,6 +20,7 @@ import {
20
20
  cornerSquaresOverlap,
21
21
  rectsOverlap,
22
22
  } from './rects.js';
23
+ import { isPaintedColor } from './boxpaint.js';
23
24
  import { scrollbarTrackRect } from './scrollbars.js';
24
25
  import { debugPaint } from './window/debugpaint.js';
25
26
 
@@ -336,11 +337,22 @@ export class NodeScrollBlit {
336
337
  * and claims it as ordinary damage. Those claims land beside the rect and
337
338
  * the frame stays a blit.
338
339
  *
340
+ * `riders` are the other way round: nodes that are not this one whose
341
+ * pixels in `rect` are part of what moves — a layer of widgets an element
342
+ * lays out over its own drawing and pans with it (a graph's node bodies).
343
+ * Each is to move by exactly (dx, dy) in this frame's layout pass and to
344
+ * change nothing else. Doing that, it does not decline the blit by being
345
+ * over the rect, its commit claims nothing, and its move claims only
346
+ * what it leaves or reaches outside the rect (`_claimRigidMove`) — so
347
+ * keep what it can paint inside the element's box, a box that clips it
348
+ * being the way. Doing anything else, it is claimed like any other node,
349
+ * which declines the blit; the rect then repaints, riders and all.
350
+ *
339
351
  * Returns whether the frame is still a blit candidate. The real answer
340
352
  * arrives as `paintDamage()`, because most of the gates cannot be decided
341
353
  * until the frame closes; a `false` here is only the ones that can.
342
354
  */
343
- scrollContents(rect, dx, dy) {
355
+ scrollContents(rect, dx, dy, riders) {
344
356
  const root = this.root;
345
357
  if (
346
358
  !root ||
@@ -403,6 +415,13 @@ export class NodeScrollBlit {
403
415
  // coalescing scrollTo gets from recording an origin
404
416
  state.dx += dx;
405
417
  state.dy += dy;
418
+ if (riders) {
419
+ for (const rider of riders) {
420
+ if (!rider || rider === this || rider.destroyed) continue;
421
+ (state.riders ??= new Set()).add(rider);
422
+ rider._ridesBlitOf = this;
423
+ }
424
+ }
406
425
  this._pendingBlitFrom ??= BLIT_CONTENTS;
407
426
  (root._pendingScrolls ??= new Set()).add(this);
408
427
  // ...and the claim about to be recorded is the shift itself, not a
@@ -426,15 +445,34 @@ export class NodeScrollBlit {
426
445
  * own, leave `_blitKeptDamage` a damage rect many times the viewport to
427
446
  * refuse. Null when the clip left nothing.
428
447
  *
429
- * Only while a blit is pending — outside that this is `paintBounds()` and
430
- * one property read. Clipping every claim to every clipping ancestor
431
- * would be correct too, and is a bigger change than the frame this is
432
- * about.
448
+ * And clipped to every other clipping ancestor too, for the same reason
449
+ * without the blit: a claim past an `overflow: 'hidden'` box repaints
450
+ * pixels nothing inside it can reach. A layer of cards panned inside a
451
+ * clipped graph pane reaches past the pane on every side, and its
452
+ * claims, unclipped, were the whole window.
433
453
  */
434
454
  _claimBounds() {
435
455
  const bounds = this.paintBounds();
436
456
  const sv = this._blitViewport();
437
- return sv ? intersectRects(bounds, sv.paintBounds()) : bounds;
457
+ const clipped = sv ? intersectRects(bounds, sv.paintBounds()) : bounds;
458
+ return clipped && this._clippedByAncestors(clipped);
459
+ }
460
+
461
+ /**
462
+ * Whether this node puts no pixels of its own on the surface — a plain
463
+ * box with no fill, border, shadow or outline, holding what is under it
464
+ * and nothing more. Conservative: anything but a box answers false.
465
+ */
466
+ _paintsNothingOwn() {
467
+ if (this.kind !== 'box' || this.isScroller?.()) return false;
468
+ const style = this.style ?? EMPTY_STYLE;
469
+ if (isPaintedColor(style.backgroundColor) || style.backgroundImage) {
470
+ return false;
471
+ }
472
+ if (style.opacity !== undefined && style.opacity < 1) return false;
473
+ if (this._outlineExtent() > 0 || this._shadowExtent() > 0) return false;
474
+ const bw = resolveBorderWidths(style, this.direction);
475
+ return !(bw.top > 0 || bw.right > 0 || bw.bottom > 0 || bw.left > 0);
438
476
  }
439
477
 
440
478
  /** The scroll container above this node that is waiting to blit, if there
@@ -473,6 +511,10 @@ export class WindowScrollBlit {
473
511
  const contents = nodes[0]._pendingBlitContents;
474
512
  const ledger = nodes[0]._blitLedger;
475
513
  for (const n of nodes) {
514
+ // the pass that needed them is over: a rider is a rider for a frame
515
+ for (const rider of n._pendingBlitContents?.riders ?? []) {
516
+ if (rider._ridesBlitOf === n) rider._ridesBlitOf = null;
517
+ }
476
518
  n._pendingBlitFrom = null;
477
519
  n._pendingBlitContents = null;
478
520
  n._blitLedger = null;
@@ -801,7 +843,13 @@ export class WindowScrollBlit {
801
843
  * balloon back towards the whole viewport. With no bars the L is two
802
844
  * disjoint rects and stays two.
803
845
  */
804
- _applyContentsBlit(node, { rect: vp, dx, dy }, width, height, target) {
846
+ _applyContentsBlit(
847
+ node,
848
+ { rect: vp, dx, dy, riders },
849
+ width,
850
+ height,
851
+ target,
852
+ ) {
805
853
  // only a whole-pixel shift of a whole-pixel region is a copy
806
854
  if (!isIntegerRect(vp)) return;
807
855
  if (!Number.isInteger(dx) || !Number.isInteger(dy)) return;
@@ -870,7 +918,7 @@ export class WindowScrollBlit {
870
918
  ) {
871
919
  return;
872
920
  }
873
- if (!this._scrollBlitSafe(node, shifted, target.top)) return;
921
+ if (!this._scrollBlitSafe(node, shifted, target.top, riders)) return;
874
922
  // the element's deltas are already how far the pixels moved, the sense
875
923
  // scrollRegion takes (0 + x rather than x: a caller's -0 would survive
876
924
  // into request buffers and test comparisons)
@@ -954,7 +1002,7 @@ export class WindowScrollBlit {
954
1002
  * only its own children — what the window draws under the surface was
955
1003
  * never in the pane's pixels.
956
1004
  */
957
- _scrollBlitSafe(scroller, vp, top = this) {
1005
+ _scrollBlitSafe(scroller, vp, top = this, riders = null) {
958
1006
  // the window itself scrolls: everything inside it *is* the scrolled
959
1007
  // content, so there is nothing that could be dragged along
960
1008
  if (scroller === this) return true;
@@ -1012,7 +1060,14 @@ export class WindowScrollBlit {
1012
1060
  // be dragged along — a pulsing toast over a list is what promotion
1013
1061
  // is for, and this is the half of it that keeps the pan a blit.
1014
1062
  if (child._promoted) continue;
1015
- if (rectsOverlap(child._subtreeBounds(), vp)) return false;
1063
+ // …and a node the blit carries has pixels that move with it
1064
+ // (`scrollContents`'s riders)
1065
+ if (riders?.has(child)) continue;
1066
+ if (!rectsOverlap(child._subtreeBounds(), vp)) continue;
1067
+ // A box that paints nothing of its own is its children, and as
1068
+ // safe as they are: the box that clips a rider to its element.
1069
+ if (child._paintsNothingOwn() && check(child)) continue;
1070
+ return false;
1016
1071
  }
1017
1072
  return true;
1018
1073
  };
@@ -154,6 +154,25 @@ class Win32TextLayout {
154
154
  return this._native.layoutCaret(this._handle, this._cuOf(cp));
155
155
  }
156
156
 
157
+ /**
158
+ * How much of each device pixel the glyphs cover, one byte a pixel,
159
+ * without drawing them anywhere (#673) — for text that is drawn where a 2D
160
+ * context is not, a GL surface's labels. The raster is the layout's box in
161
+ * whole pixels with `pad` round it, the layout's origin at (pad, pad).
162
+ *
163
+ * DirectWrite's own glyph-run analysis rather than a surface read back:
164
+ * grayscale, grid fit off, and none of the gamma or enhanced contrast
165
+ * Direct2D gives text it blends — the outlines' coverage, which is what a
166
+ * distance field is made from. Null on a bridge that predates it
167
+ * (`@windowkit/win32` before `layoutCoverage`), so a caller keeps its
168
+ * readback.
169
+ */
170
+ coverage({ pad = 0 } = {}) {
171
+ if (this._handle == null) return null;
172
+ if (typeof this._native.layoutCoverage !== 'function') return null;
173
+ return this._native.layoutCoverage(this._handle, pad) ?? null;
174
+ }
175
+
157
176
  _cuOf(cp) {
158
177
  this._offsets ??= codeUnitOffsets(this._text);
159
178
  const at = Math.max(0, Math.min(this._offsets.length - 1, cp));