react-x11 2.22.0 → 2.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.22.0",
3
+ "version": "2.22.1",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -140,39 +140,59 @@ function coalesceRects(rects) {
140
140
 
141
141
  /**
142
142
  * Add one rect to a capped, disjoint damage list, returning a new list.
143
- *
144
- * Over the cap, the pair whose merge wastes the least area is merged — waste
145
- * being the area the merged rect covers that neither of the two did, so
146
- * neighbours go first and far-apart rects last. That merge can itself overlap a
147
- * third rect, so the result is coalesced again.
148
143
  */
149
144
  export function addDamageRect(rects, add, cap = MAX_DAMAGE_RECTS) {
145
+ return addDamageRects(rects, [add], cap);
146
+ }
147
+
148
+ /**
149
+ * Add several rects to a capped, disjoint damage list at once, returning a
150
+ * new list.
151
+ *
152
+ * Over the cap, the pair whose merge leaves the least area to paint is
153
+ * merged, so neighbours go first and far-apart rects last. The merged rect
154
+ * can overlap a third one, which it then swallows — and the grown rect a
155
+ * fourth — so a pair is priced by the list it leaves once that has run its
156
+ * course, not by the box around the two. Priced by the box alone, the
157
+ * cheapest pair could be the one whose merge cascades: a rounded pane
158
+ * panned left with furniture in both bottom corners merged its zoom
159
+ * controls into a corner's repair, and the box grew through the minimap
160
+ * and the exposed strip into nearly the whole pane — every frame of the
161
+ * pan going one way was a full repaint (issue #691). Where no merge
162
+ * cascades the two prices order the pairs the same way.
163
+ *
164
+ * Everything a caller has to add goes in one call where it can: added one
165
+ * at a time, each rect is squeezed under the cap as it arrives, and a
166
+ * small one arriving last can only join a box an earlier merge already
167
+ * grew. Offered together, the small ones merge with their neighbours
168
+ * first — an element blit's strips, furniture and corner repairs repaint
169
+ * about a fifth of a pane that way where one at a time took half.
170
+ */
171
+ export function addDamageRects(rects, adds, cap = MAX_DAMAGE_RECTS) {
150
172
  let out = coalesceRects(
151
- (rects ?? []).concat([
152
- { x: add.x, y: add.y, width: add.width, height: add.height },
153
- ]),
173
+ (rects ?? []).concat(
174
+ adds.map((r) => ({ x: r.x, y: r.y, width: r.width, height: r.height })),
175
+ ),
154
176
  );
155
177
  while (out.length > cap) {
156
- let bestI = 0;
157
- let bestJ = 1;
158
- let bestWaste = Infinity;
178
+ let best = null;
179
+ let bestArea = Infinity;
159
180
  for (let i = 0; i < out.length; i++) {
160
181
  for (let j = i + 1; j < out.length; j++) {
161
- const waste =
162
- rectArea(unionRect(out[i], out[j])) -
163
- rectArea(out[i]) -
164
- rectArea(out[j]);
165
- if (waste < bestWaste) {
166
- bestWaste = waste;
167
- bestI = i;
168
- bestJ = j;
182
+ const merged = coalesceRects(
183
+ out
184
+ .filter((_, k) => k !== i && k !== j)
185
+ .concat([unionRect(out[i], out[j])]),
186
+ );
187
+ let area = 0;
188
+ for (const r of merged) area += rectArea(r);
189
+ if (area < bestArea) {
190
+ bestArea = area;
191
+ best = merged;
169
192
  }
170
193
  }
171
194
  }
172
- const merged = unionRect(out[bestI], out[bestJ]);
173
- out = coalesceRects(
174
- out.filter((_, k) => k !== bestI && k !== bestJ).concat([merged]),
175
- );
195
+ out = best;
176
196
  }
177
197
  return out;
178
198
  }
@@ -91,12 +91,12 @@ export function rectsBounds(rects) {
91
91
  return out;
92
92
  }
93
93
 
94
- /** Does `rect` reach into any of the four `radius`-sized corner squares of
95
- * `box` — the only part of a rounded border a translation cannot keep? */
96
- export function cornerSquaresOverlap(box, radius, rect) {
94
+ /** The four `radius`-sized corner squares of `box` — the only part of a
95
+ * rounded border a translation cannot keep — none when there is no radius. */
96
+ export function cornerSquares(box, radius) {
97
97
  const r = Math.min(radius, box.width / 2, box.height / 2);
98
- if (!(r > 0)) return false;
99
- const corners = [
98
+ if (!(r > 0)) return [];
99
+ return [
100
100
  { x: box.x, y: box.y, width: r, height: r },
101
101
  { x: box.x + box.width - r, y: box.y, width: r, height: r },
102
102
  { x: box.x, y: box.y + box.height - r, width: r, height: r },
@@ -107,7 +107,14 @@ export function cornerSquaresOverlap(box, radius, rect) {
107
107
  height: r,
108
108
  },
109
109
  ];
110
- return corners.some((square) => rectsOverlap(square, rect));
110
+ }
111
+
112
+ /** Does `rect` reach into any of the four `radius`-sized corner squares of
113
+ * `box`? */
114
+ export function cornerSquaresOverlap(box, radius, rect) {
115
+ return cornerSquares(box, radius).some((square) =>
116
+ rectsOverlap(square, rect),
117
+ );
111
118
  }
112
119
 
113
120
  /**
@@ -8,7 +8,12 @@
8
8
  // viewport, so the fast path can cost correctness nothing.
9
9
 
10
10
  import { EMPTY_STYLE, resolveBorderWidths } from '../styles.js';
11
- import { DAMAGE_SLOP, addDamageRect } from './damage.js';
11
+ import {
12
+ DAMAGE_SLOP,
13
+ MAX_DAMAGE_RECTS,
14
+ addDamageRect,
15
+ addDamageRects,
16
+ } from './damage.js';
12
17
  import {
13
18
  rectContains,
14
19
  isIntegerRect,
@@ -18,7 +23,7 @@ import {
18
23
  intersectRects,
19
24
  unionRect,
20
25
  rectArea,
21
- cornerSquaresOverlap,
26
+ cornerSquares,
22
27
  rectsOverlap,
23
28
  outside,
24
29
  } from './rects.js';
@@ -61,6 +66,15 @@ const SCROLL_BLIT_MIN_KEEP = 0.5;
61
66
  // Past this the blit is buying a shift and paying for the viewport anyway.
62
67
  const SCROLL_BLIT_MAX_REPAINT = 0.75;
63
68
 
69
+ // The rects an element blit may leave its frame to paint: the strips the
70
+ // shift exposed, on top of a frame's usual allowance. A diagonal pan of a
71
+ // rounded pane with furniture in two corners owes two strips, two pieces of
72
+ // furniture and four corner repairs; held to four rects they merged into
73
+ // boxes a third of the pane, and a graph panned diagonally at 41fps on
74
+ // XQuartz and 52 on macOS where two more rects make it 60 and 78 (issue
75
+ // #691). Eight bought nothing past six.
76
+ const CONTENTS_BLIT_MAX_RECTS = MAX_DAMAGE_RECTS + 2;
77
+
64
78
  // A scroll that must not blit this frame: content inside the viewport
65
79
  // already changed (see the arming check in scrollTo, and the claim-time
66
80
  // cancel in WindowNode.invalidate — react-x11#295). Truthy on purpose, so
@@ -214,6 +228,18 @@ function insidePinned(rect, pins) {
214
228
  return false;
215
229
  }
216
230
 
231
+ /** Does every corner square of a rounded `box` that reaches into `vp` lie
232
+ * inside one of `pins` — repaired after the copy rather than dragged along
233
+ * by it (issue #691)? True when none reaches in. */
234
+ function cornersPinned(box, radius, vp, pins) {
235
+ for (const square of cornerSquares(box, radius)) {
236
+ if (!rectsOverlap(square, vp)) continue;
237
+ const inside = intersectRects(outerPixels(square), vp);
238
+ if (inside && !insidePinned(inside, pins)) return false;
239
+ }
240
+ return true;
241
+ }
242
+
217
243
  /** One of `scrollContents`' lists as an array, read once — an iterable may
218
244
  * be a generator — null when it was left out, and undefined when it is not
219
245
  * a list at all. */
@@ -1053,48 +1079,20 @@ export class WindowScrollBlit {
1053
1079
  }
1054
1080
  const keep = this._blitKeptDamage(vp, true, target.damage);
1055
1081
  if (!keep) return;
1056
- // An ancestor's rounded corners reach into the top and bottom rows of
1057
- // the region (a graph pane inside a rounded card is the common shape):
1058
- // those rows do not translate, so they leave the blit and get repainted
1059
- // as bands — the same carve the element does for its own furniture —
1060
- // and the band that shifts is what is left between them.
1061
- const bands = this._cornerBands(node, vp, target.top);
1062
- const shifted =
1063
- bands.top || bands.bottom
1064
- ? {
1065
- x: vp.x,
1066
- y: vp.y + bands.top,
1067
- width: vp.width,
1068
- height: vp.height - bands.top - bands.bottom,
1069
- }
1070
- : vp;
1071
- if (shifted.height <= 0 || Math.abs(dy) >= shifted.height) return;
1072
- if (
1073
- (shifted.width - Math.abs(dx)) * (shifted.height - Math.abs(dy)) <
1074
- area * SCROLL_BLIT_MIN_KEEP
1075
- ) {
1076
- return;
1077
- }
1078
- if (!this._scrollBlitSafe(node, shifted, target.top, riders, pinned)) {
1079
- return;
1080
- }
1081
- let rects = keep;
1082
- if (bands.top) {
1083
- rects = addDamageRect(rects, {
1084
- x: vp.x,
1085
- y: vp.y,
1086
- width: vp.width,
1087
- height: bands.top,
1088
- });
1089
- }
1090
- if (bands.bottom) {
1091
- rects = addDamageRect(rects, {
1092
- x: vp.x,
1093
- y: shifted.y + shifted.height,
1094
- width: vp.width,
1095
- height: bands.bottom,
1096
- });
1097
- }
1082
+ // An ancestor's rounded corners reach into the region (a graph pane
1083
+ // inside a rounded card is the common shape). Their arcs do not
1084
+ // translate, but they live in four small squares and nowhere else, so
1085
+ // they are pinned like the element's own furniture (issue #691): the
1086
+ // whole region shifts, and each square is repaired where it is and where
1087
+ // the copy dragged its image. Carved out as the rows they reach into,
1088
+ // they took the region's full width at the top and at the bottom —
1089
+ // every card and edge in those rows repainted through the rounded clip
1090
+ // on every frame — and next to furniture in two corners, one rect more
1091
+ // than the damage cap has room for.
1092
+ const corners = this._cornerPins(node, vp, target.top);
1093
+ const pins =
1094
+ corners && pinned ? [...pinned, ...corners] : (corners ?? pinned);
1095
+ if (!this._scrollBlitSafe(node, vp, target.top, riders, pins)) return;
1098
1096
  // The strips the shift exposed, on the sides the pixels came from. The
1099
1097
  // horizontal one takes the full width and the vertical one takes what
1100
1098
  // is left, so a diagonal shift claims two rects that do not overlap —
@@ -1103,39 +1101,44 @@ export class WindowScrollBlit {
1103
1101
  const strips = [];
1104
1102
  if (dy !== 0) {
1105
1103
  strips.push({
1106
- x: shifted.x,
1107
- y: dy > 0 ? shifted.y : shifted.y + shifted.height + dy,
1108
- width: shifted.width,
1104
+ x: vp.x,
1105
+ y: dy > 0 ? vp.y : vp.y + vp.height + dy,
1106
+ width: vp.width,
1109
1107
  height: Math.abs(dy),
1110
1108
  });
1111
1109
  }
1112
1110
  if (dx !== 0) {
1113
1111
  strips.push({
1114
- x: dx > 0 ? shifted.x : shifted.x + shifted.width + dx,
1115
- y: dy > 0 ? shifted.y + dy : shifted.y,
1112
+ x: dx > 0 ? vp.x : vp.x + vp.width + dx,
1113
+ y: dy > 0 ? vp.y + dy : vp.y,
1116
1114
  width: Math.abs(dx),
1117
- height: shifted.height - Math.abs(dy),
1115
+ height: vp.height - Math.abs(dy),
1118
1116
  });
1119
1117
  }
1120
- for (const strip of strips) rects = addDamageRect(rects, strip);
1121
- if (pinned) {
1118
+ const pieces = [...strips];
1119
+ if (pins) {
1122
1120
  // Each piece of furniture where it stays, and where the copy put the
1123
1121
  // image of it it dragged along — one box, since a pan step moves it a
1124
- // few pixels, and inside what shifted, because the corner bands
1125
- // repaint whole and the copy wrote nothing outside. Less the strips,
1126
- // which repaint anyway: furniture flush against the side the pixels
1127
- // came from would otherwise merge with the strip down that side into
1128
- // the box of the two, most of the region's height.
1129
- for (const pin of pinned) {
1122
+ // few pixels, and inside the region, since the copy wrote nothing
1123
+ // outside. Less the strips, which repaint anyway: furniture flush
1124
+ // against the side the pixels came from would otherwise merge with
1125
+ // the strip down that side into the box of the two, most of the
1126
+ // region's height.
1127
+ for (const pin of pins) {
1130
1128
  const image = { ...pin, x: pin.x + dx, y: pin.y + dy };
1131
- const repair = intersectRects(unionRect(pin, image), shifted);
1129
+ const repair = intersectRects(unionRect(pin, image), vp);
1132
1130
  if (!repair) continue;
1133
- let pieces = [repair];
1131
+ let cut = [repair];
1134
1132
  for (const strip of strips) {
1135
- pieces = pieces.flatMap((piece) => outside(piece, strip));
1133
+ cut = cut.flatMap((piece) => outside(piece, strip));
1136
1134
  }
1137
- for (const piece of pieces) rects = addDamageRect(rects, piece);
1135
+ pieces.push(...cut);
1138
1136
  }
1137
+ }
1138
+ // in one call, so the small pieces find their neighbours before the
1139
+ // cap makes anything merge (`addDamageRects`)
1140
+ const rects = addDamageRects(keep, pieces, CONTENTS_BLIT_MAX_RECTS);
1141
+ if (pins) {
1139
1142
  // Furniture can be most of a pane, and the damage cap can merge a
1140
1143
  // repair with a strip into the box of the two: past this, the blit is
1141
1144
  // buying a shift and paying for the region anyway.
@@ -1149,32 +1152,29 @@ export class WindowScrollBlit {
1149
1152
  // the element's deltas are already how far the pixels moved, the sense
1150
1153
  // scrollRegion takes (0 + x rather than x: a caller's -0 would survive
1151
1154
  // into request buffers and test comparisons)
1152
- if (!target.blit({ ...shifted }, 0 + dx, 0 + dy)) return;
1155
+ if (!target.blit({ ...vp }, 0 + dx, 0 + dy)) return;
1153
1156
  target.damage = rects;
1154
1157
  }
1155
1158
 
1156
1159
  /**
1157
- * How many rows at the top and at the bottom of `vp` an ancestor's rounded
1158
- * corners reach into — the rows an element blit has to leave behind and
1159
- * repaint, so that what shifts stays clear of every corner square
1160
- * (`_scrollBlitSafe`). Whole pixels, and zero when no corner reaches in.
1160
+ * The squares of `vp` an ancestor's rounded corners reach into — the only
1161
+ * pixels of a rounded outline a translation cannot keep — in whole pixels,
1162
+ * for an element blit to pin (issue #691). Null when no corner reaches in.
1161
1163
  * Ancestors below `upTo`, the node the blit's surface belongs to
1162
1164
  * (`_blitTarget`): a pane is a rectangle no corner above it clips.
1163
1165
  */
1164
- _cornerBands(node, vp, upTo = this) {
1165
- let top = 0;
1166
- let bottom = 0;
1166
+ _cornerPins(node, vp, upTo = this) {
1167
+ let out = null;
1167
1168
  for (let n = node.parent; n && n !== upTo; n = n.parent) {
1168
1169
  const radius = n.style?.borderRadius ?? 0;
1169
1170
  if (!(radius > 0) || !n.abs) continue;
1170
- if (!cornerSquaresOverlap(n.abs, radius, vp)) continue;
1171
- top = Math.max(top, Math.ceil(n.abs.y + radius - vp.y));
1172
- bottom = Math.max(
1173
- bottom,
1174
- Math.ceil(vp.y + vp.height - (n.abs.y + n.abs.height - radius)),
1175
- );
1171
+ for (const square of cornerSquares(n.abs, radius)) {
1172
+ if (!rectsOverlap(square, vp)) continue;
1173
+ const inside = intersectRects(outerPixels(square), vp);
1174
+ if (inside) (out ??= []).push(inside);
1175
+ }
1176
1176
  }
1177
- return { top: Math.max(0, top), bottom: Math.max(0, bottom) };
1177
+ return out;
1178
1178
  }
1179
1179
 
1180
1180
  /**
@@ -1229,10 +1229,11 @@ export class WindowScrollBlit {
1229
1229
  // radius-sized squares at the corners, and the straight run of
1230
1230
  // the edge between them is the border ring already excluded
1231
1231
  // above. A viewport that reaches the edge but stays clear of the
1232
- // squares — an element that carved the corner rows into bands it
1233
- // repaints (`_cornerBands`) — is translation-safe.
1232
+ // squares is translation-safe, and so is an element blit that
1233
+ // pins the squares it reaches into (`_cornerPins`) — repaired
1234
+ // where they are and where the copy dragged them.
1234
1235
  const radius = child.style?.borderRadius ?? 0;
1235
- if (radius > 0 && cornerSquaresOverlap(child.abs, radius, vp)) {
1236
+ if (radius > 0 && !cornersPinned(child.abs, radius, vp, pinned)) {
1236
1237
  return false;
1237
1238
  }
1238
1239
  if (typeof child._scrollbars === 'function') {