ntk 6.1.0 → 6.3.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.
@@ -105,8 +105,10 @@ export default class HtmlView {
105
105
  if (this.window) {
106
106
  this._ctx = this.window.getContext('2d');
107
107
  this.window.on('expose', () => this.render());
108
- this.window.on('resize', () => {
109
- this._layoutWidth = -1;
108
+ // a move is a ConfigureNotify too; re-laying out a document per step
109
+ // of a window drag is exactly what ev.resized is for
110
+ this.window.on('resize', (ev) => {
111
+ if (ev.resized) this._layoutWidth = -1;
110
112
  });
111
113
  this.window.on('mousedown', (ev) => {
112
114
  if (ev.keycode === 4 || ev.keycode === 5) {
@@ -74,8 +74,10 @@ export default class MarkdownView {
74
74
  if (this.window) {
75
75
  this._ctx = this.window.getContext('2d');
76
76
  this.window.on('expose', () => this.render());
77
- this.window.on('resize', () => {
78
- this._layoutWidth = -1; // invalidate
77
+ // a move is a ConfigureNotify too; re-laying out a document per step
78
+ // of a window drag is exactly what ev.resized is for
79
+ this.window.on('resize', (ev) => {
80
+ if (ev.resized) this._layoutWidth = -1; // invalidate
79
81
  });
80
82
  this.window.on('mousedown', (ev) => {
81
83
  if (ev.keycode !== 1 || !this.onLink) return; // left button only
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) {
@@ -386,6 +405,10 @@ export default class Window extends Drawable {
386
405
  this.height = args.height ?? 800;
387
406
  this.x = args.x ?? 0;
388
407
  this.y = args.y ?? 0;
408
+ // the baseline 'resize' compares against (see _tagResize): what the
409
+ // window was asked to be, so the window manager's first ConfigureNotify
410
+ // reports only what it actually overrode
411
+ this._deliveredGeom = { x: this.x, y: this.y, width: this.width, height: this.height };
389
412
  const values = {
390
413
  // NorthWest bit gravity: keep the old content anchored during
391
414
  // resize instead of discarding it (less flicker between the resize
@@ -419,6 +442,8 @@ export default class Window extends Drawable {
419
442
  this.eventMask = 0;
420
443
  this.visual = 0;
421
444
  this.depth = 0;
445
+ // an adopted window's geometry is not known until GetGeometry replies
446
+ this._deliveredGeom = null;
422
447
  // populate width and height
423
448
  X.GetGeometry(this.id, (err, res) => {
424
449
  if (!err) {
@@ -427,6 +452,10 @@ export default class Window extends Drawable {
427
452
  this.x = res.xPos;
428
453
  this.y = res.yPos;
429
454
  this.depth = res.depth;
455
+ // nothing was known about this window until now, so until this
456
+ // reply lands a 'resize' reports both moved and resized (see
457
+ // _tagResize) rather than guessing
458
+ this._deliveredGeom ??= { x: this.x, y: this.y, width: this.width, height: this.height };
430
459
  }
431
460
  this._readyPromiseResolve();
432
461
  });
@@ -472,6 +501,17 @@ export default class Window extends Drawable {
472
501
  if (args.alwaysOnTop) {
473
502
  this.setAlwaysOnTop(true);
474
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
+ }
475
515
 
476
516
  X.event_consumers[this.id] = this;
477
517
  this.on('event', (ev) => {
@@ -523,10 +563,14 @@ export default class Window extends Drawable {
523
563
  // deliver buffered state events before a discrete one so handlers see
524
564
  // them in the order they happened (a drag sees the move, then the up)
525
565
  this._flushCoalesced();
566
+ if (eventName === 'resize') this._tagResize(ntkev);
526
567
  this.emit(eventName, ntkev);
527
568
  // a WM_DELETE_WINDOW ClientMessage is the window manager *asking*, and
528
569
  // 'close' is that question in a form an application can answer
529
- if (eventName === 'message') this._emitCloseRequest(ntkev);
570
+ if (eventName === 'message') {
571
+ this._emitCloseRequest(ntkev);
572
+ this._handleSyncRequest(ntkev);
573
+ }
530
574
  // anything drawn during the handlers becomes visible in one blit
531
575
  if (this._dirty) this._present();
532
576
  });
@@ -628,8 +672,6 @@ export default class Window extends Drawable {
628
672
  this._dirty = false;
629
673
  this._backingValid = false;
630
674
  this._presentScheduled = false;
631
- this._backedW = this.width;
632
- this._backedH = this.height;
633
675
 
634
676
  this._presentGc = this.X.AllocID();
635
677
  this.X.CreateGC(this._presentGc, this.id, { graphicsExposures: 0 });
@@ -642,10 +684,11 @@ export default class Window extends Drawable {
642
684
  this.X.ChangeWindowAttributes(this.id, { eventMask: this.eventMask }, () => {});
643
685
  }
644
686
 
687
+ // a pure move is a ConfigureNotify too, and reallocating a backing
688
+ // pixmap per step of a window drag is the bug this flag exists to spare
689
+ // everyone (see _tagResize)
645
690
  this.on('resize', (ev) => {
646
- if (ev.width === this._backedW && ev.height === this._backedH) return;
647
- this._backedW = ev.width;
648
- this._backedH = ev.height;
691
+ if (!ev.resized) return;
649
692
  this._allocBacking(ev.width, ev.height);
650
693
  this._backingValid = false;
651
694
  this._requestRedraw();
@@ -802,8 +845,13 @@ export default class Window extends Drawable {
802
845
  * - a timer: at most one paced frame per `frameInterval` ms, so a fast
803
846
  * local server doesn't get redraws at input-device rate.
804
847
  *
805
- * Discrete events (mousedown, keydown, ...) bypass the timer for latency,
806
- * but their blits still respect the fence via _present().
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.
807
855
  */
808
856
  _scheduleFrame() {
809
857
  const f = this._frame;
@@ -821,7 +869,15 @@ export default class Window extends Drawable {
821
869
 
822
870
  _runFrame() {
823
871
  const f = this._frame;
824
- if (!f.pending.size && !f.rafCbs.length && !f.needsRedraw && !this._dirty && !this._presentPending) return;
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;
825
881
  this._flushCoalesced();
826
882
  if (f.needsRedraw) {
827
883
  f.needsRedraw = false;
@@ -844,6 +900,9 @@ export default class Window extends Drawable {
844
900
  for (const entry of cbs) entry.cb(now);
845
901
  }
846
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();
847
906
  this._armFence();
848
907
  this._armTimer();
849
908
  }
@@ -875,6 +934,46 @@ export default class Window extends Drawable {
875
934
  this._scheduleFrame();
876
935
  }
877
936
 
937
+ /**
938
+ * Say what a 'resize' actually changed.
939
+ *
940
+ * 'resize' is ConfigureNotify, which fires for pure moves and for
941
+ * reparents as much as for size changes — under an opaque-move window
942
+ * manager a window drag is one per pointer step. Every consumer used to
943
+ * rediscover that by keeping its own copy of the last size (this library
944
+ * included, in the backing-store listener), and one that didn't paid a
945
+ * full relayout per step of a drag.
946
+ *
947
+ * So the delivered event says which it was:
948
+ *
949
+ * - `ev.resized` — the size differs from the last delivered event's
950
+ * - `ev.moved` — the position does
951
+ * - `ev.previous` — that event's `{x, y, width, height}`, for a delta;
952
+ * null on an adopted window whose geometry is not known yet, where
953
+ * both flags read true because nothing can be ruled out
954
+ *
955
+ * Measured against the last *delivered* event, not the last raw one, so
956
+ * that coalescing cannot swallow a change: a frame that merges two moves
957
+ * and a resize reports both, where per-hop flags would report only the
958
+ * final hop. The merged raw events in `ev.coalesced` are not tagged for
959
+ * that reason — the flags are a property of the delivery.
960
+ *
961
+ * Position is compared as reported. A reparenting window manager sends
962
+ * real ConfigureNotify in frame coordinates and synthetic ones in root
963
+ * coordinates (ICCCM 4.2.3), so a frame's own offset can read as a move
964
+ * on the first event after the switch. That is the safe direction —
965
+ * spurious work, never missed work — and the alternative is a
966
+ * TranslateCoordinates round trip per event, which is the cost this flag
967
+ * exists to avoid.
968
+ */
969
+ _tagResize(ev) {
970
+ const prev = this._deliveredGeom;
971
+ ev.previous = prev;
972
+ ev.moved = !prev || ev.x !== prev.x || ev.y !== prev.y;
973
+ ev.resized = !prev || ev.width !== prev.width || ev.height !== prev.height;
974
+ this._deliveredGeom = { x: ev.x, y: ev.y, width: ev.width, height: ev.height };
975
+ }
976
+
878
977
  _flushCoalesced() {
879
978
  const pending = this._frame.pending;
880
979
  if (!pending.size) return;
@@ -884,6 +983,7 @@ export default class Window extends Drawable {
884
983
  const ev = pending.get(name);
885
984
  if (ev) {
886
985
  pending.delete(name);
986
+ if (name === 'resize') this._tagResize(ev);
887
987
  this.emit(name, ev);
888
988
  }
889
989
  }
@@ -901,9 +1001,10 @@ export default class Window extends Drawable {
901
1001
  f.inFlight = false;
902
1002
  this.frameLatency = performance.now() - start;
903
1003
  if (this._presentPending) {
904
- // a blit deferred while the fence was in flight: show it now
905
- this._presentNow();
906
- this._armFence();
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();
907
1008
  }
908
1009
  if ((f.pending.size || f.rafCbs.length || f.needsRedraw) && !f.timer) this._scheduleFrame();
909
1010
  });
@@ -927,6 +1028,36 @@ export default class Window extends Drawable {
927
1028
  clearTimeout(f.timer);
928
1029
  f.timer = null;
929
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
+ }
930
1061
  f.pending.clear();
931
1062
  f.rafCbs = [];
932
1063
  f.needsRedraw = false;
@@ -963,9 +1094,11 @@ export default class Window extends Drawable {
963
1094
  * Drawing the response inside the event handler costs a frame less latency:
964
1095
  * the blit goes out with the handler's own requests (see the `_present()`
965
1096
  * at the end of the event dispatch) instead of waiting for the next paced
966
- * frame. Drawing it while a frame is in flight costs a frame's *work* for
967
- * nothing: the present is deferred until the ack and coalesces with the
968
- * one already pending, so only the last paint is ever seen. So `false`
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`
969
1102
  * means "draw it now", `true` means "leave it to the frame clock" — which
970
1103
  * is how a burst of discrete events (a spun wheel) paints its first notch
971
1104
  * immediately and folds the rest into one catch-up frame.
@@ -980,15 +1113,129 @@ export default class Window extends Drawable {
980
1113
 
981
1114
  _present() {
982
1115
  if (!this._backing) return;
983
- if (this._frame.inFlight) {
984
- // the server hasn't confirmed the previous frame yet — defer the blit
985
- this._presentPending = true;
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);
986
1121
  return;
987
1122
  }
988
1123
  this._presentNow();
989
1124
  this._armFence();
990
1125
  }
991
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
+
992
1239
  _presentNow() {
993
1240
  this._presentPending = false;
994
1241
  if (!this._backing) return;
@@ -1010,13 +1257,27 @@ export default class Window extends Drawable {
1010
1257
  if (x1 > x0 && y1 > y0) clamped.push({ x: x0, y: y0, w: x1 - x0, h: y1 - y0 });
1011
1258
  }
1012
1259
  if (!clamped.length) return;
1013
- const rects = this._blitList(clamped);
1014
- // a paced frame can fire after the connection started closing
1015
- safeRelease(this.X, () => {
1016
- for (const r of rects) {
1017
- this.X.CopyArea(this._backing.id, this.id, this._presentGc, r.x, r.y, r.x, r.y, r.w, r.h);
1018
- }
1019
- });
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();
1020
1281
  }
1021
1282
 
1022
1283
  /**
@@ -1453,6 +1714,10 @@ export default class Window extends Drawable {
1453
1714
  *
1454
1715
  * `WM_DELETE_WINDOW` needs none of this by hand: listening for the
1455
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.
1456
1721
  *
1457
1722
  * Replaces the whole list. `addProtocol`/`removeProtocol` are the
1458
1723
  * accumulating forms, and the ones to reach for: the property is a *set*,
@@ -2486,6 +2751,116 @@ export default class Window extends Drawable {
2486
2751
  * existed — keeps behaving exactly as it did, rather than suddenly
2487
2752
  * acquiring a second handler that destroys it.
2488
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
+
2489
2864
  _emitCloseRequest(ev) {
2490
2865
  if (ev.format !== 32 || !this.listenerCount('close')) return;
2491
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.1.0",
3
+ "version": "6.3.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",