ntk 8.7.0 → 8.8.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,7 +1,17 @@
1
1
  import { connectionGone } from './cleanup.js';
2
2
  import Clipboard from './clipboard.js';
3
3
  import { CursorCache } from './cursor.js';
4
- import { GLError, backendFor, glCapabilities, glError, nativeRefreshRate, resolveGLPolicy } from './gl.js';
4
+ import {
5
+ DIRECT_SAMPLES,
6
+ GLError,
7
+ backendFor,
8
+ glCapabilities,
9
+ glError,
10
+ nativeRefreshRate,
11
+ requestedSamples,
12
+ resolveGLPolicy,
13
+ warnDirectSamples
14
+ } from './gl.js';
5
15
  import { chooseGLXConfig } from './glx.js';
6
16
  import Picture from './picture.js';
7
17
  import Pixmap from './pixmap.js';
@@ -620,6 +630,13 @@ export default class App {
620
630
  * fbconfig with an alpha channel. Direct needs no round trip to answer —
621
631
  * there are no fbconfigs in it, only a window the GPU draws for.
622
632
  *
633
+ * What a backend cannot do, the answer says rather than drops: `samples`
634
+ * is the colour samples per pixel the returned config really has, which
635
+ * on the direct backend is 0 today whatever the spec asked for (issue
636
+ * #341, `DIRECT_SAMPLES` in lib/gl.js). A `SAMPLES`/`SAMPLE_BUFFERS`
637
+ * request that cannot be met also warns once per connection, because an
638
+ * aliased edge is otherwise found months later in a screenshot.
639
+ *
623
640
  * @param {object} [spec] GLX attributes, e.g. `{ DEPTH_SIZE: 24 }`
624
641
  */
625
642
  async chooseGLConfig(spec = {}) {
@@ -638,6 +655,11 @@ export default class App {
638
655
  `direct rendering: screen ${screenNum} has no 32-bit visual, so a GL window cannot have an alpha channel`
639
656
  );
640
657
  }
658
+ // asked for multisampling this backend cannot give? say so once, then
659
+ // answer honestly below rather than dropping the attribute (issue #341)
660
+ const wantSamples = requestedSamples(spec);
661
+ if (wantSamples > DIRECT_SAMPLES) warnDirectSamples(this, caps.flavor, wantSamples);
662
+
641
663
  return {
642
664
  backend: 'direct',
643
665
  // which direct pipeline this connection runs: 'dri3' or 'appledri'
@@ -649,6 +671,9 @@ export default class App {
649
671
  class: 4, // TrueColor; the only class these buffers can be read as
650
672
  doubleBuffer: true, // a swap chain, always
651
673
  depthSize: spec.DEPTH_SIZE ?? 16,
674
+ // no flavor can allocate a multisampled window buffer yet; said out
675
+ // loud so a caller can supersample instead of assuming MSAA
676
+ samples: DIRECT_SAMPLES,
652
677
  screen: screenNum,
653
678
  fbconfig: null,
654
679
  device: caps.device,
package/lib/gl.js CHANGED
@@ -44,10 +44,13 @@ import { nodeRequire } from './builtin.js';
44
44
  * Linux only, macOS needs no device node.
45
45
  * - `GL_REMOTE_DISPLAY` — a TCP or forwarded display; both flavors are
46
46
  * local-only by construction.
47
- * - `GL_NO_FD_PASSING` — the display is local, but this JavaScript runtime
48
- * cannot send a descriptor over the socket. Bun is the case in the field;
49
- * x11 does it through a Node internal Bun does not implement. Linux only —
50
- * Apple-DRI passes no descriptors.
47
+ * - `GL_NO_FD_PASSING` — the display is local, but this connection cannot
48
+ * send a descriptor over the socket. x11 has a transport for each runtime
49
+ * it supports — `process.binding('pipe_wrap')` under Node, `bun:ffi`
50
+ * calling `sendmsg(2)` under Bun since x11 4.1.0 — so what is left here is
51
+ * an x11 below that under Bun, a runtime with neither path (Deno), or a
52
+ * transport that would not initialise. Linux only — Apple-DRI passes no
53
+ * descriptors.
51
54
  * - `GL_NO_DRI3` — the server has no DRI3/Present (Xvfb, Xephyr, XQuartz —
52
55
  * though XQuartz has its own path, see `GL_NO_APPLEDRI`).
53
56
  * - `GL_NO_APPLEDRI` — macOS, and the server has no Apple-DRI extension:
@@ -418,17 +421,20 @@ export function glCapabilities(app) {
418
421
 
419
422
  if (!canPassDescriptors(app.display)) {
420
423
  // The socket is local; what is missing is the ability to send a
421
- // descriptor along it. x11 does that through Node's internal
422
- // `process.binding('pipe_wrap')`, so a runtime that does not implement
423
- // it lands here — Bun, today. Saying "not a local socket" would send
424
- // the reader off to check their DISPLAY, which is fine.
424
+ // descriptor along it. x11 carries one through Node's internal
425
+ // `process.binding('pipe_wrap')`, and under Bun through `bun:ffi`
426
+ // calling `sendmsg(2)` — the latter since x11 4.1.0, so an x11 below
427
+ // that under Bun lands here, as does a runtime with neither path and a
428
+ // transport that would not initialise. Saying "not a local socket"
429
+ // would send the reader off to check their DISPLAY, which is fine.
425
430
  return fail(
426
431
  GLError.NO_FD_PASSING,
427
- `this ${runtimeName()} cannot pass file descriptors over the X socket, and DRI3 works by passing one`,
428
- 'The connection is local; the runtime is what cannot carry the descriptor.\n' +
429
- "x11 sends one through Node's process.binding('pipe_wrap'), which Bun does\n" +
430
- 'not implement. Run under Node for direct rendering, or leave glPolicy at its\n' +
431
- 'default and use indirect GLX, which needs no descriptor passing at all.'
432
+ `this connection cannot pass file descriptors over the X socket under ${runtimeName()}, and DRI3 works by passing one`,
433
+ 'The connection is local; the descriptor transport is what is missing.\n' +
434
+ "x11 carries one through Node's process.binding('pipe_wrap') under Node, and\n" +
435
+ 'through bun:ffi sendmsg(2) under Bun from x11 4.1.0 on. Under Bun on an older\n' +
436
+ 'x11, `npm install x11@^4.1.0` is the whole fix. Otherwise leave glPolicy at\n' +
437
+ 'its default and use indirect GLX, which needs no descriptor passing at all.'
432
438
  );
433
439
  }
434
440
 
@@ -467,6 +473,68 @@ export function glCapabilities(app) {
467
473
  return app._glCaps;
468
474
  }
469
475
 
476
+ /**
477
+ * How many colour samples per pixel a direct-backend window has. Zero, on
478
+ * both flavors, and this is the one place that says so.
479
+ *
480
+ * Not because multisampling is exotic on a GPU — it is nearly free there —
481
+ * but because the sample count belongs to the pixel format the `x11-dri`
482
+ * addon builds one layer below ntk: `EGL_SAMPLES` on the EGLConfig behind
483
+ * the GBM surface (`dri3`), `kCGLPFASamples`/`kCGLPFASampleBuffers` on the
484
+ * CGL pixel format (`appledri`). Its `Gpu` and `apple.Context` take a depth
485
+ * size and no sample count, and its GL table has neither
486
+ * `renderbufferStorageMultisample` nor `blitFramebuffer`, so there is not a
487
+ * multisampled framebuffer for ntk to resolve by hand either. Nothing here
488
+ * can conjure one — what it can do is not pretend, which is why
489
+ * `chooseGLConfig` reports `samples` on every backend and says so out loud
490
+ * when the spec asked for more than it got (issue #341).
491
+ *
492
+ * When the addon grows the option this becomes what it reports, and the
493
+ * request travels the rest of the way without another change here.
494
+ */
495
+ export const DIRECT_SAMPLES = 0;
496
+
497
+ /**
498
+ * The sample count a GLX-vocabulary spec asks for: `SAMPLES` when it names
499
+ * one, 1 for a bare `SAMPLE_BUFFERS` (multisample, width up to the driver),
500
+ * and 0 when it asks for no multisampling at all — which is also what a
501
+ * config object from `chooseGLConfig` reads as, since it carries neither.
502
+ */
503
+ export function requestedSamples(spec = {}) {
504
+ const samples = Number(spec.SAMPLES) || 0;
505
+ if (samples > 0) return samples;
506
+ return Number(spec.SAMPLE_BUFFERS) > 0 ? 1 : 0;
507
+ }
508
+
509
+ /**
510
+ * Say — once per connection, on the console — that a multisample request
511
+ * cannot be honoured here.
512
+ *
513
+ * A downgrade rather than a failure: the window renders, its edges alias.
514
+ * That is exactly the kind of thing an app finds out about six months later
515
+ * from a screenshot, so it is worth one warning and a `samples` field to
516
+ * branch on. Once per connection because every window would otherwise say
517
+ * it again.
518
+ */
519
+ export function warnDirectSamples(app, flavor, wanted) {
520
+ if (app._warnedDirectSamples) return;
521
+ app._warnedDirectSamples = true;
522
+ console.warn(
523
+ `ntk: this GL request asks for SAMPLES=${wanted}, and the direct backend` +
524
+ `${flavor ? ` (${flavor} flavor)` : ''} cannot give a window a multisampled buffer, so it has ` +
525
+ 'samples: 0 and its edges will alias.\n' +
526
+ '\n' +
527
+ ' The sample count belongs to the pixel format x11-dri builds one layer below\n' +
528
+ ' ntk (EGL_SAMPLES on dri3, kCGLPFASamples on appledri), and it takes no such\n' +
529
+ ' option yet — there is nothing here to ask with.\n' +
530
+ '\n' +
531
+ ' Branch on config.samples (or gl.samples) rather than on having asked. For\n' +
532
+ " multisampling today: indirect GLX honours SAMPLES (glPolicy: 'indirect'),\n" +
533
+ ' where the server picks an fbconfig that has it — or supersample in your own\n' +
534
+ ' draw code. See docs/context-gles.md#multisampling.'
535
+ );
536
+ }
537
+
470
538
  /**
471
539
  * The backend `getContext('opengl')` should use right now, synchronously.
472
540
  *
package/lib/glx.js CHANGED
@@ -131,7 +131,15 @@ const legacyProps = {
131
131
  BLUE_SIZE: 'blueBits',
132
132
  ALPHA_SIZE: 'alphaBits',
133
133
  AUX_BUFFERS: 'numAuxBuffers',
134
- LEVEL: 'level'
134
+ LEVEL: 'level',
135
+ // Not among GetVisualConfigs' fixed properties, but a server that has
136
+ // multisample visuals sends these as (tag, value) pairs after them, which
137
+ // x11 decodes under their attribute names. Mapped to themselves so a
138
+ // SAMPLES request is filtered on this path too rather than passing
139
+ // through unexamined — a spec that asks for multisampling and gets a
140
+ // visual without it is the failure this whole field exists to stop.
141
+ SAMPLE_BUFFERS: 'SAMPLE_BUFFERS',
142
+ SAMPLES: 'SAMPLES'
135
143
  };
136
144
 
137
145
  // attributes compared as "at least this much"; the rest must match exactly
@@ -219,8 +227,11 @@ function matchLegacy(configs, spec) {
219
227
  * `null` means "don't care". `screen` picks the X screen (default 0),
220
228
  * `visual` pins a specific visual id and skips the search.
221
229
  * @returns {Promise<{visual: number, depth: number, class: number,
222
- * doubleBuffer: boolean, depthSize: number, fbconfig: number|null,
223
- * config: object}>}
230
+ * doubleBuffer: boolean, depthSize: number, samples: number|null,
231
+ * fbconfig: number|null, config: object}>} `samples` is the colour
232
+ * samples per pixel the chosen config has — 0 for no multisampling, and
233
+ * `null` only in the pinned-`visual` case, where no fbconfig was looked
234
+ * at to know.
224
235
  */
225
236
  export async function chooseGLXConfig(app, spec = {}) {
226
237
  const GLX = app.display.GLX;
@@ -255,6 +266,9 @@ export async function chooseGLXConfig(app, spec = {}) {
255
266
  class: info.visual.class,
256
267
  doubleBuffer: !!toNumber(want.DOUBLEBUFFER),
257
268
  depthSize: toNumber(want.DEPTH_SIZE) || 0,
269
+ // the caller pinned the visual, so nothing was chosen and no fbconfig
270
+ // was read: unknown, which is not the same claim as "none"
271
+ samples: null,
258
272
  screen: screenNum,
259
273
  fbconfig: null,
260
274
  config: {}
@@ -272,6 +286,7 @@ export async function chooseGLXConfig(app, spec = {}) {
272
286
  class: info.visual.class,
273
287
  doubleBuffer: !!cfg.DOUBLEBUFFER,
274
288
  depthSize: cfg.DEPTH_SIZE || 0,
289
+ samples: cfg.SAMPLES || 0,
275
290
  screen: screenNum,
276
291
  fbconfig: cfg.FBCONFIG_ID,
277
292
  config: cfg
@@ -292,6 +307,7 @@ export async function chooseGLXConfig(app, spec = {}) {
292
307
  class: info.visual.class,
293
308
  doubleBuffer: !!cfg.doubleBufferMode,
294
309
  depthSize: cfg.depthBits || 0,
310
+ samples: cfg.SAMPLES || 0,
295
311
  screen: screenNum,
296
312
  fbconfig: null,
297
313
  config: cfg
@@ -55,7 +55,9 @@ import {
55
55
  drawGlyphRuns,
56
56
  encodeGlyphItems,
57
57
  positionedRunsInk,
58
+ roundFrom,
58
59
  runId,
60
+ snapOrigin,
59
61
  } from "./text/glyphs.js";
60
62
  import { TextLayout } from "./text/layout.js";
61
63
  import { reorderRuns } from "./text/shape.js";
@@ -1365,6 +1367,12 @@ class RenderingContext2d {
1365
1367
  * whole pixel inside the surface, and the composite carries it to wherever
1366
1368
  * the text is. Glyph origins are rounded to whole pixels on the way to the
1367
1369
  * server anyway, so nothing is lost by it.
1370
+ *
1371
+ * Where it lands is rounded the way `positionGlyphs` rounds a glyph
1372
+ * (`roundFrom`), so the shadow moves with its text: drawn whole pixels
1373
+ * away, it lands exactly that many pixels away, and a scroll blit can copy
1374
+ * it (issue #350). Rounded as one floating-point sum, an anchor on a half
1375
+ * pixel went down at x 104 and up at x 152.
1368
1376
  */
1369
1377
  _shadowOfText(text, x, y) {
1370
1378
  const app = this.window.app;
@@ -1435,8 +1443,8 @@ class RenderingContext2d {
1435
1443
  if (!surface) return;
1436
1444
  this._paintShadow(
1437
1445
  surface,
1438
- Math.round(runX + this._shadowOffsetX) - originX,
1439
- Math.round(runY + this._shadowOffsetY) - originY,
1446
+ roundFrom(runX, this._shadowOffsetX) - originX,
1447
+ roundFrom(runY, this._shadowOffsetY) - originY,
1440
1448
  );
1441
1449
  }
1442
1450
 
@@ -1470,13 +1478,19 @@ class RenderingContext2d {
1470
1478
  // what the bitmap glyph path draws at anyway, and they keep the key
1471
1479
  // stable as the paragraph moves — `(x + a) - (x + b)` is not exactly
1472
1480
  // `a - b` in floating point, and an origin-dependent key would miss the
1473
- // cache on every scroll.
1481
+ // cache on every scroll. Rounding alone does not absorb that where
1482
+ // `a - b` is on a half pixel: the difference came to a hair under it at
1483
+ // x 104 and exactly on it at x 152, and rounded two ways. So both
1484
+ // origins are snapped first, as `positionGlyphs` snaps them (issue
1485
+ // #350), and snapped origins subtract exactly.
1474
1486
  const ax = positioned[0].x;
1475
1487
  const ay = positioned[0].y;
1488
+ const sx = snapOrigin(ax);
1489
+ const sy = snapOrigin(ay);
1476
1490
  const local = positioned.map((p) => ({
1477
1491
  run: p.run,
1478
- x: Math.round(p.x - ax),
1479
- y: Math.round(p.y - ay),
1492
+ x: Math.round(snapOrigin(p.x) - sx),
1493
+ y: Math.round(snapOrigin(p.y) - sy),
1480
1494
  textRendering: p.textRendering,
1481
1495
  }));
1482
1496
  const key = `${local
@@ -1516,11 +1530,12 @@ class RenderingContext2d {
1516
1530
  return coverage;
1517
1531
  });
1518
1532
  if (surface) {
1533
+ // anchored as fillText's shadow is, so it moves with the text
1519
1534
  const origin = surface._shadowOrigin;
1520
1535
  this._paintShadow(
1521
1536
  surface,
1522
- Math.round(ax + this._shadowOffsetX) - origin.x,
1523
- Math.round(ay + this._shadowOffsetY) - origin.y,
1537
+ roundFrom(ax, this._shadowOffsetX) - origin.x,
1538
+ roundFrom(ay, this._shadowOffsetY) - origin.y,
1524
1539
  );
1525
1540
  return;
1526
1541
  }
@@ -43,7 +43,15 @@
43
43
 
44
44
  import { connectionGone } from './cleanup.js';
45
45
  import Drawable from './drawable.js';
46
- import { GLError, backendFor, glError, loadDriAddon } from './gl.js';
46
+ import {
47
+ DIRECT_SAMPLES,
48
+ GLError,
49
+ backendFor,
50
+ glError,
51
+ loadDriAddon,
52
+ requestedSamples,
53
+ warnDirectSamples
54
+ } from './gl.js';
47
55
 
48
56
  /** pacing fallback where the display's rate is not known (see App#frameInterval) */
49
57
  const FALLBACK_FRAME_INTERVAL = 1000 / 60;
@@ -115,6 +123,17 @@ does not make it retroactive. Either:
115
123
  );
116
124
  }
117
125
  this.depth = depth;
126
+
127
+ /**
128
+ * Colour samples per pixel — 0 here, as on every direct flavor
129
+ * (`DIRECT_SAMPLES`). A config asking for more is a request this
130
+ * pipeline cannot pass on, so it is answered rather than dropped: the
131
+ * warning fires once per connection, and draw code branches on this.
132
+ */
133
+ this.samples = DIRECT_SAMPLES;
134
+ const wantSamples = Math.max(requestedSamples(config), Number(config.samples) || 0);
135
+ if (wantSamples > DIRECT_SAMPLES) warnDirectSamples(app, 'appledri', wantSamples);
136
+
118
137
  this._screen = config.screen ?? 0;
119
138
  this._clientId = caps.appleClientId ?? dri.apple.clientId();
120
139
 
@@ -17,7 +17,15 @@
17
17
  // is proven.
18
18
 
19
19
  import Drawable from './drawable.js';
20
- import { GLError, backendFor, glError, loadDriAddon } from './gl.js';
20
+ import {
21
+ DIRECT_SAMPLES,
22
+ GLError,
23
+ backendFor,
24
+ glError,
25
+ loadDriAddon,
26
+ requestedSamples,
27
+ warnDirectSamples
28
+ } from './gl.js';
21
29
  import { GLSwapchain } from './glswapchain.js';
22
30
 
23
31
  /**
@@ -98,6 +106,16 @@ does not make it retroactive. Either:
98
106
  }
99
107
  this.depth = depth;
100
108
 
109
+ /**
110
+ * Colour samples per pixel — 0 here, as on every direct flavor
111
+ * (`DIRECT_SAMPLES`). A config asking for more is a request this
112
+ * pipeline cannot pass on, so it is answered rather than dropped: the
113
+ * warning fires once per connection, and draw code branches on this.
114
+ */
115
+ this.samples = DIRECT_SAMPLES;
116
+ const wantSamples = Math.max(requestedSamples(config), Number(config.samples) || 0);
117
+ if (wantSamples > DIRECT_SAMPLES) warnDirectSamples(app, caps.flavor, wantSamples);
118
+
101
119
  const policy = app.glPolicy;
102
120
  this.gpu = sharedGpu(app, dri, {
103
121
  format: depth === 32 ? dri.FORMAT.ARGB8888 : dri.FORMAT.XRGB8888,
@@ -30,6 +30,14 @@ class RenderingContextOpenGL {
30
30
  this.contextId = 0;
31
31
  /** the config chosen (or passed in), see app.chooseGLXConfig */
32
32
  this.config = null;
33
+ /**
34
+ * Colour samples per pixel the config in use has: 0 until the config is
35
+ * resolved, and 0 afterwards unless the spec asked for multisampling and
36
+ * the server had an fbconfig with it. The direct contexts carry the same
37
+ * field, so draw code can decide whether to antialias itself without
38
+ * knowing which backend it is on.
39
+ */
40
+ this.samples = 0;
33
41
  this.visual = 0;
34
42
  this.error = null;
35
43
 
@@ -75,6 +83,9 @@ class RenderingContextOpenGL {
75
83
  this.ready = (async () => {
76
84
  const cfg = await this._resolveConfig(config);
77
85
  this.config = cfg;
86
+ // a config passed in by hand may be the fbconfig itself rather than a
87
+ // chooseGLXConfig result, so read either shape
88
+ this.samples = cfg.samples ?? cfg.config?.SAMPLES ?? cfg.SAMPLES ?? 0;
78
89
  this.visual = cfg.visual;
79
90
  if (window.visual && window.visual !== cfg.visual) {
80
91
  console.warn(
@@ -319,6 +319,11 @@ export function encodeGlyphItems(items, bits) {
319
319
  return { gsid, bits, elts };
320
320
  }
321
321
 
322
+ // steps per pixel a text origin is snapped to before anything rounds against
323
+ // it (see positionGlyphs and snapOrigin); a power of two, so the snap and the
324
+ // split are exact
325
+ const ORIGIN_STEPS = 256;
326
+
322
327
  /**
323
328
  * Compute device positions for shaped runs — the single source of truth for
324
329
  * where each glyph's origin lands, shared by the renderer and by tests that
@@ -328,20 +333,86 @@ export function encodeGlyphItems(items, bits) {
328
333
  * Font.shape()/shapeText() (already in visual order) and x/y is the run's
329
334
  * baseline origin in device space.
330
335
  *
336
+ * Placement is translation-invariant (issue #350): the same run drawn whole
337
+ * pixels away puts every glyph exactly that many pixels away, so a renderer
338
+ * that copies pixels it already drew — a scroll blit — matches a repaint
339
+ * byte for byte. The origin arrives as a whole-pixel position plus the
340
+ * layout's fractional offsets, summed in floating point, and the last bits
341
+ * of that sum depend on the whole part's magnitude: rounded as one number, a
342
+ * pen exactly on a half pixel went down at x 104 and up at x 152. So the
343
+ * origin is snapped to 1/256 px first and split into whole pixels and a
344
+ * fraction in [0, 1), and the pen walks from the fraction alone. Two origins
345
+ * whole pixels apart snap to fractions that are bit-identical, so every
346
+ * rounding after the snap sees the same numbers wherever the run is drawn.
347
+ * (Taking the fraction after the snap is what keeps it below 1: an origin a
348
+ * hair under a whole pixel and one exactly on it walk the same pen from 0.)
349
+ *
350
+ * The snap moves a glyph only where its pen lies within 1/512 px of a
351
+ * rounding boundary. The one knife-edge left is the snap's own — an origin
352
+ * within an ulp of an odd multiple of 1/512 px — a far narrower target than
353
+ * any pen along the run landing on a half pixel.
354
+ *
331
355
  * @returns {Array<{run, glyph, x, y}>} integer glyph-origin positions
332
356
  */
333
357
  export function positionGlyphs(positioned) {
334
358
  const out = [];
335
359
  for (const { run, x, y } of positioned) {
336
- let cursor = x;
360
+ const sx = snapOrigin(x);
361
+ const sy = snapOrigin(y);
362
+ const ox = Math.floor(sx);
363
+ const oy = Math.floor(sy);
364
+ const fy = sy - oy;
365
+ let cursor = sx - ox;
337
366
  for (const g of run.glyphs) {
338
- out.push({ run, glyph: g, x: Math.round(cursor + g.dx), y: Math.round(y - g.dy) });
367
+ out.push({
368
+ run,
369
+ glyph: g,
370
+ x: ox + Math.round(cursor + g.dx),
371
+ y: oy + Math.round(fy - g.dy)
372
+ });
339
373
  cursor += g.ax;
340
374
  }
341
375
  }
342
376
  return out;
343
377
  }
344
378
 
379
+ /**
380
+ * A device-space origin snapped to 1/256 px, the way `positionGlyphs` takes
381
+ * a run's origin before anything rounds against it (issue #350).
382
+ *
383
+ * What comes back is an exact multiple of 1/256, so whole pixels split off
384
+ * it, and two snapped origins subtract, with no rounding at all. Origins
385
+ * whole pixels apart snap exactly that many pixels apart, bit for bit, even
386
+ * though the origins themselves — a whole-pixel position plus fractional
387
+ * layout offsets, summed in floating point — differ in their last bits.
388
+ * Anything else that rounds against a text origin needs the same: a text
389
+ * shadow's anchor, and the offsets between the runs its coverage holds.
390
+ *
391
+ * @param {number} v a device coordinate
392
+ * @returns {number} `v` to the nearest 1/256 px
393
+ */
394
+ export function snapOrigin(v) {
395
+ return Math.round(v * ORIGIN_STEPS) / ORIGIN_STEPS;
396
+ }
397
+
398
+ /**
399
+ * `Math.round(origin + offset)`, rounded the way `positionGlyphs` rounds a
400
+ * glyph `offset` px from its run's origin: the snapped origin's whole
401
+ * pixels, plus its fraction and the offset rounded together. The one sum
402
+ * that rounds never sees the whole part, so moving the origin by whole
403
+ * pixels moves the answer by exactly those pixels, whatever the offset is.
404
+ * It is where a text shadow composites, `offset` being the shadow's own.
405
+ *
406
+ * @param {number} origin a device coordinate, a run's origin
407
+ * @param {number} offset px from it, along the same axis
408
+ * @returns {number} the whole pixel `origin + offset` lands on
409
+ */
410
+ export function roundFrom(origin, offset) {
411
+ const s = snapOrigin(origin);
412
+ const whole = Math.floor(s);
413
+ return whole + Math.round(s - whole + offset);
414
+ }
415
+
345
416
  /**
346
417
  * Ink extents of positioned runs, in whatever coordinates their origins are
347
418
  * given in — the union of every glyph's bounding box, laid out exactly as
package/lib/window.js CHANGED
@@ -3719,6 +3719,9 @@ export default class Window extends Drawable {
3719
3719
  * connection selected and nothing else adds to it behind the caller's
3720
3720
  * back. It resolves rather than returning undefined because the promise is
3721
3721
  * the whole interface: an awaited no-op still has to mean "you have it".
3722
+ *
3723
+ * `deselectInput(mask)` is the inverse, and the only thing that lowers the
3724
+ * mask again — `off()` drops the listener and leaves the selection.
3722
3725
  */
3723
3726
  selectInput(mask) {
3724
3727
  const added = mask & ~this.eventMask;
@@ -3736,6 +3739,92 @@ export default class Window extends Drawable {
3736
3739
  });
3737
3740
  }
3738
3741
 
3742
+ /**
3743
+ * The bits of the event mask something in this process still needs: the
3744
+ * union over every event name with a live listener, plus what ntk's own
3745
+ * machinery requires. `deselectInput` refuses exactly these, so
3746
+ * `mask & wnd.heldEventMask` asks "would that be refused?" without making
3747
+ * the request.
3748
+ *
3749
+ * Not a subset of `eventMask`. A listener implies a bit whether or not
3750
+ * this connection ever selected it — an adopted window selects nothing
3751
+ * (issue #322), and ntk's own map/unmap/destroy handlers listen without
3752
+ * asking for anything.
3753
+ */
3754
+ get heldEventMask() {
3755
+ let held = 0;
3756
+ for (const name in xevents.mask) {
3757
+ const bit = xevents.mask[name];
3758
+ // 0 marks an event that arrives regardless of any selection
3759
+ if (bit && this.listenerCount(name) > 0) held |= bit;
3760
+ }
3761
+ // the redraw cycle is driven by intercepting Expose, whether or not
3762
+ // anyone listens for it: a double-buffered window that stops selecting
3763
+ // Exposure stops repainting (see _enableBackingStore)
3764
+ if (this._backing) held |= x11.eventMask.Exposure;
3765
+ return held;
3766
+ }
3767
+
3768
+ /**
3769
+ * Stop selecting `mask` — the inverse of `selectInput`, and the only way a
3770
+ * window's event mask ever goes down (issue #318).
3771
+ *
3772
+ * `on(name, fn)` raises the mask as a side effect and `off()` does not
3773
+ * lower it again, so a window that stops needing an event goes on being
3774
+ * sent it until it is destroyed. For most masks that is invisible; for
3775
+ * PointerMotion it is 32 bytes per motion event — 2 KB/s at 60 Hz, and
3776
+ * 8 KB/s where the motion is XI2's — for as long as the pointer is over
3777
+ * the window. A viewer that hides its chrome, a toolbar that unmounts, a
3778
+ * window that goes to a background tab: they know when they stop needing
3779
+ * hover, and this is how they say so.
3780
+ *
3781
+ * Bits a live listener still needs are kept rather than cleared, because
3782
+ * dropping PointerMotion out from under an `on('mousemove')` is the one
3783
+ * bug this API can have. Several names share one bit — `map`, `unmap`,
3784
+ * `resize`, `reparent`, `gravity`, `circulate` and `destroy` are all
3785
+ * StructureNotify — so losing the last `map` listener does not make the
3786
+ * bit free. It resolves with the bits it kept for that reason, `0` when it
3787
+ * cleared everything it was asked to:
3788
+ *
3789
+ * const kept = await wnd.deselectInput(x11.eventMask.PointerMotion);
3790
+ * if (kept) ... // something in this process is still listening
3791
+ *
3792
+ * Two selections it will therefore not clear: StructureNotify, held by
3793
+ * ntk's own map/unmap/destroy bookkeeping on every window, and Exposure on
3794
+ * a double-buffered one. XI2 is selected separately and keeps its own
3795
+ * inverse — `selectXI2([])` (see lib/xi2.js).
3796
+ */
3797
+ deselectInput(mask) {
3798
+ const clear = mask & this.eventMask & ~this.heldEventMask;
3799
+ const kept = mask & this.eventMask & ~clear;
3800
+ // asking to drop what is not selected, or only what is spoken for, is a
3801
+ // request that would change nothing
3802
+ if (clear === 0) return Promise.resolve(kept);
3803
+ this.eventMask &= ~clear;
3804
+ // no motion means no hints, so no frame owes the server the QueryPointer
3805
+ // that re-arms one. A poll already in flight is left alone, as
3806
+ // setMouseHintOnly leaves it: its answer is a real position.
3807
+ if (clear & (x11.eventMask.PointerMotion | x11.eventMask.PointerMotionHint)) {
3808
+ this._hintRearm = false;
3809
+ }
3810
+ // A window the server has already taken, or a connection on its way out:
3811
+ // there is no selection left to lower, and node-x11 throws at a request
3812
+ // from then on (issue #321). The tracked mask is lowered all the same —
3813
+ // it says what this connection holds, and it holds nothing.
3814
+ if (this._destroyed || connectionGone(this.X)) return Promise.resolve(kept);
3815
+ return new Promise((resolve, reject) => {
3816
+ this.X.ChangeWindowAttributes(this.id, { eventMask: this.eventMask }, (err) => {
3817
+ if (!err) return resolve(kept);
3818
+ // the request failed as a whole, so the server kept the mask it had:
3819
+ // take the bits back rather than leave `eventMask` denying a
3820
+ // selection this connection still holds — the next on() reads it to
3821
+ // decide whether a write can be skipped
3822
+ this.eventMask |= clear;
3823
+ reject(err);
3824
+ });
3825
+ });
3826
+ }
3827
+
3739
3828
  /**
3740
3829
  * Add this window to our save-set (X ChangeSaveSet). A window manager
3741
3830
  * reparents clients into frames it owns; without the save-set, the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.7.0",
3
+ "version": "8.8.1",
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",
@@ -44,7 +44,7 @@
44
44
  "linebreak": "^1.1.0",
45
45
  "parse-color": "^1.0.0",
46
46
  "pngjs": "^7.0.0",
47
- "x11": "^4.0.1"
47
+ "x11": "^4.1.0"
48
48
  },
49
49
  "optionalDependencies": {
50
50
  "x11-dri": ">=0.5.0 <1"