ntk 6.2.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.
Files changed (2) hide show
  1. package/lib/window.js +345 -21
  2. package/package.json +1 -1
package/lib/window.js CHANGED
@@ -355,6 +355,20 @@ export default class Window extends Drawable {
355
355
  // (see docs/window.md "Frames, coalescing and slow connections")
356
356
  this._coalesce = args.coalesceEvents !== false;
357
357
  this._frameSyncEnabled = args.frameSync !== false;
358
+ // _NET_WM_SYNC_REQUEST (see enableSyncRequest): the counter the window
359
+ // manager watches, the value it last asked for, and the watchdog that
360
+ // guarantees an answer even when nothing repaints
361
+ this._syncCounter = null;
362
+ this._syncRequestAtom = 0;
363
+ this._syncPending = null;
364
+ this._syncWatchdog = null;
365
+ // Present-based blits (see enablePresent): the extension objects, the
366
+ // XFixes region reused as the update region, and the serial the server
367
+ // echoes back in its completion events
368
+ this._presentExt = null;
369
+ this._fixesExt = null;
370
+ this._updateRegion = 0;
371
+ this._presentSerial = 0;
358
372
  this.frameInterval = args.frameInterval ?? 16;
359
373
  this.frameLatency = null;
360
374
  this._frame = {
@@ -364,7 +378,12 @@ export default class Window extends Drawable {
364
378
  inFlight: false, // fence round-trip awaiting the server's reply
365
379
  timer: null,
366
380
  scheduled: false,
367
- needsRedraw: false
381
+ needsRedraw: false,
382
+ // minimum inter-blit pacing: when the last blit went out, and the timer
383
+ // that guarantees a blit held back by the interval still lands.
384
+ // -Infinity so the first blit after construction is never delayed.
385
+ lastPresentAt: -Infinity,
386
+ presentTimer: null
368
387
  };
369
388
 
370
389
  if (!args.id) {
@@ -482,6 +501,17 @@ export default class Window extends Drawable {
482
501
  if (args.alwaysOnTop) {
483
502
  this.setAlwaysOnTop(true);
484
503
  }
504
+ if (args.syncRequest && !args.id) {
505
+ // fire and forget: the window manager only reads the counter when it
506
+ // starts managing the window, and map() is the caller's next line at
507
+ // the earliest. `await wnd.enableSyncRequest()` when that is too close.
508
+ this.enableSyncRequest().catch((err) => this.app.options.onXError?.(err));
509
+ }
510
+ if (args.present && !args.id) {
511
+ // fire and forget: until the extensions answer, blits use CopyArea,
512
+ // which is what they would have done anyway
513
+ this.enablePresent().catch((err) => this.app.options.onXError?.(err));
514
+ }
485
515
 
486
516
  X.event_consumers[this.id] = this;
487
517
  this.on('event', (ev) => {
@@ -537,7 +567,10 @@ export default class Window extends Drawable {
537
567
  this.emit(eventName, ntkev);
538
568
  // a WM_DELETE_WINDOW ClientMessage is the window manager *asking*, and
539
569
  // 'close' is that question in a form an application can answer
540
- if (eventName === 'message') this._emitCloseRequest(ntkev);
570
+ if (eventName === 'message') {
571
+ this._emitCloseRequest(ntkev);
572
+ this._handleSyncRequest(ntkev);
573
+ }
541
574
  // anything drawn during the handlers becomes visible in one blit
542
575
  if (this._dirty) this._present();
543
576
  });
@@ -812,8 +845,13 @@ export default class Window extends Drawable {
812
845
  * - a timer: at most one paced frame per `frameInterval` ms, so a fast
813
846
  * local server doesn't get redraws at input-device rate.
814
847
  *
815
- * Discrete events (mousedown, keydown, ...) bypass the timer for latency,
816
- * 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.
817
855
  */
818
856
  _scheduleFrame() {
819
857
  const f = this._frame;
@@ -831,7 +869,15 @@ export default class Window extends Drawable {
831
869
 
832
870
  _runFrame() {
833
871
  const f = this._frame;
834
- if (!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;
835
881
  this._flushCoalesced();
836
882
  if (f.needsRedraw) {
837
883
  f.needsRedraw = false;
@@ -854,6 +900,9 @@ export default class Window extends Drawable {
854
900
  for (const entry of cbs) entry.cb(now);
855
901
  }
856
902
  if (this._dirty || this._presentPending) this._presentNow();
903
+ // covers a window with no backing store, and a resize that changed
904
+ // nothing: _presentNow did not run, but the frame is still "handled"
905
+ this._ackSyncRequest();
857
906
  this._armFence();
858
907
  this._armTimer();
859
908
  }
@@ -952,9 +1001,10 @@ export default class Window extends Drawable {
952
1001
  f.inFlight = false;
953
1002
  this.frameLatency = performance.now() - start;
954
1003
  if (this._presentPending) {
955
- // a blit deferred while the fence was in flight: show it now
956
- this._presentNow();
957
- 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();
958
1008
  }
959
1009
  if ((f.pending.size || f.rafCbs.length || f.needsRedraw) && !f.timer) this._scheduleFrame();
960
1010
  });
@@ -978,6 +1028,36 @@ export default class Window extends Drawable {
978
1028
  clearTimeout(f.timer);
979
1029
  f.timer = null;
980
1030
  }
1031
+ if (f.presentTimer) {
1032
+ clearTimeout(f.presentTimer);
1033
+ f.presentTimer = null;
1034
+ }
1035
+ if (this._syncWatchdog) {
1036
+ clearTimeout(this._syncWatchdog);
1037
+ this._syncWatchdog = null;
1038
+ }
1039
+ // Destroying the counter releases any Await the window manager is
1040
+ // blocked in, so a window that goes away mid-drag does not strand it.
1041
+ if (this._syncCounter && this._sync) {
1042
+ const counter = this._syncCounter;
1043
+ this._syncCounter = null;
1044
+ this._syncPending = null;
1045
+ safeRelease(this.X, () => {
1046
+ this._sync.DestroyCounter(counter);
1047
+ this.X.ReleaseID(counter);
1048
+ });
1049
+ }
1050
+ if (this._updateRegion && this._fixesExt) {
1051
+ const region = this._updateRegion;
1052
+ this._updateRegion = 0;
1053
+ this._presentExt = null;
1054
+ const fixes = this._fixesExt;
1055
+ this._fixesExt = null;
1056
+ safeRelease(this.X, () => {
1057
+ fixes.DestroyRegion(region);
1058
+ this.X.ReleaseID(region);
1059
+ });
1060
+ }
981
1061
  f.pending.clear();
982
1062
  f.rafCbs = [];
983
1063
  f.needsRedraw = false;
@@ -1014,9 +1094,11 @@ export default class Window extends Drawable {
1014
1094
  * Drawing the response inside the event handler costs a frame less latency:
1015
1095
  * the blit goes out with the handler's own requests (see the `_present()`
1016
1096
  * at the end of the event dispatch) instead of waiting for the next paced
1017
- * frame. Drawing it while a frame is in flight costs a frame's *work* for
1018
- * nothing: the present is deferred until the ack and coalesces with the
1019
- * 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`
1020
1102
  * means "draw it now", `true` means "leave it to the frame clock" — which
1021
1103
  * is how a burst of discrete events (a spun wheel) paints its first notch
1022
1104
  * immediately and folds the rest into one catch-up frame.
@@ -1031,15 +1113,129 @@ export default class Window extends Drawable {
1031
1113
 
1032
1114
  _present() {
1033
1115
  if (!this._backing) return;
1034
- if (this._frame.inFlight) {
1035
- // the server hasn't confirmed the previous frame yet — defer the blit
1036
- 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);
1037
1121
  return;
1038
1122
  }
1039
1123
  this._presentNow();
1040
1124
  this._armFence();
1041
1125
  }
1042
1126
 
1127
+ /**
1128
+ * How long until the next blit may go out; 0 when it may go now.
1129
+ *
1130
+ * The gate is `frameInterval`, the same knob that paces frames, so
1131
+ * `frameInterval: 0` keeps the old fence-only behaviour — a caller who asked
1132
+ * for no timer gate gets none here either.
1133
+ */
1134
+ _presentWait() {
1135
+ const min = this.frameInterval;
1136
+ if (!(min > 0)) return 0;
1137
+ const since = performance.now() - this._frame.lastPresentAt;
1138
+ return since >= min ? 0 : min - since;
1139
+ }
1140
+
1141
+ /**
1142
+ * Hold a blit back, and guarantee it still happens.
1143
+ *
1144
+ * This is the only place `_presentPending` is set, because every deferral has
1145
+ * to leave a wakeup behind: the fence reply when the fence is what we are
1146
+ * waiting on, this timer when the interval is. Nothing else reschedules a
1147
+ * present — `_scheduleFrame()` bails while a frame timer is armed, and
1148
+ * neither reschedule condition (in `_armFence`'s reply or `_armTimer`'s
1149
+ * callback) looks at the present. Without the timer the last blit of a burst
1150
+ * would simply never go out, leaving stale pixels on screen.
1151
+ */
1152
+ _deferPresent(wait) {
1153
+ this._presentPending = true;
1154
+ if (wait <= 0) return; // waiting on the fence: its reply blits this
1155
+ const f = this._frame;
1156
+ if (f.presentTimer) return; // already armed
1157
+ f.presentTimer = setTimeout(() => {
1158
+ f.presentTimer = null;
1159
+ if (!this._presentPending && !this._dirty) return; // someone blitted it
1160
+ const again = this._presentWait();
1161
+ if (again > 0) return this._deferPresent(again); // a paced frame re-stamped
1162
+ if (f.inFlight) return; // the fence ack is closer; it will blit
1163
+ this._presentNow();
1164
+ this._armFence();
1165
+ }, wait);
1166
+ if (typeof f.presentTimer.unref === 'function') f.presentTimer.unref();
1167
+ }
1168
+
1169
+ /**
1170
+ * Blit with the Present extension instead of `CopyArea`.
1171
+ *
1172
+ * A frame's dirty rectangles become one `PresentPixmap` with an update
1173
+ * region, in place of one `CopyArea` per rectangle. Two things follow:
1174
+ * a frame is a fixed two requests however fragmented the damage is, and
1175
+ * `_blitList`'s bounding-box collapse becomes unnecessary — its whole
1176
+ * premise is "each rectangle costs a request", so with Present the exact
1177
+ * rectangles are sent and pixels outside them are never touched.
1178
+ *
1179
+ * Presents are also scheduled by the server against the display's refresh
1180
+ * rather than executed on arrival, so a burst cannot produce more updates
1181
+ * than the output can show; the server drops superseded frames itself.
1182
+ *
1183
+ * Worth being precise about what this does *not* do: under a compositing
1184
+ * manager the window is redirected, so this schedules the server's copy
1185
+ * into the redirect pixmap — the compositor still composites on its own
1186
+ * schedule. Aligning with *that* is what `_NET_WM_SYNC_REQUEST`'s extended
1187
+ * counters are for.
1188
+ *
1189
+ * Opt-in via `createWindow({ present: true })`, and inert unless both
1190
+ * Present and XFixes are available — blits fall back to `CopyArea`, which
1191
+ * stays correct at all times, so the two paths can even alternate.
1192
+ *
1193
+ * @returns {Promise<Window>}
1194
+ */
1195
+ enablePresent() {
1196
+ if (this._presentExt || this._destroyed) return Promise.resolve(this);
1197
+ const X = this.X;
1198
+ const need = (name) =>
1199
+ new Promise((resolve) => X.require(name, (err, ext) => resolve(err ? null : ext)));
1200
+ return Promise.all([need('present'), need('fixes')]).then(([present, fixes]) => {
1201
+ if (!present || !fixes || this._destroyed) return this; // stay on CopyArea
1202
+ this._updateRegion = X.AllocID();
1203
+ safeRelease(X, () => fixes.CreateRegion(this._updateRegion, []));
1204
+ this._presentExt = present;
1205
+ this._fixesExt = fixes;
1206
+ return this;
1207
+ });
1208
+ }
1209
+
1210
+ /**
1211
+ * The Present form of the blit. Returns false when it did not happen, so
1212
+ * the caller falls back to CopyArea — which is also what runs before the
1213
+ * extensions have answered.
1214
+ */
1215
+ _presentWithExtension(rects) {
1216
+ const P = this._presentExt;
1217
+ if (!P || !this._fixesExt || !this._updateRegion) return false;
1218
+ safeRelease(this.X, () => {
1219
+ this._fixesExt.SetRegion(
1220
+ this._updateRegion,
1221
+ rects.map((r) => ({ x: r.x, y: r.y, width: r.w, height: r.h }))
1222
+ );
1223
+ this._presentExt.Pixmap(this.id, this._backing.id, {
1224
+ serial: ++this._presentSerial,
1225
+ update: this._updateRegion,
1226
+ // Option.Copy forces the copy path. Without it the server may *flip*
1227
+ // the pixmap to the screen and take ownership of it until an
1228
+ // IdleNotify — and ntk reuses one grow-only backing pixmap, so
1229
+ // drawing into it while the server owned it would paint the screen
1230
+ // directly. Removing this needs a swap chain, not just a smaller diff.
1231
+ options: P.Option.Copy
1232
+ // targetMsc stays 0: "the next vblank", which is what we want. An
1233
+ // explicit target only adds ways to fall behind.
1234
+ });
1235
+ });
1236
+ return true;
1237
+ }
1238
+
1043
1239
  _presentNow() {
1044
1240
  this._presentPending = false;
1045
1241
  if (!this._backing) return;
@@ -1061,13 +1257,27 @@ export default class Window extends Drawable {
1061
1257
  if (x1 > x0 && y1 > y0) clamped.push({ x: x0, y: y0, w: x1 - x0, h: y1 - y0 });
1062
1258
  }
1063
1259
  if (!clamped.length) return;
1064
- const rects = this._blitList(clamped);
1065
- // a paced frame can fire after the connection started closing
1066
- safeRelease(this.X, () => {
1067
- for (const r of rects) {
1068
- this.X.CopyArea(this._backing.id, this.id, this._presentGc, r.x, r.y, r.x, r.y, r.w, r.h);
1069
- }
1070
- });
1260
+ // Stamped here rather than at the call sites so it is a true inter-blit
1261
+ // interval across all three of them, and only when pixels really move — a
1262
+ // present that copies nothing must not push the next one out.
1263
+ this._frame.lastPresentAt = performance.now();
1264
+ // Present sends the exact rectangles in one request, so the bounding-box
1265
+ // collapse — which trades pixels for fewer requests — has nothing to buy
1266
+ if (!this._presentWithExtension(clamped)) {
1267
+ const rects = this._blitList(clamped);
1268
+ // a paced frame can fire after the connection started closing
1269
+ safeRelease(this.X, () => {
1270
+ for (const r of rects) {
1271
+ this.X.CopyArea(this._backing.id, this.id, this._presentGc, r.x, r.y, r.x, r.y, r.w, r.h);
1272
+ }
1273
+ });
1274
+ }
1275
+ // Queued behind whichever of the two put the pixels on their way, so the
1276
+ // window manager hears about the new size only once the server has drawn
1277
+ // it. Both paths must reach this: a window using Present *and*
1278
+ // _NET_WM_SYNC_REQUEST would otherwise never answer, and the resize would
1279
+ // stall.
1280
+ this._ackSyncRequest();
1071
1281
  }
1072
1282
 
1073
1283
  /**
@@ -1504,6 +1714,10 @@ export default class Window extends Drawable {
1504
1714
  *
1505
1715
  * `WM_DELETE_WINDOW` needs none of this by hand: listening for the
1506
1716
  * `'close'` event adds the protocol and decodes the message for you.
1717
+ * `_NET_WM_SYNC_REQUEST` likewise has a real opt-in — `enableSyncRequest()`
1718
+ * / `createWindow({ syncRequest: true })`. Adding that atom here instead
1719
+ * advertises a protocol this window cannot answer, which is worse than not
1720
+ * advertising it: the counter the window manager looks for is missing.
1507
1721
  *
1508
1722
  * Replaces the whole list. `addProtocol`/`removeProtocol` are the
1509
1723
  * accumulating forms, and the ones to reach for: the property is a *set*,
@@ -2537,6 +2751,116 @@ export default class Window extends Drawable {
2537
2751
  * existed — keeps behaving exactly as it did, rather than suddenly
2538
2752
  * acquiring a second handler that destroys it.
2539
2753
  */
2754
+ /**
2755
+ * Opt into `_NET_WM_SYNC_REQUEST` (EWMH §6.2): let the window manager pace
2756
+ * an interactive resize to how fast this window actually repaints.
2757
+ *
2758
+ * Without it a WM has no idea when a resize has been drawn, so it either
2759
+ * throws ConfigureNotify at the client as fast as the pointer moves — and
2760
+ * the window lags behind the frame the user is dragging — or guesses with a
2761
+ * timer. With it, the WM sends a serial before each resize and waits for
2762
+ * this window to echo it back once the new size is on screen.
2763
+ *
2764
+ * Also available as `createWindow({ syncRequest: true })`. This is the
2765
+ * awaitable form, for when `map()` follows immediately: the counter and the
2766
+ * `_NET_WM_SYNC_REQUEST_COUNTER` property have to exist before the window
2767
+ * leaves the withdrawn state, because that is when the WM reads them.
2768
+ *
2769
+ * Silently does nothing when the server has no SYNC extension — a WM that
2770
+ * finds no counter simply drives the resize the old way.
2771
+ *
2772
+ * Only basic (single-counter) synchronization is implemented. The extended
2773
+ * two-counter form buys frame-timing feedback rather than resize pacing, and
2774
+ * its odd/even parity rules freeze the window if they are ever got wrong; a
2775
+ * compositor that supports it falls back to basic mode on its own when the
2776
+ * property holds one counter.
2777
+ *
2778
+ * @returns {Promise<Window>}
2779
+ */
2780
+ enableSyncRequest() {
2781
+ if (this._syncCounter || this._destroyed) return Promise.resolve(this);
2782
+ const X = this.X;
2783
+ return new Promise((resolve, reject) => {
2784
+ X.require('sync', (err, Sync) => {
2785
+ if (err || !Sync || this._destroyed) return resolve(this); // no SYNC: stay quiet
2786
+ this._sync = Sync;
2787
+ const counter = X.AllocID();
2788
+ safeRelease(X, () => Sync.CreateCounter(counter, 0));
2789
+ this._syncCounter = counter;
2790
+ // The dispatch path compares against both atoms, and node-x11 caches
2791
+ // interned atoms per connection, so this is one round trip and no more.
2792
+ this.atom('WM_PROTOCOLS').catch(() => {});
2793
+ this._withAtoms(['_NET_WM_SYNC_REQUEST_COUNTER', '_NET_WM_SYNC_REQUEST'], (atoms) => {
2794
+ this._syncRequestAtom = atoms._NET_WM_SYNC_REQUEST;
2795
+ safeRelease(X, () =>
2796
+ X.ChangeProperty(
2797
+ 0,
2798
+ this.id,
2799
+ atoms._NET_WM_SYNC_REQUEST_COUNTER,
2800
+ X.atoms.CARDINAL,
2801
+ 32,
2802
+ [counter]
2803
+ )
2804
+ );
2805
+ // property first, protocol second: a window manager that sees the
2806
+ // protocol advertised must always find the counter behind it
2807
+ this.addProtocol('_NET_WM_SYNC_REQUEST').then(() => resolve(this), reject);
2808
+ });
2809
+ });
2810
+ });
2811
+ }
2812
+
2813
+ /**
2814
+ * The window manager asking us to report when a resize has been painted.
2815
+ *
2816
+ * Only the value is recorded here — acknowledging now would claim a frame
2817
+ * that has not been drawn. `_ackSyncRequest` sends it once the pixels are on
2818
+ * their way. EWMH is explicit that only the *last* message is acknowledged,
2819
+ * so a newer request simply overwrites an older one.
2820
+ */
2821
+ _handleSyncRequest(ev) {
2822
+ if (!this._syncCounter || ev.format !== 32) return;
2823
+ const X = this.X;
2824
+ if (!X.atoms.WM_PROTOCOLS || ev.message_type !== X.atoms.WM_PROTOCOLS) return;
2825
+ if (!this._syncRequestAtom || ev.data?.[0] !== this._syncRequestAtom) return;
2826
+ // data[2] is the low half of the 64-bit request number, data[3] the high
2827
+ this._syncPending = ev.data[3] * 0x100000000 + ev.data[2];
2828
+ // Not every request leads to a repaint — a move, or a resize to the size
2829
+ // we already are, leaves nothing dirty and no frame scheduled. The
2830
+ // watchdog turns a missed acknowledgement into a stutter instead of a
2831
+ // window manager that waits forever.
2832
+ this._armSyncWatchdog();
2833
+ }
2834
+
2835
+ _armSyncWatchdog() {
2836
+ if (this._syncWatchdog || this._syncPending == null) return;
2837
+ this._syncWatchdog = setTimeout(() => {
2838
+ this._syncWatchdog = null;
2839
+ this._ackSyncRequest();
2840
+ }, Math.max(this.frameInterval, 16) * 2);
2841
+ if (typeof this._syncWatchdog.unref === 'function') this._syncWatchdog.unref();
2842
+ }
2843
+
2844
+ /**
2845
+ * Answer the window manager's last sync request.
2846
+ *
2847
+ * Called after the requests that repaint have been queued, never before: X
2848
+ * runs a client's requests in order, so a SetCounter queued behind the
2849
+ * copies is executed behind them too, which is exactly the "having handled
2850
+ * all repainting" the spec asks for. (It rides the same output batch, which
2851
+ * is flushed before the event loop polls — no manual flush needed.)
2852
+ */
2853
+ _ackSyncRequest() {
2854
+ const value = this._syncPending;
2855
+ if (value == null || !this._syncCounter || !this._sync) return;
2856
+ this._syncPending = null;
2857
+ if (this._syncWatchdog) {
2858
+ clearTimeout(this._syncWatchdog);
2859
+ this._syncWatchdog = null;
2860
+ }
2861
+ safeRelease(this.X, () => this._sync.SetCounter(this._syncCounter, value));
2862
+ }
2863
+
2540
2864
  _emitCloseRequest(ev) {
2541
2865
  if (ev.format !== 32 || !this.listenerCount('close')) return;
2542
2866
  // node-x11 caches interned atoms per connection, so these are the ids
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "6.2.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",