react-x11 2.8.3 → 2.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/nodes.js CHANGED
@@ -88,6 +88,7 @@ import {
88
88
  } from './dnd.js';
89
89
  import { TYPE_GROUPS } from './transfer.js';
90
90
  import { addPendingFrame, clearPendingFrame } from './frames.js';
91
+ import { FramePacer, resolveFramePolicy } from './pacing.js';
91
92
  import { createClientMessages } from './clientmessage.js';
92
93
  import {
93
94
  argbVisual,
@@ -413,6 +414,17 @@ function insetRect(rect, by) {
413
414
  };
414
415
  }
415
416
 
417
+ /** The whole pixels inside `rect` — a fractional edge left out — or null
418
+ * when none are. */
419
+ function innerPixels(rect) {
420
+ const x = Math.ceil(rect.x);
421
+ const y = Math.ceil(rect.y);
422
+ const right = Math.floor(rect.x + rect.width);
423
+ const bottom = Math.floor(rect.y + rect.height);
424
+ if (right <= x || bottom <= y) return null;
425
+ return { x, y, width: right - x, height: bottom - y };
426
+ }
427
+
416
428
  /** The overlap of two rects, or null when they have none. */
417
429
  export function intersectRects(a, b) {
418
430
  const x = Math.max(a.x, b.x);
@@ -3501,6 +3513,7 @@ export class Node {
3501
3513
  // next tick, so a spinner that unmounts leaves the clock idle even if
3502
3514
  // nothing else ever asks for a frame
3503
3515
  this.root?._animating.delete(this);
3516
+ this.root?._opaqueNodes?.delete(this);
3504
3517
  // a surface that goes away takes its selection with it, and the app-wide
3505
3518
  // claim on being the one showing one goes with it too
3506
3519
  this._textSelection?.destroy();
@@ -3510,7 +3523,11 @@ export class Node {
3510
3523
 
3511
3524
  _setRoot(root) {
3512
3525
  if (this.root === root) return;
3526
+ // an element answering `opaqueRect()` is one the window asks per pass
3527
+ const opaque = this.opaqueRect !== Node.prototype.opaqueRect;
3528
+ if (opaque) this.root?._opaqueNodes?.delete(this);
3513
3529
  this.root = root;
3530
+ if (opaque) root?._opaqueNodes?.add(this);
3514
3531
  // A node is styled in its constructor, before it has a window — so this
3515
3532
  // is where a loop declared by the very first style finds a frame clock
3516
3533
  // to run on.
@@ -4258,6 +4275,32 @@ export class Node {
4258
4275
  return this.root?._paintDamage ?? null;
4259
4276
  }
4260
4277
 
4278
+ /**
4279
+ * The rect this element writes opaque pixels over on every paint — in
4280
+ * window coordinates like `abs`, in whole pixels — or null, the default,
4281
+ * which promises nothing.
4282
+ *
4283
+ * What it buys: a pass that lies inside it is painted without the fills
4284
+ * that would be under it — the window's background, this node's own and
4285
+ * every ancestor's — because not one of those pixels survives. On the
4286
+ * Cocoa backend those fills are full-area CoreGraphics passes, and for a
4287
+ * streaming terminal they were a fifth of the frame; on X11 they are
4288
+ * composites the server ran for nothing. An element with a retained
4289
+ * surface it draws whole — a terminal, a media frame, a chart — answers
4290
+ * with the rect it covers, and claims its damage as a **rect inside it**
4291
+ * rather than as the node: a node claim is inflated by a pixel of slop,
4292
+ * which is outside the rect and so never covered.
4293
+ *
4294
+ * The promise is the element's to keep: every pixel of the rect, alpha
4295
+ * one, on every paint of this node, whatever the props. A translucent
4296
+ * element, or one that draws a background only sometimes, answers null.
4297
+ * The answer is read at paint time, so it may follow `contentBox()`, and
4298
+ * a fractional edge is not opaque — core takes the whole pixels inside.
4299
+ */
4300
+ opaqueRect() {
4301
+ return null;
4302
+ }
4303
+
4261
4304
  /**
4262
4305
  * "The pixels in `rect` moved by (dx, dy); the rest of it is new" — the
4263
4306
  * public form of the dance `<box overflow="scroll">` has been doing since
@@ -4915,6 +4958,9 @@ export class Node {
4915
4958
  }
4916
4959
 
4917
4960
  _paintBackground(ctx) {
4961
+ // under an element that covers this pass with opaque pixels, this fill
4962
+ // is never seen (`WindowNode._coverFor`)
4963
+ if (this.root?._coverChain?.has(this)) return;
4918
4964
  const { backgroundColor, borderRadius = 0 } = this.style;
4919
4965
  const fill = (style) => {
4920
4966
  ctx.fillStyle = style;
@@ -9343,6 +9389,22 @@ export class WindowNode extends Scrollable(Node) {
9343
9389
  this.needsLayout = true;
9344
9390
  this.needsPaint = true;
9345
9391
  this._scheduled = false;
9392
+ // The frame pacer (src/pacing.js): whether a claim waits before its
9393
+ // frame is scheduled, priced by what the last frames cost. Off unless
9394
+ // the `frameRate` prop, the root's default or the environment says
9395
+ // otherwise — resolved again whenever the prop changes.
9396
+ this._pacer = new FramePacer();
9397
+ this._framePolicy = null;
9398
+ this._syncFramePolicy();
9399
+ // a claim raised by the frame on itself is scheduled once the frame is
9400
+ // over and its cost is known (`flush`)
9401
+ this._inFlush = false;
9402
+ this._claimAfterFlush = false;
9403
+ // the nodes answering `opaqueRect()`, and — during a paint pass one of
9404
+ // them covers — that node with its ancestors, whose fills are skipped
9405
+ // (`_coverFor`, `Node._paintBackground`)
9406
+ this._opaqueNodes = new Set();
9407
+ this._coverChain = null;
9346
9408
  // Nodes that want the `attention` event (ntk#37) — an
9347
9409
  // `unstable_onAttention` prop,
9348
9410
  // an `:attention` block, or both. Built before the EventManager so the
@@ -11212,6 +11274,7 @@ export class WindowNode extends Scrollable(Node) {
11212
11274
  if (this.destroyed) return;
11213
11275
  this.destroyed = true;
11214
11276
  clearPendingFrame(this);
11277
+ this._pacer.cancel();
11215
11278
  this._unwatchCompositing?.();
11216
11279
  this._unwatchCompositing = null;
11217
11280
  // before endWindowState below, which is the session this is subscribed to
@@ -11252,6 +11315,7 @@ export class WindowNode extends Scrollable(Node) {
11252
11315
  if (Boolean(newProps.trapFocus) !== Boolean(before.trapFocus)) {
11253
11316
  this._syncFocusScope();
11254
11317
  }
11318
+ if (newProps.frameRate !== before.frameRate) this._syncFramePolicy();
11255
11319
  // a <window onDrop> is a whole-window dropzone; same edge as Node
11256
11320
  if (hasDropProps(newProps) !== hasDropProps(before)) {
11257
11321
  if (hasDropProps(newProps)) this._registerDropTarget(this);
@@ -11871,16 +11935,40 @@ export class WindowNode extends Scrollable(Node) {
11871
11935
  this._scheduleFrame();
11872
11936
  }
11873
11937
 
11874
- /** A frame, on the window's clock. */
11938
+ /**
11939
+ * A frame, on the window's clock — after whatever wait the pacer asks
11940
+ * for (src/pacing.js). Off by default, the pacer answers "now" for one
11941
+ * property read; under an adaptive policy a claim that finds the window
11942
+ * in debt for its last frames is held on a one-shot, and every claim
11943
+ * until then folds into it. Nothing here changes what the frame paints:
11944
+ * the damage accumulates on the node exactly as it does between two
11945
+ * ticks of the clock.
11946
+ */
11875
11947
  _scheduleFrame() {
11876
- // Recorded before the `_scheduled` gate, not inside it: the debt is
11877
- // "this window has damage", which a discrete event may pay off early
11878
- // (see frames.js). Tying it to whether a callback is outstanding would
11879
- // hide the second of two clicks a few milliseconds apart — the first
11880
- // one's frame is still scheduled, so this returns here, and the early
11881
- // flush would find nothing to paint.
11948
+ // Recorded before either gate, not inside them: the debt is "this
11949
+ // window has damage", which a discrete event may pay off early (see
11950
+ // frames.js). Tying it to whether a callback is outstanding would hide
11951
+ // the second of two clicks a few milliseconds apart — the first one's
11952
+ // frame is still scheduled, so this returns here, and the early flush
11953
+ // would find nothing to paint. A held claim is a debt too: the early
11954
+ // flush paints it, and `flush` stands the wait down.
11882
11955
  addPendingFrame(this);
11956
+ // A claim the frame raises on itself — an animation stepping, a
11957
+ // container query settling, a promotion moving a node — is answered
11958
+ // once the frame is over, so the pacer prices the frame that raised it
11959
+ // and not the one before.
11960
+ if (this._inFlush) {
11961
+ this._claimAfterFlush = true;
11962
+ return;
11963
+ }
11883
11964
  if (this._scheduled) return;
11965
+ if (this._pacer.defer(() => this._requestFrame())) return;
11966
+ this._requestFrame();
11967
+ }
11968
+
11969
+ /** The callback on the window's clock. */
11970
+ _requestFrame() {
11971
+ if (this._scheduled || this.destroyed || !this.window) return;
11884
11972
  this._scheduled = true;
11885
11973
  const schedule =
11886
11974
  typeof this.window.requestAnimationFrame === 'function'
@@ -11892,12 +11980,68 @@ export class WindowNode extends Scrollable(Node) {
11892
11980
  });
11893
11981
  }
11894
11982
 
11983
+ /**
11984
+ * The policy this window paces its frames by: the environment, then the
11985
+ * `frameRate` prop, then the root's `createRoot({ frameRate })`, then
11986
+ * `'display'` (src/pacing.js). A bad value throws here — at mount or at
11987
+ * the prop change — naming the value and the choices.
11988
+ */
11989
+ _syncFramePolicy() {
11990
+ const policy = resolveFramePolicy(
11991
+ this.props.frameRate,
11992
+ this.app,
11993
+ `<${this.kind} frameRate>`,
11994
+ );
11995
+ this._framePolicy = policy;
11996
+ this._pacer.configure(policy);
11997
+ }
11998
+
11999
+ /**
12000
+ * The backend's half of a frame's cost, where it has one: the Cocoa
12001
+ * present — the swapchain flip and its catch-up copy — runs after the
12002
+ * flush returns, on the same thread, and is part of what the frame cost
12003
+ * (src/cocoa/window.js reports it). An ntk window's present is one
12004
+ * request, and reports nothing.
12005
+ */
12006
+ _notePresentCost(ms) {
12007
+ this._pacer.charge(ms);
12008
+ }
12009
+
12010
+ /**
12011
+ * A frame: layout if owed, then the paint passes — or the presenter's
12012
+ * frame — then the backend's word. Runs on the window's clock through
12013
+ * `_scheduleFrame`, and early, synchronously, for a discrete input
12014
+ * (frames.js). Every route lands here, so this is where a frame is
12015
+ * priced: the pacer brackets the work, and what it cost is what the
12016
+ * next claim is judged against (src/pacing.js).
12017
+ */
11895
12018
  flush() {
11896
12019
  // Whatever this frame turns out to owe, it is this call's to pay — and
11897
12020
  // a window that returns below because it is destroyed or unrealized
11898
12021
  // owes nothing at all.
11899
12022
  clearPendingFrame(this);
12023
+ // …and a wait the pacer had armed for it has nothing left to wait for:
12024
+ // whichever route got here first pays the same debt.
12025
+ this._pacer.cancel();
11900
12026
  if (this.destroyed || !this.yoga || !this.window) return;
12027
+ const pacer = this._pacer;
12028
+ pacer.began();
12029
+ this._inFlush = true;
12030
+ let painted = false;
12031
+ try {
12032
+ painted = this._flushFrame();
12033
+ } finally {
12034
+ this._inFlush = false;
12035
+ pacer.ended(undefined, painted);
12036
+ if (this._claimAfterFlush) {
12037
+ this._claimAfterFlush = false;
12038
+ if (!this.destroyed) this._scheduleFrame();
12039
+ }
12040
+ }
12041
+ }
12042
+
12043
+ /** The frame itself. True when it painted or presented something. */
12044
+ _flushFrame() {
11901
12045
  // A frame is scheduled a tick before it is painted, and the connection
11902
12046
  // can go in between: an app closing its own client, a server exit, a
11903
12047
  // test closing the app it lent the root. Nothing unmounts the tree on
@@ -11905,7 +12049,7 @@ export class WindowNode extends Scrollable(Node) {
11905
12049
  // socket, and the first request it makes throws out of the frame clock
11906
12050
  // where there is nothing waiting to catch it. There is no screen left to
11907
12051
  // paint to, so this owes nothing either.
11908
- if (this.app?.X?._closing) return;
12052
+ if (this.app?.X?._closing) return false;
11909
12053
  // a transientFor whose owner was not realized yet at commit time. The
11910
12054
  // frame after the mount is the first moment refs have attached, so the
11911
12055
  // common "two <window>s in one tree" case resolves here rather than
@@ -12016,7 +12160,7 @@ export class WindowNode extends Scrollable(Node) {
12016
12160
  // …and the next commit's claims name the arrangement this frame leaves
12017
12161
  // behind again, from before whatever scroll comes with them
12018
12162
  this._laidOut = false;
12019
- if (!this.needsPaint) return;
12163
+ if (!this.needsPaint) return false;
12020
12164
  this.needsPaint = false;
12021
12165
  const damage = this._takeDamage(width, height);
12022
12166
  if (debugPaint === 'full' && !damage && width > 0 && height > 0) {
@@ -12040,9 +12184,9 @@ export class WindowNode extends Scrollable(Node) {
12040
12184
  if (typeof this.window.presentFrame === 'function') {
12041
12185
  this.window.presentFrame(this, damage);
12042
12186
  this.app._reactX11Startup?.painted();
12043
- return;
12187
+ return true;
12044
12188
  }
12045
- if (typeof this.window.getContext !== 'function') return; // headless mock
12189
+ if (typeof this.window.getContext !== 'function') return false; // headless mock
12046
12190
  // ntk getContext creates a fresh context (with window-event
12047
12191
  // subscriptions) on every call — cache one per window
12048
12192
  const ctx = (this._ctx ??= this.window.getContext('2d'));
@@ -12082,6 +12226,8 @@ export class WindowNode extends Scrollable(Node) {
12082
12226
  // server work separate cleanly in a trace only when both are in it —
12083
12227
  // a slow virtualized GPU shows up here, not in `end`.
12084
12228
  landed: this.window.frameLatency,
12229
+ // how long the pacer held this frame's claim, ms; 0 when it did not
12230
+ waited: this._pacer.pendingWait,
12085
12231
  });
12086
12232
  }
12087
12233
  // A frame that actually painted, which is the moment the app is up
@@ -12089,6 +12235,7 @@ export class WindowNode extends Scrollable(Node) {
12089
12235
  // session clears itself off the app — which is the same bargain the
12090
12236
  // trace hook above makes with the frame loop.
12091
12237
  this.app._reactX11Startup?.painted();
12238
+ return true;
12092
12239
  }
12093
12240
 
12094
12241
  /**
@@ -12564,8 +12711,50 @@ export class WindowNode extends Scrollable(Node) {
12564
12711
  return check(this);
12565
12712
  }
12566
12713
 
12714
+ /**
12715
+ * The node whose `opaqueRect()` holds the whole of `rect`, or null: the
12716
+ * pass needs no fill under that node. Whole pixels only — a fractional
12717
+ * edge is antialiased, and an antialiased pixel is not opaque. A clipping
12718
+ * ancestor shrinks the answer to what reaches the surface, a rounded one
12719
+ * by its radius all round, since the corner squares are exactly the
12720
+ * pixels a rounded clip gives up. A handful of nodes answer at all, so
12721
+ * this is a few rect tests per pass.
12722
+ */
12723
+ _coverFor(rect) {
12724
+ const nodes = this._opaqueNodes;
12725
+ if (nodes.size === 0) return null;
12726
+ for (const node of nodes) {
12727
+ if (node.destroyed || node.hidden || node._promoted) continue;
12728
+ if (node.style?.display === 'none' || !(node.abs?.width > 0)) continue;
12729
+ let cover = node.opaqueRect();
12730
+ if (!cover) continue;
12731
+ cover = innerPixels(cover);
12732
+ for (let n = node.parent; cover && n && n !== this; n = n.parent) {
12733
+ if (n.hidden || n.style?.display === 'none') {
12734
+ cover = null;
12735
+ break;
12736
+ }
12737
+ if (n.clipsChildren()) {
12738
+ const radius = n.style?.borderRadius ?? 0;
12739
+ cover = intersectRects(
12740
+ cover,
12741
+ radius > 0 ? insetRect(n.abs, radius) : n.abs,
12742
+ );
12743
+ }
12744
+ }
12745
+ if (cover && rectContains(cover, rect)) return node;
12746
+ }
12747
+ return null;
12748
+ }
12749
+
12567
12750
  /** Repaint one damage rect, or the whole window when `damage` is null. */
12568
12751
  _paintRegion(ctx, damage, width, height) {
12752
+ // An element that covers the pass with opaque pixels (`Node.opaqueRect`)
12753
+ // makes every fill under it wasted work — the clear, the window's
12754
+ // background, the node's own and its ancestors'. The first two are
12755
+ // skipped here; `_paintBackground` skips the chain's while
12756
+ // `_coverChain` names it.
12757
+ const cover = this._coverFor(damage ?? { x: 0, y: 0, width, height });
12569
12758
  // A transparent window erases where an opaque one paints over. Its
12570
12759
  // backing store holds premultiplied ARGB, and compositing a translucent
12571
12760
  // background onto the previous frame would compound towards opaque
@@ -12578,7 +12767,7 @@ export class WindowNode extends Scrollable(Node) {
12578
12767
  // `transparencyEffective`, not `_transparent`: an ARGB window with
12579
12768
  // nothing compositing it must not clear, because the server would show
12580
12769
  // those zeroed pixels as black rather than as the desktop.
12581
- if (this.transparencyEffective) {
12770
+ if (this.transparencyEffective && !cover) {
12582
12771
  if (damage) {
12583
12772
  ctx.clearRect(damage.x, damage.y, damage.width, damage.height);
12584
12773
  } else {
@@ -12600,7 +12789,12 @@ export class WindowNode extends Scrollable(Node) {
12600
12789
  ctx.rect(damage.x, damage.y, damage.width, damage.height);
12601
12790
  ctx.clip();
12602
12791
  }
12603
- this._paintWindowBackground(ctx, damage, width, height);
12792
+ if (!cover) this._paintWindowBackground(ctx, damage, width, height);
12793
+ if (cover) {
12794
+ const chain = new Set();
12795
+ for (let n = cover; n; n = n.parent) chain.add(n);
12796
+ this._coverChain = chain;
12797
+ }
12604
12798
  this._paintDamage = damage;
12605
12799
  try {
12606
12800
  this._paintChildren(ctx);
@@ -12644,6 +12838,7 @@ export class WindowNode extends Scrollable(Node) {
12644
12838
  }
12645
12839
  } finally {
12646
12840
  this._paintDamage = null;
12841
+ this._coverChain = null;
12647
12842
  if (damage) ctx.restore();
12648
12843
  }
12649
12844
  }