ntk 6.3.0 → 6.5.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/builtin.js ADDED
@@ -0,0 +1,47 @@
1
+ // Reach a Node builtin from ESM across Node versions.
2
+ //
3
+ // `process.getBuiltinModule(id)` is the browser-safe accessor — the string is
4
+ // a function argument, so no bundler treats it as a module specifier — but it
5
+ // only exists on Node >= 20.16. ntk's floor is lower (Node 18.19, for
6
+ // react-x11), and there is no browser-safe, synchronous way to reach a builtin
7
+ // from ESM on an older Node: `getBuiltinModule` is the API that was added to
8
+ // fill exactly that gap. So where it is absent we fall back to `createRequire`.
9
+ //
10
+ // The `node:module` import below is a real specifier and the one place in lib/
11
+ // allowed to statically import a builtin other than node:events — a sanctioned
12
+ // exception in test/packaging.test.js, scoped to this file. A *browser* bundle
13
+ // of the text stack must stub this one specifier, but the fallback that uses it
14
+ // never runs in a browser (guarded on process.versions.node) and ntk ships no
15
+ // browser build. It is not a top-level await, so the CJS/SEA bundle path (see
16
+ // docs/packaging.md) is unaffected.
17
+ import { createRequire } from 'node:module';
18
+
19
+ let _require;
20
+
21
+ // One createRequire bound to ntk, made lazily. Builtins ('node:*') resolve
22
+ // regardless of the base URL, so this also serves callers that resolve a
23
+ // package in ntk's own dependency graph (e.g. KaTeX's font directory).
24
+ function req() {
25
+ return (_require ??= createRequire(import.meta.url));
26
+ }
27
+
28
+ /**
29
+ * `process.getBuiltinModule(id)` with a Node-18 fallback.
30
+ * @param {string} id e.g. 'node:child_process'
31
+ * @returns the builtin module, or `undefined` outside Node (a browser
32
+ * embedding of the text stack — supply a custom FontSource / callback there).
33
+ */
34
+ export function builtin(id) {
35
+ const p = globalThis.process;
36
+ if (!p?.versions?.node) return undefined;
37
+ if (typeof p.getBuiltinModule === 'function') return p.getBuiltinModule(id);
38
+ return req()(id);
39
+ }
40
+
41
+ /**
42
+ * A `require` for resolving packages in ntk's dependency graph.
43
+ * @returns {NodeRequire|null} `null` outside Node.
44
+ */
45
+ export function nodeRequire() {
46
+ return globalThis.process?.versions?.node ? req() : null;
47
+ }
package/lib/fontconfig.js CHANGED
@@ -1,8 +1,10 @@
1
- // node:child_process is fetched lazily (not via static import) so that
2
- // browser bundles of the package never try to resolve it; in non-node
1
+ // node:child_process is fetched lazily (via builtin(), not a static import) so
2
+ // that 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
+ import { builtin } from './builtin.js';
5
+
4
6
  function childProcess() {
5
- return globalThis.process?.getBuiltinModule?.('node:child_process');
7
+ return builtin('node:child_process');
6
8
  }
7
9
 
8
10
  function execFileSync(...args) {
package/lib/glx.js CHANGED
@@ -1,10 +1,115 @@
1
- // GLX visual/fbconfig discovery.
1
+ // GLX visual/fbconfig discovery, and the diagnosis of a failed setup.
2
2
  //
3
3
  // A GLX drawable has to use a visual the GL context was created for, so the
4
4
  // visual must be known *before* CreateWindow. This module asks the server
5
5
  // (GetFBConfigs, falling back to the GLX 1.2 GetVisualConfigs) instead of
6
6
  // shelling out to `glxinfo`, so it works headlessly and in CI.
7
7
 
8
+ /**
9
+ * `err.code` on every error this module and the OpenGL context throw, so
10
+ * callers can branch on the cause instead of matching on message text.
11
+ *
12
+ * - `GLX_NO_EXTENSION` — the server does not have the GLX extension.
13
+ * - `GLX_INDIRECT_DISABLED` — it has GLX, but refuses indirect contexts,
14
+ * which is the only kind that works over the wire. The default on Xorg
15
+ * >= 1.17 and Xwayland, and by far the most common failure.
16
+ * - `GLX_NO_CONFIG` — no visual/fbconfig matches the requested attributes.
17
+ * - `GLX_CONTEXT_FAILED` — anything else that went wrong in setup.
18
+ */
19
+ export const GLXError = {
20
+ NO_EXTENSION: 'GLX_NO_EXTENSION',
21
+ INDIRECT_DISABLED: 'GLX_INDIRECT_DISABLED',
22
+ NO_CONFIG: 'GLX_NO_CONFIG',
23
+ CONTEXT_FAILED: 'GLX_CONTEXT_FAILED'
24
+ };
25
+
26
+ // `message` says what is wrong in one line; `hint` is the multi-line
27
+ // remedy, printed by the default warning and available to anyone rendering
28
+ // their own fallback UI.
29
+ function glxError(code, message, hint, cause) {
30
+ const err = new Error(message);
31
+ err.code = code;
32
+ if (hint) err.hint = hint;
33
+ if (cause) err.cause = cause;
34
+ return err;
35
+ }
36
+
37
+ const displayName = () => process.env.DISPLAY || 'the X server';
38
+
39
+ const IGLX_HINT = `Indirect GLX is what ntk draws with: GL commands travel over the X
40
+ connection, since there is no client-side GL library involved. Xorg >= 1.17
41
+ and Xwayland ship with it disabled, and answer GLX CreateContext with
42
+ BadValue. The extension is still present and still advertises GLX 1.4 with a
43
+ full set of fbconfigs, which is why this looks like it should have worked.
44
+
45
+ To get a server that allows it:
46
+ under Wayland: Xwayland :5 +iglx & then DISPLAY=:5
47
+ under X: Xephyr :5 +iglx & then DISPLAY=:5
48
+ Xorg: +iglx on the command line, or
49
+ Option "AllowIndirectGLX" "on" in xorg.conf
50
+ XQuartz: defaults write org.xquartz.X11 enable_iglx -bool true
51
+
52
+ Note that on current Linux distros the indirect GL *engine* behind those
53
+ contexts is often gone even with +iglx on — contexts are created but nothing
54
+ rasterizes. See docs/context-opengl.md.`;
55
+
56
+ /** Turn a failed GLX CreateContext into an error that explains itself. */
57
+ export function diagnoseCreateContext(err, cfg) {
58
+ // BadValue with value 0 from CreateContext (minor opcode 3) is how a
59
+ // server without +iglx says "no indirect contexts here" — every other
60
+ // rejection names the value it disliked
61
+ if (err?.error === 2 && err.badParam === 0) {
62
+ return glxError(
63
+ GLXError.INDIRECT_DISABLED,
64
+ `${displayName()} has GLX but refuses to create indirect GLX contexts, so there is no OpenGL context to draw with`,
65
+ IGLX_HINT,
66
+ err
67
+ );
68
+ }
69
+ const visual = cfg?.visual ? ` for visual 0x${cfg.visual.toString(16)}` : '';
70
+ return glxError(
71
+ GLXError.CONTEXT_FAILED,
72
+ `GLX CreateContext${visual} failed: ${err?.message ?? err}`,
73
+ null,
74
+ err
75
+ );
76
+ }
77
+
78
+ /**
79
+ * The console form of a failed GL setup: what went wrong, then how to fix
80
+ * it. Indented so the remedy reads as one block and not as unrelated log
81
+ * lines, and ending with the way to take over the handling.
82
+ */
83
+ export function glxErrorReport(err) {
84
+ const lines = [`ntk: getContext('opengl') failed — ${err?.message ?? err}`];
85
+ if (err?.hint) lines.push('', ...err.hint.split('\n').map((l) => (l ? ` ${l}` : l)));
86
+ lines.push('', ` (await gl.ready to handle this yourself; err.code is ${err?.code ?? 'unset'})`);
87
+ return lines.join('\n');
88
+ }
89
+
90
+ /**
91
+ * Create a GLX context and find out whether it worked.
92
+ *
93
+ * CreateContext has no reply, so its failure would otherwise surface as an
94
+ * unattributed X error plus a misleading GLXBadContext from the MakeCurrent
95
+ * that follows. node-x11 >= 3.8 takes a callback here; older versions
96
+ * cannot report it at all, and this resolves optimistically rather than
97
+ * hanging.
98
+ */
99
+ export function createContext(GLX, ctx, cfg) {
100
+ if (GLX.CreateContext.length < 6) {
101
+ GLX.CreateContext(ctx, cfg.visual, cfg.screen ?? 0, 0, 0);
102
+ return Promise.resolve();
103
+ }
104
+ return new Promise((resolve, reject) => {
105
+ GLX.CreateContext(ctx, cfg.visual, cfg.screen ?? 0, 0, 0, (err) => {
106
+ if (err) reject(diagnoseCreateContext(err, cfg));
107
+ else resolve();
108
+ return true; // handled here; keep it away from the app's onXError
109
+ });
110
+ });
111
+ }
112
+
8
113
  // defaults applied on top of the caller's spec; `null` means "don't care"
9
114
  const DEFAULT_SPEC = {
10
115
  DOUBLEBUFFER: true,
@@ -120,8 +225,12 @@ function matchLegacy(configs, spec) {
120
225
  export async function chooseGLXConfig(app, spec = {}) {
121
226
  const GLX = app.display.GLX;
122
227
  if (!GLX) {
123
- throw new Error(
124
- 'GLX: extension not available on this display — indirect GLX is often disabled (X server flag +iglx / AllowIndirectGLX)'
228
+ throw glxError(
229
+ GLXError.NO_EXTENSION,
230
+ `${displayName()} does not have the GLX extension, so it cannot do OpenGL at all`,
231
+ 'A server built without GLX, or started with -extension GLX. This is a\n' +
232
+ 'different problem from indirect GLX being disabled, which is the usual\n' +
233
+ 'one — there the extension is present and CreateContext is what fails.'
125
234
  );
126
235
  }
127
236
 
@@ -135,7 +244,11 @@ export async function chooseGLXConfig(app, spec = {}) {
135
244
 
136
245
  if (want.visual) {
137
246
  const info = visualInfo(screen, want.visual);
138
- if (!info) throw new Error(`GLX: visual 0x${want.visual.toString(16)} is not on screen ${screenNum}`);
247
+ if (!info)
248
+ throw glxError(
249
+ GLXError.NO_CONFIG,
250
+ `GLX: visual 0x${want.visual.toString(16)} is not on screen ${screenNum}`
251
+ );
139
252
  return {
140
253
  visual: want.visual,
141
254
  depth: info.depth,
@@ -187,8 +300,10 @@ export async function chooseGLXConfig(app, spec = {}) {
187
300
  const legacyBest = pick(fromVisuals);
188
301
  if (legacyBest) return legacyBest;
189
302
 
190
- throw new Error(
191
- `GLX: no visual on screen ${screenNum} matches ${describe(want)} (server offered ${legacy.length} GLX visuals)`
303
+ throw glxError(
304
+ GLXError.NO_CONFIG,
305
+ `GLX: no visual on screen ${screenNum} matches ${describe(want)} (server offered ${legacy.length} GLX visuals)`,
306
+ 'Relax the spec — DEPTH_SIZE and DOUBLEBUFFER are the usual culprits.'
192
307
  );
193
308
  }
194
309
 
package/lib/image.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import jpeg from 'jpeg-js';
2
2
  import { PNG } from 'pngjs';
3
3
 
4
+ import { builtin } from './builtin.js';
4
5
  import { fromStraightRgba, pixelLayout } from './imagedata.js';
5
6
  import Picture from './picture.js';
6
7
 
@@ -126,7 +127,7 @@ export function decodeImage(buffer) {
126
127
  export async function loadImage(source) {
127
128
  if (typeof source === 'string' || source instanceof URL) {
128
129
  // lazy builtin lookup: browser bundles must not depend on node:fs
129
- const fsp = globalThis.process?.getBuiltinModule?.('node:fs/promises');
130
+ const fsp = builtin('node:fs/promises');
130
131
  if (!fsp) {
131
132
  throw new Error('loadImage: file paths need node — pass the encoded bytes instead');
132
133
  }
package/lib/index.js CHANGED
@@ -3,6 +3,7 @@ import x11 from 'x11';
3
3
  import App from './app.js';
4
4
  import Clipboard from './clipboard.js';
5
5
  import { BLANK_CURSOR, CursorCache, cursorNames, cursorShapes, resolveCursorShape } from './cursor.js';
6
+ import { GLXError } from './glx.js';
6
7
  import Window from './window.js';
7
8
  import { loadLayout } from './yoga.js';
8
9
  import { decodeKey, groupForState } from './keyboard.js';
@@ -227,7 +228,9 @@ export {
227
228
  premultiply,
228
229
  cssLength,
229
230
  decodeKey,
230
- groupForState
231
+ groupForState,
232
+ // the `code` on a failed GL setup: branch on it rather than on the message
233
+ GLXError
231
234
  };
232
235
  // The layout engine ntk lays HtmlView out with — downstream layout consumers
233
236
  // (e.g. the react-x11 renderer) must import it from here rather than from
@@ -325,14 +325,14 @@ class RenderingContext2d {
325
325
 
326
326
  this._clips = [];
327
327
  this._dropMasks();
328
- for (const gc of [this._gc, this._fillMaskGC]) {
328
+ for (const gc of [this._gc, this._fillMaskGC, this._clipMaskGC]) {
329
329
  if (!gc) continue;
330
330
  safeRelease(this.X, () => {
331
331
  this.X.FreeGC(gc);
332
332
  this.X.ReleaseID(gc);
333
333
  });
334
334
  }
335
- this._gc = this._fillMaskGC = null;
335
+ this._gc = this._fillMaskGC = this._clipMaskGC = null;
336
336
  this._gcs.length = 0;
337
337
 
338
338
  this._backgroundPicture = this._glyphSource = null;
@@ -1644,13 +1644,15 @@ class RenderingContext2d {
1644
1644
  drawable: tmpPixmap,
1645
1645
  format: R.a8,
1646
1646
  });
1647
- R.FillRectangles(
1648
- R.PictOp.Src,
1649
- tmpMask.id,
1650
- [0, 0, 0, 0],
1651
- [0, 0, bb.w, bb.h],
1652
- );
1653
- this._rasterizePolys(tmpMask, entry.polys, entry.rule, -bb.x, -bb.y);
1647
+ if (!this._uploadClipCoverage(tmpPixmap, entry, bb)) {
1648
+ R.FillRectangles(
1649
+ R.PictOp.Src,
1650
+ tmpMask.id,
1651
+ [0, 0, 0, 0],
1652
+ [0, 0, bb.w, bb.h],
1653
+ );
1654
+ this._rasterizePolys(tmpMask, entry.polys, entry.rule, -bb.x, -bb.y);
1655
+ }
1654
1656
  R.Composite(
1655
1657
  op,
1656
1658
  tmpMask.id,
@@ -1669,6 +1671,96 @@ class RenderingContext2d {
1669
1671
  tmpPixmap.destroy();
1670
1672
  }
1671
1673
 
1674
+ /**
1675
+ * The clip-mask twin of `_uploadCoverage`: rasterize one clip entry here
1676
+ * and PutImage the coverage into the bbox temp, instead of asking the
1677
+ * server for trapezoids. Returns false — caller falls back to AddTraps —
1678
+ * with no rasterizer, when the policy routes this shape to the server, or
1679
+ * when the rasterizer declines.
1680
+ *
1681
+ * Without this the routing was only half applied. `_uploadCoverage` covers
1682
+ * fills and strokes, so an app that set `rasterPolicy` to keep every
1683
+ * drawing local still emitted an AddTraps per non-rectangular clip, and on
1684
+ * glamor those were the whole remaining cost: a wall of 48 rounded cards
1685
+ * spent 12 AddTraps a frame on clips alone (react-x11#199), 116ms of
1686
+ * server drain per frame, with no policy able to reach them.
1687
+ *
1688
+ * PutImage writes Src, so it replaces the temp's clear as well.
1689
+ */
1690
+ _uploadClipCoverage(tmpPixmap, entry, bb) {
1691
+ const rasterizer = this.window.app.rasterizer;
1692
+ if (!rasterizer) return false;
1693
+ const flat = [];
1694
+ let edges = 0;
1695
+ for (const p of entry.polys) {
1696
+ if (p.pts.length < 6) continue;
1697
+ flat.push(p.pts);
1698
+ edges += p.pts.length / 2;
1699
+ }
1700
+ if (!flat.length) return false;
1701
+ if (
1702
+ routeRaster(bb.w, bb.h, edges, this.window.app.rasterPolicy) !== "local"
1703
+ ) {
1704
+ return false;
1705
+ }
1706
+
1707
+ const coverage = rasterizer.rasterize({
1708
+ polys: flat,
1709
+ rule: entry.rule,
1710
+ dx: -bb.x,
1711
+ dy: -bb.y,
1712
+ width: bb.w,
1713
+ height: bb.h,
1714
+ });
1715
+ if (!coverage) return false;
1716
+
1717
+ // scanlines padded to 4 bytes, as in _uploadCoverage — and as there, a
1718
+ // width that is already a multiple of 4 goes out as a view over the
1719
+ // rasterizer's own bytes with no copy
1720
+ const stride = (bb.w + 3) & ~3;
1721
+ let data;
1722
+ if (stride === bb.w) {
1723
+ data = Buffer.isBuffer(coverage)
1724
+ ? coverage
1725
+ : Buffer.from(coverage.buffer, coverage.byteOffset, coverage.length);
1726
+ } else {
1727
+ data = Buffer.alloc(stride * bb.h);
1728
+ for (let y = 0; y < bb.h; ++y) {
1729
+ Buffer.from(coverage.buffer, coverage.byteOffset + y * bb.w, bb.w).copy(
1730
+ data,
1731
+ y * stride,
1732
+ );
1733
+ }
1734
+ }
1735
+ this.X.PutImage(
1736
+ 2,
1737
+ tmpPixmap.id,
1738
+ this._clipGC(tmpPixmap),
1739
+ bb.w,
1740
+ bb.h,
1741
+ 0,
1742
+ 0,
1743
+ 0,
1744
+ 8,
1745
+ data,
1746
+ );
1747
+ return true;
1748
+ }
1749
+
1750
+ /**
1751
+ * GC for uploading coverage into a clip temp. Created against the first
1752
+ * temp and kept: every one of them is depth 8 on the same root, which is
1753
+ * all a GC binds to, and the temps themselves come and go per clip entry.
1754
+ */
1755
+ _clipGC(tmpPixmap) {
1756
+ if (!this._clipMaskGC) {
1757
+ this._clipMaskGC = this.X.AllocID();
1758
+ this._gcs.push(this._clipMaskGC);
1759
+ this.X.CreateGC(this._clipMaskGC, tmpPixmap.id);
1760
+ }
1761
+ return this._clipMaskGC;
1762
+ }
1763
+
1672
1764
  _rebuildClipMask() {
1673
1765
  // The stack changed shape (restore, resize): drop the mask rather than
1674
1766
  // rebuild it eagerly. A stack that went back to rectangles never needs
@@ -1,5 +1,5 @@
1
1
  import Drawable from './drawable.js';
2
- import { chooseGLXConfig } from './glx.js';
2
+ import { GLXError, chooseGLXConfig, createContext, glxErrorReport } from './glx.js';
3
3
 
4
4
  // Indirect GLX rendering context exposing an OpenGL 1.4-style api.
5
5
  // Note: many modern X servers ship with indirect GLX disabled — see README.
@@ -14,9 +14,11 @@ class RenderingContextOpenGL {
14
14
  const X = window.X;
15
15
  const GLX = window.display.GLX;
16
16
  if (!GLX) {
17
- throw new Error(
18
- "getContext('opengl'): the X server has no GLX extension — indirect GLX is often disabled (+iglx / AllowIndirectGLX)"
17
+ const err = new Error(
18
+ `getContext('opengl'): ${process.env.DISPLAY || 'the X server'} does not have the GLX extension, so it cannot do OpenGL at all`
19
19
  );
20
+ err.code = GLXError.NO_EXTENSION;
21
+ throw err;
20
22
  }
21
23
 
22
24
  this.window = window;
@@ -82,9 +84,21 @@ class RenderingContextOpenGL {
82
84
 
83
85
  const ctx = X.AllocID();
84
86
  this.contextId = ctx;
85
- GLX.CreateContext(ctx, cfg.visual, cfg.screen ?? 0, 0, 0);
87
+ // CreateContext first, and on its own: it is the request a server with
88
+ // indirect GLX disabled rejects, and going on to MakeCurrent would bury
89
+ // that under a GLXBadContext about the context that was never created
90
+ await createContext(GLX, ctx, cfg).catch((err) => {
91
+ this.contextId = 0; // nothing to destroy
92
+ throw err;
93
+ });
86
94
  const tag = await new Promise((resolve, reject) => {
87
- GLX.MakeCurrent(window.id, ctx, 0, (err, contextTag) => (err ? reject(err) : resolve(contextTag)));
95
+ GLX.MakeCurrent(window.id, ctx, 0, (err, contextTag) => {
96
+ if (!err) return resolve(contextTag);
97
+ const failed = new Error(`GLX MakeCurrent failed: ${err.message}`);
98
+ failed.code = GLXError.CONTEXT_FAILED;
99
+ failed.cause = err;
100
+ reject(failed);
101
+ });
88
102
  });
89
103
 
90
104
  this.contextTag = tag;
@@ -97,9 +111,7 @@ class RenderingContextOpenGL {
97
111
  this.ready.catch((err) => {
98
112
  this.error = err;
99
113
  queue.length = 0;
100
- console.warn(
101
- `ntk: getContext('opengl') failed: ${err.message} (await gl.ready to handle this yourself)`
102
- );
114
+ console.warn(glxErrorReport(err));
103
115
  });
104
116
  }
105
117
 
@@ -30,10 +30,11 @@
30
30
  // fontconfig: ntk ships no fonts, so the app has to hand over the ones it
31
31
  // ships, and the spec is the short way to say so.
32
32
  //
33
- // node:fs is fetched through `getBuiltinModule` inside the path branch rather
34
- // than imported, because this file is the one the browser bundle keeps — its
35
- // whole purpose is running the text stack without a filesystem. Do not turn
36
- // that into a static import (test/packaging.test.js fails if you do).
33
+ // node:fs is fetched lazily through builtin() inside the path branch, never a
34
+ // direct static import, because this file is the one the browser bundle keeps
35
+ // — its whole purpose is running the text stack without a filesystem. See
36
+ // builtin.js for how the lazy lookup stays version-tolerant.
37
+ import { builtin } from '../builtin.js';
37
38
  import { charsetHas, matchSortedSync, noFontsError, prewarm, supported } from '../fontconfig.js';
38
39
  import Font from './font.js';
39
40
 
@@ -267,7 +268,7 @@ const ACCEPTED =
267
268
  'docs/fonts.md';
268
269
 
269
270
  function fs() {
270
- const mod = globalThis.process?.getBuiltinModule?.('node:fs');
271
+ const mod = builtin('node:fs');
271
272
  if (!mod) {
272
273
  throw new Error(
273
274
  'ntk: a font path can only be read in node. In a browser, fetch the font ' +
@@ -1,6 +1,7 @@
1
1
  import { selectAll } from 'css-select';
2
2
  import { textContent } from 'domutils';
3
3
  import { parseDocument } from 'htmlparser2';
4
+ import { builtin } from '../builtin.js';
4
5
  import Yoga from '../yoga.js';
5
6
 
6
7
  import { decodeImage, Image } from '../image.js';
@@ -467,15 +468,14 @@ export default class HtmlView {
467
468
  // default policy: local files, only relative to an explicit baseUrl.
468
469
  // node builtins are fetched lazily — in environments without them
469
470
  // (browser bundles) pass a `loadResource` callback instead.
470
- const getBuiltin = globalThis.process?.getBuiltinModule;
471
- const fs = getBuiltin?.call(process, 'node:fs');
471
+ const fs = builtin('node:fs');
472
472
  if (!fs || !this.baseUrl) return null;
473
473
  let base = this.baseUrl;
474
474
  if (typeof base === 'string' && !/^[a-z][a-z0-9+.-]*:/i.test(base)) {
475
475
  // filesystem path; point at the directory itself when given one
476
476
  let p = base;
477
477
  if (!/[/\\]$/.test(p) && fs.existsSync(p) && fs.statSync(p).isDirectory()) p += '/';
478
- base = getBuiltin.call(process, 'node:url').pathToFileURL(p);
478
+ base = builtin('node:url').pathToFileURL(p);
479
479
  }
480
480
  const url = new URL(src, base);
481
481
  if (url.protocol !== 'file:') return null;
@@ -21,14 +21,11 @@
21
21
  import { parseSvgPath } from '../path.js';
22
22
  import { flatten } from '../rasterize.js';
23
23
  import { trapezoidize } from '../trapezoid.js';
24
+ import { builtin, nodeRequire } from '../builtin.js';
24
25
  import Font from '../text/font.js';
25
26
 
26
- // node builtins are fetched lazily so browser bundles never resolve them;
27
- // there, inject the assets with configureTex() instead.
28
- function nodeRequire() {
29
- const mod = globalThis.process?.getBuiltinModule?.('node:module');
30
- return mod ? mod.createRequire(import.meta.url) : null;
31
- }
27
+ // node builtins are fetched lazily (nodeRequire/builtin) so browser bundles
28
+ // never resolve them; there, inject the assets with configureTex() instead.
32
29
 
33
30
  // katex is sizable — load it on first formula, not on package import
34
31
  let katex = null;
@@ -82,8 +79,8 @@ function katexFontFiles() {
82
79
  'TeX rendering: KaTeX fonts cannot be read here — inject them with configureTex({ fonts })'
83
80
  );
84
81
  }
85
- const { readdirSync } = process.getBuiltinModule('node:fs');
86
- const { dirname, join } = process.getBuiltinModule('node:path');
82
+ const { readdirSync } = builtin('node:fs');
83
+ const { dirname, join } = builtin('node:path');
87
84
  fontDir = join(dirname(require.resolve('katex/package.json')), 'dist', 'fonts');
88
85
  fontFiles = new Set(readdirSync(fontDir).filter((f) => f.endsWith('.ttf')));
89
86
  }
package/lib/window.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import x11 from 'x11';
2
2
 
3
+ import { builtin } from './builtin.js';
3
4
  import { safeRelease } from './cleanup.js';
4
5
  import Drawable from './drawable.js';
5
6
  import { packIcons, unpackIcons } from './imagedata.js';
@@ -1086,29 +1087,35 @@ export default class Window extends Drawable {
1086
1087
  }
1087
1088
 
1088
1089
  /**
1089
- * Whether the fence for the last frame is still unanswered — the server has
1090
- * not yet confirmed it consumed everything that frame drew.
1090
+ * Whether a blit this window already owes is still waiting to go out —
1091
+ * because the fence for the last frame is unanswered, or because a present
1092
+ * is deferred behind the minimum inter-blit interval.
1091
1093
  *
1092
1094
  * The one bit of frame-clock state worth publishing, because it is the
1093
1095
  * difference between the two ways a toolkit can answer a discrete input.
1094
1096
  * Drawing the response inside the event handler costs a frame less latency:
1095
1097
  * the blit goes out with the handler's own requests (see the `_present()`
1096
1098
  * at the end of the event dispatch) instead of waiting for the next paced
1097
- * frame — provided the last blit is at least `frameInterval` old, which is
1098
- * what a discrete input arriving out of the blue always is. Drawing it while
1099
- * a frame is in flight costs a frame's *work* for nothing: the present is
1100
- * deferred until the ack and coalesces with the one already pending, so only
1101
- * the last paint is ever seen. So `false`
1099
+ * frame. Drawing it when a blit is already queued costs a frame's *work* for
1100
+ * nothing: the paint lands in the same backing store and coalesces into the
1101
+ * present already pending, so only the last one is ever seen. So `false`
1102
1102
  * means "draw it now", `true` means "leave it to the frame clock" — which
1103
1103
  * is how a burst of discrete events (a spun wheel) paints its first notch
1104
1104
  * immediately and folds the rest into one catch-up frame.
1105
1105
  *
1106
- * Always `false` under `frameSync: false`: no fence is ever sent, so there
1107
- * is nothing to wait for, and a caller gating on this gets the unpaced
1108
- * behaviour that option asks for.
1106
+ * Both gates have to be reported, not just the fence. The fence is the one
1107
+ * that bites on a slow connection. The minimum inter-blit interval (#195) is
1108
+ * the one that bites on a fast local server, where the fence for a notch is
1109
+ * answered well before the next notch is even read, and what actually holds
1110
+ * the burst back is the interval. Reporting only the fence there answers
1111
+ * "draw it now" to every notch — the precise work this gate exists to skip.
1112
+ *
1113
+ * `false` whenever nothing gates blits at all: `frameSync: false` sends no
1114
+ * fence and `frameInterval: 0` keeps no interval, so a caller who asked for
1115
+ * both gets the unpaced behaviour those options ask for.
1109
1116
  */
1110
1117
  frameInFlight() {
1111
- return this._frame.inFlight;
1118
+ return this._frame.inFlight || this._presentPending;
1112
1119
  }
1113
1120
 
1114
1121
  _present() {
@@ -1911,7 +1918,7 @@ export default class Window extends Drawable {
1911
1918
  /** os.hostname(), looked up once, and absent in a browser bundle. */
1912
1919
  static _hostname() {
1913
1920
  if (Window._cachedHostname === undefined) {
1914
- const os = globalThis.process?.getBuiltinModule?.('node:os');
1921
+ const os = builtin('node:os');
1915
1922
  Window._cachedHostname = os?.hostname?.() ?? null;
1916
1923
  }
1917
1924
  return Window._cachedHostname;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "6.3.0",
3
+ "version": "6.5.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",
@@ -30,7 +30,7 @@
30
30
  "lib"
31
31
  ],
32
32
  "engines": {
33
- "node": ">=20.19.0"
33
+ "node": ">=18.19.0"
34
34
  },
35
35
  "dependencies": {
36
36
  "bidi-js": "^1.0.3",
@@ -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.7.0",
51
+ "x11": "^3.8.0",
52
52
  "yoga-layout": "^3.2.1"
53
53
  },
54
54
  "scripts": {