react-x11 2.8.3 → 2.9.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.8.3",
3
+ "version": "2.9.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -98,7 +98,7 @@
98
98
  "yoga-layout": "^3.2.1"
99
99
  },
100
100
  "optionalDependencies": {
101
- "@windowkit/appkit": "^0.6.0",
101
+ "@windowkit/appkit": "^0.7.0",
102
102
  "dbus-native": "^0.15.1",
103
103
  "x11-dri": "^0.7.0"
104
104
  },
package/src/Reconciler.js CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  windowAttributes,
34
34
  setTextStripBelow,
35
35
  } from './nodes.js';
36
+ import { setFrameRateDefault } from './pacing.js';
36
37
  import { hasDropProps } from './dnd.js';
37
38
  import { AppProvider } from './appcontext.js';
38
39
  import {
@@ -796,6 +797,12 @@ export async function createRoot(options = {}) {
796
797
  // default, 0 for glyphs at every size.
797
798
  setTextStripBelow(app, rest.textStripBelow);
798
799
 
800
+ // How this root's windows pace their frames when their content changes
801
+ // faster than the display refreshes (src/pacing.js): `'display'` — every
802
+ // frame the clock gives — unless the root says otherwise; a window's own
803
+ // `frameRate` prop wins over this, and REACT_X11_FRAME_RATE over both.
804
+ setFrameRateDefault(app, rest.frameRate);
805
+
799
806
  // Whether a subtree coming back out of hiding — a `<Suspense>` boundary
800
807
  // resolving, an `<Activity>` shown again — takes the keyboard back with it
801
808
  // (src/events.js, `subtreeRevealed`). On by default; `false` is the
package/src/cocoa/app.js CHANGED
@@ -537,8 +537,25 @@ export class CocoaApp {
537
537
  for (const wnd of this._windows.values()) wnd.liveResizing = false;
538
538
  }
539
539
 
540
+ /**
541
+ * `requestAnimationFrame`, for a window or for the app's own clock. A
542
+ * frame asked for between two pump ticks used to wait for the next tick
543
+ * to look at it — up to a pump interval on top of whatever the clock
544
+ * owed. Now, while the pump runs, the first request into an empty queue
545
+ * arms the same one-shot a tick arms for a frame it just missed
546
+ * (`_armFrameTimer`), at the moment the clock is due; a request for a
547
+ * clock due after the next tick leaves it to the tick, as before. What
548
+ * a frame is and how many there are does not change — the clock still
549
+ * gates every one — only when it is looked at, which is what makes a
550
+ * paced claim (src/pacing.js) land at its wait rather than at its wait
551
+ * rounded up to the pump.
552
+ */
540
553
  _requestFrame(cb, wnd = null) {
541
554
  this._rafQueue.push({ cb, wnd });
555
+ if (this._pump && this._rafQueue.length === 1) {
556
+ const now = performance.now();
557
+ this._armFrameTimer(Math.max(1, this._frameWait(wnd ?? this, now)), now);
558
+ }
542
559
  return this._rafQueue.length;
543
560
  }
544
561
 
@@ -85,13 +85,23 @@ const clamp01 = (v) => Math.min(1, Math.max(0, Number(v) || 0));
85
85
  /**
86
86
  * The Render ops text draws with, numbered as XRender numbers them so a
87
87
  * caller's `ctx.Render?.PictOp?.Over ?? 3` reads the same on both
88
- * backends. Every op draws as Over here: the bridge composites glyph
89
- * coverage with the context's fill and offers no blend-mode switch, and
90
- * for the opaque inks text uses Src and Over agree.
88
+ * backends. The `op` a `drawGlyphs` call names is ignored here: the bridge
89
+ * composites glyph coverage with the context's fill through the context's
90
+ * own blend mode `globalCompositeOperation`, below and for the opaque
91
+ * inks text uses Src and Over agree.
91
92
  */
92
93
  const PICT_OP = Object.freeze({ Src: 1, Over: 3 });
93
94
  const RENDER = Object.freeze({ PictOp: PICT_OP });
94
95
 
96
+ /**
97
+ * `_state.clip` when the clip in force is not one this class can name — a
98
+ * rounded corner, a glyph, an arc, or a rect under a rotation. Null means
99
+ * nothing is clipped and a rect means that rect, in surface pixels; this
100
+ * means "there is one and I cannot tell you where", which is the answer
101
+ * that turns the memcpy blit off (see `_blit`).
102
+ */
103
+ const NON_RECT = Symbol('non-rectangular clip');
104
+
95
105
  /**
96
106
  * The path, recorded alongside the native one, so `stroke` can re-issue it
97
107
  * in pieces — `CGContextStrokePath` is QUADRATIC in the number of subpaths
@@ -189,7 +199,18 @@ export class CocoaContext2D {
189
199
  shadowOffsetY: 0,
190
200
  shadowColor: 'rgba(0,0,0,0)',
191
201
  ctm: [1, 0, 0, 1, 0, 0],
202
+ gco: 'source-over',
203
+ // null: nothing clipped. A rect (surface pixels): that rect. NON_RECT:
204
+ // a clip this class cannot name.
205
+ clip: null,
192
206
  };
207
+ // What this bridge can do, asked once. Both verbs arrived together in
208
+ // @windowkit/appkit 0.7.0, and anything older is a bridge that draws
209
+ // every composite as `source-over` through a CGImage — so the property
210
+ // refuses what it cannot honour rather than lying about it, and the
211
+ // blit path is simply never taken.
212
+ this._blendModes = typeof native.ctxSetBlendMode === 'function';
213
+ this._blits = typeof native.blitSurface === 'function';
193
214
  this._onDirty = null;
194
215
  // the recorded path, and whether the native one still matches it (a
195
216
  // chunked stroke leaves only its last chunk behind)
@@ -211,6 +232,13 @@ export class CocoaContext2D {
211
232
  n.ctxSetLineJoin(surface, st.lineJoin);
212
233
  n.ctxSetGlobalAlpha(surface, st.globalAlpha);
213
234
  n.ctxSetLineDash(surface, st.dash, st.dashOffset);
235
+ // a fresh surface is already at source-over, so only a context
236
+ // holding another op has anything to say
237
+ if (this._blendModes && st.gco !== 'source-over') {
238
+ n.ctxSetBlendMode(surface, st.gco);
239
+ }
240
+ // a fresh surface is unclipped, whatever the old one had in force
241
+ st.clip = null;
214
242
  this._stack.length = 0;
215
243
  // the path went with the surface it was built on; nothing may
216
244
  // replay it onto the new one
@@ -282,6 +310,47 @@ export class CocoaContext2D {
282
310
  }
283
311
  }
284
312
 
313
+ get globalCompositeOperation() {
314
+ return this._state.gco;
315
+ }
316
+
317
+ /**
318
+ * The vocabulary is the **bridge's**, not a table kept here: the names are
319
+ * canvas's own, `ctxSetBlendMode` answers false for one it does not have,
320
+ * and that answer is what decides whether the assignment sticks. So this
321
+ * class never goes stale against a bridge that grows an op, and never
322
+ * claims one it would not actually draw.
323
+ *
324
+ * Which makes the detection a caller writes canvas's own — an unknown
325
+ * value is *ignored* there too, leaving the op in force — so the way to
326
+ * ask is to assign and read back:
327
+ *
328
+ * ctx.globalCompositeOperation = 'copy';
329
+ * if (ctx.globalCompositeOperation === 'copy') { ... }
330
+ *
331
+ * A bridge with no `ctxSetBlendMode` at all — @windowkit/appkit before
332
+ * 0.7.0 — draws everything as source-over, so source-over is the one
333
+ * value that sticks on it. That is exactly true rather than a fallback.
334
+ *
335
+ * One divergence from a browser, shared with ntk and so the same on both
336
+ * backends: an op applies inside what the draw covers, not across the
337
+ * whole surface. A browser's `copy` clears everything the drawing missed;
338
+ * `kCGBlendModeCopy` and XRender's `Src` both leave it alone.
339
+ */
340
+ set globalCompositeOperation(value) {
341
+ if (typeof value !== 'string') return;
342
+ if (!this._blendModes) {
343
+ if (value === 'source-over') this._state.gco = value;
344
+ return;
345
+ }
346
+ // false is the bridge saying it left its own mode alone; anything else
347
+ // means the mode is now `value`, and the two must not drift — a JS state
348
+ // ahead of the native one would take the memcpy path in `_blit` for a
349
+ // composite the fallback draw would have blended.
350
+ if (this._native.ctxSetBlendMode(this._s(), value) === false) return;
351
+ this._state.gco = value;
352
+ }
353
+
285
354
  /**
286
355
  * Whether a stroke of a path with many subpaths may go out as several
287
356
  * `CGContextStrokePath` calls — on by default, because the alternative
@@ -776,9 +845,70 @@ export class CocoaContext2D {
776
845
  ) {
777
846
  return;
778
847
  }
848
+ this._state.clip = this._clipAfter();
779
849
  this._native.ctxClip(this._path());
780
850
  }
781
851
 
852
+ /**
853
+ * The current path as a whole-pixel rect in surface coordinates, or null
854
+ * for anything else. CoreGraphics owns the real clip and this is only a
855
+ * shadow of it, kept for the one caller that draws *around* the context —
856
+ * `_blit`, whose memcpy cannot see a CGContext's clip at all.
857
+ *
858
+ * So the answer has to be exact, never merely close: a rect that does not
859
+ * land on whole pixels is refused rather than rounded, because rounding
860
+ * out would copy pixels the clip excludes and rounding in would leave a
861
+ * seam of whatever the destination held. The clips a paint pass actually
862
+ * sets — the damage rect, a scroll viewport, a square-cornered `overflow`
863
+ * — are whole pixels under a translate, and those are the ones this
864
+ * recognises.
865
+ */
866
+ _pathRect() {
867
+ const cmds = this._cmds;
868
+ if (cmds.length !== 5 || cmds[0] !== P_RECT) return null;
869
+ const [, x, y, w, h] = cmds;
870
+ const [a, b, c, d, e, f] = this._state.ctm;
871
+ if (b !== 0 || c !== 0) return null; // rotated or skewed: not a rect here
872
+ const x0 = a * x + e;
873
+ const y0 = d * y + f;
874
+ const x1 = a * (x + w) + e;
875
+ const y1 = d * (y + h) + f;
876
+ const rect = {
877
+ x: Math.min(x0, x1),
878
+ y: Math.min(y0, y1),
879
+ width: Math.abs(x1 - x0),
880
+ height: Math.abs(y1 - y0),
881
+ };
882
+ for (const v of [rect.x, rect.y, rect.width, rect.height]) {
883
+ if (!Number.isInteger(v)) return null;
884
+ }
885
+ return rect;
886
+ }
887
+
888
+ /** The clip `clip()` is about to leave in force: the current one
889
+ * intersected with the path, or NON_RECT as soon as either is one. */
890
+ _clipAfter() {
891
+ const current = this._state.clip;
892
+ if (current === NON_RECT) return NON_RECT;
893
+ const rect = this._pathRect();
894
+ if (!rect) return NON_RECT;
895
+ if (!current) return rect;
896
+ const x = Math.max(current.x, rect.x);
897
+ const y = Math.max(current.y, rect.y);
898
+ return {
899
+ x,
900
+ y,
901
+ width: Math.max(
902
+ 0,
903
+ Math.min(current.x + current.width, rect.x + rect.width) - x,
904
+ ),
905
+ height: Math.max(
906
+ 0,
907
+ Math.min(current.y + current.height, rect.y + rect.height) - y,
908
+ ),
909
+ };
910
+ }
911
+
782
912
  fillRect(x, y, w, h) {
783
913
  if (!(w > 0) || !(h > 0)) return;
784
914
  const style = this._state.fillStyle;
@@ -843,10 +973,96 @@ export class CocoaContext2D {
843
973
  dw = sw;
844
974
  dh = sh;
845
975
  }
846
- this._native.ctxDrawSurface(this._s(), src, sx, sy, sw, sh, dx, dy, dw, dh);
976
+ if (!this._blit(src, sx, sy, sw, sh, dx, dy, dw, dh)) {
977
+ this._native.ctxDrawSurface(
978
+ this._s(),
979
+ src,
980
+ sx,
981
+ sy,
982
+ sw,
983
+ sh,
984
+ dx,
985
+ dy,
986
+ dw,
987
+ dh,
988
+ );
989
+ }
847
990
  this._dirty();
848
991
  }
849
992
 
993
+ /**
994
+ * `drawImage` as a row memcpy, for the one shape where a copy is all it
995
+ * ever was: a surface composited into another at a translate, whole
996
+ * pixels, same size in as out, under `globalCompositeOperation = 'copy'`.
997
+ * Answers false for everything else, and the caller draws.
998
+ *
999
+ * That shape is what an element with a surface of its own presents every
1000
+ * frame — a terminal's grid, a retained scene — and the CoreGraphics
1001
+ * route to it is `CGBitmapContextCreateImage` of the whole source plus
1002
+ * `CGContextDrawImage`: 1.7ms of a 6ms frame for a 125x45 terminal, where
1003
+ * the memcpy is 1.0 (sidorares/react-x11-components#69 §6). The saving is
1004
+ * per frame rather than per flood, which is why the frame pacer (#497)
1005
+ * had to land first for it to be worth anything.
1006
+ *
1007
+ * Every condition below is a way the memcpy would differ from the draw,
1008
+ * and a difference is a bug rather than a slower frame — so each is a
1009
+ * refusal, never a fixup:
1010
+ *
1011
+ * - **the op.** Only `copy` writes the source over the destination
1012
+ * without reading it. `source-over` is a blend, and blending is what
1013
+ * CoreGraphics is for.
1014
+ * - **the transform.** A pure translate at whole pixels. A scale or a
1015
+ * rotation resamples; a fractional offset resamples too (surfaces are
1016
+ * created with `kCGInterpolationMedium`).
1017
+ * - **`globalAlpha`, and a shadow.** Both are things `CGContextDrawImage`
1018
+ * does to the source on its way down that a memcpy does not do at all.
1019
+ * - **the clip.** A memcpy cannot see a CGContext's clip, so the rect it
1020
+ * copies is intersected with the one this class tracked — and a clip it
1021
+ * could not track (NON_RECT) means it does not know, so it draws.
1022
+ * - **the same surface twice.** Overlapping memcpy rows have no defined
1023
+ * result. The check here is on the handle; the bridge's is on the
1024
+ * backing store, which also catches two handles onto one bitmap — the
1025
+ * two ends of a shared IOSurface — and throws rather than corrupting it.
1026
+ */
1027
+ _blit(src, sx, sy, sw, sh, dx, dy, dw, dh) {
1028
+ if (!this._blits) return false;
1029
+ const st = this._state;
1030
+ if (st.gco !== 'copy') return false;
1031
+ if (st.clip === NON_RECT) return false;
1032
+ if (st.globalAlpha < 1) return false;
1033
+ if (st.shadowBlur > 0 && parseColor(st.shadowColor)[3] > 0) return false;
1034
+ if (sw !== dw || sh !== dh) return false;
1035
+ const [a, b, c, d, e, f] = st.ctm;
1036
+ if (a !== 1 || b !== 0 || c !== 0 || d !== 1) return false;
1037
+ const x = dx + e;
1038
+ const y = dy + f;
1039
+ if (
1040
+ !Number.isInteger(x) ||
1041
+ !Number.isInteger(y) ||
1042
+ !Number.isInteger(sx) ||
1043
+ !Number.isInteger(sy) ||
1044
+ !Number.isInteger(sw) ||
1045
+ !Number.isInteger(sh)
1046
+ ) {
1047
+ return false;
1048
+ }
1049
+ const dst = this._s();
1050
+ if (dst === src) return false;
1051
+ const clip = st.clip;
1052
+ this._native.blitSurface(
1053
+ src,
1054
+ sx,
1055
+ sy,
1056
+ sw,
1057
+ sh,
1058
+ dst,
1059
+ x,
1060
+ y,
1061
+ clip ? [clip.x, clip.y, clip.width, clip.height] : null,
1062
+ );
1063
+ return true;
1064
+ }
1065
+
850
1066
  /**
851
1067
  * Browser contract: a blank RGBA pixel block for the caller to fill and
852
1068
  * hand back to putImageData. Pure allocation — nothing touches the
@@ -574,13 +574,28 @@ export class CocoaWindow {
574
574
  return this.app._requestFrame(cb, this);
575
575
  }
576
576
 
577
- /** Push the backing surface at the WindowServer, if anything drew. */
577
+ /**
578
+ * Push the backing surface at the WindowServer, if anything drew — and
579
+ * tell the window node what it cost. The flip is cheap; the catch-up
580
+ * copy behind it is a damage-sized memcpy, and on a window whose every
581
+ * frame repaints most of itself that is a millisecond of the JS thread
582
+ * per frame that the flush never saw. The frame pacer prices the frame
583
+ * by the thread's time, so the present reports in (src/pacing.js,
584
+ * `WindowNode._notePresentCost`).
585
+ */
578
586
  present() {
579
- if (this._presenter) return; // layers upload as they sync
580
- if (!this._dirty || !this._surface || this.destroyed) return;
587
+ const started = performance.now();
588
+ if (!this._presentNow()) return;
589
+ this._reactX11Node?._notePresentCost?.(performance.now() - started);
590
+ }
591
+
592
+ /** The present itself: true when a frame reached the layer. */
593
+ _presentNow() {
594
+ if (this._presenter) return false; // layers upload as they sync
595
+ if (!this._dirty || !this._surface || this.destroyed) return false;
581
596
  // …and if anyone would see it. `_dirty` stays set, so the pump asks
582
597
  // again next tick and the frame goes out the moment the window is back.
583
- if (this._holdPresent || !this._visible()) return;
598
+ if (this._holdPresent || !this._visible()) return false;
584
599
  this._dirty = false;
585
600
  if (this._chain) {
586
601
  const shown = this._chain.back;
@@ -608,7 +623,7 @@ export class CocoaWindow {
608
623
  ]),
609
624
  );
610
625
  if (this._transparentWindow) this.app._shadowStale.add(this);
611
- return;
626
+ return true;
612
627
  }
613
628
  this._native.surfaceToLayer(this._surface, this._layer);
614
629
  // AppKit derives a transparent window's shadow from the content's
@@ -619,6 +634,7 @@ export class CocoaWindow {
619
634
  // the frame BEFORE this one and keeps the square rim for menus that
620
635
  // paint once and are only hovered after.
621
636
  if (this._transparentWindow) this.app._shadowStale.add(this);
637
+ return true;
622
638
  }
623
639
 
624
640
  snapshot(path) {
package/src/debug.js CHANGED
@@ -399,7 +399,7 @@ function createSession({ sink, path }) {
399
399
  }
400
400
  },
401
401
 
402
- frame({ rects, reasons, start, end, landed }) {
402
+ frame({ rects, reasons, start, end, landed, waited }) {
403
403
  frames += 1;
404
404
  const full = !rects;
405
405
  const area = full
@@ -421,7 +421,12 @@ function createSession({ sink, path }) {
421
421
  // timings cannot show. Either way it is paint-vs-everything-else.
422
422
  const wait =
423
423
  typeof landed === 'number' ? ` landed=${landed.toFixed(1)}ms` : '';
424
- line(`frame ${frames}: ${where}${why}${wait}`);
424
+ // How long the frame pacer held this frame's claim before letting
425
+ // the clock have it (src/pacing.js) — only under an adaptive
426
+ // `frameRate`, and only for a frame that was held, so the default
427
+ // path's lines are the lines they always were.
428
+ const held = waited > 0 ? ` waited=${waited.toFixed(1)}ms` : '';
429
+ line(`frame ${frames}: ${where}${why}${wait}${held}`);
425
430
  }
426
431
  record({
427
432
  name: 'frame',
@@ -437,6 +442,7 @@ function createSession({ sink, path }) {
437
442
  area,
438
443
  reasons: reasons ?? [],
439
444
  landedMs: typeof landed === 'number' ? +landed.toFixed(2) : undefined,
445
+ waitedMs: waited > 0 ? +waited.toFixed(2) : undefined,
440
446
  },
441
447
  });
442
448
  },
package/src/glnodes.js CHANGED
@@ -11,6 +11,7 @@ import { cssColorStraight } from 'ntk';
11
11
  export { directGLFailure, hasDirectGL } from './glbackend.js';
12
12
 
13
13
  import { Node } from './nodes.js';
14
+ import { FramePacer, resolveFrameRate } from './pacing.js';
14
15
 
15
16
  // One visual query per (app, spec): GetFBConfigs is a round trip and every
16
17
  // <glarea> in an app wants the same answer.
@@ -99,6 +100,9 @@ const px = (v) => Math.max(1, Math.round(v || 0));
99
100
  * - `clearColor` — CSS colour or `[r, g, b, a]` floats (default black).
100
101
  * - `frameLoop` — `'demand'` (default: redraw on prop/size/expose changes)
101
102
  * or `'always'` (drive ntk's frame clock continuously).
103
+ * - `frameRate` — how the frames are paced when they are expensive
104
+ * (src/pacing.js): a preset, a cap, or the three numbers, the same
105
+ * vocabulary as `<window frameRate>`. Defaults to the owning window's.
102
106
  * - `glx` — a `chooseGLXConfig` spec, e.g. `{ DEPTH_SIZE: 24 }`.
103
107
  *
104
108
  * The X child window is stacked above everything drawn in the parent, so 2D
@@ -114,12 +118,36 @@ export class GlAreaNode extends Node {
114
118
  this._frameScheduled = false;
115
119
  this._created = false;
116
120
  this._pointerDirty = true;
121
+ // The frame pacer (src/pacing.js), the surface's own: a scene's frames
122
+ // are drawn on a clock of their own, and what one costs — `onDraw` and
123
+ // the swap, on this thread — is what decides whether the next may
124
+ // start at once. The policy is this element's `frameRate`, else the
125
+ // owning window's, read at each request so a change on either follows.
126
+ this._pacer = new FramePacer();
127
+ this._ownPolicy = null;
128
+ this._syncFramePolicy();
117
129
  }
118
130
 
119
131
  get isGlArea() {
120
132
  return true;
121
133
  }
122
134
 
135
+ /** The policy this surface paces by: its own prop, else the window's. */
136
+ _framePolicy() {
137
+ if (this.props.frameRate !== undefined && this.props.frameRate !== null) {
138
+ return this._ownPolicy;
139
+ }
140
+ return this.root?._framePolicy ?? this._pacer.policy;
141
+ }
142
+
143
+ _syncFramePolicy() {
144
+ const value = this.props.frameRate;
145
+ this._ownPolicy =
146
+ value === undefined || value === null
147
+ ? null
148
+ : resolveFrameRate(value, '<glarea frameRate>');
149
+ }
150
+
123
151
  _setRoot(root) {
124
152
  super._setRoot(root);
125
153
  // the owning window may already exist (a <glarea> mounted into a live
@@ -275,8 +303,21 @@ export class GlAreaNode extends Node {
275
303
  this.requestFrame();
276
304
  }
277
305
 
278
- /** Draw one frame on the child window's next frame tick. */
306
+ /**
307
+ * Draw one frame on the child window's next frame tick — after whatever
308
+ * wait the pacer asks for (src/pacing.js). Off by default it answers
309
+ * "now"; under an adaptive policy a scene whose frames cost more than
310
+ * their share of the thread is held between them, and a `frameLoop` of
311
+ * `'always'` becomes a loop at the budget rather than at the display.
312
+ */
279
313
  requestFrame() {
314
+ if (!this.window || this.destroyed || this._frameScheduled) return;
315
+ this._pacer.configure(this._framePolicy());
316
+ if (this._pacer.defer(() => this._requestFrameNow())) return;
317
+ this._requestFrameNow();
318
+ }
319
+
320
+ _requestFrameNow() {
280
321
  if (!this.window || this.destroyed || this._frameScheduled) return;
281
322
  this._frameScheduled = true;
282
323
  const schedule =
@@ -290,13 +331,28 @@ export class GlAreaNode extends Node {
290
331
  }
291
332
 
292
333
  _drawFrame() {
334
+ const pacer = this._pacer;
335
+ pacer.began();
336
+ let drawn = false;
337
+ try {
338
+ drawn = this._drawFrameNow();
339
+ } finally {
340
+ pacer.ended(undefined, drawn);
341
+ }
342
+ // after the frame is priced, so the loop's next frame is judged by
343
+ // this one rather than by the one before
344
+ if (drawn && this.props.frameLoop === 'always') this.requestFrame();
345
+ }
346
+
347
+ /** The frame itself; true when it drew. */
348
+ _drawFrameNow() {
293
349
  const gl = this.gl;
294
- if (!gl || this.destroyed) return;
350
+ if (!gl || this.destroyed) return false;
295
351
  const direct = gl.backend === 'direct';
296
352
  // On the direct backend every buffer may still be held by the display,
297
353
  // and drawing into one before it comes back would paint what is on
298
354
  // screen. `onFrameAvailable` asks for this frame again when one frees.
299
- if (direct && gl.canRender && !gl.canRender()) return;
355
+ if (direct && gl.canRender && !gl.canRender()) return false;
300
356
  // binds this surface — the GPU context is shared between every <glarea>
301
357
  // on the connection — and picks up a resize
302
358
  gl.makeCurrent?.();
@@ -325,7 +381,7 @@ export class GlAreaNode extends Node {
325
381
  }
326
382
  this.props.onDraw?.(gl, info);
327
383
  gl.SwapBuffers();
328
- if (this.props.frameLoop === 'always') this.requestFrame();
384
+ return true;
329
385
  }
330
386
 
331
387
  /**
@@ -357,7 +413,9 @@ export class GlAreaNode extends Node {
357
413
  }
358
414
 
359
415
  applyProps(newProps, oldProps) {
416
+ const before = oldProps ?? this.props;
360
417
  super.applyProps(newProps, oldProps);
418
+ if (newProps.frameRate !== before.frameRate) this._syncFramePolicy();
361
419
  // onDraw/clearColor are read at frame time, so any update is a new frame
362
420
  this.requestFrame();
363
421
  }
@@ -375,6 +433,7 @@ export class GlAreaNode extends Node {
375
433
  destroySubtree() {
376
434
  if (this.destroyed) return;
377
435
  super.destroySubtree();
436
+ this._pacer.cancel();
378
437
  this.gl?.destroy?.();
379
438
  this.gl = null;
380
439
  this.window?.destroy?.();
package/src/index.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  import type { ReactNode, RefObject } from 'react';
11
11
  import type { DrawnNode, NtkApp, NtkWindow } from './types/nodes.js';
12
- import type { ReactX11Elements } from './types/elements.js';
12
+ import type { FrameRate, ReactX11Elements } from './types/elements.js';
13
13
 
14
14
  export * from './types/style.js';
15
15
  export * from './types/events.js';
@@ -232,6 +232,15 @@ export interface RootOptions {
232
232
  * run each. 6 by default; 0 paints glyphs at every size.
233
233
  */
234
234
  textStripBelow?: number;
235
+ /**
236
+ * How this root's windows pace their frames when their content changes
237
+ * faster than the display refreshes — the default for every `<window>`
238
+ * and `<glarea>` that names no `frameRate` of its own. `'display'` (every
239
+ * frame the clock gives) unless said otherwise; `'adaptive'` holds paints
240
+ * to a quarter of the time under a flood. `REACT_X11_FRAME_RATE` overrides
241
+ * it, and the props, from the environment. See {@link FrameRate}.
242
+ */
243
+ frameRate?: FrameRate;
235
244
  /** `':1'`, `'host:0.0'`, or a unix socket path. Defaults to `$DISPLAY`. */
236
245
  display?: string;
237
246
  /**