ntk 6.0.0 → 6.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@ import {
5
5
  detectStyle,
6
6
  numericWeight as numWeight
7
7
  } from './fontsource.js';
8
- import { shapeText } from './shape.js';
8
+ import { embeddingLevels, normalizedLevels, shapeText } from './shape.js';
9
9
  import { TextLayout } from './layout.js';
10
10
 
11
11
  /**
@@ -38,7 +38,7 @@ export default class FontManager {
38
38
  this._matches = new Map(); // family|weight|style -> Font
39
39
  this._fallbacks = new Map(); // family|weight|style -> Map(codepoint -> Font|null)
40
40
  this._registered = []; // { font, family (lowercase), weight, italic }
41
- this._shapeCache = new Map(); // word-level shaping memo (bounded)
41
+ this._shapeCache = new Map(); // word-level shaping memo (bounded, LRU)
42
42
  }
43
43
 
44
44
  /** the FontSource in effect (explicit, else the process-wide default) */
@@ -232,25 +232,63 @@ export default class FontManager {
232
232
  }
233
233
 
234
234
  /**
235
- * Memoized shaping used by TextLayout (bounded cache). `levelsKey` is a
236
- * compact embedding-levels encoding: a single number when uniform for the
237
- * whole fragment (the common case), else comma-separated per-char levels.
235
+ * Memoized shaping used by TextLayout and the canvas text path (bounded
236
+ * LRU). `levelsKey` is a compact embedding-levels encoding: a single
237
+ * number when uniform for the whole fragment (the common case), else
238
+ * comma-separated per-char levels.
238
239
  */
239
240
  _shapeCached(text, style, levelsKey = '0') {
240
241
  const font = style.font;
241
242
  const key = `${font ? font.key : style.family}|${style.size}|${style.weight}|${style.style}|${levelsKey}|${text}`;
242
243
  let shaped = this._shapeCache.get(key);
243
- if (!shaped) {
244
- let levels;
245
- if (levelsKey.includes(',')) {
246
- levels = levelsKey.split(',').map(Number);
247
- } else {
248
- levels = new Uint8Array(text.length).fill(Number(levelsKey));
249
- }
250
- shaped = shapeText(this, text, style, levels);
251
- if (this._shapeCache.size > 4000) this._shapeCache.clear();
244
+ if (shaped) {
245
+ // Map iterates in insertion order: re-inserting a hit moves it to the
246
+ // tail, so the eviction sweep below walks least-recently-used first
247
+ this._shapeCache.delete(key);
252
248
  this._shapeCache.set(key, shaped);
249
+ return shaped;
250
+ }
251
+ let levels;
252
+ if (levelsKey.includes(',')) {
253
+ levels = levelsKey.split(',').map(Number);
254
+ } else {
255
+ levels = new Uint8Array(text.length).fill(Number(levelsKey));
253
256
  }
257
+ shaped = shapeText(this, text, style, levels);
258
+ if (this._shapeCache.size > 4000) {
259
+ // drop the stale half in one sweep rather than one entry per insert —
260
+ // a live UI's working set sits at the recent end and survives, where
261
+ // the old wholesale clear() re-shaped everything on screen
262
+ let drop = this._shapeCache.size >> 1;
263
+ for (const k of this._shapeCache.keys()) {
264
+ if (drop-- <= 0) break;
265
+ this._shapeCache.delete(k);
266
+ }
267
+ }
268
+ this._shapeCache.set(key, shaped);
254
269
  return shaped;
255
270
  }
271
+
272
+ /**
273
+ * Memoized whole-string shaping — what `ctx.fillText` draws through, so
274
+ * repainting the same label hits the same entries TextLayout populates
275
+ * instead of re-running bidi + OpenType shaping every frame.
276
+ *
277
+ * Bidi is resolved here because the memo key needs the embedding levels;
278
+ * the paragraph level rides back on the result separately, since per-char
279
+ * levels cannot recover it (RTL text in an RTL paragraph carries level 1,
280
+ * which reads as an even base) and start/end alignment depends on it.
281
+ *
282
+ * `features` and `language` change shaped output but are not part of the
283
+ * memo key (styles reaching it via TextLayout never carry them into the
284
+ * key either), so a style with either shapes uncached rather than
285
+ * poisoning entries other callers share.
286
+ */
287
+ _shapeCachedWhole(text, style) {
288
+ if (style.features || style.language) return shapeText(this, text, style);
289
+ const emb = embeddingLevels(text, style.direction);
290
+ const baseLevel = emb.paragraphs.length ? emb.paragraphs[0].level : 0;
291
+ const shaped = this._shapeCached(text, style, normalizedLevels(emb.levels, 0, text.length));
292
+ return shaped.baseLevel === baseLevel ? shaped : { ...shaped, baseLevel };
293
+ }
256
294
  }
@@ -34,7 +34,7 @@
34
34
  // than imported, because this file is the one the browser bundle keeps — its
35
35
  // whole purpose is running the text stack without a filesystem. Do not turn
36
36
  // that into a static import (test/packaging.test.js fails if you do).
37
- import { charsetHas, matchSortedSync, noFontsError, supported } from '../fontconfig.js';
37
+ import { charsetHas, matchSortedSync, noFontsError, prewarm, supported } from '../fontconfig.js';
38
38
  import Font from './font.js';
39
39
 
40
40
  const WEIGHTS = { normal: 400, bold: 700 };
@@ -95,6 +95,17 @@ export function parseFamilies(family) {
95
95
  * self-contained alternative.
96
96
  */
97
97
  export class FontconfigFontSource {
98
+ constructor() {
99
+ // Construction is the moment fontconfig is chosen as the lookup path, so
100
+ // the fc-match spawn for the pattern every widget default resolves to —
101
+ // sans-serif at regular weight, exactly as FontManager.match normalizes
102
+ // it — starts here, asynchronously, instead of stalling the first text
103
+ // layout for ~50ms. Fire-and-forget: prewarm never rejects, and a source
104
+ // constructed where fc-match is missing stays silent until (unless) text
105
+ // is actually rendered.
106
+ prewarm({ family: 'sans-serif', weight: 400, style: 'normal' });
107
+ }
108
+
98
109
  matchSorted(pattern) {
99
110
  return matchSortedSync(pattern);
100
111
  }
@@ -1,7 +1,7 @@
1
1
  import LineBreaker from 'linebreak';
2
2
 
3
3
  import { drawGlyphRuns } from './glyphs.js';
4
- import { embeddingLevels, reorderRuns } from './shape.js';
4
+ import { embeddingLevels, normalizedLevels, reorderRuns } from './shape.js';
5
5
 
6
6
  const WS = new Set([0x20, 0x09, 0xa0]);
7
7
  const HARD_BREAKS = /[\n\r\u2028\u2029]+$/;
@@ -856,16 +856,3 @@ function sliceLevels(key, start, end) {
856
856
  if (!key.includes(',')) return key;
857
857
  return key.split(',').slice(start, end).join(',');
858
858
  }
859
-
860
- // compact levels key for the shaping cache: single char when uniform
861
- function normalizedLevels(levels, start, end) {
862
- let uniform = true;
863
- for (let i = start + 1; i < end; i++) {
864
- if (levels[i] !== levels[start]) {
865
- uniform = false;
866
- break;
867
- }
868
- }
869
- if (uniform) return String(levels[start] ?? 0);
870
- return Array.prototype.slice.call(levels, start, end).join(',');
871
- }
package/lib/text/shape.js CHANGED
@@ -28,6 +28,23 @@ export function embeddingLevels(text, direction = 'auto') {
28
28
  return bidi.getEmbeddingLevels(text, explicit);
29
29
  }
30
30
 
31
+ /**
32
+ * Compact embedding-levels encoding for the shaping memo key: a single
33
+ * number when uniform over [start, end) — the common case — else
34
+ * comma-separated per-char levels.
35
+ */
36
+ export function normalizedLevels(levels, start, end) {
37
+ let uniform = true;
38
+ for (let i = start + 1; i < end; i++) {
39
+ if (levels[i] !== levels[start]) {
40
+ uniform = false;
41
+ break;
42
+ }
43
+ }
44
+ if (uniform) return String(levels[start] ?? 0);
45
+ return Array.prototype.slice.call(levels, start, end).join(',');
46
+ }
47
+
31
48
  /**
32
49
  * Shape a string through the full pipeline:
33
50
  *
package/lib/window.js CHANGED
@@ -27,6 +27,19 @@ const MAX_DIRTY_RECTS = 8;
27
27
  */
28
28
  const SPLIT_SAVING = 0.75;
29
29
 
30
+ /**
31
+ * Backing pixmap dimensions are rounded up to this granularity.
32
+ *
33
+ * An interactive enlarge delivers a resize per frame, and an exact grow-only
34
+ * allocation crosses its previous maximum on every one of them — each frame
35
+ * paying for a CreatePixmap, a full white clear, a FreePixmap and a picture
36
+ * rebind. With headroom the reuse check absorbs the intermediate steps and a
37
+ * continuous drag reallocates O(log) times. The spare pixels are never
38
+ * presented — every blit is clamped to window ∩ backing — they just sit
39
+ * white until the window grows into them.
40
+ */
41
+ const BACKING_GRANULARITY = 128;
42
+
30
43
  function rectArea(r) {
31
44
  return Math.max(0, r.w) * Math.max(0, r.h);
32
45
  }
@@ -639,12 +652,14 @@ export default class Window extends Drawable {
639
652
  });
640
653
  }
641
654
 
642
- // grow-only backing pixmap (re)allocation; new area is cleared to white
655
+ // grow-only backing pixmap (re)allocation, with headroom (see
656
+ // BACKING_GRANULARITY); new area is cleared to white
643
657
  _allocBacking(w, h) {
644
658
  const cur = this._backing;
645
659
  if (cur && cur.width >= w && cur.height >= h) return;
646
- const newW = Math.max(w, cur ? cur.width : 0);
647
- const newH = Math.max(h, cur ? cur.height : 0);
660
+ const roundUp = (v) => Math.ceil(v / BACKING_GRANULARITY) * BACKING_GRANULARITY;
661
+ const newW = roundUp(Math.max(w, cur ? cur.width : 0));
662
+ const newH = roundUp(Math.max(h, cur ? cur.height : 0));
648
663
  const pixmap = new Pixmap(this.app, {
649
664
  parent: this,
650
665
  width: newW,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "6.0.0",
3
+ "version": "6.0.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",