ntk 8.11.1 → 8.12.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/README.md CHANGED
@@ -106,7 +106,8 @@ wnd.requestAnimationFrame(frame);
106
106
  ```
107
107
 
108
108
  See [docs/window.md](docs/window.md) for the knobs (`frameInterval`,
109
- `frameSync`, `coalesceEvents`) and the raw uncoalesced event stream.
109
+ `frameSync`, `maxFramesInFlight`, `coalesceEvents`) and the raw uncoalesced
110
+ event stream.
110
111
 
111
112
  ## Resource management
112
113
 
@@ -94,6 +94,51 @@ function intersectBox(a, b) {
94
94
  return { x, y, w: right - x, h: bottom - y };
95
95
  }
96
96
 
97
+ /**
98
+ * Whether no two of a flat `[x, y, w, h, …]` list of whole-pixel rectangles
99
+ * share a pixel — touching edges do not. A sweep along x holding the
100
+ * rectangles it is inside as disjoint y spans in order, so a list that is
101
+ * disjoint, which is the list this is asked about, costs a binary search
102
+ * per rectangle: a chart's per-column spans, an occupancy grid's cells.
103
+ */
104
+ function disjointRects(rects) {
105
+ const n = rects.length / 4;
106
+ if (n < 2) return true;
107
+ // events: where a rectangle starts and where it ends, ends first at a tie
108
+ const events = [];
109
+ for (let i = 0; i < n; i++) {
110
+ events.push([rects[i * 4], 1, i], [rects[i * 4] + rects[i * 4 + 2], 0, i]);
111
+ }
112
+ events.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
113
+ // the y spans the sweep is inside, sorted by their top, as rectangle ids
114
+ const active = [];
115
+ const top = (i) => rects[i * 4 + 1];
116
+ const bottom = (i) => rects[i * 4 + 1] + rects[i * 4 + 3];
117
+ const find = (y) => {
118
+ let lo = 0;
119
+ let hi = active.length;
120
+ while (lo < hi) {
121
+ const mid = (lo + hi) >> 1;
122
+ if (top(active[mid]) < y) lo = mid + 1;
123
+ else hi = mid;
124
+ }
125
+ return lo;
126
+ };
127
+ for (const [, starts, i] of events) {
128
+ if (!starts) {
129
+ let at = find(top(i));
130
+ while (active[at] !== i) at++;
131
+ active.splice(at, 1);
132
+ continue;
133
+ }
134
+ const at = find(top(i));
135
+ if (at > 0 && bottom(active[at - 1]) > top(i)) return false;
136
+ if (at < active.length && top(active[at]) < bottom(i)) return false;
137
+ active.splice(at, 0, i);
138
+ }
139
+ return true;
140
+ }
141
+
97
142
  /** The device-space ink bounds of a flattened path, unclamped. */
98
143
  function polysInk(polys) {
99
144
  let minX = Infinity;
@@ -1854,42 +1899,47 @@ class RenderingContext2d {
1854
1899
  * well as the AddTraps: two requests become one, and the one that is left
1855
1900
  * touches only the bounding box.
1856
1901
  */
1857
- _uploadCoverage(job, b, edges) {
1902
+ _uploadCoverage(job, b, out, edges) {
1858
1903
  const rasterizer = this.window.app.rasterizer;
1859
1904
  if (!rasterizer) return false;
1860
1905
  if (routeRaster(b.w, b.h, edges, this.window.app.rasterPolicy) !== "local")
1861
1906
  return false;
1862
1907
 
1908
+ // Rasterized over the whole box, as always, so a drawing that clips
1909
+ // split across passes — a pan's strips, a full repaint — gets the same
1910
+ // coverage in every one of them, byte for byte. Only `out`, the part the
1911
+ // clip lets through, goes on the wire (issue #372).
1863
1912
  const coverage = rasterizer.rasterize({ ...job, width: b.w, height: b.h });
1864
1913
  if (!coverage) return false;
1865
1914
 
1866
1915
  // X wants scanlines padded to 4 bytes; the rasterizer contract is
1867
- // unpadded rows. When the width is already a multiple of 4 — which every
1868
- // power-of-two icon box is — the coverage goes out as a view over the
1869
- // rasterizer's own bytes, with no copy at all.
1870
- const stride = (b.w + 3) & ~3;
1916
+ // unpadded rows. When the part sent is the whole box and its width is
1917
+ // already a multiple of 4 — which every power-of-two icon box is — the
1918
+ // coverage goes out as a view over the rasterizer's own bytes, with no
1919
+ // copy at all.
1920
+ const stride = (out.w + 3) & ~3;
1871
1921
  let data;
1872
- if (stride === b.w) {
1922
+ if (out.w === b.w && out.h === b.h && stride === b.w) {
1873
1923
  data = Buffer.isBuffer(coverage)
1874
1924
  ? coverage
1875
1925
  : Buffer.from(coverage.buffer, coverage.byteOffset, coverage.length);
1876
1926
  } else {
1877
- data = Buffer.alloc(stride * b.h);
1878
- for (let y = 0; y < b.h; ++y) {
1879
- Buffer.from(coverage.buffer, coverage.byteOffset + y * b.w, b.w).copy(
1880
- data,
1881
- y * stride,
1882
- );
1927
+ data = Buffer.alloc(stride * out.h);
1928
+ const left = out.x - b.x;
1929
+ const top = out.y - b.y;
1930
+ for (let y = 0; y < out.h; ++y) {
1931
+ const from = coverage.byteOffset + (top + y) * b.w + left;
1932
+ Buffer.from(coverage.buffer, from, out.w).copy(data, y * stride);
1883
1933
  }
1884
1934
  }
1885
1935
  this.X.PutImage(
1886
1936
  2,
1887
1937
  this.fillMaskDrawable.id,
1888
1938
  this._maskGC(),
1889
- b.w,
1890
- b.h,
1891
- b.x,
1892
- b.y,
1939
+ out.w,
1940
+ out.h,
1941
+ out.x,
1942
+ out.y,
1893
1943
  0,
1894
1944
  8,
1895
1945
  data,
@@ -1982,30 +2032,29 @@ class RenderingContext2d {
1982
2032
  */
1983
2033
  _paintThroughMask(b, job, { edges, src, op, alpha, server }) {
1984
2034
  const R = this.Render;
1985
- // A rectangular clip narrows where the coverage is *composited*; the
1986
- // mask content outside it is stale by the same argument as outside `b`,
1987
- // and a box the clip rejects outright needs no coverage at all. A stack
1988
- // whose mask was dropped by restore() may still hold a poly, and the mask
1989
- // comes back on demand. A region clip needs nothing here: the picture
1990
- // carries it, and it applies to the composite below like any other.
2035
+ // Everything is bounded to the part of the box the clip lets through:
2036
+ // the clip's rectangle, or the extents of a stack with a path in it —
2037
+ // the coverage rasterized and uploaded, the clip applied, the composite.
2038
+ // A stroke across a rounded pane used to upload its whole box through
2039
+ // the mask to paint a few rows of it (issue #372). The mask content
2040
+ // outside `out` is stale by the same argument as outside `b`, and a box
2041
+ // the clip rejects outright needs no coverage at all. A stack whose mask
2042
+ // was dropped by restore() may still hold a poly, and the mask comes
2043
+ // back on demand. A region clip needs nothing here: the picture carries
2044
+ // it, and it applies to the composite below like any other.
2045
+ const clip = this._hasPolyClip ? this._clipExtents() : this._clipRect();
1991
2046
  let out = b;
1992
- if (!this.clipMask) {
1993
- if (this._hasPolyClip) {
1994
- this._requireClipMask();
1995
- } else {
1996
- const cr = this._clipRect();
1997
- if (cr) {
1998
- out = intersectBox(b, cr);
1999
- if (!out) return false;
2000
- }
2001
- }
2047
+ if (clip) {
2048
+ out = intersectBox(b, clip);
2049
+ if (!out) return false;
2002
2050
  }
2003
- if (!this._uploadCoverage({ ...job, dx: -b.x, dy: -b.y }, b, edges)) {
2051
+ if (this._hasPolyClip) this._requireClipMask();
2052
+ if (!this._uploadCoverage({ ...job, dx: -b.x, dy: -b.y }, b, out, edges)) {
2004
2053
  R.FillRectangles(
2005
2054
  R.PictOp.Src,
2006
2055
  this.fillMask.id,
2007
2056
  [0, 0, 0, 0],
2008
- [b.x, b.y, b.w, b.h],
2057
+ [out.x, out.y, out.w, out.h],
2009
2058
  );
2010
2059
  server();
2011
2060
  }
@@ -3009,9 +3058,12 @@ class RenderingContext2d {
3009
3058
  * A solid-colour fillStyle under an identity transform and a rectangular
3010
3059
  * (or absent) clip compiles the whole list into a single
3011
3060
  * `Render.FillRectangles`, where N `fillRect` calls cost N composites.
3012
- * Gradient/Picture styles, transforms and non-rectangular clips fall
3013
- * back to that `fillRect` loop, so the answer is always right and only
3014
- * the request count varies.
3061
+ * Under a clip that is not a rectangle, rectangles that do not overlap
3062
+ * still cost three requests however many there are
3063
+ * (`_fillRectsThroughClip`). Gradient/Picture styles, transforms, and
3064
+ * overlapping rectangles or `copy` under a non-rectangular clip fall back
3065
+ * to that `fillRect` loop, so the answer is always right and only the
3066
+ * request count varies.
3015
3067
  */
3016
3068
  fillRects(rects) {
3017
3069
  if (!rects || !rects.length || this.globalAlpha <= 0) return;
@@ -3045,12 +3097,13 @@ class RenderingContext2d {
3045
3097
  if (
3046
3098
  !matIsIdentity(this._m) ||
3047
3099
  !isPlainColor(this._fillStyle) ||
3048
- this._hasPolyClip
3100
+ (this._hasPolyClip && !this._fillRectsThroughClip(flat))
3049
3101
  ) {
3050
3102
  for (let i = 0; i < flat.length; i += 4)
3051
3103
  this._fillRect(flat[i], flat[i + 1], flat[i + 2], flat[i + 3]);
3052
3104
  return;
3053
3105
  }
3106
+ if (this._hasPolyClip) return;
3054
3107
  if (clip && (clip.w === 0 || clip.h === 0)) return; // clipped away
3055
3108
  const R = this.Render;
3056
3109
  if (clip) this._setPictureClip(clip);
@@ -3073,6 +3126,102 @@ class RenderingContext2d {
3073
3126
  this._markDirty();
3074
3127
  }
3075
3128
 
3129
+ /**
3130
+ * `fillRects` under a clip that is not a rectangle — a rounded card, a
3131
+ * path: every rectangle's coverage into the scratch a8 mask with one
3132
+ * `FillRectangles`, `globalAlpha` and the clip applied to it once each,
3133
+ * and the style composited through it once. One rectangle at a time is a
3134
+ * composite through the clip each, and a chart's marks inside a rounded
3135
+ * box a zoom had put off the pixel grid were sixty thousand of them a
3136
+ * frame (issue #374).
3137
+ *
3138
+ * Only where that is the loop's picture. The rectangles must not overlap:
3139
+ * the loop blends a pixel once per rectangle over it, at the clip's
3140
+ * coverage each time, so where two overlap on the clip's antialiased edge
3141
+ * it is darker than one blend through the mask — and where they do not,
3142
+ * every pixel is blended once either way, whatever the colour's alpha.
3143
+ * And the operator must leave the destination alone where the mask is
3144
+ * zero: `copy` clears every pixel of its box the mask misses, the gaps
3145
+ * between the rectangles here. The rectangles reach the mask as the wire
3146
+ * carries every box, in whole pixels truncated toward zero, which is what
3147
+ * `_fillRect`'s composites are cut to as well.
3148
+ *
3149
+ * @returns {boolean} whether the rectangles were drawn here
3150
+ */
3151
+ _fillRectsThroughClip(flat) {
3152
+ const R = this.Render;
3153
+ const op = this._op();
3154
+ if (!this._maskBounded(op)) return false;
3155
+ const rects = [];
3156
+ let x0 = Infinity;
3157
+ let y0 = Infinity;
3158
+ let x1 = -Infinity;
3159
+ let y1 = -Infinity;
3160
+ for (let i = 0; i < flat.length; i += 4) {
3161
+ const x = Math.trunc(flat[i]);
3162
+ const y = Math.trunc(flat[i + 1]);
3163
+ const w = Math.trunc(flat[i + 2]);
3164
+ const h = Math.trunc(flat[i + 3]);
3165
+ if (w <= 0 || h <= 0) continue;
3166
+ rects.push(x, y, w, h);
3167
+ if (x < x0) x0 = x;
3168
+ if (y < y0) y0 = y;
3169
+ if (x + w > x1) x1 = x + w;
3170
+ if (y + h > y1) y1 = y + h;
3171
+ }
3172
+ if (!disjointRects(rects)) return false;
3173
+ if (!prepareStyle(this._backgroundPicture, this._m)) return true;
3174
+ // the box they cover, on the surface: nothing outside it is sampled
3175
+ const bx = Math.max(0, x0);
3176
+ const by = Math.max(0, y0);
3177
+ const bw = Math.min(this.width, x1) - bx;
3178
+ const bh = Math.min(this.height, y1) - by;
3179
+ if (!(bw > 0 && bh > 0)) return true;
3180
+ this._ensureFillMask();
3181
+ const mask = this.fillMask.id;
3182
+ R.FillRectangles(R.PictOp.Src, mask, [0, 0, 0, 0], [bx, by, bw, bh]);
3183
+ // stay under the server's maximum request size
3184
+ const chunk = 10000 * 4;
3185
+ for (let i = 0; i < rects.length; i += chunk) {
3186
+ R.FillRectangles(
3187
+ R.PictOp.Src,
3188
+ mask,
3189
+ [0, 0, 0, this.globalAlpha < 1 ? this.globalAlpha : 1],
3190
+ i === 0 && rects.length <= chunk ? rects : rects.slice(i, i + chunk),
3191
+ );
3192
+ }
3193
+ R.Composite(
3194
+ R.PictOp.In,
3195
+ this._requireClipMask().id,
3196
+ 0,
3197
+ mask,
3198
+ bx,
3199
+ by,
3200
+ 0,
3201
+ 0,
3202
+ bx,
3203
+ by,
3204
+ bw,
3205
+ bh,
3206
+ );
3207
+ R.Composite(
3208
+ op,
3209
+ this._backgroundPicture.id,
3210
+ mask,
3211
+ this._dst(),
3212
+ bx,
3213
+ by,
3214
+ bx,
3215
+ by,
3216
+ bx,
3217
+ by,
3218
+ bw,
3219
+ bh,
3220
+ );
3221
+ this._markDirty();
3222
+ return true;
3223
+ }
3224
+
3076
3225
  strokeRect(x, y, w, h) {
3077
3226
  if (this._shadowed()) this._shadowOfRect(x, y, w, h, true);
3078
3227
  const m = this._m;
@@ -3531,6 +3680,40 @@ class RenderingContext2d {
3531
3680
  return { x, y, w, h };
3532
3681
  }
3533
3682
 
3683
+ /**
3684
+ * The box the clip stack lets anything through, in device pixels on the
3685
+ * surface: the intersection of its rectangles and of its paths' bounding
3686
+ * boxes. Null when the stack holds no shape — region entries are the
3687
+ * picture's, as in `_clipRect` — and an empty box when it holds a path
3688
+ * that lands nowhere. What a drawing through the a8 mask is bounded to
3689
+ * (issue #372). Cached against the stack, which is replaced on every
3690
+ * change rather than edited.
3691
+ */
3692
+ _clipExtents() {
3693
+ if (this._extentsOf === this._clips) return this._extents;
3694
+ let out = null;
3695
+ for (const entry of this._clips) {
3696
+ if (entry.region) continue;
3697
+ const box = entry.rect ?? (entry.bbox ??= this._polysBBox(entry.polys));
3698
+ const next = box && (out ? intersectBox(out, box) : box);
3699
+ if (!next) {
3700
+ out = { x: 0, y: 0, w: 0, h: 0 };
3701
+ break;
3702
+ }
3703
+ out = next;
3704
+ }
3705
+ if (out) {
3706
+ const x = Math.max(0, Math.min(out.x, this.width));
3707
+ const y = Math.max(0, Math.min(out.y, this.height));
3708
+ const w = Math.max(0, Math.min(out.x + out.w, this.width) - x);
3709
+ const h = Math.max(0, Math.min(out.y + out.h, this.height) - y);
3710
+ out = { x, y, w, h };
3711
+ }
3712
+ this._extentsOf = this._clips;
3713
+ this._extents = out;
3714
+ return out;
3715
+ }
3716
+
3534
3717
  /**
3535
3718
  * Replace the clip stack and re-derive what kinds of entry are in it.
3536
3719
  *
package/lib/window.js CHANGED
@@ -60,6 +60,43 @@ const BACKING_GRANULARITY = 128;
60
60
  */
61
61
  const DEFAULT_FRAME_INTERVAL = 16;
62
62
 
63
+ /**
64
+ * How many frames the fence clock lets the server owe a window at once,
65
+ * unless the window asks for another number.
66
+ *
67
+ * One is a frame per round trip: the client draws, the server works through
68
+ * it, the reply comes back, and only then does the next frame start. Neither
69
+ * side works while the other does, so a frame costs the sum of the two.
70
+ * Where the reply is slow for reasons of the server's own, most of that sum
71
+ * is waiting. XQuartz answers a fence only once the frame has gone to the
72
+ * macOS window server: 13 ms at the median during a drag, against about
73
+ * 0.1 ms for an idle round trip, and the drag ran at 75-78 fps on a 120 Hz
74
+ * panel (issue #369). With two, the next frame is drawn during that wait,
75
+ * and the same drag runs at 107-118. A slow link gains the same way: a frame
76
+ * per round trip becomes two.
77
+ *
78
+ * Not more, because past two the wait is already overlapped, and each frame
79
+ * queued behind a busy server, or on a link too narrow for it, is a frame of
80
+ * latency. Where a blit goes out through Present it stays one (see
81
+ * _frameLimit).
82
+ */
83
+ const DEFAULT_MAX_FRAMES_IN_FLIGHT = 2;
84
+
85
+ /**
86
+ * `maxFramesInFlight` as given, or a RangeError saying what it takes.
87
+ *
88
+ * Zero is refused, not read as "no limit", which is what `frameInterval: 0`
89
+ * would lead a reader to expect: a window that may have no frame in flight
90
+ * never draws. The way to wait on the server not at all is `frameSync: false`.
91
+ */
92
+ function framesInFlightLimit(n) {
93
+ if (Number.isInteger(n) && n >= 1) return n;
94
+ throw new RangeError(
95
+ `ntk: maxFramesInFlight is a number of frames, 1 or more — got ${String(n)}. ` +
96
+ 'For no limit at all, create the window with frameSync: false.'
97
+ );
98
+ }
99
+
63
100
  /**
64
101
  * The range a measured vblank period has to fall in to be believed, in ms.
65
102
  *
@@ -432,6 +469,9 @@ export default class Window extends Drawable {
432
469
  if (args.id === 0) {
433
470
  throw new Error('ntk: 0 (None) is not a window id');
434
471
  }
472
+ const maxFramesInFlight = framesInFlightLimit(
473
+ args.maxFramesInFlight ?? DEFAULT_MAX_FRAMES_IN_FLIGHT
474
+ );
435
475
  if (args.id) {
436
476
  const cached = Window._cacheFor(app).get(args.id);
437
477
  // TODO: check event mask, if more events in args - update it
@@ -564,12 +604,15 @@ export default class Window extends Drawable {
564
604
  // that probe answers adopts the result when it lands.
565
605
  this._frameInterval = args.frameInterval ?? app.frameInterval ?? DEFAULT_FRAME_INTERVAL;
566
606
  this._frameIntervalExplicit = args.frameInterval !== undefined;
607
+ // how many fences may be outstanding before the next frame waits for one
608
+ // (see _frameLimit)
609
+ this._maxFramesInFlight = maxFramesInFlight;
567
610
  this.frameLatency = null;
568
611
  this._frame = {
569
612
  pending: new Map(), // coalescible event name -> merged event
570
613
  rafCbs: [],
571
614
  rafId: 0,
572
- inFlight: false, // fence round-trip awaiting the server's reply
615
+ fences: 0, // fence round trips awaiting the server's reply
573
616
  presentInFlight: false, // present awaiting the display's CompleteNotify
574
617
  presentAt: 0, // when that present was sent, for frameLatency
575
618
  stallTimer: null,
@@ -1348,9 +1391,13 @@ export default class Window extends Drawable {
1348
1391
  * That includes a server whose Present has no display behind it and
1349
1392
  * makes its vertical blanks up from a timer (see _probeMsc).
1350
1393
  *
1351
- * Either way at most one is outstanding, which is what bounds the work in
1352
- * flight: on a slow connection frames degrade to one per round-trip instead
1353
- * of queueing a trail of stale updates.
1394
+ * Either way the frames outstanding are bounded, which is what bounds the
1395
+ * work in flight: one present on the display's clock, and on the fence
1396
+ * `maxFramesInFlight` fences, two unless the window asked otherwise (see
1397
+ * _frameLimit). On a slow connection frames degrade to that many per round
1398
+ * trip instead of queueing a trail of stale updates. Two rather than one so
1399
+ * that the next frame can be drawn while the server is still working
1400
+ * through the last, instead of each waiting out the other.
1354
1401
  *
1355
1402
  * A timer backs both up. Under the fence it is the rate limit — at most one
1356
1403
  * frame per `frameInterval` ms, so a fast local server isn't asked to redraw
@@ -1371,18 +1418,63 @@ export default class Window extends Drawable {
1371
1418
  */
1372
1419
  _scheduleFrame() {
1373
1420
  const f = this._frame;
1374
- if (f.scheduled || f.inFlight || f.presentInFlight || f.timer) return;
1421
+ if (f.scheduled || this._fencesFull() || f.presentInFlight || f.timer) return;
1375
1422
  f.scheduled = true;
1376
1423
  setImmediate(() => {
1377
1424
  f.scheduled = false;
1378
1425
  // gates may have been armed after this got scheduled (work queued
1379
1426
  // from inside a running frame, e.g. a rAF loop re-registering) —
1380
1427
  // the completion / fence reply / timer expiry will reschedule then
1381
- if (f.inFlight || f.presentInFlight || f.timer) return;
1428
+ if (this._fencesFull() || f.presentInFlight || f.timer) return;
1382
1429
  this._runFrame();
1383
1430
  });
1384
1431
  }
1385
1432
 
1433
+ /**
1434
+ * How many frames may be waiting for their fence before the next one waits
1435
+ * too: `maxFramesInFlight`, except where a blit goes out through Present.
1436
+ *
1437
+ * A second frame is safe behind a CopyArea. The server runs requests in
1438
+ * order, so the copy has read the backing store before anything drawn after
1439
+ * it lands there, and the fence says no more than that the server read the
1440
+ * frame — which is all the next one needs. A present with Option.Copy is
1441
+ * different: it owns the backing store until the copy executes, at the next
1442
+ * vertical blank, and the fence reply says only that the server has read the
1443
+ * present. A frame drawn behind it early can put half of itself on the screen
1444
+ * (the hazard issue #223 describes), and a second frame in flight would
1445
+ * widen that window. So a window whose blits are presents keeps one, whether
1446
+ * the fence or the display is ending its frames — on the display's clock a
1447
+ * present outstanding is the gate anyway, and there is never more than one.
1448
+ *
1449
+ * A window with no backing store has no blit to protect. Its frames go by
1450
+ * the limit as a CopyArea window's do.
1451
+ */
1452
+ _frameLimit() {
1453
+ return this._blitsThroughPresent() ? 1 : this._maxFramesInFlight;
1454
+ }
1455
+
1456
+ /** Have the frames waiting for their fence reached the limit? */
1457
+ _fencesFull() {
1458
+ return this._frame.fences >= this._frameLimit();
1459
+ }
1460
+
1461
+ /**
1462
+ * How many frames the server may owe this window at once, on the fence
1463
+ * clock, before the next one waits for the reply to the oldest: 2 unless
1464
+ * set. `1` is a frame per round trip. Where blits go through Present, or
1465
+ * the display is ending frames, one is all there is, whatever this says —
1466
+ * see _frameLimit.
1467
+ */
1468
+ get maxFramesInFlight() {
1469
+ return this._maxFramesInFlight;
1470
+ }
1471
+
1472
+ set maxFramesInFlight(n) {
1473
+ // A raised limit needs no wakeup: the gate was shut because fences are
1474
+ // outstanding, and the next reply runs what it held back.
1475
+ this._maxFramesInFlight = framesInFlightLimit(n);
1476
+ }
1477
+
1386
1478
  /**
1387
1479
  * Minimum ms between paced frames, and between blits.
1388
1480
  *
@@ -1636,15 +1728,24 @@ export default class Window extends Drawable {
1636
1728
  for (const [name, ev] of pending) this.emit(name, ev);
1637
1729
  }
1638
1730
 
1731
+ /**
1732
+ * Fence the frame that just went out: one request with a reply, answered
1733
+ * once the server has read everything before it.
1734
+ *
1735
+ * Every frame gets its own, so `fences` is exactly the number of frames the
1736
+ * server has not confirmed. Nothing here refuses one past the limit — the
1737
+ * callers only send a frame while the gate is open, and a frame sent some
1738
+ * other way is still better counted than left unfenced, where the gate
1739
+ * would open with it unconfirmed.
1740
+ */
1639
1741
  _armFence() {
1640
1742
  if (!this._frameSyncEnabled) return;
1641
1743
  const f = this._frame;
1642
- if (f.inFlight) return;
1643
1744
  const start = performance.now();
1644
1745
  safeRelease(this.X, () => {
1645
- f.inFlight = true;
1746
+ f.fences++;
1646
1747
  this.X.GetInputFocus(() => {
1647
- f.inFlight = false;
1748
+ f.fences--;
1648
1749
  this.frameLatency = performance.now() - start;
1649
1750
  this._frameEnded();
1650
1751
  });
@@ -2139,8 +2240,9 @@ export default class Window extends Drawable {
2139
2240
  * DOM-style requestAnimationFrame: `cb(now)` runs on this window's next
2140
2241
  * paced frame — one per vertical blank where the display is the clock (see
2141
2242
  * _clockOnPresent), otherwise at most once per `frameInterval` ms — and
2142
- * always with at most one frame outstanding, so animation loops adapt to
2143
- * the output's rate and to connection latency instead of flooding either.
2243
+ * always with a bounded number of frames outstanding (one on the display's
2244
+ * clock, `maxFramesInFlight` on the fence), so animation loops adapt to the
2245
+ * output's rate and to connection latency instead of flooding either.
2144
2246
  * Returns an id for cancelAnimationFrame().
2145
2247
  */
2146
2248
  requestAnimationFrame(cb) {
@@ -2159,9 +2261,12 @@ export default class Window extends Drawable {
2159
2261
 
2160
2262
  /**
2161
2263
  * Whether a blit this window already owes is still waiting to go out —
2162
- * because the last frame is unanswered (its fence unreplied, or its present
2163
- * not yet on the display), or because a present is deferred behind the
2164
- * minimum inter-blit interval.
2264
+ * because the frames already sent are unanswered (as many fences unreplied
2265
+ * as `maxFramesInFlight` allows, or a present not yet on the display), or
2266
+ * because a present is deferred behind the minimum inter-blit interval.
2267
+ *
2268
+ * So it reads false with a frame in flight when the limit allows another:
2269
+ * a blit drawn then goes out at once, which is the question this answers.
2165
2270
  *
2166
2271
  * The one bit of frame-clock state worth publishing, because it is the
2167
2272
  * difference between the two ways a toolkit can answer a discrete input.
@@ -2188,16 +2293,17 @@ export default class Window extends Drawable {
2188
2293
  * asked for both gets the unpaced behaviour those options ask for.
2189
2294
  */
2190
2295
  frameInFlight() {
2191
- return this._frame.inFlight || this._frame.presentInFlight || this._presentPending;
2296
+ return this._fencesFull() || this._frame.presentInFlight || this._presentPending;
2192
2297
  }
2193
2298
 
2194
2299
  _present() {
2195
2300
  if (!this._backing) return;
2196
2301
  const f = this._frame;
2197
2302
  const wait = this._presentWait();
2198
- if (f.inFlight || f.presentInFlight || wait > 0) {
2199
- // the server hasn't confirmed the previous frame yet, the display hasn't
2200
- // shown it, or the last blit was too recent — defer, leaving a wakeup
2303
+ if (this._fencesFull() || f.presentInFlight || wait > 0) {
2304
+ // the server hasn't confirmed enough of the frames already sent, the
2305
+ // display hasn't shown the last one, or the last blit was too recent —
2306
+ // defer, leaving a wakeup
2201
2307
  this._deferPresent(wait);
2202
2308
  return;
2203
2309
  }
@@ -2252,7 +2358,7 @@ export default class Window extends Drawable {
2252
2358
  if (!this._presentPending && !this._dirty) return; // someone blitted it
2253
2359
  const again = this._presentWait();
2254
2360
  if (again > 0) return this._deferPresent(again); // a paced frame re-stamped
2255
- if (f.inFlight || f.presentInFlight) return; // the frame's end is closer
2361
+ if (this._fencesFull() || f.presentInFlight) return; // the frame's end is closer
2256
2362
  this._presentNow();
2257
2363
  if (!f.presentInFlight) this._armFence();
2258
2364
  }, wait);
@@ -2333,15 +2439,25 @@ export default class Window extends Drawable {
2333
2439
  });
2334
2440
  }
2335
2441
 
2442
+ /**
2443
+ * Would a blit go out through the Present extension right now, rather than
2444
+ * as CopyArea? Not before the extensions have answered, and not once the
2445
+ * window has left the path (see _presentAbandoned).
2446
+ */
2447
+ _blitsThroughPresent() {
2448
+ return (
2449
+ !!this._presentExt && !!this._fixesExt && !!this._updateRegion && !this._presentAbandoned()
2450
+ );
2451
+ }
2452
+
2336
2453
  /**
2337
2454
  * The Present form of the blit. Returns false when it did not happen, so
2338
2455
  * the caller falls back to CopyArea — which is also what runs before the
2339
2456
  * extensions have answered.
2340
2457
  */
2341
2458
  _presentWithExtension(rects) {
2459
+ if (!this._blitsThroughPresent()) return false;
2342
2460
  const P = this._presentExt;
2343
- if (!P || !this._fixesExt || !this._updateRegion) return false;
2344
- if (this._presentAbandoned()) return false;
2345
2461
  safeRelease(this.X, () => {
2346
2462
  this._fixesExt.SetRegion(
2347
2463
  this._updateRegion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.11.1",
3
+ "version": "8.12.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",