ntk 8.4.0 → 8.4.1

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
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.4.1",
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",