ntk 6.0.1 → 6.2.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
@@ -4,6 +4,7 @@ import { chooseGLXConfig } from './glx.js';
4
4
  import Picture from './picture.js';
5
5
  import Pixmap from './pixmap.js';
6
6
  import { DEFAULT_RASTER_POLICY, defaultRasterizer } from './rasterize.js';
7
+ import { ShmUploader } from './shm-upload.js';
7
8
  import FontManager from './text/fontmanager.js';
8
9
  import Window from './window.js';
9
10
 
@@ -45,6 +46,7 @@ export default class App {
45
46
  this._cursors = null;
46
47
  this._solidPictures = new Map();
47
48
  this._rasterizer = undefined;
49
+ this._shm = undefined;
48
50
  // node-x11 emits X errors it cannot route to a request callback as
49
51
  // 'error' on the client — from inside its packet parser. With no
50
52
  // listener that emit throws and the parser never re-arms, silently
@@ -81,6 +83,22 @@ export default class App {
81
83
  this._rasterizer = value;
82
84
  }
83
85
 
86
+ /**
87
+ * The MIT-SHM upload helper: large PutImage/GetImage traffic (images,
88
+ * putImageData, getImageData) travels through shared memory on a local
89
+ * connection instead of the socket, and falls back to core requests
90
+ * everywhere else. Resolved lazily; availability follows node-x11's `shm`
91
+ * connection option (unset = on where possible, `false`/`'off'` = disabled).
92
+ * See lib/shm-upload.js and docs/context-2d.md.
93
+ */
94
+ get shm() {
95
+ if (this._shm === undefined) {
96
+ this._shm = new ShmUploader(this);
97
+ this._shm.resolve(() => {}); // probe availability in the background
98
+ }
99
+ return this._shm;
100
+ }
101
+
84
102
  /** thresholds for that decision, merged over DEFAULT_RASTER_POLICY */
85
103
  get rasterPolicy() {
86
104
  return this.options.rasterPolicy
@@ -212,6 +230,10 @@ export default class App {
212
230
  this._cursors.dispose();
213
231
  this._cursors = null;
214
232
  }
233
+ if (this._shm) {
234
+ this._shm.dispose();
235
+ this._shm = undefined;
236
+ }
215
237
  // shared by every context that ever asked (see solidPicture), so their
216
238
  // lifetime is the connection's — context teardown must not free them
217
239
  for (const picture of this._solidPictures.values()) {
package/lib/image.js CHANGED
@@ -59,13 +59,17 @@ export class Image {
59
59
  gc = app._imageUploadGC = X.AllocID();
60
60
  X.CreateGC(gc, pixmap.id);
61
61
  }
62
- // stay under the server's maximum request size by uploading row bands
63
- const stride = this.width * 4;
64
- const maxBytes = ((app.display.max_request_length ?? 65535) - 8) * 4;
65
- const rowsPerBand = Math.max(1, Math.floor(maxBytes / stride));
66
- for (let y = 0; y < this.height; y += rowsPerBand) {
67
- const rows = Math.min(rowsPerBand, this.height - y);
68
- X.PutImage(2, pixmap.id, gc, this.width, rows, 0, y, 0, 32, bgra.subarray(y * stride, (y + rows) * stride));
62
+ const bytes = Buffer.isBuffer(bgra) ? bgra : Buffer.from(bgra.buffer, bgra.byteOffset, bgra.byteLength);
63
+ // Shared memory for a large image; otherwise upload row bands that stay
64
+ // under the server's maximum request size.
65
+ if (!app.shm.putImage(pixmap.id, gc, { width: this.width, height: this.height, depth: 32, data: bytes })) {
66
+ const stride = this.width * 4;
67
+ const maxBytes = ((app.display.max_request_length ?? 65535) - 8) * 4;
68
+ const rowsPerBand = Math.max(1, Math.floor(maxBytes / stride));
69
+ for (let y = 0; y < this.height; y += rowsPerBand) {
70
+ const rows = Math.min(rowsPerBand, this.height - y);
71
+ X.PutImage(2, pixmap.id, gc, this.width, rows, 0, y, 0, 32, bytes.subarray(y * stride, (y + rows) * stride));
72
+ }
69
73
  }
70
74
 
71
75
  const picture = new Picture(app, { drawable: pixmap, format: Render.rgba32 });
@@ -1942,24 +1942,36 @@ class RenderingContext2d {
1942
1942
 
1943
1943
  const layout = this._layout;
1944
1944
  const bytes = fromStraightRgba(rgba, layout, sw, sh);
1945
- // stay under the server's maximum request size by uploading row bands
1946
- const stride = sw * 4;
1947
- const maxBytes = ((this.display.max_request_length ?? 65535) - 8) * 4;
1948
- const rowsPerBand = Math.max(1, Math.floor(maxBytes / stride));
1949
- for (let row = 0; row < sh; row += rowsPerBand) {
1950
- const rows = Math.min(rowsPerBand, sh - row);
1951
- this.X.PutImage(
1952
- 2, // ZPixmap
1953
- this._target.id,
1954
- this._gc,
1955
- sw,
1956
- rows,
1957
- x + sx,
1958
- y + sy + row,
1959
- 0,
1960
- layout.depth,
1961
- bytes.subarray(row * stride, (row + rows) * stride),
1962
- );
1945
+ // Shared memory for a large blit; otherwise upload row bands that stay
1946
+ // under the server's maximum request size.
1947
+ if (
1948
+ !this.window.app.shm.putImage(this._target.id, this._gc, {
1949
+ width: sw,
1950
+ height: sh,
1951
+ depth: layout.depth,
1952
+ dstX: x + sx,
1953
+ dstY: y + sy,
1954
+ data: bytes,
1955
+ })
1956
+ ) {
1957
+ const stride = sw * 4;
1958
+ const maxBytes = ((this.display.max_request_length ?? 65535) - 8) * 4;
1959
+ const rowsPerBand = Math.max(1, Math.floor(maxBytes / stride));
1960
+ for (let row = 0; row < sh; row += rowsPerBand) {
1961
+ const rows = Math.min(rowsPerBand, sh - row);
1962
+ this.X.PutImage(
1963
+ 2, // ZPixmap
1964
+ this._target.id,
1965
+ this._gc,
1966
+ sw,
1967
+ rows,
1968
+ x + sx,
1969
+ y + sy + row,
1970
+ 0,
1971
+ layout.depth,
1972
+ bytes.subarray(row * stride, (row + rows) * stride),
1973
+ );
1974
+ }
1963
1975
  }
1964
1976
  this._markDirty();
1965
1977
  }
@@ -2007,20 +2019,31 @@ class RenderingContext2d {
2007
2019
  */
2008
2020
  readPixels(x, y, w, h) {
2009
2021
  const layout = this._layout;
2022
+ const target = this._target.id;
2023
+ const X = this.X;
2010
2024
  return new Promise((resolve, reject) => {
2011
- this.X.GetImage(
2012
- 2,
2013
- this._target.id,
2014
- x,
2015
- y,
2016
- w,
2017
- h,
2018
- 0xffffffff,
2019
- (err, img) => {
2025
+ const coreGet = () =>
2026
+ X.GetImage(2, target, x, y, w, h, 0xffffffff, (err, img) => {
2020
2027
  if (err) return reject(err);
2021
2028
  resolve({ width: w, height: h, data: img.data, layout, ...layout });
2022
- },
2023
- );
2029
+ });
2030
+
2031
+ // A readback is where shared memory helps most: a core GetImage sends the
2032
+ // whole image back over the socket and can block the server for tens of
2033
+ // milliseconds. Route large ones through a segment; fall back on any miss.
2034
+ const bytesPerPixel = layout.bitsPerPixel ? layout.bitsPerPixel >> 3 : 4;
2035
+ const shm = this.window.app.shm;
2036
+ if (shm.wantsReadback(w * h * bytesPerPixel)) {
2037
+ shm.getImage(target, x, y, w, h, layout.depth, (err, buf, rep) => {
2038
+ if (err) return coreGet();
2039
+ // buf is the segment's own memory, reused after this callback returns
2040
+ // — copy out synchronously before it is handed to the next reader
2041
+ const data = Buffer.from(buf.subarray(0, rep.size));
2042
+ resolve({ width: w, height: h, data, layout, ...layout });
2043
+ });
2044
+ } else {
2045
+ coreGet();
2046
+ }
2024
2047
  });
2025
2048
  }
2026
2049
 
@@ -0,0 +1,166 @@
1
+ // Optional MIT-SHM fast path for bulk pixel transfers.
2
+ //
3
+ // Large PutImage/GetImage traffic on a local connection is much cheaper through
4
+ // shared memory than down the socket — roughly 2x on uploads and many times
5
+ // faster on readbacks (which otherwise pay for a whole image-sized reply). This
6
+ // wraps node-x11's provider-backed MIT-SHM API (X.require('shm')) into a small,
7
+ // ordering-safe helper the 2d context and Image use, and falls back to core
8
+ // PutImage/GetImage wherever SHM will not attach (remote display, old server,
9
+ // no /dev/shm) or the transfer is too small to bother.
10
+ //
11
+ // Ordering discipline: an SHM upload is only ever issued through a segment that
12
+ // is *already attached* — so it slots into the request stream synchronously,
13
+ // exactly where a core PutImage would have gone, and never reorders drawing.
14
+ // Attaching a segment costs a round trip, so the first upload of a given size
15
+ // falls back to core and warms a segment in the background; the next one is a
16
+ // synchronous shared-memory write. A segment is returned to the pool only after
17
+ // the server signals (ShmCompletion) it has finished reading, so a reused
18
+ // buffer is never overwritten mid-read.
19
+
20
+ // Below this many bytes the socket is as fast as shared memory (measured: a
21
+ // PutImage and a tmpfs write are both syscall-bound at a few KB; the win starts
22
+ // around 64 KB and grows with size). Coverage masks (a8, 1 byte/pixel) stay
23
+ // under it at every size the rasterizer produces, which is why the 2d fill mask
24
+ // keeps using core PutImage.
25
+ const MIN_UPLOAD_BYTES = 64 * 1024;
26
+ // A readback pays the win sooner: a core GetImage reply carries the pixels back
27
+ // over the socket and, on some drivers, blocks the server for tens of
28
+ // milliseconds, so route more of them through shared memory.
29
+ const MIN_READBACK_BYTES = 16 * 1024;
30
+ // Round segment sizes up so a handful of buckets serve many nearby sizes and
31
+ // the pool actually hits.
32
+ const SIZE_QUANTUM = 64 * 1024;
33
+
34
+ function roundUp(n) {
35
+ return Math.max(SIZE_QUANTUM, Math.ceil(n / SIZE_QUANTUM) * SIZE_QUANTUM);
36
+ }
37
+
38
+ export class ShmUploader {
39
+ /** @param {import('./app.js').default} app */
40
+ constructor(app) {
41
+ this.app = app;
42
+ this.X = app.X;
43
+ this.ext = null; // node-x11 shm extension once required; false if unavailable
44
+ this.ready = false; // usable() confirmed a segment attaches
45
+ this._pool = []; // free, already-read segments (not in flight)
46
+ this._warming = false;
47
+ this._resolving = null;
48
+ }
49
+
50
+ /**
51
+ * Resolve availability once (requires the extension and probes usable()).
52
+ * `cb(ready)`. Cheap and idempotent; safe to call before every draw.
53
+ */
54
+ resolve(cb) {
55
+ if (this.ready || this.ext === false) return cb(this.ready);
56
+ if (this._resolving) {
57
+ this._resolving.push(cb);
58
+ return;
59
+ }
60
+ this._resolving = [cb];
61
+ this.X.require('shm', (err, ext) => {
62
+ // The provider-backed API (usable/createSegment) needs x11 >= 3.7; an
63
+ // older x11 still resolves the extension but without it, so treat that
64
+ // as "no shared memory" rather than throwing.
65
+ if (err || !ext || typeof ext.usable !== 'function') return this._resolved(false, false);
66
+ ext.usable((e, ok) => this._resolved(ok ? ext : false, !!ok));
67
+ });
68
+ }
69
+
70
+ _resolved(ext, ready) {
71
+ this.ext = ext;
72
+ this.ready = ready;
73
+ const cbs = this._resolving || [];
74
+ this._resolving = null;
75
+ for (const cb of cbs) cb(ready);
76
+ }
77
+
78
+ _acquire(bytes) {
79
+ for (let i = 0; i < this._pool.length; ++i) {
80
+ if (this._pool[i].size >= bytes) return this._pool.splice(i, 1)[0];
81
+ }
82
+ return null;
83
+ }
84
+
85
+ _warm(bytes) {
86
+ if (this._warming || !this.ready) return;
87
+ this._warming = true;
88
+ this.ext.createSegment(roundUp(bytes), (err, seg) => {
89
+ this._warming = false;
90
+ if (!err && seg) this._pool.push(seg);
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Upload `data` (server-layout pixels, `stride*height` bytes at offset 0) to
96
+ * `drawable` at `dstX,dstY` as a `depth` image `width*height`. Returns true
97
+ * when shared memory handled it (the caller must then issue nothing else),
98
+ * or false to signal "do it with core PutImage" — including every case where
99
+ * SHM is unavailable, too small, or momentarily out of free segments (a
100
+ * segment is warmed in the background for next time).
101
+ */
102
+ putImage(drawable, gc, { width, height, depth, dstX = 0, dstY = 0, data }) {
103
+ const bytes = data.length;
104
+ if (!this.ready || bytes < MIN_UPLOAD_BYTES) return false;
105
+ const seg = this._acquire(bytes);
106
+ if (!seg) {
107
+ this._warm(bytes);
108
+ return false;
109
+ }
110
+ data.copy ? data.copy(seg.buffer, 0, 0, bytes) : seg.buffer.set(data.subarray(0, bytes));
111
+ seg.putImage(drawable, gc, { width, height, depth, dstX, dstY, sendEvent: true });
112
+ // reuse only once the server is done reading it
113
+ seg.once('complete', () => this._pool.push(seg));
114
+ return true;
115
+ }
116
+
117
+ /**
118
+ * Whether a readback of `bytes` should use shared memory (usable and large
119
+ * enough). The caller does the async segment dance through `getImage` below.
120
+ */
121
+ wantsReadback(bytes) {
122
+ return this.ready && bytes >= MIN_READBACK_BYTES;
123
+ }
124
+
125
+ /**
126
+ * Read `width*height` pixels of `depth` from `drawable` at `x,y` through a
127
+ * shared segment. `cb(err, buffer)` — `buffer` is a view of the segment's
128
+ * bytes valid until the next `getImage`; copy out before then. Falls back is
129
+ * the caller's job: only call this when `wantsReadback` was true.
130
+ */
131
+ getImage(drawable, x, y, width, height, depth, cb) {
132
+ const bytes = width * height * ((depth + 7) >> 3 === 3 ? 4 : (depth + 7) >> 3);
133
+ const use = (seg) => {
134
+ seg.getImage(drawable, x, y, width, height, 0xffffffff, undefined, 0, (err, rep) => {
135
+ if (err) {
136
+ this._pool.push(seg);
137
+ return cb(err);
138
+ }
139
+ // hand back the buffer, then reclaim the segment after the caller has
140
+ // synchronously copied out (they must, per the doc above)
141
+ cb(null, seg.buffer, rep);
142
+ this._pool.push(seg);
143
+ });
144
+ };
145
+ const seg = this._acquire(bytes);
146
+ if (seg) return use(seg);
147
+ this.ext.createSegment(roundUp(bytes), (err, s) => {
148
+ if (err || !s) return cb(err || new Error('shm: no segment'));
149
+ use(s);
150
+ });
151
+ }
152
+
153
+ /** Detach every pooled segment (called from App#close). */
154
+ dispose() {
155
+ const pool = this._pool;
156
+ this._pool = [];
157
+ this.ready = false;
158
+ for (const seg of pool) {
159
+ try {
160
+ seg.detach();
161
+ } catch {
162
+ /* connection may already be gone */
163
+ }
164
+ }
165
+ }
166
+ }
@@ -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
@@ -386,6 +386,10 @@ export default class Window extends Drawable {
386
386
  this.height = args.height ?? 800;
387
387
  this.x = args.x ?? 0;
388
388
  this.y = args.y ?? 0;
389
+ // the baseline 'resize' compares against (see _tagResize): what the
390
+ // window was asked to be, so the window manager's first ConfigureNotify
391
+ // reports only what it actually overrode
392
+ this._deliveredGeom = { x: this.x, y: this.y, width: this.width, height: this.height };
389
393
  const values = {
390
394
  // NorthWest bit gravity: keep the old content anchored during
391
395
  // resize instead of discarding it (less flicker between the resize
@@ -419,6 +423,8 @@ export default class Window extends Drawable {
419
423
  this.eventMask = 0;
420
424
  this.visual = 0;
421
425
  this.depth = 0;
426
+ // an adopted window's geometry is not known until GetGeometry replies
427
+ this._deliveredGeom = null;
422
428
  // populate width and height
423
429
  X.GetGeometry(this.id, (err, res) => {
424
430
  if (!err) {
@@ -427,6 +433,10 @@ export default class Window extends Drawable {
427
433
  this.x = res.xPos;
428
434
  this.y = res.yPos;
429
435
  this.depth = res.depth;
436
+ // nothing was known about this window until now, so until this
437
+ // reply lands a 'resize' reports both moved and resized (see
438
+ // _tagResize) rather than guessing
439
+ this._deliveredGeom ??= { x: this.x, y: this.y, width: this.width, height: this.height };
430
440
  }
431
441
  this._readyPromiseResolve();
432
442
  });
@@ -523,6 +533,7 @@ export default class Window extends Drawable {
523
533
  // deliver buffered state events before a discrete one so handlers see
524
534
  // them in the order they happened (a drag sees the move, then the up)
525
535
  this._flushCoalesced();
536
+ if (eventName === 'resize') this._tagResize(ntkev);
526
537
  this.emit(eventName, ntkev);
527
538
  // a WM_DELETE_WINDOW ClientMessage is the window manager *asking*, and
528
539
  // 'close' is that question in a form an application can answer
@@ -628,8 +639,6 @@ export default class Window extends Drawable {
628
639
  this._dirty = false;
629
640
  this._backingValid = false;
630
641
  this._presentScheduled = false;
631
- this._backedW = this.width;
632
- this._backedH = this.height;
633
642
 
634
643
  this._presentGc = this.X.AllocID();
635
644
  this.X.CreateGC(this._presentGc, this.id, { graphicsExposures: 0 });
@@ -642,10 +651,11 @@ export default class Window extends Drawable {
642
651
  this.X.ChangeWindowAttributes(this.id, { eventMask: this.eventMask }, () => {});
643
652
  }
644
653
 
654
+ // a pure move is a ConfigureNotify too, and reallocating a backing
655
+ // pixmap per step of a window drag is the bug this flag exists to spare
656
+ // everyone (see _tagResize)
645
657
  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;
658
+ if (!ev.resized) return;
649
659
  this._allocBacking(ev.width, ev.height);
650
660
  this._backingValid = false;
651
661
  this._requestRedraw();
@@ -875,6 +885,46 @@ export default class Window extends Drawable {
875
885
  this._scheduleFrame();
876
886
  }
877
887
 
888
+ /**
889
+ * Say what a 'resize' actually changed.
890
+ *
891
+ * 'resize' is ConfigureNotify, which fires for pure moves and for
892
+ * reparents as much as for size changes — under an opaque-move window
893
+ * manager a window drag is one per pointer step. Every consumer used to
894
+ * rediscover that by keeping its own copy of the last size (this library
895
+ * included, in the backing-store listener), and one that didn't paid a
896
+ * full relayout per step of a drag.
897
+ *
898
+ * So the delivered event says which it was:
899
+ *
900
+ * - `ev.resized` — the size differs from the last delivered event's
901
+ * - `ev.moved` — the position does
902
+ * - `ev.previous` — that event's `{x, y, width, height}`, for a delta;
903
+ * null on an adopted window whose geometry is not known yet, where
904
+ * both flags read true because nothing can be ruled out
905
+ *
906
+ * Measured against the last *delivered* event, not the last raw one, so
907
+ * that coalescing cannot swallow a change: a frame that merges two moves
908
+ * and a resize reports both, where per-hop flags would report only the
909
+ * final hop. The merged raw events in `ev.coalesced` are not tagged for
910
+ * that reason — the flags are a property of the delivery.
911
+ *
912
+ * Position is compared as reported. A reparenting window manager sends
913
+ * real ConfigureNotify in frame coordinates and synthetic ones in root
914
+ * coordinates (ICCCM 4.2.3), so a frame's own offset can read as a move
915
+ * on the first event after the switch. That is the safe direction —
916
+ * spurious work, never missed work — and the alternative is a
917
+ * TranslateCoordinates round trip per event, which is the cost this flag
918
+ * exists to avoid.
919
+ */
920
+ _tagResize(ev) {
921
+ const prev = this._deliveredGeom;
922
+ ev.previous = prev;
923
+ ev.moved = !prev || ev.x !== prev.x || ev.y !== prev.y;
924
+ ev.resized = !prev || ev.width !== prev.width || ev.height !== prev.height;
925
+ this._deliveredGeom = { x: ev.x, y: ev.y, width: ev.width, height: ev.height };
926
+ }
927
+
878
928
  _flushCoalesced() {
879
929
  const pending = this._frame.pending;
880
930
  if (!pending.size) return;
@@ -884,6 +934,7 @@ export default class Window extends Drawable {
884
934
  const ev = pending.get(name);
885
935
  if (ev) {
886
936
  pending.delete(name);
937
+ if (name === 'resize') this._tagResize(ev);
887
938
  this.emit(name, ev);
888
939
  }
889
940
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "6.0.1",
3
+ "version": "6.2.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",
@@ -48,7 +48,7 @@
48
48
  "parse-color": "^1.0.0",
49
49
  "pngjs": "^7.0.0",
50
50
  "postcss": "^8.5.23",
51
- "x11": "^3.6.0",
51
+ "x11": "^3.7.0",
52
52
  "yoga-layout": "^3.2.1"
53
53
  },
54
54
  "scripts": {