ntk 8.6.0 → 8.8.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,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
@@ -467,6 +467,68 @@ export function glCapabilities(app) {
467
467
  return app._glCaps;
468
468
  }
469
469
 
470
+ /**
471
+ * How many colour samples per pixel a direct-backend window has. Zero, on
472
+ * both flavors, and this is the one place that says so.
473
+ *
474
+ * Not because multisampling is exotic on a GPU — it is nearly free there —
475
+ * but because the sample count belongs to the pixel format the `x11-dri`
476
+ * addon builds one layer below ntk: `EGL_SAMPLES` on the EGLConfig behind
477
+ * the GBM surface (`dri3`), `kCGLPFASamples`/`kCGLPFASampleBuffers` on the
478
+ * CGL pixel format (`appledri`). Its `Gpu` and `apple.Context` take a depth
479
+ * size and no sample count, and its GL table has neither
480
+ * `renderbufferStorageMultisample` nor `blitFramebuffer`, so there is not a
481
+ * multisampled framebuffer for ntk to resolve by hand either. Nothing here
482
+ * can conjure one — what it can do is not pretend, which is why
483
+ * `chooseGLConfig` reports `samples` on every backend and says so out loud
484
+ * when the spec asked for more than it got (issue #341).
485
+ *
486
+ * When the addon grows the option this becomes what it reports, and the
487
+ * request travels the rest of the way without another change here.
488
+ */
489
+ export const DIRECT_SAMPLES = 0;
490
+
491
+ /**
492
+ * The sample count a GLX-vocabulary spec asks for: `SAMPLES` when it names
493
+ * one, 1 for a bare `SAMPLE_BUFFERS` (multisample, width up to the driver),
494
+ * and 0 when it asks for no multisampling at all — which is also what a
495
+ * config object from `chooseGLConfig` reads as, since it carries neither.
496
+ */
497
+ export function requestedSamples(spec = {}) {
498
+ const samples = Number(spec.SAMPLES) || 0;
499
+ if (samples > 0) return samples;
500
+ return Number(spec.SAMPLE_BUFFERS) > 0 ? 1 : 0;
501
+ }
502
+
503
+ /**
504
+ * Say — once per connection, on the console — that a multisample request
505
+ * cannot be honoured here.
506
+ *
507
+ * A downgrade rather than a failure: the window renders, its edges alias.
508
+ * That is exactly the kind of thing an app finds out about six months later
509
+ * from a screenshot, so it is worth one warning and a `samples` field to
510
+ * branch on. Once per connection because every window would otherwise say
511
+ * it again.
512
+ */
513
+ export function warnDirectSamples(app, flavor, wanted) {
514
+ if (app._warnedDirectSamples) return;
515
+ app._warnedDirectSamples = true;
516
+ console.warn(
517
+ `ntk: this GL request asks for SAMPLES=${wanted}, and the direct backend` +
518
+ `${flavor ? ` (${flavor} flavor)` : ''} cannot give a window a multisampled buffer, so it has ` +
519
+ 'samples: 0 and its edges will alias.\n' +
520
+ '\n' +
521
+ ' The sample count belongs to the pixel format x11-dri builds one layer below\n' +
522
+ ' ntk (EGL_SAMPLES on dri3, kCGLPFASamples on appledri), and it takes no such\n' +
523
+ ' option yet — there is nothing here to ask with.\n' +
524
+ '\n' +
525
+ ' Branch on config.samples (or gl.samples) rather than on having asked. For\n' +
526
+ " multisampling today: indirect GLX honours SAMPLES (glPolicy: 'indirect'),\n" +
527
+ ' where the server picks an fbconfig that has it — or supersample in your own\n' +
528
+ ' draw code. See docs/context-gles.md#multisampling.'
529
+ );
530
+ }
531
+
470
532
  /**
471
533
  * The backend `getContext('opengl')` should use right now, synchronously.
472
534
  *
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
package/lib/index.js CHANGED
@@ -51,6 +51,7 @@ import { DEFAULT_MASK_POLICY } from './maskcluster.js';
51
51
  import {
52
52
  DEFAULT_SHADOW_POLICY,
53
53
  blurCoverage,
54
+ blurScale,
54
55
  gaussianKernel1d,
55
56
  shadowReach,
56
57
  shadowSigma
@@ -286,9 +287,12 @@ export {
286
287
  // caller drawing shapes ntk's shadow properties never see. `blurCoverage`
287
288
  // *bakes* the two separable passes into a surface's pixels — unlike
288
289
  // `picture().setBlurFilter()`, whose kernel the server re-runs on every
289
- // composite, which a cached shadow pays for once per frame
290
+ // composite, which a cached shadow pays for once per frame — and runs a
291
+ // wide one at reduced scale, which `blurScale` names for a caller that
292
+ // would rather draw its shape small in the first place (issue #338)
290
293
  DEFAULT_SHADOW_POLICY,
291
294
  blurCoverage,
295
+ blurScale,
292
296
  gaussianKernel1d,
293
297
  shadowReach,
294
298
  shadowSigma,
@@ -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(
package/lib/shadow.js CHANGED
@@ -33,17 +33,31 @@
33
33
  // cached copy composites as a plain mask rather than re-running a kernel on
34
34
  // every frame.
35
35
  //
36
+ // ## Why a wide one runs small
37
+ //
38
+ // Two passes still cost `2 * taps * w * h`, and both grow with sigma: the
39
+ // ten shadows on react-x11's configurator come to 118M multiply-accumulates,
40
+ // which is 596ms of a first paint on software RENDER, and 73% of it is two
41
+ // wide ones (issue #338). A gaussian carries no detail finer than about σ/2
42
+ // px, so that work is spent resolving what the result cannot hold — past
43
+ // σ 8 the coverage is shrunk by 2 or 4 first, blurred at `sigma / scale`,
44
+ // and resolved back, which is `scale` off the kernel and `scale²` off the
45
+ // area for a difference of three levels of 8-bit alpha at worst.
46
+ // `blurScale` picks it, `scaleSigma` / `maxScale` bound it, and the surface
47
+ // that comes back is the size it always was, still with no filter on it.
48
+ //
36
49
  // ## What of this is public
37
50
  //
38
- // The bake and the maths around it — `blurCoverage`, `shadowSigma`,
39
- // `shadowReach`, `gaussianKernel1d` and `DEFAULT_SHADOW_POLICY` — are
40
- // re-exported from `lib/index.js` (issue #335). A toolkit that draws its own
41
- // shapes (react-x11 paints a `<box>`'s `boxShadow` itself, because the
42
- // rounded rect is a path it already has and the result goes through its own
43
- // paint cache) needs exactly this and nothing else: the alternative on the
44
- // public surface, `picture().setBlurFilter()`, sets a k×k filter that the
45
- // server re-runs on *every* composite, which is invisible until someone
46
- // profiles a real display. The surfaces and the cache below stay private —
51
+ // The bake and the maths around it — `blurCoverage`, `blurScale`,
52
+ // `shadowSigma`, `shadowReach`, `gaussianKernel1d` and
53
+ // `DEFAULT_SHADOW_POLICY` — are re-exported from `lib/index.js` (issues
54
+ // #335 and #338). A toolkit that draws its own shapes (react-x11 paints a
55
+ // `<box>`'s `boxShadow` itself, because the rounded rect is a path it
56
+ // already has and the result goes through its own paint cache) needs exactly
57
+ // this and nothing else: the alternative on the public surface,
58
+ // `picture().setBlurFilter()`, sets a k×k filter that the server re-runs on
59
+ // *every* composite, which is invisible until someone profiles a real
60
+ // display. The surfaces and the cache below stay private —
47
61
  // they are the 2d context's bookkeeping, not a primitive.
48
62
  import { Surface } from './surface.js';
49
63
 
@@ -61,11 +75,26 @@ import { Surface } from './surface.js';
61
75
  * - `maxPixels` — the largest coverage surface built for one shadow. Beyond
62
76
  * it the shadow is dropped rather than turning one drawing into a
63
77
  * multi-megabyte allocation; the drawing itself is unaffected.
78
+ * - `scaleSigma` — the σ a reduced-scale blur is not allowed to fall below.
79
+ * A gaussian carries no detail finer than about σ/2 px, so a wide one does
80
+ * not need full resolution to resolve it: past twice this, coverage is
81
+ * shrunk by the largest power of two that keeps σ/scale at or above it,
82
+ * blurred there, and resolved back — `scale` off the kernel and `scale²`
83
+ * off the area, which is where a first paint's time goes (issue #338).
84
+ * What the shrink costs is set by that reduced σ and not by the ratio: 4
85
+ * holds the difference from an exact blur inside three levels of 8-bit
86
+ * alpha, where 3 is worth four levels and 2 is worth seven.
87
+ * - `maxScale` — how far the shrink may go whatever the floor allows, so a
88
+ * `maxSigma`-wide blur cannot resample its way down to a thumbnail.
89
+ * `maxScale: 1` blurs everything at full resolution, which is what 8.6
90
+ * did, and is the setting for a caller that needs the exact kernel.
64
91
  */
65
92
  export const DEFAULT_SHADOW_POLICY = {
66
93
  cacheBytes: 4 << 20,
67
94
  maxSigma: 32,
68
- maxPixels: 8 << 20
95
+ maxPixels: 8 << 20,
96
+ scaleSigma: 4,
97
+ maxScale: 4
69
98
  };
70
99
 
71
100
  /** the policy for one app, merged over the defaults */
@@ -123,6 +152,130 @@ export function gaussianKernel1d(sigma, reach = shadowReach(sigma)) {
123
152
  return values;
124
153
  }
125
154
 
155
+ /**
156
+ * The scale a blur of this sigma is run at: 1, 2 or 4 by default, meaning
157
+ * "shrink the coverage by this much, blur at `sigma / scale`, resolve back".
158
+ *
159
+ * A gaussian is a low-pass filter — it carries nothing finer than about σ/2
160
+ * px — so resolving a wide one at full resolution spends most of its time on
161
+ * detail the result cannot hold. Shrinking first takes `scale` off the
162
+ * kernel and `scale²` off the area it runs over: at σ 21 over 552×396, k = 4
163
+ * turns 55.5M multiply-accumulates into 0.9M, which on a software-RENDER
164
+ * server (XQuartz) is most of a first paint (issue #338).
165
+ *
166
+ * The scale is a power of two so that each shrink is an exact 2×2 average,
167
+ * and it is capped both by `maxScale` and by the σ floor `scaleSigma`, which
168
+ * is what keeps the reduced blur wide enough to still be a gaussian — the
169
+ * difference from an exact blur is set by that reduced σ, and stays inside
170
+ * three levels of 8-bit alpha at the 4 the policy defaults to. σ under
171
+ * `2 * scaleSigma` gets 1: below that the two resampling composites cost
172
+ * more than the kernel they save.
173
+ *
174
+ * Exported for a caller that draws its own shapes and can therefore skip the
175
+ * shrink entirely: draw the shape into a surface `1 / scale` the size (its
176
+ * padding scaled with it), blur at `sigma / scale`, and composite through a
177
+ * `1 / scale` picture transform. See docs/surface.md#baking-a-blur.
178
+ */
179
+ export function blurScale(sigma, policy = DEFAULT_SHADOW_POLICY) {
180
+ const floor = policy.scaleSigma ?? DEFAULT_SHADOW_POLICY.scaleSigma;
181
+ const max = policy.maxScale ?? DEFAULT_SHADOW_POLICY.maxScale;
182
+ if (!(sigma > 0) || !(floor > 0) || !(max > 1)) return 1;
183
+ let scale = 1;
184
+ while (scale * 2 <= max && sigma / (scale * 2) >= floor) scale *= 2;
185
+ return scale;
186
+ }
187
+
188
+ /**
189
+ * The smallest a shrunk coverage surface is allowed to get on either side.
190
+ *
191
+ * Nothing a shadow builds comes near it — a blur wide enough to be scaled at
192
+ * all pads by 24px a side — but `blurCoverage` takes any a8 surface, and a
193
+ * hand-built sliver shrunk to a couple of pixels would come back as a smear
194
+ * rather than a blur.
195
+ */
196
+ const MIN_SCALED_SIDE = 16;
197
+
198
+ /** the scale `blurCoverage` will actually use: the policy's (or the
199
+ * caller's), snapped down to a power of two and to what the surface can be
200
+ * shrunk to without losing its shape */
201
+ function resolveScale(shape, sigma, requested) {
202
+ const wanted =
203
+ requested === undefined ? blurScale(sigma, shadowPolicyOf(shape.app)) : requested;
204
+ let scale = 2 ** Math.floor(Math.log2(wanted));
205
+ const side = Math.min(shape.width, shape.height);
206
+ while (scale > 1 && side / scale < MIN_SCALED_SIDE) scale /= 2;
207
+ return scale;
208
+ }
209
+
210
+ /**
211
+ * Shrink coverage by exactly 2, server-side, destroying the input.
212
+ *
213
+ * The transform maps a destination pixel centre to `2 * (i + 0.5)` in the
214
+ * source, which lands the bilinear sample exactly between two texels in each
215
+ * axis: the 2×2 box average, with no weight of the original left out. Which
216
+ * is why the scale is a power of two — one bilinear tap of a 4× shrink would
217
+ * sample every fourth texel and simply not see the pixels in between.
218
+ */
219
+ function halveCoverage(surface, R) {
220
+ const width = Math.max(1, Math.ceil(surface.width / 2));
221
+ const height = Math.max(1, Math.ceil(surface.height / 2));
222
+ const small = new Surface(surface.app, { width, height, format: 'a8' });
223
+ const picture = surface.picture();
224
+ picture.setFilter('bilinear');
225
+ R.SetPictureTransform(picture.id, [2, 0, 0, 0, 2, 0, 0, 0, 1]);
226
+ R.Composite(R.PictOp.Src, picture.id, 0, small.picture().id, 0, 0, 0, 0, 0, 0, width, height);
227
+ surface.destroy();
228
+ return small;
229
+ }
230
+
231
+ /**
232
+ * Resolve shrunk coverage back to `width` × `height`, destroying the input.
233
+ *
234
+ * Bilinear again, and this is where the error of the whole scheme lives: the
235
+ * blurred coverage is reconstructed linearly between samples `scale` px
236
+ * apart, and what a straight line misses between two samples of a gaussian
237
+ * goes as the curvature there — `1 / σ'²`, in the reduced surface's own
238
+ * sigma. So the bound is a property of the σ the blur ran at, which is what
239
+ * `scaleSigma` pins: three alpha levels at σ' 4, seven at σ' 2 (measured
240
+ * against Xorg; a server whose bilinear rounds less is a level better).
241
+ *
242
+ * It happens once, into an ordinary surface: what comes back carries no
243
+ * filter and no transform, so every composite of it afterwards is a plain
244
+ * mask, exactly as it was before the shrink existed.
245
+ */
246
+ function expandCoverage(small, width, height, scale, R) {
247
+ const out = new Surface(small.app, { width, height, format: 'a8' });
248
+ const picture = small.picture();
249
+ picture.setFilter('bilinear');
250
+ R.SetPictureTransform(picture.id, [1 / scale, 0, 0, 0, 1 / scale, 0, 0, 0, 1]);
251
+ R.Composite(R.PictOp.Src, picture.id, 0, out.picture().id, 0, 0, 0, 0, 0, 0, width, height);
252
+ small.destroy();
253
+ return out;
254
+ }
255
+
256
+ /** the two separable passes themselves, at whatever scale they are run —
257
+ * `shape` in, blurred copy out, input destroyed */
258
+ function bakeBlur(shape, sigma, R) {
259
+ const { app, width, height } = shape;
260
+ const kernel = gaussianKernel1d(sigma);
261
+ const scratch = new Surface(app, { width, height, format: 'a8' });
262
+ const out = new Surface(app, { width, height, format: 'a8' });
263
+
264
+ const pass = (src, dst, params) => {
265
+ src.picture().setFilter('convolution', params);
266
+ // Src, not Over: each pass replaces the destination, which was cleared
267
+ // to transparent on creation. Over would accumulate the sharp copy's
268
+ // coverage under the blurred one and give the shadow a hard core.
269
+ R.Composite(R.PictOp.Src, src.picture().id, 0, dst.picture().id, 0, 0, 0, 0, 0, 0, width, height);
270
+ };
271
+ pass(shape, scratch, [kernel.length, 1, ...kernel]);
272
+ pass(scratch, out, [1, kernel.length, ...kernel]);
273
+
274
+ shape.destroy();
275
+ scratch.destroy();
276
+ return out;
277
+ }
278
+
126
279
  /**
127
280
  * Blur an a8 coverage surface, returning a new one holding the result.
128
281
  *
@@ -138,8 +291,16 @@ export function gaussianKernel1d(sigma, reach = shadowReach(sigma)) {
138
291
  * bakes instead. `shape` must be a coverage surface with the blur's reach as
139
292
  * padding on all four sides, or the result ends in a straight line where the
140
293
  * kernel ran out of pixels; see docs/surface.md.
294
+ *
295
+ * A wide blur does not run at full resolution. Past σ 8 the coverage is
296
+ * shrunk by a power of two, blurred at `sigma / scale` and resolved back —
297
+ * `scale³` off the work, for a difference of at most three levels of 8-bit
298
+ * alpha (issue #338). `scale` overrides that: `{ scale: 1 }` is the exact
299
+ * path 8.6 took, and a larger one is snapped down to a power of two. Which
300
+ * scale the policy picks is `blurScale(sigma)`; the size of what comes back
301
+ * never changes with it.
141
302
  */
142
- export function blurCoverage(shape, sigma) {
303
+ export function blurCoverage(shape, sigma, { scale } = {}) {
143
304
  if (!(sigma > 0) || !Number.isFinite(sigma)) {
144
305
  throw new Error(
145
306
  `blurCoverage: sigma must be a finite number above 0, got ${sigma}. ` +
@@ -155,26 +316,23 @@ export function blurCoverage(shape, sigma) {
155
316
  'the blur runs on alpha, so an argb32 surface would lose its colour.'
156
317
  );
157
318
  }
158
- const app = shape.app;
159
- const R = app.display.Render;
319
+ if (scale !== undefined && (!Number.isFinite(scale) || scale < 1)) {
320
+ throw new Error(
321
+ `blurCoverage: scale must be a finite number of 1 or more, got ${scale}. ` +
322
+ 'It is how much the coverage is shrunk before the blur runs on it, ' +
323
+ 'so 1 is "blur at full resolution"; anything else is snapped down to ' +
324
+ 'a power of two. Leave it out to take the scale from the app\'s ' +
325
+ 'shadowPolicy (scaleSigma / maxScale).'
326
+ );
327
+ }
328
+ const R = shape.app.display.Render;
160
329
  const { width, height } = shape;
161
- const kernel = gaussianKernel1d(sigma);
162
- const scratch = new Surface(app, { width, height, format: 'a8' });
163
- const out = new Surface(app, { width, height, format: 'a8' });
164
-
165
- const pass = (src, dst, params) => {
166
- src.picture().setFilter('convolution', params);
167
- // Src, not Over: each pass replaces the destination, which was cleared
168
- // to transparent on creation. Over would accumulate the sharp copy's
169
- // coverage under the blurred one and give the shadow a hard core.
170
- R.Composite(R.PictOp.Src, src.picture().id, 0, dst.picture().id, 0, 0, 0, 0, 0, 0, width, height);
171
- };
172
- pass(shape, scratch, [kernel.length, 1, ...kernel]);
173
- pass(scratch, out, [1, kernel.length, ...kernel]);
330
+ const k = resolveScale(shape, sigma, scale);
331
+ if (k === 1) return bakeBlur(shape, sigma, R);
174
332
 
175
- shape.destroy();
176
- scratch.destroy();
177
- return out;
333
+ let small = shape;
334
+ for (let step = k; step > 1; step /= 2) small = halveCoverage(small, R);
335
+ return expandCoverage(bakeBlur(small, sigma / k, R), width, height, k, R);
178
336
  }
179
337
 
180
338
  /**
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.6.0",
3
+ "version": "8.8.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",