react-x11 2.21.2 → 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.
@@ -7,7 +7,13 @@ import { Surface } from '../offscreen.js';
7
7
  import { isPaintedColor } from './boxpaint.js';
8
8
  import { DAMAGE_SLOP } from './damage.js';
9
9
  import { DRAWN_KINDS } from './kinds.js';
10
- import { rectsOverlap } from './rects.js';
10
+ import {
11
+ intersectRects,
12
+ isIntegerRect,
13
+ rectsOverlap,
14
+ roundedCorners,
15
+ } from './rects.js';
16
+ import { keepCorners } from './roundclip.js';
11
17
  import { DEV } from './util.js';
12
18
  import { devWarnWindowShadow } from './window/capabilities.js';
13
19
  import { FLASH_COLORS, debugPaint } from './window/debugpaint.js';
@@ -289,12 +295,12 @@ export class NodePaint {
289
295
  * truncates, and then almost every cell's text fits — 191 clips a frame, of
290
296
  * which a handful do anything.
291
297
  *
292
- * Rounded corners are never skipped: the clip is not a rectangle then, and
293
- * the rounding can cut a child that a rect test says fits. The one-pixel
294
- * inset is for antialiasing, which can put ink just outside a glyph's box.
298
+ * A rectangle test, so it answers for the box's rectangle only: rounded
299
+ * corners can cut a child this says fits, which `_paintRoundedChildren`
300
+ * settles before it asks. The one-pixel inset is for antialiasing, which
301
+ * can put ink just outside a glyph's box.
295
302
  */
296
303
  _childrenCanOverflow() {
297
- if (this.style?.borderRadius) return true;
298
304
  const box = this.abs;
299
305
  for (const child of this.children) {
300
306
  if (child.isWindow || !child.yoga || child.hidden) continue;
@@ -320,19 +326,108 @@ export class NodePaint {
320
326
  if (this._ownPaintOnly) return;
321
327
  const order = this.paintOrder();
322
328
  if (order.length === 0) return;
323
- const clip = this.clipsChildren() && this._childrenCanOverflow();
329
+ if (!this.clipsChildren()) {
330
+ this._paintChildList(ctx, order);
331
+ return;
332
+ }
333
+ // a context with no `roundRect` has always clipped a rounded box to its
334
+ // rectangle, the way `_roundedPath` degrades
335
+ const radius =
336
+ typeof ctx.roundRect === 'function' ? (this.style.borderRadius ?? 0) : 0;
337
+ if (radius > 0 && this._paintRoundedChildren(ctx, order, radius)) return;
338
+ const clip = this._childrenCanOverflow();
324
339
  if (clip) {
325
340
  ctx.save();
326
- this._roundedPath(ctx, this.style.borderRadius ?? 0);
341
+ this._roundedPath(ctx, 0);
327
342
  ctx.clip();
328
343
  }
344
+ this._paintChildList(ctx, order);
345
+ if (clip) ctx.restore();
346
+ }
347
+
348
+ /** `order` painted in turn, each child culled against the window, its
349
+ * clipping ancestors and the pass. */
350
+ _paintChildList(ctx, order) {
329
351
  for (const child of order) {
330
352
  if (child._promoted) continue; // on a layer of its own: a hole here
331
353
  if (child._offscreen(child.abs, DAMAGE_SLOP)) continue;
332
354
  if (child._outsideDamage()) continue;
333
355
  child.paint(ctx);
334
356
  }
335
- if (clip) ctx.restore();
357
+ }
358
+
359
+ /**
360
+ * Would a pass over `rect` paint this node? The culling `_paintChildList`
361
+ * does, asked of a rect before it is the pass.
362
+ */
363
+ _paintsInto(rect) {
364
+ if (this._promoted) return false;
365
+ if (!rectsOverlap(this._subtreeBounds(), rect, DAMAGE_SLOP)) return false;
366
+ return !this._offscreen(this.abs, DAMAGE_SLOP);
367
+ }
368
+
369
+ /**
370
+ * Children clipped to a rounded outline (issue #685) — false when this pass
371
+ * needs no more than a square box's clip, which the caller then applies.
372
+ *
373
+ * A clip that is not a rectangle is one ntk builds as an a8 mask the size
374
+ * of the window, and every fill and glyph run under it leaves the
375
+ * server-side fast paths for composites through that mask: a pane that
376
+ * clipped its content to a rounded border paid it for every drawing in
377
+ * every pass, to decide a few pixels in each corner. A `<Flow>` with
378
+ * `borderRadius: 6` panned at 10 fps where the square pane ran at 56.
379
+ *
380
+ * The outline is the box's rectangle everywhere but its four corner
381
+ * squares, so this asks which squares the pass reaches that a child draws
382
+ * into. None — a pass that misses them, or a box whose padding keeps its
383
+ * children off them — and the outline clips nothing the rectangle would
384
+ * not. Otherwise the children are drawn once, under the rectangle,
385
+ * and those squares are put back the way they were outside the arc
386
+ * (`keepCorners`): a few composites the size of a corner, whatever the
387
+ * children drew. A context that cannot read its pixels back clips to the
388
+ * outline, as does a box off the pixel grid or one that is corner all the
389
+ * way along — a pill.
390
+ */
391
+ _paintRoundedChildren(ctx, order, radius) {
392
+ const damage = this.root?._paintDamage ?? null;
393
+ const corners =
394
+ !damage || isIntegerRect(damage)
395
+ ? roundedCorners(this.abs, radius)
396
+ : null;
397
+ let kept = null;
398
+ if (corners) {
399
+ // nothing inside the outline is being repainted
400
+ const pass = damage ? intersectRects(damage, this.abs) : this.abs;
401
+ if (!pass) return true;
402
+ const cut = corners.squares.filter((square) => {
403
+ const piece = intersectRects(square, pass);
404
+ return piece && order.some((child) => child._paintsInto(piece));
405
+ });
406
+ if (cut.length === 0) return false;
407
+ kept = keepCorners(ctx, this.app, corners, cut);
408
+ }
409
+ if (!kept) {
410
+ ctx.save();
411
+ this._roundedPath(ctx, radius);
412
+ ctx.clip();
413
+ this._paintChildList(ctx, order);
414
+ ctx.restore();
415
+ return true;
416
+ }
417
+ try {
418
+ const clip = this._childrenCanOverflow();
419
+ if (clip) {
420
+ ctx.save();
421
+ this._roundedPath(ctx, 0);
422
+ ctx.clip();
423
+ }
424
+ this._paintChildList(ctx, order);
425
+ if (clip) ctx.restore();
426
+ kept.restore();
427
+ } finally {
428
+ kept.release();
429
+ }
430
+ return true;
336
431
  }
337
432
 
338
433
  /**
@@ -362,6 +362,7 @@ export class WindowPosition {
362
362
  }
363
363
  if (this._damage !== FULL_DAMAGE) {
364
364
  this._damage = addDamageRect(this._damage, claim, cap);
365
+ this._logClaim(claim, node);
365
366
  }
366
367
  if (panes) this._addPaneDamage(claim);
367
368
  claimed = true;
@@ -57,6 +57,16 @@ export function intersectRects(a, b) {
57
57
  return { x, y, width: right - x, height: bottom - y };
58
58
  }
59
59
 
60
+ /** `rect` moved by (dx, dy). */
61
+ export function shiftRect(rect, dx, dy) {
62
+ return {
63
+ x: rect.x + dx,
64
+ y: rect.y + dy,
65
+ width: rect.width,
66
+ height: rect.height,
67
+ };
68
+ }
69
+
60
70
  export function unionRect(a, b) {
61
71
  if (!b) return a ?? null;
62
72
  if (!a) return { ...b };
@@ -81,12 +91,12 @@ export function rectsBounds(rects) {
81
91
  return out;
82
92
  }
83
93
 
84
- /** Does `rect` reach into any of the four `radius`-sized corner squares of
85
- * `box` — the only part of a rounded border a translation cannot keep? */
86
- 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) {
87
97
  const r = Math.min(radius, box.width / 2, box.height / 2);
88
- if (!(r > 0)) return false;
89
- const corners = [
98
+ if (!(r > 0)) return [];
99
+ return [
90
100
  { x: box.x, y: box.y, width: r, height: r },
91
101
  { x: box.x + box.width - r, y: box.y, width: r, height: r },
92
102
  { x: box.x, y: box.y + box.height - r, width: r, height: r },
@@ -97,7 +107,47 @@ export function cornerSquaresOverlap(box, radius, rect) {
97
107
  height: r,
98
108
  },
99
109
  ];
100
- 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
+ );
118
+ }
119
+
120
+ /**
121
+ * The four squares of `box` a rounded outline of `radius` is not a rectangle
122
+ * in: whole pixels, `side` on a side — the radius taken up to a whole pixel,
123
+ * since the arc spans exactly `radius` from each edge and a pixel it crosses
124
+ * is one it shades — and named for their corner. Outside them the outline
125
+ * *is* the box, so a clip to it can be the box's rectangle there.
126
+ *
127
+ * Null when that cut cannot be made: a box off the pixel grid, whose own
128
+ * edges are antialiased wherever they run, and a radius whose squares would
129
+ * overlap — a pill, which is corner all the way along.
130
+ */
131
+ export function roundedCorners(box, radius) {
132
+ if (!isIntegerRect(box)) return null;
133
+ // the radius a canvas `roundRect` draws: scaled down so that two corners
134
+ // never meet — for a uniform radius, half the shorter side
135
+ const r = Math.min(radius, box.width / 2, box.height / 2);
136
+ if (!(r > 0)) return null;
137
+ const side = Math.ceil(r);
138
+ if (side * 2 > box.width || side * 2 > box.height) return null;
139
+ const right = box.x + box.width - side;
140
+ const bottom = box.y + box.height - side;
141
+ return {
142
+ radius: r,
143
+ side,
144
+ squares: [
145
+ { corner: 'tl', x: box.x, y: box.y, width: side, height: side },
146
+ { corner: 'tr', x: right, y: box.y, width: side, height: side },
147
+ { corner: 'br', x: right, y: bottom, width: side, height: side },
148
+ { corner: 'bl', x: box.x, y: bottom, width: side, height: side },
149
+ ],
150
+ };
101
151
  }
102
152
 
103
153
  /**
@@ -113,3 +163,48 @@ export function rectsOverlap(a, b, margin = 0) {
113
163
  b.y < a.y + a.height + margin
114
164
  );
115
165
  }
166
+
167
+ /** The parts of `rect` outside `hole`: up to four bands, full width above
168
+ * and below, the rest beside. */
169
+ export function outside(rect, hole) {
170
+ const cut = intersectRects(rect, hole);
171
+ if (!cut) return [rect];
172
+ const out = [];
173
+ const right = rect.x + rect.width;
174
+ const bottom = rect.y + rect.height;
175
+ if (cut.y > rect.y) {
176
+ out.push({
177
+ x: rect.x,
178
+ y: rect.y,
179
+ width: rect.width,
180
+ height: cut.y - rect.y,
181
+ });
182
+ }
183
+ const cutBottom = cut.y + cut.height;
184
+ if (cutBottom < bottom) {
185
+ out.push({
186
+ x: rect.x,
187
+ y: cutBottom,
188
+ width: rect.width,
189
+ height: bottom - cutBottom,
190
+ });
191
+ }
192
+ if (cut.x > rect.x) {
193
+ out.push({
194
+ x: rect.x,
195
+ y: cut.y,
196
+ width: cut.x - rect.x,
197
+ height: cut.height,
198
+ });
199
+ }
200
+ const cutRight = cut.x + cut.width;
201
+ if (cutRight < right) {
202
+ out.push({
203
+ x: cutRight,
204
+ y: cut.y,
205
+ width: right - cutRight,
206
+ height: cut.height,
207
+ });
208
+ }
209
+ return out;
210
+ }
@@ -0,0 +1,214 @@
1
+ // A rounded outline clipping a box's children, made without a clip mask
2
+ // (issue #685). The outline is the box's rectangle everywhere but its four
3
+ // corner squares, so the children are drawn once under the rectangle — the
4
+ // clip every server-side fast path takes — and each corner square a child
5
+ // reaches is put back the way it was outside the arc: kept before the
6
+ // children draw, returned through the coverage of the arc's outside after.
7
+ //
8
+ // That needs a context that can read its own pixels back into a surface of
9
+ // its own and composite a coverage surface: ntk's, whose `picture` is the
10
+ // server-side picture it draws through. Anywhere else the caller clips to the
11
+ // outline as it always has.
12
+
13
+ import { Surface } from '../offscreen.js';
14
+
15
+ // per app: the coverage of each corner's outside, by corner, radius and
16
+ // side, and the surfaces a corner is kept in between the two halves
17
+ const states = new WeakMap();
18
+
19
+ function stateFor(app) {
20
+ let state = states.get(app);
21
+ if (!state) {
22
+ state = { coverage: new Map(), spare: [] };
23
+ states.set(app, state);
24
+ }
25
+ return state;
26
+ }
27
+
28
+ /**
29
+ * The outline inside one corner square, as a path: the square with the
30
+ * outside of the arc cut away. The arc is `roundRect`'s own — the same
31
+ * centre, radius and quarter, reached by the same line — so the pixels it
32
+ * shades are the ones a clip to the whole outline shades.
33
+ */
34
+ function cornerPath(ctx, box, { radius: r, side: s }, corner) {
35
+ const left = box.x;
36
+ const top = box.y;
37
+ const right = box.x + box.width;
38
+ const bottom = box.y + box.height;
39
+ ctx.beginPath();
40
+ if (corner === 'tl') {
41
+ ctx.moveTo(left, top + s);
42
+ ctx.lineTo(left, top + r);
43
+ ctx.arc(left + r, top + r, r, Math.PI, Math.PI * 1.5);
44
+ ctx.lineTo(left + s, top);
45
+ ctx.lineTo(left + s, top + s);
46
+ } else if (corner === 'tr') {
47
+ ctx.moveTo(right - s, top);
48
+ ctx.lineTo(right - r, top);
49
+ ctx.arc(right - r, top + r, r, -Math.PI / 2, 0);
50
+ ctx.lineTo(right, top + s);
51
+ ctx.lineTo(right - s, top + s);
52
+ } else if (corner === 'br') {
53
+ ctx.moveTo(right, bottom - s);
54
+ ctx.lineTo(right, bottom - r);
55
+ ctx.arc(right - r, bottom - r, r, 0, Math.PI / 2);
56
+ ctx.lineTo(right - s, bottom);
57
+ ctx.lineTo(right - s, bottom - s);
58
+ } else {
59
+ ctx.moveTo(left + s, bottom);
60
+ ctx.lineTo(left + r, bottom);
61
+ ctx.arc(left + r, bottom - r, r, Math.PI / 2, Math.PI);
62
+ ctx.lineTo(left, bottom - s);
63
+ ctx.lineTo(left + s, bottom - s);
64
+ }
65
+ ctx.closePath();
66
+ }
67
+
68
+ /**
69
+ * How much of each pixel of a corner square lies outside the outline, as an
70
+ * a8 surface the size of the square: the square filled, and the outline's
71
+ * corner taken out of it. One per corner, radius and side on an app, and
72
+ * kept — every box with that radius shares it.
73
+ */
74
+ function outsideCoverage(app, corners, corner) {
75
+ const state = stateFor(app);
76
+ const s = corners.side;
77
+ const key = `${corner}|${corners.radius}|${s}`;
78
+ let surface = state.coverage.get(key);
79
+ if (surface) return surface;
80
+ surface = new Surface(app, { width: s, height: s, format: 'a8' });
81
+ // a box whose corner lands on the square's own, in the square's space
82
+ const far = corner === 'tr' || corner === 'br';
83
+ const low = corner === 'bl' || corner === 'br';
84
+ const box = { x: far ? -s : 0, y: low ? -s : 0, width: 2 * s, height: 2 * s };
85
+ surface.render((sctx) => {
86
+ sctx.fillStyle = '#ffffff';
87
+ sctx.fillRect(0, 0, s, s);
88
+ sctx.globalCompositeOperation = 'destination-out';
89
+ cornerPath(sctx, box, corners, corner);
90
+ sctx.fill();
91
+ });
92
+ state.coverage.set(key, surface);
93
+ return surface;
94
+ }
95
+
96
+ /**
97
+ * A surface at least `side` square to keep a corner in, from the app's
98
+ * spares, with the context it is drawn through: a box nested in a box keeps
99
+ * its corners while the outer one's are still kept, so there is one per
100
+ * corner being held, not one in all. The context is held with the surface —
101
+ * ntk makes a new one per `getContext`, and a surface drawn into every frame
102
+ * would otherwise make one a frame.
103
+ */
104
+ function takeSpare(app, side) {
105
+ const spare = stateFor(app).spare;
106
+ for (let i = 0; i < spare.length; i++) {
107
+ const { surface } = spare[i];
108
+ if (surface.width >= side && surface.height >= side) {
109
+ return spare.splice(i, 1)[0];
110
+ }
111
+ }
112
+ const size = Math.max(16, side);
113
+ const surface = new Surface(app, { width: size, height: size });
114
+ return { surface, ctx: surface.getContext('2d') };
115
+ }
116
+
117
+ /**
118
+ * Where the context puts window coordinate (0, 0) on the picture it draws
119
+ * into — the only transform the paint walk runs under is a whole-pixel
120
+ * translation (a faded group's surface, a pane's) — and that picture, as
121
+ * something `drawImage` can read from; null when either is missing, and the
122
+ * corners cannot be kept this way.
123
+ */
124
+ function readback(ctx) {
125
+ const m = typeof ctx.getTransform === 'function' ? ctx.getTransform() : null;
126
+ if (!m || m.a !== 1 || m.b !== 0 || m.c !== 0 || m.d !== 1) return null;
127
+ if (!Number.isInteger(m.e) || !Number.isInteger(m.f)) return null;
128
+ // reading it settles the picture's clip, which the next drawing re-stamps
129
+ if (!ctx.picture || !Number.isFinite(ctx.width)) return null;
130
+ return {
131
+ dx: m.e,
132
+ dy: m.f,
133
+ source: {
134
+ width: ctx.width,
135
+ height: ctx.height,
136
+ picture: () => ctx.picture,
137
+ },
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Keep the corner squares in `cut` — the ones a child is about to draw into
143
+ * — before the children draw, and hand back what puts them back afterwards:
144
+ * `restore()` composites each square the way it was outside the arc, over
145
+ * whatever the children left there, and `release()` returns the surfaces.
146
+ * Null when the context cannot read its pixels back.
147
+ *
148
+ * Each kept square is premultiplied by the outside's coverage, so putting it
149
+ * back is two composites and no mask: the destination scaled by the inside's
150
+ * coverage (`destination-out` through the outside's), then the kept pixels
151
+ * added (`lighter`). Where the outline covers a pixel wholly, nothing of what
152
+ * the children drew changes; where it misses it wholly, what was there
153
+ * before comes back exactly; the antialiased pixels on the arc blend the two
154
+ * by the arc's coverage. That is the children clipped as one layer, which is
155
+ * what a clip means — a per-drawing clip lets a covered layer bleed into
156
+ * those pixels, and a stack of them darkens the edge.
157
+ */
158
+ export function keepCorners(ctx, app, corners, cut) {
159
+ const at = app ? readback(ctx) : null;
160
+ if (!at) return null;
161
+ const s = corners.side;
162
+ const kept = [];
163
+ const release = () => {
164
+ const spare = stateFor(app).spare;
165
+ for (const { held } of kept) spare.push(held);
166
+ kept.length = 0;
167
+ };
168
+ try {
169
+ for (const square of cut) {
170
+ const outside = outsideCoverage(app, corners, square.corner);
171
+ const held = takeSpare(app, s);
172
+ kept.push({ square, outside, held });
173
+ const sctx = held.ctx;
174
+ sctx.save();
175
+ sctx.globalAlpha = 1;
176
+ sctx.globalCompositeOperation = 'copy';
177
+ sctx.drawImage(
178
+ at.source,
179
+ square.x + at.dx,
180
+ square.y + at.dy,
181
+ s,
182
+ s,
183
+ 0,
184
+ 0,
185
+ s,
186
+ s,
187
+ );
188
+ sctx.globalCompositeOperation = 'destination-in';
189
+ sctx.fillStyle = '#000000';
190
+ sctx.drawImage(outside, 0, 0);
191
+ sctx.restore();
192
+ }
193
+ } catch {
194
+ // no surface to keep a corner in: the caller clips to the outline, and
195
+ // what was taken goes back
196
+ release();
197
+ return null;
198
+ }
199
+ return {
200
+ restore() {
201
+ for (const { square, outside, held } of kept) {
202
+ ctx.save();
203
+ ctx.globalAlpha = 1;
204
+ ctx.globalCompositeOperation = 'destination-out';
205
+ ctx.fillStyle = '#000000';
206
+ ctx.drawImage(outside, square.x, square.y);
207
+ ctx.globalCompositeOperation = 'lighter';
208
+ ctx.drawImage(held.surface, 0, 0, s, s, square.x, square.y, s, s);
209
+ ctx.restore();
210
+ }
211
+ },
212
+ release,
213
+ };
214
+ }
@@ -163,7 +163,10 @@ export const Scrollable = (Base) =>
163
163
  this.abs.y - wasY,
164
164
  );
165
165
  } finally {
166
- if (move) layoutDiff.shift = null;
166
+ if (move) {
167
+ layoutDiff.shift = null;
168
+ layoutDiff.ride = false;
169
+ }
167
170
  }
168
171
  if (move) this._claimRigidMove(move);
169
172
  }
@@ -316,9 +319,11 @@ export const Scrollable = (Base) =>
316
319
  // content shrank, or the viewport grew, under the offset — a row
317
320
  // collapsing at the end of a list scrolled to its end — and
318
321
  // nothing claimed the viewport every child just moved in. Nor
319
- // for a `<glarea>` child's move that this pane rides (issue
320
- // #644): its box claimed nothing, and a pane laid out again may
321
- // have moved rows the move's copy carries where they were.
322
+ // for a move this pane rides whose pixels are copied — a
323
+ // `<glarea>` child's on its pane (issue #644), a subtree's in
324
+ // the window (issue #681): its box claimed nothing, and a pane
325
+ // laid out again may have moved rows the move's copy carries
326
+ // where they were.
322
327
  if (clamped || layoutDiff.ride) outer(vp, this);
323
328
  } else {
324
329
  layoutDiff.sink = (rect, node) => {
@@ -67,9 +67,15 @@ export function scrollbarGeometry({
67
67
  // radius downstream draw from the same number.
68
68
  const barWidth = SCROLLBAR_WIDTH * scale;
69
69
  const rtl = direction === 'rtl';
70
- const thumbLength = Math.max(
71
- SCROLLBAR_MIN_THUMB * scale,
72
- (length * length) / content,
70
+ // Never longer than the track: a pane shorter than the minimum gets a
71
+ // thumb that fills it and has no travel. The bar paints outside the
72
+ // pane's clip, and a node that clips its children reaches no further than
73
+ // its own box (`_subtreeBounds`), so a thumb run past the track put ink
74
+ // where no damage claim could reach — left behind by every move of the
75
+ // pane, and on a part the pointer could not hit.
76
+ const thumbLength = Math.min(
77
+ length,
78
+ Math.max(SCROLLBAR_MIN_THUMB * scale, (length * length) / content),
73
79
  );
74
80
  const range = content - viewport;
75
81
  const travel = Math.max(0, length - thumbLength);