ntk 8.13.0 → 8.14.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.
package/lib/app.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { connectionGone } from './cleanup.js';
2
+ import { cssColor } from './color.js';
2
3
  import Clipboard from './clipboard.js';
3
4
  import { CursorCache } from './cursor.js';
4
5
  import {
@@ -147,6 +148,8 @@ export default class App {
147
148
  sharedGlyphsFor(this);
148
149
  this._cursors = null;
149
150
  this._solidPictures = new Map();
151
+ // the same pictures by the CSS colour that asked for them (solidPictureOf)
152
+ this._solidByName = new Map();
150
153
  this._rasterizer = undefined;
151
154
  this._shm = undefined;
152
155
  this._extensionPromises = new Map();
@@ -345,12 +348,16 @@ export default class App {
345
348
  const X = this.X;
346
349
  if (connectionGone(X)) return;
347
350
  const name = '_NET_WM_CM_S0';
351
+ // Each answer can arrive after the app has closed — a window that
352
+ // presented once and was torn down at once — and a request made from it
353
+ // then throws out of the reply dispatch, where no caller can catch it.
348
354
  X.QueryExtension('XWAYLAND', (xwErr, ext) => {
349
355
  this._xwayland = !xwErr && !!ext?.present;
356
+ if (connectionGone(X)) return;
350
357
  X.InternAtom(false, name, (atomErr, atom) => {
351
- if (atomErr || !atom) return;
358
+ if (atomErr || !atom || connectionGone(X)) return;
352
359
  X.GetSelectionOwner(atom, (ownerErr, owner) => {
353
- if (ownerErr) return;
360
+ if (ownerErr || connectionGone(X)) return;
354
361
  this._setCompositing(owner !== 0);
355
362
  this.clipboard
356
363
  .watch(name, (ev) => this._setCompositing(ev.reason === 'new-owner' && ev.owner !== 0))
@@ -988,6 +995,26 @@ export default class App {
988
995
  return p;
989
996
  }
990
997
 
998
+ /**
999
+ * The solid picture a CSS colour string paints with. A UI sets the same few
1000
+ * strings again on every paint — every box's background, every border —
1001
+ * and the way to `solidPicture` is a parse, a premultiply and a key made of
1002
+ * four numbers turned into text; by the string it is one lookup. Throws, as
1003
+ * `fillStyle` always has, for a string that is not a colour.
1004
+ */
1005
+ solidPictureOf(value) {
1006
+ let p = this._solidByName.get(value);
1007
+ if (p !== undefined) return p;
1008
+ const c = cssColor(value);
1009
+ if (!c) throw new Error(`Not a color: ${JSON.stringify(value)}`);
1010
+ p = this.solidPicture(c[0], c[1], c[2], c[3]);
1011
+ // spellings are unbounded where solids are not: an app animating a
1012
+ // colour writes a new string every frame
1013
+ if (this._solidByName.size >= 1024) this._solidByName.clear();
1014
+ this._solidByName.set(value, p);
1015
+ return p;
1016
+ }
1017
+
991
1018
  // flush pending requests and close the connection
992
1019
  close() {
993
1020
  if (this._cursors) {
@@ -1008,6 +1035,7 @@ export default class App {
1008
1035
  picture._sourcePixmap?.destroy();
1009
1036
  }
1010
1037
  this._solidPictures.clear();
1038
+ this._solidByName.clear();
1011
1039
  // GPU contexts are shared by every direct GL surface on this connection
1012
1040
  // (see renderingcontext_gles.js), so they outlive individual contexts and
1013
1041
  // are the connection's to release. Each holds an EGL display, a GBM device
package/lib/clipboard.js CHANGED
@@ -63,6 +63,7 @@ export default class Clipboard {
63
63
  this._fixes = null; // XFixes extension, required on first watch()
64
64
  this._fixesFirstEvent = -1; // its event base, to recognise its events
65
65
  this._selectionWatchers = new Map(); // selection atom -> { name, handlers }
66
+ this._listening = false; // _onXEvent is on the raw event stream
66
67
  this._onXEvent = this._onXEvent.bind(this);
67
68
  }
68
69
 
@@ -328,11 +329,17 @@ export default class Clipboard {
328
329
  // never delivers them to the Window wrapper — listen on the raw
329
330
  // client instead. INCR chunk pacing needs PropertyNotify for the
330
331
  // requestor's window, which is not ours either.
331
- this.X.on('event', this._onXEvent);
332
+ this._listen();
332
333
  })();
333
334
  return this._ready;
334
335
  }
335
336
 
337
+ _listen() {
338
+ if (this._listening) return;
339
+ this._listening = true;
340
+ this.X.on('event', this._onXEvent);
341
+ }
342
+
336
343
  /**
337
344
  * Call `handler` whenever a selection changes hands.
338
345
  *
@@ -355,6 +362,9 @@ export default class Clipboard {
355
362
  *
356
363
  * Watchers share one server-side registration per selection, so watching
357
364
  * the same selection twice costs one extra callback and no extra protocol.
365
+ * The registration is on the root window, as GDK's is, so watching makes
366
+ * no window of the clipboard's own: an app that only watches — every app
367
+ * does, through `App#compositing` — has none.
358
368
  *
359
369
  * @param {string} selection selection atom name, e.g. 'CLIPBOARD' or 'PRIMARY'
360
370
  * @param {function} handler called with the event
@@ -364,9 +374,10 @@ export default class Clipboard {
364
374
  if (typeof handler !== 'function') {
365
375
  throw new TypeError('clipboard: watch needs a handler function');
366
376
  }
367
- await this._ensure();
368
377
  const fixes = await this._ensureFixes();
369
378
  const sel = await this._atom(selection);
379
+ this._listen();
380
+ const root = this.app.display.screen[0].root;
370
381
 
371
382
  let entry = this._selectionWatchers.get(sel);
372
383
  if (!entry) {
@@ -376,7 +387,7 @@ export default class Clipboard {
376
387
  fixes.SelectionEventMask.SetSelectionOwner |
377
388
  fixes.SelectionEventMask.SelectionWindowDestroy |
378
389
  fixes.SelectionEventMask.SelectionClientClose;
379
- safeRelease(this.X, () => fixes.SelectSelectionInput(this._window.id, sel, mask));
390
+ safeRelease(this.X, () => fixes.SelectSelectionInput(root, sel, mask));
380
391
  }
381
392
  entry.handlers.add(handler);
382
393
 
@@ -390,7 +401,7 @@ export default class Clipboard {
390
401
  if (current.handlers.size) return;
391
402
  // last watcher for this selection: drop the server-side registration
392
403
  this._selectionWatchers.delete(sel);
393
- safeRelease(this.X, () => fixes.SelectSelectionInput(this._window.id, sel, 0));
404
+ safeRelease(this.X, () => fixes.SelectSelectionInput(root, sel, 0));
394
405
  };
395
406
  }
396
407
 
@@ -522,7 +533,6 @@ export default class Clipboard {
522
533
  }
523
534
 
524
535
  _onXEvent(ev) {
525
- if (!this._window) return;
526
536
  // XFixes events carry a server-assigned type above the core range, so
527
537
  // this cannot be confused with core SelectionNotify, which is 31
528
538
  if (ev.type === this._fixesFirstEvent && this._selectionWatchers.size) {
@@ -532,6 +542,7 @@ export default class Clipboard {
532
542
  );
533
543
  return;
534
544
  }
545
+ if (!this._window) return;
535
546
  if (ev.type === 28) {
536
547
  // PropertyNotify, state 1 = Deleted: a requestor consumed the INCR
537
548
  // chunk we gave it and is asking for the next one
@@ -0,0 +1,128 @@
1
+ // Geometry clipped to a rectangle, for what goes to the server in XRender's
2
+ // 16.16 fixed point: trapezoids and triangles. A coordinate past 32,767 there
3
+ // overflows the 32-bit word node-x11 writes it into, and the RangeError comes
4
+ // out of the paint that drew it. A box of a code block twenty thousand lines
5
+ // tall in a scroll pane was one, filled as a rounded rectangle.
6
+ //
7
+ // Only what reaches past what the wire can carry is clipped (`WIRE_REACH`):
8
+ // everything else goes out as it came, so a drawing that fits is sent exactly
9
+ // as before.
10
+
11
+ /**
12
+ * How far from a picture's origin a coordinate may reach and still be
13
+ * carried: half of what 16.16 fixed point and a 16-bit origin hold, so an
14
+ * offset the size of a surface on top of it still fits. Geometry within it
15
+ * goes to the server as it came, and the server clips it — cutting it here
16
+ * costs more than that, and a zoomed graph has hundreds of edges a little
17
+ * past the window every frame. Only geometry past it is cut.
18
+ */
19
+ export const WIRE_REACH = 16384;
20
+
21
+ /** Whether every point of a flat `[x, y, …]` list is inside the rectangle. */
22
+ export function withinRect(pts, x0, y0, x1, y1) {
23
+ for (let i = 0; i < pts.length; i += 2) {
24
+ const x = pts[i];
25
+ const y = pts[i + 1];
26
+ if (x < x0 || x > x1 || y < y0 || y > y1) return false;
27
+ }
28
+ return true;
29
+ }
30
+
31
+ /**
32
+ * A closed ring clipped to the rectangle (Sutherland–Hodgman), as a flat
33
+ * `[x, y, …]` list, empty when nothing of it is inside. Clipping each ring of
34
+ * a shape on its own keeps its fill, nonzero or even-odd, the same at every
35
+ * point inside a convex region.
36
+ */
37
+ export function clipRingToRect(pts, x0, y0, x1, y1) {
38
+ let ring = pts;
39
+ // each edge of the rectangle: which coordinate, which bound, which side
40
+ for (const [axis, bound, keepAbove] of [
41
+ [0, x0, true],
42
+ [0, x1, false],
43
+ [1, y0, true],
44
+ [1, y1, false],
45
+ ]) {
46
+ const n = ring.length / 2;
47
+ if (n === 0) break;
48
+ const out = [];
49
+ const inside = (i) =>
50
+ keepAbove ? ring[i * 2 + axis] >= bound : ring[i * 2 + axis] <= bound;
51
+ for (let i = 0; i < n; i++) {
52
+ const j = (i + n - 1) % n; // the previous point
53
+ const iin = inside(i);
54
+ const jin = inside(j);
55
+ if (iin !== jin) {
56
+ // where the segment from j to i crosses the bound
57
+ const a = ring[j * 2 + axis];
58
+ const b = ring[i * 2 + axis];
59
+ const t = (bound - a) / (b - a);
60
+ const other = 1 - axis;
61
+ const oa = ring[j * 2 + other];
62
+ const ob = ring[i * 2 + other];
63
+ const p = [0, 0];
64
+ p[axis] = bound;
65
+ p[other] = oa + (ob - oa) * t;
66
+ out.push(p[0], p[1]);
67
+ }
68
+ if (iin) out.push(ring[i * 2], ring[i * 2 + 1]);
69
+ }
70
+ ring = out;
71
+ }
72
+ return ring;
73
+ }
74
+
75
+ /**
76
+ * A flat list of triangles, six numbers each, with every one that reaches
77
+ * past the rectangle clipped to it and cut back into triangles (a fan: what
78
+ * is left of a triangle inside a rectangle is convex).
79
+ */
80
+ export function clipTrianglesToRect(tris, x0, y0, x1, y1) {
81
+ const out = [];
82
+ for (let i = 0; i + 5 < tris.length; i += 6) {
83
+ const tri = tris.slice(i, i + 6);
84
+ if (withinRect(tri, x0, y0, x1, y1)) {
85
+ out.push(...tri);
86
+ continue;
87
+ }
88
+ const poly = clipRingToRect(tri, x0, y0, x1, y1);
89
+ for (let k = 2; k * 2 + 1 < poly.length; k++) {
90
+ out.push(
91
+ poly[0],
92
+ poly[1],
93
+ poly[(k - 1) * 2],
94
+ poly[(k - 1) * 2 + 1],
95
+ poly[k * 2],
96
+ poly[k * 2 + 1],
97
+ );
98
+ }
99
+ }
100
+ return out;
101
+ }
102
+
103
+ /**
104
+ * A flat `[x, y, w, h, …]` list of rectangles cut to `[0, width) × [0,
105
+ * height)`, dropping the ones with nothing left: core X's rectangles are 16
106
+ * bits. The list itself when every rectangle is already on the surface.
107
+ */
108
+ export function rectsOnSurface(flat, width, height) {
109
+ let out = null;
110
+ for (let i = 0; i + 3 < flat.length; i += 4) {
111
+ const x = flat[i];
112
+ const y = flat[i + 1];
113
+ const w = flat[i + 2];
114
+ const h = flat[i + 3];
115
+ const inside = x >= 0 && y >= 0 && x + w <= width && y + h <= height;
116
+ if (inside) {
117
+ if (out) out.push(x, y, w, h);
118
+ continue;
119
+ }
120
+ if (!out) out = flat.slice(0, i);
121
+ const x0 = Math.max(x, 0);
122
+ const y0 = Math.max(y, 0);
123
+ const x1 = Math.min(x + w, width);
124
+ const y1 = Math.min(y + h, height);
125
+ if (x1 > x0 && y1 > y0) out.push(x0, y0, x1 - x0, y1 - y0);
126
+ }
127
+ return out ?? flat;
128
+ }
package/lib/color.js CHANGED
@@ -42,6 +42,17 @@ function parseHex(value) {
42
42
  return [part(0) / 255, part(1) / 255, part(2) / 255, hasAlpha ? part(3) / 255 : 1];
43
43
  }
44
44
 
45
+ // Each spelling is parsed once. A UI draws with a few dozen colours and sets
46
+ // them again on every paint — every box's background, every border, every
47
+ // edge of a graph — and a parse is a regular expression and an allocation
48
+ // or two: 6% of a 2D graph pan's frame, 3% of a table's. Bounded, and
49
+ // dropped whole rather than evicted, since a palette is a few dozen strings
50
+ // (react-x11's Cocoa context keeps the same cache for the same reason). The
51
+ // caller gets a copy, as it always has: an array handed out is its to keep
52
+ // or scale.
53
+ const parsed = new Map();
54
+ const PARSED_MAX = 512;
55
+
45
56
  /**
46
57
  * Parse a CSS colour to `[r, g, b, a]` floats in 0..1 with **straight**
47
58
  * (unassociated) alpha, or null if it is not a colour.
@@ -57,6 +68,16 @@ function parseHex(value) {
57
68
  */
58
69
  export function cssColorStraight(value) {
59
70
  if (typeof value !== 'string') return null;
71
+ let rgba = parsed.get(value);
72
+ if (rgba === undefined) {
73
+ rgba = parseStraight(value);
74
+ if (parsed.size >= PARSED_MAX) parsed.clear();
75
+ parsed.set(value, rgba);
76
+ }
77
+ return rgba && rgba.slice();
78
+ }
79
+
80
+ function parseStraight(value) {
60
81
  const v = value.trim();
61
82
  if (!v) return null;
62
83
  if (v.toLowerCase() === 'transparent') return [0, 0, 0, 0];