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 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
- if (best < MIN_REFRESH_RATE || best > MAX_REFRESH_RATE) return;
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`.
@@ -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
- * 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.
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}|${variationsKeyOf(opts.variations)}`;
161
- let font = this._matches.get(cacheKey);
162
- if (font) {
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, font);
166
- return font;
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
- font = this._matchRegistered(
228
+ face = this._matchRegistered(
175
229
  families.map((f) => f.toLowerCase()),
176
230
  weight,
177
231
  italic
178
232
  );
179
- if (!font) {
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
- font = this._open(candidates[0]);
240
+ face = this._open(candidates[0]);
187
241
  }
188
- font = instantiate(font, weight, opts.variations);
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.
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 font;
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 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.
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
@@ -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
- const baseFont = style.font ?? fonts.match(family, style);
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.4.0",
3
+ "version": "8.5.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",