ntk 3.10.1 → 3.10.2

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.
@@ -236,7 +236,14 @@ class RenderingContext2d {
236
236
 
237
237
  // notify a double-buffered window that its backing content changed
238
238
  _markDirty() {
239
- if (typeof this.window._markDirty === 'function') this.window._markDirty();
239
+ if (typeof this.window._markDirty !== 'function') return;
240
+ // The clip bounds everything this operation could have touched, so it is
241
+ // also the region the window has to blit. Reporting it lets the present
242
+ // copy the part of the backing store that changed instead of all of it —
243
+ // a hover repaint of two tab headers used to blit the whole window. A
244
+ // context with no rectangular clip reports nothing, and the window falls
245
+ // back to a full blit, which is what any drawing outside a clip needs.
246
+ this.window._markDirty(this._clips.length ? this._clipRect() : null);
240
247
  }
241
248
 
242
249
  // html context2d compatibility: canvas.getContext('2d').canvas == canvas
package/lib/window.js CHANGED
@@ -6,6 +6,108 @@ import Drawable from './drawable.js';
6
6
  import Pixmap from './pixmap.js';
7
7
  import * as xevents from './events_map.js';
8
8
 
9
+ /**
10
+ * How many rectangles a frame's dirty region is allowed to hold.
11
+ *
12
+ * Drawing reports one region per operation, so a repaint reports hundreds, and
13
+ * an uncapped list would turn every one of them into its own CopyArea. Past
14
+ * the cap the cheapest pair is merged (see addDirtyRect), which keeps
15
+ * genuinely separated clusters apart while adjacent ones coalesce.
16
+ */
17
+ const MAX_DIRTY_RECTS = 8;
18
+
19
+ /**
20
+ * How much of the surrounding box a split has to save to be worth making.
21
+ *
22
+ * Several rectangles are several CopyArea requests, and they only beat the one
23
+ * box around them when they are meaningfully smaller than it: two tab headers
24
+ * side by side describe nearly the same area either way, two corners of the
25
+ * window do not. Below this fraction the region is collapsed to its box.
26
+ */
27
+ const SPLIT_SAVING = 0.75;
28
+
29
+ function rectArea(r) {
30
+ return Math.max(0, r.w) * Math.max(0, r.h);
31
+ }
32
+
33
+ function coversRect(outer, inner) {
34
+ return (
35
+ inner.x >= outer.x &&
36
+ inner.y >= outer.y &&
37
+ inner.x + inner.w <= outer.x + outer.w &&
38
+ inner.y + inner.h <= outer.y + outer.h
39
+ );
40
+ }
41
+
42
+ function mergeRects(a, b) {
43
+ const x = Math.min(a.x, b.x);
44
+ const y = Math.min(a.y, b.y);
45
+ const right = Math.max(a.x + a.w, b.x + b.w);
46
+ const bottom = Math.max(a.y + a.h, b.y + b.h);
47
+ return { x, y, w: right - x, h: bottom - y };
48
+ }
49
+
50
+ /** The box around a non-empty list of rectangles. */
51
+ function rectsBounds(rects) {
52
+ let out = rects[0];
53
+ for (let i = 1; i < rects.length; i++) out = mergeRects(out, rects[i]);
54
+ return out;
55
+ }
56
+
57
+ /**
58
+ * Add one rectangle to a capped list, returning a new list.
59
+ *
60
+ * A rectangle already covered by one in the list adds nothing, which is the
61
+ * common case by a wide margin: consecutive operations under the same clip all
62
+ * report the same region. Otherwise it goes on the end, and if that overflows
63
+ * the cap the pair whose merge wastes the least area is merged — waste being
64
+ * the area the merged rectangle covers that neither of the two did, so
65
+ * overlapping and adjacent pairs go first and far-apart ones last.
66
+ */
67
+ function addDirtyRect(rects, add) {
68
+ for (const r of rects) {
69
+ if (coversRect(r, add)) return rects;
70
+ }
71
+ const out = rects.filter((r) => !coversRect(add, r));
72
+ out.push({ x: add.x, y: add.y, w: add.w, h: add.h });
73
+ if (out.length <= MAX_DIRTY_RECTS) return out;
74
+ let bestI = 0;
75
+ let bestJ = 1;
76
+ let bestMerged = null;
77
+ let bestWaste = Infinity;
78
+ for (let i = 0; i < out.length; i++) {
79
+ for (let j = i + 1; j < out.length; j++) {
80
+ const merged = mergeRects(out[i], out[j]);
81
+ const waste = rectArea(merged) - rectArea(out[i]) - rectArea(out[j]);
82
+ if (waste < bestWaste) {
83
+ bestWaste = waste;
84
+ bestI = i;
85
+ bestJ = j;
86
+ bestMerged = merged;
87
+ }
88
+ }
89
+ }
90
+ out[bestI] = bestMerged;
91
+ out.splice(bestJ, 1);
92
+ return out;
93
+ }
94
+
95
+ /**
96
+ * Union a reported region into a frame's dirty region, where `undefined` is
97
+ * "nothing yet" and `null` is "unbounded".
98
+ *
99
+ * The asymmetry is the whole point: once any operation declines to say where it
100
+ * drew, the region cannot be trusted to bound the frame, and it has to stay
101
+ * unbounded no matter what is unioned into it afterwards. Getting that
102
+ * backwards means blitting less than was drawn, which leaves stale pixels on
103
+ * screen — so the absorbing state is the safe one.
104
+ */
105
+ function unionDirty(current, add) {
106
+ if (current === null || add === null || add === undefined) return null;
107
+ if (current === undefined) return [{ x: add.x, y: add.y, w: add.w, h: add.h }];
108
+ return addDirtyRect(current, add);
109
+ }
110
+
9
111
  // X window attributes forwarded verbatim from constructor args into the
10
112
  // CreateWindow value list (node-x11 valueMask names, see x11
11
113
  // lib/corereqs.js). Deliberately not forwarded:
@@ -397,9 +499,25 @@ export default class Window extends Drawable {
397
499
  this._scheduleFrame();
398
500
  }
399
501
 
400
- // called by rendering contexts after each drawing operation
401
- _markDirty() {
502
+ /**
503
+ * Called by rendering contexts after each drawing operation.
504
+ *
505
+ * `bounds` is the region that operation could have touched — a context
506
+ * reports its clip rectangle, which by definition contains everything it
507
+ * drew. The regions accumulate, and the next blit copies just those instead
508
+ * of the whole backing store: a repaint of two tab headers is a 125x31
509
+ * CopyArea rather than a 1000x700 one. They accumulate as a short list of
510
+ * rectangles rather than one box around them all, so two repaints at
511
+ * opposite corners of the window no longer drag everything between them
512
+ * along. An operation that reports nothing, because it was not clipped,
513
+ * gives up the optimisation for this frame and the blit covers everything —
514
+ * the safe direction, and the reason this needs no cooperation from the
515
+ * caller to be correct.
516
+ */
517
+ _markDirty(bounds) {
402
518
  if (!this._backing) return;
519
+ if (!this._dirty) this._dirtyRegion = undefined;
520
+ this._dirtyRegion = unionDirty(this._dirtyRegion, bounds);
403
521
  this._dirty = true;
404
522
  this._backingValid = true;
405
523
  if (this._presentScheduled) return;
@@ -591,14 +709,50 @@ export default class Window extends Drawable {
591
709
  this._presentPending = false;
592
710
  if (!this._backing) return;
593
711
  this._dirty = false;
712
+ const region = this._dirtyRegion;
713
+ this._dirtyRegion = undefined;
594
714
  const w = Math.min(this.width, this._backing.width);
595
715
  const h = Math.min(this.height, this._backing.height);
716
+ // Clamped to both drawables: CopyArea outside either is a no-op region at
717
+ // best, and the backing store is grow-only so it can be larger than the
718
+ // window after a shrink. A rectangle that survives clamping with no area
719
+ // left is dropped; if that leaves nothing, nothing needs copying.
720
+ const clamped = [];
721
+ for (const rect of region ?? [{ x: 0, y: 0, w, h }]) {
722
+ const x0 = Math.max(0, Math.floor(rect.x));
723
+ const y0 = Math.max(0, Math.floor(rect.y));
724
+ const x1 = Math.min(w, Math.ceil(rect.x + rect.w));
725
+ const y1 = Math.min(h, Math.ceil(rect.y + rect.h));
726
+ if (x1 > x0 && y1 > y0) clamped.push({ x: x0, y: y0, w: x1 - x0, h: y1 - y0 });
727
+ }
728
+ if (!clamped.length) return;
729
+ const rects = this._blitList(clamped);
596
730
  // a paced frame can fire after the connection started closing
597
731
  safeRelease(this.X, () => {
598
- this.X.CopyArea(this._backing.id, this.id, this._presentGc, 0, 0, 0, 0, w, h);
732
+ for (const r of rects) {
733
+ this.X.CopyArea(this._backing.id, this.id, this._presentGc, r.x, r.y, r.x, r.y, r.w, r.h);
734
+ }
599
735
  });
600
736
  }
601
737
 
738
+ /**
739
+ * The rectangles a present actually copies, given the ones drawing reported.
740
+ *
741
+ * The list is not used as it stands: each rectangle is a request, and a
742
+ * region whose pieces nearly fill the box around them is better served by
743
+ * one copy of the box. So the split is kept only when it saves enough of
744
+ * that box to pay for the extra requests, and collapsed otherwise. Both
745
+ * directions cover every reported pixel, so this is a cost decision and
746
+ * never a correctness one.
747
+ */
748
+ _blitList(rects) {
749
+ if (rects.length < 2) return rects;
750
+ const box = rectsBounds(rects);
751
+ let sum = 0;
752
+ for (const r of rects) sum += rectArea(r);
753
+ return sum > rectArea(box) * SPLIT_SAVING ? [box] : rects;
754
+ }
755
+
602
756
  map() {
603
757
  this.X.MapWindow(this.id);
604
758
  return this;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "3.10.1",
3
+ "version": "3.10.2",
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",