react-x11 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,12 +19,19 @@
19
19
  //
20
20
  // Sibling order is zPosition, assigned from the node's own paintOrder() —
21
21
  // no sublayer-list surgery, ever. Dirt arrives on the invalidate channel
22
- // (`noteInvalidate`): a node means that node, null means everything (the
23
- // same "no bound named repaints everything" rule the X11 damage model has),
24
- // and geometry is re-diffed every frame because comparing four numbers is
25
- // cheaper than knowing.
22
+ // (`noteInvalidate`): a node means that node, a bare rect means that part
23
+ // of every raster it touches, null means everything (the same "no bound
24
+ // named repaints everything" rule the X11 damage model has), and geometry
25
+ // is re-diffed every frame because comparing four numbers is cheaper than
26
+ // knowing.
26
27
  import { cssColorStraight } from 'ntk';
27
28
 
29
+ import {
30
+ Node,
31
+ addDamageRect,
32
+ damageToPaint,
33
+ intersectRects,
34
+ } from '../nodes.js';
28
35
  import { CocoaContext2D } from './context2d.js';
29
36
 
30
37
  const RASTER_PAD = 2; // antialiasing/italic overhang outside the ink bounds
@@ -254,8 +261,27 @@ class ShapeRecorder {
254
261
  }
255
262
  }
256
263
 
257
- /** Paint everything a node draws itself — Node.paint minus the children. */
264
+ /**
265
+ * Paint everything a node draws itself — Node.paint minus the children.
266
+ *
267
+ * An element that overrides `paint` (every drawing element in
268
+ * @react-x11/components does: `super.paint(ctx)` for the box, then the
269
+ * scene) has its content nowhere but in that override, so the override is
270
+ * what a raster replays — with the children held back by `_ownPaintOnly`,
271
+ * the one presenter-side flag `Node._paintChildren` honours, because they
272
+ * have visuals of their own. Everything else paints piecewise, which is
273
+ * what `Node.paint` would do minus the child walk.
274
+ */
258
275
  function paintSelf(node, ctx) {
276
+ if (node.paint !== Node.prototype.paint) {
277
+ node._ownPaintOnly = true;
278
+ try {
279
+ node.paint(ctx);
280
+ } finally {
281
+ node._ownPaintOnly = false;
282
+ }
283
+ return;
284
+ }
259
285
  node._paintShadow(ctx);
260
286
  node._paintBackground(ctx);
261
287
  node.paintContent(ctx);
@@ -263,6 +289,26 @@ function paintSelf(node, ctx) {
263
289
  node._paintOutline(ctx);
264
290
  }
265
291
 
292
+ // Past this many bare-rect claims in one frame the overlap test costs more
293
+ // than it saves, and the answer is the one a null claim gives: everything.
294
+ const MAX_DIRTY_RECTS = 64;
295
+
296
+ // The pass list of a raster that repaints in full: one pass, unbounded.
297
+ const FULL_PASS = Object.freeze([null]);
298
+
299
+ /** A rect grown outward to whole pixels — a pass clears and clips to its
300
+ * edges, and a fractional edge would antialias the clip into a seam. */
301
+ function wholePixels(rect) {
302
+ const x = Math.floor(rect.x);
303
+ const y = Math.floor(rect.y);
304
+ return {
305
+ x,
306
+ y,
307
+ width: Math.ceil(rect.x + rect.width) - x,
308
+ height: Math.ceil(rect.y + rect.height) - y,
309
+ };
310
+ }
311
+
266
312
  const EDGE_PROPS = [
267
313
  'borderTopColor',
268
314
  'borderRightColor',
@@ -354,6 +400,9 @@ class RasterState {
354
400
 
355
401
  ensure(presenter, width, height, scale) {
356
402
  if (!this.surface || this.width !== width || this.height !== height) {
403
+ // the layer holds its own copy of what it shows, so the bitmap a
404
+ // resize retires can go now rather than with the handle's finalizer
405
+ this.release(presenter.native);
357
406
  this.surface = presenter.native.createSurface(width, height, scale);
358
407
  this.width = width;
359
408
  this.height = height;
@@ -369,6 +418,16 @@ class RasterState {
369
418
  }
370
419
  return this.ctx;
371
420
  }
421
+
422
+ /** Free the bitmap now (bridge 0.4's `releaseSurface`); older bridges
423
+ * free it from the handle's finalizer, and this is then just the drop. */
424
+ release(native) {
425
+ const surface = this.surface;
426
+ this.surface = null;
427
+ if (surface && typeof native.releaseSurface === 'function') {
428
+ native.releaseSurface(surface);
429
+ }
430
+ }
372
431
  }
373
432
 
374
433
  export class CocoaLayerPresenter {
@@ -380,8 +439,14 @@ export class CocoaLayerPresenter {
380
439
  this.visuals = new Map(); // node -> Visual
381
440
  this.rasters = new Map(); // node -> RasterState
382
441
  this.bars = new Map(); // scroller node -> Map(axis -> { layer, raster })
383
- this.dirty = new Set();
442
+ // Claims since the last frame — taken at the top of `frame()`, the way
443
+ // the X11 path takes its damage before painting, so a claim made from
444
+ // inside a paint lands in the next frame instead of being cleared with
445
+ // this one.
446
+ this.dirty = new Set(); // nodes
447
+ this.dirtyRects = []; // bare rects, window coordinates
384
448
  this.dirtyAll = true; // first frame rasters everything
449
+ this._claims = null; // the frame in progress: { all, nodes, rects }
385
450
  this.rootVisual = {
386
451
  layer: window._layer,
387
452
  };
@@ -402,12 +467,77 @@ export class CocoaLayerPresenter {
402
467
  // say which node that was. Everything re-rasters; a scroll names its
403
468
  // node and stays off this path.
404
469
  this.dirtyAll = true;
470
+ } else if (damage.width > 0 && damage.height > 0) {
471
+ // A bare rect with no layout behind it is a claim about pixels — an
472
+ // element's `invalidate(false, rect)` for the box a dragged node
473
+ // moved through, the region `scrollContents` shifts, the strip an
474
+ // animation ticks in — and it cannot name the node it came from
475
+ // either. The frame repaints that part of every raster visual whose
476
+ // ink the rect touches (the same conservative answer the damage model
477
+ // gives a rect: whatever draws there repaints, clipped to it);
478
+ // property boxes carry no raster and re-diff every frame regardless.
479
+ // Copied, because a caller's rect is very often a live `abs` about
480
+ // to be laid out.
481
+ if (this.dirtyRects.length >= MAX_DIRTY_RECTS) {
482
+ this.dirtyAll = true;
483
+ } else {
484
+ this.dirtyRects.push({
485
+ x: damage.x,
486
+ y: damage.y,
487
+ width: damage.width,
488
+ height: damage.height,
489
+ });
490
+ }
405
491
  }
406
492
  }
407
493
 
494
+ /** The claims a frame consumes, cleared for the next one. */
495
+ _takeClaims() {
496
+ const claims = {
497
+ all: this.dirtyAll,
498
+ nodes: this.dirty,
499
+ rects: this.dirtyAll ? [] : this.dirtyRects,
500
+ };
501
+ this.dirtyAll = false;
502
+ this.dirty = new Set();
503
+ this.dirtyRects = [];
504
+ return claims;
505
+ }
506
+
507
+ /**
508
+ * What this frame repaints of the raster covering `rect` for `node`: null
509
+ * for nothing, `FULL_PASS` for all of it, otherwise the window-space rects
510
+ * the frame's bare claims cover inside it, shaped exactly as the X11 path
511
+ * shapes its damage list: whole pixels, disjoint, at most a few of them,
512
+ * and one box instead of several when the several fill most of it —
513
+ * because a node painted in two overlapping passes blends translucent ink
514
+ * over itself, and because each pass is a full replay of the node's paint
515
+ * that only its own culling makes cheap. A pan's shifted region and the
516
+ * two strips beside it come out as the one pass they are.
517
+ */
518
+ _rasterPasses(node, rect, sizeChanged) {
519
+ const claims = this._claims;
520
+ if (sizeChanged || !claims || claims.all || claims.nodes.has(node)) {
521
+ return FULL_PASS;
522
+ }
523
+ let passes = null;
524
+ for (const claimed of claims.rects) {
525
+ const hit = intersectRects(wholePixels(claimed), rect);
526
+ if (hit) passes = addDamageRect(passes, hit);
527
+ }
528
+ return passes && damageToPaint(passes);
529
+ }
530
+
531
+ /** Does this frame re-raster the visual covering `rect` for `node`? */
532
+ _needsRaster(node, rect) {
533
+ return this._rasterPasses(node, rect, false) !== null;
534
+ }
535
+
408
536
  /** The whole frame: one walk, one transaction, property diffs only. */
409
537
  frame(windowNode) {
410
538
  const native = this.native;
539
+ const claims = (this._claims = this._takeClaims());
540
+ let presented = false;
411
541
  native.txBegin({ disableActions: true });
412
542
  try {
413
543
  this._syncWindowBackground(windowNode);
@@ -417,21 +547,42 @@ export class CocoaLayerPresenter {
417
547
  if (!seen.has(node)) {
418
548
  visual.destroy();
419
549
  this.visuals.delete(node);
420
- this.rasters.delete(node);
550
+ this._dropRaster(node);
421
551
  const bars = this.bars.get(node);
422
552
  if (bars) {
423
553
  for (const entry of bars.values()) {
424
554
  native.removeFromSuperlayer(entry.layer);
555
+ entry.raster.release(native);
425
556
  }
426
557
  this.bars.delete(node);
427
558
  }
428
559
  }
429
560
  }
561
+ presented = true;
430
562
  } finally {
431
563
  native.txCommit();
564
+ this._claims = null;
565
+ // a frame that threw half-way presents what it got to; the claims it
566
+ // was answering are still owed, so the next frame answers them again
567
+ if (!presented) this._restoreClaims(claims);
432
568
  }
433
- this.dirty.clear();
434
- this.dirtyAll = false;
569
+ }
570
+
571
+ _restoreClaims(claims) {
572
+ this.dirtyAll ||= claims.all;
573
+ for (const node of claims.nodes) this.dirty.add(node);
574
+ if (this.dirtyRects.length + claims.rects.length > MAX_DIRTY_RECTS) {
575
+ this.dirtyAll = true;
576
+ } else {
577
+ this.dirtyRects.push(...claims.rects);
578
+ }
579
+ }
580
+
581
+ _dropRaster(node) {
582
+ const raster = this.rasters.get(node);
583
+ if (!raster) return;
584
+ raster.release(this.native);
585
+ this.rasters.delete(node);
435
586
  }
436
587
 
437
588
  _syncWindowBackground(windowNode) {
@@ -460,7 +611,7 @@ export class CocoaLayerPresenter {
460
611
  let visual = this.visuals.get(node);
461
612
  if (visual && visual.isRaster !== wantsRaster) {
462
613
  visual.destroy();
463
- this.rasters.delete(node);
614
+ this._dropRaster(node);
464
615
  visual = null;
465
616
  }
466
617
  if (!visual) {
@@ -590,9 +741,8 @@ export class CocoaLayerPresenter {
590
741
  const s = this.scale;
591
742
  if (
592
743
  !sizeChanged &&
593
- !this.dirtyAll &&
594
- !this.dirty.has(node) &&
595
- visual.shapeSignature
744
+ visual.shapeSignature &&
745
+ !this._needsRaster(node, rect)
596
746
  ) {
597
747
  return true; // shapes are current
598
748
  }
@@ -738,13 +888,38 @@ export class CocoaLayerPresenter {
738
888
  }
739
889
  this._dropSvgShapes(visual);
740
890
  }
741
- if (!this.dirtyAll && !this.dirty.has(node) && !sizeChanged) return;
891
+ const passes = this._rasterPasses(node, rect, sizeChanged);
892
+ if (!passes) return;
742
893
  const ctx = raster.ensure(this, rect.width, rect.height, this.window.scale);
894
+ // The bitmap is this visual's composition cache, the way the window's
895
+ // backing store is the surface presenter's: a pass over part of it
896
+ // clears and clips to that part and leaves the rest as last frame drew
897
+ // it, and `paintDamage()` names the pass, so an element culls a drag
898
+ // step or an animation tick exactly as it does on X11 (docs/extending.md,
899
+ // "Drawing a scene into one node") instead of replaying its whole scene
900
+ // into a clip that throws almost all of it away. The full pass is the
901
+ // same loop with nothing to clip.
902
+ const root = node.root;
743
903
  ctx.save();
744
904
  try {
745
- ctx.clearRect(0, 0, rect.width, rect.height);
746
905
  ctx.translate(-rect.x, -rect.y);
747
- paintSelf(node, ctx);
906
+ for (const pass of passes) {
907
+ const area = pass ?? rect;
908
+ ctx.save();
909
+ try {
910
+ if (pass) {
911
+ ctx.beginPath();
912
+ ctx.rect(area.x, area.y, area.width, area.height);
913
+ ctx.clip();
914
+ }
915
+ ctx.clearRect(area.x, area.y, area.width, area.height);
916
+ if (root) root._paintDamage = pass;
917
+ paintSelf(node, ctx);
918
+ } finally {
919
+ if (root) root._paintDamage = null;
920
+ ctx.restore();
921
+ }
922
+ }
748
923
  } finally {
749
924
  ctx.restore();
750
925
  }
@@ -0,0 +1,235 @@
1
+ // An offscreen drawing surface on the Cocoa backend — ntk's `Surface`
2
+ // contract (draw once, composite many, and shift a retained band in place)
3
+ // over one @windowkit/appkit CG bitmap. `react-x11/ntk`'s `Surface` hands
4
+ // out one of these when the app it is given is a Cocoa app (the app's
5
+ // `createSurface` seam, src/cocoa/app.js), so a component allocates its
6
+ // buffer the same way on both backends and names neither:
7
+ //
8
+ // const surface = new Surface(app, { width, height }); // device pixels
9
+ // const ctx = surface.getContext('2d'); // a CocoaContext2D
10
+ // ctx.fillRect(0, 0, width, height);
11
+ // surface.copyWithin({ x: 0, y: 0, width, height }, 0, -rowHeight);
12
+ // windowCtx.drawImage(surface, x, y); // one composite
13
+ //
14
+ // What is the same as ntk's: the constructor, `width`/`height`/`format`/
15
+ // `depth`/`bytes`, `getContext`, `render`, `clear`, `copyWithin` down to its
16
+ // clamping (ntk#252 — integer deltas, the band that survives, false when
17
+ // nothing does), `destroy`/`Symbol.dispose`, and `drawImage` taking the
18
+ // surface as a source. What differs is stated here, because this is where
19
+ // it lives:
20
+ //
21
+ // - **One graphics state per surface.** CoreGraphics keeps the CTM, the
22
+ // clip and the path in the bitmap context itself, where an X connection
23
+ // keeps them per Picture/GC. So `getContext('2d')` answers the same
24
+ // context every time — a JS object over that one state, nothing to free,
25
+ // and `destroy()` on it is a no-op — and `render()` brackets its callback
26
+ // in save/restore from the identity transform, so a one-shot draw leaves
27
+ // no residue for the next painter, which is what ntk gets from building a
28
+ // fresh context per call.
29
+ // - **`format: 'a8'` is not here yet.** Coverage surfaces are what the paint
30
+ // cache's masks and the shadows use, and both stay on their X path; every
31
+ // consumer that allocates a surface of its own asks for argb32. Asking
32
+ // for a8 throws rather than answering a colour surface that would
33
+ // composite differently.
34
+ // - **No Picture.** `picture()` is X's compositing handle; here a surface
35
+ // composites through `ctx.drawImage`, and asking for the picture says so.
36
+ // - **Freed on `destroy()`.** The bridge's `releaseSurface` (0.4) frees the
37
+ // bitmap on the call and hands its bytes back to V8's account; the
38
+ // handle's finalizer stays as the safety net for a surface that is
39
+ // dropped without one. Under an older bridge the memory goes with the
40
+ // next GC, as it always did.
41
+ //
42
+ // Units are device pixels, like the window's backing store: a caller sizes
43
+ // one from `contentBox()` numbers, which are device pixels already
44
+ // (docs/scale.md). The bridge is told the app's scale so the bitmap carries
45
+ // it — inert for a `drawImage` source, right for a layer's contents.
46
+ import { CocoaContext2D } from './context2d.js';
47
+
48
+ export class CocoaSurface {
49
+ constructor(app, { width, height, format = 'argb32' } = {}) {
50
+ if (
51
+ !Number.isInteger(width) ||
52
+ !Number.isInteger(height) ||
53
+ width <= 0 ||
54
+ height <= 0
55
+ ) {
56
+ throw new Error('Surface: width and height must be positive integers');
57
+ }
58
+ if (format === 'a8') {
59
+ throw new Error(
60
+ "Surface: format 'a8' (a coverage surface) is not on the cocoa " +
61
+ 'backend yet — allocate argb32, which every backend has, and ' +
62
+ 'tint through fillStyle/globalAlpha; track docs/macos.md ' +
63
+ '"Custom drawing on a layer tree".',
64
+ );
65
+ }
66
+ if (format !== 'argb32') {
67
+ throw new Error(
68
+ `Surface: unknown format ${JSON.stringify(format)} (argb32 or a8)`,
69
+ );
70
+ }
71
+ this.app = app;
72
+ this.width = width;
73
+ this.height = height;
74
+ this.format = format;
75
+ this.depth = 32;
76
+ this._native = app._native;
77
+ this._fonts = app.fonts ?? null;
78
+ this._ctx = null;
79
+ this._destroyed = false;
80
+ this._surfaceHandle = this._native.createSurface(
81
+ width,
82
+ height,
83
+ app.scale ?? 1,
84
+ );
85
+ // a fresh bitmap's contents are the allocator's; a surface that is only
86
+ // partly drawn must composite nothing where nothing was drawn
87
+ this.clear();
88
+ }
89
+
90
+ /** bytes of backing storage — what a cache budgets against */
91
+ get bytes() {
92
+ return this.width * this.height * 4;
93
+ }
94
+
95
+ /** X's compositing handle, which this backend does not have. */
96
+ picture() {
97
+ throw new Error(
98
+ 'Surface: a surface on the cocoa backend has no XRender Picture — ' +
99
+ 'composite it with ctx.drawImage(surface, x, y), which takes a ' +
100
+ 'surface directly on both backends.',
101
+ );
102
+ }
103
+
104
+ _handle() {
105
+ if (this._destroyed) {
106
+ throw new Error(
107
+ 'Surface: destroyed — a context on a destroyed surface cannot ' +
108
+ 'draw; allocate a new Surface and draw into that.',
109
+ );
110
+ }
111
+ return this._surfaceHandle;
112
+ }
113
+
114
+ _context() {
115
+ if (!this._ctx) {
116
+ this._ctx = new CocoaContext2D(
117
+ this._native,
118
+ () => this._handle(),
119
+ () => 1,
120
+ );
121
+ this._ctx._fonts = this._fonts;
122
+ }
123
+ return this._ctx;
124
+ }
125
+
126
+ /**
127
+ * The 2d context on the bitmap — the same one every time, since the
128
+ * bitmap has one graphics state (see the header). ntk's contract has the
129
+ * caller owning it and owing it a `destroy()`; that call is honoured as a
130
+ * no-op, so a caller written against ntk needs no branch.
131
+ */
132
+ getContext(name = '2d') {
133
+ this._handle();
134
+ if (name !== '2d') {
135
+ throw new Error(
136
+ `Surface: getContext(${JSON.stringify(name)}) — a surface on the ` +
137
+ "cocoa backend has a '2d' context and nothing else.",
138
+ );
139
+ }
140
+ return this._context();
141
+ }
142
+
143
+ /**
144
+ * Draw into the surface through a context that starts clean — identity
145
+ * transform, the fill and line state as they were — and leaves the
146
+ * surface's state as it found it: the save/restore bracket stands in for
147
+ * the per-call context ntk builds and destroys.
148
+ */
149
+ render(fn) {
150
+ const ctx = this.getContext('2d');
151
+ ctx.save();
152
+ try {
153
+ ctx.resetTransform();
154
+ fn(ctx);
155
+ } finally {
156
+ ctx.restore();
157
+ }
158
+ return this;
159
+ }
160
+
161
+ /** Reset every pixel to fully transparent, whatever transform a live
162
+ * context holds — the clear is issued from the identity. */
163
+ clear() {
164
+ if (this._destroyed) return this;
165
+ const ctx = this._context();
166
+ ctx.save();
167
+ try {
168
+ ctx.resetTransform();
169
+ ctx.clearRect(0, 0, this.width, this.height);
170
+ } finally {
171
+ ctx.restore();
172
+ }
173
+ return this;
174
+ }
175
+
176
+ /**
177
+ * Scroll the pixels of `src` (surface coordinates, `{x, y, width,
178
+ * height}`) by (dx, dy) in place: one in-place copy of the band that
179
+ * survives the shift (the bridge's `scrollSurface`, a memmove per row), in
180
+ * place of redrawing everything that merely moved. True when the copy was
181
+ * issued; false means "nothing survives the shift here" and the caller
182
+ * repaints `src` exactly as it would have without this method.
183
+ *
184
+ * ntk#252's contract, clamp for clamp: refused when the delta is
185
+ * fractional (a sub-pixel shift changes every pixel), when it is zero,
186
+ * when nothing of `src` survives after clamping to the surface, or on a
187
+ * destroyed surface. The band is `clamped src ∩ (clamped src + delta)`,
188
+ * so nothing outside `src` is written; the overlap is safe because the
189
+ * copy walks rows in the direction that reads before it overwrites.
190
+ */
191
+ copyWithin(src, dx, dy) {
192
+ if (this._destroyed) return false;
193
+ if (!Number.isInteger(dx) || !Number.isInteger(dy)) return false;
194
+ if (dx === 0 && dy === 0) return false;
195
+ const x0 = Math.max(0, Math.floor(src.x));
196
+ const y0 = Math.max(0, Math.floor(src.y));
197
+ const x1 = Math.min(this.width, Math.ceil(src.x + src.width));
198
+ const y1 = Math.min(this.height, Math.ceil(src.y + src.height));
199
+ const dstX0 = Math.max(x0, x0 + dx);
200
+ const dstY0 = Math.max(y0, y0 + dy);
201
+ const dstX1 = Math.min(x1, x1 + dx);
202
+ const dstY1 = Math.min(y1, y1 + dy);
203
+ // written as the positive test so a NaN edge (a rect with no numbers
204
+ // in it) is a refusal too, never a native call with garbage
205
+ if (!(dstX1 > dstX0 && dstY1 > dstY0)) return false;
206
+ return Boolean(
207
+ this._native.scrollSurface(
208
+ this._surfaceHandle,
209
+ x0,
210
+ y0,
211
+ x1 - x0,
212
+ y1 - y0,
213
+ dx,
214
+ dy,
215
+ ),
216
+ );
217
+ }
218
+
219
+ destroy() {
220
+ if (this._destroyed) return;
221
+ this._destroyed = true;
222
+ const handle = this._surfaceHandle;
223
+ this._surfaceHandle = null;
224
+ this._ctx = null;
225
+ if (typeof this._native.releaseSurface === 'function') {
226
+ this._native.releaseSurface(handle);
227
+ }
228
+ }
229
+
230
+ [Symbol.dispose]() {
231
+ this.destroy();
232
+ }
233
+ }
234
+
235
+ export default CocoaSurface;