react-x11 2.24.1 → 2.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.24.1",
3
+ "version": "2.25.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -103,15 +103,15 @@
103
103
  "dependencies": {
104
104
  "ical.js": "^2.2.1",
105
105
  "linebreak": "^1.1.0",
106
- "ntk": "^8.14.3",
106
+ "ntk": "^8.16.1",
107
107
  "react-reconciler": "^0.34.0",
108
108
  "yoga-layout": "^3.2.1"
109
109
  },
110
110
  "optionalDependencies": {
111
- "@windowkit/appkit": "^0.16.0",
111
+ "@windowkit/appkit": "^0.17.0",
112
112
  "@windowkit/wayland": "^3.1.1",
113
- "@windowkit/win32": "^0.0.5",
114
- "dbus-native": "^0.15.1",
113
+ "@windowkit/win32": "^0.0.6",
114
+ "dbus-native": "^0.16.0",
115
115
  "x11-dri": "^0.9.0"
116
116
  },
117
117
  "peerDependencies": {
@@ -25,6 +25,10 @@
25
25
  // resize — `_gen` is that generation.
26
26
  import { cssColorStraight } from 'ntk/color';
27
27
 
28
+ import { normalizeRadii, planShadowTiles } from 'ntk/shadow-tiles';
29
+
30
+ import { shadowTilesFor } from './shadowtiles.js';
31
+
28
32
  const BLACK = [0, 0, 0, 1];
29
33
 
30
34
  // A colour string is parsed once: a frame over a large tree sets the same
@@ -244,9 +248,56 @@ const P_RECT = 5;
244
248
  const P_ROUND = 6;
245
249
  const P_ARC = 7;
246
250
  const P_ELLIPSE = 8;
251
+ /** A rounded rect whose corners are elliptical: x y w h, then each corner's
252
+ * x and y radius, top left first. A bridge answering `ctxRoundRectXY`
253
+ * reads it in `ctxPath` too; one that does not is sent the curves. */
254
+ const P_ROUND_XY = 9;
247
255
  /** how many numbers each op carries, and what it costs a chunk's budget */
248
- const P_ARGS = [2, 2, 6, 4, 0, 4, 8, 6, 4];
249
- const P_POINTS = [1, 1, 3, 2, 0, 4, 8, 8, 4];
256
+ const P_ARGS = [2, 2, 6, 4, 0, 4, 8, 6, 4, 12];
257
+ const P_POINTS = [1, 1, 3, 2, 0, 4, 8, 8, 4, 8];
258
+
259
+ /** A quarter ellipse as one cubic: how far along each tangent its handles
260
+ * reach, as a fraction of the radius. */
261
+ const KAPPA = 0.5522847498307936;
262
+
263
+ /**
264
+ * An elliptical-cornered rounded rect as lines and curves, through `to` —
265
+ * a bridge with no `ctxRoundRectXY`. `c[a]…` is the op's twelve numbers.
266
+ */
267
+ function roundRectCurves(to, c, a) {
268
+ const x = c[a];
269
+ const y = c[a + 1];
270
+ const r = x + c[a + 2];
271
+ const b = y + c[a + 3];
272
+ const [tlx, tly, trx, tRy, brx, bry, blx, bly] = [
273
+ c[a + 4],
274
+ c[a + 5],
275
+ c[a + 6],
276
+ c[a + 7],
277
+ c[a + 8],
278
+ c[a + 9],
279
+ c[a + 10],
280
+ c[a + 11],
281
+ ];
282
+ to.move(x + tlx, y);
283
+ to.line(r - trx, y);
284
+ if (trx > 0 && tRy > 0) {
285
+ to.curve(r - trx * (1 - KAPPA), y, r, y + tRy * (1 - KAPPA), r, y + tRy);
286
+ }
287
+ to.line(r, b - bry);
288
+ if (brx > 0 && bry > 0) {
289
+ to.curve(r, b - bry * (1 - KAPPA), r - brx * (1 - KAPPA), b, r - brx, b);
290
+ }
291
+ to.line(x + blx, b);
292
+ if (blx > 0 && bly > 0) {
293
+ to.curve(x + blx * (1 - KAPPA), b, x, b - bly * (1 - KAPPA), x, b - bly);
294
+ }
295
+ to.line(x, y + tly);
296
+ if (tlx > 0 && tly > 0) {
297
+ to.curve(x, y + tly * (1 - KAPPA), x + tlx * (1 - KAPPA), y, x + tlx, y);
298
+ }
299
+ to.close();
300
+ }
250
301
 
251
302
  /**
252
303
  * A chunk closes at the first subpath boundary past either budget. Swept
@@ -265,6 +316,77 @@ const STROKE_CHUNK_SUBPATHS = 128;
265
316
  */
266
317
  const NO_STROKE_CHUNKING = process.env.REACT_X11_NO_STROKE_CHUNKING === '1';
267
318
 
319
+ const SQUARE = Object.freeze([
320
+ { x: 0, y: 0 },
321
+ { x: 0, y: 0 },
322
+ { x: 0, y: 0 },
323
+ { x: 0, y: 0 },
324
+ ]);
325
+
326
+ /**
327
+ * The shape the recorded op at `i` draws, where it is one a shadow tile
328
+ * knows — a `rect`, or a `roundRect` with circular or elliptical corners —
329
+ * as `{ x, y, w, h, corners, end }`, `end` the index of the op after it;
330
+ * null for any other op.
331
+ */
332
+ function shapeAt(c, i) {
333
+ const op = c[i];
334
+ const end = i + 1 + P_ARGS[op];
335
+ if (op === P_RECT) {
336
+ let [x, y, w, h] = [c[i + 1], c[i + 2], c[i + 3], c[i + 4]];
337
+ if (w < 0) ((x += w), (w = -w));
338
+ if (h < 0) ((y += h), (h = -h));
339
+ return { x, y, w, h, corners: SQUARE, end };
340
+ }
341
+ if (op === P_ROUND) {
342
+ const r = (v) => ({ x: v, y: v });
343
+ return {
344
+ x: c[i + 1],
345
+ y: c[i + 2],
346
+ w: c[i + 3],
347
+ h: c[i + 4],
348
+ corners: [r(c[i + 5]), r(c[i + 6]), r(c[i + 7]), r(c[i + 8])],
349
+ end,
350
+ };
351
+ }
352
+ if (op === P_ROUND_XY) {
353
+ const r = (j) => ({ x: c[i + j], y: c[i + j + 1] });
354
+ return {
355
+ x: c[i + 1],
356
+ y: c[i + 2],
357
+ w: c[i + 3],
358
+ h: c[i + 4],
359
+ corners: [r(5), r(7), r(9), r(11)],
360
+ end,
361
+ };
362
+ }
363
+ return null;
364
+ }
365
+
366
+ /** Whether shape `b` lies inside shape `a`'s box. */
367
+ function contains(a, b) {
368
+ return (
369
+ b.x >= a.x && b.y >= a.y && b.x + b.w <= a.x + a.w && b.y + b.h <= a.y + a.h
370
+ );
371
+ }
372
+
373
+ /** Whether two `{ x, y, width, height }` rects share any area. */
374
+ function meets(a, b) {
375
+ return (
376
+ a.x < b.x + b.width &&
377
+ b.x < a.x + a.width &&
378
+ a.y < b.y + b.height &&
379
+ b.y < a.y + a.height
380
+ );
381
+ }
382
+
383
+ /**
384
+ * The off switch for shadow tiles (src/backend/shadowtiles.js): every
385
+ * shadow drawn by the bridge, as before them. For measuring the tiles
386
+ * against the live blur, and as first aid. Read once.
387
+ */
388
+ const NO_SHADOW_TILES = process.env.REACT_X11_NO_SHADOW_TILES === '1';
389
+
268
390
  /**
269
391
  * A solid ink for `drawGlyphs` — ntk's `createSolidPicture` answers an
270
392
  * XRender picture; here it is the colour itself. Premultiplied 0..1 in, as
@@ -340,6 +462,13 @@ export class BackendContext2D {
340
462
  'ctxPath' in native && typeof native.ctxPath === 'function';
341
463
  this._pathBuf = null;
342
464
  this._strokeChunking = !NO_STROKE_CHUNKING;
465
+ // Elliptical corners go over whole where the bridge takes them; the
466
+ // count of them in the recorded path is what tells `_emit` whether a
467
+ // whole path handed to `ctxPath` needs them spelled as curves first.
468
+ this._roundXY =
469
+ 'ctxRoundRectXY' in native && typeof native.ctxRoundRectXY === 'function';
470
+ this._xyOps = 0;
471
+ this._shadowTiles = !NO_SHADOW_TILES;
343
472
  }
344
473
 
345
474
  /**
@@ -388,6 +517,7 @@ export class BackendContext2D {
388
517
  // the path went with the surface it was built on; nothing may
389
518
  // replay it onto the new one
390
519
  this._cmds.length = 0;
520
+ this._xyOps = 0;
391
521
  this._pathStale = false;
392
522
  }
393
523
  return surface;
@@ -745,6 +875,11 @@ export class BackendContext2D {
745
875
  const c = this._cmds;
746
876
  const n = this._native;
747
877
  if (this._bulkPaths) {
878
+ if (this._xyOps > 0 && !this._roundXY) {
879
+ // a bridge that does not read op 9 would stop at it
880
+ n.ctxPath(surface, this._spelledPath(from, to));
881
+ return;
882
+ }
748
883
  // one call, through a buffer kept between paths — the record is the
749
884
  // same stream the bridge parses, arguments and all
750
885
  const count = to - from;
@@ -803,12 +938,36 @@ export class BackendContext2D {
803
938
  );
804
939
  else if (op === P_ELLIPSE)
805
940
  n.ctxEllipse(surface, c[a], c[a + 1], c[a + 2], c[a + 3]);
941
+ else if (op === P_ROUND_XY) this._emitRoundXY(surface, c, a);
806
942
  i += 1 + P_ARGS[op];
807
943
  }
808
944
  }
809
945
 
946
+ /** The record `[from, to)` as a stream for a bridge that does not know
947
+ * op 9: each elliptical rounded rect spelled as the lines and curves
948
+ * it is, everything else as it was. */
949
+ _spelledPath(from, to) {
950
+ const c = this._cmds;
951
+ const out = [];
952
+ const spell = {
953
+ move: (x, y) => out.push(P_MOVE, x, y),
954
+ line: (x, y) => out.push(P_LINE, x, y),
955
+ curve: (ax, ay, bx, by, x, y) => out.push(P_CURVE, ax, ay, bx, by, x, y),
956
+ close: () => out.push(P_CLOSE),
957
+ };
958
+ for (let i = from; i < to;) {
959
+ const op = c[i];
960
+ const end = i + 1 + P_ARGS[op];
961
+ if (op === P_ROUND_XY) roundRectCurves(spell, c, i + 1);
962
+ else for (let j = i; j < end; j++) out.push(c[j]);
963
+ i = end;
964
+ }
965
+ return new Float64Array(out);
966
+ }
967
+
810
968
  beginPath() {
811
969
  this._cmds.length = 0;
970
+ this._xyOps = 0;
812
971
  if (this._bulkPaths) {
813
972
  this._s();
814
973
  // uploaded, empty or not, when something paints it
@@ -849,24 +1008,93 @@ export class BackendContext2D {
849
1008
  if (surface !== null) this._native.ctxRect(surface, x, y, w, h);
850
1009
  }
851
1010
 
1011
+ /**
1012
+ * Canvas's `roundRect`: `radii` a number, a `{ x, y }` point for an
1013
+ * elliptical corner, or a list of one to four of either, scaled down
1014
+ * together where two corners on a side would overlap (HTML, "roundRect").
1015
+ * Circular corners go to the bridge as they always did; elliptical ones
1016
+ * as `ctxRoundRectXY` where the bridge has it, and as curves where not.
1017
+ */
852
1018
  roundRect(x, y, w, h, radii) {
853
- let r = radii ?? 0;
854
- if (typeof r === 'number') r = [r, r, r, r];
855
- else if (r.length === 1) r = [r[0], r[0], r[0], r[0]];
856
- else if (r.length === 2) r = [r[0], r[1], r[0], r[1]];
857
- else if (r.length === 3) r = [r[0], r[1], r[2], r[1]];
858
- const cap = Math.min(Math.abs(w) / 2, Math.abs(h) / 2);
859
- const clamp = (v) => Math.max(0, Math.min(Number(v) || 0, cap));
1019
+ if (![x, y, w, h].every(Number.isFinite)) return;
1020
+ // an ellipse with no extent on one axis is no curve at all: the corner
1021
+ // is square, as the spec's arc of nothing leaves it
1022
+ let corners = normalizeRadii(Math.abs(w), Math.abs(h), radii).map((r) =>
1023
+ r.x > 0 && r.y > 0 ? r : { x: 0, y: 0 },
1024
+ );
1025
+ // a negative extent is the same box drawn from its other side: the
1026
+ // corners swap across the axis it runs back along
1027
+ if (w < 0) {
1028
+ x += w;
1029
+ w = -w;
1030
+ corners = [corners[1], corners[0], corners[3], corners[2]];
1031
+ }
1032
+ if (h < 0) {
1033
+ y += h;
1034
+ h = -h;
1035
+ corners = [corners[3], corners[2], corners[1], corners[0]];
1036
+ }
1037
+ const [tl, tr, br, bl] = corners;
860
1038
  const surface = this._pathFor();
861
- const [r0, r1, r2, r3] = [
862
- clamp(r[0]),
863
- clamp(r[1]),
864
- clamp(r[2]),
865
- clamp(r[3]),
866
- ];
867
- this._cmds.push(P_ROUND, x, y, w, h, r0, r1, r2, r3);
868
- if (surface !== null)
869
- this._native.ctxRoundRect(surface, x, y, w, h, r0, r1, r2, r3);
1039
+ if (tl.x === tl.y && tr.x === tr.y && br.x === br.y && bl.x === bl.y) {
1040
+ this._cmds.push(P_ROUND, x, y, w, h, tl.x, tr.x, br.x, bl.x);
1041
+ if (surface !== null)
1042
+ this._native.ctxRoundRect(surface, x, y, w, h, tl.x, tr.x, br.x, bl.x);
1043
+ return;
1044
+ }
1045
+ const at = this._cmds.length + 1;
1046
+ this._cmds.push(
1047
+ P_ROUND_XY,
1048
+ x,
1049
+ y,
1050
+ w,
1051
+ h,
1052
+ tl.x,
1053
+ tl.y,
1054
+ tr.x,
1055
+ tr.y,
1056
+ br.x,
1057
+ br.y,
1058
+ bl.x,
1059
+ bl.y,
1060
+ );
1061
+ this._xyOps++;
1062
+ if (surface !== null) this._emitRoundXY(surface, this._cmds, at);
1063
+ }
1064
+
1065
+ /** One elliptical-cornered rounded rect, from the record at `a`, onto a
1066
+ * bridge's path: whole where it takes one, as curves where not. */
1067
+ _emitRoundXY(surface, c, a) {
1068
+ const n = this._native;
1069
+ if (this._roundXY) {
1070
+ n.ctxRoundRectXY(
1071
+ surface,
1072
+ c[a],
1073
+ c[a + 1],
1074
+ c[a + 2],
1075
+ c[a + 3],
1076
+ c[a + 4],
1077
+ c[a + 5],
1078
+ c[a + 6],
1079
+ c[a + 7],
1080
+ c[a + 8],
1081
+ c[a + 9],
1082
+ c[a + 10],
1083
+ c[a + 11],
1084
+ );
1085
+ return;
1086
+ }
1087
+ roundRectCurves(
1088
+ {
1089
+ move: (px, py) => n.ctxMoveTo(surface, px, py),
1090
+ line: (px, py) => n.ctxLineTo(surface, px, py),
1091
+ curve: (ax, ay, bx, by, px, py) =>
1092
+ n.ctxCurveTo(surface, ax, ay, bx, by, px, py),
1093
+ close: () => n.ctxClosePath(surface),
1094
+ },
1095
+ c,
1096
+ a,
1097
+ );
870
1098
  }
871
1099
 
872
1100
  arc(x, y, radius, start, end, anticlockwise = false) {
@@ -1020,6 +1248,13 @@ export class BackendContext2D {
1020
1248
  const hasPath = pathOrRule != null && typeof pathOrRule === 'object';
1021
1249
  const rule = hasPath ? maybeRule : pathOrRule;
1022
1250
  if (hasPath && !this._replayPath(pathOrRule)) return;
1251
+ if (this._castsShadow() && this._tiledShadow(rule === 'evenodd')) {
1252
+ this._unshadowed(() => this._fillPath(rule));
1253
+ } else this._fillPath(rule);
1254
+ this._dirty();
1255
+ }
1256
+
1257
+ _fillPath(rule) {
1023
1258
  this._path();
1024
1259
  const style = this._state.fillStyle;
1025
1260
  if (style instanceof LinearGradient) {
@@ -1036,7 +1271,111 @@ export class BackendContext2D {
1036
1271
  this._applyFill();
1037
1272
  this._native.ctxFill(this._s(), rule === 'evenodd');
1038
1273
  }
1039
- this._dirty();
1274
+ }
1275
+
1276
+ // --- shadows from tiles (src/backend/shadowtiles.js) -----------------------
1277
+
1278
+ /** Whether what is filled now casts a blurred shadow a tile could draw:
1279
+ * one there, in a colour, composited the ordinary way. */
1280
+ _castsShadow() {
1281
+ if (!this._shadowTiles) return false;
1282
+ const st = this._state;
1283
+ return (
1284
+ st.shadowBlur > 0 &&
1285
+ st.gco === 'source-over' &&
1286
+ parseColor(st.shadowColor)[3] > 0
1287
+ );
1288
+ }
1289
+
1290
+ /**
1291
+ * The current path's shadow, from a tile, where the path is a shape a
1292
+ * tile draws: one `rect` or `roundRect`, or — filled `evenodd` — two of
1293
+ * them one inside the other, the frame an inset box shadow is cast
1294
+ * around. False for anything else, and the bridge draws it.
1295
+ */
1296
+ _tiledShadow(evenodd) {
1297
+ const c = this._cmds;
1298
+ const first = shapeAt(c, 0);
1299
+ if (!first) return false;
1300
+ if (first.end === c.length) return this._drawTiledShadow(first, null);
1301
+ if (!evenodd) return false;
1302
+ const second = shapeAt(c, first.end);
1303
+ if (!second || second.end !== c.length) return false;
1304
+ if (contains(first, second)) return this._drawTiledShadow(first, second);
1305
+ if (contains(second, first)) return this._drawTiledShadow(second, first);
1306
+ return false;
1307
+ }
1308
+
1309
+ /**
1310
+ * Draw the shadow of `outer` — less `inner`, where there is one — from a
1311
+ * tile: in device pixels, moved by the shadow's offset, which, like its
1312
+ * blur, the transform does not scale (HTML, "shadows"). A transform
1313
+ * that rotates, skews or mirrors is not one a tile can follow.
1314
+ */
1315
+ _drawTiledShadow(outer, inner) {
1316
+ const st = this._state;
1317
+ const [a, b, c, d, e, f] = st.ctm;
1318
+ if (b !== 0 || c !== 0 || !(a > 0) || !(d > 0)) return false;
1319
+ const device = (r) => {
1320
+ const x0 = Math.round(a * r.x + e + st.shadowOffsetX);
1321
+ const y0 = Math.round(d * r.y + f + st.shadowOffsetY);
1322
+ return {
1323
+ x0,
1324
+ y0,
1325
+ x1: Math.round(a * (r.x + r.w) + e + st.shadowOffsetX),
1326
+ y1: Math.round(d * (r.y + r.h) + f + st.shadowOffsetY),
1327
+ corners: r.corners.map((k) => ({ x: k.x * a, y: k.y * d })),
1328
+ };
1329
+ };
1330
+ const o = device(outer);
1331
+ if (![o.x0, o.y0, o.x1, o.y1].every(Number.isFinite)) return false;
1332
+ // a shape with nothing in it casts nothing
1333
+ if (!(o.x1 > o.x0 && o.y1 > o.y0)) return true;
1334
+ let hole = inner && device(inner);
1335
+ if (hole && !(hole.x1 > hole.x0 && hole.y1 > hole.y0)) hole = null;
1336
+ const plan = planShadowTiles(o, hole, st.shadowBlur);
1337
+ if (!plan) return false;
1338
+ const clip = st.clip === NON_RECT ? null : st.clip;
1339
+ if (clip && !meets(plan.bounds, clip)) return true;
1340
+ const tile = shadowTilesFor(this._native).get(
1341
+ plan,
1342
+ parseColor(st.shadowColor),
1343
+ );
1344
+ if (tile == null) return false;
1345
+ const s = this._s();
1346
+ const n = this._native;
1347
+ n.ctxSave(s);
1348
+ n.ctxSetShadow(s, 0, 0, 0, 0, 0, 0, 0);
1349
+ for (const [sx, sy, sw, sh, dx, dy, dw, dh] of plan.pieces) {
1350
+ if (clip && !meets({ x: dx, y: dy, width: dw, height: dh }, clip))
1351
+ continue;
1352
+ n.ctxDrawSurface(
1353
+ s,
1354
+ tile,
1355
+ sx,
1356
+ sy,
1357
+ sw,
1358
+ sh,
1359
+ (dx - e) / a,
1360
+ (dy - f) / d,
1361
+ dw / a,
1362
+ dh / d,
1363
+ );
1364
+ }
1365
+ n.ctxRestore(s);
1366
+ // the shape is filled next, from the record: nothing above may have
1367
+ // left the bridge's own copy of the path as it was
1368
+ this._pathStale = true;
1369
+ return true;
1370
+ }
1371
+
1372
+ /** `fn`'s drawing with no shadow: the tile has drawn it already. */
1373
+ _unshadowed(fn) {
1374
+ const s = this._s();
1375
+ this._native.ctxSave(s);
1376
+ this._native.ctxSetShadow(s, 0, 0, 0, 0, 0, 0, 0);
1377
+ fn();
1378
+ this._native.ctxRestore(s);
1040
1379
  }
1041
1380
 
1042
1381
  stroke(path) {
@@ -1129,6 +1468,16 @@ export class BackendContext2D {
1129
1468
 
1130
1469
  fillRect(x, y, w, h) {
1131
1470
  if (!(w > 0) || !(h > 0)) return;
1471
+ if (
1472
+ this._castsShadow() &&
1473
+ this._drawTiledShadow({ x, y, w, h, corners: SQUARE }, null)
1474
+ ) {
1475
+ this._unshadowed(() => this._fillRectNow(x, y, w, h));
1476
+ } else this._fillRectNow(x, y, w, h);
1477
+ this._dirty();
1478
+ }
1479
+
1480
+ _fillRectNow(x, y, w, h) {
1132
1481
  const style = this._state.fillStyle;
1133
1482
  if (style instanceof LinearGradient) {
1134
1483
  const { coords, flat } = style._normalized();
@@ -1148,7 +1497,6 @@ export class BackendContext2D {
1148
1497
  this._applyFill();
1149
1498
  this._native.ctxFillRect(this._s(), x, y, w, h);
1150
1499
  }
1151
- this._dirty();
1152
1500
  }
1153
1501
 
1154
1502
  fillRects(rects) {
@@ -0,0 +1,84 @@
1
+ // The tiles blurred shadows are drawn from, on a bridge's own surfaces.
2
+ //
3
+ // A gaussian over a shape costs its area times its width, and CoreGraphics
4
+ // pays it on every `fill` that has a shadow set — a header's
5
+ // `box-shadow: inset 0 0 100px` was 130 to 380 ms of each scroll frame on a
6
+ // 2x display, and a card's 200px hover shadow 110 ms, on every strip a
7
+ // scroll exposed across it. Direct2D draws no live shadow at all (the win32
8
+ // bridge records one and draws nothing). ntk's `ntk/shadow-tiles` plans
9
+ // the shadow of a rect, a rounded rect or a rect with a rounded hole as a
10
+ // small tile and nine pieces of it — the corners as they are, a straight
11
+ // pixel stretched between them — and makes the tile's pixels; this keeps
12
+ // the tiles, as surfaces of the bridge's, and `BackendContext2D` draws the
13
+ // pieces (src/backend/context2d.js, `_drawTiledShadow`).
14
+ //
15
+ // A surface here has no coverage format to composite through a colour, as
16
+ // X's `a8` does, so a tile is made in its colour and the colour is part of
17
+ // its name: one per shadow colour in use, rather than one per shadow.
18
+
19
+ import { rasterShadowTile } from 'ntk/shadow-tiles';
20
+
21
+ /** What the tiles of one bridge may hold, least recently drawn let go
22
+ * first. A card's tile is tens of kilobytes. */
23
+ export const TILE_BUDGET = 8 << 20;
24
+
25
+ /**
26
+ * The tiles made for one bridge, least recently drawn let go first once
27
+ * they hold more than `budget` bytes. There is one bridge in a process, so
28
+ * every window of an app shares them.
29
+ */
30
+ export class ShadowTileCache {
31
+ constructor(native, budget = TILE_BUDGET) {
32
+ this._native = native;
33
+ this._budget = budget;
34
+ this._tiles = new Map();
35
+ this._bytes = 0;
36
+ this.stats = { made: 0, hits: 0, released: 0 };
37
+ }
38
+
39
+ /** The tile for `plan` in `rgba` (0..1 each), made now if it is not
40
+ * held; null where the bridge made no surface. */
41
+ get(plan, rgba) {
42
+ const key = `${plan.key}|${rgba.join(',')}`;
43
+ const held = this._tiles.get(key);
44
+ if (held) {
45
+ this._tiles.delete(key);
46
+ this._tiles.set(key, held);
47
+ this.stats.hits++;
48
+ return held.handle;
49
+ }
50
+ const n = this._native;
51
+ const handle = n.createSurface(plan.width, plan.height, 1);
52
+ if (handle == null) return null;
53
+ const pixels = rasterShadowTile(plan, rgba);
54
+ n.ctxPutImageData(
55
+ handle,
56
+ Buffer.from(pixels.buffer, pixels.byteOffset, pixels.byteLength),
57
+ plan.width,
58
+ plan.height,
59
+ 0,
60
+ 0,
61
+ );
62
+ const bytes = plan.width * plan.height * 4;
63
+ this._tiles.set(key, { handle, bytes });
64
+ this._bytes += bytes;
65
+ this.stats.made++;
66
+ for (const [oldest, tile] of this._tiles) {
67
+ if (this._bytes <= this._budget || tile.handle === handle) break;
68
+ this._tiles.delete(oldest);
69
+ this._bytes -= tile.bytes;
70
+ this.stats.released++;
71
+ n.releaseSurface?.(tile.handle);
72
+ }
73
+ return handle;
74
+ }
75
+ }
76
+
77
+ const caches = new WeakMap();
78
+
79
+ /** The one tile cache for a bridge. */
80
+ export function shadowTilesFor(native) {
81
+ let cache = caches.get(native);
82
+ if (!cache) caches.set(native, (cache = new ShadowTileCache(native)));
83
+ return cache;
84
+ }
package/src/events.js CHANGED
@@ -431,6 +431,10 @@ export class EventManager {
431
431
  this._wheelOwed = { x: 0, y: 0 };
432
432
  // the window's DragSession while a press has armed it (src/dnd.js)
433
433
  this._dragArmed = null;
434
+ // where the pointer last was over this window — the motion or the wheel
435
+ // that said so — for asking again what is under it once what is under
436
+ // it has moved (`refreshHover`). Null while the pointer is elsewhere.
437
+ this._pointer = null;
434
438
  // on a *top-level* window's manager: the chords bound anywhere in its
435
439
  // tree, newest last (`registerAccelerator`). Null until one is bound,
436
440
  // which is the common case and costs one null check per key.
@@ -792,6 +796,7 @@ export class EventManager {
792
796
  * render it started for the notch before.
793
797
  */
794
798
  _onWheel(native) {
799
+ this._pointer = native;
795
800
  // The first wheel is what says this window wants smooth scrolling. It was
796
801
  // created on core events — an XI2 selection costs four times as many
797
802
  // bytes per *pointer move*, which a window that is never scrolled would
@@ -1004,6 +1009,7 @@ export class EventManager {
1004
1009
  }
1005
1010
 
1006
1011
  _onMouseMove(native) {
1012
+ this._pointer = native;
1007
1013
  if (inspectHandler) {
1008
1014
  inspectHandler('move', this._hit(native), native);
1009
1015
  return;
@@ -1090,6 +1096,7 @@ export class EventManager {
1090
1096
  // ancestor was told the pointer had gone and then that it was back, and
1091
1097
  // `onMouseOut` that it had left a window it was still in.
1092
1098
  if (this._leftIntoSurface(native)) return;
1099
+ this._pointer = null;
1093
1100
  runWithPriority(ContinuousEventPriority, () => {
1094
1101
  this._updateHover([], native);
1095
1102
  // the pointer is somewhere else entirely: whatever it was heading for
@@ -1186,6 +1193,41 @@ export class EventManager {
1186
1193
  this.pressPath = path;
1187
1194
  }
1188
1195
 
1196
+ /**
1197
+ * Hover follows content. The pointer stayed where it was and what is
1198
+ * under it moved — a scroll, or a layout that put something else there —
1199
+ * so the question a motion asks is asked again at the point, as a browser
1200
+ * asks it at the frame after a scroll: `:hover`, enter and leave change
1201
+ * as a motion would change them, and the node under the pointer hears
1202
+ * `defaultMouseMove` there, since an element that paints its own hover
1203
+ * (a document, a graph) has content that moved under the point even where
1204
+ * the node did not. The application's `onMouseMove` is not called: the
1205
+ * pointer did not move. Nothing is asked while a press or a capture holds
1206
+ * the pointer, where hover is frozen for the gesture.
1207
+ *
1208
+ * Called by the window after a frame that laid out (nodes/window/flush.js),
1209
+ * which is every frame a scroll moves content in.
1210
+ */
1211
+ refreshHover() {
1212
+ const at = this._pointer;
1213
+ if (!at || inspectHandler || this.node.destroyed) return;
1214
+ if (this._captured() || this.downNode || this._dragArmed) return;
1215
+ runWithPriority(ContinuousEventPriority, () => {
1216
+ const target = this._hit(at);
1217
+ const path = this._path(target);
1218
+ const old = this.hoverPath;
1219
+ if (
1220
+ path.length !== old.length ||
1221
+ path.some((node, i) => node !== old[i])
1222
+ ) {
1223
+ this._updateHover(path, at);
1224
+ }
1225
+ if (typeof target.defaultMouseMove === 'function') {
1226
+ target.defaultMouseMove(this._makeEvent('mouseMove', at, target));
1227
+ }
1228
+ });
1229
+ }
1230
+
1189
1231
  /** enter/leave do not propagate: each node on the diff gets its own call. */
1190
1232
  _updateHover(newPath, native) {
1191
1233
  const oldPath = this.hoverPath;
package/src/node.d.ts CHANGED
@@ -48,6 +48,10 @@ export interface ImageDataLike {
48
48
  * Anything ntk documents beyond this is reachable at runtime and is
49
49
  * ntk's to change.
50
50
  */
51
+ /** One corner's radius for `roundRect`: circular, or elliptical as a
52
+ * `DOMPointInit`. */
53
+ export type RoundRectRadius = number | { x?: number; y?: number };
54
+
51
55
  export interface Context2D {
52
56
  fillStyle: string | CanvasGradientLike;
53
57
  strokeStyle: string | CanvasGradientLike;
@@ -125,12 +129,16 @@ export interface Context2D {
125
129
  anticlockwise?: boolean,
126
130
  ): void;
127
131
  rect(x: number, y: number, width: number, height: number): void;
132
+ /** Canvas's `roundRect`: a radius for every corner, a `{ x, y }` point
133
+ * for an elliptical one, or a list of one to four of either, clockwise
134
+ * from the top left. A shadowed `fill` of one — or of a `rect` less one,
135
+ * filled `'evenodd'` — is drawn from a shadow tile made once. */
128
136
  roundRect(
129
137
  x: number,
130
138
  y: number,
131
139
  width: number,
132
140
  height: number,
133
- radii: number | number[],
141
+ radii: RoundRectRadius | RoundRectRadius[],
134
142
  ): void;
135
143
  fill(fillRule?: 'nonzero' | 'evenodd'): void;
136
144
  stroke(): void;
@@ -515,6 +523,17 @@ export declare class Node {
515
523
  * paint, whatever the props. A translucent element answers `null`.
516
524
  */
517
525
  opaqueRect(): Rect | null;
526
+ /**
527
+ * What this element draws that stays put in the viewport of the scroll
528
+ * pane it is in while the pane scrolls — a document's fixed background, a
529
+ * `position: fixed` header — as rects in window coordinates, device
530
+ * pixels, or null, the default, for drawing that moves with the content.
531
+ * A scroll of that pane that blits repaints them where they are and where
532
+ * the copy dragged their image; one that covers most of the viewport
533
+ * makes the scroll a repaint. Only the nearest scroll pane asks. Read on
534
+ * each scroll the pane blits, after layout.
535
+ */
536
+ viewportFixedRects(): Rect[] | null;
518
537
  /**
519
538
  * How far this element's own drawing reaches past its box, in device
520
539
  * pixels on every side — 0, the default, for an element that stays inside
@@ -267,10 +267,42 @@ export class NodeBoxPaint {
267
267
  ctx.fillShadow(rect, r, shadow.blur, color);
268
268
  continue;
269
269
  }
270
+ // No X server to bake a blur on — CoreGraphics, Direct2D: the
271
+ // context's own shadow, which draws a rounded rect's from a tile it
272
+ // keeps (src/backend/shadowtiles.js). The coverage route below needs
273
+ // RENDER and drew nothing there.
274
+ if (!this.app?.display?.Render) {
275
+ this._paintShadowOnContext(ctx, rect, r, shadow.blur, color);
276
+ continue;
277
+ }
270
278
  this._paintBlurredShadow(ctx, rect, r, shadow.blur, color);
271
279
  }
272
280
  }
273
281
 
282
+ /**
283
+ * One blurred shadow through the context's `shadowBlur`: the rounded rect
284
+ * filled clear of the surface to its left, and its shadow thrown back by
285
+ * as much, so that only the shadow lands. Offsets are device pixels and
286
+ * the shape moves in the context's own units, hence the scale between.
287
+ */
288
+ _paintShadowOnContext(ctx, rect, radius, blur, color) {
289
+ if (!('shadowBlur' in ctx) || typeof ctx.roundRect !== 'function') return;
290
+ const m = ctx.getTransform?.() ?? { a: 1, e: 0 };
291
+ const reach = Math.ceil(blur * 1.5) + 1;
292
+ const away =
293
+ Math.ceil(Math.abs(rect.x) + rect.width + Math.abs(m.e) + reach) + 1;
294
+ ctx.save();
295
+ ctx.shadowColor = color;
296
+ ctx.shadowBlur = blur;
297
+ ctx.shadowOffsetX = away * m.a;
298
+ ctx.shadowOffsetY = 0;
299
+ ctx.fillStyle = '#000000';
300
+ ctx.beginPath();
301
+ ctx.roundRect(rect.x - away, rect.y, rect.width, rect.height, radius);
302
+ ctx.fill();
303
+ ctx.restore();
304
+ }
305
+
274
306
  /**
275
307
  * One blurred shadow, through the paint cache when there is one.
276
308
  *
@@ -114,6 +114,29 @@ export class NodeInvalidate {
114
114
  return null;
115
115
  }
116
116
 
117
+ /**
118
+ * What this element draws that stays put in the viewport of the scroll
119
+ * pane it is in while the pane scrolls, as rects in window coordinates,
120
+ * device pixels, like `abs` — or null, the default, for an element
121
+ * whose drawing moves with the content. A document's fixed background or
122
+ * `position: fixed` header is drawn where the pane's viewport is, not
123
+ * where the content under it is, and a scroll's blit copies the pane's
124
+ * pixels with the content: it dragged the header along, and left the
125
+ * background scrolling with the text.
126
+ *
127
+ * A pane scroll that blits repaints what these rects answer where they
128
+ * are and where the copy dragged their image, as it does the corners of
129
+ * a rounded box around the pane; one that covers most of the viewport
130
+ * makes the scroll a repaint. Only the nearest scroll pane's scroll is
131
+ * asked about: a pane further out moves the inner one, and what is fixed
132
+ * to it, with its content. Read on each scroll the pane blits, after
133
+ * layout, so it may follow `abs` and the pane's `contentBox()`; the
134
+ * element draws those rects in its `paint` from the same geometry.
135
+ */
136
+ viewportFixedRects() {
137
+ return null;
138
+ }
139
+
117
140
  /**
118
141
  * How far this element's own drawing reaches past its box — device
119
142
  * pixels, on every side — or 0, the default, for one that stays inside
package/src/nodes/node.js CHANGED
@@ -607,6 +607,7 @@ export class Node {
607
607
  // nothing else ever asks for a frame
608
608
  this.root?._animating.delete(this);
609
609
  this.root?._opaqueNodes?.delete(this);
610
+ this.root?._viewportFixedNodes?.delete(this);
610
611
  // a surface that goes away takes its selection with it, and the app-wide
611
612
  // claim on being the one showing one goes with it too
612
613
  this._textSelection?.destroy();
@@ -625,11 +626,15 @@ export class Node {
625
626
  this.root?._layoutHosts?.delete(this);
626
627
  root?._layoutHosts?.add(this);
627
628
  }
628
- // an element answering `opaqueRect()` is one the window asks per pass
629
+ // an element answering `opaqueRect()` is one the window asks per pass,
630
+ // and one answering `viewportFixedRects()` one it asks per scroll blit
629
631
  const opaque = this.opaqueRect !== Node.prototype.opaqueRect;
632
+ const fixed = this.viewportFixedRects !== Node.prototype.viewportFixedRects;
630
633
  if (opaque) this.root?._opaqueNodes?.delete(this);
634
+ if (fixed) this.root?._viewportFixedNodes?.delete(this);
631
635
  this.root = root;
632
636
  if (opaque) root?._opaqueNodes?.add(this);
637
+ if (fixed) root?._viewportFixedNodes?.add(this);
633
638
  // styled before it had a window, so this is where a placed node is
634
639
  // first registered (WindowNode._placeNodes)
635
640
  if (this.style && isPlaced(this.style)) root?._placedNodes?.add(this);
@@ -218,6 +218,13 @@ function unionIsBox(a, b) {
218
218
  );
219
219
  }
220
220
 
221
+ /** Two lists of pins as one, either of which may be null. */
222
+ function joinPins(a, b) {
223
+ if (!a) return b;
224
+ if (!b) return a;
225
+ return [...a, ...b];
226
+ }
227
+
221
228
  /** Is `rect` inside one of `pins`, the furniture an element blit pinned —
222
229
  * repaired whole after the copy, whatever changed in it (issue #682)? */
223
230
  function insidePinned(rect, pins) {
@@ -862,7 +869,13 @@ export class WindowScrollBlit {
862
869
  // (issue #691) and repaired below, where they are and where the copy
863
870
  // dragged their image. Refused instead, a list or a document inside a
864
871
  // rounded frame repainted its whole viewport on every step of a scroll.
865
- const corners = this._cornerPins(node, vp, target.top);
872
+ // …and so is what an element in the pane draws fixed to its viewport
873
+ // (`viewportFixedRects()`): a document's fixed background or header,
874
+ // which the copy would drag along with the text under it
875
+ const corners = joinPins(
876
+ this._cornerPins(node, vp, target.top),
877
+ this._viewportFixedPins(node, vp),
878
+ );
866
879
  if (!this._scrollBlitSafe(node, vp, target.top, null, corners)) return;
867
880
  // The band the scrolled bar's thumb travels in is repaired on its own.
868
881
  // The thumb's rects are thin and run along the viewport's edge, and a
@@ -1189,6 +1202,30 @@ export class WindowScrollBlit {
1189
1202
  * Ancestors below `upTo`, the node the blit's surface belongs to
1190
1203
  * (`_blitTarget`): a pane is a rectangle no corner above it clips.
1191
1204
  */
1205
+ /**
1206
+ * What the elements in scroll pane `node` draw fixed to its viewport
1207
+ * (`viewportFixedRects()`), inside `vp` and out to whole pixels: pinned
1208
+ * like a rounded ancestor's corners, repaired where they are and where the
1209
+ * blit dragged their image. Asked only of an element whose nearest scroll
1210
+ * pane is `node` — a pane further in moves with the content, and whatever
1211
+ * is fixed to it with it — and not of one hidden or gone.
1212
+ */
1213
+ _viewportFixedPins(node, vp) {
1214
+ let out = null;
1215
+ for (const fixed of this._viewportFixedNodes ?? []) {
1216
+ if (fixed.destroyed || fixed.hidden || fixed.root !== this) continue;
1217
+ let pane = fixed.parent;
1218
+ while (pane && !pane.isWindow && !pane.isScroller?.()) pane = pane.parent;
1219
+ if (pane !== node) continue;
1220
+ for (const rect of fixed.viewportFixedRects() ?? []) {
1221
+ if (!rectsOverlap(rect, vp)) continue;
1222
+ const inside = intersectRects(outerPixels(rect), vp);
1223
+ if (inside) (out ??= []).push(inside);
1224
+ }
1225
+ }
1226
+ return out;
1227
+ }
1228
+
1192
1229
  _cornerPins(node, vp, upTo = this) {
1193
1230
  let out = null;
1194
1231
  for (let n = node.parent; n && n !== upTo; n = n.parent) {
@@ -138,6 +138,14 @@ export class WindowFlush {
138
138
  if (!this.destroyed) this._scheduleFrame();
139
139
  }
140
140
  }
141
+ // A frame that laid out may have moved what is under a pointer that did
142
+ // not move — a scroll does, every frame of it — and hover follows the
143
+ // content (`EventManager.refreshHover`). After the frame: what hover
144
+ // changes is the next frame's to paint.
145
+ if (this._hoverRecheck) {
146
+ this._hoverRecheck = false;
147
+ if (!this.destroyed) this.events?.refreshHover();
148
+ }
141
149
  }
142
150
 
143
151
  /** The frame itself. True when it painted or presented something. */
@@ -289,7 +297,11 @@ export class WindowFlush {
289
297
  // like `presentFrame`; an ntk window has no such half.
290
298
  this.window.prepareFrame?.(this, layoutRan);
291
299
  // any node this pass laid out may be what an open popup is anchored to
292
- if (layoutRan) this._notifyAnchorChange();
300
+ // — or what a pointer that has not moved is over now (`flush`)
301
+ if (layoutRan) {
302
+ this._notifyAnchorChange();
303
+ this._hoverRecheck = true;
304
+ }
293
305
  // A subtree that only moved and covers its box opaquely is copied where
294
306
  // it went, and the frame paints around the copy — or its move is
295
307
  // claimed, where it was and where it is (nodes/moveblit.js). Once every
@@ -185,11 +185,17 @@ export class WindowNode extends Scrollable(Node) {
185
185
  // over and its cost is known (`flush`)
186
186
  this._inFlush = false;
187
187
  this._claimAfterFlush = false;
188
+ // a frame laid out, so what is under the pointer may have moved
189
+ // (`flush`, `EventManager.refreshHover`)
190
+ this._hoverRecheck = false;
188
191
  // the nodes answering `opaqueRect()`, and — during a paint pass one of
189
192
  // them covers — that node with its ancestors, whose fills are skipped
190
193
  // (`_coverFor`, `Node._paintBackground`)
191
194
  this._opaqueNodes = new Set();
192
195
  this._coverChain = null;
196
+ // the nodes answering `viewportFixedRects()`, whose rects a scroll pane
197
+ // they are in repairs when it blits (`_viewportFixedPins`)
198
+ this._viewportFixedNodes = new Set();
193
199
  // Nodes that want the `attention` event (ntk#37) — an
194
200
  // `unstable_onAttention` prop,
195
201
  // an `:attention` block, or both. Built before the EventManager so the