react-x11 2.5.0 → 2.6.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/src/nodes.js CHANGED
@@ -250,6 +250,10 @@ const NO_DAMAGE = Symbol('no-damage');
250
250
  // against it.
251
251
  const DAMAGE_SLOP = 1;
252
252
 
253
+ /** Stale bounds are undebuggable, and every other cache here has an escape
254
+ * hatch — see NO_SCROLL_BLIT and the paint cache's DISABLED. */
255
+ const NO_BOUNDS_CACHE = process.env.REACT_X11_NO_BOUNDS_CACHE === '1';
256
+
253
257
  // While a bounded frame's layout pass runs, every node whose absolute rect
254
258
  // comes out different reports its old and new rects here (each already
255
259
  // inflated by that node's own paint reach) — which is what lets a layout
@@ -279,6 +283,7 @@ const INVALIDATE_REASONS = new Set([
279
283
  'outline', // …and the same for an outline a style swap took away
280
284
  'theme', // a theme/token change restyled a subtree
281
285
  'direction', // the reading direction moved: sides, glyph order, bar edge
286
+ 'scale', // a `scale` prop zoomed a subtree: every length in it moved
282
287
  'animation', // a transition frame
283
288
  'scroll', // scrollTo/scrollBy/scrollIntoView, textarea/textinput panning
284
289
  'text', // text content, input value or caret editing
@@ -405,7 +410,7 @@ function insetRect(rect, by) {
405
410
  }
406
411
 
407
412
  /** The overlap of two rects, or null when they have none. */
408
- function intersectRects(a, b) {
413
+ export function intersectRects(a, b) {
409
414
  const x = Math.max(a.x, b.x);
410
415
  const y = Math.max(a.y, b.y);
411
416
  const right = Math.min(a.x + a.width, b.x + b.width);
@@ -548,13 +553,13 @@ function coalesceRects(rects) {
548
553
  * neighbours go first and far-apart rects last. That merge can itself overlap a
549
554
  * third rect, so the result is coalesced again.
550
555
  */
551
- function addDamageRect(rects, add) {
556
+ export function addDamageRect(rects, add, cap = MAX_DAMAGE_RECTS) {
552
557
  let out = coalesceRects(
553
558
  (rects ?? []).concat([
554
559
  { x: add.x, y: add.y, width: add.width, height: add.height },
555
560
  ]),
556
561
  );
557
- while (out.length > MAX_DAMAGE_RECTS) {
562
+ while (out.length > cap) {
558
563
  let bestI = 0;
559
564
  let bestJ = 1;
560
565
  let bestWaste = Infinity;
@@ -587,7 +592,7 @@ function addDamageRect(rects, add) {
587
592
  * answers cover every claimed pixel, so this is a cost decision and never a
588
593
  * correctness one.
589
594
  */
590
- function damageToPaint(rects) {
595
+ export function damageToPaint(rects) {
591
596
  if (rects.length < 2) return rects;
592
597
  const box = rectsBounds(rects);
593
598
  let sum = 0;
@@ -946,6 +951,9 @@ function assertWindowSize(props, kind) {
946
951
  * pass run with no height on offer the row came out at nothing, taking its
947
952
  * leaves down with it. A container in that position is recovered by looking
948
953
  * inside it; a leaf has nothing inside, which is what this is for.
954
+ *
955
+ * Only the leaves the measurement is going to ask about: the walk goes
956
+ * through the nodes whose height extent is stale and no others.
949
957
  */
950
958
  function captureLeafHeights(node, out) {
951
959
  let leaf = true;
@@ -953,7 +961,7 @@ function captureLeafHeights(node, out) {
953
961
  if (!child.yoga || child.isWindow) continue;
954
962
  if (child.style.display === 'none') continue;
955
963
  if (child.style.position !== 'absolute') leaf = false;
956
- captureLeafHeights(child, out);
964
+ if (child._floorH === undefined) captureLeafHeights(child, out);
957
965
  }
958
966
  if (leaf) out.set(node, node.yoga.getComputedHeight());
959
967
  }
@@ -991,7 +999,7 @@ function namesOwnFloor(node, axis) {
991
999
  * answer, so there is no need to look inside it. Everything that names a
992
1000
  * floor, plus the two other ways a style can bound itself: a **size**, which
993
1001
  * a min-content measurement here keeps rather than shrinking past (see
994
- * `writeContentFloors`), and a **ceiling**, since CSS clamps the content
1002
+ * `writeFloors`), and a **ceiling**, since CSS clamps the content
995
1003
  * suggestion by the specified `max-*` too.
996
1004
  */
997
1005
  function declaresOwnMinimum(node, axis) {
@@ -1024,6 +1032,144 @@ const mainAxisOf = (node) => {
1024
1032
  : 'height';
1025
1033
  };
1026
1034
 
1035
+ /**
1036
+ * Whether `child` is one the floors are **written on** along `axis`: a flex
1037
+ * item on its container's main axis whose author left the minimum to the
1038
+ * content. The same test `writeFloors` applies, asked ahead of time — it is
1039
+ * what decides whether a stale extent is one anybody will read.
1040
+ */
1041
+ function receivesFloor(child, axis) {
1042
+ const parent = child.parent;
1043
+ if (!parent || mainAxisOf(parent) !== axis || !inFlow(child)) return false;
1044
+ const own = axis === 'width' ? 'minWidth' : 'minHeight';
1045
+ return typeof child.style[own] !== 'number';
1046
+ }
1047
+
1048
+ /**
1049
+ * Find what the last measurement can no longer answer for, before a layout
1050
+ * pass clears the evidence.
1051
+ *
1052
+ * The floors are content, and yoga already keeps the exact record of which
1053
+ * content moved: a style setter that changed something, a child that came
1054
+ * or went, a text that asked to be re-measured all mark their node dirty
1055
+ * and every node above it, and the next `calculateLayout` clears the lot.
1056
+ * So this walks the dirty part of the tree — and only that part, since a
1057
+ * clean node has clean children — and takes the cached extents off every
1058
+ * node it finds (`_floorW`/`_floorH`), which is what "stale" means from
1059
+ * here on. The dirty nodes are listed for `writeFloors`: they are the
1060
+ * nodes whose children's floors can have moved — as is, later, every node
1061
+ * `contentSpan` measures a child of, since a window's natural-size
1062
+ * measurement (`_measure`) clears yoga's record before the first floors
1063
+ * pass, and a scroll pane's rows measured through a clean scroll pane still
1064
+ * need their floors written.
1065
+ *
1066
+ * `found` records whether any stale node is one a floor is written on: if
1067
+ * none is, no measurement is owed on that axis at all, whatever changed —
1068
+ * the extents that moved are ones nobody reads. An extent nobody read
1069
+ * stays unmeasured, on a clean node, for as long as nobody does; it is
1070
+ * asked for at the one moment it can start to matter, which is when its
1071
+ * parent changes (a column that turns into a row), and its parent is dirty
1072
+ * then. So the children of a dirty node are all looked at, and only the
1073
+ * dirty ones are descended into.
1074
+ *
1075
+ * Until the first floors pass has settled both axes (`sweep`), the walk
1076
+ * also goes down through nodes that were never measured, dirty or not: a
1077
+ * window's natural-size measurement (`_measure`) lays the tree out before
1078
+ * the first floors pass and clears yoga's record on the way, and a scroll
1079
+ * pane that names its own minimum receives no floor while the rows inside
1080
+ * it do. After that pass every reachable node has an extent on any axis
1081
+ * that had a floor to write, and the dirty path is the whole story.
1082
+ *
1083
+ * A `display: 'none'` subtree is marked and left: it takes part in no
1084
+ * layout, and a change inside it is still there when it is shown again,
1085
+ * because yoga clears a hidden node's own flag but never its children's.
1086
+ */
1087
+ function collectFloorStale(node, stale, found, sweep) {
1088
+ for (const child of node.children) {
1089
+ if (!child.yoga || child.isWindow) continue;
1090
+ const dirty = child.yoga.isDirty();
1091
+ if (dirty) {
1092
+ child._floorW = undefined;
1093
+ child._floorH = undefined;
1094
+ // the minimum yoga holds may not be ours any more: a style change on
1095
+ // this node went through `applyLayoutStyle`, which writes the
1096
+ // author's minimum over whatever floor was there
1097
+ child._floorMinW = null;
1098
+ child._floorMinH = null;
1099
+ }
1100
+ if (child.style.display === 'none') continue;
1101
+ if (child._floorW === undefined && receivesFloor(child, 'width')) {
1102
+ found.width = true;
1103
+ }
1104
+ if (child._floorH === undefined && receivesFloor(child, 'height')) {
1105
+ found.height = true;
1106
+ }
1107
+ if (dirty) stale.add(child);
1108
+ else if (
1109
+ !sweep ||
1110
+ (child._floorW !== undefined && child._floorH !== undefined)
1111
+ ) {
1112
+ continue;
1113
+ }
1114
+ collectFloorStale(child, stale, found, sweep);
1115
+ }
1116
+ }
1117
+
1118
+ /**
1119
+ * After a pass that settled the widths: which of the height floors, each a
1120
+ * height *for a width*, were measured for a width their node no longer has.
1121
+ *
1122
+ * Walks down through every node whose width moved — a clean subtree whose
1123
+ * root is still the width it was measured at holds no surprises, so the
1124
+ * walk stops there — and asks each leaf it reaches the one question that
1125
+ * matters, in JavaScript rather than through a layout pass: is your height
1126
+ * at this width the height you had at the old one? A paragraph that still
1127
+ * fits on its line says no, and so does every unwrapped label in a grid
1128
+ * whose cells just moved a pixel, which is what makes a relayout of a large
1129
+ * tree one pass instead of four. A leaf that wraps differently is marked
1130
+ * stale with everything above it, and `hit.owed` says whether any of those
1131
+ * is a node a floor is written on.
1132
+ *
1133
+ * Widths are compared with a pixel of slack: the measuring passes run with
1134
+ * the pixel grid off and the real one with it on, so the same layout reads
1135
+ * a fraction apart between them.
1136
+ */
1137
+ function probeHeightFloors(node, root, hit) {
1138
+ for (const child of node.children) {
1139
+ if (!child.yoga || child.isWindow) continue;
1140
+ if (child.style.display === 'none') continue;
1141
+ const stale = child._floorH === undefined;
1142
+ const width = child.yoga.getComputedWidth();
1143
+ const at = child._floorAtW;
1144
+ if (!stale && at !== undefined && Math.abs(width - at) < 1) continue;
1145
+ if (child._measureFn) {
1146
+ if (
1147
+ !stale &&
1148
+ at !== undefined &&
1149
+ child._heightForWidth(width) !== child._heightForWidth(at)
1150
+ ) {
1151
+ markHeightStale(child, root, hit);
1152
+ }
1153
+ } else {
1154
+ probeHeightFloors(child, root, hit);
1155
+ }
1156
+ // whatever the extent is, it is the extent for this width now — a
1157
+ // stale one is about to be measured here, a clean one was just checked
1158
+ child._floorAtW = width;
1159
+ }
1160
+ }
1161
+
1162
+ /** A leaf whose height moved takes every extent above it with it. */
1163
+ function markHeightStale(node, root, hit) {
1164
+ hit.marked = true;
1165
+ for (let n = node; n && n !== root; n = n.parent) {
1166
+ if (n._floorH === undefined) continue;
1167
+ n._floorH = undefined;
1168
+ root._floorsStale.add(n);
1169
+ if (receivesFloor(n, 'height')) hit.owed = true;
1170
+ }
1171
+ }
1172
+
1027
1173
  /**
1028
1174
  * Put the tree in the state a **min-content** measurement means: a node that
1029
1175
  * has said how small it can be is let go all the way down to it, and a node
@@ -1034,27 +1180,38 @@ const mainAxisOf = (node) => {
1034
1180
  * own `flexShrink: 0` default. Now that the default is CSS's `1` (#249) the
1035
1181
  * pass has to be told, or every node would shrink to nothing and answer that
1036
1182
  * the content needs no room — which is true of no content anywhere.
1183
+ *
1184
+ * Every child of a node being measured is told, so that the node's own
1185
+ * layout is the one it always was; below a child whose extent is still
1186
+ * good nothing is, since nothing in there is read. `out` collects what was
1187
+ * written so `restoreShrink` can put back exactly that.
1037
1188
  */
1038
- function setMeasuringShrink(node, axis) {
1189
+ function setMeasuringShrink(node, axis, out) {
1039
1190
  for (const child of node.children) {
1040
1191
  if (!child.yoga || child.isWindow) continue;
1041
- child.yoga.setFlexShrink(namesOwnFloor(child, axis) ? 1 : 0);
1042
1192
  // …and not into a `display: 'none'` subtree, which the measurement does
1043
- // not read and `writeContentFloors` therefore does not walk back through
1193
+ // not read and the floors are not written back through
1044
1194
  if (child.style.display === 'none') continue;
1045
- setMeasuringShrink(child, axis);
1195
+ const shrink = namesOwnFloor(child, axis) ? 1 : 0;
1196
+ if ((child.style.flexShrink ?? 1) !== shrink) {
1197
+ child.yoga.setFlexShrink(shrink);
1198
+ out.push(child);
1199
+ }
1200
+ if (floorStale(child, axis)) setMeasuringShrink(child, axis, out);
1046
1201
  }
1047
1202
  }
1048
1203
 
1049
1204
  /** …and back to the layout everything else is run from. */
1050
- function restoreShrink(node) {
1051
- for (const child of node.children) {
1052
- if (!child.yoga || child.isWindow) continue;
1205
+ function restoreShrink(shrunk) {
1206
+ for (const child of shrunk) {
1053
1207
  child.yoga.setFlexShrink(child.style.flexShrink ?? 1);
1054
- restoreShrink(child);
1055
1208
  }
1056
1209
  }
1057
1210
 
1211
+ /** Whether `node`'s extent along `axis` has to be measured again. */
1212
+ const floorStale = (node, axis) =>
1213
+ (axis === 'width' ? node._floorW : node._floorH) === undefined;
1214
+
1058
1215
  /**
1059
1216
  * Pin every box under `node` at the width the pass just settled it at, so
1060
1217
  * that the collapse which follows can only take **height** away.
@@ -1080,27 +1237,25 @@ function restoreShrink(node) {
1080
1237
  * else the pass is doing, and its children are laid out inside that. It costs
1081
1238
  * nothing either: every leaf is offered the width it was already measured at,
1082
1239
  * so the paragraphs the first pass shaped come back out of the layout cache.
1240
+ *
1241
+ * Pinned as far as the measurement reads, like the shrink: the children of
1242
+ * every node whose extent is being measured, and no further.
1083
1243
  */
1084
- function freezeWidths(node) {
1244
+ function freezeWidths(node, out) {
1085
1245
  for (const child of node.children) {
1086
1246
  if (!child.yoga || child.isWindow) continue;
1087
1247
  // as in `setMeasuringShrink`: a `display: 'none'` subtree was not laid
1088
1248
  // out, so there is no width in there to keep
1089
1249
  if (child.style.display === 'none') continue;
1090
1250
  child.yoga.setWidth(child.yoga.getComputedWidth());
1091
- freezeWidths(child);
1251
+ out.push(child);
1252
+ if (child._floorH === undefined) freezeWidths(child, out);
1092
1253
  }
1093
1254
  }
1094
1255
 
1095
- /** …and back to the width the style asks for. Walks what `freezeWidths`
1096
- * walked, so a box it never pinned is never written to either. */
1097
- function restoreWidths(node) {
1098
- for (const child of node.children) {
1099
- if (!child.yoga || child.isWindow) continue;
1100
- if (child.style.display === 'none') continue;
1101
- child.yoga.setWidth(child.style.width);
1102
- restoreWidths(child);
1103
- }
1256
+ /** …and back to the width the style asks for, on exactly what was pinned. */
1257
+ function restoreWidths(frozen) {
1258
+ for (const child of frozen) child.yoga.setWidth(child.style.width);
1104
1259
  }
1105
1260
 
1106
1261
  /**
@@ -1124,16 +1279,23 @@ function restoreWidths(node) {
1124
1279
  * needs it. A container that came out at nothing is recovered by looking
1125
1280
  * inside it; a leaf has nothing inside, so it has to be remembered.
1126
1281
  *
1127
- * `out` collects, for every node on the way, **the extent it contributes to
1128
- * the box around it**which is exactly that node's automatic minimum size,
1129
- * so one pass and one walk give the whole tree its floors (#249) instead of
1130
- * a measurement per node. The recursion is therefore over all the children,
1131
- * even the ones whose own content does not count towards this one's: a
1132
- * scroll pane contributes nothing to the floor above it and still needs
1133
- * floors written *inside* it, or the column of rows it holds would shrink to
1134
- * the viewport and there would be nothing left to scroll.
1282
+ * What every node contributes to the box around it which is exactly that
1283
+ * node's automatic minimum size — is written onto the node (`_floorW`,
1284
+ * `_floorH`), so one pass and one walk give the whole tree its floors (#249)
1285
+ * instead of a measurement per node, and so that the next measurement can
1286
+ * **read it back instead of looking again**: a child whose extent is still
1287
+ * there is taken at that number, and nothing under it is visited. The
1288
+ * extent is a function of the subtree alone its content and its styles —
1289
+ * which is what makes the cache honest: `collectFloorStale` takes it off
1290
+ * every node whose subtree changed, and for a height the width it was
1291
+ * measured at is kept beside it (`_floorAtW`) for `probeHeightFloors` to
1292
+ * check. The recursion is therefore over the stale children only, and
1293
+ * still through the ones whose own content does not count towards this
1294
+ * one's: a scroll pane contributes nothing to the floor above it and still
1295
+ * needs floors written *inside* it, or the column of rows it holds would
1296
+ * shrink to the viewport and there would be nothing left to scroll.
1135
1297
  */
1136
- function contentSpan(node, axis, intrinsic, out) {
1298
+ function contentSpan(node, axis, intrinsic, root) {
1137
1299
  const yoga = node.yoga;
1138
1300
  const horizontal = axis === 'width';
1139
1301
  const own = horizontal ? yoga.getComputedWidth() : yoga.getComputedHeight();
@@ -1153,19 +1315,31 @@ function contentSpan(node, axis, intrinsic, out) {
1153
1315
  // by itself, and a <text> span has no box of its own
1154
1316
  if (!child.yoga || child.isWindow) continue;
1155
1317
  if (child.style.display === 'none') continue;
1156
- const span = contentSpan(child, axis, intrinsic, out);
1157
1318
  const laidOut = horizontal
1158
1319
  ? child.yoga.getComputedWidth()
1159
1320
  : child.yoga.getComputedHeight();
1160
- // What the child needs from this box. A node that has said how small it
1161
- // can be is taken at its word — the measuring pass already let it shrink
1162
- // to exactly that and anything else is asked what is inside it. Its
1163
- // laid-out size is deliberately *not* a floor under that answer: nothing
1164
- // shrank in this pass, so a box that measures its own content is sitting
1165
- // at its **max**-content size, which is the width a label would like to
1166
- // be rather than the width it can be squeezed to.
1167
- const extent = declaresOwnMinimum(child, axis) ? laidOut : span;
1168
- out?.set(child, extent);
1321
+ let extent = horizontal ? child._floorW : child._floorH;
1322
+ if (extent === undefined) {
1323
+ const span = contentSpan(child, axis, intrinsic, root);
1324
+ // What the child needs from this box. A node that has said how small
1325
+ // it can be is taken at its word the measuring pass already let it
1326
+ // shrink to exactly that and anything else is asked what is inside
1327
+ // it. Its laid-out size is deliberately *not* a floor under that
1328
+ // answer: nothing shrank in this pass, so a box that measures its own
1329
+ // content is sitting at its **max**-content size, which is the width
1330
+ // a label would like to be rather than the width it can be squeezed
1331
+ // to.
1332
+ extent = declaresOwnMinimum(child, axis) ? laidOut : span;
1333
+ if (horizontal) child._floorW = extent;
1334
+ else {
1335
+ child._floorH = extent;
1336
+ child._floorAtW = child.yoga.getComputedWidth();
1337
+ }
1338
+ root._floorsMeasured += 1;
1339
+ // a fresh extent is a floor to write, whether or not this node was
1340
+ // dirty: see `collectFloorStale`
1341
+ root._floorsStale.add(node);
1342
+ }
1169
1343
  if (child.style.position === 'absolute') continue;
1170
1344
  const at =
1171
1345
  (horizontal
@@ -1211,9 +1385,8 @@ function contentSpan(node, axis, intrinsic, out) {
1211
1385
  }
1212
1386
 
1213
1387
  /**
1214
- * Write CSS's **automatic minimum size** onto every flex item under `node`:
1215
- * a floor of the extent it needs, along the axis its container lays out on,
1216
- * and restore the `flexShrink` the measurement borrowed on the way past.
1388
+ * Write CSS's **automatic minimum size** onto the flex items in `node`: a
1389
+ * floor of the extent each needs, along the axis its container lays out on.
1217
1390
  *
1218
1391
  * This is the other half of `flexShrink` defaulting to `1` (#249), and
1219
1392
  * neither half is any good without the other. Yoga implements the shrink and
@@ -1226,7 +1399,10 @@ function contentSpan(node, axis, intrinsic, out) {
1226
1399
  *
1227
1400
  * Only the **main** axis, as in CSS: shrinking happens along the axis the
1228
1401
  * container packs on, and on the other one an item is stretched or fits its
1229
- * content either way.
1402
+ * content either way. On the other axis the child gets its style's minimum
1403
+ * back — which is what takes a floor off a child whose container turned
1404
+ * from a column into a row, since the floor it needs now is on the other
1405
+ * axis.
1230
1406
  *
1231
1407
  * Where this deliberately parts company with CSS is a node that named a
1232
1408
  * size: CSS floors that at `min(the size, the content)`, so a `height: 40`
@@ -1238,10 +1414,14 @@ function contentSpan(node, axis, intrinsic, out) {
1238
1414
  * that was named is a size that is kept**, and `minHeight: 0` is how an
1239
1415
  * author says otherwise.
1240
1416
  *
1241
- * `mins` is what every node contributes to the box around it, measured in
1242
- * one pass by `contentSpan`. `floored` collects what was written so the next
1243
- * measurement can take it back off — a floor left in place would be read
1244
- * back as content that cannot give, and could then only ratchet upwards.
1417
+ * One node's children, from the extents on them (`contentSpan`): the
1418
+ * callers run it over every node whose children's extents may have moved,
1419
+ * and nothing else — a floor that did not change is not written again, so a
1420
+ * clean subtree is neither visited nor dirtied. What was written is kept
1421
+ * (`_floorMinW`/`_floorMinH`) so that the next write can tell; a stale
1422
+ * extent writes the style's own minimum, which is how a floor comes off a
1423
+ * node that is about to be measured, and which is why measuring a node
1424
+ * that carries one cannot read it back as content the tree cannot give up.
1245
1425
  *
1246
1426
  * A floor is written **unrounded**, and the measurement it came from ran
1247
1427
  * with the pixel grid off (`measuringExactly`) for the reason given there:
@@ -1256,28 +1436,29 @@ function contentSpan(node, axis, intrinsic, out) {
1256
1436
  * tall (issue #411). Whole pixels cancel exactly, which is why the text
1257
1437
  * measures here answer in them (`TextNode._trim`).
1258
1438
  */
1259
- function writeContentFloors(node, axis, mins, floored) {
1439
+ function writeFloors(node, axis) {
1440
+ const horizontal = axis === 'width';
1260
1441
  const axisIsMain = mainAxisOf(node) === axis;
1261
- const own = axis === 'width' ? 'minWidth' : 'minHeight';
1442
+ const own = horizontal ? 'minWidth' : 'minHeight';
1262
1443
  for (const child of node.children) {
1263
1444
  if (!child.yoga || child.isWindow) continue;
1264
- child.yoga.setFlexShrink(child.style.flexShrink ?? 1);
1265
- if (child.style.display === 'none') continue;
1445
+ let floor;
1266
1446
  // A floor of 0 is what yoga does anyway, and an author who named their
1267
1447
  // own `minWidth`/`minHeight` has already answered — overwriting it would
1268
1448
  // put a measurement of ours above a number they wrote.
1269
- const floor = mins.get(child) ?? 0;
1270
- if (
1271
- axisIsMain &&
1272
- inFlow(child) &&
1273
- floor > 0 &&
1274
- typeof child.style[own] !== 'number'
1275
- ) {
1276
- if (axis === 'width') child.yoga.setMinWidth(floor);
1277
- else child.yoga.setMinHeight(floor);
1278
- floored.add(child);
1449
+ if (axisIsMain && inFlow(child) && typeof child.style[own] !== 'number') {
1450
+ const extent = horizontal ? child._floorW : child._floorH;
1451
+ if (extent > 0) floor = extent;
1452
+ }
1453
+ if ((horizontal ? child._floorMinW : child._floorMinH) === floor) continue;
1454
+ const value = floor ?? child.style[own];
1455
+ if (horizontal) {
1456
+ child.yoga.setMinWidth(value);
1457
+ child._floorMinW = floor;
1458
+ } else {
1459
+ child.yoga.setMinHeight(value);
1460
+ child._floorMinH = floor;
1279
1461
  }
1280
- writeContentFloors(child, axis, mins, floored);
1281
1462
  }
1282
1463
  }
1283
1464
 
@@ -1432,6 +1613,46 @@ const WINDOW_SEMANTIC_NAMES = new Set([
1432
1613
  * are not: an element that wrote `widthMode === 0` would be pinned to
1433
1614
  * yoga's ABI through us, which is exactly what the seam exists to stop.
1434
1615
  */
1616
+ /**
1617
+ * Text smaller than this many logical pixels is painted as a strip in its
1618
+ * ink instead of as glyphs (`TextNode._paintsStrip`). Six is under the
1619
+ * smallest size any UI sets on purpose and over what a zoomed-out view
1620
+ * shrinks its labels to; `createRoot({ textStripBelow })` is the seam.
1621
+ */
1622
+ export const TEXT_STRIP_BELOW = 6;
1623
+ // The band a strip covers, in ems around the baseline — the x-height and a
1624
+ // little of the ascenders above it, the descenders below — and the share of
1625
+ // the ink it is painted at, which is roughly how much of that band small
1626
+ // text actually inks.
1627
+ const STRIP_ABOVE_BASELINE = 0.6;
1628
+ const STRIP_BELOW_BASELINE = 0.1;
1629
+ const STRIP_COVERAGE = 0.45;
1630
+
1631
+ const textStripBelow = new WeakMap();
1632
+
1633
+ /** The size under which this connection's text is painted as strips. */
1634
+ export function setTextStripBelow(app, below) {
1635
+ if (below === undefined) return;
1636
+ if (typeof below !== 'number' || !(below >= 0)) {
1637
+ throw new Error(
1638
+ `react-x11: createRoot({ textStripBelow: ${JSON.stringify(below)} }) ` +
1639
+ '— a size in logical pixels, or 0 to paint glyphs at every size.',
1640
+ );
1641
+ }
1642
+ textStripBelow.set(app, below);
1643
+ }
1644
+
1645
+ // `REACT_X11_TEXT_STRIP_BELOW` is the same line for a process that cannot
1646
+ // reach `createRoot` — a bench comparing the strip against glyphs, an app
1647
+ // run under a harness — read once
1648
+ const textStripBelowEnv = Number(process.env.REACT_X11_TEXT_STRIP_BELOW);
1649
+
1650
+ const textStripBelowFor = (app) =>
1651
+ textStripBelow.get(app) ??
1652
+ (Number.isFinite(textStripBelowEnv) && textStripBelowEnv >= 0
1653
+ ? textStripBelowEnv
1654
+ : TEXT_STRIP_BELOW);
1655
+
1435
1656
  const MEASURE_MODES = [];
1436
1657
  MEASURE_MODES[Yoga.MEASURE_MODE_UNDEFINED] = 'unconstrained';
1437
1658
  MEASURE_MODES[Yoga.MEASURE_MODE_EXACTLY] = 'exactly';
@@ -1502,6 +1723,33 @@ function shallowEqual(a, b) {
1502
1723
  return ka.length === kb.length && ka.every((k) => a[k] === b[k]);
1503
1724
  }
1504
1725
 
1726
+ /**
1727
+ * A node's own `scale` prop as a factor: what it multiplies the scale it
1728
+ * inherits by (`Node.scale`). Absent means 1, which is every node in a tree
1729
+ * that never mentions it.
1730
+ *
1731
+ * A zero, a negative or a NaN is a mistake rather than a design — it would
1732
+ * lay the subtree out at nothing, or at infinity — and in development it
1733
+ * says so where the mistake is, rather than as a blank pane three frames
1734
+ * later. Production falls back to 1 for the same reason a bad token keeps
1735
+ * the property dropped: a GUI that carries on is worth more than one that
1736
+ * dies on a fraction somebody divided by.
1737
+ */
1738
+ function scaleFactorOf(props, kind) {
1739
+ const own = props?.scale;
1740
+ if (own === undefined) return 1;
1741
+ if (typeof own === 'number' && Number.isFinite(own) && own > 0) return own;
1742
+ if (DEV) {
1743
+ throw new Error(
1744
+ `react-x11: <${kind} scale={${JSON.stringify(own)}}> — a subtree ` +
1745
+ 'scale is a positive number, the factor this subtree is zoomed by ' +
1746
+ '(2 draws it twice the size, 0.5 half), and leaving it out means 1. ' +
1747
+ 'See docs/scale.md, "A subtree of its own".',
1748
+ );
1749
+ }
1750
+ return 1;
1751
+ }
1752
+
1505
1753
  /** The half of `Node._joinsYoga` that is about the child alone — a real X
1506
1754
  * window (`<window>`, `<popup>`) or a node built without a box at all (a text
1507
1755
  * chunk) sits outside whatever parent it lands in. This is what
@@ -1515,16 +1763,93 @@ export class Node {
1515
1763
  }
1516
1764
 
1517
1765
  /**
1518
- * Device pixels per logical pixel for this node's connection resolved
1519
- * once by `createRoot` (src/scale.js) and constant for the node's life,
1520
- * which is what makes the instance cache below sound. `this.style` and
1521
- * `this.abs` are already device pixels; this is for the values that never
1522
- * pass through a style — a paint constant like the caret's width, or an
1523
- * event coordinate on its way back to logical. A registered element that
1524
- * draws with its own constants multiplies them by this.
1766
+ * Device pixels per logical pixel **for this node**the display scale
1767
+ * `createRoot` resolved (src/scale.js), times every `scale` prop between
1768
+ * this node and its window. `this.style` and `this.abs` are already
1769
+ * device pixels; this is for the values that never pass through a style —
1770
+ * a paint constant like the caret's width, or an event coordinate on its
1771
+ * way back to logical. A registered element that draws with its own
1772
+ * constants multiplies them by this.
1773
+ *
1774
+ * The `scale` prop is CSS `zoom`, not a transform: it multiplies the
1775
+ * inherited factor, and the node's *own* style scales with it. So
1776
+ * everything downstream of the style funnel follows with no second
1777
+ * mechanism — yoga lays out the scaled numbers like any others, paint
1778
+ * reads the scaled style, the caret and the scrollbar read this getter,
1779
+ * and text is shaped at the size it will be drawn at rather than
1780
+ * rasterized once and stretched (docs/scale.md, "A subtree of its own").
1781
+ *
1782
+ * **A real X window is its own root.** `<window>` and `<popup>` geometry
1783
+ * is the server's, in the display's pixels — `scaleWindowGeometry` reads
1784
+ * `scaleOf(app)` directly and a WM sees no zoom — so the cascade stops
1785
+ * there and a menu opened from a zoomed card comes up at the app's own
1786
+ * size. Everything else inherits, including `<glarea>` and `<foreign>`,
1787
+ * whose boxes are laid out by the parent like any other child's.
1788
+ *
1789
+ * Cached per node, dropped by `_rescaleSubtree` — the same contract
1790
+ * `theme` and `direction` have, for the same reason.
1525
1791
  */
1526
1792
  get scale() {
1527
- return (this._scaleCache ??= scaleOf(this.app));
1793
+ if (this._scaleCache !== undefined) return this._scaleCache;
1794
+ if (this.isWindow) return (this._scaleCache = scaleOf(this.app));
1795
+ const base = this.parent ? this.parent.scale : scaleOf(this.app);
1796
+ return (this._scaleCache = base * scaleFactorOf(this.props, this.kind));
1797
+ }
1798
+
1799
+ /**
1800
+ * The effective scale of this node moved — its own `scale` prop changed,
1801
+ * or it was attached under an ancestor whose scale is not the one it
1802
+ * resolved against while detached.
1803
+ *
1804
+ * Everything below inherits, so the whole subtree is restyled; the early
1805
+ * out is the answer not having moved, which is what makes the call at
1806
+ * each of the attach sites free in the overwhelmingly common case of a
1807
+ * tree with no `scale` prop in it at all.
1808
+ *
1809
+ * `mounting` is `insertBefore` attaching a subtree that has never been in
1810
+ * the tree, and means the same thing it means to `_themeChanged`: resolve
1811
+ * everything, claim nothing (issue #402). A node that has never painted
1812
+ * has no stale pixels to cover, and where it lands is claimed by the
1813
+ * child-list protocol.
1814
+ */
1815
+ _rescaleSubtree(mounting = false) {
1816
+ if (!this._rescaleMoved()) return;
1817
+ // One claim for the whole walk: `_invalidateLayout` bounds the subtree
1818
+ // as it stands and queues the after-layout claim, so the per-node
1819
+ // repeats the recursion below would make are the same rect over again.
1820
+ if (!mounting) this._invalidateLayout('scale');
1821
+ this._rescaled(mounting);
1822
+ }
1823
+
1824
+ /** Drop the cached scale and re-ask; true when the answer moved. The
1825
+ * getter is the only place the rule lives, so a `<window>` — which
1826
+ * resolves the display scale whatever it is written inside — answers
1827
+ * false here and the walk stops at it. */
1828
+ _rescaleMoved() {
1829
+ const before = this._scaleCache;
1830
+ this._scaleCache = undefined;
1831
+ return this.scale !== before;
1832
+ }
1833
+
1834
+ /** …the walk itself, for a node whose scale is already known to have
1835
+ * moved. Restyle in the new unit, then carry it down: an ordinary
1836
+ * descendant's scale is a product of this one, so it moved too. */
1837
+ _rescaled(mounting) {
1838
+ // both hold a size in device pixels at the old scale
1839
+ this._textBase = undefined;
1840
+ this._textScaled = null;
1841
+ const prevStyle = this.style;
1842
+ const style = this._syncStyle(this.props, mounting);
1843
+ if (this.yoga && style !== prevStyle) {
1844
+ applyLayoutStyle(this.yoga, style, prevStyle);
1845
+ }
1846
+ if (localTextStyleChanged(style, prevStyle)) this._textContentChanged();
1847
+ // its own claims are bounded, so this runs on a mount too — the same
1848
+ // rule `_themeChanged` follows
1849
+ this._retext();
1850
+ for (const child of this.children) {
1851
+ if (child._rescaleMoved()) child._rescaled(mounting);
1852
+ }
1528
1853
  }
1529
1854
 
1530
1855
  constructor(kind, props, app, { yoga = true } = {}) {
@@ -1547,6 +1872,20 @@ export class Node {
1547
1872
  // that a second mutation reuses the rect instead of walking the subtree
1548
1873
  // again. Lives exactly as long as membership in `root._reflowed`.
1549
1874
  this._reflowBefore = null;
1875
+ // The automatic minimum size (#249), cached per node: what this node
1876
+ // contributes to the box around it on each axis (`contentSpan`), the
1877
+ // width the height was measured for (`probeHeightFloors`), and the
1878
+ // floor yoga currently holds from us on each axis (`writeFloors`).
1879
+ // `undefined` on an extent means it has to be measured again; `null`
1880
+ // on a written floor means yoga's minimum may not be ours any more.
1881
+ this._floorW = undefined;
1882
+ this._floorH = undefined;
1883
+ this._floorAtW = undefined;
1884
+ this._floorMinW = undefined;
1885
+ this._floorMinH = undefined;
1886
+ // the width mode yoga last measured this leaf in with no height on
1887
+ // offer — the question `_heightForWidth` repeats
1888
+ this._floorMeasureMode = null;
1550
1889
  this.root = null; // owning WindowNode once attached
1551
1890
  this.hidden = false;
1552
1891
  this.destroyed = false;
@@ -1579,6 +1918,11 @@ export class Node {
1579
1918
  // inherits. Undefined means "never asked", which is load-bearing — see
1580
1919
  // `_retext`
1581
1920
  this._resolvedText = undefined;
1921
+ // `inheritedTextStyle`'s cache at a *scale boundary* — a node whose
1922
+ // `scale` prop puts it in a different unit from its parent, so the
1923
+ // device size it inherits has to be re-expressed. Null everywhere else,
1924
+ // which is every node in a tree with no `scale` prop in it.
1925
+ this._textScaled = null;
1582
1926
  // `direction`'s cache, same contract as `_resolvedText`'s: undefined means
1583
1927
  // "never asked", which is what lets `_redirectSubtree` stop at a node
1584
1928
  // nothing below has resolved through
@@ -1762,6 +2106,10 @@ export class Node {
1762
2106
  this.style = this._anim?.size
1763
2107
  ? { ...target, ...this._animatedValues() }
1764
2108
  : target;
2109
+ // The paint reach reads the style now in force — a shadow's spread, an
2110
+ // outline's width — so it is dropped on every swap, here, before the
2111
+ // old-extent claims below measure the new reach against the old one.
2112
+ this._clearPaintBounds();
1765
2113
  // `hitSlop` feeds the cached hit reach and `overflow` decides where its
1766
2114
  // invalidation walks stop, so a swap that changes either clears here —
1767
2115
  // the one funnel every style path goes through. Animation ticks never
@@ -2014,6 +2362,9 @@ export class Node {
2014
2362
  setStyleState(name, on) {
2015
2363
  if (this.states[name] === on) return;
2016
2364
  this.states[name] = on;
2365
+ // the focus ring's reach is read off `:focus-visible` whether or not
2366
+ // the node has a state block of its own (`_outlineExtent`)
2367
+ this._clearPaintBounds();
2017
2368
  if (!this._stateful || this.destroyed) return;
2018
2369
  const next = scaleResolvedStyle(
2019
2370
  resolveComputedStyle(resolveStyleStates(this._baseStyle, this.states)),
@@ -2194,7 +2545,26 @@ export class Node {
2194
2545
  * `insertBefore` re-resolves the subtree against the real one.
2195
2546
  */
2196
2547
  get inheritedTextStyle() {
2197
- if (this.parent) return this.parent.resolvedTextStyle();
2548
+ if (this.parent) {
2549
+ const inherited = this.parent.resolvedTextStyle();
2550
+ // A **scale boundary** — this node's `scale` prop, or a `<popup>`
2551
+ // written inside a zoomed subtree, which goes back to the display's
2552
+ // own unit. What comes down the cascade is already device pixels at
2553
+ // the parent's scale (that is the point of resolving the theme's size
2554
+ // once, at the root), so re-expressing it here is the only place a
2555
+ // second multiply is right: a `fontSize: 14` theme inside a
2556
+ // `scale={2}` box is 28 logical, 28 device at 1x, and the descendants
2557
+ // below inherit that without compounding it again.
2558
+ const from = this.parent.scale;
2559
+ const to = this.scale;
2560
+ if (from === to) return inherited;
2561
+ const size = (inherited.size * to) / from;
2562
+ const cached = this._textScaled;
2563
+ if (cached?.from !== inherited || cached.style.size !== size) {
2564
+ this._textScaled = { from: inherited, style: { ...inherited, size } };
2565
+ }
2566
+ return this._textScaled.style;
2567
+ }
2198
2568
  const theme = this.theme;
2199
2569
  const color = theme.text;
2200
2570
  // A palette can reach a node as a bare `theme` **prop** rather than a
@@ -2469,6 +2839,24 @@ export class Node {
2469
2839
  this.yoga.setMeasureFunc(measure);
2470
2840
  }
2471
2841
 
2842
+ /**
2843
+ * The height this leaf takes at `width` with nothing bounding its height
2844
+ * — the answer yoga gets from the measuring pass that settles the height
2845
+ * floors, asked directly. `probeHeightFloors` asks it twice, for the width
2846
+ * a leaf was measured at and the one it has now, to find out whether a
2847
+ * relayout that moved the leaf changed what it needs; a paragraph answers
2848
+ * from its layout cache, and the elements whose height is not a function
2849
+ * of their width at all answer at once.
2850
+ */
2851
+ _heightForWidth(width) {
2852
+ return this.measureContent({
2853
+ width,
2854
+ height: Infinity,
2855
+ widthMode: this._floorMeasureMode ?? 'at-most',
2856
+ heightMode: 'unconstrained',
2857
+ })?.height;
2858
+ }
2859
+
2472
2860
  /**
2473
2861
  * Hand `measureContent` to layout, translated: the modes arrive as words,
2474
2862
  * an axis with no bound arrives as `Infinity` rather than as yoga's null,
@@ -2477,6 +2865,9 @@ export class Node {
2477
2865
  */
2478
2866
  _useMeasureContent() {
2479
2867
  this._setMeasureFunc((width, widthMode, height, heightMode) => {
2868
+ if (heightMode === Yoga.MEASURE_MODE_UNDEFINED) {
2869
+ this._floorMeasureMode = MEASURE_MODES[widthMode];
2870
+ }
2480
2871
  const size = this.measureContent({
2481
2872
  width: measureOffer(width, widthMode),
2482
2873
  height: measureOffer(height, heightMode),
@@ -2626,6 +3017,9 @@ export class Node {
2626
3017
  // it can see its ancestors now, so any token in its style can resolve.
2627
3018
  // With no theme anywhere there is nothing to resolve and nothing to walk
2628
3019
  if (this.theme || child.props.theme) child._themeChanged(mounting);
3020
+ // …and the same for the scale: a subtree styled while detached resolved
3021
+ // against the app's, and only now can see the `scale` props above it.
3022
+ child._rescaleSubtree(mounting);
2629
3023
  this._textContentChanged();
2630
3024
  this._childListChanged(before);
2631
3025
  a11yHooks.attached?.(this, child);
@@ -2818,6 +3212,11 @@ export class Node {
2818
3212
  const themeChanged = newProps.theme !== prev.theme;
2819
3213
  this.props = newProps;
2820
3214
  if (themeChanged) this._themeChanged();
3215
+ // Ahead of the `_syncStyle` below, because the funnel that runs
3216
+ // multiplies by `this.scale` and this is what makes it read the new
3217
+ // factor. The walk restyles this node too, so the call after it hits
3218
+ // the identity check and costs nothing twice.
3219
+ if (newProps.scale !== prev.scale) this._rescaleSubtree();
2821
3220
  const style = this._syncStyle(newProps);
2822
3221
  let layoutChanged = false;
2823
3222
  // hoisted styles hit the identity check and skip the whole update
@@ -3249,6 +3648,9 @@ export class Node {
3249
3648
  * invalid, whose own clear walked the rest of the way up.
3250
3649
  */
3251
3650
  _clearHitBounds() {
3651
+ // what moves a hit reach moves the paint reach — first, because the
3652
+ // walk below returns from wherever it finds an ancestor already clear
3653
+ this._clearPaintBounds();
3252
3654
  this._hitBoundsCache = null;
3253
3655
  for (let n = this.parent; n; n = n.parent) {
3254
3656
  if (n.clipsChildren() || n._hitBoundsCache === null) return;
@@ -3392,6 +3794,13 @@ export class Node {
3392
3794
  const abs = this.abs;
3393
3795
  abs.x += dx;
3394
3796
  abs.y += dy;
3797
+ // the paint reach rides along the same way — unless it *is* `abs`,
3798
+ // which just moved
3799
+ const p = this._paintBoundsCache;
3800
+ if (p && p !== abs) {
3801
+ p.x += dx;
3802
+ p.y += dy;
3803
+ }
3395
3804
  const b = this._hitBoundsCache;
3396
3805
  if (b) {
3397
3806
  b.left += dx;
@@ -3449,6 +3858,10 @@ export class Node {
3449
3858
  // a layout change may grow what an enclosing scroll pane has to scroll,
3450
3859
  // through a route yoga never sees (issue #405)
3451
3860
  if (layoutChanged) this._markScrollMeasureDirty();
3861
+ // a node that says its appearance changed may have changed how far it
3862
+ // reaches — a shadow, an outline, a scene element's ink — so its cached
3863
+ // paint reach goes with the claim
3864
+ if (damage === this || damage === null) this._clearPaintBounds();
3452
3865
  this.root?.invalidate(layoutChanged, damage, reason);
3453
3866
  }
3454
3867
 
@@ -3682,16 +4095,44 @@ export class Node {
3682
4095
  * almost nothing.
3683
4096
  */
3684
4097
  _subtreeBounds() {
4098
+ // Cached like the hit reach (`_hitBounds`), and for the same reason: a
4099
+ // bounded frame asks every subtree on the way to its rect whether it
4100
+ // reaches in, and answering by walking the subtree made a one-cell
4101
+ // repaint cost the whole tree — a millisecond at four thousand nodes,
4102
+ // five at fourteen thousand, every frame. The cache is dropped up the
4103
+ // chain by whatever changes a reach: a rect assigned by layout, a
4104
+ // child list mutation (`_clearHitBounds`), and every change a node
4105
+ // announces about itself (`invalidate`, `setStyleState`) — so a stale
4106
+ // answer would need a change nobody announced, which is already a
4107
+ // repaint bug.
4108
+ const cached = this._paintBoundsCache;
4109
+ if (cached && !NO_BOUNDS_CACHE) return cached;
3685
4110
  let bounds = this._ownPaintBounds();
3686
- if (this.clipsChildren()) return bounds;
3687
- for (const child of this.children) {
3688
- if (child.isWindow || !child.yoga || child.hidden) continue;
3689
- if (child.style?.display === 'none') continue;
3690
- bounds = unionRect(bounds, child._subtreeBounds());
4111
+ if (!this.clipsChildren()) {
4112
+ for (const child of this.children) {
4113
+ if (child.isWindow || !child.yoga || child.hidden) continue;
4114
+ if (child.style?.display === 'none') continue;
4115
+ bounds = unionRect(bounds, child._subtreeBounds());
4116
+ }
3691
4117
  }
4118
+ this._paintBoundsCache = bounds;
3692
4119
  return bounds;
3693
4120
  }
3694
4121
 
4122
+ /**
4123
+ * This node's paint reach changed: drop the cached union here and up the
4124
+ * chain, stopping where `_clearHitBounds` stops and for the same reasons
4125
+ * — a clipping ancestor's reach is its own rect, and an ancestor already
4126
+ * cleared has cleared the rest of the way up.
4127
+ */
4128
+ _clearPaintBounds() {
4129
+ this._paintBoundsCache = null;
4130
+ for (let n = this.parent; n; n = n.parent) {
4131
+ if (n.clipsChildren() || n._paintBoundsCache === null) return;
4132
+ n._paintBoundsCache = null;
4133
+ }
4134
+ }
4135
+
3695
4136
  /**
3696
4137
  * This node's own rect, grown by anything it draws outside it — the
3697
4138
  * outline and the shadow, the only two. Per node rather than once at the
@@ -4443,11 +4884,17 @@ export class Node {
4443
4884
  * format, // 'argb32', or 'a8' for coverage that gets tinted
4444
4885
  * tint, // the colour an 'a8' surface is painted through
4445
4886
  * }
4446
- * paintCached(ctx, box) -> void // draw at the origin of `box`
4887
+ * paintCached(ctx, box, ink) -> void // draw at the origin of `box`
4447
4888
  *
4448
4889
  * Returning null opts out for this frame, which is the right answer
4449
4890
  * whenever the paint depends on something the key cannot see.
4450
4891
  *
4892
+ * `ink` is the colour a mono drawing — one that asked for `'a8'` — must
4893
+ * paint in: white where the surface is coverage and the tint arrives at
4894
+ * blit time, the tint itself on a backend without coverage surfaces,
4895
+ * where the cache bakes the colour into an argb32 entry and puts it in
4896
+ * the key (src/paintcache.js). A multi-colour drawing ignores it.
4897
+ *
4451
4898
  * **The key is the entire correctness surface.** It must name every input
4452
4899
  * `paintCached` reads, derived from the same values `applyProps` compares
4453
4900
  * so the two cannot drift. Never cache a paint that depends on state
@@ -4494,6 +4941,11 @@ export class Node {
4494
4941
  }
4495
4942
 
4496
4943
  _paintChildren(ctx) {
4944
+ // A retained presenter replays a node's `paint` into a visual of that
4945
+ // node's own — its children have visuals of their own, so it sets this
4946
+ // for the duration of the call (src/cocoa/presenter.js, `paintSelf`).
4947
+ // Never set on the X11 or surface paths, where a frame is one walk.
4948
+ if (this._ownPaintOnly) return;
4497
4949
  const order = this.paintOrder();
4498
4950
  if (order.length === 0) return;
4499
4951
  const clip = this.clipsChildren() && this._childrenCanOverflow();
@@ -4995,7 +5447,7 @@ export class TextNode extends Node {
4995
5447
  * that should have cancelled to zero; a fraction that is not exact in
4996
5448
  * binary leaves a rounding residue there instead, and dividing by it laid
4997
5449
  * the section titles of `examples/configurator` out 5.6 billion pixels
4998
- * tall. See `writeContentFloors`, which is the other end of it.
5450
+ * tall. See `writeFloors`, which is the other end of it.
4999
5451
  */
5000
5452
  _trim(layout) {
5001
5453
  if (this.style.textBoxTrim !== 'cap-alphabetic') return null;
@@ -5081,9 +5533,60 @@ export class TextNode extends Node {
5081
5533
  const placed = this._placedLayout();
5082
5534
  if (!placed) return;
5083
5535
  this._paintSelection(ctx);
5536
+ if (this._paintsStrip()) {
5537
+ this._paintStrip(ctx, placed);
5538
+ return;
5539
+ }
5084
5540
  placed.layout.draw(ctx, placed.x, placed.y);
5085
5541
  }
5086
5542
 
5543
+ /**
5544
+ * Whether this paragraph is too small to read, and is painted as a strip
5545
+ * where its lines are instead of as glyphs (`_paintStrip`).
5546
+ *
5547
+ * A zoomed-out view — a minimap, a graph at a tenth of its size, a grid
5548
+ * of five thousand cells — is a screen full of labels nobody can read,
5549
+ * each of which costs a `CTLineDraw` or a glyph-run composite at exactly
5550
+ * the price of a legible one. Below a legible size a label is a smudge
5551
+ * of its ink, and a strip of that ink at the coverage of small text is
5552
+ * the same smudge for one fill. The size is in logical pixels: what is
5553
+ * legible is a physical question, and a 5px label is the same size on a
5554
+ * 2x panel as on a 1x monitor. `textStripBelow` on `createRoot` moves
5555
+ * the line, and `0` keeps glyphs at every size.
5556
+ */
5557
+ _paintsStrip() {
5558
+ const below = textStripBelowFor(this.app);
5559
+ return below > 0 && this.resolvedTextStyle().size / this.scale < below;
5560
+ }
5561
+
5562
+ /**
5563
+ * One rectangle per line, over the band the letters sit in — from a
5564
+ * little above the x-height down past the baseline — in the ink at a
5565
+ * coverage that reads the way small text does: solid ink would be a bar,
5566
+ * and a paragraph is mostly white space at any size.
5567
+ */
5568
+ _paintStrip(ctx, { layout, x, y }) {
5569
+ const lines = layout.lines;
5570
+ if (!lines?.length) return;
5571
+ const style = this.resolvedTextStyle();
5572
+ const em = style.size;
5573
+ const ink = cssColorStraight(style.color);
5574
+ if (!ink) return;
5575
+ const rects = [];
5576
+ for (const line of lines) {
5577
+ if (!(line.width > 0)) continue;
5578
+ rects.push(
5579
+ x + line.x,
5580
+ y + line.baseline - em * STRIP_ABOVE_BASELINE,
5581
+ line.width,
5582
+ em * (STRIP_ABOVE_BASELINE + STRIP_BELOW_BASELINE),
5583
+ );
5584
+ }
5585
+ if (!rects.length) return;
5586
+ ctx.fillStyle = `rgba(${Math.round(ink[0] * 255)}, ${Math.round(ink[1] * 255)}, ${Math.round(ink[2] * 255)}, ${ink[3] * STRIP_COVERAGE})`;
5587
+ ctx.fillRects(rects);
5588
+ }
5589
+
5087
5590
  /** The band under the glyphs, when a document selection reaches this
5088
5591
  * paragraph. Drawn from the same accessors a registered element would use,
5089
5592
  * so the built-in and the custom surface cannot drift apart. */
@@ -6419,7 +6922,7 @@ export class CanvasNode extends Node {
6419
6922
  };
6420
6923
  }
6421
6924
 
6422
- paintCached(ctx, box) {
6925
+ paintCached(ctx, box, ink = '#ffffff') {
6423
6926
  const onDraw = this.props.onDraw;
6424
6927
  if (typeof onDraw !== 'function') return;
6425
6928
  ctx.save();
@@ -6428,8 +6931,9 @@ export class CanvasNode extends Node {
6428
6931
  ctx.clip();
6429
6932
  ctx.translate(box.x, box.y);
6430
6933
  // Into a coverage surface only the alpha of a paint survives and the
6431
- // tint arrives at blit time, so any opaque colour renders the same mask.
6432
- if (this.props.mono) this._presetMono(ctx, '#ffffff');
6934
+ // tint arrives at blit time, so any opaque colour renders the same mask
6935
+ // and the cache says white then, or the tint where it bakes colour.
6936
+ if (this.props.mono) this._presetMono(ctx, ink);
6433
6937
  try {
6434
6938
  onDraw(ctx, {
6435
6939
  width: box.width,
@@ -8509,12 +9013,21 @@ export class WindowNode extends Scrollable(Node) {
8509
9013
  // it moves (see _sendSizeHints for why the size is part of it)
8510
9014
  this._sentHints = null;
8511
9015
  this._sentHintsAt = null;
8512
- // The automatic minimum size (#249): the nodes carrying a floor this
8513
- // window measured, whether the floors are still the answer, and the
8514
- // width the height half of them was measured for.
8515
- this._floored = new Set();
9016
+ // The automatic minimum size (#249): whether the floors are still the
9017
+ // answer, the width the height half of them was measured for, the nodes
9018
+ // this frame found stale (`collectFloorStale`), and two counters the
9019
+ // tests read — layout passes over the root and nodes measured.
8516
9020
  this._floorsDirty = true;
8517
9021
  this._floorsWidth = null;
9022
+ this._floorsStale = new Set();
9023
+ // whether the first floors pass has run (`collectFloorStale`'s sweep)
9024
+ this._floorsSwept = false;
9025
+ this._floorsMeasured = 0;
9026
+ this._layoutPasses = 0;
9027
+ // whether the tree's CONTENT moved since the floors were measured — a
9028
+ // resize alone does not, which is what lets a live resize defer them
9029
+ this._floorsContentDirty = true;
9030
+ this._floorsCatchUp = false;
8518
9031
  }
8519
9032
 
8520
9033
  /**
@@ -8567,7 +9080,19 @@ export class WindowNode extends Scrollable(Node) {
8567
9080
  this.invalidate(true, null, 'direction');
8568
9081
  }
8569
9082
 
8570
- _measureContentSpans(axis, forWidth, out) {
9083
+ /**
9084
+ * One measuring pass over the tree, read back into the extents of every
9085
+ * node still to be measured (`contentSpan`), and the root's own span
9086
+ * returned — the number a `minWidth="auto"` window sends as its hint.
9087
+ *
9088
+ * `forWidth` is the width the heights are measured for; `probe` says
9089
+ * whether the widths that pass settles are still to be checked against
9090
+ * the ones the height floors were measured at (`probeHeightFloors`) —
9091
+ * they are when nothing has looked yet this frame, and a leaf the probe
9092
+ * finds moved has its floor taken off and the pass run again, since a
9093
+ * floor still on a node being measured would be read back as content.
9094
+ */
9095
+ _measureContentSpans(axis, forWidth, probe = false) {
8571
9096
  const yoga = this.yoga;
8572
9097
  const dir = this._rootDirection;
8573
9098
  // The root carries whatever size the last pass pinned on it, and an
@@ -8575,9 +9100,13 @@ export class WindowNode extends Scrollable(Node) {
8575
9100
  yoga.setWidth(undefined);
8576
9101
  yoga.setHeight(undefined);
8577
9102
  if (axis === 'width') {
8578
- setMeasuringShrink(this, axis);
9103
+ const shrunk = [];
9104
+ setMeasuringShrink(this, axis, shrunk);
9105
+ this._layoutPasses += 1;
8579
9106
  yoga.calculateLayout(0, undefined, dir);
8580
- return contentSpan(this, axis, undefined, out);
9107
+ const span = contentSpan(this, axis, null, this);
9108
+ restoreShrink(shrunk);
9109
+ return span;
8581
9110
  }
8582
9111
  // Height takes two passes. The first is the tree at its real width with
8583
9112
  // no bound on the height, which is where every leaf reports the height
@@ -8588,36 +9117,108 @@ export class WindowNode extends Scrollable(Node) {
8588
9117
  // stretches: those are the ones the map above puts back. The widths the
8589
9118
  // first pass settled are held across the second (`freezeWidths`), which
8590
9119
  // is the only thing keeping it a collapse rather than a second opinion.
9120
+ this._layoutPasses += 1;
8591
9121
  yoga.calculateLayout(forWidth, undefined, dir);
9122
+ if (probe && this._probeHeightFloors().marked) {
9123
+ this._writeFloors('height');
9124
+ this._layoutPasses += 1;
9125
+ yoga.calculateLayout(forWidth, undefined, dir);
9126
+ }
8592
9127
  const intrinsic = new Map();
8593
9128
  captureLeafHeights(this, intrinsic);
8594
- freezeWidths(this);
8595
- setMeasuringShrink(this, axis);
9129
+ const frozen = [];
9130
+ freezeWidths(this, frozen);
9131
+ const shrunk = [];
9132
+ setMeasuringShrink(this, axis, shrunk);
9133
+ this._layoutPasses += 1;
8596
9134
  yoga.calculateLayout(forWidth, 0, dir);
8597
- const span = contentSpan(this, axis, intrinsic, out);
8598
- restoreWidths(this);
9135
+ const span = contentSpan(this, axis, intrinsic, this);
9136
+ restoreWidths(frozen);
9137
+ restoreShrink(shrunk);
9138
+ return span;
9139
+ }
9140
+
9141
+ /**
9142
+ * What the last measurement can no longer answer for, taken off the
9143
+ * nodes and listed (`collectFloorStale`). Run ahead of anything that lays
9144
+ * the tree out, since a pass clears yoga's record of what changed — and
9145
+ * cheap enough to run twice in a frame, because the second walk finds the
9146
+ * marks the first one left.
9147
+ */
9148
+ _collectFloorStale() {
9149
+ const found = { width: false, height: false };
9150
+ this._floorsStale.clear();
9151
+ // the root's own children are written from here too, and its direction
9152
+ // can move like any node's
9153
+ this._floorsStale.add(this);
9154
+ collectFloorStale(this, this._floorsStale, found, !this._floorsSwept);
9155
+ return found;
9156
+ }
9157
+
9158
+ /** The floors on the children of every node found stale, from the extents
9159
+ * they carry — `writeFloors` for each. */
9160
+ _writeFloors(axis) {
9161
+ for (const node of this._floorsStale) {
9162
+ if (!node.destroyed) writeFloors(node, axis);
9163
+ }
9164
+ }
9165
+
9166
+ /** `probeHeightFloors` over this window's tree. */
9167
+ _probeHeightFloors() {
9168
+ const hit = { marked: false, owed: false };
9169
+ probeHeightFloors(this, this, hit);
9170
+ return hit;
9171
+ }
9172
+
9173
+ /**
9174
+ * Measure the width extents that are stale and write the width floors
9175
+ * from them. The floors on the nodes about to be measured come off first
9176
+ * (a stale extent writes the style's own minimum), which is what keeps a
9177
+ * floor from ratcheting: read back as content, it could only ever grow.
9178
+ */
9179
+ _measureWidthFloors() {
9180
+ this._writeFloors('width');
9181
+ const span = this._measureContentSpans('width');
9182
+ this._writeFloors('width');
9183
+ return span;
9184
+ }
9185
+
9186
+ /** The same for the heights, at `forWidth`. */
9187
+ _measureHeightFloors(forWidth, probe) {
9188
+ this._writeFloors('height');
9189
+ const span = this._measureContentSpans('height', forWidth, probe);
9190
+ this._writeFloors('height');
8599
9191
  return span;
8600
9192
  }
8601
9193
 
8602
9194
  _measureMinimum(axis, forWidth) {
8603
- // Measured from the styles alone, so the floors this window wrote itself
8604
- // have to come off first: they were derived from this same measurement,
8605
- // and left in place they would be read back as content the tree cannot
8606
- // give up — a floor that could only ever ratchet upwards.
8607
- this._resetContentFloors();
8608
- const min = measuringExactly(() =>
8609
- Math.ceil(this._measureContentSpans(axis, forWidth)),
9195
+ // Measured from the styles alone: the stale nodes' own floors come off
9196
+ // in the measurement, and a clean node's extent was measured the same
9197
+ // way before it was floored.
9198
+ this._collectFloorStale();
9199
+ return measuringExactly(() =>
9200
+ Math.ceil(
9201
+ axis === 'width'
9202
+ ? this._measureWidthFloors()
9203
+ : this._measureHeightFloors(forWidth, true),
9204
+ ),
8610
9205
  );
8611
- restoreShrink(this);
8612
- return min;
9206
+ }
9207
+
9208
+ /** The real layout pass: the tree at the window's size, on the pixel grid. */
9209
+ _layoutRoot(width, height) {
9210
+ this._layoutPasses += 1;
9211
+ this.yoga.setWidth(width);
9212
+ this.yoga.setHeight(height);
9213
+ this.yoga.calculateLayout(width, height, this._rootDirection);
8613
9214
  }
8614
9215
 
8615
9216
  /**
8616
9217
  * Give every flex item in this window's tree the floor CSS calls its
8617
9218
  * automatic minimum size, so that `flexShrink`'s default of `1` squeezes a
8618
9219
  * row into the space it has without squeezing its contents out of
8619
- * existence. See `writeContentFloors` for what that means and why both
8620
- * halves are needed.
9220
+ * existence, and lay the tree out with them. See `writeFloors` for what
9221
+ * that means and why both halves are needed.
8621
9222
  *
8622
9223
  * Two measurements, in this order because they depend that way round: the
8623
9224
  * widths from a pass with no room on offer at all, then — with those floors
@@ -8626,34 +9227,113 @@ export class WindowNode extends Scrollable(Node) {
8626
9227
  *
8627
9228
  * Nothing about this is per frame: the floors are content, so they survive
8628
9229
  * every frame that did not change any (`_floorsDirty`), which is what keeps
8629
- * a wheel notch to the one layout pass it always was.
8630
- */
8631
- _applyContentFloors(width) {
8632
- if (!this._floorsDirty && this._floorsWidth === width) return;
8633
- this._resetContentFloors();
9230
+ * a wheel notch to the one layout pass it always was. And nothing about
9231
+ * it is per node either: every node keeps the extent it was last measured
9232
+ * at, and a measurement re-reads only the nodes whose subtree changed
9233
+ * (`collectFloorStale`), taking the rest at the number they carry. So a
9234
+ * padding change on a container measures the container and nothing
9235
+ * below it, a row that mounts measures itself alone, and a colour change
9236
+ * measures nothing.
9237
+ *
9238
+ * The passes are paid for only where a floor is going to be **written**
9239
+ * from what they find. The width pass runs when a stale node is one on a
9240
+ * row's main axis; and the heights are settled the other way round — the
9241
+ * real layout runs first, `probeHeightFloors` walks the nodes it moved
9242
+ * and asks each leaf whether its height at its new width is the height
9243
+ * it had, and only if one says otherwise (or content changed under a
9244
+ * node a floor is written on) do the two height passes run and the
9245
+ * layout with them. A relayout of a large tree whose labels all still
9246
+ * fit — a panel toggle, a theme switch, a resize that wraps nothing — is
9247
+ * one pass over yoga where it was four.
9248
+ */
9249
+ _applyContentFloors(width, height) {
9250
+ const found = this._collectFloorStale();
8634
9251
  measuringExactly(() => {
8635
- const widths = new Map();
8636
- this._measureContentSpans('width', undefined, widths);
8637
- writeContentFloors(this, 'width', widths, this._floored);
8638
- const heights = new Map();
8639
- this._measureContentSpans('height', width, heights);
8640
- writeContentFloors(this, 'height', heights, this._floored);
9252
+ if (found.width) this._measureWidthFloors();
9253
+ else this._writeFloors('width');
9254
+ // from the extents on hand; a stale one writes the style's minimum,
9255
+ // which is the floor coming off ahead of its measurement below
9256
+ this._writeFloors('height');
8641
9257
  });
9258
+ let heights = found.height;
9259
+ let probed = false;
9260
+ if (!heights) {
9261
+ this._layoutRoot(width, height);
9262
+ heights = this._probeHeightFloors().owed;
9263
+ probed = true;
9264
+ }
9265
+ if (heights) {
9266
+ measuringExactly(() => this._measureHeightFloors(width, !probed));
9267
+ this._layoutRoot(width, height);
9268
+ }
8642
9269
  this._floorsDirty = false;
9270
+ this._floorsContentDirty = false;
9271
+ this._floorsSwept = true;
8643
9272
  this._floorsWidth = width;
8644
9273
  }
8645
9274
 
8646
- /** Take the measured floors back off, leaving each node with whatever its
8647
- * own style says. */
8648
- _resetContentFloors() {
8649
- if (!this._floored.size) return;
8650
- for (const node of this._floored) {
8651
- if (node.destroyed || !node.yoga) continue;
8652
- node.yoga.setMinWidth(node.style.minWidth);
8653
- node.yoga.setMinHeight(node.style.minHeight);
8654
- }
8655
- this._floored.clear();
8656
- this._floorsDirty = true;
9275
+ /**
9276
+ * Answer a live resize with the floors already in hand, and measure fresh
9277
+ * ones once the drag is over.
9278
+ *
9279
+ * The floors were half of a relayout on a large tree — three extra layout
9280
+ * passes and their walks, measured at 21 of a 44ms frame on 3,600 nodes
9281
+ * (`npm run bench:presenters -- --scenario=layout`) before they were
9282
+ * measured incrementally — and a resize is the one layout change they
9283
+ * cannot follow at input rate: AppKit's resize loop calls the frame for
9284
+ * every pointer move, from inside the event, and the next move waits for
9285
+ * the frame. So a tick of a drag lays the tree out against the floors the
9286
+ * last measurement left, which are exact along the main axis (a
9287
+ * min-content width is content, and the content did not move) and a
9288
+ * frame stale for wrapped text along the other, and the frame after the
9289
+ * release measures once and lays out again — "answer the input, then
9290
+ * catch up". Only while the window says it is being resized live
9291
+ * (`liveResizing`, the Cocoa window's reading of AppKit's flag; an X
9292
+ * window has no such thing and takes the measured path every time), only
9293
+ * when the floors exist to reuse, and never over a content change the
9294
+ * floors have not seen — a row that mounted mid-drag has no floor at all,
9295
+ * and no floor is the collapse #249 exists to prevent.
9296
+ */
9297
+ _deferContentFloors(width) {
9298
+ // nothing to measure: `_applyContentFloors` returns at once, and a
9299
+ // catch-up frame would owe nothing
9300
+ if (!this._floorsDirty && this._floorsWidth === width) return false;
9301
+ return (
9302
+ this.window?.liveResizing === true &&
9303
+ this._floorsWidth != null &&
9304
+ !this._floorsContentDirty
9305
+ );
9306
+ }
9307
+
9308
+ /**
9309
+ * The frame a deferred measurement owes: a full relayout with fresh
9310
+ * floors, run on the first frame tick after the live resize ends. One at
9311
+ * a time — a drag is many ticks, and the catch-up waits for the last of
9312
+ * them rather than following each.
9313
+ */
9314
+ _scheduleFloorsCatchUp() {
9315
+ if (this._floorsCatchUp) return;
9316
+ this._floorsCatchUp = true;
9317
+ const schedule =
9318
+ typeof this.window?.requestAnimationFrame === 'function'
9319
+ ? (cb) => this.window.requestAnimationFrame(cb)
9320
+ : (cb) => setImmediate(cb);
9321
+ const run = () => {
9322
+ if (this.destroyed || !this.window) {
9323
+ this._floorsCatchUp = false;
9324
+ return;
9325
+ }
9326
+ // still dragging (a pump tick inside a pause of the drag): wait on
9327
+ if (this.window.liveResizing) {
9328
+ schedule(run);
9329
+ return;
9330
+ }
9331
+ this._floorsCatchUp = false;
9332
+ this._floorsDirty = true;
9333
+ this.invalidate(true, null, 'resize');
9334
+ this.flush();
9335
+ };
9336
+ schedule(run);
8657
9337
  }
8658
9338
 
8659
9339
  /**
@@ -10536,6 +11216,9 @@ export class WindowNode extends Scrollable(Node) {
10536
11216
  // offset applied during `absolutize` and leaves every yoga node exactly
10537
11217
  // as it was, at input rate, on the biggest trees in any app.
10538
11218
  if (reason !== 'scroll') this._floorsDirty = true;
11219
+ if (reason !== 'scroll' && reason !== 'resize') {
11220
+ this._floorsContentDirty = true;
11221
+ }
10539
11222
  }
10540
11223
  // A layout change with no bound named repaints everything, because a
10541
11224
  // reflow can move any node and one that moved leaves stale pixels at a
@@ -10627,7 +11310,7 @@ export class WindowNode extends Scrollable(Node) {
10627
11310
  // as a list of rects rather than one box around them all, so two changes
10628
11311
  // at opposite corners of the window no longer repaint everything
10629
11312
  // between them.
10630
- this._damage = addDamageRect(this._damage, bounds);
11313
+ this._damage = addDamageRect(this._damage, bounds, this._damageRectCap());
10631
11314
  }
10632
11315
  this.needsPaint = true;
10633
11316
  // Recorded before the `_scheduled` gate, not inside it: the debt is
@@ -10673,7 +11356,12 @@ export class WindowNode extends Scrollable(Node) {
10673
11356
  // A window that realized after a loop registered has one now
10674
11357
  if (this._loopNodes.size && !this._loopWatch) this._watchLoops();
10675
11358
  this._advanceAnimations(now());
10676
- if (this.needsLayout) this._refit();
11359
+ if (this.needsLayout) {
11360
+ // before `_refit` lays anything out: a pass clears yoga's record of
11361
+ // which subtrees changed, and the floors are measured from that record
11362
+ this._collectFloorStale();
11363
+ this._refit();
11364
+ }
10677
11365
  const width = this.window.width ?? this._requestedSize?.width ?? 0;
10678
11366
  const height = this.window.height ?? this._requestedSize?.height ?? 0;
10679
11367
  let layoutMoved = false;
@@ -10687,24 +11375,30 @@ export class WindowNode extends Scrollable(Node) {
10687
11375
  // ledger's rect moves with the shift (issue #398).
10688
11376
  this._laidOut = true;
10689
11377
  this._resolveSizeQueries(width, height);
10690
- this._applyContentFloors(width);
10691
- this.yoga.setWidth(width);
10692
- this.yoga.setHeight(height);
10693
- this.yoga.calculateLayout(width, height, this._rootDirection);
11378
+ if (!this._floorsDirty && this._floorsWidth === width) {
11379
+ this._layoutRoot(width, height);
11380
+ } else if (this._deferContentFloors(width)) {
11381
+ this._scheduleFloorsCatchUp();
11382
+ this._layoutRoot(width, height);
11383
+ } else {
11384
+ this._applyContentFloors(width, height);
11385
+ }
10694
11386
  this.abs = { x: 0, y: 0, width, height };
10695
11387
  this._placed = true;
10696
11388
  // the root's rect is written here, not through _assignAbs, so its
10697
11389
  // cached hit reach is dropped here too (children bubble their own)
10698
11390
  this._hitBoundsCache = null;
11391
+ this._paintBoundsCache = null;
10699
11392
  // A bounded frame watches the walk: whatever this pass actually moved
10700
11393
  // claims its old and new rects through the sink, and the frame stays
10701
11394
  // a few rects instead of the whole window. An unbounded frame skips
10702
11395
  // the bookkeeping — it repaints everything anyway.
10703
11396
  if (this._damage !== FULL_DAMAGE) {
11397
+ const cap = this._damageRectCap();
10704
11398
  layoutDiffSink = (rect) => {
10705
11399
  if (this._damage === FULL_DAMAGE) return;
10706
11400
  layoutMoved = true;
10707
- this._damage = addDamageRect(this._damage, rect);
11401
+ this._damage = addDamageRect(this._damage, rect, cap);
10708
11402
  };
10709
11403
  }
10710
11404
  try {
@@ -10736,7 +11430,11 @@ export class WindowNode extends Scrollable(Node) {
10736
11430
  if (sv && !sv._recordBlitClaim(after)) {
10737
11431
  sv._pendingBlitFrom = BLIT_POISONED;
10738
11432
  }
10739
- this._damage = addDamageRect(this._damage, after);
11433
+ this._damage = addDamageRect(
11434
+ this._damage,
11435
+ after,
11436
+ this._damageRectCap(),
11437
+ );
10740
11438
  }
10741
11439
  this._reflowed.clear();
10742
11440
  } else if (this._reflowed.size) {
@@ -11458,6 +12156,19 @@ export class WindowNode extends Scrollable(Node) {
11458
12156
  * has to clear the flag, so it degrades to a full repaint rather than
11459
12157
  * painting nothing.
11460
12158
  */
12159
+ /**
12160
+ * How many rects a frame's damage may hold before `addDamageRect` merges
12161
+ * the closest pair. Four on X11 (`MAX_DAMAGE_RECTS`), where every pass
12162
+ * costs the server a clip mask; a backend whose pass is a client-side
12163
+ * clip and a culled walk says so on its window (`damageRectCap`) and
12164
+ * keeps more of them — a clock, a graph and a status row ticking in one
12165
+ * frame stay three small rects instead of the box around all three.
12166
+ */
12167
+ _damageRectCap() {
12168
+ const cap = this.window?.damageRectCap;
12169
+ return Number.isInteger(cap) && cap > 0 ? cap : MAX_DAMAGE_RECTS;
12170
+ }
12171
+
11461
12172
  _takeDamage(width, height) {
11462
12173
  const damage = this._damage;
11463
12174
  this._damage = null;