ntk 6.0.0 → 6.1.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,8 +1,10 @@
1
1
  import Clipboard from './clipboard.js';
2
2
  import { CursorCache } from './cursor.js';
3
3
  import { chooseGLXConfig } from './glx.js';
4
+ import Picture from './picture.js';
4
5
  import Pixmap from './pixmap.js';
5
6
  import { DEFAULT_RASTER_POLICY, defaultRasterizer } from './rasterize.js';
7
+ import { ShmUploader } from './shm-upload.js';
6
8
  import FontManager from './text/fontmanager.js';
7
9
  import Window from './window.js';
8
10
 
@@ -42,7 +44,9 @@ export default class App {
42
44
  this._fonts = null;
43
45
  this._clipboard = null;
44
46
  this._cursors = null;
47
+ this._solidPictures = new Map();
45
48
  this._rasterizer = undefined;
49
+ this._shm = undefined;
46
50
  // node-x11 emits X errors it cannot route to a request callback as
47
51
  // 'error' on the client — from inside its packet parser. With no
48
52
  // listener that emit throws and the parser never re-arms, silently
@@ -79,6 +83,22 @@ export default class App {
79
83
  this._rasterizer = value;
80
84
  }
81
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
+
82
102
  /** thresholds for that decision, merged over DEFAULT_RASTER_POLICY */
83
103
  get rasterPolicy() {
84
104
  return this.options.rasterPolicy
@@ -167,12 +187,60 @@ export default class App {
167
187
  return new Pixmap(this, args);
168
188
  }
169
189
 
190
+ /**
191
+ * A repeating source Picture of one colour, for compositing. Components
192
+ * are 0..1 floats, premultiplied by alpha.
193
+ *
194
+ * Cached per connection, not per context: colours a context uses are the
195
+ * app's palette, and `Surface.render` builds a context per call — a
196
+ * per-context cache made every render recreate its fill colours
197
+ * server-side. Nothing evicts; a solid is a few dozen bytes on the server
198
+ * and an app cycles through few colours. Freed in `close()`.
199
+ *
200
+ * On RENDER >= 0.10 a solid is one CreateSolidFill request; an older
201
+ * server (or a hand-built App that skipped the version handshake in
202
+ * `createClient`) gets a 1x1 repeat pixmap, which composites identically.
203
+ */
204
+ solidPicture(r, g, b, a) {
205
+ const key = `${r}|${g}|${b}|${a}`;
206
+ let p = this._solidPictures.get(key);
207
+ if (p) return p;
208
+
209
+ const Render = this.display.Render;
210
+ const [major, minor] = Render.version || [0, 0];
211
+ if (major > 0 || minor >= 10) {
212
+ const pid = this.X.AllocID();
213
+ Render.CreateSolidFill(pid, r, g, b, a);
214
+ p = new Picture(this, { id: pid });
215
+ } else {
216
+ const pixmap = this.createPixmap({ depth: 32, width: 1, height: 1 });
217
+ const pid = this.X.AllocID();
218
+ Render.CreatePicture(pid, pixmap.id, Render.rgba32, { repeat: 1 });
219
+ Render.FillRectangles(Render.PictOp.Src, pid, [r, g, b, a], [0, 0, 1, 1]);
220
+ p = new Picture(this, { id: pid });
221
+ p._sourcePixmap = pixmap; // keep the 1x1 pixmap alive alongside the picture
222
+ }
223
+ this._solidPictures.set(key, p);
224
+ return p;
225
+ }
226
+
170
227
  // flush pending requests and close the connection
171
228
  close() {
172
229
  if (this._cursors) {
173
230
  this._cursors.dispose();
174
231
  this._cursors = null;
175
232
  }
233
+ if (this._shm) {
234
+ this._shm.dispose();
235
+ this._shm = undefined;
236
+ }
237
+ // shared by every context that ever asked (see solidPicture), so their
238
+ // lifetime is the connection's — context teardown must not free them
239
+ for (const picture of this._solidPictures.values()) {
240
+ picture.destroy();
241
+ picture._sourcePixmap?.destroy();
242
+ }
243
+ this._solidPictures.clear();
176
244
  return new Promise((resolve) => this.X.close(resolve));
177
245
  }
178
246
 
package/lib/fontconfig.js CHANGED
@@ -1,8 +1,12 @@
1
1
  // node:child_process is fetched lazily (not via static import) so that
2
2
  // browser bundles of the package never try to resolve it; in non-node
3
3
  // environments use a custom FontSource instead (see text/fontsource.js).
4
+ function childProcess() {
5
+ return globalThis.process?.getBuiltinModule?.('node:child_process');
6
+ }
7
+
4
8
  function execFileSync(...args) {
5
- const cp = globalThis.process?.getBuiltinModule?.('node:child_process');
9
+ const cp = childProcess();
6
10
  if (!cp) {
7
11
  throw noFontsError(
8
12
  'fontconfig matching needs node (the fc-match CLI) and this is not a node environment'
@@ -101,6 +105,69 @@ function patternFor({ family, weight, style }) {
101
105
  return fc;
102
106
  }
103
107
 
108
+ // One command shared by the sync and async paths, so a prewarmed cache entry
109
+ // is byte-for-byte what the sync call would have computed.
110
+ const fcMatchArgs = ['-s', '--format', '%{file}\t%{postscriptname}\t%{charset}\n'];
111
+ const fcMatchOpts = { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 };
112
+
113
+ /** fc-match -s output -> candidates, filtered to formats fontkit can parse */
114
+ function parseMatches(out) {
115
+ const list = [];
116
+ for (const line of out.split('\n')) {
117
+ const [path, postscriptName, charset] = line.split('\t');
118
+ if (path && supported.test(path)) {
119
+ list.push({ path, postscriptName, charset: charset || '', _ranges: null });
120
+ }
121
+ }
122
+ return list;
123
+ }
124
+
125
+ const prewarming = new Map();
126
+
127
+ /**
128
+ * Seed the match cache for a pattern ahead of time, off the event loop.
129
+ *
130
+ * matchSortedSync is deliberately synchronous — it answers from inside text
131
+ * layout — so the first layout for a pattern pays the fc-match spawn (~50ms)
132
+ * as a first-paint stall. Starting the same command here with a non-blocking
133
+ * execFile, while the X connection is still being set up, moves that cost off
134
+ * the critical path (issue #182).
135
+ *
136
+ * Never rejects and never reports: a prewarm is an optimization, and an app
137
+ * that never renders text must not crash — or even warn — over a missing
138
+ * fc-match. An app that does render text reaches the sync path, which
139
+ * diagnoses the failure properly. For the same reason nothing here touches
140
+ * `unavailable`: the first sync throw keeps its original spawn error as
141
+ * `cause`.
142
+ *
143
+ * A sync call racing this one wins — the child's result is discarded
144
+ * whenever the pattern is already cached by the time it exits.
145
+ *
146
+ * @returns {Promise<void>} resolves once the cache is seeded or the attempt
147
+ * abandoned
148
+ */
149
+ export function prewarm(pattern = {}) {
150
+ const fc = patternFor(pattern);
151
+ if (sortedCache.has(fc) || unavailable) return Promise.resolve();
152
+ const inflight = prewarming.get(fc);
153
+ if (inflight) return inflight;
154
+ const cp = childProcess();
155
+ if (!cp) return Promise.resolve();
156
+
157
+ const promise = new Promise((resolve) => {
158
+ cp.execFile('fc-match', [...fcMatchArgs, fc], fcMatchOpts, (err, out) => {
159
+ prewarming.delete(fc);
160
+ if (!err && !sortedCache.has(fc)) {
161
+ const list = parseMatches(out);
162
+ if (list.length > 0) sortedCache.set(fc, list);
163
+ }
164
+ resolve();
165
+ });
166
+ });
167
+ prewarming.set(fc, promise);
168
+ return promise;
169
+ }
170
+
104
171
  /**
105
172
  * Full fontconfig match list for a pattern, best match first — this is the
106
173
  * system's font fallback chain. Each candidate carries the unicode coverage
@@ -119,11 +186,7 @@ export function matchSortedSync(pattern) {
119
186
 
120
187
  let out;
121
188
  try {
122
- out = execFileSync(
123
- 'fc-match',
124
- ['-s', '--format', '%{file}\t%{postscriptname}\t%{charset}\n', fc],
125
- { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }
126
- );
189
+ out = execFileSync('fc-match', [...fcMatchArgs, fc], fcMatchOpts);
127
190
  } catch (err) {
128
191
  if (err.code === 'ERR_NTK_NO_FONTS') throw err; // no child_process at all
129
192
  if (isSpawnFailure(err)) {
@@ -140,13 +203,7 @@ export function matchSortedSync(pattern) {
140
203
  throw err;
141
204
  }
142
205
 
143
- list = [];
144
- for (const line of out.split('\n')) {
145
- const [path, postscriptName, charset] = line.split('\t');
146
- if (path && supported.test(path)) {
147
- list.push({ path, postscriptName, charset: charset || '', _ranges: null });
148
- }
149
- }
206
+ list = parseMatches(out);
150
207
  if (list.length === 0) {
151
208
  throw noFontsError(
152
209
  `fontconfig matched no font ntk can parse for "${fc}" (needs ` +
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 });
package/lib/index.js CHANGED
@@ -106,7 +106,16 @@ export function createClient(options, callback) {
106
106
  // synchronously, which keeps the legacy callback form working. The
107
107
  // caller's options object is copied, never mutated.
108
108
  const appOptions = options ? { ...options } : {};
109
- if (appOptions.fontSource != null) appOptions.fontSource = createFontSource(appOptions.fontSource);
109
+ if (appOptions.fontSource != null) {
110
+ appOptions.fontSource = createFontSource(appOptions.fontSource);
111
+ } else {
112
+ // An app without a fontSource of its own resolves the process default
113
+ // lazily, at first text layout — too late for the fontconfig source's
114
+ // async fc-match prewarm to hide the ~50ms spawn (issue #182). Touch
115
+ // the default now, while the connection handshake runs; only touch,
116
+ // so a later setDefaultFontSource still takes effect.
117
+ defaultFontSource();
118
+ }
110
119
 
111
120
  x11.createClient(x11Options, (error, display) => {
112
121
  if (error) return reject(error);
@@ -124,26 +133,35 @@ export function createClient(options, callback) {
124
133
  // rendering too bright.
125
134
  if (process.env.NTK_STRICT_COLORS) Render.strictColors = true;
126
135
  display.GLX = glxError ? null : GLX;
127
- const X = display.client;
128
- X.keycode2keysyms = {};
129
-
130
- function updateKeyboardMapping(min, max) {
131
- if (max <= min) return;
132
- X.GetKeyboardMapping(min, max - min, (err, list) => {
133
- if (err) return;
134
- for (let i = 0; i < list.length; ++i) X.keycode2keysyms[i + min] = list[i].slice();
135
- });
136
- }
137
136
 
138
- X.on('event', (ev) => {
139
- // MappingNotify
140
- if (ev.type === 34) {
141
- updateKeyboardMapping(ev.firstKeyCode, ev.firstKeyCode + ev.count);
137
+ // RENDER requires the client to declare its version before using
138
+ // requests beyond 0.0, and the reply is what gates them afterwards
139
+ // — CreateSolidFill needs 0.10 (see App#solidPicture). 0.11 is the
140
+ // last published protocol revision.
141
+ Render.QueryVersion(0, 11, (versionError, version) => {
142
+ Render.version = versionError ? [0, 0] : version;
143
+
144
+ const X = display.client;
145
+ X.keycode2keysyms = {};
146
+
147
+ function updateKeyboardMapping(min, max) {
148
+ if (max <= min) return;
149
+ X.GetKeyboardMapping(min, max - min, (err, list) => {
150
+ if (err) return;
151
+ for (let i = 0; i < list.length; ++i) X.keycode2keysyms[i + min] = list[i].slice();
152
+ });
142
153
  }
143
- });
144
- updateKeyboardMapping(display.min_keycode, display.max_keycode);
145
154
 
146
- resolve(new App(display, appOptions));
155
+ X.on('event', (ev) => {
156
+ // MappingNotify
157
+ if (ev.type === 34) {
158
+ updateKeyboardMapping(ev.firstKeyCode, ev.firstKeyCode + ev.count);
159
+ }
160
+ });
161
+ updateKeyboardMapping(display.min_keycode, display.max_keycode);
162
+
163
+ resolve(new App(display, appOptions));
164
+ });
147
165
  });
148
166
  });
149
167
  });