ntk 8.4.0 → 8.5.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 +17 -6
- package/lib/gl.js +16 -0
- package/lib/renderingcontext_2d.js +30 -0
- package/lib/text/fontmanager.js +80 -29
- package/lib/text/layout.js +2 -0
- package/lib/text/shape.js +4 -1
- package/lib/window.js +131 -0
- package/package.json +1 -1
package/lib/app.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { connectionGone } from './cleanup.js';
|
|
2
2
|
import Clipboard from './clipboard.js';
|
|
3
3
|
import { CursorCache } from './cursor.js';
|
|
4
|
-
import { GLError, backendFor, glCapabilities, glError, resolveGLPolicy } from './gl.js';
|
|
4
|
+
import { GLError, backendFor, glCapabilities, glError, nativeRefreshRate, resolveGLPolicy } from './gl.js';
|
|
5
5
|
import { chooseGLXConfig } from './glx.js';
|
|
6
6
|
import Picture from './picture.js';
|
|
7
7
|
import Pixmap from './pixmap.js';
|
|
@@ -254,6 +254,19 @@ export default class App {
|
|
|
254
254
|
_probeRefreshRate() {
|
|
255
255
|
this._refreshProbe = null; // in progress; only ever started once
|
|
256
256
|
const X = this.X;
|
|
257
|
+
// RandR's answer, or the native layer's where RandR has no usable one.
|
|
258
|
+
// An implausible rate is treated as no answer, not clamped: XQuartz
|
|
259
|
+
// synthesizes real timing for its canned mode list but fills the current
|
|
260
|
+
// desktop-sized mode with dot_clock = width * height — exactly 1 Hz — so
|
|
261
|
+
// the number that describes what is actually driving the panel has to
|
|
262
|
+
// come from the OS (x11-dri >= 0.6.0, apple.refreshRate()).
|
|
263
|
+
const plausible = (rate) => rate >= MIN_REFRESH_RATE && rate <= MAX_REFRESH_RATE;
|
|
264
|
+
const settle = (rate) => {
|
|
265
|
+
if (!plausible(rate)) rate = nativeRefreshRate();
|
|
266
|
+
if (!rate || !plausible(rate)) return;
|
|
267
|
+
this._refreshRate = rate;
|
|
268
|
+
this._adoptFrameInterval(1000 / rate);
|
|
269
|
+
};
|
|
257
270
|
// A connection on its way out is one more "no rate to be had". The probe
|
|
258
271
|
// is started lazily by the first window built on this connection, which
|
|
259
272
|
// may be one adopted from an event still arriving as it closes — and
|
|
@@ -261,10 +274,10 @@ export default class App {
|
|
|
261
274
|
// event dispatch has no caller to catch it (issue #321).
|
|
262
275
|
if (connectionGone(X)) return;
|
|
263
276
|
X.require('randr', (err, R) => {
|
|
264
|
-
if (err || !R) return;
|
|
277
|
+
if (err || !R) return settle(0);
|
|
265
278
|
const root = this.display.screen[0].root;
|
|
266
279
|
R.GetScreenResourcesCurrent(root, (resourcesError, resources) => {
|
|
267
|
-
if (resourcesError || !resources?.crtcs?.length) return;
|
|
280
|
+
if (resourcesError || !resources?.crtcs?.length) return settle(0);
|
|
268
281
|
const modes = new Map(resources.modeinfos.map((mode) => [mode.id, mode]));
|
|
269
282
|
let pending = resources.crtcs.length;
|
|
270
283
|
let best = 0;
|
|
@@ -273,9 +286,7 @@ export default class App {
|
|
|
273
286
|
// a crtc with no mode is one that is switched off
|
|
274
287
|
if (!crtcError && info) best = Math.max(best, modeRate(modes.get(info.mode)));
|
|
275
288
|
if (--pending) return;
|
|
276
|
-
|
|
277
|
-
this._refreshRate = best;
|
|
278
|
-
this._adoptFrameInterval(1000 / best);
|
|
289
|
+
settle(best);
|
|
279
290
|
});
|
|
280
291
|
}
|
|
281
292
|
});
|
package/lib/gl.js
CHANGED
|
@@ -171,6 +171,22 @@ export function setDriAddon(module) {
|
|
|
171
171
|
addon = module;
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
/**
|
|
175
|
+
* The display's refresh rate asked of the native layer, in Hz, for servers
|
|
176
|
+
* whose RandR carries no usable timing — XQuartz reports the current
|
|
177
|
+
* desktop-sized mode with dot_clock = width * height, exactly 1 Hz.
|
|
178
|
+
* `null` everywhere there is no answer: not darwin, no addon (or one
|
|
179
|
+
* predating 0.6.0), or a session with no display to ask.
|
|
180
|
+
*/
|
|
181
|
+
export function nativeRefreshRate() {
|
|
182
|
+
if (globalThis.process?.platform !== 'darwin') return null;
|
|
183
|
+
try {
|
|
184
|
+
return loadDriAddon()?.apple?.refreshRate?.() ?? null;
|
|
185
|
+
} catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
174
190
|
const INSTALL_HINT = `Direct rendering needs the optional native addon:
|
|
175
191
|
|
|
176
192
|
npm install x11-dri
|
|
@@ -1057,6 +1057,7 @@ class RenderingContext2d {
|
|
|
1057
1057
|
textStyle: this._textStyle,
|
|
1058
1058
|
fontString: this._lastFontString,
|
|
1059
1059
|
fontVariations: this._fontVariations,
|
|
1060
|
+
fontOpticalSizing: this._fontOpticalSizing,
|
|
1060
1061
|
textRendering: this._textRendering,
|
|
1061
1062
|
textAlign: this.textAlign,
|
|
1062
1063
|
textBaseline: this.textBaseline,
|
|
@@ -1086,6 +1087,7 @@ class RenderingContext2d {
|
|
|
1086
1087
|
this._textStyle = s.textStyle;
|
|
1087
1088
|
this._lastFontString = s.fontString;
|
|
1088
1089
|
this._fontVariations = s.fontVariations;
|
|
1090
|
+
this._fontOpticalSizing = s.fontOpticalSizing;
|
|
1089
1091
|
this._textRendering = s.textRendering;
|
|
1090
1092
|
this.textAlign = s.textAlign;
|
|
1091
1093
|
this.textBaseline = s.textBaseline;
|
|
@@ -1249,6 +1251,7 @@ class RenderingContext2d {
|
|
|
1249
1251
|
sctx._textStyle = this._textStyle;
|
|
1250
1252
|
sctx._lastFontString = this._lastFontString;
|
|
1251
1253
|
sctx._fontVariations = this._fontVariations;
|
|
1254
|
+
sctx._fontOpticalSizing = this._fontOpticalSizing;
|
|
1252
1255
|
sctx._textRendering = this._textRendering;
|
|
1253
1256
|
sctx.textAlign = this.textAlign;
|
|
1254
1257
|
sctx.textBaseline = this.textBaseline;
|
|
@@ -4029,6 +4032,7 @@ class RenderingContext2d {
|
|
|
4029
4032
|
style: parsed.style,
|
|
4030
4033
|
size: parsed.size,
|
|
4031
4034
|
variations: this._fontVariations,
|
|
4035
|
+
opticalSizing: this._fontOpticalSizing,
|
|
4032
4036
|
};
|
|
4033
4037
|
style.font = this.window.app.fonts.match(style.family, style);
|
|
4034
4038
|
this._lastFontString = val;
|
|
@@ -4061,6 +4065,32 @@ class RenderingContext2d {
|
|
|
4061
4065
|
return this._fontVariations ?? null;
|
|
4062
4066
|
}
|
|
4063
4067
|
|
|
4068
|
+
/**
|
|
4069
|
+
* CSS's `font-optical-sizing`: `'auto'` (the default) sets a face's `opsz`
|
|
4070
|
+
* axis at the size the text is drawn at, `'none'` leaves it wherever the
|
|
4071
|
+
* font file's default is.
|
|
4072
|
+
*
|
|
4073
|
+
* `'auto'` is what the CSS initial value has always been and what a
|
|
4074
|
+
* reader expects — small text set in the family's Text cut, headlines in
|
|
4075
|
+
* its Display cut — so this is the escape hatch, not the switch that
|
|
4076
|
+
* turns the feature on. Reach for it when the size the canvas is drawing
|
|
4077
|
+
* at is not the size the text is *read* at (a canvas scaled up by a
|
|
4078
|
+
* transform, glyphs measured for something else), and pin the axis with
|
|
4079
|
+
* `fontVariationSettings = { opsz: … }` when the answer is a specific
|
|
4080
|
+
* optical size rather than the file's default.
|
|
4081
|
+
*
|
|
4082
|
+
* Order-independent, like `fontVariationSettings`: setting it re-resolves
|
|
4083
|
+
* the face already in force.
|
|
4084
|
+
*/
|
|
4085
|
+
set fontOpticalSizing(val) {
|
|
4086
|
+
this._fontOpticalSizing = val === "none" ? "none" : "auto";
|
|
4087
|
+
if (this._textStyle) this.font = this._lastFontString;
|
|
4088
|
+
}
|
|
4089
|
+
|
|
4090
|
+
get fontOpticalSizing() {
|
|
4091
|
+
return this._fontOpticalSizing ?? "auto";
|
|
4092
|
+
}
|
|
4093
|
+
|
|
4064
4094
|
/**
|
|
4065
4095
|
* CSS's `text-rendering`: which glyph path this text takes, overriding the
|
|
4066
4096
|
* size thresholds in `app.textPolicy`.
|
package/lib/text/fontmanager.js
CHANGED
|
@@ -20,26 +20,69 @@ function variationsKeyOf(variations) {
|
|
|
20
20
|
.join(',');
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The optical size a style is set at, or `undefined` for "leave `opsz`
|
|
25
|
+
* alone" — see `instantiate()`.
|
|
26
|
+
*
|
|
27
|
+
* `opsz` is the one axis whose coordinate is not a design choice but a
|
|
28
|
+
* consequence of the size, so it defaults from `size` and needs an escape
|
|
29
|
+
* hatch for each way that default can be wrong:
|
|
30
|
+
*
|
|
31
|
+
* - `opticalSizing: 'none'` is CSS's `font-optical-sizing: none` — the face
|
|
32
|
+
* stays at its own default optical size;
|
|
33
|
+
* - `opticalSize` is the typographic size to use when it is not `size`.
|
|
34
|
+
* `size` on this path is CSS px, and a caller that has already multiplied
|
|
35
|
+
* by a device scale is holding device pixels: a 13px label on a 2×
|
|
36
|
+
* display arrives here as 26 and would pick a display cut. Such a caller
|
|
37
|
+
* passes the unscaled size as `opticalSize` and keeps the scaled one for
|
|
38
|
+
* the glyphs.
|
|
39
|
+
*/
|
|
40
|
+
function opticalSizeFor(style) {
|
|
41
|
+
if (!style || style.opticalSizing === 'none') return undefined;
|
|
42
|
+
const size = style.opticalSize ?? style.size;
|
|
43
|
+
return typeof size === 'number' && Number.isFinite(size) ? size : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Cache-key fragment for the optical size, `''` when the axis is left alone. */
|
|
47
|
+
function opticalKeyOf(style) {
|
|
48
|
+
return opticalSizeFor(style) ?? '';
|
|
49
|
+
}
|
|
50
|
+
|
|
23
51
|
/**
|
|
24
52
|
* Put a resolved face at the point in its design space the style asked for.
|
|
25
53
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
54
|
+
* Two axes get a coordinate from a style that never names them, because CSS
|
|
55
|
+
* says both are consequences of properties the style does name:
|
|
56
|
+
*
|
|
57
|
+
* - `weight`. `font-weight: 460` on a variable font means the `wght` axis at
|
|
58
|
+
* 460, not "the nearest face", so an app that hands ntk a variable file
|
|
59
|
+
* gets the weight it asked for without knowing an axis exists.
|
|
60
|
+
* - `size`. `font-optical-sizing: auto` has been the initial value since
|
|
61
|
+
* optical sizing was specified: `opsz` tracks `font-size` unless the
|
|
62
|
+
* author says otherwise. Without this a face is drawn at whatever optical
|
|
63
|
+
* size its file happens to default to — on macOS the only San Francisco
|
|
64
|
+
* fontconfig can see is a variable file defaulting to `opsz` 28, a
|
|
65
|
+
* *display* cut, so every 13px menu label was set in display-sized
|
|
66
|
+
* letterforms. Clamping does the rest: 13 against SF's `17..96` lands on
|
|
67
|
+
* 17, which is exactly Apple's Text end.
|
|
68
|
+
*
|
|
69
|
+
* An explicit `variations.wght` / `variations.opsz` wins in both cases,
|
|
70
|
+
* because a caller naming the axis directly is being more specific than one
|
|
71
|
+
* naming a weight or a size — the same order CSS gives
|
|
72
|
+
* `font-variation-settings` over the properties it overlaps.
|
|
33
73
|
*
|
|
34
74
|
* Everything here is a no-op for a static face: `variation()` returns the
|
|
35
75
|
* font unchanged when the settings do not apply, so this costs one property
|
|
36
76
|
* read on the path every non-variable app is already on.
|
|
37
77
|
*/
|
|
38
|
-
function instantiate(font, weight, variations) {
|
|
78
|
+
function instantiate(font, weight, variations, opticalSize) {
|
|
39
79
|
const axes = font.variationAxes;
|
|
40
|
-
if (!axes || (!axes.wght && !variations)) return font;
|
|
80
|
+
if (!axes || (!axes.wght && !axes.opsz && !variations)) return font;
|
|
41
81
|
const settings = { ...variations };
|
|
42
82
|
if (axes.wght && settings.wght === undefined) settings.wght = weight;
|
|
83
|
+
if (axes.opsz && settings.opsz === undefined && opticalSize !== undefined) {
|
|
84
|
+
settings.opsz = opticalSize;
|
|
85
|
+
}
|
|
43
86
|
return font.variation(settings);
|
|
44
87
|
}
|
|
45
88
|
|
|
@@ -151,19 +194,30 @@ export default class FontManager {
|
|
|
151
194
|
* Resolve a family (or CSS-style comma-separated family list) to a Font.
|
|
152
195
|
* Registered fonts are consulted first, then fontconfig.
|
|
153
196
|
*
|
|
197
|
+
* What is cached here is the *face* the pattern resolved to — the
|
|
198
|
+
* expensive half, since it is an fc-match and a file parse — and not the
|
|
199
|
+
* instance `instantiate()` cuts out of it. Two things follow. The map
|
|
200
|
+
* stays one entry per family/weight/style however many points of an axis
|
|
201
|
+
* an app asks for, so a slider animating `wght`, or a tree of labels at a
|
|
202
|
+
* dozen sizes, walks a face's own bounded instance cache rather than
|
|
203
|
+
* evicting other families out of this one. And `size` can drive `opsz`
|
|
204
|
+
* without joining the key: the coordinate is applied per call, so the
|
|
205
|
+
* first size asked for does not become every later one's.
|
|
206
|
+
*
|
|
154
207
|
* @param {string} family e.g. `'Ubuntu Mono', monospace`
|
|
155
|
-
* @param {object} [opts] { weight: 400|'bold'|…, style: 'normal'|'italic'
|
|
208
|
+
* @param {object} [opts] { weight: 400|'bold'|…, style: 'normal'|'italic',
|
|
209
|
+
* size, variations, opticalSize, opticalSizing: 'auto'|'none' }
|
|
156
210
|
*/
|
|
157
211
|
match(family = 'sans-serif', opts = {}) {
|
|
158
212
|
const weight = numWeight(opts.weight);
|
|
159
213
|
const italic = !!(opts.style && opts.style.includes('italic'));
|
|
160
|
-
const cacheKey = `${family}|${weight}|${italic}
|
|
161
|
-
let
|
|
162
|
-
if (
|
|
214
|
+
const cacheKey = `${family}|${weight}|${italic}`;
|
|
215
|
+
let face = this._matches.get(cacheKey);
|
|
216
|
+
if (face) {
|
|
163
217
|
// insertion order is LRU order; re-inserting a hit moves it to the tail
|
|
164
218
|
this._matches.delete(cacheKey);
|
|
165
|
-
this._matches.set(cacheKey,
|
|
166
|
-
return
|
|
219
|
+
this._matches.set(cacheKey, face);
|
|
220
|
+
return instantiate(face, weight, opts.variations, opticalSizeFor(opts));
|
|
167
221
|
}
|
|
168
222
|
|
|
169
223
|
const families = String(family)
|
|
@@ -171,26 +225,23 @@ export default class FontManager {
|
|
|
171
225
|
.map((f) => f.trim().replace(/^["']|["']$/g, ''))
|
|
172
226
|
.filter(Boolean);
|
|
173
227
|
|
|
174
|
-
|
|
228
|
+
face = this._matchRegistered(
|
|
175
229
|
families.map((f) => f.toLowerCase()),
|
|
176
230
|
weight,
|
|
177
231
|
italic
|
|
178
232
|
);
|
|
179
|
-
if (!
|
|
233
|
+
if (!face) {
|
|
180
234
|
// sources understand comma-separated family lists natively
|
|
181
235
|
const candidates = this.source.matchSorted({
|
|
182
236
|
family: families.join(','),
|
|
183
237
|
weight,
|
|
184
238
|
style: italic ? 'italic' : 'normal'
|
|
185
239
|
});
|
|
186
|
-
|
|
240
|
+
face = this._open(candidates[0]);
|
|
187
241
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
//
|
|
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.
|
|
242
|
+
this._matches.set(cacheKey, face);
|
|
243
|
+
// A long-lived app can name a lot of families. Same sweep as the shaping
|
|
244
|
+
// memo: drop the stale half in one pass rather than one entry per insert.
|
|
194
245
|
if (this._matches.size > MAX_MATCHES) {
|
|
195
246
|
let drop = this._matches.size >> 1;
|
|
196
247
|
for (const key of this._matches.keys()) {
|
|
@@ -198,7 +249,7 @@ export default class FontManager {
|
|
|
198
249
|
this._matches.delete(key);
|
|
199
250
|
}
|
|
200
251
|
}
|
|
201
|
-
return
|
|
252
|
+
return instantiate(face, weight, opts.variations, opticalSizeFor(opts));
|
|
202
253
|
}
|
|
203
254
|
|
|
204
255
|
/**
|
|
@@ -292,14 +343,14 @@ export default class FontManager {
|
|
|
292
343
|
_shapeCached(text, style, levelsKey = '0') {
|
|
293
344
|
const font = style.font;
|
|
294
345
|
// A resolved `font` already carries its coordinates in its key, so the
|
|
295
|
-
// variations
|
|
296
|
-
// points of one axis would otherwise share a
|
|
297
|
-
// would be drawn with the first's advances.
|
|
346
|
+
// variations and optical-size fragments only earn their keep on the
|
|
347
|
+
// family path — where two points of one axis would otherwise share a
|
|
348
|
+
// shaped run, and the second would be drawn with the first's advances.
|
|
298
349
|
const key = font
|
|
299
350
|
? `${font.key}|${style.size}|${style.weight}|${style.style}|${levelsKey}|${text}`
|
|
300
351
|
: `${style.family}|${style.size}|${style.weight}|${style.style}|${variationsKeyOf(
|
|
301
352
|
style.variations
|
|
302
|
-
)}|${levelsKey}|${text}`;
|
|
353
|
+
)}|${opticalKeyOf(style)}|${levelsKey}|${text}`;
|
|
303
354
|
let shaped = this._shapeCache.get(key);
|
|
304
355
|
if (shaped) {
|
|
305
356
|
// Map iterates in insertion order: re-inserting a hit moves it to the
|
package/lib/text/layout.js
CHANGED
|
@@ -67,6 +67,8 @@ export class TextLayout {
|
|
|
67
67
|
weight: s.weight ?? style.weight,
|
|
68
68
|
style: s.style ?? style.style,
|
|
69
69
|
variations: s.variations ?? style.variations,
|
|
70
|
+
opticalSize: s.opticalSize ?? style.opticalSize,
|
|
71
|
+
opticalSizing: s.opticalSizing ?? style.opticalSizing,
|
|
70
72
|
textRendering: s.textRendering ?? style.textRendering,
|
|
71
73
|
features: s.features ?? style.features,
|
|
72
74
|
language: s.language ?? style.language,
|
package/lib/text/shape.js
CHANGED
|
@@ -69,7 +69,10 @@ export function normalizedLevels(levels, start, end) {
|
|
|
69
69
|
export function shapeText(fonts, text, style, levels) {
|
|
70
70
|
const size = style.size ?? 16;
|
|
71
71
|
const family = style.family ?? 'sans-serif';
|
|
72
|
-
|
|
72
|
+
// `size` is what drives the `opsz` axis, so a style that leaves it out
|
|
73
|
+
// hands `match()` the size this actually sets at rather than nothing
|
|
74
|
+
const baseFont =
|
|
75
|
+
style.font ?? fonts.match(family, style.size === undefined ? { ...style, size } : style);
|
|
73
76
|
|
|
74
77
|
let baseLevel = 0;
|
|
75
78
|
if (!levels) {
|
package/lib/window.js
CHANGED
|
@@ -109,6 +109,16 @@ const REFRESH_QUANTILE = 0.25;
|
|
|
109
109
|
const STALL_TIMEOUT = 2000;
|
|
110
110
|
const STALL_TIMEOUT_FIRST = 250;
|
|
111
111
|
|
|
112
|
+
/**
|
|
113
|
+
* MotionNotify detail saying "this is the only motion you get until you ask".
|
|
114
|
+
*
|
|
115
|
+
* The core protocol calls it NotifyHint and puts it in the event's detail
|
|
116
|
+
* byte; node-x11 parses a MotionNotify's detail into `keycode`, the field
|
|
117
|
+
* that carries a button number on ButtonPress — so this is compared against
|
|
118
|
+
* `ev.keycode`, not `ev.detail`, which motion events do not have.
|
|
119
|
+
*/
|
|
120
|
+
const MOTION_NOTIFY_HINT = 1;
|
|
121
|
+
|
|
112
122
|
function rectArea(r) {
|
|
113
123
|
return Math.max(0, r.w) * Math.max(0, r.h);
|
|
114
124
|
}
|
|
@@ -493,6 +503,15 @@ export default class Window extends Drawable {
|
|
|
493
503
|
this._geSinkOpcode = 0;
|
|
494
504
|
// XI2 state, once a window has selected it (see selectXI2)
|
|
495
505
|
this._xi2 = null;
|
|
506
|
+
// PointerMotionHint bookkeeping (see setMouseHintOnly and
|
|
507
|
+
// _rearmMotionHint): whether a frame owes the server the QueryPointer
|
|
508
|
+
// that re-arms the hint, whether one is already in flight, the timestamp
|
|
509
|
+
// of the hint that asked for it, and the last position delivered — a
|
|
510
|
+
// reply that repeats it is not an event.
|
|
511
|
+
this._hintRearm = false;
|
|
512
|
+
this._hintPollPending = false;
|
|
513
|
+
this._hintTime = 0;
|
|
514
|
+
this._hintLast = null;
|
|
496
515
|
// The vblank clock (see _onPresentComplete): the period learnt from
|
|
497
516
|
// completion events, the samples it is drawn from, and the latch the
|
|
498
517
|
// watchdog sets when completions stop arriving.
|
|
@@ -762,6 +781,21 @@ export default class Window extends Drawable {
|
|
|
762
781
|
if (key.codepoint !== undefined) ev.codepoint = key.codepoint;
|
|
763
782
|
}
|
|
764
783
|
}
|
|
784
|
+
// the server saying "the pointer moved, and I will say no more about it
|
|
785
|
+
// until you ask": the event still carries the position it was generated
|
|
786
|
+
// at, so it is delivered like any other move, but the conversation has
|
|
787
|
+
// to be picked back up or this is the last one (see _rearmMotionHint)
|
|
788
|
+
if (eventName === 'mousemove' && ev.keycode === MOTION_NOTIFY_HINT) {
|
|
789
|
+
this._hintTime = ev.time;
|
|
790
|
+
this._hintLast = { x: ev.x, y: ev.y };
|
|
791
|
+
this._hintRearm = true;
|
|
792
|
+
this._deliverEvent(eventName, ntkev);
|
|
793
|
+
// the frame that just took the move sends the poll — one per frame,
|
|
794
|
+
// which is the rate a frame's worth of motion is reduced to anyway.
|
|
795
|
+
// An uncoalesced window has no frame to hang it on, so it goes now.
|
|
796
|
+
if (!this._coalesce) this._rearmMotionHint();
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
765
799
|
// a wheel is a button in the core protocol; say so in the units a
|
|
766
800
|
// consumer wants. Suppressed once XI2 is delivering the same scroll as
|
|
767
801
|
// valuators, which is the whole reason to select it (see selectXI2).
|
|
@@ -1393,6 +1427,9 @@ export default class Window extends Drawable {
|
|
|
1393
1427
|
)
|
|
1394
1428
|
return;
|
|
1395
1429
|
this._flushCoalesced();
|
|
1430
|
+
// after the flush: the frame's motion has been delivered, so what the
|
|
1431
|
+
// server is asked for now is what happened since
|
|
1432
|
+
if (this._hintRearm) this._rearmMotionHint();
|
|
1396
1433
|
if (f.needsRedraw) {
|
|
1397
1434
|
f.needsRedraw = false;
|
|
1398
1435
|
const ev = {
|
|
@@ -1987,6 +2024,9 @@ export default class Window extends Drawable {
|
|
|
1987
2024
|
f.rafCbs = [];
|
|
1988
2025
|
f.needsRedraw = false;
|
|
1989
2026
|
this._presentPending = false;
|
|
2027
|
+
// a window with no frames left has nothing to send the poll from
|
|
2028
|
+
this._hintRearm = false;
|
|
2029
|
+
this._hintLast = null;
|
|
1990
2030
|
}
|
|
1991
2031
|
|
|
1992
2032
|
/**
|
|
@@ -3252,11 +3292,45 @@ export default class Window extends Drawable {
|
|
|
3252
3292
|
return this;
|
|
3253
3293
|
}
|
|
3254
3294
|
|
|
3295
|
+
/**
|
|
3296
|
+
* Trade motion events for round trips: ask the server to report the pointer
|
|
3297
|
+
* moving *once*, and then wait to be asked where it went.
|
|
3298
|
+
*
|
|
3299
|
+
* PointerMotionHint is a two-party protocol. With it selected the server
|
|
3300
|
+
* may send a single MotionNotify carrying detail NotifyHint and then say
|
|
3301
|
+
* nothing more about the pointer until the client asks — so a window that
|
|
3302
|
+
* only sets the bit hears about one move and then silence (issue #319).
|
|
3303
|
+
* ntk holds up the other end: a hinted move is delivered with the position
|
|
3304
|
+
* it carries, and the frame it lands in sends the QueryPointer that lets
|
|
3305
|
+
* the server speak again (see _rearmMotionHint).
|
|
3306
|
+
*
|
|
3307
|
+
* What that buys, and what it costs, are the same thing. Motion stops being
|
|
3308
|
+
* paced by the input device and starts being paced by the connection: one
|
|
3309
|
+
* event per round trip instead of one per hardware sample, which is a large
|
|
3310
|
+
* saving on a link where bytes are scarce — X over ssh, a tunnel, a slow
|
|
3311
|
+
* network — and no saving at all on a local socket, where the poll costs
|
|
3312
|
+
* more bytes than the events it replaced. It also means a handler acts on a
|
|
3313
|
+
* position that is up to one round trip old. Reach for it when bandwidth is
|
|
3314
|
+
* the problem; leave it alone when latency is.
|
|
3315
|
+
*
|
|
3316
|
+
* Two things it is not:
|
|
3317
|
+
*
|
|
3318
|
+
* - not a substitute for selecting motion. The hint modifies
|
|
3319
|
+
* PointerMotion, it does not imply it — a window with no `mousemove`
|
|
3320
|
+
* listener and no PointerMotion in its mask receives nothing either way.
|
|
3321
|
+
* - not an XI2 control. A window that called `selectXI2(['Motion'])` is
|
|
3322
|
+
* off the core motion stream entirely (see lib/xi2.js), and the core
|
|
3323
|
+
* hint has nothing left to thin.
|
|
3324
|
+
*/
|
|
3255
3325
|
setMouseHintOnly(isOn) {
|
|
3256
3326
|
if (isOn && !(this.eventMask & x11.eventMask.PointerMotionHint)) {
|
|
3257
3327
|
this.eventMask |= x11.eventMask.PointerMotionHint;
|
|
3258
3328
|
} else if (!isOn && this.eventMask & x11.eventMask.PointerMotionHint) {
|
|
3259
3329
|
this.eventMask &= ~x11.eventMask.PointerMotionHint;
|
|
3330
|
+
// the server will report motion on its own again, so no frame owes it a
|
|
3331
|
+
// question. A poll already in flight is left alone: its answer is a
|
|
3332
|
+
// real position, and it is deduped against the last one like any other.
|
|
3333
|
+
this._hintRearm = false;
|
|
3260
3334
|
} else {
|
|
3261
3335
|
return this;
|
|
3262
3336
|
}
|
|
@@ -3265,6 +3339,63 @@ export default class Window extends Drawable {
|
|
|
3265
3339
|
return this;
|
|
3266
3340
|
}
|
|
3267
3341
|
|
|
3342
|
+
/**
|
|
3343
|
+
* Answer a NotifyHint: ask where the pointer is, which is what re-arms the
|
|
3344
|
+
* server, and deliver the answer if it moved somewhere no event reported.
|
|
3345
|
+
*
|
|
3346
|
+
* The reply is not redundant with the hint that asked for it. Between the
|
|
3347
|
+
* hint being generated and the server processing this request the pointer
|
|
3348
|
+
* may have moved again, and that motion produces no event — it is exactly
|
|
3349
|
+
* what the hint suppressed. Dropped, the last position of a gesture that
|
|
3350
|
+
* ends mid-flight is lost and the window's idea of where the pointer is
|
|
3351
|
+
* stays wrong until it moves again. So the poll's position is delivered as
|
|
3352
|
+
* a motion event when it differs from the last one delivered, and nothing
|
|
3353
|
+
* is emitted when the pointer sat still.
|
|
3354
|
+
*
|
|
3355
|
+
* One poll at a time, and one per frame: a burst of motion cannot queue a
|
|
3356
|
+
* round trip each, and the flag stays raised if a poll is already out, so
|
|
3357
|
+
* the next frame picks it up.
|
|
3358
|
+
*/
|
|
3359
|
+
_rearmMotionHint() {
|
|
3360
|
+
if (this._hintPollPending) return;
|
|
3361
|
+
this._hintRearm = false;
|
|
3362
|
+
if (this._destroyed || connectionGone(this.X)) return;
|
|
3363
|
+
this._hintPollPending = true;
|
|
3364
|
+
const time = this._hintTime;
|
|
3365
|
+
this.X.QueryPointer(this.id, (err, pointer) => {
|
|
3366
|
+
this._hintPollPending = false;
|
|
3367
|
+
if (err || this._destroyed || connectionGone(this.X)) return;
|
|
3368
|
+
// the pointer is on another screen: `child` and the window-relative
|
|
3369
|
+
// coordinates are zero by protocol, not a position (core QueryPointer)
|
|
3370
|
+
if (!pointer.sameScreen) return;
|
|
3371
|
+
const last = this._hintLast;
|
|
3372
|
+
if (last && last.x === pointer.childX && last.y === pointer.childY) return;
|
|
3373
|
+
this._hintLast = { x: pointer.childX, y: pointer.childY };
|
|
3374
|
+
this._deliverEvent('mousemove', {
|
|
3375
|
+
type: 6,
|
|
3376
|
+
name: 'MotionNotify',
|
|
3377
|
+
// the poll has no timestamp of its own — QueryPointer's reply carries
|
|
3378
|
+
// no time — so the event is stamped with the hint that prompted it
|
|
3379
|
+
time,
|
|
3380
|
+
keycode: 0,
|
|
3381
|
+
root: pointer.root,
|
|
3382
|
+
wid: this.id,
|
|
3383
|
+
child: pointer.child,
|
|
3384
|
+
rootx: pointer.rootX,
|
|
3385
|
+
rooty: pointer.rootY,
|
|
3386
|
+
x: pointer.childX,
|
|
3387
|
+
y: pointer.childY,
|
|
3388
|
+
buttons: pointer.keyMask,
|
|
3389
|
+
sameScreen: pointer.sameScreen,
|
|
3390
|
+
// built from a reply rather than read off the wire, the way a paced
|
|
3391
|
+
// window's redraw events are
|
|
3392
|
+
synthetic: true,
|
|
3393
|
+
window: this,
|
|
3394
|
+
target: this
|
|
3395
|
+
});
|
|
3396
|
+
});
|
|
3397
|
+
}
|
|
3398
|
+
|
|
3268
3399
|
queryPointer(callback) {
|
|
3269
3400
|
this.X.QueryPointer(this.id, callback);
|
|
3270
3401
|
return this;
|