ntk 6.7.0 → 7.1.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/lib/app.js +159 -0
- package/lib/renderingcontext_2d.js +62 -5
- package/lib/text/font.js +142 -0
- package/lib/text/fontmanager.js +63 -3
- package/lib/text/layout.js +1 -0
- package/lib/window.js +501 -60
- package/package.json +1 -1
package/lib/app.js
CHANGED
|
@@ -8,6 +8,50 @@ import { ShmUploader } from './shm-upload.js';
|
|
|
8
8
|
import FontManager from './text/fontmanager.js';
|
|
9
9
|
import Window from './window.js';
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* The highest predefined atom id in the core protocol, `XA_WM_TRANSIENT_FOR`.
|
|
13
|
+
*
|
|
14
|
+
* Atoms 1 to 68 are named by the protocol itself, so every server agrees on
|
|
15
|
+
* them and no server ever frees them. Anything above is interned at runtime
|
|
16
|
+
* and belongs to the connection that asked for it — see `_isolateAtoms`.
|
|
17
|
+
*/
|
|
18
|
+
const PREDEFINED_ATOM_MAX = 68;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Frame interval used before the display has been asked, in ms — and the one
|
|
22
|
+
* kept on a server that cannot answer.
|
|
23
|
+
*/
|
|
24
|
+
const DEFAULT_FRAME_INTERVAL = 16;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The range a refresh rate has to fall in to be paced against, in Hz. Below
|
|
28
|
+
* this a display is not something a UI should be throttled to, and above it
|
|
29
|
+
* the numbers are not describing a display at all.
|
|
30
|
+
*/
|
|
31
|
+
const MIN_REFRESH_RATE = 20;
|
|
32
|
+
const MAX_REFRESH_RATE = 1000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A RandR mode's vertical refresh rate in Hz, or 0 when it does not describe
|
|
36
|
+
* one.
|
|
37
|
+
*
|
|
38
|
+
* `dot_clock / (h_total * v_total)`, with the two flags that make a frame
|
|
39
|
+
* something other than one pass down the mode, exactly as xrandr computes it:
|
|
40
|
+
* an interlaced mode paints half the lines per pass, a doublescan mode paints
|
|
41
|
+
* each line twice.
|
|
42
|
+
*
|
|
43
|
+
* The zero check is not defensive: a virtual output has no pixel clock to
|
|
44
|
+
* report and Xvfb — which is what CI runs against — fills all three fields
|
|
45
|
+
* with zeroes, so the arithmetic yields NaN rather than a rate.
|
|
46
|
+
*/
|
|
47
|
+
function modeRate(mode) {
|
|
48
|
+
if (!mode || !mode.dot_clock || !mode.h_total || !mode.v_total) return 0;
|
|
49
|
+
let vTotal = mode.v_total;
|
|
50
|
+
if (mode.modeflags & 0x10) vTotal /= 2; // Interlace
|
|
51
|
+
if (mode.modeflags & 0x20) vTotal *= 2; // DoubleScan
|
|
52
|
+
return mode.dot_clock / (mode.h_total * vTotal);
|
|
53
|
+
}
|
|
54
|
+
|
|
11
55
|
/**
|
|
12
56
|
* A connection to an X server. Owns the underlying node-x11 client
|
|
13
57
|
* (`app.X`) and acts as a factory for windows and pixmaps.
|
|
@@ -41,6 +85,7 @@ export default class App {
|
|
|
41
85
|
this.display = display;
|
|
42
86
|
this.X = display.client;
|
|
43
87
|
this.options = options;
|
|
88
|
+
this._isolateAtoms();
|
|
44
89
|
this._fonts = null;
|
|
45
90
|
this._clipboard = null;
|
|
46
91
|
this._cursors = null;
|
|
@@ -59,6 +104,120 @@ export default class App {
|
|
|
59
104
|
});
|
|
60
105
|
}
|
|
61
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Give this connection an atom table of its own.
|
|
109
|
+
*
|
|
110
|
+
* node-x11 hands every client the *same* table object — `this.atoms =
|
|
111
|
+
* stdatoms` in xcore, assigned rather than copied — and `InternAtom`
|
|
112
|
+
* answers out of it without a round trip when the name is already there.
|
|
113
|
+
* So an id interned on one connection is handed to every other connection
|
|
114
|
+
* in the process, which have never asked the server for it.
|
|
115
|
+
*
|
|
116
|
+
* That is safe only while something keeps the server alive. An X server
|
|
117
|
+
* frees every atom it holds when its **last** client disconnects, and
|
|
118
|
+
* hands the same ids out again to whatever is interned next. A process
|
|
119
|
+
* that opens connections in sequence — a test suite, a tool that runs a
|
|
120
|
+
* few apps, anything under Xvfb with no window manager holding the server
|
|
121
|
+
* open — therefore writes properties with ids the server has forgotten.
|
|
122
|
+
* The visible half of that is `BadAtom` on a `ChangeProperty` naming an
|
|
123
|
+
* atom the app never chose; the quiet half is worse, because once the id
|
|
124
|
+
* has been reissued to a different name the write lands under *that* atom
|
|
125
|
+
* and no error is reported at all.
|
|
126
|
+
*
|
|
127
|
+
* Keeping only the predefined atoms is what makes the copy correct: ids 1
|
|
128
|
+
* to 68 are fixed by the core protocol, identical on every server, and
|
|
129
|
+
* never freed. Everything above that has to be interned per connection,
|
|
130
|
+
* which now happens because this table starts without it. Within one
|
|
131
|
+
* connection the caching still works, and it is still correct, because a
|
|
132
|
+
* live connection is exactly what stops the server resetting.
|
|
133
|
+
*/
|
|
134
|
+
_isolateAtoms() {
|
|
135
|
+
const shared = this.X.atoms;
|
|
136
|
+
if (!shared) return;
|
|
137
|
+
const own = {};
|
|
138
|
+
const names = {};
|
|
139
|
+
for (const [name, id] of Object.entries(shared)) {
|
|
140
|
+
if (id > PREDEFINED_ATOM_MAX) continue;
|
|
141
|
+
own[name] = id;
|
|
142
|
+
names[id] = name;
|
|
143
|
+
}
|
|
144
|
+
this.X.atoms = own;
|
|
145
|
+
this.X.atom_names = names;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The fastest refresh rate any active output is running at, in Hz, or
|
|
150
|
+
* `null` until the display has been asked — and on a server with no RandR
|
|
151
|
+
* or no mode worth pacing to.
|
|
152
|
+
*
|
|
153
|
+
* The fastest rather than the one this or that window is on, because what
|
|
154
|
+
* it feeds is a rate *ceiling* (see `frameInterval`), and a window paced
|
|
155
|
+
* faster than its own monitor is bounded by the next gate along rather than
|
|
156
|
+
* drawing more than anyone can see. Tracking which output every window
|
|
157
|
+
* overlaps would cost a per-window RandR lookup plus change tracking, to
|
|
158
|
+
* make a ceiling slightly tighter.
|
|
159
|
+
*/
|
|
160
|
+
get refreshRate() {
|
|
161
|
+
if (this._refreshProbe === undefined) this._probeRefreshRate();
|
|
162
|
+
return this._refreshRate ?? null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The frame interval windows on this connection take by default, in ms:
|
|
167
|
+
* the display's own period rather than a guess, so an animation loop on a
|
|
168
|
+
* 165Hz output is not held to the 62.5fps a hardcoded 16 implies. `null`
|
|
169
|
+
* until the probe has answered; windows created before then start on the
|
|
170
|
+
* default and adopt this when it lands.
|
|
171
|
+
*
|
|
172
|
+
* A window that presents takes its rate from the display directly and needs
|
|
173
|
+
* none of this — see docs/window.md "What ends a frame". This is what the
|
|
174
|
+
* fence clock paces to, which is every window on a server without Present,
|
|
175
|
+
* and every window before its first frames have been shown.
|
|
176
|
+
*/
|
|
177
|
+
get frameInterval() {
|
|
178
|
+
const rate = this.refreshRate;
|
|
179
|
+
return rate ? 1000 / rate : null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Ask RandR what the outputs are running at, once per connection, in the
|
|
184
|
+
* background.
|
|
185
|
+
*
|
|
186
|
+
* Not on the connect path: `createClient` is already four round trips deep
|
|
187
|
+
* and this is three more, for something no window needs before its first
|
|
188
|
+
* frame. Failures are silent by design — every one of them means "no rate
|
|
189
|
+
* to be had", which is exactly what the default covers.
|
|
190
|
+
*/
|
|
191
|
+
_probeRefreshRate() {
|
|
192
|
+
this._refreshProbe = null; // in progress; only ever started once
|
|
193
|
+
const X = this.X;
|
|
194
|
+
X.require('randr', (err, R) => {
|
|
195
|
+
if (err || !R) return;
|
|
196
|
+
const root = this.display.screen[0].root;
|
|
197
|
+
R.GetScreenResourcesCurrent(root, (resourcesError, resources) => {
|
|
198
|
+
if (resourcesError || !resources?.crtcs?.length) return;
|
|
199
|
+
const modes = new Map(resources.modeinfos.map((mode) => [mode.id, mode]));
|
|
200
|
+
let pending = resources.crtcs.length;
|
|
201
|
+
let best = 0;
|
|
202
|
+
for (const crtc of resources.crtcs) {
|
|
203
|
+
R.GetCrtcInfo(crtc, resources.config_timestamp, (crtcError, info) => {
|
|
204
|
+
// a crtc with no mode is one that is switched off
|
|
205
|
+
if (!crtcError && info) best = Math.max(best, modeRate(modes.get(info.mode)));
|
|
206
|
+
if (--pending) return;
|
|
207
|
+
if (best < MIN_REFRESH_RATE || best > MAX_REFRESH_RATE) return;
|
|
208
|
+
this._refreshRate = best;
|
|
209
|
+
this._adoptFrameInterval(1000 / best);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** hand the measured default to the windows that are still on the guess */
|
|
217
|
+
_adoptFrameInterval(ms) {
|
|
218
|
+
for (const wnd of Window._cacheFor(this).values()) wnd._adoptDefaultFrameInterval(ms);
|
|
219
|
+
}
|
|
220
|
+
|
|
62
221
|
/** the text API entry point: font matching/loading, shaping, layout */
|
|
63
222
|
get fonts() {
|
|
64
223
|
if (!this._fonts) this._fonts = new FontManager({ source: this.options.fontSource });
|
|
@@ -60,6 +60,23 @@ function intersectBox(a, b) {
|
|
|
60
60
|
|
|
61
61
|
const DEFAULT_FONT = "20px sans-serif";
|
|
62
62
|
|
|
63
|
+
// `"wght" 460, "wdth" 87.5` — the CSS grammar, quotes optional. An object is
|
|
64
|
+
// passed through, so callers can skip the string entirely.
|
|
65
|
+
const VARIATION_RE = /["']?([a-zA-Z0-9]{4})["']?\s+(-?[\d.]+)/g;
|
|
66
|
+
|
|
67
|
+
function parseVariationSettings(val) {
|
|
68
|
+
if (!val || val === "normal") return null;
|
|
69
|
+
if (typeof val === "object") return val;
|
|
70
|
+
if (typeof val !== "string") return null;
|
|
71
|
+
const out = {};
|
|
72
|
+
let any = false;
|
|
73
|
+
for (const [, tag, value] of val.matchAll(VARIATION_RE)) {
|
|
74
|
+
out[tag] = Number(value);
|
|
75
|
+
any = true;
|
|
76
|
+
}
|
|
77
|
+
return any ? out : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
63
80
|
/**
|
|
64
81
|
* Fallback for a context dropped without `destroy()`, matching Pixmap,
|
|
65
82
|
* Picture and GlyphSet. Only the GCs are freed here: everything else a
|
|
@@ -507,6 +524,7 @@ class RenderingContext2d {
|
|
|
507
524
|
gco: this._gco,
|
|
508
525
|
textStyle: this._textStyle,
|
|
509
526
|
fontString: this._lastFontString,
|
|
527
|
+
fontVariations: this._fontVariations,
|
|
510
528
|
textAlign: this.textAlign,
|
|
511
529
|
textBaseline: this.textBaseline,
|
|
512
530
|
m: this._m.slice(),
|
|
@@ -529,6 +547,7 @@ class RenderingContext2d {
|
|
|
529
547
|
this._gco = s.gco;
|
|
530
548
|
this._textStyle = s.textStyle;
|
|
531
549
|
this._lastFontString = s.fontString;
|
|
550
|
+
this._fontVariations = s.fontVariations;
|
|
532
551
|
this.textAlign = s.textAlign;
|
|
533
552
|
this.textBaseline = s.textBaseline;
|
|
534
553
|
this._m = s.m;
|
|
@@ -1710,10 +1729,10 @@ class RenderingContext2d {
|
|
|
1710
1729
|
* Stroke fast path for a device-space box (from a roundRect tag or
|
|
1711
1730
|
* strokeRect). Beyond the fill's conditions: uniform lineWidth, no
|
|
1712
1731
|
* dashes — and the half-pixel one that matters: the stroke band must land
|
|
1713
|
-
* on pixel boundaries. A
|
|
1714
|
-
*
|
|
1715
|
-
* integer path coordinates is genuinely a two-row 50% band
|
|
1716
|
-
* falling back, because the fast path would not reproduce it.
|
|
1732
|
+
* on pixel boundaries. A border drawn the correct way (path inset by bw/2,
|
|
1733
|
+
* so the band [X, X+bw] sits on integers) passes at any width, odd or even;
|
|
1734
|
+
* a 1px stroke on integer path coordinates is genuinely a two-row 50% band
|
|
1735
|
+
* and must keep falling back, because the fast path would not reproduce it.
|
|
1717
1736
|
*/
|
|
1718
1737
|
_tryStrokeBox(box) {
|
|
1719
1738
|
// the same off switch as the fill's — see _tryRoundRectFill
|
|
@@ -1742,12 +1761,27 @@ class RenderingContext2d {
|
|
|
1742
1761
|
const Y0 = y - bw / 2;
|
|
1743
1762
|
const W = w + bw; // outer band size
|
|
1744
1763
|
const H = h + bw;
|
|
1764
|
+
// What the route needs is that the *ink* is pixel-aligned: the band's
|
|
1765
|
+
// four outer edges on integers, which is what the four tests below say,
|
|
1766
|
+
// and an integer width so the straight runs are whole rows and columns
|
|
1767
|
+
// of FillRectangles (fractional extents would be truncated on the wire —
|
|
1768
|
+
// and a fractional lineWidth passes the outer-band tests, e.g. x = 0.75
|
|
1769
|
+
// with bw = 1.5, so this clause carries real weight).
|
|
1770
|
+
//
|
|
1771
|
+
// The *path* radius is free. The corner glyph box is cut at
|
|
1772
|
+
// K = ceil(r + bw/2), and from the arc's tangent point out to that cut
|
|
1773
|
+
// the band is already its own straight continuation — bw whole rows —
|
|
1774
|
+
// so a fractional radius rides inside the glyph, which rasterizes on the
|
|
1775
|
+
// device pixel grid, exactly as it does on the polygon route. This is
|
|
1776
|
+
// what a border inset by bw/2 needs: nesting inside a background corner
|
|
1777
|
+
// of radius R means a path radius of R - bw/2, half-integer for every
|
|
1778
|
+
// odd width (issue #217).
|
|
1745
1779
|
if (
|
|
1746
1780
|
!Number.isInteger(X0) ||
|
|
1747
1781
|
!Number.isInteger(Y0) ||
|
|
1748
1782
|
!Number.isInteger(W) ||
|
|
1749
1783
|
!Number.isInteger(H) ||
|
|
1750
|
-
!Number.isInteger(
|
|
1784
|
+
!Number.isInteger(bw)
|
|
1751
1785
|
) {
|
|
1752
1786
|
return this._shapeMiss("fractional");
|
|
1753
1787
|
}
|
|
@@ -2384,6 +2418,7 @@ class RenderingContext2d {
|
|
|
2384
2418
|
weight: parsed.weight,
|
|
2385
2419
|
style: parsed.style,
|
|
2386
2420
|
size: parsed.size,
|
|
2421
|
+
variations: this._fontVariations,
|
|
2387
2422
|
};
|
|
2388
2423
|
style.font = this.window.app.fonts.match(style.family, style);
|
|
2389
2424
|
this._lastFontString = val;
|
|
@@ -2394,6 +2429,28 @@ class RenderingContext2d {
|
|
|
2394
2429
|
return this._lastFontString || DEFAULT_FONT;
|
|
2395
2430
|
}
|
|
2396
2431
|
|
|
2432
|
+
/**
|
|
2433
|
+
* CSS's `font-variation-settings`, for a variable font: `'"wght" 460'`,
|
|
2434
|
+
* or `{ wght: 460 }`. Axes a font does not have are ignored and values are
|
|
2435
|
+
* clamped to their range, so this is safe to set unconditionally.
|
|
2436
|
+
*
|
|
2437
|
+
* The `wght` axis needs none of this — a numeric weight in the `font`
|
|
2438
|
+
* shorthand already drives it (`ctx.font = '460 40px Inter'`). This is for
|
|
2439
|
+
* the rest: `wdth`, `slnt`, `opsz` and whatever a display face invents.
|
|
2440
|
+
*
|
|
2441
|
+
* Order-independent: setting it after `font` re-resolves the face, so the
|
|
2442
|
+
* two can be assigned either way round.
|
|
2443
|
+
*/
|
|
2444
|
+
set fontVariationSettings(val) {
|
|
2445
|
+
this._fontVariations = parseVariationSettings(val);
|
|
2446
|
+
// re-resolve against the font already in force, if there is one
|
|
2447
|
+
if (this._textStyle) this.font = this._lastFontString;
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
get fontVariationSettings() {
|
|
2451
|
+
return this._fontVariations ?? null;
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2397
2454
|
// ------------------------------------------------------------------
|
|
2398
2455
|
// gradients / images
|
|
2399
2456
|
|
package/lib/text/font.js
CHANGED
|
@@ -2,6 +2,60 @@ import * as fontkit from 'fontkit';
|
|
|
2
2
|
|
|
3
3
|
import { flatten, rasterizePath } from '../rasterize.js';
|
|
4
4
|
|
|
5
|
+
// Axis coordinates are rounded to this many decimals before anything is
|
|
6
|
+
// instantiated or cached. Every distinct coordinate is a font in its own
|
|
7
|
+
// right — its own rasterized glyphs, its own server-side glyphset — so the
|
|
8
|
+
// precision of the number that reaches here is the precision of the cache,
|
|
9
|
+
// and a slider handing over 459.9999999997 must not mint a face that
|
|
10
|
+
// 460 will never hit again. Two decimals is far finer than any axis is
|
|
11
|
+
// perceptible at, and makes the coordinate that reaches fontkit and the
|
|
12
|
+
// coordinate in the key the same number by construction.
|
|
13
|
+
const AXIS_DECIMALS = 2;
|
|
14
|
+
const round = (v) => Number(v.toFixed(AXIS_DECIMALS));
|
|
15
|
+
|
|
16
|
+
// Instances kept per base font. Reached only by an app driving an axis
|
|
17
|
+
// continuously — an animation, a drag — where the tail is cold by
|
|
18
|
+
// definition; a design system's handful of weights never approaches it.
|
|
19
|
+
// Server-side glyph pages have their own byte budget (text/glyphs.js), and
|
|
20
|
+
// evicting a Font here does not strand them: the key is derived from the
|
|
21
|
+
// coordinates, so re-instantiating the same point produces the same key and
|
|
22
|
+
// finds the same page.
|
|
23
|
+
const MAX_INSTANCES = 64;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Clamp `settings` into a font's design space: drop axes it does not have,
|
|
27
|
+
* clamp the rest to their range, round, and drop anything left sitting on
|
|
28
|
+
* its own default (the base font already *is* the default instance).
|
|
29
|
+
*
|
|
30
|
+
* @returns {object|null} the coordinates to instantiate, or null for "this
|
|
31
|
+
* font already is that instance"
|
|
32
|
+
*/
|
|
33
|
+
export function normalizeVariations(settings, axes) {
|
|
34
|
+
if (!settings || !axes) return null;
|
|
35
|
+
const coords = {};
|
|
36
|
+
let any = false;
|
|
37
|
+
for (const tag of Object.keys(settings).sort()) {
|
|
38
|
+
const axis = axes[tag];
|
|
39
|
+
if (!axis) continue; // a font that has no such axis is not wrong, just flat
|
|
40
|
+
const value = Number(settings[tag]);
|
|
41
|
+
if (!Number.isFinite(value)) continue;
|
|
42
|
+
const clamped = round(Math.min(axis.max, Math.max(axis.min, value)));
|
|
43
|
+
if (clamped === round(axis.default)) continue;
|
|
44
|
+
coords[tag] = clamped;
|
|
45
|
+
any = true;
|
|
46
|
+
}
|
|
47
|
+
return any ? coords : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Canonical cache key for normalized coordinates: `wdth=87.5,wght=460`. */
|
|
51
|
+
export function variationKey(coords) {
|
|
52
|
+
if (!coords) return '';
|
|
53
|
+
return Object.keys(coords)
|
|
54
|
+
.sort()
|
|
55
|
+
.map((tag) => `${tag}=${coords[tag]}`)
|
|
56
|
+
.join(',');
|
|
57
|
+
}
|
|
58
|
+
|
|
5
59
|
/**
|
|
6
60
|
* A single font face (one entry of a .ttc collection, or a whole .ttf/.otf),
|
|
7
61
|
* parsed with fontkit. Wraps everything the text pipeline needs:
|
|
@@ -71,6 +125,94 @@ export default class Font {
|
|
|
71
125
|
return this.fk.unitsPerEm;
|
|
72
126
|
}
|
|
73
127
|
|
|
128
|
+
/**
|
|
129
|
+
* The variation axes this face offers, keyed by OpenType tag:
|
|
130
|
+
* `{ wght: { name, min, default, max } }`. `{}` for a static font, so
|
|
131
|
+
* `Object.keys(font.variationAxes).length` is the "is this variable?"
|
|
132
|
+
* question and needs no other API.
|
|
133
|
+
*/
|
|
134
|
+
get variationAxes() {
|
|
135
|
+
return this.fk.variationAxes ?? {};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A static instance of this face at a point in its design space — the
|
|
140
|
+
* thing a variable font is *for*. `font.variation({ wght: 460 })` is the
|
|
141
|
+
* `font-variation-settings: "wght" 460` of CSS.
|
|
142
|
+
*
|
|
143
|
+
* Instances are ordinary `Font`s from here on: they shape, measure and
|
|
144
|
+
* rasterize like any face, and because the key is derived from the
|
|
145
|
+
* coordinates they get their own glyph page and their own server-side
|
|
146
|
+
* glyphset. That is what keeps the axis honest — leave the key alone and
|
|
147
|
+
* every instance of one file collides in the glyph cache, so the whole
|
|
148
|
+
* axis renders as whichever coordinate got there first.
|
|
149
|
+
*
|
|
150
|
+
* Nothing is rasterized here. Instantiating is a table-level operation;
|
|
151
|
+
* glyphs are still rasterized one at a time, on first use, at the size
|
|
152
|
+
* they are drawn at.
|
|
153
|
+
*
|
|
154
|
+
* Settings for axes this font does not have are ignored, values are
|
|
155
|
+
* clamped to each axis's range, and coordinates that land on their own
|
|
156
|
+
* default return **this font** rather than a redundant copy of it.
|
|
157
|
+
*
|
|
158
|
+
* @param {object} settings axis tag -> value, e.g. `{ wght: 460 }`
|
|
159
|
+
* @returns {Font} the instance, or `this` when the settings are a no-op
|
|
160
|
+
*/
|
|
161
|
+
variation(settings) {
|
|
162
|
+
const coords = normalizeVariations(settings, this.variationAxes);
|
|
163
|
+
if (!coords) return this;
|
|
164
|
+
const suffix = variationKey(coords);
|
|
165
|
+
|
|
166
|
+
if (!this._variations) this._variations = new Map();
|
|
167
|
+
const cached = this._variations.get(suffix);
|
|
168
|
+
if (cached) {
|
|
169
|
+
// Map iterates in insertion order: re-inserting a hit moves it to the
|
|
170
|
+
// tail, so the eviction below drops the least recently asked for
|
|
171
|
+
this._variations.delete(suffix);
|
|
172
|
+
this._variations.set(suffix, cached);
|
|
173
|
+
return cached;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const instance = new Font(this._instantiate(coords), this.path, this.postscriptName);
|
|
177
|
+
instance.key = `${this.key}|${suffix}`;
|
|
178
|
+
/** the face this was cut from, for callers walking back to the axes */
|
|
179
|
+
instance.variationOf = this;
|
|
180
|
+
instance.variationCoords = coords;
|
|
181
|
+
|
|
182
|
+
this._variations.set(suffix, instance);
|
|
183
|
+
if (this._variations.size > MAX_INSTANCES) {
|
|
184
|
+
const oldest = this._variations.keys().next().value;
|
|
185
|
+
this._variations.delete(oldest);
|
|
186
|
+
}
|
|
187
|
+
return instance;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* fontkit's instancing, with the one failure it has turned into something
|
|
192
|
+
* that says what to do about it.
|
|
193
|
+
*
|
|
194
|
+
* `getVariation` rebuilds the face from the original stream at the
|
|
195
|
+
* original directory offset. For a WOFF2 that stream is still
|
|
196
|
+
* Brotli-compressed, so the copy comes back with a directory parsed out of
|
|
197
|
+
* compressed bytes — no `cmap`, and a `Cannot read properties of undefined
|
|
198
|
+
* (reading 'tables')` from somewhere deep in shaping, at the first
|
|
199
|
+
* character drawn rather than here. Checking the instance is well-formed
|
|
200
|
+
* costs one property read and moves the report to the call that caused it.
|
|
201
|
+
*/
|
|
202
|
+
_instantiate(coords) {
|
|
203
|
+
const instance = this.fk.getVariation(coords);
|
|
204
|
+
if (!instance?.directory?.tables?.cmap) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
`ntk: cannot instantiate a variation of ${this.postscriptName || this.key}` +
|
|
207
|
+
(this.fk.type ? ` (${this.fk.type})` : '') +
|
|
208
|
+
'. fontkit can only instantiate axes from an uncompressed font — ' +
|
|
209
|
+
'ship the .ttf/.otf of a variable font rather than its .woff2. ' +
|
|
210
|
+
'See docs/fonts.md#variable-fonts'
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return instance;
|
|
214
|
+
}
|
|
215
|
+
|
|
74
216
|
scale(size) {
|
|
75
217
|
return size / this.fk.unitsPerEm;
|
|
76
218
|
}
|
package/lib/text/fontmanager.js
CHANGED
|
@@ -8,6 +8,41 @@ import {
|
|
|
8
8
|
import { embeddingLevels, normalizedLevels, shapeText } from './shape.js';
|
|
9
9
|
import { TextLayout } from './layout.js';
|
|
10
10
|
|
|
11
|
+
// Bound on the resolved-match map. See the sweep in `match()`.
|
|
12
|
+
const MAX_MATCHES = 512;
|
|
13
|
+
|
|
14
|
+
/** Cache-key fragment for a style's `variations`, before any font is known. */
|
|
15
|
+
function variationsKeyOf(variations) {
|
|
16
|
+
if (!variations) return '';
|
|
17
|
+
return Object.keys(variations)
|
|
18
|
+
.sort()
|
|
19
|
+
.map((tag) => `${tag}=${variations[tag]}`)
|
|
20
|
+
.join(',');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Put a resolved face at the point in its design space the style asked for.
|
|
25
|
+
*
|
|
26
|
+
* The interesting half is `weight`. CSS has said for years that
|
|
27
|
+
* `font-weight: 460` on a variable font means the `wght` axis at 460, not
|
|
28
|
+
* "the nearest face"; a face with a `wght` axis therefore takes the
|
|
29
|
+
* requested weight as a coordinate, and an app that hands ntk a variable
|
|
30
|
+
* file gets the weight it asked for without knowing an axis exists. An
|
|
31
|
+
* explicit `variations.wght` wins, because a caller naming the axis
|
|
32
|
+
* directly is being more specific than one naming a weight.
|
|
33
|
+
*
|
|
34
|
+
* Everything here is a no-op for a static face: `variation()` returns the
|
|
35
|
+
* font unchanged when the settings do not apply, so this costs one property
|
|
36
|
+
* read on the path every non-variable app is already on.
|
|
37
|
+
*/
|
|
38
|
+
function instantiate(font, weight, variations) {
|
|
39
|
+
const axes = font.variationAxes;
|
|
40
|
+
if (!axes || (!axes.wght && !variations)) return font;
|
|
41
|
+
const settings = { ...variations };
|
|
42
|
+
if (axes.wght && settings.wght === undefined) settings.wght = weight;
|
|
43
|
+
return font.variation(settings);
|
|
44
|
+
}
|
|
45
|
+
|
|
11
46
|
/**
|
|
12
47
|
* Font lookup, loading and caching — the entry point of the text API,
|
|
13
48
|
* available as `app.fonts` (and constructible standalone for headless
|
|
@@ -122,9 +157,14 @@ export default class FontManager {
|
|
|
122
157
|
match(family = 'sans-serif', opts = {}) {
|
|
123
158
|
const weight = numWeight(opts.weight);
|
|
124
159
|
const italic = !!(opts.style && opts.style.includes('italic'));
|
|
125
|
-
const cacheKey = `${family}|${weight}|${italic}`;
|
|
160
|
+
const cacheKey = `${family}|${weight}|${italic}|${variationsKeyOf(opts.variations)}`;
|
|
126
161
|
let font = this._matches.get(cacheKey);
|
|
127
|
-
if (font)
|
|
162
|
+
if (font) {
|
|
163
|
+
// insertion order is LRU order; re-inserting a hit moves it to the tail
|
|
164
|
+
this._matches.delete(cacheKey);
|
|
165
|
+
this._matches.set(cacheKey, font);
|
|
166
|
+
return font;
|
|
167
|
+
}
|
|
128
168
|
|
|
129
169
|
const families = String(family)
|
|
130
170
|
.split(',')
|
|
@@ -145,7 +185,19 @@ export default class FontManager {
|
|
|
145
185
|
});
|
|
146
186
|
font = this._open(candidates[0]);
|
|
147
187
|
}
|
|
188
|
+
font = instantiate(font, weight, opts.variations);
|
|
148
189
|
this._matches.set(cacheKey, font);
|
|
190
|
+
// The key now carries a point in a continuous space rather than one of a
|
|
191
|
+
// handful of weights, so an app animating an axis walks this map instead
|
|
192
|
+
// of hitting it. Same sweep as the shaping memo: drop the stale half in
|
|
193
|
+
// one pass rather than one entry per insert.
|
|
194
|
+
if (this._matches.size > MAX_MATCHES) {
|
|
195
|
+
let drop = this._matches.size >> 1;
|
|
196
|
+
for (const key of this._matches.keys()) {
|
|
197
|
+
if (drop-- <= 0) break;
|
|
198
|
+
this._matches.delete(key);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
149
201
|
return font;
|
|
150
202
|
}
|
|
151
203
|
|
|
@@ -239,7 +291,15 @@ export default class FontManager {
|
|
|
239
291
|
*/
|
|
240
292
|
_shapeCached(text, style, levelsKey = '0') {
|
|
241
293
|
const font = style.font;
|
|
242
|
-
|
|
294
|
+
// A resolved `font` already carries its coordinates in its key, so the
|
|
295
|
+
// variations fragment only earns its keep on the family path — where two
|
|
296
|
+
// points of one axis would otherwise share a shaped run, and the second
|
|
297
|
+
// would be drawn with the first's advances.
|
|
298
|
+
const key = font
|
|
299
|
+
? `${font.key}|${style.size}|${style.weight}|${style.style}|${levelsKey}|${text}`
|
|
300
|
+
: `${style.family}|${style.size}|${style.weight}|${style.style}|${variationsKeyOf(
|
|
301
|
+
style.variations
|
|
302
|
+
)}|${levelsKey}|${text}`;
|
|
243
303
|
let shaped = this._shapeCache.get(key);
|
|
244
304
|
if (shaped) {
|
|
245
305
|
// Map iterates in insertion order: re-inserting a hit moves it to the
|
package/lib/text/layout.js
CHANGED
|
@@ -66,6 +66,7 @@ export class TextLayout {
|
|
|
66
66
|
size: s.size ?? style.size ?? 16,
|
|
67
67
|
weight: s.weight ?? style.weight,
|
|
68
68
|
style: s.style ?? style.style,
|
|
69
|
+
variations: s.variations ?? style.variations,
|
|
69
70
|
features: s.features ?? style.features,
|
|
70
71
|
language: s.language ?? style.language,
|
|
71
72
|
color: s.color ?? style.color ?? null
|
package/lib/window.js
CHANGED
|
@@ -41,6 +41,63 @@ const SPLIT_SAVING = 0.75;
|
|
|
41
41
|
*/
|
|
42
42
|
const BACKING_GRANULARITY = 128;
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Frame period assumed before anything better is known, in ms.
|
|
46
|
+
*
|
|
47
|
+
* The fence clock's rate limit, and the vblank clock's placeholder until the
|
|
48
|
+
* display has reported its own period (see _sampleRefresh).
|
|
49
|
+
*/
|
|
50
|
+
const DEFAULT_FRAME_INTERVAL = 16;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The range a measured vblank period has to fall in to be believed, in ms.
|
|
54
|
+
*
|
|
55
|
+
* Completion timestamps come from the server's clock and its idea of a frame
|
|
56
|
+
* counter, neither of which is guaranteed to mean what it does on a physical
|
|
57
|
+
* output — a window with no CRTC behind it gets a synthesized msc, and an idle
|
|
58
|
+
* gap between two presents inflates the period derived from them. A sample
|
|
59
|
+
* outside 1000Hz..10Hz is not a refresh rate and is dropped.
|
|
60
|
+
*/
|
|
61
|
+
const MIN_REFRESH_INTERVAL = 1;
|
|
62
|
+
const MAX_REFRESH_INTERVAL = 100;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* How many recent completion intervals the refresh estimate is drawn from,
|
|
66
|
+
* and how far up their sorted order it reads.
|
|
67
|
+
*
|
|
68
|
+
* Near the bottom rather than the middle, because what the msc counts is not
|
|
69
|
+
* the same everywhere. Where it counts vertical blanks a one-blank gap is
|
|
70
|
+
* exactly one period and every sample agrees; where it counts *presents* —
|
|
71
|
+
* Xwayland advances it once per frame the compositor takes — a slow frame
|
|
72
|
+
* reports its own duration as if it were the display's, and only the quick
|
|
73
|
+
* frames are telling the truth. Not the outright minimum, because a
|
|
74
|
+
* timer-driven fake vblank (Xvfb) jitters in both directions and a single
|
|
75
|
+
* short gap should not become the answer. A history, rather than a running
|
|
76
|
+
* value, is what lets the estimate rise again after a mode change to a slower
|
|
77
|
+
* rate.
|
|
78
|
+
*/
|
|
79
|
+
const REFRESH_SAMPLES = 16;
|
|
80
|
+
const REFRESH_QUANTILE = 0.25;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* How long a frame may stay outstanding before the vblank clock is abandoned
|
|
84
|
+
* for the fence, in ms.
|
|
85
|
+
*
|
|
86
|
+
* Deliberately far above any frame rate: this is a deadlock breaker, not a
|
|
87
|
+
* pacer, and the rate a window is *legitimately* given can be very low. A
|
|
88
|
+
* Wayland compositor throttles a window nobody can see — measured at ~1Hz for
|
|
89
|
+
* an occluded window under Mutter — by simply not sending the frame callback
|
|
90
|
+
* that completes its present, and that window is right to stop rendering.
|
|
91
|
+
* Waking it up to run frames at fence rate would burn CPU on pixels no one
|
|
92
|
+
* sees, so the timeout has to sit above the slowest honest answer.
|
|
93
|
+
*
|
|
94
|
+
* A server that never completes anything is a different case and worth
|
|
95
|
+
* catching quickly, so the first present a window ever sends is given the
|
|
96
|
+
* shorter deadline — nothing has proved it will be answered yet.
|
|
97
|
+
*/
|
|
98
|
+
const STALL_TIMEOUT = 2000;
|
|
99
|
+
const STALL_TIMEOUT_FIRST = 250;
|
|
100
|
+
|
|
44
101
|
function rectArea(r) {
|
|
45
102
|
return Math.max(0, r.w) * Math.max(0, r.h);
|
|
46
103
|
}
|
|
@@ -359,6 +416,10 @@ export default class Window extends Drawable {
|
|
|
359
416
|
// (see docs/window.md "Frames, coalescing and slow connections")
|
|
360
417
|
this._coalesce = args.coalesceEvents !== false;
|
|
361
418
|
this._frameSyncEnabled = args.frameSync !== false;
|
|
419
|
+
// which signal ends a frame (see _clockOnPresent): 'auto' takes the
|
|
420
|
+
// display's own vblank where the Present path can supply it and the fence
|
|
421
|
+
// everywhere else, 'fence' pins the round-trip clock
|
|
422
|
+
this._frameClockPref = args.frameClock ?? 'auto';
|
|
362
423
|
// _NET_WM_SYNC_REQUEST (see enableSyncRequest): the counter the window
|
|
363
424
|
// manager watches, the value it last asked for, and the watchdog that
|
|
364
425
|
// guarantees an answer even when nothing repaints
|
|
@@ -373,13 +434,34 @@ export default class Window extends Drawable {
|
|
|
373
434
|
this._fixesExt = null;
|
|
374
435
|
this._updateRegion = 0;
|
|
375
436
|
this._presentSerial = 0;
|
|
376
|
-
this.
|
|
437
|
+
this._presentEid = 0;
|
|
438
|
+
// The vblank clock (see _onPresentComplete): the period learnt from
|
|
439
|
+
// completion events, the samples it is drawn from, and the latch the
|
|
440
|
+
// watchdog sets when completions stop arriving.
|
|
441
|
+
this.refreshInterval = null;
|
|
442
|
+
this.droppedFrames = 0;
|
|
443
|
+
this._refreshSamples = [];
|
|
444
|
+
this._lastComplete = null;
|
|
445
|
+
this._clockStalled = false;
|
|
446
|
+
// `frameInterval` is a plain number, but whether the caller set it is what
|
|
447
|
+
// decides its meaning under the vblank clock — an explicit value caps the
|
|
448
|
+
// frame rate, an unasked-for default must not (see _armTimer). Assigning
|
|
449
|
+
// the property later is the caller setting it, hence the accessor.
|
|
450
|
+
//
|
|
451
|
+
// The default is the display's own period where the connection has found
|
|
452
|
+
// it out, and DEFAULT_FRAME_INTERVAL until it has: a window built before
|
|
453
|
+
// that probe answers adopts the result when it lands.
|
|
454
|
+
this._frameInterval = args.frameInterval ?? app.frameInterval ?? DEFAULT_FRAME_INTERVAL;
|
|
455
|
+
this._frameIntervalExplicit = args.frameInterval !== undefined;
|
|
377
456
|
this.frameLatency = null;
|
|
378
457
|
this._frame = {
|
|
379
458
|
pending: new Map(), // coalescible event name -> merged event
|
|
380
459
|
rafCbs: [],
|
|
381
460
|
rafId: 0,
|
|
382
461
|
inFlight: false, // fence round-trip awaiting the server's reply
|
|
462
|
+
presentInFlight: false, // present awaiting the display's CompleteNotify
|
|
463
|
+
presentAt: 0, // when that present was sent, for frameLatency
|
|
464
|
+
stallTimer: null,
|
|
383
465
|
timer: null,
|
|
384
466
|
scheduled: false,
|
|
385
467
|
needsRedraw: false,
|
|
@@ -511,14 +593,30 @@ export default class Window extends Drawable {
|
|
|
511
593
|
// the earliest. `await wnd.enableSyncRequest()` when that is too close.
|
|
512
594
|
this.enableSyncRequest().catch((err) => this.app.options.onXError?.(err));
|
|
513
595
|
}
|
|
596
|
+
// Presenting is what a double-buffered window does unless it says
|
|
597
|
+
// otherwise, so the decision is deferred to the point one gets a backing
|
|
598
|
+
// store (see _enableBackingStore): a window drawing through 'opengl' or
|
|
599
|
+
// 'x11' has no pixmap to present and would only be paying for an update
|
|
600
|
+
// region and an event selection it never uses. `present: true` is still
|
|
601
|
+
// honoured eagerly, for a caller who wants it up before the first paint.
|
|
602
|
+
this._presentOptOut = args.present === false;
|
|
514
603
|
if (args.present && !args.id) {
|
|
515
604
|
// fire and forget: until the extensions answer, blits use CopyArea,
|
|
516
605
|
// which is what they would have done anyway
|
|
517
|
-
this.enablePresent().catch((err) => this.app.options
|
|
606
|
+
this.enablePresent().catch((err) => this.app.options?.onXError?.(err));
|
|
518
607
|
}
|
|
519
608
|
|
|
520
609
|
X.event_consumers[this.id] = this;
|
|
521
610
|
this.on('event', (ev) => {
|
|
611
|
+
// Present speaks in X Generic Events, which carry an extension opcode
|
|
612
|
+
// and a sub-type where a core event carries its code — the name table
|
|
613
|
+
// below cannot see them, and they are not events user code asked for
|
|
614
|
+
if (ev.type === 35) {
|
|
615
|
+
if (this._presentExt && ev.extension === this._presentExt.majorOpcode) {
|
|
616
|
+
this._handlePresentEvent(ev);
|
|
617
|
+
}
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
522
620
|
const ntkev = ev; // todo: clone
|
|
523
621
|
ntkev.window = this;
|
|
524
622
|
ntkev.target = this;
|
|
@@ -640,7 +738,7 @@ export default class Window extends Drawable {
|
|
|
640
738
|
this.on('destroy', () => {
|
|
641
739
|
this._destroyed = true;
|
|
642
740
|
this._forget();
|
|
643
|
-
this._teardownFrame();
|
|
741
|
+
this._teardownFrame(false); // the server has already taken the window
|
|
644
742
|
// the server frees window-backed pictures with the window — let
|
|
645
743
|
// contexts drop their wrappers without issuing FreePicture
|
|
646
744
|
this.emit('_destroyed');
|
|
@@ -676,6 +774,12 @@ export default class Window extends Drawable {
|
|
|
676
774
|
this._dirty = false;
|
|
677
775
|
this._backingValid = false;
|
|
678
776
|
this._presentScheduled = false;
|
|
777
|
+
// Now there is a pixmap to present. Inert where the extensions are
|
|
778
|
+
// missing, so this costs a window on such a server two extension queries
|
|
779
|
+
// that node-x11 answers from its cache after the first.
|
|
780
|
+
if (!this._presentOptOut && !this._foreign) {
|
|
781
|
+
this.enablePresent().catch((err) => this.app.options?.onXError?.(err));
|
|
782
|
+
}
|
|
679
783
|
|
|
680
784
|
this._presentGc = this.X.AllocID();
|
|
681
785
|
this.X.CreateGC(this._presentGc, this.id, { graphicsExposures: 0 });
|
|
@@ -897,39 +1001,120 @@ export default class Window extends Drawable {
|
|
|
897
1001
|
|
|
898
1002
|
/*
|
|
899
1003
|
* Frame clock. Noisy events (see events_map coalesce), synthetic redraws
|
|
900
|
-
* and requestAnimationFrame callbacks are delivered in "frames"
|
|
901
|
-
* two
|
|
902
|
-
*
|
|
903
|
-
* -
|
|
904
|
-
*
|
|
905
|
-
*
|
|
906
|
-
*
|
|
907
|
-
*
|
|
908
|
-
*
|
|
909
|
-
*
|
|
1004
|
+
* and requestAnimationFrame callbacks are delivered in "frames". What ends
|
|
1005
|
+
* a frame — and so what paces the next one — is one of two signals:
|
|
1006
|
+
*
|
|
1007
|
+
* - the display, on a window presenting through the Present extension: the
|
|
1008
|
+
* CompleteNotify for the frame's PresentPixmap, which the server sends
|
|
1009
|
+
* when it has executed the copy at a vertical blank. Frames then run at
|
|
1010
|
+
* the output's own rate, phase-locked to it, whatever that rate is.
|
|
1011
|
+
* - a fence, everywhere else: a cheap request with a reply
|
|
1012
|
+
* (GetInputFocus) sent after each frame; X processes requests in order,
|
|
1013
|
+
* so its reply confirms the server consumed everything the frame drew.
|
|
1014
|
+
*
|
|
1015
|
+
* Either way at most one is outstanding, which is what bounds the work in
|
|
1016
|
+
* flight: on a slow connection frames degrade to one per round-trip instead
|
|
1017
|
+
* of queueing a trail of stale updates.
|
|
1018
|
+
*
|
|
1019
|
+
* A timer backs both up. Under the fence it is the rate limit — at most one
|
|
1020
|
+
* frame per `frameInterval` ms, so a fast local server isn't asked to redraw
|
|
1021
|
+
* at input-device rate. Under the vblank clock the display is the rate
|
|
1022
|
+
* limit, and the timer only covers frames that drew nothing: those produce
|
|
1023
|
+
* no present, so no completion is coming and something else has to run the
|
|
1024
|
+
* next frame (`refreshInterval` is the period there, so a compute-only
|
|
1025
|
+
* animation loop still runs at display rate).
|
|
910
1026
|
*
|
|
911
1027
|
* Discrete events (mousedown, keydown, ...) bypass the timer for latency:
|
|
912
1028
|
* the first blit after a quiet moment goes out with the handler's own
|
|
913
|
-
* requests. Their blits are still bounded, by
|
|
914
|
-
* inter-blit interval (
|
|
915
|
-
* interval a stream of discrete events blits at round-trip
|
|
916
|
-
* local server is several hundred per second, and under a
|
|
917
|
-
* blit is a recomposite.
|
|
1029
|
+
* requests. Their blits are still bounded, by whichever clock is running and
|
|
1030
|
+
* by a minimum inter-blit interval (`frameInterval` again) — see _present().
|
|
1031
|
+
* Without that interval a stream of discrete events blits at round-trip
|
|
1032
|
+
* rate, which on a local server is several hundred per second, and under a
|
|
1033
|
+
* compositor every blit is a recomposite. The vblank clock has no need of
|
|
1034
|
+
* it: a present outstanding already means the display has not caught up.
|
|
918
1035
|
*/
|
|
919
1036
|
_scheduleFrame() {
|
|
920
1037
|
const f = this._frame;
|
|
921
|
-
if (f.scheduled || f.inFlight || f.timer) return;
|
|
1038
|
+
if (f.scheduled || f.inFlight || f.presentInFlight || f.timer) return;
|
|
922
1039
|
f.scheduled = true;
|
|
923
1040
|
setImmediate(() => {
|
|
924
1041
|
f.scheduled = false;
|
|
925
1042
|
// gates may have been armed after this got scheduled (work queued
|
|
926
1043
|
// from inside a running frame, e.g. a rAF loop re-registering) —
|
|
927
|
-
// the fence reply / timer expiry will reschedule then
|
|
928
|
-
if (f.inFlight || f.timer) return;
|
|
1044
|
+
// the completion / fence reply / timer expiry will reschedule then
|
|
1045
|
+
if (f.inFlight || f.presentInFlight || f.timer) return;
|
|
929
1046
|
this._runFrame();
|
|
930
1047
|
});
|
|
931
1048
|
}
|
|
932
1049
|
|
|
1050
|
+
/**
|
|
1051
|
+
* Minimum ms between paced frames, and between blits.
|
|
1052
|
+
*
|
|
1053
|
+
* Under the vblank clock an *explicit* value is a cap on top of the
|
|
1054
|
+
* display's rate — `frameInterval: 33` renders at 30fps on any output,
|
|
1055
|
+
* which is a real ask on battery — while the default must not cap anything,
|
|
1056
|
+
* or a 165Hz display would be held to the 62.5fps that 16ms implies.
|
|
1057
|
+
* Assigning the property counts as explicit, which is why this is an
|
|
1058
|
+
* accessor: `wnd.frameInterval = 33` has to mean the same as passing it.
|
|
1059
|
+
*/
|
|
1060
|
+
get frameInterval() {
|
|
1061
|
+
return this._frameInterval;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
set frameInterval(ms) {
|
|
1065
|
+
this._frameInterval = ms;
|
|
1066
|
+
this._frameIntervalExplicit = true;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* Take the connection's measured default, if this window is still on the
|
|
1071
|
+
* guess. Called when the RandR probe answers, which is normally a few
|
|
1072
|
+
* round trips after the first window was built — see App#_probeRefreshRate.
|
|
1073
|
+
*/
|
|
1074
|
+
_adoptDefaultFrameInterval(ms) {
|
|
1075
|
+
if (this._frameIntervalExplicit || this._destroyed) return;
|
|
1076
|
+
this._frameInterval = ms;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* Which clock is ending this window's frames right now: 'present' when the
|
|
1081
|
+
* display is, 'fence' when the server round-trip is. Assignable with
|
|
1082
|
+
* 'auto' (prefer the display) or 'fence' (never use it).
|
|
1083
|
+
*/
|
|
1084
|
+
get frameClock() {
|
|
1085
|
+
return this._clockOnPresent() ? 'present' : 'fence';
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
set frameClock(mode) {
|
|
1089
|
+
this._frameClockPref = mode === 'present' ? 'auto' : mode;
|
|
1090
|
+
if (this._frameClockPref !== 'fence') this._selectPresentInput();
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Is the display ending frames on this window, rather than the socket?
|
|
1095
|
+
*
|
|
1096
|
+
* Needs the Present path up (there is no completion event without it) and
|
|
1097
|
+
* the frame sync the caller may have turned off — `frameSync: false` asks
|
|
1098
|
+
* for pacing that never waits on the server, and a completion is the
|
|
1099
|
+
* strongest wait there is. `_clockStalled` is the watchdog's latch: a
|
|
1100
|
+
* completion that never came drops the window back to the fence until one
|
|
1101
|
+
* does, so a clock that stops cannot stop the window with it.
|
|
1102
|
+
*
|
|
1103
|
+
* `_presentFlipped` is the one-way door — a server that has flipped a copy
|
|
1104
|
+
* present is off this path for good, and its blits no longer produce the
|
|
1105
|
+
* completions the clock is made of.
|
|
1106
|
+
*/
|
|
1107
|
+
_clockOnPresent() {
|
|
1108
|
+
return (
|
|
1109
|
+
this._frameClockPref !== 'fence' &&
|
|
1110
|
+
this._frameSyncEnabled &&
|
|
1111
|
+
!this._clockStalled &&
|
|
1112
|
+
!this._presentFlipped &&
|
|
1113
|
+
!!this._presentExt &&
|
|
1114
|
+
!!this._presentEid
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
933
1118
|
_runFrame() {
|
|
934
1119
|
const f = this._frame;
|
|
935
1120
|
if (
|
|
@@ -966,7 +1151,10 @@ export default class Window extends Drawable {
|
|
|
966
1151
|
// covers a window with no backing store, and a resize that changed
|
|
967
1152
|
// nothing: _presentNow did not run, but the frame is still "handled"
|
|
968
1153
|
this._ackSyncRequest();
|
|
969
|
-
|
|
1154
|
+
// A frame that presented is already clocked — its completion ends it. One
|
|
1155
|
+
// that drew nothing has to fall back to the timer, or nothing would ever
|
|
1156
|
+
// run the next frame.
|
|
1157
|
+
if (!this._clockOnPresent()) this._armFence();
|
|
970
1158
|
this._armTimer();
|
|
971
1159
|
}
|
|
972
1160
|
|
|
@@ -1063,29 +1251,209 @@ export default class Window extends Drawable {
|
|
|
1063
1251
|
this.X.GetInputFocus(() => {
|
|
1064
1252
|
f.inFlight = false;
|
|
1065
1253
|
this.frameLatency = performance.now() - start;
|
|
1066
|
-
|
|
1067
|
-
// a blit deferred while the fence was in flight: show it as soon as
|
|
1068
|
-
// the minimum inter-blit interval allows (immediately when the last
|
|
1069
|
-
// blit is already older than that, which is the common case)
|
|
1070
|
-
this._present();
|
|
1071
|
-
}
|
|
1072
|
-
if ((f.pending.size || f.rafCbs.length || f.needsRedraw) && !f.timer) this._scheduleFrame();
|
|
1254
|
+
this._frameEnded();
|
|
1073
1255
|
});
|
|
1074
1256
|
});
|
|
1075
1257
|
}
|
|
1076
1258
|
|
|
1259
|
+
/**
|
|
1260
|
+
* Present's events, which are the ones the core event table cannot name:
|
|
1261
|
+
* they arrive as X Generic Events (type 35), carrying an extension opcode
|
|
1262
|
+
* and a sub-type instead of an event code.
|
|
1263
|
+
*/
|
|
1264
|
+
_handlePresentEvent(ev) {
|
|
1265
|
+
const P = this._presentExt;
|
|
1266
|
+
if (!P || ev.evtype !== P.events.CompleteNotify) return;
|
|
1267
|
+
if (ev.kind !== P.CompleteKind.Pixmap) return; // not a frame of ours
|
|
1268
|
+
if (ev.mode === P.CompleteMode.Flip) {
|
|
1269
|
+
// The server took the pixmap for scanout instead of copying it, and
|
|
1270
|
+
// keeps it until some later present — but ntk has one grow-only backing
|
|
1271
|
+
// pixmap and is about to draw into it, which would now paint the screen
|
|
1272
|
+
// directly. Option.Copy is passed on every present precisely so this
|
|
1273
|
+
// cannot happen (presentproto: "'pixmap' will be idle ... as soon as the
|
|
1274
|
+
// operation occurs"), so a server doing it anyway is one whose Present
|
|
1275
|
+
// path we cannot use at all.
|
|
1276
|
+
this._presentFlipped = true;
|
|
1277
|
+
this.app.options.onXError?.(
|
|
1278
|
+
new Error(
|
|
1279
|
+
'ntk: the X server flipped a present sent with Option.Copy, which leaves it ' +
|
|
1280
|
+
"owning the window's backing pixmap — falling back to CopyArea blits"
|
|
1281
|
+
)
|
|
1282
|
+
);
|
|
1283
|
+
this._stallClock();
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
this._onPresentComplete(ev);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/**
|
|
1290
|
+
* A frame reached the display: the server executed the copy, at a vblank.
|
|
1291
|
+
*
|
|
1292
|
+
* This is `_armFence`'s reply callback with a better trigger behind it. The
|
|
1293
|
+
* fence proves the server *read* the frame's requests; this proves it *ran*
|
|
1294
|
+
* them, on the output, which is both a stronger flow-control signal and the
|
|
1295
|
+
* moment the backing pixmap is ours to draw into again.
|
|
1296
|
+
*/
|
|
1297
|
+
_onPresentComplete(ev) {
|
|
1298
|
+
const f = this._frame;
|
|
1299
|
+
const now = performance.now();
|
|
1300
|
+
this._clearStallWatchdog();
|
|
1301
|
+
// completions are arriving again, whatever the watchdog concluded before
|
|
1302
|
+
this._clockStalled = false;
|
|
1303
|
+
if (!f.presentInFlight) return; // a completion we are not clocking on
|
|
1304
|
+
f.presentInFlight = false;
|
|
1305
|
+
this.frameLatency = now - f.presentAt;
|
|
1306
|
+
this._sampleRefresh(ev, now);
|
|
1307
|
+
this._frameEnded();
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* What both clocks do when a frame ends: send the blit that was held back
|
|
1312
|
+
* behind it, then run the next frame if anything is waiting for one.
|
|
1313
|
+
*/
|
|
1314
|
+
_frameEnded() {
|
|
1315
|
+
const f = this._frame;
|
|
1316
|
+
if (this._presentPending) {
|
|
1317
|
+
// a blit deferred while the frame was outstanding: show it as soon as
|
|
1318
|
+
// the pacing allows, which under the vblank clock is now
|
|
1319
|
+
this._present();
|
|
1320
|
+
}
|
|
1321
|
+
if ((f.pending.size || f.rafCbs.length || f.needsRedraw) && !f.timer) this._scheduleFrame();
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* Learn the display's refresh period from the frame that just landed.
|
|
1326
|
+
*
|
|
1327
|
+
* Two completions give it without asking anyone: `ust` is when the
|
|
1328
|
+
* presentation happened (microseconds, on the server's clock) and `msc`
|
|
1329
|
+
* counts the frames between them, so their ratio is one vblank. Nothing is
|
|
1330
|
+
* configured, nothing is queried, and it follows the window to another
|
|
1331
|
+
* monitor or through a mode change on its own — where a RandR lookup would
|
|
1332
|
+
* need change-tracking to keep up.
|
|
1333
|
+
*
|
|
1334
|
+
* Only differences are read from the server clock, never an absolute time,
|
|
1335
|
+
* and a sample is believed only if it lands in the range a refresh rate can
|
|
1336
|
+
* be in — which is also what keeps a window the compositor has throttled to
|
|
1337
|
+
* a frame a second from being mistaken for a 1Hz display.
|
|
1338
|
+
*/
|
|
1339
|
+
_sampleRefresh(ev, now) {
|
|
1340
|
+
const prev = this._lastComplete;
|
|
1341
|
+
this._lastComplete = { msc: ev.msc, ust: ev.ust, at: now };
|
|
1342
|
+
if (!prev || !(ev.msc > prev.msc)) return;
|
|
1343
|
+
const frames = ev.msc - prev.msc;
|
|
1344
|
+
// Frames the display went through without one of ours. Counted from the
|
|
1345
|
+
// msc, which is the only place the information exists: where the counter
|
|
1346
|
+
// tracks presents rather than vblanks the gap is always 1 and a miss is
|
|
1347
|
+
// simply not observable, and reporting nothing beats inferring it from
|
|
1348
|
+
// elapsed time on a server whose timing is synthetic to begin with.
|
|
1349
|
+
//
|
|
1350
|
+
// Only across a gap short enough to have been a frame at all. A window
|
|
1351
|
+
// that sat idle between two clicks also let the counter run, and that is
|
|
1352
|
+
// not a dropped frame; past a tenth of a second the two are anyway
|
|
1353
|
+
// indistinguishable, and under-reporting is the right way for a
|
|
1354
|
+
// diagnostic to be wrong.
|
|
1355
|
+
if (now - prev.at <= MAX_REFRESH_INTERVAL) this.droppedFrames += frames - 1;
|
|
1356
|
+
// Only a single-frame gap measures a frame's length. A longer one spans a
|
|
1357
|
+
// miss, and dividing it out assumes the msc advanced for the same reason
|
|
1358
|
+
// in each — true of a hardware counter, false of one driven off a timer,
|
|
1359
|
+
// where it makes a late frame look like a *faster* display.
|
|
1360
|
+
if (frames !== 1) return;
|
|
1361
|
+
// ust is the server's clock and local receipt is ours; they measure the
|
|
1362
|
+
// same interval, so the local one stands in when ust is not usable (a
|
|
1363
|
+
// synthesized msc can carry a ust that does not move with it)
|
|
1364
|
+
const byUst = (ev.ust - prev.ust) / 1000;
|
|
1365
|
+
const sample = byUst >= MIN_REFRESH_INTERVAL ? byUst : now - prev.at;
|
|
1366
|
+
if (!(sample >= MIN_REFRESH_INTERVAL && sample <= MAX_REFRESH_INTERVAL)) return;
|
|
1367
|
+
const samples = this._refreshSamples;
|
|
1368
|
+
samples.push(sample);
|
|
1369
|
+
if (samples.length > REFRESH_SAMPLES) samples.shift();
|
|
1370
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
1371
|
+
this.refreshInterval = sorted[Math.floor(REFRESH_QUANTILE * (sorted.length - 1))];
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* Guarantee that a frame ends even if its completion does not arrive.
|
|
1376
|
+
*
|
|
1377
|
+
* A fence is a request with a reply and the server always sends one. A
|
|
1378
|
+
* completion is an event about work the server may never do: an unmapped
|
|
1379
|
+
* window, a compositor changing its mind about redirection, or simply a
|
|
1380
|
+
* server whose Present is a stub. `_presentPending` has exactly one wakeup,
|
|
1381
|
+
* so a completion that never comes would be a window that never updates
|
|
1382
|
+
* again — a permanently stale window, which is the trap #195 hit.
|
|
1383
|
+
*
|
|
1384
|
+
* See STALL_TIMEOUT for why the deadline is measured in seconds rather than
|
|
1385
|
+
* in frames: a slow answer is usually the display being honest about how
|
|
1386
|
+
* often this window is worth drawing.
|
|
1387
|
+
*/
|
|
1388
|
+
_armStallWatchdog() {
|
|
1389
|
+
const f = this._frame;
|
|
1390
|
+
if (f.stallTimer) return;
|
|
1391
|
+
const timeout = this._lastComplete ? STALL_TIMEOUT : STALL_TIMEOUT_FIRST;
|
|
1392
|
+
f.stallTimer = setTimeout(() => {
|
|
1393
|
+
f.stallTimer = null;
|
|
1394
|
+
if (f.presentInFlight) this._stallClock();
|
|
1395
|
+
}, timeout);
|
|
1396
|
+
if (typeof f.stallTimer.unref === 'function') f.stallTimer.unref();
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
_clearStallWatchdog() {
|
|
1400
|
+
const f = this._frame;
|
|
1401
|
+
if (!f.stallTimer) return;
|
|
1402
|
+
clearTimeout(f.stallTimer);
|
|
1403
|
+
f.stallTimer = null;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/**
|
|
1407
|
+
* Give up on the display as a clock, for now. The fence takes over, and the
|
|
1408
|
+
* next completion to arrive hands it back (see _onPresentComplete) — a
|
|
1409
|
+
* window that is merely unmapped for a while must not be left on the slower
|
|
1410
|
+
* clock forever.
|
|
1411
|
+
*/
|
|
1412
|
+
_stallClock() {
|
|
1413
|
+
const f = this._frame;
|
|
1414
|
+
this._clockStalled = true;
|
|
1415
|
+
f.presentInFlight = false;
|
|
1416
|
+
this._clearStallWatchdog();
|
|
1417
|
+
// nothing else needs re-arming: an idle window wants no clock, and the
|
|
1418
|
+
// frame _frameEnded schedules will arm the fence on its way out
|
|
1419
|
+
this._frameEnded();
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/**
|
|
1423
|
+
* The timer gate, whose period depends on what it is standing in for.
|
|
1424
|
+
*
|
|
1425
|
+
* Under the fence it is the rate limit and `frameInterval` is the period.
|
|
1426
|
+
* Under the vblank clock it is the fallback for a frame that presented
|
|
1427
|
+
* nothing — there is no completion coming for a frame that drew nothing —
|
|
1428
|
+
* so the period is the display's, and a frame that *did* present arms no
|
|
1429
|
+
* timer at all: the completion is closer and more accurate than any timer,
|
|
1430
|
+
* and arming one would only cap the rate the display just set. An explicit
|
|
1431
|
+
* `frameInterval` is honoured in both, since a cap the caller asked for
|
|
1432
|
+
* applies whatever ends the frame.
|
|
1433
|
+
*/
|
|
1077
1434
|
_armTimer() {
|
|
1078
|
-
if (!(this.frameInterval > 0)) return;
|
|
1079
1435
|
const f = this._frame;
|
|
1080
1436
|
if (f.timer) return;
|
|
1437
|
+
let period = this.frameInterval;
|
|
1438
|
+
if (this._clockOnPresent() && !this._frameIntervalExplicit) {
|
|
1439
|
+
if (f.presentInFlight) return;
|
|
1440
|
+
period = this.refreshInterval ?? DEFAULT_FRAME_INTERVAL;
|
|
1441
|
+
}
|
|
1442
|
+
if (!(period > 0)) return;
|
|
1081
1443
|
f.timer = setTimeout(() => {
|
|
1082
1444
|
f.timer = null;
|
|
1083
1445
|
if (f.pending.size || f.rafCbs.length || f.needsRedraw) this._scheduleFrame();
|
|
1084
|
-
},
|
|
1446
|
+
}, period);
|
|
1085
1447
|
if (typeof f.timer.unref === 'function') f.timer.unref();
|
|
1086
1448
|
}
|
|
1087
1449
|
|
|
1088
|
-
|
|
1450
|
+
/**
|
|
1451
|
+
* @param {boolean} [windowAlive] false when the server has already destroyed
|
|
1452
|
+
* the window (a DestroyNotify), which takes its Present event context with
|
|
1453
|
+
* it — deleting one explicitly then is a request against a window id that
|
|
1454
|
+
* no longer exists, i.e. a BadWindow for the app's error hook to explain.
|
|
1455
|
+
*/
|
|
1456
|
+
_teardownFrame(windowAlive = true) {
|
|
1089
1457
|
const f = this._frame;
|
|
1090
1458
|
if (f.timer) {
|
|
1091
1459
|
clearTimeout(f.timer);
|
|
@@ -1095,6 +1463,18 @@ export default class Window extends Drawable {
|
|
|
1095
1463
|
clearTimeout(f.presentTimer);
|
|
1096
1464
|
f.presentTimer = null;
|
|
1097
1465
|
}
|
|
1466
|
+
this._clearStallWatchdog();
|
|
1467
|
+
if (this._presentEid) {
|
|
1468
|
+
const eid = this._presentEid;
|
|
1469
|
+
const P = this._presentExt;
|
|
1470
|
+
this._presentEid = 0;
|
|
1471
|
+
f.presentInFlight = false;
|
|
1472
|
+
safeRelease(this.X, () => {
|
|
1473
|
+
// an empty mask deletes the event context (presentproto)
|
|
1474
|
+
if (windowAlive && P) P.SelectInput(eid, this.id, P.EventMask.NoEvent);
|
|
1475
|
+
this.X.ReleaseID(eid);
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1098
1478
|
if (this._syncWatchdog) {
|
|
1099
1479
|
clearTimeout(this._syncWatchdog);
|
|
1100
1480
|
this._syncWatchdog = null;
|
|
@@ -1129,9 +1509,10 @@ export default class Window extends Drawable {
|
|
|
1129
1509
|
|
|
1130
1510
|
/**
|
|
1131
1511
|
* DOM-style requestAnimationFrame: `cb(now)` runs on this window's next
|
|
1132
|
-
* paced frame —
|
|
1133
|
-
*
|
|
1134
|
-
*
|
|
1512
|
+
* paced frame — one per vertical blank where the display is the clock (see
|
|
1513
|
+
* _clockOnPresent), otherwise at most once per `frameInterval` ms — and
|
|
1514
|
+
* always with at most one frame outstanding, so animation loops adapt to
|
|
1515
|
+
* the output's rate and to connection latency instead of flooding either.
|
|
1135
1516
|
* Returns an id for cancelAnimationFrame().
|
|
1136
1517
|
*/
|
|
1137
1518
|
requestAnimationFrame(cb) {
|
|
@@ -1150,8 +1531,9 @@ export default class Window extends Drawable {
|
|
|
1150
1531
|
|
|
1151
1532
|
/**
|
|
1152
1533
|
* Whether a blit this window already owes is still waiting to go out —
|
|
1153
|
-
* because the
|
|
1154
|
-
* is deferred behind the
|
|
1534
|
+
* because the last frame is unanswered (its fence unreplied, or its present
|
|
1535
|
+
* not yet on the display), or because a present is deferred behind the
|
|
1536
|
+
* minimum inter-blit interval.
|
|
1155
1537
|
*
|
|
1156
1538
|
* The one bit of frame-clock state worth publishing, because it is the
|
|
1157
1539
|
* difference between the two ways a toolkit can answer a discrete input.
|
|
@@ -1173,24 +1555,29 @@ export default class Window extends Drawable {
|
|
|
1173
1555
|
* "draw it now" to every notch — the precise work this gate exists to skip.
|
|
1174
1556
|
*
|
|
1175
1557
|
* `false` whenever nothing gates blits at all: `frameSync: false` sends no
|
|
1176
|
-
* fence and
|
|
1177
|
-
*
|
|
1558
|
+
* fence and takes the vblank clock with it (a completion is a wait on the
|
|
1559
|
+
* server too), and `frameInterval: 0` keeps no interval, so a caller who
|
|
1560
|
+
* asked for both gets the unpaced behaviour those options ask for.
|
|
1178
1561
|
*/
|
|
1179
1562
|
frameInFlight() {
|
|
1180
|
-
return this._frame.inFlight || this._presentPending;
|
|
1563
|
+
return this._frame.inFlight || this._frame.presentInFlight || this._presentPending;
|
|
1181
1564
|
}
|
|
1182
1565
|
|
|
1183
1566
|
_present() {
|
|
1184
1567
|
if (!this._backing) return;
|
|
1568
|
+
const f = this._frame;
|
|
1185
1569
|
const wait = this._presentWait();
|
|
1186
|
-
if (
|
|
1187
|
-
// the server hasn't confirmed the previous frame yet,
|
|
1188
|
-
// was too recent — defer,
|
|
1570
|
+
if (f.inFlight || f.presentInFlight || wait > 0) {
|
|
1571
|
+
// the server hasn't confirmed the previous frame yet, the display hasn't
|
|
1572
|
+
// shown it, or the last blit was too recent — defer, leaving a wakeup
|
|
1189
1573
|
this._deferPresent(wait);
|
|
1190
1574
|
return;
|
|
1191
1575
|
}
|
|
1192
1576
|
this._presentNow();
|
|
1193
|
-
this
|
|
1577
|
+
// the fence is what ends this blit's frame unless the display is doing
|
|
1578
|
+
// it — which is decided by _presentNow, since a blit that fell back to
|
|
1579
|
+
// CopyArea gets no completion however the clock is configured
|
|
1580
|
+
if (!f.presentInFlight) this._armFence();
|
|
1194
1581
|
}
|
|
1195
1582
|
|
|
1196
1583
|
/**
|
|
@@ -1199,8 +1586,16 @@ export default class Window extends Drawable {
|
|
|
1199
1586
|
* The gate is `frameInterval`, the same knob that paces frames, so
|
|
1200
1587
|
* `frameInterval: 0` keeps the old fence-only behaviour — a caller who asked
|
|
1201
1588
|
* for no timer gate gets none here either.
|
|
1589
|
+
*
|
|
1590
|
+
* The vblank clock needs no such gate and must not inherit the default one:
|
|
1591
|
+
* an outstanding present already means the display has not caught up, and
|
|
1592
|
+
* that is a truer statement of the same thing than any interval. Holding a
|
|
1593
|
+
* click's repaint for a 16ms default on a 165Hz output is exactly the
|
|
1594
|
+
* latency this gate was careful not to add. An explicit interval still
|
|
1595
|
+
* applies — a cap the caller asked for is a cap.
|
|
1202
1596
|
*/
|
|
1203
1597
|
_presentWait() {
|
|
1598
|
+
if (this._clockOnPresent() && !this._frameIntervalExplicit) return 0;
|
|
1204
1599
|
const min = this.frameInterval;
|
|
1205
1600
|
if (!(min > 0)) return 0;
|
|
1206
1601
|
const since = performance.now() - this._frame.lastPresentAt;
|
|
@@ -1211,16 +1606,17 @@ export default class Window extends Drawable {
|
|
|
1211
1606
|
* Hold a blit back, and guarantee it still happens.
|
|
1212
1607
|
*
|
|
1213
1608
|
* This is the only place `_presentPending` is set, because every deferral has
|
|
1214
|
-
* to leave a wakeup behind: the
|
|
1215
|
-
*
|
|
1216
|
-
* present — `_scheduleFrame()`
|
|
1217
|
-
*
|
|
1218
|
-
* callback) looks at the present. Without the
|
|
1219
|
-
* would simply never go out, leaving stale
|
|
1609
|
+
* to leave a wakeup behind: the end of the frame we are waiting on (its
|
|
1610
|
+
* fence reply, or its completion), this timer when the interval is what we
|
|
1611
|
+
* are waiting on. Nothing else reschedules a present — `_scheduleFrame()`
|
|
1612
|
+
* bails while a frame timer is armed, and no reschedule condition
|
|
1613
|
+
* (`_frameEnded`, `_armTimer`'s callback) looks at the present. Without the
|
|
1614
|
+
* timer the last blit of a burst would simply never go out, leaving stale
|
|
1615
|
+
* pixels on screen.
|
|
1220
1616
|
*/
|
|
1221
1617
|
_deferPresent(wait) {
|
|
1222
1618
|
this._presentPending = true;
|
|
1223
|
-
if (wait <= 0) return; // waiting on the
|
|
1619
|
+
if (wait <= 0) return; // waiting on the frame: its end blits this
|
|
1224
1620
|
const f = this._frame;
|
|
1225
1621
|
if (f.presentTimer) return; // already armed
|
|
1226
1622
|
f.presentTimer = setTimeout(() => {
|
|
@@ -1228,9 +1624,9 @@ export default class Window extends Drawable {
|
|
|
1228
1624
|
if (!this._presentPending && !this._dirty) return; // someone blitted it
|
|
1229
1625
|
const again = this._presentWait();
|
|
1230
1626
|
if (again > 0) return this._deferPresent(again); // a paced frame re-stamped
|
|
1231
|
-
if (f.inFlight) return; // the
|
|
1627
|
+
if (f.inFlight || f.presentInFlight) return; // the frame's end is closer
|
|
1232
1628
|
this._presentNow();
|
|
1233
|
-
this._armFence();
|
|
1629
|
+
if (!f.presentInFlight) this._armFence();
|
|
1234
1630
|
}, wait);
|
|
1235
1631
|
if (typeof f.presentTimer.unref === 'function') f.presentTimer.unref();
|
|
1236
1632
|
}
|
|
@@ -1263,17 +1659,49 @@ export default class Window extends Drawable {
|
|
|
1263
1659
|
*/
|
|
1264
1660
|
enablePresent() {
|
|
1265
1661
|
if (this._presentExt || this._destroyed) return Promise.resolve(this);
|
|
1662
|
+
// Memoized because `createWindow({ present: true })` starts this and
|
|
1663
|
+
// `await wnd.enablePresent()` is the documented way to find out whether it
|
|
1664
|
+
// took — doing both is the normal thing to write, and two runs in flight
|
|
1665
|
+
// at once would each allocate an update region, orphaning one on the
|
|
1666
|
+
// server for the life of the window.
|
|
1667
|
+
if (this._presentPromise) return this._presentPromise;
|
|
1266
1668
|
const X = this.X;
|
|
1267
1669
|
const need = (name) =>
|
|
1268
1670
|
new Promise((resolve) => X.require(name, (err, ext) => resolve(err ? null : ext)));
|
|
1269
|
-
|
|
1671
|
+
this._presentPromise = Promise.all([need('present'), need('fixes')]).then(([present, fixes]) => {
|
|
1270
1672
|
if (!present || !fixes || this._destroyed) return this; // stay on CopyArea
|
|
1271
1673
|
this._updateRegion = X.AllocID();
|
|
1272
1674
|
safeRelease(X, () => fixes.CreateRegion(this._updateRegion, []));
|
|
1273
1675
|
this._presentExt = present;
|
|
1274
1676
|
this._fixesExt = fixes;
|
|
1677
|
+
this._selectPresentInput();
|
|
1275
1678
|
return this;
|
|
1276
1679
|
});
|
|
1680
|
+
return this._presentPromise;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
/**
|
|
1684
|
+
* Ask for the completion events the vblank clock runs on.
|
|
1685
|
+
*
|
|
1686
|
+
* CompleteNotify only; IdleNotify would say the same thing twice, since
|
|
1687
|
+
* every present ntk sends carries Option.Copy and the spec makes such a
|
|
1688
|
+
* pixmap idle "as soon as the operation occurs" — the moment the completion
|
|
1689
|
+
* reports. Nothing is selected for a window pinned to the fence, so a
|
|
1690
|
+
* caller who opted out is not sent events it will not read.
|
|
1691
|
+
*/
|
|
1692
|
+
_selectPresentInput() {
|
|
1693
|
+
const P = this._presentExt;
|
|
1694
|
+
if (!P || this._presentEid || this._destroyed) return;
|
|
1695
|
+
if (this._frameClockPref === 'fence' || !this._frameSyncEnabled) return;
|
|
1696
|
+
const eid = this.X.AllocID();
|
|
1697
|
+
// the id is only recorded once the request is really on its way: the
|
|
1698
|
+
// clock's whole contract is that a completion will arrive, and a
|
|
1699
|
+
// selection that never left (a connection closing under us) promises
|
|
1700
|
+
// nothing. Failing this way leaves the window on the fence, which works.
|
|
1701
|
+
safeRelease(this.X, () => {
|
|
1702
|
+
P.SelectInput(eid, this.id, P.EventMask.CompleteNotify);
|
|
1703
|
+
this._presentEid = eid;
|
|
1704
|
+
});
|
|
1277
1705
|
}
|
|
1278
1706
|
|
|
1279
1707
|
/**
|
|
@@ -1284,6 +1712,7 @@ export default class Window extends Drawable {
|
|
|
1284
1712
|
_presentWithExtension(rects) {
|
|
1285
1713
|
const P = this._presentExt;
|
|
1286
1714
|
if (!P || !this._fixesExt || !this._updateRegion) return false;
|
|
1715
|
+
if (this._presentFlipped) return false; // see _handlePresentEvent
|
|
1287
1716
|
safeRelease(this.X, () => {
|
|
1288
1717
|
this._fixesExt.SetRegion(
|
|
1289
1718
|
this._updateRegion,
|
|
@@ -1329,10 +1758,20 @@ export default class Window extends Drawable {
|
|
|
1329
1758
|
// Stamped here rather than at the call sites so it is a true inter-blit
|
|
1330
1759
|
// interval across all three of them, and only when pixels really move — a
|
|
1331
1760
|
// present that copies nothing must not push the next one out.
|
|
1332
|
-
|
|
1761
|
+
const f = this._frame;
|
|
1762
|
+
f.lastPresentAt = performance.now();
|
|
1333
1763
|
// Present sends the exact rectangles in one request, so the bounding-box
|
|
1334
1764
|
// collapse — which trades pixels for fewer requests — has nothing to buy
|
|
1335
|
-
if (
|
|
1765
|
+
if (this._presentWithExtension(clamped)) {
|
|
1766
|
+
// This frame is now the display's to end. Only a present through the
|
|
1767
|
+
// extension can be: a CopyArea reports nothing back, so a window that
|
|
1768
|
+
// fell back to it must keep the fence even with the clock configured.
|
|
1769
|
+
if (this._clockOnPresent()) {
|
|
1770
|
+
f.presentInFlight = true;
|
|
1771
|
+
f.presentAt = f.lastPresentAt;
|
|
1772
|
+
this._armStallWatchdog();
|
|
1773
|
+
}
|
|
1774
|
+
} else {
|
|
1336
1775
|
const rects = this._blitList(clamped);
|
|
1337
1776
|
// a paced frame can fire after the connection started closing
|
|
1338
1777
|
safeRelease(this.X, () => {
|
|
@@ -1463,8 +1902,9 @@ export default class Window extends Drawable {
|
|
|
1463
1902
|
|
|
1464
1903
|
/**
|
|
1465
1904
|
* Intern several atoms and hand them to `cb` as an object keyed by name.
|
|
1466
|
-
*
|
|
1467
|
-
*
|
|
1905
|
+
* Interned atoms are cached per connection — which is true because App
|
|
1906
|
+
* makes it true, see `_isolateAtoms` — so a repeated call costs no round
|
|
1907
|
+
* trip. Same deferred-chain hazards as setTitle: by the
|
|
1468
1908
|
* time the replies land the window may be gone, so callers re-check
|
|
1469
1909
|
* `this._destroyed`.
|
|
1470
1910
|
*/
|
|
@@ -2932,8 +3372,9 @@ export default class Window extends Drawable {
|
|
|
2932
3372
|
|
|
2933
3373
|
_emitCloseRequest(ev) {
|
|
2934
3374
|
if (ev.format !== 32 || !this.listenerCount('close')) return;
|
|
2935
|
-
//
|
|
2936
|
-
// the
|
|
3375
|
+
// interned atoms are cached per connection (App#_isolateAtoms), so these
|
|
3376
|
+
// are the ids this window's own addProtocol interned — an id another
|
|
3377
|
+
// connection happened to intern would not be one this server still knows
|
|
2937
3378
|
const X = this.X;
|
|
2938
3379
|
if (!X.atoms.WM_PROTOCOLS || ev.message_type !== X.atoms.WM_PROTOCOLS) return;
|
|
2939
3380
|
if (!X.atoms.WM_DELETE_WINDOW || ev.data?.[0] !== X.atoms.WM_DELETE_WINDOW) return;
|