ntk 8.0.0 → 8.1.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/shadow.js ADDED
@@ -0,0 +1,199 @@
1
+ // Drop shadows for the 2d context (issue #272): the blur, the surfaces it
2
+ // runs on, and the cache that keeps a redrawn shadow from being rebuilt.
3
+ //
4
+ // The drawing itself lives in renderingcontext_2d.js — everything here is
5
+ // the part with no X connection in it (the kernel maths) or the part every
6
+ // shadowed operation shares (the coverage surfaces and their budget).
7
+ //
8
+ // ## How a shadow is drawn
9
+ //
10
+ // A shadow is the drawing's *coverage*, blurred, offset, and painted in one
11
+ // colour. Coverage is what an `a8` Surface holds and what XRender composites
12
+ // a solid through, so the whole thing is three server-side steps:
13
+ //
14
+ // 1. draw the shape into a padded a8 surface — white on transparent, so
15
+ // every pixel is its own alpha
16
+ // 2. blur it: two convolution passes, horizontal then vertical
17
+ // 3. composite the result as a mask, with `shadowColor` as the source
18
+ //
19
+ // The padding in (1) is not optional. A convolution samples outside the
20
+ // picture, where RepeatNone reads transparent, so a shape drawn flush to the
21
+ // surface edge ends in a straight line where the kernel ran out of pixels.
22
+ // `reach` is how far coverage can spread, and every surface carries it on
23
+ // all four sides.
24
+ //
25
+ // ## Why two passes
26
+ //
27
+ // A gaussian is separable, and that is the difference between a shadow you
28
+ // can animate and one you cannot: a k-wide 2d kernel costs k² multiplies per
29
+ // pixel where two 1d passes cost 2k. At `shadowBlur: 30` that is 8281 against
30
+ // 182. RENDER has no separable-convolution filter, but it does not need one —
31
+ // two `convolution` filters and two composites are the same thing, and the
32
+ // second pass leaves a surface with the blur already in its pixels, so the
33
+ // cached copy composites as a plain mask rather than re-running a kernel on
34
+ // every frame.
35
+ import { Surface } from './surface.js';
36
+
37
+ /**
38
+ * Shadow policy — the cost ceilings, per app via `app.shadowPolicy`
39
+ * (partial objects are merged over the defaults). See docs/context-2d.md.
40
+ *
41
+ * - `cacheBytes` — LRU budget for retained shadow coverage per connection.
42
+ * Keyed by (text, font, blur) for text; least-recently-drawn surfaces are
43
+ * destroyed server-side once the total goes over.
44
+ * - `maxSigma` — the widest gaussian actually run. The kernel is 6σ+1 wide,
45
+ * the request carries every tap, and the server multiplies each of them
46
+ * per pixel per pass, so an unbounded `shadowBlur` is an unbounded
47
+ * request and an unbounded stall. Past this the blur stops widening.
48
+ * - `maxPixels` — the largest coverage surface built for one shadow. Beyond
49
+ * it the shadow is dropped rather than turning one drawing into a
50
+ * multi-megabyte allocation; the drawing itself is unaffected.
51
+ */
52
+ export const DEFAULT_SHADOW_POLICY = {
53
+ cacheBytes: 4 << 20,
54
+ maxSigma: 32,
55
+ maxPixels: 8 << 20
56
+ };
57
+
58
+ /** the policy for one app, merged over the defaults */
59
+ export function shadowPolicyOf(app) {
60
+ return app?.shadowPolicy
61
+ ? { ...DEFAULT_SHADOW_POLICY, ...app.shadowPolicy }
62
+ : DEFAULT_SHADOW_POLICY;
63
+ }
64
+
65
+ /**
66
+ * The gaussian a `shadowBlur` asks for.
67
+ *
68
+ * `shadowBlur` is a **diameter**, not a radius: the canvas spec says a
69
+ * shadow is blurred by a gaussian whose standard deviation is half of it.
70
+ * Getting this wrong is invisible until someone compares against a browser,
71
+ * so it is one line with a test on it — `shadowBlur: 8` must be σ = 4 here
72
+ * exactly as it is in Chrome and Firefox.
73
+ *
74
+ * Clamped to the policy's `maxSigma`, which is the only place a shadow
75
+ * silently stops matching a browser; the cap is chosen so that no shadow a
76
+ * UI actually draws reaches it.
77
+ */
78
+ export function shadowSigma(blur, policy = DEFAULT_SHADOW_POLICY) {
79
+ const sigma = blur / 2;
80
+ return sigma > policy.maxSigma ? policy.maxSigma : sigma;
81
+ }
82
+
83
+ /**
84
+ * How far the blur spreads coverage, in pixels — the kernel's half-width,
85
+ * and therefore the padding every coverage surface needs on each side.
86
+ *
87
+ * Truncating a gaussian at 3σ leaves 0.3% of its weight outside, which is
88
+ * below one step of the 8-bit coverage it is convolving.
89
+ */
90
+ export function shadowReach(sigma) {
91
+ return sigma > 0 ? Math.ceil(sigma * 3) : 0;
92
+ }
93
+
94
+ /**
95
+ * A normalized 1d gaussian, `2 * reach + 1` taps wide.
96
+ *
97
+ * Normalizing the *truncated* kernel rather than the ideal one is what keeps
98
+ * a flat interior at full coverage: a shadow under an opaque box must stay
99
+ * opaque in the middle, and a kernel summing to 0.997 would leave it at 254.
100
+ */
101
+ export function gaussianKernel1d(sigma, reach = shadowReach(sigma)) {
102
+ const values = new Array(reach * 2 + 1);
103
+ let sum = 0;
104
+ for (let i = -reach; i <= reach; i++) {
105
+ const v = Math.exp(-(i * i) / (2 * sigma * sigma));
106
+ values[i + reach] = v;
107
+ sum += v;
108
+ }
109
+ for (let i = 0; i < values.length; i++) values[i] /= sum;
110
+ return values;
111
+ }
112
+
113
+ /**
114
+ * Blur an a8 coverage surface, returning a new one holding the result.
115
+ *
116
+ * The input is destroyed: a caller has no use for the sharp copy afterwards,
117
+ * and leaving it alive would double what the cache is holding. The output
118
+ * carries no filter of its own, so compositing it is an ordinary masked
119
+ * composite no matter how wide the blur was.
120
+ */
121
+ export function blurCoverage(shape, sigma) {
122
+ const app = shape.app;
123
+ const R = app.display.Render;
124
+ const { width, height } = shape;
125
+ const kernel = gaussianKernel1d(sigma);
126
+ const scratch = new Surface(app, { width, height, format: 'a8' });
127
+ const out = new Surface(app, { width, height, format: 'a8' });
128
+
129
+ const pass = (src, dst, params) => {
130
+ src.picture().setFilter('convolution', params);
131
+ // Src, not Over: each pass replaces the destination, which was cleared
132
+ // to transparent on creation. Over would accumulate the sharp copy's
133
+ // coverage under the blurred one and give the shadow a hard core.
134
+ R.Composite(R.PictOp.Src, src.picture().id, 0, dst.picture().id, 0, 0, 0, 0, 0, 0, width, height);
135
+ };
136
+ pass(shape, scratch, [kernel.length, 1, ...kernel]);
137
+ pass(scratch, out, [1, kernel.length, ...kernel]);
138
+
139
+ shape.destroy();
140
+ scratch.destroy();
141
+ return out;
142
+ }
143
+
144
+ /**
145
+ * A shadow's coverage, from the cache when it has been seen before.
146
+ *
147
+ * `build()` returns the finished (blurred) surface, or null when there is
148
+ * nothing to draw. A null `key` means "not cacheable" — the geometry has no
149
+ * short name, as a path's does not — and the caller owns the surface it gets
150
+ * back. With a key, the cache owns it and the caller must not destroy it.
151
+ *
152
+ * Cached on the app rather than the context because contexts are short-lived
153
+ * (`Surface.render` builds one per call) while a shadow is a property of the
154
+ * drawing, which outlives them — the same reason `solidPicture` and the
155
+ * glyph pages live there.
156
+ */
157
+ export function cachedShadow(app, key, build) {
158
+ if (key === null) return build();
159
+ const cache = (app._shadowSurfaces ??= new Map());
160
+ const hit = cache.get(key);
161
+ if (hit) {
162
+ // Map iteration order is insertion order — re-insert to mark recent
163
+ cache.delete(key);
164
+ cache.set(key, hit);
165
+ return hit;
166
+ }
167
+ const surface = build();
168
+ if (!surface) return null;
169
+ cache.set(key, surface);
170
+ trimShadowSurfaces(app, shadowPolicyOf(app), surface);
171
+ return surface;
172
+ }
173
+
174
+ /**
175
+ * Evict least-recently-drawn shadow coverage until the cache fits its
176
+ * budget. `keep` is never evicted: it is the surface the caller is about to
177
+ * composite, and a budget smaller than one shadow must not free it mid-draw.
178
+ */
179
+ export function trimShadowSurfaces(app, policy = shadowPolicyOf(app), keep = null) {
180
+ const cache = app._shadowSurfaces;
181
+ if (!cache) return;
182
+ let total = 0;
183
+ for (const surface of cache.values()) total += surface.bytes;
184
+ for (const [key, surface] of cache) {
185
+ if (total <= policy.cacheBytes) break;
186
+ if (surface === keep) continue;
187
+ cache.delete(key);
188
+ total -= surface.bytes;
189
+ surface.destroy();
190
+ }
191
+ }
192
+
193
+ /** free every cached shadow surface — connection teardown */
194
+ export function dropShadowSurfaces(app) {
195
+ const cache = app._shadowSurfaces;
196
+ if (!cache) return;
197
+ for (const surface of cache.values()) surface.destroy();
198
+ cache.clear();
199
+ }
@@ -9,11 +9,22 @@
9
9
  // Full match list for a pattern, best first — the fallback chain.
10
10
  // `family` may be a CSS-style comma-separated list. Must return at
11
11
  // least one candidate or throw. A candidate is openable:
12
- // { key?, path?, data?, font?, postscriptName? }
12
+ // { key?, path?, data?, font?, postscriptName?, family?, families? }
13
13
  // - `font`: an already-open Font instance (preferred when available)
14
14
  // - `data`: font file bytes (Uint8Array/Buffer) for fontkit
15
15
  // - `path`: font file path (node only)
16
16
  // - `key`: stable cache key (defaults to `${path}#${postscriptName}`)
17
+ // - `family`: the face's family name, for showing a match list without
18
+ // opening every file in it; `families` its aliases, first one first
19
+ // (both optional, but both sources here fill them in)
20
+ //
21
+ // matchSortedAsync({ family, weight, style }) -> Promise<candidate[]>
22
+ // The same answer, awaited. Text layout is synchronous and always calls
23
+ // matchSorted; this is for an app asking a source directly — a font
24
+ // picker matching as the user types — where the fontconfig source's
25
+ // ~100ms spawn has no business blocking the event loop. A source with
26
+ // nothing to await implements it by resolving immediately, so a caller
27
+ // can be written once against either.
17
28
  //
18
29
  // covers(candidate, codepoint) -> boolean [optional]
19
30
  // Cheap coverage pre-filter used during per-codepoint fallback, ideally
@@ -35,7 +46,14 @@
35
46
  // — its whole purpose is running the text stack without a filesystem. See
36
47
  // builtin.js for how the lazy lookup stays version-tolerant.
37
48
  import { builtin } from '../builtin.js';
38
- import { charsetHas, matchSortedSync, noFontsError, prewarm, supported } from '../fontconfig.js';
49
+ import {
50
+ charsetHas,
51
+ matchSorted,
52
+ matchSortedSync,
53
+ noFontsError,
54
+ prewarm,
55
+ supported
56
+ } from '../fontconfig.js';
39
57
  import Font from './font.js';
40
58
 
41
59
  const WEIGHTS = { normal: 400, bold: 700 };
@@ -111,6 +129,16 @@ export class FontconfigFontSource {
111
129
  return matchSortedSync(pattern);
112
130
  }
113
131
 
132
+ /**
133
+ * Non-blocking sibling: the fc-match spawn runs off the event loop and
134
+ * seeds the same cache, so a later synchronous layout for the pattern is
135
+ * a cache hit. Rejects with the same ERR_NTK_NO_FONTS the sync path
136
+ * throws — the failure case must not be the one that blocks.
137
+ */
138
+ matchSortedAsync(pattern) {
139
+ return matchSorted(pattern);
140
+ }
141
+
114
142
  covers(candidate, codepoint) {
115
143
  return charsetHas(candidate, codepoint);
116
144
  }
@@ -237,12 +265,28 @@ export class StaticFontSource {
237
265
  scored.sort((a, b) => a.score - b.score);
238
266
  return scored.map(({ face }) => {
239
267
  if (!face.candidate) {
240
- face.candidate = { key: face.font.key, font: face.font };
268
+ // the font's own family name, not the (lowercased, possibly aliased)
269
+ // one it is matched by — this field is the one to show a reader
270
+ const name = face.font.familyName || '';
271
+ face.candidate = {
272
+ key: face.font.key,
273
+ font: face.font,
274
+ family: name,
275
+ families: name ? [name] : []
276
+ };
241
277
  }
242
278
  return face.candidate;
243
279
  });
244
280
  }
245
281
 
282
+ /**
283
+ * Nothing here is off-process, so matching is already instant — this
284
+ * exists so an app can await either source without asking which it has.
285
+ */
286
+ async matchSortedAsync(pattern = {}) {
287
+ return this.matchSorted(pattern);
288
+ }
289
+
246
290
  covers(candidate, codepoint) {
247
291
  return candidate.font.hasGlyph(codepoint);
248
292
  }
@@ -468,9 +468,21 @@ export class TextLayout {
468
468
  }
469
469
 
470
470
  /**
471
- * Draw onto a 2d context at (x, y) = top-left of the layout box.
471
+ * Draw onto a 2d context at (x, y) = top-left of the layout box, in the
472
+ * context's **user space**: the current transform applies to the origin,
473
+ * the same way it applies to `fillText`, `fillRect` and `drawImage`, so a
474
+ * paragraph drawn into a translated context lands where the rest of the
475
+ * drawing does (issue #280). The glyphs themselves are not rotated or
476
+ * scaled by it — set the span size instead.
477
+ *
472
478
  * Span `color`s override the context fillStyle; consecutive same-color
473
479
  * runs are batched into single CompositeGlyphs requests.
480
+ *
481
+ * `caretPosition`/`indexAt` and the line/run geometry speak the same
482
+ * layout-relative coordinates as the (x, y) here, so hit testing stays
483
+ * `layout.indexAt(px - x, py - y)` — with (px, py) in user space too,
484
+ * which for a pointer event under a transformed context means undoing
485
+ * `ctx.getTransform()` first.
474
486
  */
475
487
  draw(ctx, x = 0, y = 0) {
476
488
  const app = ctx.window.app;
@@ -484,8 +496,9 @@ export class TextLayout {
484
496
  const flush = () => {
485
497
  if (batch.length === 0) return;
486
498
  const src = batchColor ? ctx._stylePicture(batchColor) : ctx._backgroundPicture;
487
- // via the context so the clip is applied (drawGlyphRuns composites
488
- // straight onto the picture and cannot see it)
499
+ // via the context so the clip and the transform are applied
500
+ // (drawGlyphRuns composites straight onto the picture and can see
501
+ // neither)
489
502
  if (typeof ctx.drawGlyphs === 'function') {
490
503
  ctx.drawGlyphs(Render.PictOp.Over, src, batch);
491
504
  } else {
@@ -22,7 +22,7 @@
22
22
  import { textContent } from 'domutils';
23
23
  import { parseDocument } from 'htmlparser2';
24
24
 
25
- import { Path2D, flattenPath, matApply } from '../path.js';
25
+ import { Path2D, flattenPath } from '../path.js';
26
26
 
27
27
  const INHERITED = {
28
28
  fill: '#000',
@@ -486,16 +486,15 @@ export default class SvgView {
486
486
  if (!bbox) return v;
487
487
  return axis === 'x' ? bbox.x + v * bbox.w : bbox.y + v * bbox.h;
488
488
  };
489
- // ntk gradients live in device space: map user-space endpoints through
490
- // the current transform
491
- const m = ctx.getTransform();
492
- const mat = [m.a, m.b, m.c, m.d, m.e, m.f];
493
- const dev = (px, py) => matApply(mat, px, py);
494
-
489
+ // gradient coordinates are user space, like the path's own — the
490
+ // context resolves them against the transform in force when it paints,
491
+ // which is this one (issue #271)
495
492
  let gradient;
496
493
  if (tag(node) === 'lineargradient') {
497
- const [x1, y1] = dev(coord(a.x1, 0, 'x'), coord(a.y1, 0, 'y'));
498
- const [x2, y2] = dev(coord(a.x2, 1, 'x'), coord(a.y2, 0, 'y'));
494
+ const x1 = coord(a.x1, 0, 'x');
495
+ const y1 = coord(a.y1, 0, 'y');
496
+ const x2 = coord(a.x2, 1, 'x');
497
+ const y2 = coord(a.y2, 0, 'y');
499
498
  gradient = ctx.createLinearGradient(x1, y1, x2, y2);
500
499
  } else {
501
500
  const cx = coord(a.cx, 0.5, 'x');
@@ -503,9 +502,7 @@ export default class SvgView {
503
502
  let r = a.r === undefined ? 0.5 : parseFloat(a.r);
504
503
  if (String(a.r ?? '').endsWith('%')) r /= 100;
505
504
  if (bbox) r *= (bbox.w + bbox.h) / 2;
506
- const [dcx, dcy] = dev(cx, cy);
507
- const scale = Math.sqrt(Math.abs(mat[0] * mat[3] - mat[1] * mat[2])) || 1;
508
- gradient = ctx.createRadialGradient(dcx, dcy, 0, dcx, dcy, r * scale);
505
+ gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
509
506
  }
510
507
 
511
508
  for (const stop of node.children || []) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.0.0",
3
+ "version": "8.1.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",