react-x11 2.15.3 → 2.16.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.
Files changed (67) hide show
  1. package/README.md +37 -0
  2. package/package.json +3 -3
  3. package/src/Reconciler.js +85 -22
  4. package/src/acceleratorhooks.js +40 -6
  5. package/src/anchor.js +79 -19
  6. package/src/capabilities.js +29 -4
  7. package/src/cocoa/app.js +211 -11
  8. package/src/cocoa/context2d.js +23 -0
  9. package/src/cocoa/fonts.js +78 -0
  10. package/src/cocoa/presenter.js +17 -0
  11. package/src/cocoa/promotion.js +20 -0
  12. package/src/cocoa/relaunch.js +8 -3
  13. package/src/cocoa/symbols.js +64 -0
  14. package/src/cocoa/threaded.js +24 -4
  15. package/src/cocoa/window.js +362 -139
  16. package/src/components/ProgressBar.js +1 -1
  17. package/src/components/Slider.js +72 -39
  18. package/src/components/anchor.js +7 -2
  19. package/src/components/index.js +1 -0
  20. package/src/components/theme.js +32 -28
  21. package/src/desktopcapabilityhooks.js +29 -6
  22. package/src/filedialoghooks.js +3 -5
  23. package/src/frame/childmain.js +8 -20
  24. package/src/frame/env.js +2 -10
  25. package/src/icontheme.js +240 -0
  26. package/src/imagesource.js +83 -1
  27. package/src/index.d.ts +10 -1
  28. package/src/index.js +3 -0
  29. package/src/keysymchars.js +47 -0
  30. package/src/keysyms.d.ts +19 -1
  31. package/src/keysyms.js +107 -8
  32. package/src/node.d.ts +7 -0
  33. package/src/nodes/animation.js +17 -47
  34. package/src/nodes/cascade.js +17 -2
  35. package/src/nodes/image.js +63 -1
  36. package/src/nodes/kinds.js +12 -0
  37. package/src/nodes/layout.js +5 -1
  38. package/src/nodes/node.js +17 -3
  39. package/src/nodes/paint.js +117 -0
  40. package/src/nodes/scope.js +259 -0
  41. package/src/nodes/scrollable.js +53 -6
  42. package/src/nodes/text.js +2 -0
  43. package/src/nodes/textarea.js +1 -1
  44. package/src/nodes/textinput.js +1 -1
  45. package/src/nodes/window/anchoring.js +45 -18
  46. package/src/nodes/window/flush.js +6 -5
  47. package/src/nodes/window/popup.js +10 -0
  48. package/src/nodes/window/size.js +40 -2
  49. package/src/nodes/window/window.js +41 -14
  50. package/src/registry.js +2 -1
  51. package/src/screens.js +159 -24
  52. package/src/settings.js +332 -0
  53. package/src/statusnotifier.js +164 -17
  54. package/src/styles.js +212 -8
  55. package/src/symbols.js +200 -0
  56. package/src/testing/mock-app.js +10 -0
  57. package/src/trayhooks.js +21 -5
  58. package/src/types/capabilities.d.ts +13 -1
  59. package/src/types/components.d.ts +33 -0
  60. package/src/types/elements.d.ts +57 -6
  61. package/src/types/events.d.ts +5 -0
  62. package/src/types/filedialog.d.ts +3 -1
  63. package/src/types/style.d.ts +57 -0
  64. package/src/types/system.d.ts +104 -0
  65. package/src/types/tray.d.ts +14 -2
  66. package/src/wayland/xkb.js +170 -59
  67. package/src/windowid.js +62 -20
@@ -12,11 +12,38 @@ import { CocoaLayerPresenter } from './presenter.js';
12
12
  import { CocoaPromotion } from './promotion.js';
13
13
 
14
14
  let nextWindowId = 1;
15
- // How long a worker's flip may keep its window's back buffer before the
16
- // window draws into it anyway (`_armFence`). The release is reported once
17
- // the replacing frame has committed, a fraction of a millisecond later, and
18
- // a window must not freeze on a report that never came.
15
+ // How long a worker's flip may hold its window's next frame (`_armFence`).
16
+ // The release is reported once the replacing frame has committed, a
17
+ // fraction of a millisecond later, and a window must not freeze on a report
18
+ // that never came.
19
19
  const FENCE_TIMEOUT_MS = 100;
20
+ // How many buffers a window's swapchain may hold, the one on glass included.
21
+ // A buffer leaves glass and the WindowServer lets go of it a refresh or so
22
+ // later — a median of about 9ms on a 120Hz panel, and under 40 at the 99th
23
+ // percentile on a loaded machine — so a frame on the next refresh often
24
+ // finds the buffer its flip replaced still held, and takes the one before
25
+ // (`_takeBack`). Three, a `CAMetalLayer`'s depth, is what an animation
26
+ // mostly needs; an input answered between two of its frames puts two flips
27
+ // inside one refresh, and four kept every write of a clicked 120Hz animation
28
+ // off a held buffer (docs/macos.md, "Measured: the swapchain and the
29
+ // WindowServer's hold").
30
+ const MAX_BUFFERS = 4;
31
+ // Past this many rects, what a buffer missed is copied whole: a buffer left
32
+ // out for a while owes a rect list per frame, and one memcpy of the window
33
+ // is cheaper than keeping them.
34
+ const OWED_RECTS_MAX = 64;
35
+ // How long a window goes without a frame before a chain that grew past two
36
+ // buffers gives the rest back (`_trimChain`).
37
+ const TRIM_AFTER_MS = 1000;
38
+
39
+ /** What a buffer owes once `damage` has been presented without it: the
40
+ * rects it missed, or 'full'. */
41
+ function owe(owed, damage) {
42
+ if (owed === 'full' || damage === 'full') return 'full';
43
+ if (!owed) return damage;
44
+ const rects = owed.concat(damage);
45
+ return rects.length > OWED_RECTS_MAX ? 'full' : rects;
46
+ }
20
47
 
21
48
  export class CocoaWindow {
22
49
  constructor(app, attributes = {}) {
@@ -44,28 +71,35 @@ export class CocoaWindow {
44
71
  this._presentedAt = -Infinity;
45
72
 
46
73
  const s = this.scale;
47
- // Snapped to whole POINTS: AppKit rounds window sizes to the point
48
- // grid, so an odd device-pixel request comes back one short in the
49
- // resize echo, the echo re-requests, and the backing surface churns —
50
- // each swap an uninitialized canvas only the next damage rect repaints.
51
- const snap = (v, fallback) =>
52
- Math.max(1, Math.round(Math.max(1, Math.round(v ?? fallback)) / s) * s);
53
- this.width = snap(attributes.width, 640);
54
- this.height = snap(attributes.height, 480);
74
+ const born = this.snapSize(
75
+ attributes.width ?? 640,
76
+ attributes.height ?? 480,
77
+ );
78
+ this.width = born.width;
79
+ this.height = born.height;
55
80
  this.title = attributes.title ?? '';
56
81
  this._popup = attributes.overrideRedirect === true;
82
+ // A popup that takes the keyboard (`<popup grabKeyboard>`): AppKit's
83
+ // popup panel can never become key, so this one is a borderless window
84
+ // that can, at the popup level, and it activates the app when it shows
85
+ // — how NSPopover's own window behaves. It is decided here because the
86
+ // kind of window is: a later change of the prop does not remake it.
87
+ this._keyPopup = this._popup && attributes.grabKeyboard === true;
57
88
 
58
89
  const options = {
59
90
  width: this.width / s,
60
91
  height: this.height / s,
61
92
  title: this.title,
62
- kind: this._popup
63
- ? 'popup'
64
- : attributes.decorations === false
65
- ? 'borderless'
66
- : 'normal',
93
+ kind: this._keyPopup
94
+ ? 'borderless'
95
+ : this._popup
96
+ ? 'popup'
97
+ : attributes.decorations === false
98
+ ? 'borderless'
99
+ : 'normal',
67
100
  resizable: attributes.resizable !== false,
68
101
  };
102
+ if (this._keyPopup) options.level = 'popup';
69
103
  if (typeof attributes.x === 'number' && typeof attributes.y === 'number') {
70
104
  options.x = attributes.x / s;
71
105
  options.y = attributes.y / s;
@@ -104,13 +138,11 @@ export class CocoaWindow {
104
138
  // (`_frameSize`)
105
139
  this._points = null;
106
140
  // Threaded mode's swapchain fence (`frameInFlight`): the IOSurface id
107
- // on the layer as far as this window has asked, the one the last flip
108
- // is taking off glass until the bridge says it is released, and the
109
- // catch-up copy the back buffer is owed once it is back.
110
- this._onGlass = null;
141
+ // the last flip is taking off glass, until the bridge says the flip has
142
+ // been applied.
111
143
  this._awaiting = null;
112
- this._catchUp = null;
113
144
  this._fenceTimer = null;
145
+ this._trimTimer = null;
114
146
  this._shadowTimer = null;
115
147
  // AppKit's live resize, between the bridge's `window-live-resize` begin
116
148
  // and end (`CocoaApp._routeLiveResize`): a tick of it lays out with the
@@ -241,10 +273,32 @@ export class CocoaWindow {
241
273
  this._refreshFrameInterval();
242
274
  }
243
275
 
276
+ /**
277
+ * The size this window really takes for a request of `width` x `height`
278
+ * device pixels — **whole POINTS**, because that is the grid AppKit puts a
279
+ * window on. An odd device-pixel request at scale 2 comes back one short
280
+ * in the resize echo, which churns the backing surface — each swap an
281
+ * uninitialized canvas only the next damage rect repaints — and which the
282
+ * renderer reads as somebody else having set the size, ending an
283
+ * `'auto'` window's authority over its own for good (#586). So the
284
+ * renderer asks this what it is going to get and records that instead
285
+ * (`WindowNode._snapSize`).
286
+ *
287
+ * **Up**, not to the nearest: a window a device pixel taller than its
288
+ * content shows all of it, and one a device pixel shorter clips it.
289
+ */
290
+ snapSize(width, height) {
291
+ const s = this.scale;
292
+ const up = (v) =>
293
+ Math.max(1, Math.ceil(Math.max(1, Math.round(v)) / s) * s);
294
+ return { width: up(width), height: up(height) };
295
+ }
296
+
244
297
  resize(width, height) {
245
298
  const s = this.scale;
246
- this.width = Math.max(1, Math.round(Math.round(width) / s) * s);
247
- this.height = Math.max(1, Math.round(Math.round(height) / s) * s);
299
+ const size = this.snapSize(width, height);
300
+ this.width = size.width;
301
+ this.height = size.height;
248
302
  this._native.setWindowFrame(
249
303
  this._h,
250
304
  null,
@@ -274,9 +328,10 @@ export class CocoaWindow {
274
328
  // so a window that was hidden behind one, unmapped and mapped again
275
329
  // does not wait on an event that may already have been delivered.
276
330
  this._occluded = false;
277
- // A popup must not take the keyboard from its owner; a toplevel's first
278
- // map is the app coming up and takes it.
279
- this._native.showWindow(this._h, !this._popup);
331
+ // A popup must not take the keyboard from its owner, unless taking it
332
+ // is what it is for; a toplevel's first map is the app coming up and
333
+ // takes it.
334
+ this._native.showWindow(this._h, !this._popup || this._keyPopup);
280
335
  this._refreshOrigin();
281
336
  this._refreshFrameInterval();
282
337
  }
@@ -434,28 +489,36 @@ export class CocoaWindow {
434
489
  // --- drawing -------------------------------------------------------------
435
490
 
436
491
  /**
437
- * The backing store is a two-buffer IOSurface swapchain: painters draw
438
- * into the back buffer's CG bitmap, and presenting is `layer.contents =
439
- * iosurface` — zero-copy, where the plain-surface path paid a
440
- * window-sized CGImage copy per dirty frame (12ms at 900x700@2x — the
441
- * presenter bench's whole surface-vs-layers gap on bounded damage).
442
- * After a flip the new back buffer is one frame stale, so present copies
443
- * the just-shown frame's damage across — a damage-sized memcpy replacing
444
- * a window-sized upload. Falls back to the single plain surface where
445
- * IOSurface creation fails.
492
+ * The backing store is an IOSurface swapchain: painters draw into the
493
+ * back buffer's CG bitmap, and presenting is `layer.contents = iosurface`
494
+ * — zero-copy, where the plain-surface path paid a window-sized CGImage
495
+ * copy per dirty frame (12ms at 900x700@2x — the presenter bench's whole
496
+ * surface-vs-layers gap on bounded damage). A buffer that has been on
497
+ * glass is behind by the frames presented since, so the frame that takes
498
+ * it copies what it missed across first — a damage-sized memcpy
499
+ * replacing a window-sized upload. Falls back to the single plain surface
500
+ * where IOSurface creation fails.
501
+ *
502
+ * Which buffer that is, and when, is the WindowServer's to say (#602). It
503
+ * composites out of the buffer it was handed, and lets go of one a
504
+ * refresh or so after a flip replaced it — on a worker, not before the
505
+ * flip has even been applied. A write into a buffer it still holds is a
506
+ * write into what it may be compositing, and a composite caught mid-write
507
+ * is half a frame on glass. So no buffer is chosen at the flip: the next
508
+ * frame takes one at its first draw, from those the WindowServer has let
509
+ * go of (`_takeBack`).
446
510
  *
447
- * A new size retires the pair, and the retired pair is released on the
448
- * spot (`_releaseBacking`): a resize tick allocates two window-sized
449
- * IOSurfaces, 20MB at 900x700@2x, and left to the handles' finalizers a
450
- * forty-tick drag held 800MB until a collection happened to run — the
451
- * `rss +80MB` docs/macos.md measured. The layer keeps its own reference
452
- * to whichever IOSurface it is still showing, so the free is safe while
453
- * that frame is on glass.
511
+ * A new size retires the chain, released on the spot (`_releaseBacking`):
512
+ * left to the handles' finalizers, a forty-tick drag of the two-buffer
513
+ * chain this used to be held 800MB until a collection happened to run —
514
+ * the `rss +80MB` docs/macos.md measured. The layer keeps its own
515
+ * reference to whichever IOSurface it is still showing, so the free is
516
+ * safe while that frame is on glass. A new chain starts with the one
517
+ * buffer its first frame paints: a live-resize tick paints its size once,
518
+ * and the next tick's size needs a chain of its own. A chain that grew
519
+ * past two gives the rest back once the window stops (`_armTrim`).
454
520
  */
455
521
  _ensureSurface() {
456
- // a paint that did not wait for the fence: the back buffer gets its
457
- // catch-up now, so what it paints lands over the frame before it
458
- this._settleBack();
459
522
  const w = this.width;
460
523
  const h = this.height;
461
524
  if (
@@ -466,13 +529,12 @@ export class CocoaWindow {
466
529
  const hadSurface = Boolean(this._surface);
467
530
  this._releaseBacking();
468
531
  try {
469
- const a = this._native.createSurfaceIOSurface(w, h, this.scale);
470
- const b = this._native.createSurfaceIOSurface(w, h, this.scale);
471
- this._chain = { back: a, front: b };
472
- this._native.surfaceLock(a.handle);
473
- this._native.ctxClearRect(a.handle, 0, 0, w, h);
474
- this._native.ctxClearRect(b.handle, 0, 0, w, h);
475
- this._surface = a.handle;
532
+ const back = this._newBuffer(w, h);
533
+ back.owed = null;
534
+ this._chain = { buffers: [back], front: null, back };
535
+ this._native.surfaceLock(back.handle);
536
+ this._native.ctxClearRect(back.handle, 0, 0, w, h);
537
+ this._surface = back.handle;
476
538
  } catch {
477
539
  this._surface = this._native.createSurface(w, h, this.scale);
478
540
  this._native.ctxClearRect(this._surface, 0, 0, w, h);
@@ -491,33 +553,193 @@ export class CocoaWindow {
491
553
  // frames that all ran on the mouse release — the freeze after a
492
554
  // resize, measured at seconds on a large tree.
493
555
  if (hadSurface) this._freshSurface = true;
556
+ } else if (this._chain && !this._chain.back) {
557
+ this._takeBack();
494
558
  }
495
559
  return this._surface;
496
560
  }
497
561
 
562
+ /** A buffer for the chain, owing everything: nothing has been drawn in it. */
563
+ _newBuffer(w, h) {
564
+ const { handle, iosurfaceId } = this._native.createSurfaceIOSurface(
565
+ w,
566
+ h,
567
+ this.scale,
568
+ );
569
+ return {
570
+ handle,
571
+ iosurfaceId,
572
+ // what it missed while another buffer was on glass: rects, or 'full'
573
+ owed: 'full',
574
+ // when a flip last took it off glass
575
+ offGlassAt: -Infinity,
576
+ // false from a worker's flip that took it off glass until the bridge
577
+ // reports that flip applied (`_surfaceReleased`)
578
+ released: true,
579
+ };
580
+ }
581
+
498
582
  /**
499
- * Free the backing store now the swapchain pair, or the plain surface
500
- * the fallback holdsrather than when V8 collects the handles. Bridges
501
- * before 0.4 have no `releaseSurface`; there the finalizer is still the
502
- * only owner, and this is the drop it always was.
583
+ * The buffer the frame about to be drawn goes into, taken at its first
584
+ * draw rather than at the flip before it as late as the frame allows,
585
+ * so the WindowServer has had as long as it can to let go.
586
+ *
587
+ * Of the buffers not on glass, one it has let go of: `surfaceIsInUse`
588
+ * false, and on a worker the flip that retired it applied. Of those, the
589
+ * one on glass most recently, which missed the fewest frames. When every
590
+ * one is still held — the buffer the last flip replaced usually is, and a
591
+ * burst of flips inside one refresh holds more — a new buffer, while the
592
+ * chain has room for one. Past that, the one off glass longest: the
593
+ * likeliest to have been let go of by now, and the one a two-buffer chain
594
+ * always drew into.
595
+ *
596
+ * Then what it missed, copied across from the frame on glass.
597
+ */
598
+ _takeBack() {
599
+ const chain = this._chain;
600
+ let free = null;
601
+ let oldest = null;
602
+ for (const buffer of chain.buffers) {
603
+ if (buffer === chain.front) continue;
604
+ if (
605
+ !oldest ||
606
+ (buffer.released && !oldest.released) ||
607
+ (buffer.released === oldest.released &&
608
+ buffer.offGlassAt < oldest.offGlassAt)
609
+ ) {
610
+ oldest = buffer;
611
+ }
612
+ if (!buffer.released || this._held(buffer)) continue;
613
+ if (!free || buffer.offGlassAt > free.offGlassAt) free = buffer;
614
+ }
615
+ let back = free;
616
+ if (!back && chain.buffers.length < MAX_BUFFERS) {
617
+ try {
618
+ back = this._newBuffer(
619
+ this._surfaceSize.width,
620
+ this._surfaceSize.height,
621
+ );
622
+ chain.buffers.push(back);
623
+ if (chain.buffers.length > 2) this._armTrim();
624
+ } catch {
625
+ // no memory for another: the chain draws with what it has
626
+ }
627
+ }
628
+ // …and a chain of one, whose next buffer could not be made, draws where
629
+ // it shows, as the plain surface does
630
+ back ??= oldest ?? chain.front;
631
+ chain.back = back;
632
+ this._surface = back.handle;
633
+ // a different native surface owns the graphics state now — the context
634
+ // re-syncs its sticky state off the generation
635
+ this._surfaceGen++;
636
+ this._native.surfaceLock(back.handle);
637
+ if (back.owed && back !== chain.front) {
638
+ this._native.copySurfaceRegion(
639
+ chain.front.handle,
640
+ back.handle,
641
+ back.owed === 'full'
642
+ ? null
643
+ : back.owed.flatMap((r) => [
644
+ Math.floor(r.x),
645
+ Math.floor(r.y),
646
+ Math.ceil(r.width) + 1,
647
+ Math.ceil(r.height) + 1,
648
+ ]),
649
+ );
650
+ }
651
+ back.owed = null;
652
+ }
653
+
654
+ /**
655
+ * A chain that grew past two buffers gives the rest back once the window
656
+ * has gone `TRIM_AFTER_MS` without a frame. The extra buffers are what an
657
+ * animation needs, a window-sized IOSurface each, and a window that has
658
+ * stopped keeps the two a still window always had: the one on glass, and
659
+ * the one on glass before it — which owes the least, so the next frame
660
+ * takes it and makes nothing. A frame drawn and not yet presented keeps
661
+ * its own buffer instead.
662
+ */
663
+ _armTrim() {
664
+ if (this._trimTimer) return;
665
+ this._trimTimer = setTimeout(() => {
666
+ this._trimTimer = null;
667
+ if (!this.destroyed && !this._trimChain()) this._armTrim();
668
+ }, TRIM_AFTER_MS);
669
+ }
670
+
671
+ /** The trim, at `now`: false when the window is still painting, and the
672
+ * trim has to look again later. */
673
+ _trimChain(now = performance.now()) {
674
+ const chain = this._chain;
675
+ if (!chain || chain.buffers.length <= 2) return true;
676
+ // a worker's flip not yet applied names a buffer the release will look for
677
+ if (now - this._presentedAt < TRIM_AFTER_MS || this._awaiting != null) {
678
+ return false;
679
+ }
680
+ let keep = chain.back;
681
+ if (!keep) {
682
+ for (const buffer of chain.buffers) {
683
+ if (buffer === chain.front) continue;
684
+ if (
685
+ !keep ||
686
+ (buffer.released && !keep.released) ||
687
+ (buffer.released === keep.released &&
688
+ buffer.offGlassAt > keep.offGlassAt)
689
+ ) {
690
+ keep = buffer;
691
+ }
692
+ }
693
+ }
694
+ const release = this._native.releaseSurface;
695
+ const kept = [];
696
+ for (const buffer of chain.buffers) {
697
+ if (buffer === chain.front || buffer === keep) {
698
+ kept.push(buffer);
699
+ } else if (typeof release === 'function') {
700
+ release.call(this._native, buffer.handle);
701
+ }
702
+ }
703
+ chain.buffers = kept;
704
+ return true;
705
+ }
706
+
707
+ /** Whether the WindowServer still holds `buffer`: `IOSurfaceIsInUse`. A
708
+ * bridge that cannot say (before 0.10) is answered no, which is the
709
+ * two-buffer chain this was. */
710
+ _held(buffer) {
711
+ const inUse = this._native.surfaceIsInUse;
712
+ return (
713
+ typeof inUse === 'function' &&
714
+ inUse.call(this._native, buffer.handle) === true
715
+ );
716
+ }
717
+
718
+ /**
719
+ * Free the backing store now — the swapchain's buffers, or the plain
720
+ * surface the fallback holds — rather than when V8 collects the handles.
721
+ * Bridges before 0.4 have no `releaseSurface`; there the finalizer is
722
+ * still the only owner, and this is the drop it always was.
503
723
  */
504
724
  _releaseBacking() {
505
725
  const release = this._native.releaseSurface;
506
726
  if (typeof release === 'function') {
507
727
  if (this._chain) {
508
- release.call(this._native, this._chain.back.handle);
509
- release.call(this._native, this._chain.front.handle);
728
+ for (const buffer of this._chain.buffers) {
729
+ release.call(this._native, buffer.handle);
730
+ }
510
731
  } else if (this._surface) {
511
732
  release.call(this._native, this._surface);
512
733
  }
513
734
  }
514
735
  this._chain = null;
515
736
  this._surface = null;
516
- // a new pair owes nothing to what the old one was showing
737
+ // a new chain owes nothing to what the old one was showing
517
738
  clearTimeout(this._fenceTimer);
518
739
  this._fenceTimer = null;
519
740
  this._awaiting = null;
520
- this._catchUp = null;
741
+ clearTimeout(this._trimTimer);
742
+ this._trimTimer = null;
521
743
  }
522
744
 
523
745
  /**
@@ -593,13 +815,21 @@ export class CocoaWindow {
593
815
  * rect, and a delta that leaves no surviving band reports false so the
594
816
  * caller falls back to the plain repaint. */
595
817
  scrollRegion(rect, dx, dy) {
596
- if (!this._surface) return false;
597
- // the band moves over the frame before this one, not over a buffer
598
- // still owed its catch-up
599
- this._settleBack();
818
+ // Nothing painted yet, or a size the next paint makes a new chain for:
819
+ // there is no band to move.
820
+ if (
821
+ !this._surface ||
822
+ this._surfaceSize?.width !== this.width ||
823
+ this._surfaceSize?.height !== this.height
824
+ ) {
825
+ return false;
826
+ }
600
827
  if (!Number.isInteger(dx) || !Number.isInteger(dy)) return false;
828
+ // The band moves in the buffer this frame draws into — taken, and
829
+ // caught up to the frame on glass, before anything moves in it.
830
+ const surface = this._ensureSurface();
601
831
  const moved = this._native.scrollSurface(
602
- this._surface,
832
+ surface,
603
833
  Math.round(rect.x),
604
834
  Math.round(rect.y),
605
835
  Math.round(rect.width),
@@ -609,11 +839,11 @@ export class CocoaWindow {
609
839
  );
610
840
  if (moved) {
611
841
  this._dirty = true;
612
- // The band moved inside the BACK buffer only. After the flip the
613
- // other buffer still holds the band where it was, and the catch-up
614
- // copy only covers what the flush painted — the strips the shift
842
+ // The band moved inside the BACK buffer only. After the flip every
843
+ // other buffer still holds the band where it was, and what a buffer
844
+ // is owed only covers what the flush painted — the strips the shift
615
845
  // exposed — so the next frame would blit a band one frame stale.
616
- // Record the shifted rect as painted, and the flip's copy carries it.
846
+ // Record the shifted rect as painted, and the catch-up carries it.
617
847
  this.noteFrameDamage([
618
848
  {
619
849
  x: Math.round(rect.x),
@@ -627,14 +857,14 @@ export class CocoaWindow {
627
857
  }
628
858
 
629
859
  /**
630
- * Whether this window's last frame still holds the buffer the next one
631
- * would be drawn into — the X11 contract's fence (src/frames.js). The
632
- * pump never needs it: a pump-mode flip is applied in the call, so the
633
- * other buffer is off glass before the next paint. A worker's flip is a
634
- * command the UI thread applies later, and until the bridge reports the
635
- * buffer it replaced as released (`_surfaceReleased`), drawing into that
636
- * buffer draws into what is on glass. A new size is never in flight: it
637
- * paints into a new pair that nothing is showing.
860
+ * Whether this window's last flip has yet to reach the layer the X11
861
+ * contract's fence (src/frames.js). The pump never needs it: a pump-mode
862
+ * flip is applied in the call. A worker's flip is a command the UI thread
863
+ * applies later, and until the bridge reports the buffer it replaced as
864
+ * released (`_surfaceReleased`), that buffer is still what is on glass,
865
+ * and a frame painted now would be one the UI thread has not caught up
866
+ * with. A new size is never in flight: it paints into a new chain that
867
+ * nothing is showing.
638
868
  *
639
869
  * And while a batch is being routed (`CocoaApp._routeBatch`), its frame
640
870
  * is the one owed: it goes out when the batch is done, answering every
@@ -660,10 +890,13 @@ export class CocoaWindow {
660
890
  * buffer the first flip had taken off glass microseconds before, while the
661
891
  * WindowServer could still be compositing from it. A scroll's frame blits,
662
892
  * then repaints what the shift got wrong, so a composite caught between the
663
- * two shows the blit alone — a held sticky header dragged up a pixel for a
664
- * frame, or the rows under it painted over it, then corrected. No other
665
- * input has both halves of that: a resize paints into a pair it has just
666
- * made, and a press or a key does not come in bursts inside a refresh.
893
+ * two showed the blit alone — a held sticky header dragged up a pixel for a
894
+ * frame. The swapchain no longer draws into a buffer the WindowServer
895
+ * holds (`_takeBack`), but one refresh still shows one frame: the rest of a
896
+ * burst is work nobody sees, and each flip of it would hold a buffer. A
897
+ * resize paints into a chain it has just made, and a press or a key does
898
+ * not come in bursts inside a refresh — the one answered between two
899
+ * frames of an animation is what the chain's fourth buffer is for.
667
900
  *
668
901
  * Off with the interval: `frameInterval: 0` asks for no pacing at all.
669
902
  */
@@ -679,12 +912,10 @@ export class CocoaWindow {
679
912
 
680
913
  /**
681
914
  * Push the backing surface at the WindowServer, if anything drew — and
682
- * tell the window node what it cost. The flip is cheap; the catch-up
683
- * copy behind it is a damage-sized memcpy, and on a window whose every
684
- * frame repaints most of itself that is a millisecond of the JS thread
685
- * per frame that the flush never saw. The frame pacer prices the frame
686
- * by the thread's time, so the present reports in (src/pacing.js,
687
- * `WindowNode._notePresentCost`).
915
+ * tell the window node what it cost. The flip itself is cheap, and the
916
+ * catch-up copy is the next frame's (`_takeBack`), inside its flush; the
917
+ * present still reports in, since the frame pacer prices a frame by the
918
+ * thread's time (src/pacing.js, `WindowNode._notePresentCost`).
688
919
  */
689
920
  present() {
690
921
  const started = performance.now();
@@ -701,33 +932,39 @@ export class CocoaWindow {
701
932
  if (this._holdPresent || !this._visible()) return false;
702
933
  this._dirty = false;
703
934
  this._presentedAt = performance.now();
704
- if (this._chain) {
705
- const shown = this._chain.back;
935
+ const chain = this._chain;
936
+ if (chain) {
937
+ const shown = chain.back;
706
938
  this._native.surfaceUnlock(shown.handle);
707
939
  this._flip(() =>
708
940
  this._native.setLayerContentsIOSurface(this._layer, shown.iosurfaceId),
709
941
  );
710
- this._chain.back = this._chain.front;
711
- this._chain.front = shown;
712
- this._surface = this._chain.back.handle;
713
- // a different native surface owns the graphics state now — the
714
- // context re-syncs its sticky state off the generation
715
- this._surfaceGen++;
716
- const catchUp = { from: shown.handle, damage: this._flushDamage };
942
+ // Every other buffer has missed this frame. Nothing is copied into
943
+ // any of them now: the buffer this flip takes off glass is the one
944
+ // the WindowServer is surest to be holding (`_takeBack`).
945
+ const damage = this._flushDamage ?? 'full';
717
946
  this._flushDamage = null;
718
- // The buffer drawn into next is the one this flip takes off glass —
719
- // when it was on glass at all: a new pair's first flip replaces a
720
- // buffer of the old pair, or nothing. On a worker the flip has not
721
- // happened yet, so its catch-up and the next paint wait for the
722
- // bridge to say it has (`frameInFlight`).
723
- const previous = this._onGlass;
724
- this._onGlass = shown.iosurfaceId;
725
- if (this.app._threaded && previous === this._chain.back.iosurfaceId) {
726
- this._awaiting = previous;
727
- this._catchUp = catchUp;
728
- this._armFence();
729
- } else {
730
- this._settle(catchUp);
947
+ for (const buffer of chain.buffers) {
948
+ if (buffer !== shown) buffer.owed = owe(buffer.owed, damage);
949
+ }
950
+ const retired = chain.front;
951
+ chain.front = shown;
952
+ chain.back = null;
953
+ // Until the next frame takes a buffer, the surface is the frame on
954
+ // glass: a read gets the picture presented, and nothing here draws
955
+ // into it — every draw asks `_ensureSurface` first.
956
+ this._surface = shown.handle;
957
+ // A new chain's first flip replaces a buffer of the old one, or
958
+ // nothing, and a buffer flipped again takes nothing off glass.
959
+ if (retired && retired !== shown) {
960
+ retired.offGlassAt = this._presentedAt;
961
+ // On a worker the flip has not happened yet: the next frame waits
962
+ // for the bridge to say it has (`frameInFlight`).
963
+ if (this.app._threaded) {
964
+ retired.released = false;
965
+ this._awaiting = retired.iosurfaceId;
966
+ this._armFence();
967
+ }
731
968
  }
732
969
  this._shadowAfterFlip();
733
970
  return true;
@@ -774,49 +1011,35 @@ export class CocoaWindow {
774
1011
  return { width: this.width / s, height: this.height / s };
775
1012
  }
776
1013
 
777
- /** The back buffer is off glass: lock it for drawing and copy across what
778
- * the frame now shown painted, so that the next frame starts from it. */
779
- _settle({ from, damage }) {
780
- this._native.surfaceLock(this._surface);
781
- this._native.copySurfaceRegion(
782
- from,
783
- this._surface,
784
- damage === 'full' || !damage
785
- ? null
786
- : damage.flatMap((r) => [
787
- Math.floor(r.x),
788
- Math.floor(r.y),
789
- Math.ceil(r.width) + 1,
790
- Math.ceil(r.height) + 1,
791
- ]),
792
- );
793
- }
794
-
795
- /** Stop waiting on the fence: the back buffer's catch-up, now. */
796
- _settleBack() {
797
- if (this._awaiting == null) return;
1014
+ /** Stop waiting on the fence. */
1015
+ _stopWaiting() {
798
1016
  clearTimeout(this._fenceTimer);
799
1017
  this._fenceTimer = null;
800
1018
  this._awaiting = null;
801
- const catchUp = this._catchUp;
802
- this._catchUp = null;
803
- if (catchUp && this._chain) this._settle(catchUp);
804
1019
  }
805
1020
 
806
- /** `surface-released` for `id`: true when it was the buffer this window
807
- * was waiting on. */
1021
+ /** `surface-released` for `id`: true when the buffer is this window's. It
1022
+ * may be drawn into again once the WindowServer lets go of it too. */
808
1023
  _surfaceReleased(id) {
809
- if (this._awaiting == null || this._awaiting !== id) return false;
810
- this._settleBack();
1024
+ const buffer = this._chain?.buffers.find((b) => b.iosurfaceId === id);
1025
+ if (!buffer) return false;
1026
+ buffer.released = true;
1027
+ if (this._awaiting === id) this._stopWaiting();
811
1028
  return true;
812
1029
  }
813
1030
 
1031
+ /**
1032
+ * A release that never comes stops holding the window's frames after a
1033
+ * moment. It does not make the buffer free: a flip the UI thread has not
1034
+ * applied still has that buffer on glass, and the next frame takes
1035
+ * another (`_takeBack`) until the release does come.
1036
+ */
814
1037
  _armFence() {
815
1038
  clearTimeout(this._fenceTimer);
816
1039
  this._fenceTimer = setTimeout(() => {
817
1040
  this._fenceTimer = null;
818
1041
  if (this._awaiting == null || this.destroyed) return;
819
- this._settleBack();
1042
+ this._stopWaiting();
820
1043
  this.app._tickFrames();
821
1044
  this.app._presentAll();
822
1045
  }, FENCE_TIMEOUT_MS);