ntk 6.0.0 → 6.1.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 +68 -0
- package/lib/fontconfig.js +70 -13
- package/lib/image.js +11 -7
- package/lib/index.js +36 -18
- package/lib/renderingcontext_2d.js +937 -279
- package/lib/shm-upload.js +166 -0
- package/lib/text/fontmanager.js +52 -14
- package/lib/text/fontsource.js +12 -1
- package/lib/text/layout.js +1 -14
- package/lib/text/shape.js +17 -0
- package/lib/window.js +18 -3
- package/package.json +2 -2
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Optional MIT-SHM fast path for bulk pixel transfers.
|
|
2
|
+
//
|
|
3
|
+
// Large PutImage/GetImage traffic on a local connection is much cheaper through
|
|
4
|
+
// shared memory than down the socket — roughly 2x on uploads and many times
|
|
5
|
+
// faster on readbacks (which otherwise pay for a whole image-sized reply). This
|
|
6
|
+
// wraps node-x11's provider-backed MIT-SHM API (X.require('shm')) into a small,
|
|
7
|
+
// ordering-safe helper the 2d context and Image use, and falls back to core
|
|
8
|
+
// PutImage/GetImage wherever SHM will not attach (remote display, old server,
|
|
9
|
+
// no /dev/shm) or the transfer is too small to bother.
|
|
10
|
+
//
|
|
11
|
+
// Ordering discipline: an SHM upload is only ever issued through a segment that
|
|
12
|
+
// is *already attached* — so it slots into the request stream synchronously,
|
|
13
|
+
// exactly where a core PutImage would have gone, and never reorders drawing.
|
|
14
|
+
// Attaching a segment costs a round trip, so the first upload of a given size
|
|
15
|
+
// falls back to core and warms a segment in the background; the next one is a
|
|
16
|
+
// synchronous shared-memory write. A segment is returned to the pool only after
|
|
17
|
+
// the server signals (ShmCompletion) it has finished reading, so a reused
|
|
18
|
+
// buffer is never overwritten mid-read.
|
|
19
|
+
|
|
20
|
+
// Below this many bytes the socket is as fast as shared memory (measured: a
|
|
21
|
+
// PutImage and a tmpfs write are both syscall-bound at a few KB; the win starts
|
|
22
|
+
// around 64 KB and grows with size). Coverage masks (a8, 1 byte/pixel) stay
|
|
23
|
+
// under it at every size the rasterizer produces, which is why the 2d fill mask
|
|
24
|
+
// keeps using core PutImage.
|
|
25
|
+
const MIN_UPLOAD_BYTES = 64 * 1024;
|
|
26
|
+
// A readback pays the win sooner: a core GetImage reply carries the pixels back
|
|
27
|
+
// over the socket and, on some drivers, blocks the server for tens of
|
|
28
|
+
// milliseconds, so route more of them through shared memory.
|
|
29
|
+
const MIN_READBACK_BYTES = 16 * 1024;
|
|
30
|
+
// Round segment sizes up so a handful of buckets serve many nearby sizes and
|
|
31
|
+
// the pool actually hits.
|
|
32
|
+
const SIZE_QUANTUM = 64 * 1024;
|
|
33
|
+
|
|
34
|
+
function roundUp(n) {
|
|
35
|
+
return Math.max(SIZE_QUANTUM, Math.ceil(n / SIZE_QUANTUM) * SIZE_QUANTUM);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class ShmUploader {
|
|
39
|
+
/** @param {import('./app.js').default} app */
|
|
40
|
+
constructor(app) {
|
|
41
|
+
this.app = app;
|
|
42
|
+
this.X = app.X;
|
|
43
|
+
this.ext = null; // node-x11 shm extension once required; false if unavailable
|
|
44
|
+
this.ready = false; // usable() confirmed a segment attaches
|
|
45
|
+
this._pool = []; // free, already-read segments (not in flight)
|
|
46
|
+
this._warming = false;
|
|
47
|
+
this._resolving = null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve availability once (requires the extension and probes usable()).
|
|
52
|
+
* `cb(ready)`. Cheap and idempotent; safe to call before every draw.
|
|
53
|
+
*/
|
|
54
|
+
resolve(cb) {
|
|
55
|
+
if (this.ready || this.ext === false) return cb(this.ready);
|
|
56
|
+
if (this._resolving) {
|
|
57
|
+
this._resolving.push(cb);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
this._resolving = [cb];
|
|
61
|
+
this.X.require('shm', (err, ext) => {
|
|
62
|
+
// The provider-backed API (usable/createSegment) needs x11 >= 3.7; an
|
|
63
|
+
// older x11 still resolves the extension but without it, so treat that
|
|
64
|
+
// as "no shared memory" rather than throwing.
|
|
65
|
+
if (err || !ext || typeof ext.usable !== 'function') return this._resolved(false, false);
|
|
66
|
+
ext.usable((e, ok) => this._resolved(ok ? ext : false, !!ok));
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
_resolved(ext, ready) {
|
|
71
|
+
this.ext = ext;
|
|
72
|
+
this.ready = ready;
|
|
73
|
+
const cbs = this._resolving || [];
|
|
74
|
+
this._resolving = null;
|
|
75
|
+
for (const cb of cbs) cb(ready);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
_acquire(bytes) {
|
|
79
|
+
for (let i = 0; i < this._pool.length; ++i) {
|
|
80
|
+
if (this._pool[i].size >= bytes) return this._pool.splice(i, 1)[0];
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
_warm(bytes) {
|
|
86
|
+
if (this._warming || !this.ready) return;
|
|
87
|
+
this._warming = true;
|
|
88
|
+
this.ext.createSegment(roundUp(bytes), (err, seg) => {
|
|
89
|
+
this._warming = false;
|
|
90
|
+
if (!err && seg) this._pool.push(seg);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Upload `data` (server-layout pixels, `stride*height` bytes at offset 0) to
|
|
96
|
+
* `drawable` at `dstX,dstY` as a `depth` image `width*height`. Returns true
|
|
97
|
+
* when shared memory handled it (the caller must then issue nothing else),
|
|
98
|
+
* or false to signal "do it with core PutImage" — including every case where
|
|
99
|
+
* SHM is unavailable, too small, or momentarily out of free segments (a
|
|
100
|
+
* segment is warmed in the background for next time).
|
|
101
|
+
*/
|
|
102
|
+
putImage(drawable, gc, { width, height, depth, dstX = 0, dstY = 0, data }) {
|
|
103
|
+
const bytes = data.length;
|
|
104
|
+
if (!this.ready || bytes < MIN_UPLOAD_BYTES) return false;
|
|
105
|
+
const seg = this._acquire(bytes);
|
|
106
|
+
if (!seg) {
|
|
107
|
+
this._warm(bytes);
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
data.copy ? data.copy(seg.buffer, 0, 0, bytes) : seg.buffer.set(data.subarray(0, bytes));
|
|
111
|
+
seg.putImage(drawable, gc, { width, height, depth, dstX, dstY, sendEvent: true });
|
|
112
|
+
// reuse only once the server is done reading it
|
|
113
|
+
seg.once('complete', () => this._pool.push(seg));
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Whether a readback of `bytes` should use shared memory (usable and large
|
|
119
|
+
* enough). The caller does the async segment dance through `getImage` below.
|
|
120
|
+
*/
|
|
121
|
+
wantsReadback(bytes) {
|
|
122
|
+
return this.ready && bytes >= MIN_READBACK_BYTES;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Read `width*height` pixels of `depth` from `drawable` at `x,y` through a
|
|
127
|
+
* shared segment. `cb(err, buffer)` — `buffer` is a view of the segment's
|
|
128
|
+
* bytes valid until the next `getImage`; copy out before then. Falls back is
|
|
129
|
+
* the caller's job: only call this when `wantsReadback` was true.
|
|
130
|
+
*/
|
|
131
|
+
getImage(drawable, x, y, width, height, depth, cb) {
|
|
132
|
+
const bytes = width * height * ((depth + 7) >> 3 === 3 ? 4 : (depth + 7) >> 3);
|
|
133
|
+
const use = (seg) => {
|
|
134
|
+
seg.getImage(drawable, x, y, width, height, 0xffffffff, undefined, 0, (err, rep) => {
|
|
135
|
+
if (err) {
|
|
136
|
+
this._pool.push(seg);
|
|
137
|
+
return cb(err);
|
|
138
|
+
}
|
|
139
|
+
// hand back the buffer, then reclaim the segment after the caller has
|
|
140
|
+
// synchronously copied out (they must, per the doc above)
|
|
141
|
+
cb(null, seg.buffer, rep);
|
|
142
|
+
this._pool.push(seg);
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
const seg = this._acquire(bytes);
|
|
146
|
+
if (seg) return use(seg);
|
|
147
|
+
this.ext.createSegment(roundUp(bytes), (err, s) => {
|
|
148
|
+
if (err || !s) return cb(err || new Error('shm: no segment'));
|
|
149
|
+
use(s);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Detach every pooled segment (called from App#close). */
|
|
154
|
+
dispose() {
|
|
155
|
+
const pool = this._pool;
|
|
156
|
+
this._pool = [];
|
|
157
|
+
this.ready = false;
|
|
158
|
+
for (const seg of pool) {
|
|
159
|
+
try {
|
|
160
|
+
seg.detach();
|
|
161
|
+
} catch {
|
|
162
|
+
/* connection may already be gone */
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
package/lib/text/fontmanager.js
CHANGED
|
@@ -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
|
|
236
|
-
* compact embedding-levels encoding: a single
|
|
237
|
-
* whole fragment (the common case), else
|
|
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 (
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
}
|
package/lib/text/fontsource.js
CHANGED
|
@@ -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
|
}
|
package/lib/text/layout.js
CHANGED
|
@@ -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
|
|
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
|
|
647
|
-
const
|
|
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.
|
|
3
|
+
"version": "6.1.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",
|
|
@@ -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.
|
|
51
|
+
"x11": "^3.7.0",
|
|
52
52
|
"yoga-layout": "^3.2.1"
|
|
53
53
|
},
|
|
54
54
|
"scripts": {
|