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 +2 -2
- package/src/Reconciler.js +7 -0
- package/src/cocoa/app.js +17 -0
- package/src/cocoa/context2d.js +220 -4
- package/src/cocoa/window.js +21 -5
- package/src/debug.js +8 -2
- package/src/glnodes.js +63 -4
- package/src/index.d.ts +10 -1
- package/src/node.d.ts +214 -2
- package/src/nodes.js +208 -13
- package/src/pacing.js +476 -0
- package/src/trace-registry.js +4 -3
- package/src/types/elements.d.ts +54 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-x11",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
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
|
|
package/src/cocoa/context2d.js
CHANGED
|
@@ -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.
|
|
89
|
-
* coverage with the context's fill
|
|
90
|
-
*
|
|
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
|
-
|
|
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
|
package/src/cocoa/window.js
CHANGED
|
@@ -574,13 +574,28 @@ export class CocoaWindow {
|
|
|
574
574
|
return this.app._requestFrame(cb, this);
|
|
575
575
|
}
|
|
576
576
|
|
|
577
|
-
/**
|
|
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
|
-
|
|
580
|
-
if (!this.
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
/**
|