ntk 6.2.0 → 6.4.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/glx.js +121 -6
- package/lib/index.js +4 -1
- package/lib/renderingcontext_opengl.js +20 -8
- package/lib/window.js +345 -21
- package/package.json +2 -2
package/lib/glx.js
CHANGED
|
@@ -1,10 +1,115 @@
|
|
|
1
|
-
// GLX visual/fbconfig discovery.
|
|
1
|
+
// GLX visual/fbconfig discovery, and the diagnosis of a failed setup.
|
|
2
2
|
//
|
|
3
3
|
// A GLX drawable has to use a visual the GL context was created for, so the
|
|
4
4
|
// visual must be known *before* CreateWindow. This module asks the server
|
|
5
5
|
// (GetFBConfigs, falling back to the GLX 1.2 GetVisualConfigs) instead of
|
|
6
6
|
// shelling out to `glxinfo`, so it works headlessly and in CI.
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* `err.code` on every error this module and the OpenGL context throw, so
|
|
10
|
+
* callers can branch on the cause instead of matching on message text.
|
|
11
|
+
*
|
|
12
|
+
* - `GLX_NO_EXTENSION` — the server does not have the GLX extension.
|
|
13
|
+
* - `GLX_INDIRECT_DISABLED` — it has GLX, but refuses indirect contexts,
|
|
14
|
+
* which is the only kind that works over the wire. The default on Xorg
|
|
15
|
+
* >= 1.17 and Xwayland, and by far the most common failure.
|
|
16
|
+
* - `GLX_NO_CONFIG` — no visual/fbconfig matches the requested attributes.
|
|
17
|
+
* - `GLX_CONTEXT_FAILED` — anything else that went wrong in setup.
|
|
18
|
+
*/
|
|
19
|
+
export const GLXError = {
|
|
20
|
+
NO_EXTENSION: 'GLX_NO_EXTENSION',
|
|
21
|
+
INDIRECT_DISABLED: 'GLX_INDIRECT_DISABLED',
|
|
22
|
+
NO_CONFIG: 'GLX_NO_CONFIG',
|
|
23
|
+
CONTEXT_FAILED: 'GLX_CONTEXT_FAILED'
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// `message` says what is wrong in one line; `hint` is the multi-line
|
|
27
|
+
// remedy, printed by the default warning and available to anyone rendering
|
|
28
|
+
// their own fallback UI.
|
|
29
|
+
function glxError(code, message, hint, cause) {
|
|
30
|
+
const err = new Error(message);
|
|
31
|
+
err.code = code;
|
|
32
|
+
if (hint) err.hint = hint;
|
|
33
|
+
if (cause) err.cause = cause;
|
|
34
|
+
return err;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const displayName = () => process.env.DISPLAY || 'the X server';
|
|
38
|
+
|
|
39
|
+
const IGLX_HINT = `Indirect GLX is what ntk draws with: GL commands travel over the X
|
|
40
|
+
connection, since there is no client-side GL library involved. Xorg >= 1.17
|
|
41
|
+
and Xwayland ship with it disabled, and answer GLX CreateContext with
|
|
42
|
+
BadValue. The extension is still present and still advertises GLX 1.4 with a
|
|
43
|
+
full set of fbconfigs, which is why this looks like it should have worked.
|
|
44
|
+
|
|
45
|
+
To get a server that allows it:
|
|
46
|
+
under Wayland: Xwayland :5 +iglx & then DISPLAY=:5
|
|
47
|
+
under X: Xephyr :5 +iglx & then DISPLAY=:5
|
|
48
|
+
Xorg: +iglx on the command line, or
|
|
49
|
+
Option "AllowIndirectGLX" "on" in xorg.conf
|
|
50
|
+
XQuartz: defaults write org.xquartz.X11 enable_iglx -bool true
|
|
51
|
+
|
|
52
|
+
Note that on current Linux distros the indirect GL *engine* behind those
|
|
53
|
+
contexts is often gone even with +iglx on — contexts are created but nothing
|
|
54
|
+
rasterizes. See docs/context-opengl.md.`;
|
|
55
|
+
|
|
56
|
+
/** Turn a failed GLX CreateContext into an error that explains itself. */
|
|
57
|
+
export function diagnoseCreateContext(err, cfg) {
|
|
58
|
+
// BadValue with value 0 from CreateContext (minor opcode 3) is how a
|
|
59
|
+
// server without +iglx says "no indirect contexts here" — every other
|
|
60
|
+
// rejection names the value it disliked
|
|
61
|
+
if (err?.error === 2 && err.badParam === 0) {
|
|
62
|
+
return glxError(
|
|
63
|
+
GLXError.INDIRECT_DISABLED,
|
|
64
|
+
`${displayName()} has GLX but refuses to create indirect GLX contexts, so there is no OpenGL context to draw with`,
|
|
65
|
+
IGLX_HINT,
|
|
66
|
+
err
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
const visual = cfg?.visual ? ` for visual 0x${cfg.visual.toString(16)}` : '';
|
|
70
|
+
return glxError(
|
|
71
|
+
GLXError.CONTEXT_FAILED,
|
|
72
|
+
`GLX CreateContext${visual} failed: ${err?.message ?? err}`,
|
|
73
|
+
null,
|
|
74
|
+
err
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The console form of a failed GL setup: what went wrong, then how to fix
|
|
80
|
+
* it. Indented so the remedy reads as one block and not as unrelated log
|
|
81
|
+
* lines, and ending with the way to take over the handling.
|
|
82
|
+
*/
|
|
83
|
+
export function glxErrorReport(err) {
|
|
84
|
+
const lines = [`ntk: getContext('opengl') failed — ${err?.message ?? err}`];
|
|
85
|
+
if (err?.hint) lines.push('', ...err.hint.split('\n').map((l) => (l ? ` ${l}` : l)));
|
|
86
|
+
lines.push('', ` (await gl.ready to handle this yourself; err.code is ${err?.code ?? 'unset'})`);
|
|
87
|
+
return lines.join('\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Create a GLX context and find out whether it worked.
|
|
92
|
+
*
|
|
93
|
+
* CreateContext has no reply, so its failure would otherwise surface as an
|
|
94
|
+
* unattributed X error plus a misleading GLXBadContext from the MakeCurrent
|
|
95
|
+
* that follows. node-x11 >= 3.8 takes a callback here; older versions
|
|
96
|
+
* cannot report it at all, and this resolves optimistically rather than
|
|
97
|
+
* hanging.
|
|
98
|
+
*/
|
|
99
|
+
export function createContext(GLX, ctx, cfg) {
|
|
100
|
+
if (GLX.CreateContext.length < 6) {
|
|
101
|
+
GLX.CreateContext(ctx, cfg.visual, cfg.screen ?? 0, 0, 0);
|
|
102
|
+
return Promise.resolve();
|
|
103
|
+
}
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
GLX.CreateContext(ctx, cfg.visual, cfg.screen ?? 0, 0, 0, (err) => {
|
|
106
|
+
if (err) reject(diagnoseCreateContext(err, cfg));
|
|
107
|
+
else resolve();
|
|
108
|
+
return true; // handled here; keep it away from the app's onXError
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
8
113
|
// defaults applied on top of the caller's spec; `null` means "don't care"
|
|
9
114
|
const DEFAULT_SPEC = {
|
|
10
115
|
DOUBLEBUFFER: true,
|
|
@@ -120,8 +225,12 @@ function matchLegacy(configs, spec) {
|
|
|
120
225
|
export async function chooseGLXConfig(app, spec = {}) {
|
|
121
226
|
const GLX = app.display.GLX;
|
|
122
227
|
if (!GLX) {
|
|
123
|
-
throw
|
|
124
|
-
|
|
228
|
+
throw glxError(
|
|
229
|
+
GLXError.NO_EXTENSION,
|
|
230
|
+
`${displayName()} does not have the GLX extension, so it cannot do OpenGL at all`,
|
|
231
|
+
'A server built without GLX, or started with -extension GLX. This is a\n' +
|
|
232
|
+
'different problem from indirect GLX being disabled, which is the usual\n' +
|
|
233
|
+
'one — there the extension is present and CreateContext is what fails.'
|
|
125
234
|
);
|
|
126
235
|
}
|
|
127
236
|
|
|
@@ -135,7 +244,11 @@ export async function chooseGLXConfig(app, spec = {}) {
|
|
|
135
244
|
|
|
136
245
|
if (want.visual) {
|
|
137
246
|
const info = visualInfo(screen, want.visual);
|
|
138
|
-
if (!info)
|
|
247
|
+
if (!info)
|
|
248
|
+
throw glxError(
|
|
249
|
+
GLXError.NO_CONFIG,
|
|
250
|
+
`GLX: visual 0x${want.visual.toString(16)} is not on screen ${screenNum}`
|
|
251
|
+
);
|
|
139
252
|
return {
|
|
140
253
|
visual: want.visual,
|
|
141
254
|
depth: info.depth,
|
|
@@ -187,8 +300,10 @@ export async function chooseGLXConfig(app, spec = {}) {
|
|
|
187
300
|
const legacyBest = pick(fromVisuals);
|
|
188
301
|
if (legacyBest) return legacyBest;
|
|
189
302
|
|
|
190
|
-
throw
|
|
191
|
-
|
|
303
|
+
throw glxError(
|
|
304
|
+
GLXError.NO_CONFIG,
|
|
305
|
+
`GLX: no visual on screen ${screenNum} matches ${describe(want)} (server offered ${legacy.length} GLX visuals)`,
|
|
306
|
+
'Relax the spec — DEPTH_SIZE and DOUBLEBUFFER are the usual culprits.'
|
|
192
307
|
);
|
|
193
308
|
}
|
|
194
309
|
|
package/lib/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import x11 from 'x11';
|
|
|
3
3
|
import App from './app.js';
|
|
4
4
|
import Clipboard from './clipboard.js';
|
|
5
5
|
import { BLANK_CURSOR, CursorCache, cursorNames, cursorShapes, resolveCursorShape } from './cursor.js';
|
|
6
|
+
import { GLXError } from './glx.js';
|
|
6
7
|
import Window from './window.js';
|
|
7
8
|
import { loadLayout } from './yoga.js';
|
|
8
9
|
import { decodeKey, groupForState } from './keyboard.js';
|
|
@@ -227,7 +228,9 @@ export {
|
|
|
227
228
|
premultiply,
|
|
228
229
|
cssLength,
|
|
229
230
|
decodeKey,
|
|
230
|
-
groupForState
|
|
231
|
+
groupForState,
|
|
232
|
+
// the `code` on a failed GL setup: branch on it rather than on the message
|
|
233
|
+
GLXError
|
|
231
234
|
};
|
|
232
235
|
// The layout engine ntk lays HtmlView out with — downstream layout consumers
|
|
233
236
|
// (e.g. the react-x11 renderer) must import it from here rather than from
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import Drawable from './drawable.js';
|
|
2
|
-
import { chooseGLXConfig } from './glx.js';
|
|
2
|
+
import { GLXError, chooseGLXConfig, createContext, glxErrorReport } from './glx.js';
|
|
3
3
|
|
|
4
4
|
// Indirect GLX rendering context exposing an OpenGL 1.4-style api.
|
|
5
5
|
// Note: many modern X servers ship with indirect GLX disabled — see README.
|
|
@@ -14,9 +14,11 @@ class RenderingContextOpenGL {
|
|
|
14
14
|
const X = window.X;
|
|
15
15
|
const GLX = window.display.GLX;
|
|
16
16
|
if (!GLX) {
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
const err = new Error(
|
|
18
|
+
`getContext('opengl'): ${process.env.DISPLAY || 'the X server'} does not have the GLX extension, so it cannot do OpenGL at all`
|
|
19
19
|
);
|
|
20
|
+
err.code = GLXError.NO_EXTENSION;
|
|
21
|
+
throw err;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
this.window = window;
|
|
@@ -82,9 +84,21 @@ class RenderingContextOpenGL {
|
|
|
82
84
|
|
|
83
85
|
const ctx = X.AllocID();
|
|
84
86
|
this.contextId = ctx;
|
|
85
|
-
|
|
87
|
+
// CreateContext first, and on its own: it is the request a server with
|
|
88
|
+
// indirect GLX disabled rejects, and going on to MakeCurrent would bury
|
|
89
|
+
// that under a GLXBadContext about the context that was never created
|
|
90
|
+
await createContext(GLX, ctx, cfg).catch((err) => {
|
|
91
|
+
this.contextId = 0; // nothing to destroy
|
|
92
|
+
throw err;
|
|
93
|
+
});
|
|
86
94
|
const tag = await new Promise((resolve, reject) => {
|
|
87
|
-
GLX.MakeCurrent(window.id, ctx, 0, (err, contextTag) =>
|
|
95
|
+
GLX.MakeCurrent(window.id, ctx, 0, (err, contextTag) => {
|
|
96
|
+
if (!err) return resolve(contextTag);
|
|
97
|
+
const failed = new Error(`GLX MakeCurrent failed: ${err.message}`);
|
|
98
|
+
failed.code = GLXError.CONTEXT_FAILED;
|
|
99
|
+
failed.cause = err;
|
|
100
|
+
reject(failed);
|
|
101
|
+
});
|
|
88
102
|
});
|
|
89
103
|
|
|
90
104
|
this.contextTag = tag;
|
|
@@ -97,9 +111,7 @@ class RenderingContextOpenGL {
|
|
|
97
111
|
this.ready.catch((err) => {
|
|
98
112
|
this.error = err;
|
|
99
113
|
queue.length = 0;
|
|
100
|
-
console.warn(
|
|
101
|
-
`ntk: getContext('opengl') failed: ${err.message} (await gl.ready to handle this yourself)`
|
|
102
|
-
);
|
|
114
|
+
console.warn(glxErrorReport(err));
|
|
103
115
|
});
|
|
104
116
|
}
|
|
105
117
|
|
package/lib/window.js
CHANGED
|
@@ -355,6 +355,20 @@ export default class Window extends Drawable {
|
|
|
355
355
|
// (see docs/window.md "Frames, coalescing and slow connections")
|
|
356
356
|
this._coalesce = args.coalesceEvents !== false;
|
|
357
357
|
this._frameSyncEnabled = args.frameSync !== false;
|
|
358
|
+
// _NET_WM_SYNC_REQUEST (see enableSyncRequest): the counter the window
|
|
359
|
+
// manager watches, the value it last asked for, and the watchdog that
|
|
360
|
+
// guarantees an answer even when nothing repaints
|
|
361
|
+
this._syncCounter = null;
|
|
362
|
+
this._syncRequestAtom = 0;
|
|
363
|
+
this._syncPending = null;
|
|
364
|
+
this._syncWatchdog = null;
|
|
365
|
+
// Present-based blits (see enablePresent): the extension objects, the
|
|
366
|
+
// XFixes region reused as the update region, and the serial the server
|
|
367
|
+
// echoes back in its completion events
|
|
368
|
+
this._presentExt = null;
|
|
369
|
+
this._fixesExt = null;
|
|
370
|
+
this._updateRegion = 0;
|
|
371
|
+
this._presentSerial = 0;
|
|
358
372
|
this.frameInterval = args.frameInterval ?? 16;
|
|
359
373
|
this.frameLatency = null;
|
|
360
374
|
this._frame = {
|
|
@@ -364,7 +378,12 @@ export default class Window extends Drawable {
|
|
|
364
378
|
inFlight: false, // fence round-trip awaiting the server's reply
|
|
365
379
|
timer: null,
|
|
366
380
|
scheduled: false,
|
|
367
|
-
needsRedraw: false
|
|
381
|
+
needsRedraw: false,
|
|
382
|
+
// minimum inter-blit pacing: when the last blit went out, and the timer
|
|
383
|
+
// that guarantees a blit held back by the interval still lands.
|
|
384
|
+
// -Infinity so the first blit after construction is never delayed.
|
|
385
|
+
lastPresentAt: -Infinity,
|
|
386
|
+
presentTimer: null
|
|
368
387
|
};
|
|
369
388
|
|
|
370
389
|
if (!args.id) {
|
|
@@ -482,6 +501,17 @@ export default class Window extends Drawable {
|
|
|
482
501
|
if (args.alwaysOnTop) {
|
|
483
502
|
this.setAlwaysOnTop(true);
|
|
484
503
|
}
|
|
504
|
+
if (args.syncRequest && !args.id) {
|
|
505
|
+
// fire and forget: the window manager only reads the counter when it
|
|
506
|
+
// starts managing the window, and map() is the caller's next line at
|
|
507
|
+
// the earliest. `await wnd.enableSyncRequest()` when that is too close.
|
|
508
|
+
this.enableSyncRequest().catch((err) => this.app.options.onXError?.(err));
|
|
509
|
+
}
|
|
510
|
+
if (args.present && !args.id) {
|
|
511
|
+
// fire and forget: until the extensions answer, blits use CopyArea,
|
|
512
|
+
// which is what they would have done anyway
|
|
513
|
+
this.enablePresent().catch((err) => this.app.options.onXError?.(err));
|
|
514
|
+
}
|
|
485
515
|
|
|
486
516
|
X.event_consumers[this.id] = this;
|
|
487
517
|
this.on('event', (ev) => {
|
|
@@ -537,7 +567,10 @@ export default class Window extends Drawable {
|
|
|
537
567
|
this.emit(eventName, ntkev);
|
|
538
568
|
// a WM_DELETE_WINDOW ClientMessage is the window manager *asking*, and
|
|
539
569
|
// 'close' is that question in a form an application can answer
|
|
540
|
-
if (eventName === 'message')
|
|
570
|
+
if (eventName === 'message') {
|
|
571
|
+
this._emitCloseRequest(ntkev);
|
|
572
|
+
this._handleSyncRequest(ntkev);
|
|
573
|
+
}
|
|
541
574
|
// anything drawn during the handlers becomes visible in one blit
|
|
542
575
|
if (this._dirty) this._present();
|
|
543
576
|
});
|
|
@@ -812,8 +845,13 @@ export default class Window extends Drawable {
|
|
|
812
845
|
* - a timer: at most one paced frame per `frameInterval` ms, so a fast
|
|
813
846
|
* local server doesn't get redraws at input-device rate.
|
|
814
847
|
*
|
|
815
|
-
* Discrete events (mousedown, keydown, ...) bypass the timer for latency
|
|
816
|
-
*
|
|
848
|
+
* Discrete events (mousedown, keydown, ...) bypass the timer for latency:
|
|
849
|
+
* the first blit after a quiet moment goes out with the handler's own
|
|
850
|
+
* requests. Their blits are still bounded, by the fence and by a minimum
|
|
851
|
+
* inter-blit interval (also `frameInterval`) — see _present(). Without that
|
|
852
|
+
* interval a stream of discrete events blits at round-trip rate, which on a
|
|
853
|
+
* local server is several hundred per second, and under a compositor every
|
|
854
|
+
* blit is a recomposite.
|
|
817
855
|
*/
|
|
818
856
|
_scheduleFrame() {
|
|
819
857
|
const f = this._frame;
|
|
@@ -831,7 +869,15 @@ export default class Window extends Drawable {
|
|
|
831
869
|
|
|
832
870
|
_runFrame() {
|
|
833
871
|
const f = this._frame;
|
|
834
|
-
if (
|
|
872
|
+
if (
|
|
873
|
+
!f.pending.size &&
|
|
874
|
+
!f.rafCbs.length &&
|
|
875
|
+
!f.needsRedraw &&
|
|
876
|
+
!this._dirty &&
|
|
877
|
+
!this._presentPending &&
|
|
878
|
+
this._syncPending == null
|
|
879
|
+
)
|
|
880
|
+
return;
|
|
835
881
|
this._flushCoalesced();
|
|
836
882
|
if (f.needsRedraw) {
|
|
837
883
|
f.needsRedraw = false;
|
|
@@ -854,6 +900,9 @@ export default class Window extends Drawable {
|
|
|
854
900
|
for (const entry of cbs) entry.cb(now);
|
|
855
901
|
}
|
|
856
902
|
if (this._dirty || this._presentPending) this._presentNow();
|
|
903
|
+
// covers a window with no backing store, and a resize that changed
|
|
904
|
+
// nothing: _presentNow did not run, but the frame is still "handled"
|
|
905
|
+
this._ackSyncRequest();
|
|
857
906
|
this._armFence();
|
|
858
907
|
this._armTimer();
|
|
859
908
|
}
|
|
@@ -952,9 +1001,10 @@ export default class Window extends Drawable {
|
|
|
952
1001
|
f.inFlight = false;
|
|
953
1002
|
this.frameLatency = performance.now() - start;
|
|
954
1003
|
if (this._presentPending) {
|
|
955
|
-
// a blit deferred while the fence was in flight: show it
|
|
956
|
-
|
|
957
|
-
|
|
1004
|
+
// a blit deferred while the fence was in flight: show it as soon as
|
|
1005
|
+
// the minimum inter-blit interval allows (immediately when the last
|
|
1006
|
+
// blit is already older than that, which is the common case)
|
|
1007
|
+
this._present();
|
|
958
1008
|
}
|
|
959
1009
|
if ((f.pending.size || f.rafCbs.length || f.needsRedraw) && !f.timer) this._scheduleFrame();
|
|
960
1010
|
});
|
|
@@ -978,6 +1028,36 @@ export default class Window extends Drawable {
|
|
|
978
1028
|
clearTimeout(f.timer);
|
|
979
1029
|
f.timer = null;
|
|
980
1030
|
}
|
|
1031
|
+
if (f.presentTimer) {
|
|
1032
|
+
clearTimeout(f.presentTimer);
|
|
1033
|
+
f.presentTimer = null;
|
|
1034
|
+
}
|
|
1035
|
+
if (this._syncWatchdog) {
|
|
1036
|
+
clearTimeout(this._syncWatchdog);
|
|
1037
|
+
this._syncWatchdog = null;
|
|
1038
|
+
}
|
|
1039
|
+
// Destroying the counter releases any Await the window manager is
|
|
1040
|
+
// blocked in, so a window that goes away mid-drag does not strand it.
|
|
1041
|
+
if (this._syncCounter && this._sync) {
|
|
1042
|
+
const counter = this._syncCounter;
|
|
1043
|
+
this._syncCounter = null;
|
|
1044
|
+
this._syncPending = null;
|
|
1045
|
+
safeRelease(this.X, () => {
|
|
1046
|
+
this._sync.DestroyCounter(counter);
|
|
1047
|
+
this.X.ReleaseID(counter);
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
if (this._updateRegion && this._fixesExt) {
|
|
1051
|
+
const region = this._updateRegion;
|
|
1052
|
+
this._updateRegion = 0;
|
|
1053
|
+
this._presentExt = null;
|
|
1054
|
+
const fixes = this._fixesExt;
|
|
1055
|
+
this._fixesExt = null;
|
|
1056
|
+
safeRelease(this.X, () => {
|
|
1057
|
+
fixes.DestroyRegion(region);
|
|
1058
|
+
this.X.ReleaseID(region);
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
981
1061
|
f.pending.clear();
|
|
982
1062
|
f.rafCbs = [];
|
|
983
1063
|
f.needsRedraw = false;
|
|
@@ -1014,9 +1094,11 @@ export default class Window extends Drawable {
|
|
|
1014
1094
|
* Drawing the response inside the event handler costs a frame less latency:
|
|
1015
1095
|
* the blit goes out with the handler's own requests (see the `_present()`
|
|
1016
1096
|
* at the end of the event dispatch) instead of waiting for the next paced
|
|
1017
|
-
* frame
|
|
1018
|
-
*
|
|
1019
|
-
*
|
|
1097
|
+
* frame — provided the last blit is at least `frameInterval` old, which is
|
|
1098
|
+
* what a discrete input arriving out of the blue always is. Drawing it while
|
|
1099
|
+
* a frame is in flight costs a frame's *work* for nothing: the present is
|
|
1100
|
+
* deferred until the ack and coalesces with the one already pending, so only
|
|
1101
|
+
* the last paint is ever seen. So `false`
|
|
1020
1102
|
* means "draw it now", `true` means "leave it to the frame clock" — which
|
|
1021
1103
|
* is how a burst of discrete events (a spun wheel) paints its first notch
|
|
1022
1104
|
* immediately and folds the rest into one catch-up frame.
|
|
@@ -1031,15 +1113,129 @@ export default class Window extends Drawable {
|
|
|
1031
1113
|
|
|
1032
1114
|
_present() {
|
|
1033
1115
|
if (!this._backing) return;
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1116
|
+
const wait = this._presentWait();
|
|
1117
|
+
if (this._frame.inFlight || wait > 0) {
|
|
1118
|
+
// the server hasn't confirmed the previous frame yet, or the last blit
|
|
1119
|
+
// was too recent — defer, and leave a wakeup behind
|
|
1120
|
+
this._deferPresent(wait);
|
|
1037
1121
|
return;
|
|
1038
1122
|
}
|
|
1039
1123
|
this._presentNow();
|
|
1040
1124
|
this._armFence();
|
|
1041
1125
|
}
|
|
1042
1126
|
|
|
1127
|
+
/**
|
|
1128
|
+
* How long until the next blit may go out; 0 when it may go now.
|
|
1129
|
+
*
|
|
1130
|
+
* The gate is `frameInterval`, the same knob that paces frames, so
|
|
1131
|
+
* `frameInterval: 0` keeps the old fence-only behaviour — a caller who asked
|
|
1132
|
+
* for no timer gate gets none here either.
|
|
1133
|
+
*/
|
|
1134
|
+
_presentWait() {
|
|
1135
|
+
const min = this.frameInterval;
|
|
1136
|
+
if (!(min > 0)) return 0;
|
|
1137
|
+
const since = performance.now() - this._frame.lastPresentAt;
|
|
1138
|
+
return since >= min ? 0 : min - since;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Hold a blit back, and guarantee it still happens.
|
|
1143
|
+
*
|
|
1144
|
+
* This is the only place `_presentPending` is set, because every deferral has
|
|
1145
|
+
* to leave a wakeup behind: the fence reply when the fence is what we are
|
|
1146
|
+
* waiting on, this timer when the interval is. Nothing else reschedules a
|
|
1147
|
+
* present — `_scheduleFrame()` bails while a frame timer is armed, and
|
|
1148
|
+
* neither reschedule condition (in `_armFence`'s reply or `_armTimer`'s
|
|
1149
|
+
* callback) looks at the present. Without the timer the last blit of a burst
|
|
1150
|
+
* would simply never go out, leaving stale pixels on screen.
|
|
1151
|
+
*/
|
|
1152
|
+
_deferPresent(wait) {
|
|
1153
|
+
this._presentPending = true;
|
|
1154
|
+
if (wait <= 0) return; // waiting on the fence: its reply blits this
|
|
1155
|
+
const f = this._frame;
|
|
1156
|
+
if (f.presentTimer) return; // already armed
|
|
1157
|
+
f.presentTimer = setTimeout(() => {
|
|
1158
|
+
f.presentTimer = null;
|
|
1159
|
+
if (!this._presentPending && !this._dirty) return; // someone blitted it
|
|
1160
|
+
const again = this._presentWait();
|
|
1161
|
+
if (again > 0) return this._deferPresent(again); // a paced frame re-stamped
|
|
1162
|
+
if (f.inFlight) return; // the fence ack is closer; it will blit
|
|
1163
|
+
this._presentNow();
|
|
1164
|
+
this._armFence();
|
|
1165
|
+
}, wait);
|
|
1166
|
+
if (typeof f.presentTimer.unref === 'function') f.presentTimer.unref();
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* Blit with the Present extension instead of `CopyArea`.
|
|
1171
|
+
*
|
|
1172
|
+
* A frame's dirty rectangles become one `PresentPixmap` with an update
|
|
1173
|
+
* region, in place of one `CopyArea` per rectangle. Two things follow:
|
|
1174
|
+
* a frame is a fixed two requests however fragmented the damage is, and
|
|
1175
|
+
* `_blitList`'s bounding-box collapse becomes unnecessary — its whole
|
|
1176
|
+
* premise is "each rectangle costs a request", so with Present the exact
|
|
1177
|
+
* rectangles are sent and pixels outside them are never touched.
|
|
1178
|
+
*
|
|
1179
|
+
* Presents are also scheduled by the server against the display's refresh
|
|
1180
|
+
* rather than executed on arrival, so a burst cannot produce more updates
|
|
1181
|
+
* than the output can show; the server drops superseded frames itself.
|
|
1182
|
+
*
|
|
1183
|
+
* Worth being precise about what this does *not* do: under a compositing
|
|
1184
|
+
* manager the window is redirected, so this schedules the server's copy
|
|
1185
|
+
* into the redirect pixmap — the compositor still composites on its own
|
|
1186
|
+
* schedule. Aligning with *that* is what `_NET_WM_SYNC_REQUEST`'s extended
|
|
1187
|
+
* counters are for.
|
|
1188
|
+
*
|
|
1189
|
+
* Opt-in via `createWindow({ present: true })`, and inert unless both
|
|
1190
|
+
* Present and XFixes are available — blits fall back to `CopyArea`, which
|
|
1191
|
+
* stays correct at all times, so the two paths can even alternate.
|
|
1192
|
+
*
|
|
1193
|
+
* @returns {Promise<Window>}
|
|
1194
|
+
*/
|
|
1195
|
+
enablePresent() {
|
|
1196
|
+
if (this._presentExt || this._destroyed) return Promise.resolve(this);
|
|
1197
|
+
const X = this.X;
|
|
1198
|
+
const need = (name) =>
|
|
1199
|
+
new Promise((resolve) => X.require(name, (err, ext) => resolve(err ? null : ext)));
|
|
1200
|
+
return Promise.all([need('present'), need('fixes')]).then(([present, fixes]) => {
|
|
1201
|
+
if (!present || !fixes || this._destroyed) return this; // stay on CopyArea
|
|
1202
|
+
this._updateRegion = X.AllocID();
|
|
1203
|
+
safeRelease(X, () => fixes.CreateRegion(this._updateRegion, []));
|
|
1204
|
+
this._presentExt = present;
|
|
1205
|
+
this._fixesExt = fixes;
|
|
1206
|
+
return this;
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* The Present form of the blit. Returns false when it did not happen, so
|
|
1212
|
+
* the caller falls back to CopyArea — which is also what runs before the
|
|
1213
|
+
* extensions have answered.
|
|
1214
|
+
*/
|
|
1215
|
+
_presentWithExtension(rects) {
|
|
1216
|
+
const P = this._presentExt;
|
|
1217
|
+
if (!P || !this._fixesExt || !this._updateRegion) return false;
|
|
1218
|
+
safeRelease(this.X, () => {
|
|
1219
|
+
this._fixesExt.SetRegion(
|
|
1220
|
+
this._updateRegion,
|
|
1221
|
+
rects.map((r) => ({ x: r.x, y: r.y, width: r.w, height: r.h }))
|
|
1222
|
+
);
|
|
1223
|
+
this._presentExt.Pixmap(this.id, this._backing.id, {
|
|
1224
|
+
serial: ++this._presentSerial,
|
|
1225
|
+
update: this._updateRegion,
|
|
1226
|
+
// Option.Copy forces the copy path. Without it the server may *flip*
|
|
1227
|
+
// the pixmap to the screen and take ownership of it until an
|
|
1228
|
+
// IdleNotify — and ntk reuses one grow-only backing pixmap, so
|
|
1229
|
+
// drawing into it while the server owned it would paint the screen
|
|
1230
|
+
// directly. Removing this needs a swap chain, not just a smaller diff.
|
|
1231
|
+
options: P.Option.Copy
|
|
1232
|
+
// targetMsc stays 0: "the next vblank", which is what we want. An
|
|
1233
|
+
// explicit target only adds ways to fall behind.
|
|
1234
|
+
});
|
|
1235
|
+
});
|
|
1236
|
+
return true;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1043
1239
|
_presentNow() {
|
|
1044
1240
|
this._presentPending = false;
|
|
1045
1241
|
if (!this._backing) return;
|
|
@@ -1061,13 +1257,27 @@ export default class Window extends Drawable {
|
|
|
1061
1257
|
if (x1 > x0 && y1 > y0) clamped.push({ x: x0, y: y0, w: x1 - x0, h: y1 - y0 });
|
|
1062
1258
|
}
|
|
1063
1259
|
if (!clamped.length) return;
|
|
1064
|
-
|
|
1065
|
-
//
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1260
|
+
// Stamped here rather than at the call sites so it is a true inter-blit
|
|
1261
|
+
// interval across all three of them, and only when pixels really move — a
|
|
1262
|
+
// present that copies nothing must not push the next one out.
|
|
1263
|
+
this._frame.lastPresentAt = performance.now();
|
|
1264
|
+
// Present sends the exact rectangles in one request, so the bounding-box
|
|
1265
|
+
// collapse — which trades pixels for fewer requests — has nothing to buy
|
|
1266
|
+
if (!this._presentWithExtension(clamped)) {
|
|
1267
|
+
const rects = this._blitList(clamped);
|
|
1268
|
+
// a paced frame can fire after the connection started closing
|
|
1269
|
+
safeRelease(this.X, () => {
|
|
1270
|
+
for (const r of rects) {
|
|
1271
|
+
this.X.CopyArea(this._backing.id, this.id, this._presentGc, r.x, r.y, r.x, r.y, r.w, r.h);
|
|
1272
|
+
}
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
// Queued behind whichever of the two put the pixels on their way, so the
|
|
1276
|
+
// window manager hears about the new size only once the server has drawn
|
|
1277
|
+
// it. Both paths must reach this: a window using Present *and*
|
|
1278
|
+
// _NET_WM_SYNC_REQUEST would otherwise never answer, and the resize would
|
|
1279
|
+
// stall.
|
|
1280
|
+
this._ackSyncRequest();
|
|
1071
1281
|
}
|
|
1072
1282
|
|
|
1073
1283
|
/**
|
|
@@ -1504,6 +1714,10 @@ export default class Window extends Drawable {
|
|
|
1504
1714
|
*
|
|
1505
1715
|
* `WM_DELETE_WINDOW` needs none of this by hand: listening for the
|
|
1506
1716
|
* `'close'` event adds the protocol and decodes the message for you.
|
|
1717
|
+
* `_NET_WM_SYNC_REQUEST` likewise has a real opt-in — `enableSyncRequest()`
|
|
1718
|
+
* / `createWindow({ syncRequest: true })`. Adding that atom here instead
|
|
1719
|
+
* advertises a protocol this window cannot answer, which is worse than not
|
|
1720
|
+
* advertising it: the counter the window manager looks for is missing.
|
|
1507
1721
|
*
|
|
1508
1722
|
* Replaces the whole list. `addProtocol`/`removeProtocol` are the
|
|
1509
1723
|
* accumulating forms, and the ones to reach for: the property is a *set*,
|
|
@@ -2537,6 +2751,116 @@ export default class Window extends Drawable {
|
|
|
2537
2751
|
* existed — keeps behaving exactly as it did, rather than suddenly
|
|
2538
2752
|
* acquiring a second handler that destroys it.
|
|
2539
2753
|
*/
|
|
2754
|
+
/**
|
|
2755
|
+
* Opt into `_NET_WM_SYNC_REQUEST` (EWMH §6.2): let the window manager pace
|
|
2756
|
+
* an interactive resize to how fast this window actually repaints.
|
|
2757
|
+
*
|
|
2758
|
+
* Without it a WM has no idea when a resize has been drawn, so it either
|
|
2759
|
+
* throws ConfigureNotify at the client as fast as the pointer moves — and
|
|
2760
|
+
* the window lags behind the frame the user is dragging — or guesses with a
|
|
2761
|
+
* timer. With it, the WM sends a serial before each resize and waits for
|
|
2762
|
+
* this window to echo it back once the new size is on screen.
|
|
2763
|
+
*
|
|
2764
|
+
* Also available as `createWindow({ syncRequest: true })`. This is the
|
|
2765
|
+
* awaitable form, for when `map()` follows immediately: the counter and the
|
|
2766
|
+
* `_NET_WM_SYNC_REQUEST_COUNTER` property have to exist before the window
|
|
2767
|
+
* leaves the withdrawn state, because that is when the WM reads them.
|
|
2768
|
+
*
|
|
2769
|
+
* Silently does nothing when the server has no SYNC extension — a WM that
|
|
2770
|
+
* finds no counter simply drives the resize the old way.
|
|
2771
|
+
*
|
|
2772
|
+
* Only basic (single-counter) synchronization is implemented. The extended
|
|
2773
|
+
* two-counter form buys frame-timing feedback rather than resize pacing, and
|
|
2774
|
+
* its odd/even parity rules freeze the window if they are ever got wrong; a
|
|
2775
|
+
* compositor that supports it falls back to basic mode on its own when the
|
|
2776
|
+
* property holds one counter.
|
|
2777
|
+
*
|
|
2778
|
+
* @returns {Promise<Window>}
|
|
2779
|
+
*/
|
|
2780
|
+
enableSyncRequest() {
|
|
2781
|
+
if (this._syncCounter || this._destroyed) return Promise.resolve(this);
|
|
2782
|
+
const X = this.X;
|
|
2783
|
+
return new Promise((resolve, reject) => {
|
|
2784
|
+
X.require('sync', (err, Sync) => {
|
|
2785
|
+
if (err || !Sync || this._destroyed) return resolve(this); // no SYNC: stay quiet
|
|
2786
|
+
this._sync = Sync;
|
|
2787
|
+
const counter = X.AllocID();
|
|
2788
|
+
safeRelease(X, () => Sync.CreateCounter(counter, 0));
|
|
2789
|
+
this._syncCounter = counter;
|
|
2790
|
+
// The dispatch path compares against both atoms, and node-x11 caches
|
|
2791
|
+
// interned atoms per connection, so this is one round trip and no more.
|
|
2792
|
+
this.atom('WM_PROTOCOLS').catch(() => {});
|
|
2793
|
+
this._withAtoms(['_NET_WM_SYNC_REQUEST_COUNTER', '_NET_WM_SYNC_REQUEST'], (atoms) => {
|
|
2794
|
+
this._syncRequestAtom = atoms._NET_WM_SYNC_REQUEST;
|
|
2795
|
+
safeRelease(X, () =>
|
|
2796
|
+
X.ChangeProperty(
|
|
2797
|
+
0,
|
|
2798
|
+
this.id,
|
|
2799
|
+
atoms._NET_WM_SYNC_REQUEST_COUNTER,
|
|
2800
|
+
X.atoms.CARDINAL,
|
|
2801
|
+
32,
|
|
2802
|
+
[counter]
|
|
2803
|
+
)
|
|
2804
|
+
);
|
|
2805
|
+
// property first, protocol second: a window manager that sees the
|
|
2806
|
+
// protocol advertised must always find the counter behind it
|
|
2807
|
+
this.addProtocol('_NET_WM_SYNC_REQUEST').then(() => resolve(this), reject);
|
|
2808
|
+
});
|
|
2809
|
+
});
|
|
2810
|
+
});
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
/**
|
|
2814
|
+
* The window manager asking us to report when a resize has been painted.
|
|
2815
|
+
*
|
|
2816
|
+
* Only the value is recorded here — acknowledging now would claim a frame
|
|
2817
|
+
* that has not been drawn. `_ackSyncRequest` sends it once the pixels are on
|
|
2818
|
+
* their way. EWMH is explicit that only the *last* message is acknowledged,
|
|
2819
|
+
* so a newer request simply overwrites an older one.
|
|
2820
|
+
*/
|
|
2821
|
+
_handleSyncRequest(ev) {
|
|
2822
|
+
if (!this._syncCounter || ev.format !== 32) return;
|
|
2823
|
+
const X = this.X;
|
|
2824
|
+
if (!X.atoms.WM_PROTOCOLS || ev.message_type !== X.atoms.WM_PROTOCOLS) return;
|
|
2825
|
+
if (!this._syncRequestAtom || ev.data?.[0] !== this._syncRequestAtom) return;
|
|
2826
|
+
// data[2] is the low half of the 64-bit request number, data[3] the high
|
|
2827
|
+
this._syncPending = ev.data[3] * 0x100000000 + ev.data[2];
|
|
2828
|
+
// Not every request leads to a repaint — a move, or a resize to the size
|
|
2829
|
+
// we already are, leaves nothing dirty and no frame scheduled. The
|
|
2830
|
+
// watchdog turns a missed acknowledgement into a stutter instead of a
|
|
2831
|
+
// window manager that waits forever.
|
|
2832
|
+
this._armSyncWatchdog();
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
_armSyncWatchdog() {
|
|
2836
|
+
if (this._syncWatchdog || this._syncPending == null) return;
|
|
2837
|
+
this._syncWatchdog = setTimeout(() => {
|
|
2838
|
+
this._syncWatchdog = null;
|
|
2839
|
+
this._ackSyncRequest();
|
|
2840
|
+
}, Math.max(this.frameInterval, 16) * 2);
|
|
2841
|
+
if (typeof this._syncWatchdog.unref === 'function') this._syncWatchdog.unref();
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
/**
|
|
2845
|
+
* Answer the window manager's last sync request.
|
|
2846
|
+
*
|
|
2847
|
+
* Called after the requests that repaint have been queued, never before: X
|
|
2848
|
+
* runs a client's requests in order, so a SetCounter queued behind the
|
|
2849
|
+
* copies is executed behind them too, which is exactly the "having handled
|
|
2850
|
+
* all repainting" the spec asks for. (It rides the same output batch, which
|
|
2851
|
+
* is flushed before the event loop polls — no manual flush needed.)
|
|
2852
|
+
*/
|
|
2853
|
+
_ackSyncRequest() {
|
|
2854
|
+
const value = this._syncPending;
|
|
2855
|
+
if (value == null || !this._syncCounter || !this._sync) return;
|
|
2856
|
+
this._syncPending = null;
|
|
2857
|
+
if (this._syncWatchdog) {
|
|
2858
|
+
clearTimeout(this._syncWatchdog);
|
|
2859
|
+
this._syncWatchdog = null;
|
|
2860
|
+
}
|
|
2861
|
+
safeRelease(this.X, () => this._sync.SetCounter(this._syncCounter, value));
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2540
2864
|
_emitCloseRequest(ev) {
|
|
2541
2865
|
if (ev.format !== 32 || !this.listenerCount('close')) return;
|
|
2542
2866
|
// node-x11 caches interned atoms per connection, so these are the ids
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ntk",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.4.0",
|
|
4
4
|
"description": "Desktop UI toolkit for X11 with canvas-like 2d and OpenGL rendering",
|
|
5
5
|
"author": "Andrey Sidorov <sidorares@yandex.ru>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"parse-color": "^1.0.0",
|
|
49
49
|
"pngjs": "^7.0.0",
|
|
50
50
|
"postcss": "^8.5.23",
|
|
51
|
-
"x11": "^3.
|
|
51
|
+
"x11": "^3.8.0",
|
|
52
52
|
"yoga-layout": "^3.2.1"
|
|
53
53
|
},
|
|
54
54
|
"scripts": {
|