ntk 6.0.0 → 6.0.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/lib/app.js CHANGED
@@ -1,6 +1,7 @@
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';
6
7
  import FontManager from './text/fontmanager.js';
@@ -42,6 +43,7 @@ export default class App {
42
43
  this._fonts = null;
43
44
  this._clipboard = null;
44
45
  this._cursors = null;
46
+ this._solidPictures = new Map();
45
47
  this._rasterizer = undefined;
46
48
  // node-x11 emits X errors it cannot route to a request callback as
47
49
  // 'error' on the client — from inside its packet parser. With no
@@ -167,12 +169,56 @@ export default class App {
167
169
  return new Pixmap(this, args);
168
170
  }
169
171
 
172
+ /**
173
+ * A repeating source Picture of one colour, for compositing. Components
174
+ * are 0..1 floats, premultiplied by alpha.
175
+ *
176
+ * Cached per connection, not per context: colours a context uses are the
177
+ * app's palette, and `Surface.render` builds a context per call — a
178
+ * per-context cache made every render recreate its fill colours
179
+ * server-side. Nothing evicts; a solid is a few dozen bytes on the server
180
+ * and an app cycles through few colours. Freed in `close()`.
181
+ *
182
+ * On RENDER >= 0.10 a solid is one CreateSolidFill request; an older
183
+ * server (or a hand-built App that skipped the version handshake in
184
+ * `createClient`) gets a 1x1 repeat pixmap, which composites identically.
185
+ */
186
+ solidPicture(r, g, b, a) {
187
+ const key = `${r}|${g}|${b}|${a}`;
188
+ let p = this._solidPictures.get(key);
189
+ if (p) return p;
190
+
191
+ const Render = this.display.Render;
192
+ const [major, minor] = Render.version || [0, 0];
193
+ if (major > 0 || minor >= 10) {
194
+ const pid = this.X.AllocID();
195
+ Render.CreateSolidFill(pid, r, g, b, a);
196
+ p = new Picture(this, { id: pid });
197
+ } else {
198
+ const pixmap = this.createPixmap({ depth: 32, width: 1, height: 1 });
199
+ const pid = this.X.AllocID();
200
+ Render.CreatePicture(pid, pixmap.id, Render.rgba32, { repeat: 1 });
201
+ Render.FillRectangles(Render.PictOp.Src, pid, [r, g, b, a], [0, 0, 1, 1]);
202
+ p = new Picture(this, { id: pid });
203
+ p._sourcePixmap = pixmap; // keep the 1x1 pixmap alive alongside the picture
204
+ }
205
+ this._solidPictures.set(key, p);
206
+ return p;
207
+ }
208
+
170
209
  // flush pending requests and close the connection
171
210
  close() {
172
211
  if (this._cursors) {
173
212
  this._cursors.dispose();
174
213
  this._cursors = null;
175
214
  }
215
+ // shared by every context that ever asked (see solidPicture), so their
216
+ // lifetime is the connection's — context teardown must not free them
217
+ for (const picture of this._solidPictures.values()) {
218
+ picture.destroy();
219
+ picture._sourcePixmap?.destroy();
220
+ }
221
+ this._solidPictures.clear();
176
222
  return new Promise((resolve) => this.X.close(resolve));
177
223
  }
178
224
 
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/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
  });