react-x11 2.46.0 → 2.47.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.46.0",
3
+ "version": "2.47.1",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -109,7 +109,7 @@
109
109
  "yoga-layout": "^3.2.1"
110
110
  },
111
111
  "optionalDependencies": {
112
- "@windowkit/appkit": "^0.27.0",
112
+ "@windowkit/appkit": "^0.28.0",
113
113
  "@windowkit/wayland": "^3.1.1",
114
114
  "@windowkit/win32": "^0.0.9",
115
115
  "dbus-native": "^0.16.0",
@@ -28,6 +28,7 @@ import { cssColorStraight } from 'ntk/color';
28
28
  import { normalizeRadii, planShadowTiles } from 'ntk/shadow-tiles';
29
29
 
30
30
  import {
31
+ blurPixels,
31
32
  filterColour,
32
33
  filterPixels,
33
34
  filterStops,
@@ -183,9 +184,10 @@ function warnUndrawable(image) {
183
184
  );
184
185
  }
185
186
 
186
- class LinearGradient {
187
- constructor(x0, y0, x1, y1) {
188
- this._coords = [x0, y0, x1, y1];
187
+ /** A canvas gradient's stops, which `fillStyle` takes: `LinearGradient`
188
+ * and `RadialGradient` are where they run. */
189
+ class Gradient {
190
+ constructor() {
189
191
  this._stops = [];
190
192
  }
191
193
 
@@ -194,6 +196,25 @@ class LinearGradient {
194
196
  this._stops.push(offset, r, g, b, a);
195
197
  }
196
198
 
199
+ /** The stops as `[offset, r, g, b, a]`, in order of offset, two at
200
+ * least: one stop is a colour from end to end. */
201
+ _sorted() {
202
+ const stops = [];
203
+ for (let i = 0; i + 4 < this._stops.length; i += 5) {
204
+ stops.push(this._stops.slice(i, i + 5));
205
+ }
206
+ stops.sort((p, q) => p[0] - q[0]);
207
+ if (stops.length === 1) stops.push([...stops[0]]);
208
+ return stops;
209
+ }
210
+ }
211
+
212
+ class LinearGradient extends Gradient {
213
+ constructor(x0, y0, x1, y1) {
214
+ super();
215
+ this._coords = [x0, y0, x1, y1];
216
+ }
217
+
197
218
  /**
198
219
  * CoreGraphics requires stop locations inside [0, 1]; the decorations
199
220
  * parser deliberately pads a gradient's line past both ends (its end
@@ -203,13 +224,8 @@ class LinearGradient {
203
224
  * remaps into [0, 1].
204
225
  */
205
226
  _normalized() {
206
- const stops = [];
207
- for (let i = 0; i + 4 < this._stops.length; i += 5) {
208
- stops.push(this._stops.slice(i, i + 5));
209
- }
210
- stops.sort((p, q) => p[0] - q[0]);
227
+ const stops = this._sorted();
211
228
  if (stops.length === 0) return { coords: this._coords, flat: [] };
212
- if (stops.length === 1) stops.push([...stops[0]]);
213
229
  const min = Math.min(0, stops[0][0]);
214
230
  const max = Math.max(1, stops[stops.length - 1][0]);
215
231
  let [x0, y0, x1, y1] = this._coords;
@@ -230,6 +246,35 @@ class LinearGradient {
230
246
  }
231
247
  }
232
248
 
249
+ /**
250
+ * Canvas's radial gradient: the colours spread from the start circle to
251
+ * the end one (@windowkit/appkit's `ctxFillRadialGradient`, whose
252
+ * CoreGraphics gradient is canvas's two-circle one). Canvas keeps every
253
+ * stop's offset in [0, 1], so they are only put in order.
254
+ */
255
+ class RadialGradient extends Gradient {
256
+ constructor(x0, y0, r0, x1, y1, r1) {
257
+ super();
258
+ this._circles = [x0, y0, r0, x1, y1, r1];
259
+ }
260
+
261
+ _normalized() {
262
+ const stops = this._sorted();
263
+ for (const stop of stops) stop[0] = Math.min(1, Math.max(0, stop[0]));
264
+ return { circles: this._circles, flat: stops.flat() };
265
+ }
266
+ }
267
+
268
+ /**
269
+ * `createRadialGradient` on a bridge with no radial fill: a linear gradient
270
+ * along no line, which paints flat, as every radial gradient did before
271
+ * the bridge had one. It takes no circles, which is how a caller tells it
272
+ * from the one that draws them (`<Html>` asks `createRadialGradient.length`).
273
+ */
274
+ function flatRadialGradient() {
275
+ return new LinearGradient(0, 0, 0, 0);
276
+ }
277
+
233
278
  const clamp01 = (v) => Math.min(1, Math.max(0, Number(v) || 0));
234
279
 
235
280
  // What `textAlign`, `textBaseline` and `direction` take. A value outside
@@ -431,6 +476,10 @@ function meets(a, b) {
431
476
  );
432
477
  }
433
478
 
479
+ /** How many pixels across a blur's standard deviation is kept as the
480
+ * source is scaled down for it (`_drawBlurred`). */
481
+ const BLUR_SCALE = 2;
482
+
434
483
  /** The qualities `imageSmoothingQuality` takes, canvas's. */
435
484
  const SMOOTHING_QUALITIES = new Set(['low', 'medium', 'high']);
436
485
 
@@ -525,6 +574,15 @@ export class BackendContext2D {
525
574
  this._filters = ['createSurface', 'ctxGetImageData', 'ctxPutImageData']
526
575
  .map((name) => name in native && typeof native[name] === 'function')
527
576
  .every(Boolean);
577
+ // and a radial gradient's fill, @windowkit/appkit 0.28.0's: where the
578
+ // bridge has none, the flat stand-in it always had, which takes no
579
+ // circles — the one a caller asks for, and keys on
580
+ if (!(
581
+ 'ctxFillRadialGradient' in native &&
582
+ typeof native.ctxFillRadialGradient === 'function'
583
+ )) {
584
+ this.createRadialGradient = flatRadialGradient;
585
+ }
528
586
  this._onDirty = null;
529
587
  // the recorded path, and whether the native one still matches it (a
530
588
  // chunked stroke leaves only its last chunk behind)
@@ -1330,9 +1388,13 @@ export class BackendContext2D {
1330
1388
  return new LinearGradient(x0, y0, x1, y1);
1331
1389
  }
1332
1390
 
1333
- createRadialGradient() {
1334
- // radial paints flat until someone needs it; the stop list still works
1335
- return new LinearGradient(0, 0, 0, 0);
1391
+ createRadialGradient(x0, y0, r0, x1, y1, r1) {
1392
+ if (r0 < 0 || r1 < 0) {
1393
+ throw new RangeError(
1394
+ "Failed to execute 'createRadialGradient': a radius is negative.",
1395
+ );
1396
+ }
1397
+ return new RadialGradient(x0, y0, r0, x1, y1, r1);
1336
1398
  }
1337
1399
 
1338
1400
  _applyFill() {
@@ -1452,7 +1514,14 @@ export class BackendContext2D {
1452
1514
  _fillPath(rule) {
1453
1515
  this._path();
1454
1516
  const style = this._state.fillStyle;
1455
- if (style instanceof LinearGradient) {
1517
+ if (style instanceof RadialGradient) {
1518
+ const { circles, flat: stops } = style._normalized();
1519
+ this._native.ctxFillRadialGradient(
1520
+ this._s(),
1521
+ ...circles,
1522
+ this._stops(stops),
1523
+ );
1524
+ } else if (style instanceof LinearGradient) {
1456
1525
  const { coords, flat: stops } = style._normalized();
1457
1526
  const flat = this._stops(stops);
1458
1527
  this._native.ctxFillLinearGradient(
@@ -1688,7 +1757,18 @@ export class BackendContext2D {
1688
1757
 
1689
1758
  _fillRectNow(x, y, w, h) {
1690
1759
  const style = this._state.fillStyle;
1691
- if (style instanceof LinearGradient) {
1760
+ if (style instanceof RadialGradient) {
1761
+ const { circles, flat: stops } = style._normalized();
1762
+ this._native.ctxFillRadialGradient(
1763
+ this._s(),
1764
+ ...circles,
1765
+ this._stops(stops),
1766
+ x,
1767
+ y,
1768
+ w,
1769
+ h,
1770
+ );
1771
+ } else if (style instanceof LinearGradient) {
1692
1772
  const { coords, flat: stops } = style._normalized();
1693
1773
  const flat = this._stops(stops);
1694
1774
  this._native.ctxFillLinearGradient(
@@ -1802,6 +1882,10 @@ export class BackendContext2D {
1802
1882
  * resampling an `imageSmoothingQuality` sets all take as before.
1803
1883
  */
1804
1884
  _drawFiltered(src, size, [sx, sy, sw, sh, dx, dy, dw, dh], ink) {
1885
+ if (ink.blur > 0) {
1886
+ this._drawBlurred(src, size, [sx, sy, sw, sh, dx, dy, dw, dh], ink);
1887
+ return;
1888
+ }
1805
1889
  const x0 = Math.max(0, Math.floor(sx));
1806
1890
  const y0 = Math.max(0, Math.floor(sy));
1807
1891
  const w = Math.min(size.width, Math.ceil(sx + sw)) - x0;
@@ -1832,6 +1916,83 @@ export class BackendContext2D {
1832
1916
  }
1833
1917
  }
1834
1918
 
1919
+ /**
1920
+ * `drawImage` under a filter with a `blur()`: the part of the source the
1921
+ * source rect covers and as far round it as the blur reaches, three
1922
+ * standard deviations, scaled down so that the blur is a few pixels
1923
+ * across — the resampling the bridge does as it draws, which averages —
1924
+ * blurred and run through the rest of the filter there, and drawn back up
1925
+ * over the destination and as far round it, smoothed. A Gaussian is
1926
+ * smooth to the scale it was taken at, so the pixels it lost scaling down
1927
+ * were ones it would have blurred away, as Skia blurs a large radius. What
1928
+ * is past the source is nothing, which a blur fades into, as canvas has it.
1929
+ */
1930
+ _drawBlurred(src, size, [sx, sy, sw, sh, dx, dy, dw, dh], ink) {
1931
+ if (!(sw > 0 && sh > 0 && dw !== 0 && dh !== 0)) return;
1932
+ // the blur in source pixels, across and down
1933
+ const bx = (ink.blur * sw) / Math.abs(dw);
1934
+ const by = (ink.blur * sh) / Math.abs(dh);
1935
+ const mx = Math.ceil(3 * bx);
1936
+ const my = Math.ceil(3 * by);
1937
+ // what is read, in source pixels, the source's part of it, and the
1938
+ // factor it is scaled down by
1939
+ const left = Math.floor(sx) - mx;
1940
+ const top = Math.floor(sy) - my;
1941
+ const across = Math.ceil(sx + sw) + mx - left;
1942
+ const down = Math.ceil(sy + sh) + my - top;
1943
+ const factor = Math.max(1, Math.floor(Math.min(bx, by) / BLUR_SCALE));
1944
+ const w = Math.max(1, Math.ceil(across / factor));
1945
+ const h = Math.max(1, Math.ceil(down / factor));
1946
+ const n = this._native;
1947
+ const small = n.createSurface(w, h, 1);
1948
+ const st = this._state;
1949
+ try {
1950
+ const ix = Math.max(0, left);
1951
+ const iy = Math.max(0, top);
1952
+ const iw = Math.min(size.width, left + across) - ix;
1953
+ const ih = Math.min(size.height, top + down) - iy;
1954
+ if (iw > 0 && ih > 0) {
1955
+ if (this._smoothing) n.ctxSetImageSmoothing(small, 'high');
1956
+ n.ctxDrawSurface(
1957
+ small,
1958
+ src,
1959
+ ix,
1960
+ iy,
1961
+ iw,
1962
+ ih,
1963
+ (ix - left) / factor,
1964
+ (iy - top) / factor,
1965
+ iw / factor,
1966
+ ih / factor,
1967
+ );
1968
+ }
1969
+ const pixels = n.ctxGetImageData(small, 0, 0, w, h);
1970
+ blurPixels(pixels, w, h, Math.min(bx, by) / factor);
1971
+ if (ink.matrices.length || ink.alpha < 1) {
1972
+ filterPixels(pixels, pixels, ink);
1973
+ }
1974
+ n.ctxPutImageData(small, pixels, w, h, 0, 0);
1975
+ st.filterInk = null;
1976
+ // the destination's units a source pixel is
1977
+ const kx = dw / sw;
1978
+ const ky = dh / sh;
1979
+ this.drawImage(
1980
+ { _surfaceHandle: small },
1981
+ 0,
1982
+ 0,
1983
+ across / factor,
1984
+ down / factor,
1985
+ dx + (left - sx) * kx,
1986
+ dy + (top - sy) * ky,
1987
+ across * kx,
1988
+ down * ky,
1989
+ );
1990
+ } finally {
1991
+ st.filterInk = ink;
1992
+ release(n, small);
1993
+ }
1994
+ }
1995
+
1835
1996
  /**
1836
1997
  * Whether a surface is drawn under this `globalAlpha` from its pixels
1837
1998
  * scaled by it (@windowkit/appkit's `ctxDrawSurfaceFaded`): where the
@@ -2129,7 +2290,7 @@ export class BackendContext2D {
2129
2290
  ];
2130
2291
  }
2131
2292
  const style = this._state.fillStyle;
2132
- return style instanceof LinearGradient ? BLACK : parseColor(style);
2293
+ return style instanceof Gradient ? BLACK : parseColor(style);
2133
2294
  }
2134
2295
 
2135
2296
  // --- text (minimal: enough for <canvas onDraw> users) --------------------
@@ -9,10 +9,21 @@
9
9
  // glyph run's ink and a symbol's colour are filtered by filtering the colour
10
10
  // they are drawn in, exactly. An image and a surface are filtered pixel by
11
11
  // pixel, and a text layout that carries colours of its own on a surface of
12
- // its own (src/backend/context2d.js). `blur()`, `drop-shadow()` and a
13
- // `url()` reach past what is drawn, which a colour cannot say; this context
14
- // does not apply them, so a list with one in it does not stick, and a
15
- // caller knows from reading the property back.
12
+ // its own (src/backend/context2d.js). `drop-shadow()` and a `url()` reach
13
+ // past what is drawn, which a colour cannot say; this context does not
14
+ // apply them, so a list with one in it does not stick, and a caller knows
15
+ // from reading the property back.
16
+ //
17
+ // `blur()` reaches past what is drawn too, and is applied to an image or a
18
+ // surface drawn — `drawImage`, what a renderer composites a layer of its
19
+ // own with, CSS's `backdrop-filter` among them — by blurring its pixels
20
+ // (`blurPixels`). A fill, a stroke and text are drawn sharp under it, in
21
+ // the colours the rest of the list makes. Its standard deviation is in the
22
+ // units `drawImage`'s destination is given in. Several `blur()`s are one,
23
+ // the root of the sum of their squares, as two Gaussians make one; and each
24
+ // colour function here is a linear map on premultiplied colour, which a
25
+ // blur, a weighted sum of premultiplied pixels, commutes with, so where in
26
+ // the list a blur is written changes nothing but where the clamps fall.
16
27
 
17
28
  /** A function's amount where none is written: all of the effect for the
18
29
  * ones whose argument is how much of it to take, and none for the others
@@ -38,17 +49,19 @@ const ANGLE_UNITS = {
38
49
  };
39
50
 
40
51
  /**
41
- * A filter list as this context applies it: `{ matrices, alpha }`, the
42
- * colour matrices in the order written, each over straight RGB in 0–1 with
43
- * an offset — none for a function that changes no colour — and what alpha
44
- * is multiplied by. Null for `none`; undefined for what is no filter list,
45
- * or one with a function this context does not apply.
52
+ * A filter list as this context applies it: `{ matrices, alpha, blur }`,
53
+ * the colour matrices in the order written, each over straight RGB in 0–1
54
+ * with an offset — none for a function that changes no colour — what alpha
55
+ * is multiplied by, and the standard deviation of the blur, 0 for none.
56
+ * Null for `none`; undefined for what is no filter list, or one with a
57
+ * function this context does not apply.
46
58
  */
47
59
  export function parseCanvasFilter(value) {
48
60
  const text = String(value).trim();
49
61
  if (text.toLowerCase() === 'none') return null;
50
62
  const matrices = [];
51
63
  let alpha = 1;
64
+ let blur2 = 0;
52
65
  let rest = text;
53
66
  let seen = 0;
54
67
  while (rest.length) {
@@ -57,8 +70,14 @@ export function parseCanvasFilter(value) {
57
70
  rest = rest.slice(m[0].length);
58
71
  seen += 1;
59
72
  const name = m[1].toLowerCase();
60
- if (!COLOUR.has(name)) return undefined;
61
73
  const arg = m[2].trim();
74
+ if (name === 'blur') {
75
+ const radius = arg ? lengthOf(arg) : 0;
76
+ if (radius === null || radius < 0) return undefined;
77
+ blur2 += radius * radius;
78
+ continue;
79
+ }
80
+ if (!COLOUR.has(name)) return undefined;
62
81
  if (name === 'hue-rotate') {
63
82
  const angle = arg ? angleOf(arg) : 0;
64
83
  if (angle === null) return undefined;
@@ -76,12 +95,24 @@ export function parseCanvasFilter(value) {
76
95
  if (matrix) matrices.push(matrix);
77
96
  }
78
97
  if (!seen) return undefined;
79
- return { matrices, alpha };
98
+ return { matrices, alpha, blur: Math.sqrt(blur2) };
80
99
  }
81
100
 
82
101
  /** Whether a parsed filter changes anything drawn. */
83
102
  export function filters(filter) {
84
- return !!filter && (filter.matrices.length > 0 || filter.alpha < 1);
103
+ return (
104
+ !!filter &&
105
+ (filter.matrices.length > 0 || filter.alpha < 1 || filter.blur > 0)
106
+ );
107
+ }
108
+
109
+ /** A length in pixels; a bare 0 is one. */
110
+ function lengthOf(arg) {
111
+ const m = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)(px)?$/i.exec(arg);
112
+ if (!m) return null;
113
+ const n = Number(m[1]);
114
+ if (!Number.isFinite(n)) return null;
115
+ return m[2] || n === 0 ? n : null;
85
116
  }
86
117
 
87
118
  /** A number or a percentage of one. */
@@ -283,3 +314,117 @@ export function filterPixels(src, dst, filter) {
283
314
  function clampByte(v) {
284
315
  return v <= 0 ? 0 : v >= 255 ? 255 : Math.round(v);
285
316
  }
317
+
318
+ /**
319
+ * Straight RGBA pixels, 0–255, `width` by `height`, blurred in place by a
320
+ * Gaussian of standard deviation `sigma` pixels: three box blurs across and
321
+ * three down, each as wide as the Gaussian they come to (Kovesi, "Fast
322
+ * almost-Gaussian filtering", 2010), on premultiplied colour — a blur of
323
+ * straight colour bleeds the colour of what is transparent into its edge —
324
+ * with nothing past the edge, as canvas has it.
325
+ */
326
+ export function blurPixels(pixels, width, height, sigma) {
327
+ if (!(sigma > 0) || !(width > 0 && height > 0)) return;
328
+ const n = width * height;
329
+ // premultiplied, the four channels of a pixel side by side
330
+ const plane = new Float32Array(n * 4);
331
+ for (let p = 0; p < n * 4; p += 4) {
332
+ const alpha = pixels[p + 3] / 255;
333
+ plane[p] = pixels[p] * alpha;
334
+ plane[p + 1] = pixels[p + 1] * alpha;
335
+ plane[p + 2] = pixels[p + 2] * alpha;
336
+ plane[p + 3] = pixels[p + 3];
337
+ }
338
+ const line = new Float32Array(Math.max(width, height) * 4);
339
+ for (const radius of boxRadii(sigma)) {
340
+ if (radius < 1) continue;
341
+ boxPass(plane, width, height, radius, 4, width * 4, line);
342
+ boxPass(plane, height, width, radius, width * 4, 4, line);
343
+ }
344
+ for (let p = 0; p < n * 4; p += 4) {
345
+ const alpha = plane[p + 3];
346
+ if (alpha <= 0.5) {
347
+ pixels[p] = pixels[p + 1] = pixels[p + 2] = pixels[p + 3] = 0;
348
+ continue;
349
+ }
350
+ const k = 255 / alpha;
351
+ pixels[p] = clampByte(plane[p] * k);
352
+ pixels[p + 1] = clampByte(plane[p + 1] * k);
353
+ pixels[p + 2] = clampByte(plane[p + 2] * k);
354
+ pixels[p + 3] = clampByte(alpha);
355
+ }
356
+ }
357
+
358
+ /** The radii of three box blurs that come to a Gaussian of `sigma`. */
359
+ function boxRadii(sigma) {
360
+ const passes = 3;
361
+ const ideal = Math.sqrt((12 * sigma * sigma) / passes + 1);
362
+ let lower = Math.floor(ideal);
363
+ if (lower % 2 === 0) lower -= 1;
364
+ const upper = lower + 2;
365
+ const m = Math.round(
366
+ (12 * sigma * sigma -
367
+ passes * lower * lower -
368
+ 4 * passes * lower -
369
+ 3 * passes) /
370
+ (-4 * lower - 4),
371
+ );
372
+ const out = [];
373
+ for (let i = 0; i < passes; i += 1) {
374
+ out.push(((i < m ? lower : upper) - 1) / 2);
375
+ }
376
+ return out;
377
+ }
378
+
379
+ /**
380
+ * One box blur of `radius` along every line of a plane of RGBA pixels:
381
+ * `lines` lines of `length` pixels, a pixel `step` values from the next
382
+ * along a line and a line `stride` values from the next. Past either end
383
+ * is nothing.
384
+ */
385
+ function boxPass(plane, length, lines, radius, step, stride, line) {
386
+ const scale = 1 / (2 * radius + 1);
387
+ for (let l = 0; l < lines; l += 1) {
388
+ const base = l * stride;
389
+ for (let i = 0, q = base; i < length; i += 1, q += step) {
390
+ const o = i * 4;
391
+ line[o] = plane[q];
392
+ line[o + 1] = plane[q + 1];
393
+ line[o + 2] = plane[q + 2];
394
+ line[o + 3] = plane[q + 3];
395
+ }
396
+ let r = 0;
397
+ let g = 0;
398
+ let b = 0;
399
+ let a = 0;
400
+ for (let i = 0; i <= radius && i < length; i += 1) {
401
+ const o = i * 4;
402
+ r += line[o];
403
+ g += line[o + 1];
404
+ b += line[o + 2];
405
+ a += line[o + 3];
406
+ }
407
+ for (let i = 0, q = base; i < length; i += 1, q += step) {
408
+ plane[q] = r * scale;
409
+ plane[q + 1] = g * scale;
410
+ plane[q + 2] = b * scale;
411
+ plane[q + 3] = a * scale;
412
+ const enter = i + radius + 1;
413
+ if (enter < length) {
414
+ const o = enter * 4;
415
+ r += line[o];
416
+ g += line[o + 1];
417
+ b += line[o + 2];
418
+ a += line[o + 3];
419
+ }
420
+ const leave = i - radius;
421
+ if (leave >= 0) {
422
+ const o = leave * 4;
423
+ r -= line[o];
424
+ g -= line[o + 1];
425
+ b -= line[o + 2];
426
+ a -= line[o + 3];
427
+ }
428
+ }
429
+ }
430
+ }
@@ -168,6 +168,18 @@ export async function runFrameChild(transport, options = {}) {
168
168
  }
169
169
  markFramed();
170
170
 
171
+ // The host's watchdog asks whether this process is still answering; the
172
+ // answer comes from the event loop, which is the thing it asks about.
173
+ // From the first moment, since the host asks from its hello on.
174
+ transport.onMessage((msg) => {
175
+ if (msg?.type !== 'ping') return;
176
+ try {
177
+ transport.send({ type: 'pong', seq: msg.seq });
178
+ } catch {
179
+ // the host is going away
180
+ }
181
+ });
182
+
171
183
  const hello = await new Promise((resolve) => {
172
184
  const off = transport.onMessage((msg) => {
173
185
  if (msg?.type !== 'hello') return;
@@ -146,6 +146,32 @@ function paneEnv(env, display) {
146
146
  * something else restarts the pane. Throws on what a fork cannot take,
147
147
  * which the session reports as a `spawn` failure.
148
148
  */
149
+ /** How long a pane may go without answering by default: long enough for a
150
+ * pane that is working, through a collection or a long layout, short
151
+ * enough that one that is not is noticed before the reader gives up. */
152
+ const WATCHDOG_MS = 15000;
153
+
154
+ /**
155
+ * The watchdog's limit, in ms, or 0 where the pane is not watched: `false`,
156
+ * or a pane started under the inspector — by its own flags, this process's
157
+ * that it inherits, or `NODE_OPTIONS` — which a debugger stops on purpose.
158
+ * The flags that turn the inspector on, and not its kin that only set it
159
+ * up: Node's test runner hands every test process `--inspect-port` and
160
+ * `--inspect-publish-uid`, and a pane in a test is not being debugged.
161
+ */
162
+ function watchdogMs(watchdog, execArgv) {
163
+ if (watchdog === false) return 0;
164
+ const limit = Number(watchdog);
165
+ if (!(limit > 0)) return 0;
166
+ const flags = [
167
+ ...process.execArgv,
168
+ ...(execArgv ?? []),
169
+ ...(process.env.NODE_OPTIONS ?? '').split(/\s+/),
170
+ ];
171
+ return flags.some((flag) => INSPECTING.test(flag)) ? 0 : limit;
172
+ }
173
+ const INSPECTING = /^--inspect(?:-brk|-wait)?(?:=|$)/;
174
+
149
175
  function launchOf(execArgv, env) {
150
176
  if (
151
177
  execArgv !== undefined &&
@@ -275,6 +301,7 @@ export function Frame({
275
301
  display,
276
302
  execArgv,
277
303
  env: processEnv,
304
+ watchdog = WATCHDOG_MS,
278
305
  bridge = true,
279
306
  fallback,
280
307
  focusable,
@@ -328,6 +355,8 @@ export function Frame({
328
355
  }
329
356
  const paneProcess = useRef(null);
330
357
  paneProcess.current = { execArgv: execArgv ?? [], env: processEnv ?? {} };
358
+ // how long the pane may go without answering, where it is watched at all
359
+ const watched = watchdogMs(watchdog, execArgv);
331
360
 
332
361
  // The session: one child process per (src, display, the process's flags
333
362
  // and environment, transport, restart).
@@ -382,6 +411,8 @@ export function Frame({
382
411
  fatal: null,
383
412
  sent: null,
384
413
  shutdown: null,
414
+ answered: performance.now(),
415
+ watch: null,
385
416
  };
386
417
  session.current = s;
387
418
 
@@ -410,6 +441,8 @@ export function Frame({
410
441
  pid: t.pid ?? null,
411
442
  windowId: msg.windowId,
412
443
  });
444
+ } else if (msg?.type === 'pong') {
445
+ s.answered = performance.now();
413
446
  } else if (msg?.type === 'invoke') {
414
447
  s.table.invoke(msg.id, msg.args);
415
448
  } else if (msg?.type === 'fatal') {
@@ -427,6 +460,7 @@ export function Frame({
427
460
  };
428
461
  offExit = t.onExit(({ code = null, signal = null, error } = {}) => {
429
462
  clearEscalation?.();
463
+ clearInterval(s.watch);
430
464
  detach();
431
465
  const expected = s.closing;
432
466
  if (alive && !expected) {
@@ -455,6 +489,7 @@ export function Frame({
455
489
  s.shutdown = () => {
456
490
  if (s.closing) return;
457
491
  s.closing = true;
492
+ clearInterval(s.watch);
458
493
  let sent = false;
459
494
  try {
460
495
  t.send({ type: 'unmount' });
@@ -503,6 +538,48 @@ export function Frame({
503
538
  ...(paneApp?.playsForPanes && { players: true }),
504
539
  });
505
540
 
541
+ // The watchdog. A pane whose event loop is stuck stays `running`, is
542
+ // sent updates it never reads, and on X11 holds the keys while the
543
+ // pointer is over it — and says nothing. So it is asked, about once a
544
+ // second, and answers from its event loop (`runFrameChild`); one that
545
+ // has not answered in `watchdog` ms has stopped, and goes the way a
546
+ // pane that crashed goes: failed, phase `'unresponsive'`, and its
547
+ // process ended, so `fallback` can offer to start it again. A tick of
548
+ // this side's own that comes late — the machine slept, or this
549
+ // process was the one stuck — is no news of the pane's, and the
550
+ // count starts again from it.
551
+ if (watched > 0) {
552
+ const every = Math.max(50, Math.min(1000, watched / 4));
553
+ let ticked = performance.now();
554
+ let seq = 0;
555
+ s.watch = setInterval(() => {
556
+ if (s.closing) return;
557
+ const now = performance.now();
558
+ const late = now - ticked > every * 3;
559
+ ticked = now;
560
+ if (late) s.answered = now;
561
+ else if (now - s.answered > watched) {
562
+ clearInterval(s.watch);
563
+ const message = `the pane stopped answering for ${Math.round(
564
+ now - s.answered,
565
+ )} ms`;
566
+ s.fatal = { phase: 'unresponsive', message };
567
+ fail(Object.assign(new Error(message), { phase: 'unresponsive' }));
568
+ // it cannot read an unmount: ended, from outside
569
+ t.kill?.('SIGTERM');
570
+ const kill = setTimeout(() => t.kill?.('SIGKILL'), 2000);
571
+ kill.unref?.();
572
+ return;
573
+ }
574
+ try {
575
+ t.send({ type: 'ping', seq: (seq += 1) });
576
+ } catch {
577
+ // the channel is going; the exit says the rest
578
+ }
579
+ }, every);
580
+ s.watch.unref?.();
581
+ }
582
+
506
583
  return () => {
507
584
  alive = false;
508
585
  s.shutdown();
@@ -512,7 +589,15 @@ export function Frame({
512
589
  // ask, and everything is unref'd so nothing holds the host open
513
590
  if (session.current === s) session.current = null;
514
591
  };
515
- }, [source, display, launch, generation, makeTransport, canShowPane]);
592
+ }, [
593
+ source,
594
+ display,
595
+ launch,
596
+ generation,
597
+ makeTransport,
598
+ canShowPane,
599
+ watched,
600
+ ]);
516
601
 
517
602
  // One update per commit that changed the pane's inputs, props and env in
518
603
  // the same message — so a theme flip and the state change that caused it
@@ -3,17 +3,19 @@
3
3
  // channel (the fork transport), a loopback pair (the tests), or whatever a
4
4
  // custom transport is built on.
5
5
  //
6
- // Six messages, all objects with a `type`:
6
+ // Eight messages, all objects with a `type`:
7
7
  //
8
8
  // parent → child
9
9
  // hello { protocol, src, display, rect, props, env, players? } first, once
10
10
  // update { props, env } full snapshots, one per parent commit
11
11
  // unmount {} run close handlers, unmount, exit
12
+ // ping { seq } the watchdog: still answering?
12
13
  //
13
14
  // child → parent
14
15
  // ready { windowId } the pane mounted; embed this
15
16
  // invoke { id, args } a bridged callback fired
16
17
  // fatal { phase, message, stack } why the exit about to happen is one
18
+ // pong { seq } yes, from the event loop
17
19
  //
18
20
  // Values cross by **structured clone** (fork uses `serialization:
19
21
  // 'advanced'`), so Dates, Maps, TypedArrays and cycles survive — and
@@ -33,7 +35,9 @@
33
35
  // what makes the grace window about racing messages rather than about how
34
36
  // the app was written.
35
37
 
36
- export const PROTOCOL = 1;
38
+ // 2: the watchdog's ping and pong. A pane of protocol 1 answers no ping,
39
+ // and would be ended as one that stopped; it fails the handshake instead.
40
+ export const PROTOCOL = 2;
37
41
 
38
42
  /** The marker key a function in `props` becomes on the wire. */
39
43
  export const CALLBACK = '$$reactX11FrameCallback';
package/src/node.d.ts CHANGED
@@ -90,10 +90,11 @@ export interface Context2D {
90
90
  * Canvas's: a CSS filter list applied to what is drawn after it. Native
91
91
  * contexts only (macOS, Windows), and only the colour functions —
92
92
  * `grayscale()`, `sepia()`, `saturate()`, `hue-rotate()`, `invert()`,
93
- * `brightness()`, `contrast()` and `opacity()` — over a bridge that reads
94
- * a surface's pixels back. Anything else does not stick: assign and read
95
- * back to know. Absent on X11, whose server runs no colour matrix and
96
- * answers a read a round trip later.
93
+ * `brightness()`, `contrast()` and `opacity()` — and `blur()`, which
94
+ * blurs what `drawImage` draws and leaves fills, strokes and text sharp,
95
+ * over a bridge that reads a surface's pixels back. Anything else does
96
+ * not stick: assign and read back to know. Absent on X11, whose server
97
+ * runs no colour matrix and answers a read a round trip later.
97
98
  */
98
99
  filter?: string;
99
100
  save(): void;
@@ -16,7 +16,7 @@ export type FrameProps = Record<string, unknown>;
16
16
  /** Why a pane is not running, for `fallback` and `onExit` to read. */
17
17
  export interface FrameError extends Error {
18
18
  /** Where it went wrong: `'spawn' | 'load' | 'connect' | 'handshake' |
19
- * 'runtime' | 'send' | 'embed' | 'exit'`. */
19
+ * 'runtime' | 'send' | 'embed' | 'unresponsive' | 'exit'`. */
20
20
  phase?: string;
21
21
  code?: number | null;
22
22
  signal?: string | null;
@@ -53,6 +53,14 @@ export interface FrameComponentProps {
53
53
  * the same flags in a new array do not.
54
54
  */
55
55
  execArgv?: readonly string[];
56
+ /**
57
+ * How long the pane may go without answering, in ms, before it is taken
58
+ * for one whose event loop is stuck: failed with phase `'unresponsive'`,
59
+ * its process ended, and `fallback` shown. Asked about once a second,
60
+ * from its event loop. Default 15000; `false` never. A pane started
61
+ * under the inspector is never watched.
62
+ */
63
+ watchdog?: number | false;
56
64
  /**
57
65
  * Environment variables for the pane's process, over this one's; a value
58
66
  * of `undefined` takes a variable away. `DISPLAY` is `display`'s and
@@ -255,7 +255,11 @@ export class WaylandGLContext {
255
255
  if (this.destroyed) return null;
256
256
  const w = this.window.bufferWidth;
257
257
  const h = this.window.bufferHeight;
258
- const surface = this.chain.surfaceFor(w, h);
258
+ // a window whose viewport shows part of a buffer keeps its buffers
259
+ // through a resize that fits them (`surfaceFor`'s slack)
260
+ const surface = this.chain.surfaceFor(w, h, {
261
+ slack: this.window.showsPartOfBuffer === true,
262
+ });
259
263
  this._surface = surface;
260
264
  this.gpu.makeCurrent(surface);
261
265
  let resized = false;
@@ -321,11 +325,12 @@ export class WaylandGLContext {
321
325
  // next buffer might be missing. Then the GPU work is settled (optionally
322
326
  // through a fence rather than a stall) before the swap hands it over.
323
327
  const rects = this.chain.blitHint(damage);
328
+ // into the top left of a buffer that may be larger than the window
324
329
  this.backing.blitTo(
325
330
  null,
326
331
  rects === 'all' ? null : rects,
327
- this.chain.width,
328
- this.chain.height,
332
+ this.chain.allocWidth,
333
+ this.chain.allocHeight,
329
334
  );
330
335
  await this._settleGpu();
331
336
 
@@ -338,6 +343,9 @@ export class WaylandGLContext {
338
343
  // is EGL_BAD_SURFACE (0x300d): the second window of examples/windows.jsx.
339
344
  this.gpu.makeCurrent(this._surface);
340
345
  const presented = await this.chain.swap(damage);
346
+ // the part of the buffer just attached that is the window — only with a
347
+ // buffer, since the one shown otherwise is the last, of its own size
348
+ if (presented) win.showBufferPart?.(this.chain.width, this.chain.height);
341
349
  // `swap` attached and damaged but did not commit; and when nothing went
342
350
  // out — every buffer busy — the frame request still needs a commit to be
343
351
  // delivered. Either way, exactly one commit.
@@ -59,6 +59,20 @@ import { importDmabuf, DmabufRefused } from './dmabuf.js';
59
59
  /** How many frames may be in the compositor's hands at once. */
60
60
  const DEFAULT_MAX_IN_FLIGHT = 2;
61
61
 
62
+ /**
63
+ * Buffers are made a multiple of this larger than the window where they
64
+ * may be (`slack`), so that a resize a few pixels at a time — a window edge
65
+ * dragged — shows a different part of the same buffers rather than making
66
+ * new ones at every step.
67
+ */
68
+ const SLACK_STEP = 128;
69
+ /** A visible area this many times smaller than the buffers makes them
70
+ * again, smaller: a window shrunk far is not to hold its old size's
71
+ * memory. */
72
+ const SLACK_SHRINK = 4;
73
+ /** Frames at one size before buffers grown for a drag are cut back to it. */
74
+ const SLACK_SETTLE = 60;
75
+
62
76
  /**
63
77
  * Damage sets are kept small: past a handful of rectangles the bookkeeping
64
78
  * costs more than the full-surface copy it is trying to avoid, and the
@@ -92,8 +106,14 @@ export class WaylandSwapchain {
92
106
  this.gbm = null;
93
107
  /** `${generation}:${key}` -> { wlBuffer, busy, damage, key, generation } */
94
108
  this.buffers = new Map();
109
+ /** the part of each buffer shown: the window's buffer size */
95
110
  this.width = 0;
96
111
  this.height = 0;
112
+ /** the buffers' own size, at least the part shown (`slack`) */
113
+ this.allocWidth = 0;
114
+ this.allocHeight = 0;
115
+ /** frames since the size shown last changed */
116
+ this._sameSize = 0;
97
117
  /** set once the compositor has refused a tiled buffer */
98
118
  this.linear = false;
99
119
  /** swaps since a buffer was last seen for the first time */
@@ -111,14 +131,66 @@ export class WaylandSwapchain {
111
131
  /**
112
132
  * Point the chain at a size.
113
133
  *
134
+ * With `slack` — where the window has a viewport, whose source rectangle
135
+ * shows part of a buffer — the buffers are made larger than the size and
136
+ * kept while it fits in them: dragging a window's edge used to make the
137
+ * chain's buffers again at every step, and import each one into the
138
+ * compositor, a dozen requests and a GPU allocation a step. Without it,
139
+ * the buffers are the size exactly, as a surface with no viewport shows
140
+ * all of its buffer.
141
+ *
142
+ * @param {number} width
143
+ * @param {number} height
144
+ * @param {{ slack?: boolean }} [opts]
114
145
  * @returns the GBM surface to draw into
115
146
  */
116
- surfaceFor(width, height) {
147
+ surfaceFor(width, height, { slack = false } = {}) {
117
148
  const w = Math.max(1, Math.round(width));
118
149
  const h = Math.max(1, Math.round(height));
119
- if (!this.gbm) return this._create(w, h);
120
- if (w === this.width && h === this.height) return this.gbm;
150
+ if (!this.gbm) {
151
+ const [aw, ah] = slack ? slackFor(w, h, 1) : [w, h];
152
+ this._create(aw, ah);
153
+ this.width = w;
154
+ this.height = h;
155
+ return this.gbm;
156
+ }
157
+ if (w === this.width && h === this.height) {
158
+ // A size held a while is given buffers its own size again, where a
159
+ // drag left them larger than it needs.
160
+ if (
161
+ ++this._sameSize === SLACK_SETTLE &&
162
+ (this.allocWidth !== w || this.allocHeight !== h)
163
+ ) {
164
+ const [aw, ah] = slack ? slackFor(w, h, 1) : [w, h];
165
+ if (aw !== this.allocWidth || ah !== this.allocHeight)
166
+ this._resize(aw, ah);
167
+ }
168
+ return this.gbm;
169
+ }
170
+ this._sameSize = 0;
171
+ if (
172
+ slack &&
173
+ w <= this.allocWidth &&
174
+ h <= this.allocHeight &&
175
+ w * h * SLACK_SHRINK >= this.allocWidth * this.allocHeight
176
+ ) {
177
+ // The buffers hold it: show another part of them. What each holds is
178
+ // of the old size, so all of it is owed to it.
179
+ this.width = w;
180
+ this.height = h;
181
+ for (const entry of this.buffers.values()) entry.damage = 'all';
182
+ return this.gbm;
183
+ }
184
+ const growing = w > this.allocWidth || h > this.allocHeight;
185
+ const [aw, ah] = slack ? slackFor(w, h, growing ? 1.25 : 1) : [w, h];
186
+ this._resize(aw, ah);
187
+ this.width = w;
188
+ this.height = h;
189
+ return this.gbm;
190
+ }
121
191
 
192
+ /** Give the chain buffers of this size, keeping its GBM surface. */
193
+ _resize(w, h) {
122
194
  // In place: the handle stays valid, the context keeps its GL objects and
123
195
  // stays current. Buffers from the old generation are gone, so their
124
196
  // `wl_buffer`s go with them — the compositor may still be showing one, so
@@ -132,14 +204,13 @@ export class WaylandSwapchain {
132
204
  { cause: err },
133
205
  ),
134
206
  );
135
- return this.gbm;
207
+ return;
136
208
  }
137
209
  this._dropBuffers();
138
- this.width = w;
139
- this.height = h;
210
+ this.allocWidth = w;
211
+ this.allocHeight = h;
140
212
  this.inFlight = 0;
141
213
  this._swapsSinceNew = 0;
142
- return this.gbm;
143
214
  }
144
215
 
145
216
  _create(w, h) {
@@ -157,8 +228,8 @@ export class WaylandSwapchain {
157
228
  { cause: err },
158
229
  );
159
230
  }
160
- this.width = w;
161
- this.height = h;
231
+ this.allocWidth = w;
232
+ this.allocHeight = h;
162
233
  this._swapsSinceNew = 0;
163
234
  return this.gbm;
164
235
  }
@@ -285,7 +356,8 @@ export class WaylandSwapchain {
285
356
  this.policy.linearFallback &&
286
357
  !this.linear
287
358
  ) {
288
- const { width, height } = this;
359
+ // the buffers again, at the size they were: the part shown stays
360
+ const { allocWidth: width, allocHeight: height } = this;
289
361
  this.linear = true;
290
362
  this._dropBuffers();
291
363
  try {
@@ -409,3 +481,14 @@ export function unionDamage(a, b) {
409
481
  const merged = [...a, ...b];
410
482
  return merged.length > MAX_RECTS ? 'all' : merged;
411
483
  }
484
+
485
+ /**
486
+ * Buffers for a size, with room to grow: each side rounded up to a
487
+ * multiple of `SLACK_STEP`, after `grow` — 1.25 for a size outgrowing the
488
+ * buffers it had, so a window dragged wider makes a few sets of buffers
489
+ * rather than one a bucket.
490
+ */
491
+ function slackFor(w, h, grow) {
492
+ const up = (v) => Math.ceil((v * grow) / SLACK_STEP) * SLACK_STEP;
493
+ return [up(w), up(h)];
494
+ }
@@ -182,6 +182,8 @@ export class WaylandWindow extends EventEmitter {
182
182
  this._onConfigured = resolve;
183
183
  });
184
184
  this._viewport = null;
185
+ /** the part of the buffer the viewport shows, once one is attached */
186
+ this._shownPart = null;
185
187
  this._fractional = null;
186
188
  this._geometry = null;
187
189
  /** the xdg-decoration object, where the compositor has the protocol (ssd.js) */
@@ -545,6 +547,31 @@ export class WaylandWindow extends EventEmitter {
545
547
  }
546
548
  }
547
549
 
550
+ /**
551
+ * Whether the surface shows a part of its buffer, the top left
552
+ * `bufferWidth` by `bufferHeight` of it, rather than all of it: where the
553
+ * compositor has a viewport to say so. The buffer may then be larger than
554
+ * the window, and is kept as the window is resized within it.
555
+ */
556
+ get showsPartOfBuffer() {
557
+ return this._viewport !== null;
558
+ }
559
+
560
+ /**
561
+ * Show the top left `width` by `height` of the buffer the next commit
562
+ * attaches (`showsPartOfBuffer`). Only with a commit that attaches one at
563
+ * least that large: a source rectangle past the buffer shown is a protocol
564
+ * error, and a commit with no new buffer — every buffer busy, a configure
565
+ * acked — shows the old one. In buffer pixels, the buffer scale being 1.
566
+ */
567
+ showBufferPart(width, height) {
568
+ if (!this._viewport) return;
569
+ const shown = this._shownPart;
570
+ if (shown && shown.width === width && shown.height === height) return;
571
+ this._shownPart = { width, height };
572
+ this._viewport.$.set_source(0, 0, width, height);
573
+ }
574
+
548
575
  /** The size the backing buffer has to be for the current scale. */
549
576
  get bufferWidth() {
550
577
  return Math.max(1, Math.round(this.width * this.scale));