react-x11 2.13.0 → 2.15.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.
Files changed (70) hide show
  1. package/package.json +11 -8
  2. package/src/Reconciler.js +34 -21
  3. package/src/components/Select.js +8 -2
  4. package/src/events.js +8 -2
  5. package/src/glnodes.js +15 -3
  6. package/src/index.d.ts +5 -0
  7. package/src/nodes/boxpaint.js +9 -0
  8. package/src/nodes/preedit.js +64 -14
  9. package/src/ntk.js +10 -1
  10. package/src/scale.js +52 -22
  11. package/src/screencolor.js +104 -17
  12. package/src/types/system.d.ts +14 -1
  13. package/src/wayland/app.js +574 -0
  14. package/src/wayland/backendwindow.js +1199 -0
  15. package/src/wayland/clipboard.js +326 -0
  16. package/src/wayland/connection.js +485 -0
  17. package/src/wayland/context2d.js +2290 -0
  18. package/src/wayland/decorations.js +476 -0
  19. package/src/wayland/device.js +55 -0
  20. package/src/wayland/dmabuf.js +89 -0
  21. package/src/wayland/dnd.js +581 -0
  22. package/src/wayland/fdutil.js +108 -0
  23. package/src/wayland/framestyle.js +257 -0
  24. package/src/wayland/framewatch.js +190 -0
  25. package/src/wayland/glarea.js +372 -0
  26. package/src/wayland/glcontext.js +415 -0
  27. package/src/wayland/glyphatlas.js +237 -0
  28. package/src/wayland/input.js +417 -0
  29. package/src/wayland/keysymnames.js +35 -0
  30. package/src/wayland/layershell.js +363 -0
  31. package/src/wayland/outputs.js +601 -0
  32. package/src/wayland/protocols/cursor-shape-v1.json +1 -0
  33. package/src/wayland/protocols/ext-idle-notify-v1.json +1 -0
  34. package/src/wayland/protocols/ext-image-capture-source-v1.json +1 -0
  35. package/src/wayland/protocols/ext-image-copy-capture-v1.json +1 -0
  36. package/src/wayland/protocols/fractional-scale-v1.json +1 -0
  37. package/src/wayland/protocols/index.json +131 -0
  38. package/src/wayland/protocols/kde-server-decoration.json +1 -0
  39. package/src/wayland/protocols/keyboard-shortcuts-inhibit-unstable-v1.json +1 -0
  40. package/src/wayland/protocols/linux-dmabuf-v1.json +1 -0
  41. package/src/wayland/protocols/pointer-constraints-unstable-v1.json +1 -0
  42. package/src/wayland/protocols/presentation-time.json +1 -0
  43. package/src/wayland/protocols/primary-selection-unstable-v1.json +1 -0
  44. package/src/wayland/protocols/relative-pointer-unstable-v1.json +1 -0
  45. package/src/wayland/protocols/tablet-v2.json +1 -0
  46. package/src/wayland/protocols/text-input-unstable-v3.json +1 -0
  47. package/src/wayland/protocols/viewporter.json +1 -0
  48. package/src/wayland/protocols/wayland.json +1 -0
  49. package/src/wayland/protocols/wlr-layer-shell-unstable-v1.json +1 -0
  50. package/src/wayland/protocols/wlr-screencopy-unstable-v1.json +1 -0
  51. package/src/wayland/protocols/xdg-activation-v1.json +1 -0
  52. package/src/wayland/protocols/xdg-decoration-unstable-v1.json +1 -0
  53. package/src/wayland/protocols/xdg-output-unstable-v1.json +1 -0
  54. package/src/wayland/protocols/xdg-shell.json +1 -0
  55. package/src/wayland/protocols/xdg-toplevel-icon-v1.json +1 -0
  56. package/src/wayland/readback.js +99 -0
  57. package/src/wayland/screencopy.js +584 -0
  58. package/src/wayland/seat.js +584 -0
  59. package/src/wayland/shm.js +226 -0
  60. package/src/wayland/ssd.js +234 -0
  61. package/src/wayland/surface.js +141 -0
  62. package/src/wayland/swapchain.js +411 -0
  63. package/src/wayland/tablet.js +522 -0
  64. package/src/wayland/target.js +269 -0
  65. package/src/wayland/text.js +113 -0
  66. package/src/wayland/textinput.js +671 -0
  67. package/src/wayland/touch.js +284 -0
  68. package/src/wayland/window.js +854 -0
  69. package/src/wayland/xkb.js +425 -0
  70. package/src/windowstate.js +41 -1
@@ -0,0 +1,2290 @@
1
+ // Tier D's 2d context: canvas-shaped drawing that ends up as GLES draw calls.
2
+ //
3
+ // This is the piece the RFC calls the largest workstream, and the shape it
4
+ // takes here is worth stating because it is not the obvious one.
5
+ //
6
+ // **Analytic shapes first, stencil second.** A rounded rectangle — which is
7
+ // most of what a UI draws — is a signed distance field evaluated in the
8
+ // fragment shader: antialiased without multisampling, and batched, so every
9
+ // box in a window can share one draw call. Arbitrary paths (SVG, custom
10
+ // canvas drawing) go the conventional way, stencil-then-cover: the flattened
11
+ // path's winding is counted into the stencil attachment of the render target
12
+ // (target.js), then a quad over its bounds is drawn wherever the count is
13
+ // non-zero. That path is aliased for now; the stencil bits it needs came with
14
+ // x11-dri 0.8.0 and the target's own renderbuffer.
15
+ //
16
+ // **One shader, batched by mode.** Vertices carry position, texture
17
+ // coordinates, a premultiplied colour and four shape parameters; per-batch
18
+ // uniforms pick how the fragment shader reads them. A batch flushes when the
19
+ // mode, the texture, the texture's byte layout or the scissor changes, which
20
+ // for a widget UI means a handful of draw calls for a whole window.
21
+ //
22
+ // **Transforms on the CPU.** The matrix is applied as vertices are emitted
23
+ // rather than sent as a uniform, so a translate does not break a batch. A UI
24
+ // emits thousands of vertices per frame, not millions.
25
+ //
26
+ // **Clipping is the scissor.** Rectangular clips — a scroll pane, a list row,
27
+ // a text field — are exact. A non-rectangular clip falls back to its bounding
28
+ // box, which over-paints rather than under-paints; `clipApproximations`
29
+ // counts them.
30
+ //
31
+ // **Three pixel layouts, one texture path.** ntk's `Image` is premultiplied
32
+ // BGRA (it was made for XRender), canvas `ImageData` is straight RGBA (the
33
+ // spec), and a render target is premultiplied RGBA (ours). Converting on the
34
+ // CPU would cost a pass per upload; the fragment shader swizzles instead,
35
+ // selected per batch.
36
+ //
37
+ // **Text is ntk's up to the last inch.** `drawGlyphs` is the contract
38
+ // `TextLayout.draw` calls, and everything before it — matching, shaping,
39
+ // bidi, line breaking — is the same code the X11 backend runs. Only the
40
+ // composite changes: an A8 atlas texture instead of a server glyph set.
41
+
42
+ import { createRequire } from 'node:module';
43
+ import path from 'node:path';
44
+ import { pathToFileURL } from 'node:url';
45
+ import { Path2D, Image as NtkImage } from 'ntk';
46
+ import { glDevice } from './device.js';
47
+ import { GlyphAtlas } from './glyphatlas.js';
48
+ import { GLTarget } from './target.js';
49
+
50
+ // ntk's exports map hides lib/path.js, and `flattenPath` is what turns a
51
+ // Path2D into device-space polygons — reach it by file.
52
+ const require = createRequire(import.meta.url);
53
+ const ntkLib = path.dirname(require.resolve('ntk'));
54
+ const { flattenPath } = await import(
55
+ pathToFileURL(path.join(ntkLib, 'path.js'))
56
+ );
57
+ const { rasterizePolys } = await import(
58
+ pathToFileURL(path.join(ntkLib, 'rasterize.js'))
59
+ );
60
+
61
+ const MODE_SOLID = 0;
62
+ const MODE_TEXTURE = 1;
63
+ const MODE_GLYPH = 2;
64
+ const MODE_RRECT = 3;
65
+ const MODE_GRADIENT = 4;
66
+
67
+ /**
68
+ * The stencil buffer, shared between two uses: bit 7 is the clip (set where
69
+ * drawing may land while a non-rectangular clip is in force) and the low
70
+ * seven bits are the winding count stencil-then-cover works in. A path's
71
+ * passes write with `WINDING_MASK` and never disturb the clip; the clip's
72
+ * passes write with `CLIP_BIT` and leave the counts to their cleanup.
73
+ */
74
+ const CLIP_BIT = 0x80;
75
+ const WINDING_MASK = 0x7f;
76
+ /**
77
+ * The largest fill the CPU rasteriser takes (see `_fillCoverage`): a
78
+ * 512×512 raster is well under a millisecond; a screen-sized one is not, and
79
+ * it goes through the stencil instead.
80
+ */
81
+ const COVERAGE_MAX_PIXELS = 512 * 512;
82
+
83
+ /** `uSwizzle`: how the bound texture's bytes are to be read. */
84
+ const SWIZZLE_RGBA = 0; // premultiplied RGBA (render targets)
85
+ const SWIZZLE_BGRA = 1; // premultiplied BGRA (ntk Image)
86
+ const SWIZZLE_STRAIGHT = 2; // straight RGBA (ImageData)
87
+
88
+ /** floats per vertex: pos(2) uv(2) color(4) params(4) */
89
+ const STRIDE = 12;
90
+ const MAX_VERTS = 6 * 4096;
91
+ const TRI = [0, 1, 2, 0, 2, 3];
92
+
93
+ const VERT = `
94
+ attribute vec2 aPos;
95
+ attribute vec2 aUV;
96
+ attribute vec4 aColor;
97
+ attribute vec4 aParams;
98
+ uniform vec2 uViewport;
99
+ varying vec2 vUV;
100
+ varying vec4 vColor;
101
+ varying vec4 vParams;
102
+ void main() {
103
+ vUV = aUV;
104
+ vColor = aColor;
105
+ vParams = aParams;
106
+ // Device pixels (y down, origin top-left) to clip space (y up).
107
+ gl_Position = vec4(aPos.x / uViewport.x * 2.0 - 1.0,
108
+ 1.0 - aPos.y / uViewport.y * 2.0, 0.0, 1.0);
109
+ }`;
110
+
111
+ const FRAG = `
112
+ precision mediump float;
113
+ varying vec2 vUV;
114
+ varying vec4 vColor;
115
+ varying vec4 vParams;
116
+ uniform int uMode;
117
+ uniform int uSwizzle;
118
+ uniform sampler2D uTex;
119
+ // highp to match the vertex stage's default: GLSL ES links a uniform used in
120
+ // both stages only when their precisions agree, and device coordinates past
121
+ // 2048 need the range anyway.
122
+ uniform highp vec2 uViewport;
123
+ uniform int uGradKind;
124
+ uniform vec4 uGradA;
125
+ uniform vec4 uGradB;
126
+
127
+ float sdRoundBox(vec2 p, vec2 b, float r) {
128
+ vec2 q = abs(p) - b + r;
129
+ return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
130
+ }
131
+
132
+ vec4 sampleTex(vec2 uv) {
133
+ vec4 c = texture2D(uTex, uv);
134
+ if (uSwizzle == 1) c = c.bgra;
135
+ else if (uSwizzle == 2) c = vec4(c.rgb * c.a, c.a);
136
+ return c;
137
+ }
138
+
139
+ void main() {
140
+ if (uMode == 0) {
141
+ gl_FragColor = vColor;
142
+ } else if (uMode == 1) {
143
+ gl_FragColor = vColor * sampleTex(vUV);
144
+ } else if (uMode == 2) {
145
+ gl_FragColor = vColor * texture2D(uTex, vUV).r; // an R8 atlas
146
+ } else if (uMode == 3) {
147
+ // vUV is the offset from the rect centre, in pixels; vParams.w selects
148
+ // fill (0), stroke of that width (> 0) or a shadow feathered by that
149
+ // much (< 0).
150
+ float d = sdRoundBox(vUV, vParams.xy, vParams.z);
151
+ float w = vParams.w;
152
+ float a;
153
+ if (w > 0.0) a = 1.0 - smoothstep(w * 0.5 - 0.5, w * 0.5 + 0.5, abs(d));
154
+ else if (w < 0.0) a = 1.0 - smoothstep(w, -w, d);
155
+ else a = 1.0 - smoothstep(-0.5, 0.5, d);
156
+ gl_FragColor = vColor * a;
157
+ } else {
158
+ // Gradients are evaluated per fragment from its device position, so any
159
+ // shape — a cover quad over a path included — can be filled with one.
160
+ vec2 p = vec2(gl_FragCoord.x, uViewport.y - gl_FragCoord.y);
161
+ float t;
162
+ if (uGradKind == 0) {
163
+ vec2 d = uGradB.xy - uGradA.xy;
164
+ float l = dot(d, d);
165
+ t = l > 0.0 ? dot(p - uGradA.xy, d) / l : 0.0;
166
+ } else {
167
+ float r0 = uGradA.z;
168
+ float r1 = uGradB.x;
169
+ float dist = length(p - uGradA.xy);
170
+ t = r1 > r0 ? (dist - r0) / (r1 - r0) : 0.0;
171
+ }
172
+ gl_FragColor = vColor * texture2D(uTex, vec2(clamp(t, 0.0, 1.0), 0.5));
173
+ }
174
+ }`;
175
+
176
+ // ---- small helpers ----------------------------------------------------------
177
+
178
+ /** A 2x3 affine matrix in canvas order [a b c d e f]. */
179
+ const IDENTITY = Object.freeze([1, 0, 0, 1, 0, 0]);
180
+
181
+ function mul(m, n) {
182
+ // m × n, where n is applied first (canvas semantics for ctx.transform)
183
+ return [
184
+ m[0] * n[0] + m[2] * n[1],
185
+ m[1] * n[0] + m[3] * n[1],
186
+ m[0] * n[2] + m[2] * n[3],
187
+ m[1] * n[2] + m[3] * n[3],
188
+ m[0] * n[4] + m[2] * n[5] + m[4],
189
+ m[1] * n[4] + m[3] * n[5] + m[5],
190
+ ];
191
+ }
192
+
193
+ function applyX(m, x, y) {
194
+ return m[0] * x + m[2] * y + m[4];
195
+ }
196
+ function applyY(m, x, y) {
197
+ return m[1] * x + m[3] * y + m[5];
198
+ }
199
+ function isAxisAligned(m) {
200
+ return m[1] === 0 && m[2] === 0;
201
+ }
202
+ /** the uniform scale a matrix applies, for stroke widths */
203
+ function scaleOf(m) {
204
+ return Math.sqrt(Math.abs(m[0] * m[3] - m[1] * m[2])) || 1;
205
+ }
206
+
207
+ /**
208
+ * The Render ops a `drawGlyphs` call can name, numbered as XRender numbers
209
+ * them — the same table the Cocoa backend answers (`src/cocoa/context2d.js`),
210
+ * so `ctx.Render.PictOp.Over` reads the same on every backend. The op is
211
+ * ignored here: text composites Over, and for the opaque inks text uses Src
212
+ * and Over agree.
213
+ */
214
+ const RENDER = Object.freeze({ PictOp: Object.freeze({ Src: 1, Over: 3 }) });
215
+
216
+ const clamp01 = (v) => Math.min(1, Math.max(0, Number(v) || 0));
217
+
218
+ const colorCache = new Map();
219
+
220
+ /**
221
+ * Parse a CSS-ish colour into premultiplied 0..1 RGBA. Memoised: the
222
+ * renderer above resolves palettes long before a paint call, so a frame sees
223
+ * a few dozen distinct strings thousands of times.
224
+ */
225
+ export function parseColor(value) {
226
+ if (Array.isArray(value)) return value;
227
+ if (typeof value !== 'string') return BLACK;
228
+ const hit = colorCache.get(value);
229
+ if (hit) return hit;
230
+ const c = parseColorUncached(value);
231
+ if (colorCache.size > 4096) colorCache.clear();
232
+ colorCache.set(value, c);
233
+ return c;
234
+ }
235
+
236
+ const BLACK = Object.freeze([0, 0, 0, 1]);
237
+ const CLEAR = Object.freeze([0, 0, 0, 0]);
238
+ const OPAQUE = Object.freeze([1, 1, 1, 1]);
239
+
240
+ function parseColorUncached(value) {
241
+ const s = value.trim();
242
+ if (s[0] === '#') {
243
+ const h = s.slice(1);
244
+ const n = h.length;
245
+ const q = (i, len) =>
246
+ parseInt(len === 1 ? h[i] + h[i] : h.slice(i * 2, i * 2 + 2), 16) / 255;
247
+ if (n === 3 || n === 4)
248
+ return premul(q(0, 1), q(1, 1), q(2, 1), n === 4 ? q(3, 1) : 1);
249
+ if (n === 6 || n === 8)
250
+ return premul(q(0, 2), q(1, 2), q(2, 2), n === 8 ? q(3, 2) : 1);
251
+ }
252
+ const m = s.match(/^rgba?\(([^)]+)\)$/i);
253
+ if (m) {
254
+ const p = m[1].split(/[,\s/]+/).filter(Boolean);
255
+ const chan = (v) =>
256
+ (v.endsWith('%') ? parseFloat(v) * 2.55 : parseFloat(v)) / 255;
257
+ const alpha =
258
+ p.length > 3
259
+ ? p[3].endsWith('%')
260
+ ? parseFloat(p[3]) / 100
261
+ : parseFloat(p[3])
262
+ : 1;
263
+ return premul(
264
+ chan(p[0] || '0'),
265
+ chan(p[1] || '0'),
266
+ chan(p[2] || '0'),
267
+ Number.isFinite(alpha) ? alpha : 1,
268
+ );
269
+ }
270
+ if (s === 'transparent' || s === 'none') return CLEAR;
271
+ const named = NAMED[s.toLowerCase()];
272
+ if (named) return named;
273
+ return BLACK;
274
+ }
275
+
276
+ function premul(r, g, b, a) {
277
+ return Object.freeze([r * a, g * a, b * a, a]);
278
+ }
279
+
280
+ const NAMED = {
281
+ black: BLACK,
282
+ white: premul(1, 1, 1, 1),
283
+ red: premul(1, 0, 0, 1),
284
+ green: premul(0, 128 / 255, 0, 1),
285
+ blue: premul(0, 0, 1, 1),
286
+ gray: premul(128 / 255, 128 / 255, 128 / 255, 1),
287
+ grey: premul(128 / 255, 128 / 255, 128 / 255, 1),
288
+ yellow: premul(1, 1, 0, 1),
289
+ orange: premul(1, 165 / 255, 0, 1),
290
+ purple: premul(128 / 255, 0, 128 / 255, 1),
291
+ cyan: premul(0, 1, 1, 1),
292
+ magenta: premul(1, 0, 1, 1),
293
+ silver: premul(192 / 255, 192 / 255, 192 / 255, 1),
294
+ currentcolor: BLACK,
295
+ };
296
+
297
+ /**
298
+ * A gradient: stops, and a 256-texel ramp texture baked from them on first
299
+ * use. Interpolated premultiplied, which is what keeps a fade to transparent
300
+ * from passing through grey.
301
+ */
302
+ class Gradient {
303
+ constructor(kind, a) {
304
+ this.kind = kind; // 'linear' | 'radial'
305
+ this.a = a; // linear: [x0,y0,x1,y1]; radial: [cx0,cy0,r0,cx1,cy1,r1]
306
+ this.stops = [];
307
+ this._ramp = null;
308
+ this._baked = 0;
309
+ }
310
+
311
+ addColorStop(offset, color) {
312
+ const o = Math.min(1, Math.max(0, Number(offset) || 0));
313
+ this.stops.push({ offset: o, color: parseColor(color) });
314
+ this.stops.sort((p, q) => p.offset - q.offset);
315
+ return this;
316
+ }
317
+
318
+ /** the ramp as premultiplied RGBA8, 256 texels */
319
+ ramp() {
320
+ if (this._ramp && this._baked === this.stops.length) return this._ramp;
321
+ const out = new Uint8Array(256 * 4);
322
+ const stops = this.stops.length
323
+ ? this.stops
324
+ : [{ offset: 0, color: CLEAR }];
325
+ for (let i = 0; i < 256; i++) {
326
+ const t = i / 255;
327
+ let lo = stops[0];
328
+ let hi = stops[stops.length - 1];
329
+ for (let s = 0; s < stops.length - 1; s++) {
330
+ if (t >= stops[s].offset && t <= stops[s + 1].offset) {
331
+ lo = stops[s];
332
+ hi = stops[s + 1];
333
+ break;
334
+ }
335
+ }
336
+ const span = hi.offset - lo.offset;
337
+ const f = span > 0 ? Math.min(1, Math.max(0, (t - lo.offset) / span)) : 0;
338
+ for (let c = 0; c < 4; c++) {
339
+ out[i * 4 + c] = Math.round(
340
+ (lo.color[c] + (hi.color[c] - lo.color[c]) * f) * 255,
341
+ );
342
+ }
343
+ }
344
+ this._ramp = out;
345
+ this._baked = this.stops.length;
346
+ this._dirty = true;
347
+ return out;
348
+ }
349
+ }
350
+
351
+ /**
352
+ * The CSS font shorthand, as much of it as a UI writes:
353
+ * `[style] [weight] size[px|pt] family[, family…]`.
354
+ */
355
+ export function parseFont(font) {
356
+ const m = String(font ?? '')
357
+ .trim()
358
+ .match(
359
+ /^(?:(italic|oblique|normal)\s+)?(?:(normal|bold|bolder|lighter|[1-9]00)\s+)?(\d+(?:\.\d+)?)(px|pt)?(?:\s*\/\s*[^\s]+)?\s+(.+)$/i,
360
+ );
361
+ if (!m) return { italic: false, weight: 400, size: 16, family: 'sans-serif' };
362
+ const size = parseFloat(m[3]) * (m[4]?.toLowerCase() === 'pt' ? 4 / 3 : 1);
363
+ const w = (m[2] ?? 'normal').toLowerCase();
364
+ const weight =
365
+ w === 'bold' || w === 'bolder'
366
+ ? 700
367
+ : w === 'lighter'
368
+ ? 300
369
+ : w === 'normal'
370
+ ? 400
371
+ : parseInt(w, 10);
372
+ const family = m[5]
373
+ .split(',')[0]
374
+ .trim()
375
+ .replace(/^["']|["']$/g, '');
376
+ return { italic: /italic|oblique/i.test(m[1] ?? ''), weight, size, family };
377
+ }
378
+
379
+ // ---- the context --------------------------------------------------------------
380
+
381
+ /**
382
+ * A font's identity for the glyph cache. ntk's `font.key` is its file and
383
+ * face, plus the axis values of a variable-font instance, so Adwaita Sans at
384
+ * 400 and at 700 are two keys. The PostScript name used before is shared by
385
+ * every instance of a variable font and missing from some faces — and two
386
+ * faces with one key share bitmaps, which is text drawn in another face's
387
+ * glyphs. A key ntk derived from the name alone (`mem:#…`) gets the object's
388
+ * identity added.
389
+ */
390
+ const fontIds = new WeakMap();
391
+ let nextFontId = 1;
392
+ function fontKey(font) {
393
+ let id = fontIds.get(font);
394
+ if (!id) fontIds.set(font, (id = `f${nextFontId++}`));
395
+ const key = typeof font.key === 'string' ? font.key : '';
396
+ return key && !key.startsWith('mem:') ? key : `${key}@${id}`;
397
+ }
398
+
399
+ export class WaylandContext2D {
400
+ /**
401
+ * @param {object} gl the GLES entry points
402
+ * @param {object} [opts]
403
+ * @param {object} [opts.fontManager] an ntk FontManager, for `fillText`
404
+ * @param {import('./target.js').GLTarget} [opts.target] where to draw
405
+ * @param {() => void} [opts.makeCurrent] called before this context takes
406
+ * the device back, for an owner that has to make a GL surface current
407
+ * first — an offscreen surface, whose context may be drawn through at
408
+ * any time, not only inside a window's frame
409
+ */
410
+ constructor(gl, { fontManager = null, target = null, makeCurrent } = {}) {
411
+ this.gl = gl;
412
+ this.fontManager = fontManager;
413
+ /** @see ./device.js — the state this context shares with its siblings */
414
+ this._device = glDevice(gl);
415
+ this._makeCurrent = makeCurrent ?? null;
416
+ // `TextLayout.draw` opens with `ctx.window.app.display.Render` and reads
417
+ // one constant from it. Satisfy the shape rather than fork the layout.
418
+ this.window = { app: { display: { Render: RENDER } } };
419
+ this.atlas = new GlyphAtlas(gl);
420
+ // Quads already buffered against the atlas must be drawn before the
421
+ // atlas re-lays itself out under them.
422
+ this.atlas.onBeforeGrow = () => this._flush();
423
+ // Path fills that go through the CPU rasteriser land here as A8 masks,
424
+ // drawn like glyphs; emptied every frame, since a mask is one draw.
425
+ this.masks = new GlyphAtlas(gl, { size: 256 });
426
+ this.masks.onBeforeGrow = () => this._flush();
427
+ this._maskSeq = 0;
428
+ /** the non-rectangular clips in force, device space, outermost first */
429
+ this._clipPath = null;
430
+ /** inside a stencil pass: `_flush` leaves the stencil state alone */
431
+ this._stencilling = false;
432
+ this._target = target;
433
+
434
+ this.fillStyle = '#000000';
435
+ this.strokeStyle = '#000000';
436
+ this.lineWidth = 1;
437
+ this.lineCap = 'butt';
438
+ this.lineJoin = 'miter';
439
+ this.miterLimit = 10;
440
+ this.globalAlpha = 1;
441
+ this.textAlign = 'start';
442
+ this.textBaseline = 'alphabetic';
443
+ this.timestamp = 0;
444
+ this._font = '16px sans-serif';
445
+ this._fontSpec = parseFont(this._font);
446
+ this._fontFace = null;
447
+ this._fontFaceFor = null;
448
+
449
+ /** how many non-rectangular clips were approximated by their bbox */
450
+ this.clipApproximations = 0;
451
+ this.shapeStats = { quads: 0, batches: 0, glyphs: 0, paths: 0 };
452
+
453
+ this._m = IDENTITY;
454
+ this._stack = [];
455
+ this._clip = null;
456
+ this._path = new Path2D();
457
+ this._rects = [];
458
+ this._rectOnly = true;
459
+
460
+ this._width = 0;
461
+ this._height = 0;
462
+
463
+ this._verts = new Float32Array(MAX_VERTS * STRIDE);
464
+ this._n = 0;
465
+ this._mode = MODE_SOLID;
466
+ this._texture = null;
467
+ this._swizzle = SWIZZLE_RGBA;
468
+ this._drew = false;
469
+
470
+ this._program = null;
471
+ this._buffer = null;
472
+ this._loc = {};
473
+ /** image object -> { tex, w, h } */
474
+ this._textures = new WeakMap();
475
+ this._gradTex = new WeakMap();
476
+ }
477
+
478
+ // ---- lifecycle --------------------------------------------------------
479
+
480
+ /** Compile the program and allocate the vertex buffer. Context must be current. */
481
+ init() {
482
+ const gl = this.gl;
483
+ if (this._program) return;
484
+ const vs = compile(gl, gl.VERTEX_SHADER, VERT, 'vertex');
485
+ const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG, 'fragment');
486
+ const p = gl.createProgram();
487
+ gl.attachShader(p, vs);
488
+ gl.attachShader(p, fs);
489
+ gl.linkProgram(p);
490
+ if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
491
+ throw new Error(`2d program failed to link: ${gl.getProgramInfoLog(p)}`);
492
+ }
493
+ this._program = p;
494
+ const u = (n) => gl.getUniformLocation(p, n);
495
+ this._loc = {
496
+ aPos: gl.getAttribLocation(p, 'aPos'),
497
+ aUV: gl.getAttribLocation(p, 'aUV'),
498
+ aColor: gl.getAttribLocation(p, 'aColor'),
499
+ aParams: gl.getAttribLocation(p, 'aParams'),
500
+ uViewport: u('uViewport'),
501
+ uMode: u('uMode'),
502
+ uSwizzle: u('uSwizzle'),
503
+ uTex: u('uTex'),
504
+ uGradKind: u('uGradKind'),
505
+ uGradA: u('uGradA'),
506
+ uGradB: u('uGradB'),
507
+ };
508
+ this._buffer = gl.createBuffer();
509
+ gl.bindBuffer(gl.ARRAY_BUFFER, this._buffer);
510
+ // The addon's bufferData takes a TypedArray, not the byte-length
511
+ // overload WebGL also accepts. Allocated once; frames use bufferSubData.
512
+ gl.bufferData(gl.ARRAY_BUFFER, this._verts, gl.DYNAMIC_DRAW);
513
+ }
514
+
515
+ /** Where this context draws. A window's backing, or a surface's target. */
516
+ attach(target) {
517
+ if (this._target === target) return;
518
+ this._flush();
519
+ this._target = target;
520
+ // The projection and the bound framebuffer both belonged to the old
521
+ // target; the next draw re-establishes them against this one.
522
+ this._release();
523
+ }
524
+
525
+ get target() {
526
+ return this._target;
527
+ }
528
+
529
+ /**
530
+ * Start a frame. Resets state as a fresh canvas would, binds the target,
531
+ * and sizes the projection to it.
532
+ */
533
+ begin(width, height, timestamp = 0) {
534
+ this.init();
535
+ this._device.owner = this;
536
+ const t = this._target;
537
+ if (t) {
538
+ t.bind();
539
+ width = t.width;
540
+ height = t.height;
541
+ }
542
+ this._width = width;
543
+ this._height = height;
544
+ this._m = IDENTITY;
545
+ this._stack.length = 0;
546
+ this._clip = null;
547
+ this._path = new Path2D();
548
+ this._rects = [];
549
+ this._rectOnly = true;
550
+ this._n = 0;
551
+ this._drew = false;
552
+ this.timestamp = timestamp;
553
+ this.shapeStats = { quads: 0, batches: 0, glyphs: 0, paths: 0 };
554
+ this.clipApproximations = 0;
555
+ this._clipPath = null;
556
+ this._stencilling = false;
557
+ this._maskSeq = 0;
558
+ this.masks.reset();
559
+ this._applyState();
560
+ // scratch for paths and clips: a frame starts with it clean
561
+ const gl = this.gl;
562
+ gl.stencilMask(0xff);
563
+ gl.clear(gl.STENCIL_BUFFER_BIT);
564
+ }
565
+
566
+ /**
567
+ * The GL state every draw here assumes: the target bound, the projection
568
+ * sized to it, and the rest of the device saying what the shader was
569
+ * written against. `begin()` sets it; `_claim()` sets it again whenever
570
+ * something else has been drawing — another context, or a `<glarea>`'s
571
+ * foreign GL — since this one last did.
572
+ */
573
+ _applyState() {
574
+ const gl = this.gl;
575
+ this._target?.bind();
576
+ gl.useProgram(this._program);
577
+ gl.uniform2f(this._loc.uViewport, this._width, this._height);
578
+ gl.disable(gl.DEPTH_TEST);
579
+ gl.disable(gl.CULL_FACE);
580
+ gl.disable(gl.STENCIL_TEST);
581
+ gl.disable(gl.SCISSOR_TEST);
582
+ gl.enable(gl.BLEND);
583
+ gl.colorMask(true, true, true, true);
584
+ // Premultiplied alpha throughout — colours are premultiplied on the way
585
+ // in, and the glyph and texture paths both produce premultiplied output.
586
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
587
+ // …and what a frame of this context's own never has to say, because
588
+ // nothing here changes it. Foreign GL does.
589
+ if (typeof gl.bindVertexArray === 'function') gl.bindVertexArray(null);
590
+ gl.blendEquation(gl.FUNC_ADD);
591
+ gl.stencilMask(0xff);
592
+ gl.frontFace(gl.CCW);
593
+ gl.depthMask(true);
594
+ gl.activeTexture(gl.TEXTURE0);
595
+ }
596
+
597
+ /**
598
+ * Take the device before touching GL (device.js).
599
+ *
600
+ * Contexts on this backend share one GLES context, so the framebuffer and
601
+ * the state a draw needs are whatever the last one to draw left behind.
602
+ * This is the one place that notices and puts them back — which is what
603
+ * makes a held offscreen-surface context work the way ntk documents it,
604
+ * rather than only inside `Surface#render` (#566), and what spares a node
605
+ * that painted into a surface mid-frame from having to restore the
606
+ * window's context by hand afterwards.
607
+ *
608
+ * Nothing happens in the common case: a property compare against a
609
+ * context that is already the owner.
610
+ */
611
+ _claim() {
612
+ // Mid stencil-then-cover the state is set up for the pass, not for a
613
+ // plain batch, and the device cannot have changed hands under it.
614
+ if (this._device.owner === this || this._stencilling) return;
615
+ this._device.owner = this;
616
+ this._makeCurrent?.();
617
+ this.init();
618
+ // As `begin()` has it: a target's size *is* the projection.
619
+ const t = this._target;
620
+ if (t) {
621
+ this._width = t.width;
622
+ this._height = t.height;
623
+ }
624
+ this._applyState();
625
+ }
626
+
627
+ /** Give the device up, so the next draw through this context re-takes it. */
628
+ _release() {
629
+ if (this._device.owner === this) this._device.owner = null;
630
+ }
631
+
632
+ /** After foreign GL drew — a `<glarea>`'s frame — before drawing again. */
633
+ restoreGLState() {
634
+ // Foreign GL leaves no trace in the bookkeeping, so say so and re-take
635
+ // the device. State first, then the flush: anything still buffered is
636
+ // drawn under this context's state rather than under what was left.
637
+ this._release();
638
+ this._claim();
639
+ this._flush();
640
+ }
641
+
642
+ /** Flush what is buffered without ending the frame. */
643
+ flush() {
644
+ this._flush();
645
+ }
646
+
647
+ /** Flush anything still buffered. Call before presenting. */
648
+ end() {
649
+ this._flush();
650
+ this.gl.disable(this.gl.SCISSOR_TEST);
651
+ }
652
+
653
+ /** Whether anything was drawn since `begin()`. */
654
+ get drew() {
655
+ return this._drew || this._n > 0;
656
+ }
657
+
658
+ // ---- state ------------------------------------------------------------
659
+
660
+ save() {
661
+ this._stack.push({
662
+ m: this._m,
663
+ clip: this._clip,
664
+ clipPath: this._clipPath,
665
+ fill: this.fillStyle,
666
+ stroke: this.strokeStyle,
667
+ lw: this.lineWidth,
668
+ cap: this.lineCap,
669
+ join: this.lineJoin,
670
+ miter: this.miterLimit,
671
+ alpha: this.globalAlpha,
672
+ font: this._font,
673
+ align: this.textAlign,
674
+ baseline: this.textBaseline,
675
+ });
676
+ }
677
+
678
+ restore() {
679
+ const s = this._stack.pop();
680
+ if (!s) return;
681
+ if (s.clipPath !== this._clipPath)
682
+ this._rebuildClipStencil(s.clipPath, s.clip);
683
+ if (s.clip !== this._clip) this._flush();
684
+ this._m = s.m;
685
+ this._clip = s.clip;
686
+ this.fillStyle = s.fill;
687
+ this.strokeStyle = s.stroke;
688
+ this.lineWidth = s.lw;
689
+ this.lineCap = s.cap;
690
+ this.lineJoin = s.join;
691
+ this.miterLimit = s.miter;
692
+ this.globalAlpha = s.alpha;
693
+ if (s.font !== this._font) this.font = s.font;
694
+ this.textAlign = s.align;
695
+ this.textBaseline = s.baseline;
696
+ }
697
+
698
+ translate(x, y) {
699
+ this._m = mul(this._m, [1, 0, 0, 1, x, y]);
700
+ }
701
+
702
+ scale(x, y = x) {
703
+ this._m = mul(this._m, [x, 0, 0, y, 0, 0]);
704
+ }
705
+
706
+ rotate(angle) {
707
+ const c = Math.cos(angle);
708
+ const s = Math.sin(angle);
709
+ this._m = mul(this._m, [c, s, -s, c, 0, 0]);
710
+ }
711
+
712
+ transform(a, b, c, d, e, f) {
713
+ this._m = mul(this._m, [a, b, c, d, e, f]);
714
+ }
715
+
716
+ setTransform(a, b, c, d, e, f) {
717
+ if (typeof a === 'object' && a) this._m = [a.a, a.b, a.c, a.d, a.e, a.f];
718
+ else this._m = [a, b, c, d, e, f];
719
+ }
720
+
721
+ resetTransform() {
722
+ this._m = IDENTITY;
723
+ }
724
+
725
+ getTransform() {
726
+ const [a, b, c, d, e, f] = this._m;
727
+ return { a, b, c, d, e, f };
728
+ }
729
+
730
+ setLineDash() {
731
+ // Dashes are a stroke-geometry feature not implemented here; accepted
732
+ // and ignored so a caller that dashes a focus ring still gets the ring.
733
+ }
734
+
735
+ getLineDash() {
736
+ return [];
737
+ }
738
+
739
+ get font() {
740
+ return this._font;
741
+ }
742
+
743
+ set font(value) {
744
+ if (value === this._font) return;
745
+ this._font = String(value);
746
+ this._fontSpec = parseFont(this._font);
747
+ this._fontFace = null;
748
+ }
749
+
750
+ createLinearGradient(x0, y0, x1, y1) {
751
+ return new Gradient('linear', [x0, y0, x1, y1]);
752
+ }
753
+
754
+ createRadialGradient(x0, y0, r0, x1, y1, r1) {
755
+ return new Gradient('radial', [x0, y0, r0, x1, y1, r1]);
756
+ }
757
+
758
+ createPattern() {
759
+ // Patterns need a repeating texture mode; not yet.
760
+ return null;
761
+ }
762
+
763
+ // ---- paths ------------------------------------------------------------
764
+
765
+ beginPath() {
766
+ this._path = new Path2D();
767
+ this._rects = [];
768
+ this._rectOnly = true;
769
+ }
770
+
771
+ rect(x, y, w, h) {
772
+ this._rects.push({ x, y, w, h, r: 0 });
773
+ this._path.rect(x, y, w, h);
774
+ }
775
+
776
+ roundRect(x, y, w, h, radii) {
777
+ // One radius for all four corners is the analytic rounded rectangle;
778
+ // anything else — `[0, 0, r, r]`, a box with one corner rounded — is a
779
+ // path. Taking the first radius for all four drew the wrong shape, and
780
+ // as a clip made `[0, 0, r, r]` a plain rectangle.
781
+ const list = Array.isArray(radii) ? radii : [radii];
782
+ const first = Number(list[0]) || 0;
783
+ if (list.every((v) => (Number(v) || 0) === first)) {
784
+ this._rects.push({ x, y, w, h, r: first });
785
+ } else {
786
+ this._rectOnly = false;
787
+ }
788
+ this._path.roundRect(x, y, w, h, radii);
789
+ }
790
+
791
+ moveTo(x, y) {
792
+ this._rectOnly = false;
793
+ this._path.moveTo(x, y);
794
+ }
795
+
796
+ lineTo(x, y) {
797
+ this._rectOnly = false;
798
+ this._path.lineTo(x, y);
799
+ }
800
+
801
+ closePath() {
802
+ this._path.closePath();
803
+ }
804
+
805
+ bezierCurveTo(x1, y1, x2, y2, x, y) {
806
+ this._rectOnly = false;
807
+ this._path.bezierCurveTo(x1, y1, x2, y2, x, y);
808
+ }
809
+
810
+ quadraticCurveTo(x1, y1, x, y) {
811
+ this._rectOnly = false;
812
+ this._path.quadraticCurveTo(x1, y1, x, y);
813
+ }
814
+
815
+ arc(x, y, r, a0, a1, ccw) {
816
+ this._rectOnly = false;
817
+ this._path.arc(x, y, r, a0, a1, ccw);
818
+ }
819
+
820
+ arcTo(x1, y1, x2, y2, r) {
821
+ this._rectOnly = false;
822
+ this._path.arcTo(x1, y1, x2, y2, r);
823
+ }
824
+
825
+ ellipse(x, y, rx, ry, rot, a0, a1, ccw) {
826
+ this._rectOnly = false;
827
+ this._path.ellipse(x, y, rx, ry, rot, a0, a1, ccw);
828
+ }
829
+
830
+ /**
831
+ * `fill()`, `fill(rule)`, `fill(path)`, `fill(path, rule)`.
832
+ *
833
+ * Rectangles and rounded rectangles built with `rect`/`roundRect` take the
834
+ * analytic path; anything else is flattened and stencilled.
835
+ */
836
+ fill(a, b) {
837
+ const external = a instanceof Path2D ? a : null;
838
+ const rule = (external ? b : a) === 'evenodd' ? 'evenodd' : 'nonzero';
839
+ if (!external && this._rectOnly) {
840
+ for (const r of this._rects)
841
+ this._rect(r.x, r.y, r.w, r.h, r.r, this.fillStyle, 0);
842
+ return;
843
+ }
844
+ const cmds = external ? external._cmds : this._path._cmds;
845
+ const polys = flattenPath(cmds, this._m);
846
+ this._fillPolys(polys, rule, this.fillStyle);
847
+ }
848
+
849
+ stroke(external) {
850
+ const p = external instanceof Path2D ? external : null;
851
+ if (!p && this._rectOnly) {
852
+ for (const r of this._rects)
853
+ this._rect(
854
+ r.x,
855
+ r.y,
856
+ r.w,
857
+ r.h,
858
+ r.r,
859
+ this.strokeStyle,
860
+ Math.max(0.5, this.lineWidth),
861
+ );
862
+ return;
863
+ }
864
+ const cmds = p ? p._cmds : this._path._cmds;
865
+ const polylines = flattenPath(cmds, this._m);
866
+ const width = Math.max(0.5, this.lineWidth * scaleOf(this._m));
867
+ const polys = strokePolys(
868
+ polylines,
869
+ width,
870
+ this.lineJoin,
871
+ this.lineCap,
872
+ this.miterLimit,
873
+ );
874
+ this._fillPolys(polys, 'nonzero', this.strokeStyle);
875
+ }
876
+
877
+ /**
878
+ * Intersect the clip with the current path.
879
+ *
880
+ * One axis-aligned rectangle is the scissor, as before, and costs nothing
881
+ * per draw. Anything else — a rounded rectangle (`overflow: hidden` with
882
+ * a radius), a path, several rectangles — is exact through the stencil's
883
+ * clip bit (`_applyClipPath`), and every batch drawn while it is in force
884
+ * tests that bit. The scissor still narrows to the shape's bounds, so the
885
+ * test is only ever paid inside them.
886
+ */
887
+ clip(a, b) {
888
+ const external = a instanceof Path2D ? a : null;
889
+ const rule = (external ? b : a) === 'evenodd' ? 'evenodd' : 'nonzero';
890
+ if (
891
+ !external &&
892
+ this._rectOnly &&
893
+ this._rects.length === 1 &&
894
+ !(this._rects[0].r > 0) &&
895
+ isAxisAligned(this._m)
896
+ ) {
897
+ const r = this._rects[0];
898
+ const bounds = this._deviceRect(r.x, r.y, r.w, r.h);
899
+ this._flush();
900
+ this._clip = this._clip ? intersectRect(this._clip, bounds) : bounds;
901
+ return;
902
+ }
903
+ const polys = flattenPath(
904
+ external ? external._cmds : this._path._cmds,
905
+ this._m,
906
+ );
907
+ const bounds = polysBounds(polys);
908
+ if (!bounds || !(bounds.w > 0) || !(bounds.h > 0)) {
909
+ // an empty path clips everything
910
+ this._flush();
911
+ this._clip = { x: 0, y: 0, w: 0, h: 0 };
912
+ return;
913
+ }
914
+ this._applyClipPath(polys, rule, bounds);
915
+ }
916
+
917
+ isPointInPath(x, y) {
918
+ const polys = flattenPath(this._path._cmds, this._m);
919
+ const dx = applyX(this._m, x, y);
920
+ const dy = applyY(this._m, x, y);
921
+ return polysContain(polys, dx, dy);
922
+ }
923
+
924
+ // ---- fills ------------------------------------------------------------
925
+
926
+ fillRect(x, y, w, h) {
927
+ this._rect(x, y, w, h, 0, this.fillStyle, 0);
928
+ }
929
+
930
+ strokeRect(x, y, w, h) {
931
+ this._rect(x, y, w, h, 0, this.strokeStyle, Math.max(0.5, this.lineWidth));
932
+ }
933
+
934
+ /**
935
+ * The batched form react-x11 uses for backgrounds and selection bands.
936
+ *
937
+ * Three shapes, because all three are in use: `[[x,y,w,h],…]`,
938
+ * `[{x,y,width,height},…]`, and the flat `[x,y,w,h,x,y,w,h,…]` that
939
+ * `Render.FillRectangles` takes on the wire. ntk and the Cocoa context
940
+ * both accept the flat list, so it is what a caller written against
941
+ * either of them hands us; reading it as one rectangle per element makes
942
+ * every field `undefined` and draws nothing at all.
943
+ */
944
+ fillRects(rects) {
945
+ if (!rects?.length) return;
946
+ if (typeof rects[0] === 'number') {
947
+ // a trailing partial rectangle is not one, so stop four short
948
+ for (let i = 0; i + 3 < rects.length; i += 4) {
949
+ const x = rects[i];
950
+ const y = rects[i + 1];
951
+ const w = rects[i + 2];
952
+ const h = rects[i + 3];
953
+ this._rect(x, y, w, h, 0, this.fillStyle, 0);
954
+ }
955
+ return;
956
+ }
957
+ for (const r of rects) {
958
+ const x = r.x ?? r[0];
959
+ const y = r.y ?? r[1];
960
+ const w = r.width ?? r.w ?? r[2];
961
+ const h = r.height ?? r.h ?? r[3];
962
+ this._rect(x, y, w, h, 0, this.fillStyle, 0);
963
+ }
964
+ }
965
+
966
+ /**
967
+ * A blurred rounded-rectangle shadow, drawn analytically: the distance
968
+ * field's edge is feathered by the blur radius instead of stepping at
969
+ * zero. `boxpaint.js` calls this when it exists, in place of rendering
970
+ * coverage to a surface and gaussian-blurring it on the CPU — the same
971
+ * look for the shapes a box shadow can have, at the cost of one quad.
972
+ */
973
+ fillShadow(rect, radius, blur, color) {
974
+ const feather = Math.max(0.5, blur);
975
+ this._rect(
976
+ rect.x,
977
+ rect.y,
978
+ rect.width,
979
+ rect.height,
980
+ radius,
981
+ color,
982
+ -feather,
983
+ );
984
+ }
985
+
986
+ /** Clear to transparent — writes zeroes rather than blending. */
987
+ clearRect(x, y, w, h) {
988
+ if (!(w > 0 && h > 0)) return;
989
+ this._flush();
990
+ const gl = this.gl;
991
+ gl.blendFunc(gl.ONE, gl.ZERO);
992
+ this._emitRect(x, y, w, h, CLEAR);
993
+ this._flush();
994
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
995
+ }
996
+
997
+ _rect(x, y, w, h, radius, style, strokeWidth) {
998
+ if (!(w > 0 && h > 0)) return;
999
+ if (style instanceof Gradient) {
1000
+ if (radius <= 0 && strokeWidth === 0)
1001
+ return this._gradientQuad(x, y, w, h, style);
1002
+ // rounded/stroked gradient: stencil the shape, cover with the gradient
1003
+ const p = new Path2D();
1004
+ p.roundRect(x, y, w, h, radius);
1005
+ const polys = flattenPath(p._cmds, this._m);
1006
+ return this._fillPolys(polys, 'nonzero', style);
1007
+ }
1008
+ const color = this._color(style);
1009
+ if (color[3] === 0) return;
1010
+ if (radius <= 0 && strokeWidth === 0 && isAxisAligned(this._m)) {
1011
+ return this._emitRect(x, y, w, h, color);
1012
+ }
1013
+ // Rounded, stroked, shadowed or rotated: hand the shader the rect in its
1014
+ // own space, padded so the antialiased edge (or feather) has room.
1015
+ const pad =
1016
+ strokeWidth > 0
1017
+ ? strokeWidth * 0.5 + 1
1018
+ : strokeWidth < 0
1019
+ ? -strokeWidth + 1
1020
+ : 1;
1021
+ const hw = w / 2;
1022
+ const hh = h / 2;
1023
+ const cx = x + hw;
1024
+ const cy = y + hh;
1025
+ const r = Math.min(Math.max(0, radius), hw, hh);
1026
+ this._setMode(MODE_RRECT, null, SWIZZLE_RGBA);
1027
+ const m = this._m;
1028
+ const s = scaleOf(m);
1029
+ const px = pad / s;
1030
+ const c = [
1031
+ [-hw - px, -hh - px],
1032
+ [hw + px, -hh - px],
1033
+ [hw + px, hh + px],
1034
+ [-hw - px, hh + px],
1035
+ ];
1036
+ const pos = new Array(8);
1037
+ const uv = new Array(8);
1038
+ for (let i = 0; i < 4; i++) {
1039
+ const [dx, dy] = c[i];
1040
+ pos[i * 2] = applyX(m, cx + dx, cy + dy);
1041
+ pos[i * 2 + 1] = applyY(m, cx + dx, cy + dy);
1042
+ uv[i * 2] = dx * s;
1043
+ uv[i * 2 + 1] = dy * s;
1044
+ }
1045
+ this._quad(pos, uv, color, [hw * s, hh * s, r * s, strokeWidth]);
1046
+ }
1047
+
1048
+ _emitRect(x, y, w, h, color) {
1049
+ this._setMode(MODE_SOLID, null, SWIZZLE_RGBA);
1050
+ const m = this._m;
1051
+ this._quad(
1052
+ [
1053
+ applyX(m, x, y),
1054
+ applyY(m, x, y),
1055
+ applyX(m, x + w, y),
1056
+ applyY(m, x + w, y),
1057
+ applyX(m, x + w, y + h),
1058
+ applyY(m, x + w, y + h),
1059
+ applyX(m, x, y + h),
1060
+ applyY(m, x, y + h),
1061
+ ],
1062
+ UV_UNIT,
1063
+ color,
1064
+ ZERO4,
1065
+ );
1066
+ }
1067
+
1068
+ /** Bind a gradient's ramp and endpoints as the current batch's uniforms. */
1069
+ _useGradient(grad) {
1070
+ const gl = this.gl;
1071
+ this._flush();
1072
+ let tex = this._gradTex.get(grad);
1073
+ const ramp = grad.ramp();
1074
+ if (!tex) {
1075
+ tex = gl.createTexture();
1076
+ gl.bindTexture(gl.TEXTURE_2D, tex);
1077
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
1078
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
1079
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1080
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1081
+ this._gradTex.set(grad, tex);
1082
+ grad._dirty = true;
1083
+ }
1084
+ if (grad._dirty) {
1085
+ gl.bindTexture(gl.TEXTURE_2D, tex);
1086
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
1087
+ gl.texImage2D(
1088
+ gl.TEXTURE_2D,
1089
+ 0,
1090
+ gl.RGBA,
1091
+ 256,
1092
+ 1,
1093
+ 0,
1094
+ gl.RGBA,
1095
+ gl.UNSIGNED_BYTE,
1096
+ ramp,
1097
+ );
1098
+ grad._dirty = false;
1099
+ }
1100
+ this._mode = MODE_GRADIENT;
1101
+ this._texture = tex;
1102
+ this._swizzle = SWIZZLE_RGBA;
1103
+ const m = this._m;
1104
+ const a = grad.a;
1105
+ gl.useProgram(this._program);
1106
+ if (grad.kind === 'linear') {
1107
+ gl.uniform1i(this._loc.uGradKind, 0);
1108
+ gl.uniform4f(
1109
+ this._loc.uGradA,
1110
+ applyX(m, a[0], a[1]),
1111
+ applyY(m, a[0], a[1]),
1112
+ 0,
1113
+ 0,
1114
+ );
1115
+ gl.uniform4f(
1116
+ this._loc.uGradB,
1117
+ applyX(m, a[2], a[3]),
1118
+ applyY(m, a[2], a[3]),
1119
+ 0,
1120
+ 0,
1121
+ );
1122
+ } else {
1123
+ const s = scaleOf(m);
1124
+ gl.uniform1i(this._loc.uGradKind, 1);
1125
+ gl.uniform4f(
1126
+ this._loc.uGradA,
1127
+ applyX(m, a[3], a[4]),
1128
+ applyY(m, a[3], a[4]),
1129
+ a[2] * s,
1130
+ 0,
1131
+ );
1132
+ gl.uniform4f(this._loc.uGradB, a[5] * s, 0, 0, 0);
1133
+ }
1134
+ }
1135
+
1136
+ _gradientQuad(x, y, w, h, grad) {
1137
+ this._useGradient(grad);
1138
+ const m = this._m;
1139
+ const alpha = this.globalAlpha;
1140
+ this._quad(
1141
+ [
1142
+ applyX(m, x, y),
1143
+ applyY(m, x, y),
1144
+ applyX(m, x + w, y),
1145
+ applyY(m, x + w, y),
1146
+ applyX(m, x + w, y + h),
1147
+ applyY(m, x + w, y + h),
1148
+ applyX(m, x, y + h),
1149
+ applyY(m, x, y + h),
1150
+ ],
1151
+ UV_UNIT,
1152
+ [alpha, alpha, alpha, alpha],
1153
+ ZERO4,
1154
+ );
1155
+ this._flush();
1156
+ }
1157
+
1158
+ // ---- path fills -------------------------------------------------------
1159
+
1160
+ /**
1161
+ * Fill device-space polygons with a paint.
1162
+ *
1163
+ * Two routes. A solid fill of a shape that fits the CPU budget is
1164
+ * **coverage**: ntk's own rasteriser — the one the X11 backend's local
1165
+ * raster policy and every glyph use — turns the polygons into an A8 mask,
1166
+ * which is drawn like a glyph. Antialiased, deterministic, the same
1167
+ * pixels as X11, at ~25µs for an icon. Everything else — a gradient, or a
1168
+ * shape bigger than `COVERAGE_MAX_PIXELS` — is **stencil-then-cover**:
1169
+ * pass 1 counts winding into the stencil buffer with colour writes off
1170
+ * (one triangle fan per subpath, incrementing for one facing and
1171
+ * decrementing for the other, or inverting for even-odd), pass 2 covers
1172
+ * the bounds with the paint wherever the count is non-zero, zeroing the
1173
+ * count as it goes. Aliased at the edge; MSAA is the fix, when a target
1174
+ * that can be validated has it.
1175
+ *
1176
+ * Under a path clip (`_clipPath`) the cover also requires the clip bit,
1177
+ * and a third pass clears the counts the cover did not reach.
1178
+ */
1179
+ _fillPolys(polys, rule, style) {
1180
+ const bounds = polysBounds(polys);
1181
+ if (!bounds || bounds.w <= 0 || bounds.h <= 0) return;
1182
+ // Before `_fillCoverage`, which cuts its raster to `_width`/`_height`:
1183
+ // on a context that has never opened a frame those are the target's
1184
+ // only once the device has been claimed.
1185
+ this._claim();
1186
+ let color = null;
1187
+ if (!(style instanceof Gradient)) {
1188
+ color = this._color(style);
1189
+ if (color[3] === 0) return;
1190
+ if (this._fillCoverage(polys, rule, bounds, color)) return;
1191
+ }
1192
+ const gl = this.gl;
1193
+ this._flush();
1194
+ this.shapeStats.paths++;
1195
+ this._stencilling = true;
1196
+ this._windingPass(polys, rule);
1197
+
1198
+ // cover: where the count is non-zero — and, under a clip, the bit is set
1199
+ gl.colorMask(true, true, true, true);
1200
+ if (this._clipPath) gl.stencilFunc(gl.LESS, CLIP_BIT, 0xff);
1201
+ else gl.stencilFunc(gl.NOTEQUAL, 0, WINDING_MASK);
1202
+ // the mask is still WINDING_MASK: the count goes, the clip bit stays
1203
+ gl.stencilOp(gl.KEEP, gl.KEEP, gl.ZERO);
1204
+ const { x, y, w, h } = bounds;
1205
+ const saved = this._m;
1206
+ this._m = IDENTITY;
1207
+ if (style instanceof Gradient) {
1208
+ this._m = saved; // gradient endpoints are in user space
1209
+ this._useGradient(style);
1210
+ this._m = IDENTITY;
1211
+ const a = this.globalAlpha;
1212
+ this._quad(
1213
+ [x, y, x + w, y, x + w, y + h, x, y + h],
1214
+ UV_UNIT,
1215
+ [a, a, a, a],
1216
+ ZERO4,
1217
+ );
1218
+ } else {
1219
+ this._emitRect(x, y, w, h, color);
1220
+ }
1221
+ this._flush();
1222
+ this._m = saved;
1223
+ if (this._clipPath) {
1224
+ // counts outside the clip, which the cover did not reach
1225
+ gl.colorMask(false, false, false, false);
1226
+ gl.stencilFunc(gl.NOTEQUAL, 0, WINDING_MASK);
1227
+ this._stencilQuad(x, y, w, h);
1228
+ gl.colorMask(true, true, true, true);
1229
+ }
1230
+ gl.disable(gl.STENCIL_TEST);
1231
+ this._stencilling = false;
1232
+ }
1233
+
1234
+ /**
1235
+ * The coverage route. The raster is cut to the clip and the target first:
1236
+ * pixels outside are never seen, and a large path that is mostly clipped
1237
+ * away — a chart line scrolled half out of view — is a small raster.
1238
+ *
1239
+ * @returns {boolean} false when the stencil route should take the fill
1240
+ */
1241
+ _fillCoverage(polys, rule, bounds, color) {
1242
+ const c = this._clip;
1243
+ const x0 = Math.max(0, Math.floor(bounds.x) - 1, c ? Math.floor(c.x) : 0);
1244
+ const y0 = Math.max(0, Math.floor(bounds.y) - 1, c ? Math.floor(c.y) : 0);
1245
+ const x1 = Math.min(
1246
+ this._width,
1247
+ Math.ceil(bounds.x + bounds.w) + 1,
1248
+ c ? Math.ceil(c.x + c.w) : this._width,
1249
+ );
1250
+ const y1 = Math.min(
1251
+ this._height,
1252
+ Math.ceil(bounds.y + bounds.h) + 1,
1253
+ c ? Math.ceil(c.y + c.h) : this._height,
1254
+ );
1255
+ const w = x1 - x0;
1256
+ const h = y1 - y0;
1257
+ if (w <= 0 || h <= 0) return true; // nothing of it is visible
1258
+ if (w * h > COVERAGE_MAX_PIXELS) return false;
1259
+ const data = rasterizePolys(
1260
+ polys.map((poly) => poly.pts),
1261
+ w,
1262
+ h,
1263
+ rule,
1264
+ { dx: -x0, dy: -y0 },
1265
+ );
1266
+ const entry = this.masks.get(`m${this._maskSeq++}`, () => ({
1267
+ width: w,
1268
+ height: h,
1269
+ left: 0,
1270
+ top: 0,
1271
+ data,
1272
+ }));
1273
+ // no room even after growing: the stencil takes this one
1274
+ if (entry.empty) return false;
1275
+ this.shapeStats.paths++;
1276
+ this._setMode(MODE_GLYPH, this.masks.bind(), SWIZZLE_RGBA);
1277
+ this._quad(
1278
+ [x0, y0, x1, y0, x1, y1, x0, y1],
1279
+ [
1280
+ entry.u0,
1281
+ entry.v0,
1282
+ entry.u1,
1283
+ entry.v0,
1284
+ entry.u1,
1285
+ entry.v1,
1286
+ entry.u0,
1287
+ entry.v1,
1288
+ ],
1289
+ color,
1290
+ ZERO4,
1291
+ );
1292
+ return true;
1293
+ }
1294
+
1295
+ /** Pass 1 of stencil-then-cover: winding counts into the low seven bits,
1296
+ * colour writes off. Leaves the stencil test on and colour writes off. */
1297
+ _windingPass(polys, rule) {
1298
+ const gl = this.gl;
1299
+ gl.enable(gl.STENCIL_TEST);
1300
+ gl.stencilMask(WINDING_MASK);
1301
+ gl.colorMask(false, false, false, false);
1302
+ gl.stencilFunc(gl.ALWAYS, 0, 0xff);
1303
+ this._mode = MODE_SOLID;
1304
+ this._texture = null;
1305
+ this._swizzle = SWIZZLE_RGBA;
1306
+
1307
+ const fans = () => {
1308
+ for (const poly of polys) {
1309
+ const p = poly.pts;
1310
+ if (p.length < 6) continue;
1311
+ for (let i = 2; i + 1 < p.length; i += 2) {
1312
+ this._tri(p[0], p[1], p[i - 2], p[i - 1], p[i], p[i + 1]);
1313
+ }
1314
+ }
1315
+ this._flush();
1316
+ };
1317
+ if (rule === 'evenodd') {
1318
+ gl.stencilOp(gl.KEEP, gl.KEEP, gl.INVERT);
1319
+ fans();
1320
+ } else {
1321
+ // two draws with culling, since the binding has no stencilOpSeparate
1322
+ gl.enable(gl.CULL_FACE);
1323
+ gl.cullFace(gl.BACK);
1324
+ gl.stencilOp(gl.KEEP, gl.KEEP, gl.INCR_WRAP);
1325
+ fans();
1326
+ gl.cullFace(gl.FRONT);
1327
+ gl.stencilOp(gl.KEEP, gl.KEEP, gl.DECR_WRAP);
1328
+ fans();
1329
+ gl.disable(gl.CULL_FACE);
1330
+ }
1331
+ }
1332
+
1333
+ /** A device-space rectangle drawn for its stencil effect alone. */
1334
+ _stencilQuad(x, y, w, h) {
1335
+ const saved = this._m;
1336
+ this._m = IDENTITY;
1337
+ this._emitRect(x, y, w, h, OPAQUE);
1338
+ this._flush();
1339
+ this._m = saved;
1340
+ }
1341
+
1342
+ /**
1343
+ * Put a shape into the clip.
1344
+ *
1345
+ * The first clip sets the bit where the shape's count is non-zero. A
1346
+ * nested one intersects instead: across the clip so far, the bit is
1347
+ * *cleared* where the new count is zero — which covers the region outside
1348
+ * the new shape's bounds too, so the pass runs over the old clip rect,
1349
+ * before the scissor narrows. Either way the counts are then cleared, and
1350
+ * the scissor narrows to the bounds.
1351
+ */
1352
+ _applyClipPath(polys, rule, bounds) {
1353
+ const gl = this.gl;
1354
+ this._flush();
1355
+ this._stencilling = true;
1356
+ this._windingPass(polys, rule);
1357
+ if (!this._clipPath) {
1358
+ gl.stencilMask(CLIP_BIT);
1359
+ gl.stencilFunc(gl.NOTEQUAL, CLIP_BIT, WINDING_MASK);
1360
+ // `stencilOp(sfail, dpfail, dppass)`: only the selected pixels change
1361
+ gl.stencilOp(gl.KEEP, gl.KEEP, gl.REPLACE);
1362
+ this._stencilQuad(bounds.x, bounds.y, bounds.w, bounds.h);
1363
+ } else {
1364
+ const old = this._clip ?? {
1365
+ x: 0,
1366
+ y: 0,
1367
+ w: this._width,
1368
+ h: this._height,
1369
+ };
1370
+ gl.stencilMask(CLIP_BIT);
1371
+ gl.stencilFunc(gl.EQUAL, CLIP_BIT, 0xff);
1372
+ gl.stencilOp(gl.KEEP, gl.KEEP, gl.ZERO);
1373
+ this._stencilQuad(old.x, old.y, old.w, old.h);
1374
+ }
1375
+ gl.stencilMask(WINDING_MASK);
1376
+ gl.stencilFunc(gl.NOTEQUAL, 0, WINDING_MASK);
1377
+ gl.stencilOp(gl.KEEP, gl.KEEP, gl.ZERO);
1378
+ this._stencilQuad(bounds.x, bounds.y, bounds.w, bounds.h);
1379
+ gl.colorMask(true, true, true, true);
1380
+ gl.disable(gl.STENCIL_TEST);
1381
+ this._stencilling = false;
1382
+ this._clipPath = [...(this._clipPath ?? []), { polys, rule, bounds }];
1383
+ this._clip = this._clip ? intersectRect(this._clip, bounds) : bounds;
1384
+ }
1385
+
1386
+ /**
1387
+ * `restore()` to a state with a different clip path: the bit is dropped
1388
+ * everywhere the current clip could have set it — its rect — and the
1389
+ * restored list is applied again from the restored rect. Clips nest
1390
+ * rarely and shallowly; re-rendering beats keeping a bit per level.
1391
+ */
1392
+ _rebuildClipStencil(list, rect) {
1393
+ const gl = this.gl;
1394
+ this._flush();
1395
+ if (this._clipPath) {
1396
+ const old = this._clip ?? {
1397
+ x: 0,
1398
+ y: 0,
1399
+ w: this._width,
1400
+ h: this._height,
1401
+ };
1402
+ this._stencilling = true;
1403
+ gl.enable(gl.STENCIL_TEST);
1404
+ gl.colorMask(false, false, false, false);
1405
+ gl.stencilMask(CLIP_BIT);
1406
+ gl.stencilFunc(gl.ALWAYS, 0, 0xff);
1407
+ gl.stencilOp(gl.KEEP, gl.KEEP, gl.ZERO);
1408
+ this._stencilQuad(old.x, old.y, old.w, old.h);
1409
+ gl.colorMask(true, true, true, true);
1410
+ gl.disable(gl.STENCIL_TEST);
1411
+ this._stencilling = false;
1412
+ }
1413
+ this._clipPath = null;
1414
+ this._clip = rect;
1415
+ for (const c of list ?? []) this._applyClipPath(c.polys, c.rule, c.bounds);
1416
+ }
1417
+
1418
+ // ---- images -----------------------------------------------------------
1419
+
1420
+ /**
1421
+ * Draw an image, a canvas `ImageData`, or an offscreen surface.
1422
+ *
1423
+ * `(img, dx, dy)`, `(img, dx, dy, dw, dh)` and
1424
+ * `(img, sx, sy, sw, sh, dx, dy, dw, dh)`, as canvas.
1425
+ */
1426
+ drawImage(img, ...args) {
1427
+ this._claim();
1428
+ const src = this._textureFor(img);
1429
+ if (!src) return;
1430
+ let sx = 0;
1431
+ let sy = 0;
1432
+ let sw = src.w;
1433
+ let sh = src.h;
1434
+ let dx;
1435
+ let dy;
1436
+ let dw;
1437
+ let dh;
1438
+ if (args.length >= 8) [sx, sy, sw, sh, dx, dy, dw, dh] = args;
1439
+ else if (args.length >= 4) [dx, dy, dw, dh] = args;
1440
+ else {
1441
+ [dx, dy] = args;
1442
+ dw = sw;
1443
+ dh = sh;
1444
+ }
1445
+ if (!(dw > 0 && dh > 0 && sw > 0 && sh > 0)) return;
1446
+ this._setMode(MODE_TEXTURE, src.tex, src.swizzle);
1447
+ let u0 = sx / src.w;
1448
+ let v0 = sy / src.h;
1449
+ let u1 = (sx + sw) / src.w;
1450
+ let v1 = (sy + sh) / src.h;
1451
+ if (src.flipY) {
1452
+ // a render target's row 0 is the bottom of what was drawn into it
1453
+ v0 = 1 - v0;
1454
+ v1 = 1 - v1;
1455
+ }
1456
+ const m = this._m;
1457
+ const a = this.globalAlpha;
1458
+ this._quad(
1459
+ [
1460
+ applyX(m, dx, dy),
1461
+ applyY(m, dx, dy),
1462
+ applyX(m, dx + dw, dy),
1463
+ applyY(m, dx + dw, dy),
1464
+ applyX(m, dx + dw, dy + dh),
1465
+ applyY(m, dx + dw, dy + dh),
1466
+ applyX(m, dx, dy + dh),
1467
+ applyY(m, dx, dy + dh),
1468
+ ],
1469
+ [u0, v0, u1, v0, u1, v1, u0, v1],
1470
+ [a, a, a, a],
1471
+ ZERO4,
1472
+ );
1473
+ }
1474
+
1475
+ /**
1476
+ * Write pixels, ignoring the transform and the clip (the canvas rule).
1477
+ * `dirtyX/Y/Width/Height` select part of the data, as the spec has it.
1478
+ */
1479
+ putImageData(data, x, y, dirtyX = 0, dirtyY = 0, dirtyWidth, dirtyHeight) {
1480
+ if (!data?.width || !data?.height) return;
1481
+ const dw = dirtyWidth ?? data.width;
1482
+ const dh = dirtyHeight ?? data.height;
1483
+ this._flush();
1484
+ const savedM = this._m;
1485
+ const savedClip = this._clip;
1486
+ const savedAlpha = this.globalAlpha;
1487
+ this._m = IDENTITY;
1488
+ this._clip = null;
1489
+ this.globalAlpha = 1;
1490
+ // Replace rather than blend, as the spec requires.
1491
+ const gl = this.gl;
1492
+ gl.blendFunc(gl.ONE, gl.ZERO);
1493
+ this.drawImage(
1494
+ data,
1495
+ dirtyX,
1496
+ dirtyY,
1497
+ dw,
1498
+ dh,
1499
+ x + dirtyX,
1500
+ y + dirtyY,
1501
+ dw,
1502
+ dh,
1503
+ );
1504
+ this._flush();
1505
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
1506
+ this._m = savedM;
1507
+ this._clip = savedClip;
1508
+ this.globalAlpha = savedAlpha;
1509
+ }
1510
+
1511
+ /** Read pixels back as straight RGBA, top row first. */
1512
+ getImageData(x, y, w, h) {
1513
+ this._flush();
1514
+ const gl = this.gl;
1515
+ // `readPixels` reads the bound framebuffer, and a blit or another
1516
+ // context may have left it pointing elsewhere since the last draw.
1517
+ this._target?.bind();
1518
+ const W = Math.max(0, w | 0);
1519
+ const H = Math.max(0, h | 0);
1520
+ const raw = new Uint8Array(W * H * 4);
1521
+ if (W && H) {
1522
+ gl.readPixels(
1523
+ x | 0,
1524
+ this._height - (y | 0) - H,
1525
+ W,
1526
+ H,
1527
+ gl.RGBA,
1528
+ gl.UNSIGNED_BYTE,
1529
+ raw,
1530
+ );
1531
+ }
1532
+ const data = new Uint8ClampedArray(W * H * 4);
1533
+ const row = W * 4;
1534
+ for (let r = 0; r < H; r++)
1535
+ data.set(raw.subarray((H - 1 - r) * row, (H - r) * row), r * row);
1536
+ for (let i = 0; i < data.length; i += 4) {
1537
+ const a = data[i + 3];
1538
+ if (a === 0 || a === 255) continue;
1539
+ data[i] = Math.min(255, (data[i] * 255) / a);
1540
+ data[i + 1] = Math.min(255, (data[i + 1] * 255) / a);
1541
+ data[i + 2] = Math.min(255, (data[i + 2] * 255) / a);
1542
+ }
1543
+ return { width: W, height: H, data };
1544
+ }
1545
+
1546
+ createImageData(w, h) {
1547
+ return {
1548
+ width: w | 0,
1549
+ height: h | 0,
1550
+ data: new Uint8ClampedArray((w | 0) * (h | 0) * 4),
1551
+ };
1552
+ }
1553
+
1554
+ /**
1555
+ * A texture for an image-like thing, with its byte layout.
1556
+ *
1557
+ * ntk `Image`s are immutable and uploaded once. `ImageData` is written by
1558
+ * the caller between draws, so its bytes go up every time — into the same
1559
+ * texture when the size has not changed. A render target *is* a texture.
1560
+ */
1561
+ _textureFor(img) {
1562
+ if (!img) return null;
1563
+ const gl = this.gl;
1564
+ const target =
1565
+ img instanceof GLTarget
1566
+ ? img
1567
+ : img.target instanceof GLTarget
1568
+ ? img.target
1569
+ : null;
1570
+ if (target)
1571
+ return {
1572
+ tex: target.texture,
1573
+ w: target.width,
1574
+ h: target.height,
1575
+ swizzle: SWIZZLE_RGBA,
1576
+ flipY: true,
1577
+ };
1578
+
1579
+ const w = img.width | 0;
1580
+ const h = img.height | 0;
1581
+ const data = img.data ?? img.pixels;
1582
+ if (!w || !h || !data) return null;
1583
+ const isNtkImage = img instanceof NtkImage;
1584
+ const swizzle = isNtkImage
1585
+ ? SWIZZLE_BGRA
1586
+ : data instanceof Uint8ClampedArray
1587
+ ? SWIZZLE_STRAIGHT
1588
+ : img.premultiplied === false
1589
+ ? SWIZZLE_STRAIGHT
1590
+ : SWIZZLE_RGBA;
1591
+
1592
+ let entry = this._textures.get(img);
1593
+ const bytes =
1594
+ data instanceof Uint8Array
1595
+ ? data
1596
+ : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
1597
+ if (!entry || entry.w !== w || entry.h !== h) {
1598
+ if (entry) gl.deleteTexture(entry.tex);
1599
+ const tex = gl.createTexture();
1600
+ gl.bindTexture(gl.TEXTURE_2D, tex);
1601
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
1602
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
1603
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
1604
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1605
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1606
+ gl.texImage2D(
1607
+ gl.TEXTURE_2D,
1608
+ 0,
1609
+ gl.RGBA,
1610
+ w,
1611
+ h,
1612
+ 0,
1613
+ gl.RGBA,
1614
+ gl.UNSIGNED_BYTE,
1615
+ bytes,
1616
+ );
1617
+ entry = { tex, w, h, swizzle, flipY: false, immutable: isNtkImage };
1618
+ this._textures.set(img, entry);
1619
+ } else if (!entry.immutable) {
1620
+ this._flush();
1621
+ gl.bindTexture(gl.TEXTURE_2D, entry.tex);
1622
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
1623
+ gl.texSubImage2D(
1624
+ gl.TEXTURE_2D,
1625
+ 0,
1626
+ 0,
1627
+ 0,
1628
+ w,
1629
+ h,
1630
+ gl.RGBA,
1631
+ gl.UNSIGNED_BYTE,
1632
+ bytes,
1633
+ );
1634
+ }
1635
+ return entry;
1636
+ }
1637
+
1638
+ // ---- text -------------------------------------------------------------
1639
+
1640
+ /** ntk's Render extension object, as much of it as glyph runs need. */
1641
+ get Render() {
1642
+ return RENDER;
1643
+ }
1644
+
1645
+ /**
1646
+ * A solid ink for `drawGlyphs`. ntk answers an XRender `Picture` here and
1647
+ * the Cocoa backend the colour itself; this answers the same stand-in
1648
+ * `_stylePicture` builds, so the way ntk documents the call
1649
+ *
1650
+ * ctx.drawGlyphs(ctx.Render.PictOp.Over,
1651
+ * ctx.createSolidPicture(r, g, b, a), runs)
1652
+ *
1653
+ * draws on every backend unchanged. Half the pair — `drawGlyphs` without
1654
+ * this — left every caller that positions its own glyphs (a terminal
1655
+ * grid, a tabular column) feature-detecting, and a feature test that came
1656
+ * back false drew a frame with no text in it rather than throwing (issue
1657
+ * #565).
1658
+ *
1659
+ * The channels are **premultiplied** 0..1, as XRender solids are, which
1660
+ * is the form this context carries colours in — so they pass through
1661
+ * `parseColor` untouched. A channel brighter than the alpha is not a
1662
+ * premultiplied colour; it is clamped down to it, which is the same
1663
+ * colour the Cocoa backend lands on for that input (it un-premultiplies
1664
+ * and clamps to 1).
1665
+ */
1666
+ createSolidPicture(r, g, b, a) {
1667
+ const alpha = clamp01(a);
1668
+ return {
1669
+ color: Object.freeze([
1670
+ Math.min(clamp01(r), alpha),
1671
+ Math.min(clamp01(g), alpha),
1672
+ Math.min(clamp01(b), alpha),
1673
+ alpha,
1674
+ ]),
1675
+ };
1676
+ }
1677
+
1678
+ /**
1679
+ * ntk's glyph-drawing contract, which is how `TextLayout.draw` reaches a
1680
+ * context — and therefore how every `<text>`, `<textinput>` and
1681
+ * `<textarea>` in react-x11 gets on screen.
1682
+ *
1683
+ * The signature is XRender's: a `PictOp`, a source `Picture`, and glyph
1684
+ * runs already placed at absolute baselines. The op is always `Over` for
1685
+ * text and the source is always a solid colour, so what this does is take
1686
+ * the colour back out of the ink handed over (`_inkOf`) and draw the runs
1687
+ * through the atlas.
1688
+ *
1689
+ * @param {number} op ignored — text is always drawn `Over`
1690
+ * @param {object|string|number[]|null} src a `createSolidPicture` ink, a
1691
+ * `_stylePicture` stand-in, a colour, or null for the fill style
1692
+ * @param {Array<{run:object, x:number, y:number}>} positioned
1693
+ */
1694
+ drawGlyphs(op, src, positioned) {
1695
+ if (!Array.isArray(positioned)) return;
1696
+ const color = this._inkOf(src);
1697
+ const runs = [];
1698
+ for (const p of positioned) {
1699
+ const run = p.run;
1700
+ if (!run?.glyphs?.length) continue;
1701
+ // ntk's shaped glyphs carry advances, not positions: the server walked
1702
+ // the run and accumulated them. There is no server, so walk it here.
1703
+ let pen = p.x;
1704
+ const glyphs = [];
1705
+ for (const g of run.glyphs) {
1706
+ glyphs.push({ id: g.id, x: pen + (g.dx ?? 0), y: p.y + (g.dy ?? 0) });
1707
+ pen += g.ax ?? 0;
1708
+ }
1709
+ runs.push({ font: run.font, size: run.size, glyphs, color });
1710
+ }
1711
+ this.drawTextRuns(runs);
1712
+ }
1713
+
1714
+ /** A solid-colour stand-in for the XRender `Picture` a source would be. */
1715
+ _stylePicture(color) {
1716
+ return { color };
1717
+ }
1718
+
1719
+ /**
1720
+ * The colour a `drawGlyphs` source paints with: a `createSolidPicture`
1721
+ * ink or a `_stylePicture` stand-in (both carry `.color`), a CSS colour
1722
+ * string, a premultiplied `[r, g, b, a]` — or, for anything this context
1723
+ * cannot ink glyphs with (a gradient), the fill style in force. The same
1724
+ * set the Cocoa backend takes, so a caller that skips the picture and
1725
+ * names a colour draws here too.
1726
+ */
1727
+ _inkOf(src) {
1728
+ if (typeof src === 'string' || Array.isArray(src)) return src;
1729
+ const color = src?.color;
1730
+ return typeof color === 'string' || Array.isArray(color)
1731
+ ? color
1732
+ : this.fillStyle;
1733
+ }
1734
+
1735
+ get _backgroundPicture() {
1736
+ return { color: this.fillStyle };
1737
+ }
1738
+
1739
+ /** The X11 context tracks dirty regions for its damage; this one does not. */
1740
+ _markDirty() {}
1741
+
1742
+ /**
1743
+ * Draw positioned glyph runs through the atlas.
1744
+ *
1745
+ * Glyph origins are rounded to whole device pixels — the same rule ntk's
1746
+ * layout applies — because a glyph bitmap sampled at a fractional offset
1747
+ * under linear filtering is a blurred glyph.
1748
+ *
1749
+ * @param {Array<{font, size, glyphs:Array<{id,x,y}>, color}>} runs
1750
+ */
1751
+ drawTextRuns(runs) {
1752
+ const m = this._m;
1753
+ for (const run of runs) {
1754
+ const font = run.font;
1755
+ if (!font) continue;
1756
+ const size = run.size ?? font.size ?? 16;
1757
+ const color = this._color(run.color ?? this.fillStyle);
1758
+ if (color[3] === 0) continue;
1759
+ const key = fontKey(font);
1760
+ const tex = this.atlas.bind();
1761
+ this._setMode(MODE_GLYPH, tex, SWIZZLE_RGBA);
1762
+ for (const g of run.glyphs) {
1763
+ const entry = this.atlas.get(`${key}|${size}|${g.id}`, () =>
1764
+ font.rasterize(g.id, size),
1765
+ );
1766
+ if (entry.empty) continue;
1767
+ // `left`/`top` are the bitmap's offset from the glyph origin in the
1768
+ // rasteriser's **y-down** pixel space: a cap-height glyph has a
1769
+ // negative `top` (H at 32px: -24).
1770
+ const ox = Math.round(applyX(m, g.x, g.y)) + entry.left;
1771
+ const oy = Math.round(applyY(m, g.x, g.y)) + entry.top;
1772
+ // the atlas may have grown mid-run; rebind so the batch names the
1773
+ // new texture (the old quads were flushed by onBeforeGrow)
1774
+ if (this.atlas.texture !== this._texture)
1775
+ this._setMode(MODE_GLYPH, this.atlas.bind(), SWIZZLE_RGBA);
1776
+ this._quad(
1777
+ [
1778
+ ox,
1779
+ oy,
1780
+ ox + entry.w,
1781
+ oy,
1782
+ ox + entry.w,
1783
+ oy + entry.h,
1784
+ ox,
1785
+ oy + entry.h,
1786
+ ],
1787
+ [
1788
+ entry.u0,
1789
+ entry.v0,
1790
+ entry.u1,
1791
+ entry.v0,
1792
+ entry.u1,
1793
+ entry.v1,
1794
+ entry.u0,
1795
+ entry.v1,
1796
+ ],
1797
+ color,
1798
+ ZERO4,
1799
+ );
1800
+ this.shapeStats.glyphs++;
1801
+ }
1802
+ // anything rasterised in this run goes up before the batch is drawn
1803
+ if (this.atlas._dirty || this.atlas._fresh) this.atlas.bind();
1804
+ }
1805
+ }
1806
+
1807
+ /** The Font for the current `font` string, or null while it is loading. */
1808
+ _resolveFont() {
1809
+ if (!this.fontManager) return null;
1810
+ if (this._fontFace && this._fontFaceFor === this._font)
1811
+ return this._fontFace;
1812
+ const spec = this._fontSpec;
1813
+ let face = null;
1814
+ try {
1815
+ face = this.fontManager.match({
1816
+ family: spec.family,
1817
+ weight: spec.weight,
1818
+ italic: spec.italic,
1819
+ size: spec.size,
1820
+ });
1821
+ } catch {
1822
+ return null;
1823
+ }
1824
+ if (face && typeof face.then === 'function') {
1825
+ // a face still opening: use it next time
1826
+ face.then(
1827
+ (f) => {
1828
+ if (this._font === spec && f) {
1829
+ this._fontFace = f;
1830
+ this._fontFaceFor = this._font;
1831
+ }
1832
+ },
1833
+ () => {},
1834
+ );
1835
+ return null;
1836
+ }
1837
+ this._fontFace = face;
1838
+ this._fontFaceFor = this._font;
1839
+ return face;
1840
+ }
1841
+
1842
+ /** Shaped text for `fillText`/`measureText`, through ntk's shaping memo. */
1843
+ _shape(text) {
1844
+ const face = this._resolveFont();
1845
+ if (!face) return null;
1846
+ const spec = this._fontSpec;
1847
+ const style = {
1848
+ font: face,
1849
+ family: spec.family,
1850
+ size: spec.size,
1851
+ weight: spec.weight,
1852
+ italic: spec.italic,
1853
+ };
1854
+ const shaped = this.fontManager._shapeCachedWhole(String(text), style);
1855
+ return { shaped, style };
1856
+ }
1857
+
1858
+ fillText(text, x, y) {
1859
+ text = String(text ?? '');
1860
+ if (!text) return;
1861
+ const r = this._shape(text);
1862
+ if (!r) return;
1863
+ const { shaped, style } = r;
1864
+ const metrics = style.font.metrics(style.size);
1865
+ let ax = 0;
1866
+ let align = this.textAlign;
1867
+ if (align === 'start') align = shaped.baseLevel & 1 ? 'right' : 'left';
1868
+ if (align === 'end') align = shaped.baseLevel & 1 ? 'left' : 'right';
1869
+ if (align === 'center') ax = -shaped.width / 2;
1870
+ else if (align === 'right') ax = -shaped.width;
1871
+ let ay = 0;
1872
+ switch (this.textBaseline) {
1873
+ case 'top':
1874
+ ay = metrics.ascent;
1875
+ break;
1876
+ case 'hanging':
1877
+ ay = metrics.ascent * 0.8;
1878
+ break;
1879
+ case 'middle':
1880
+ ay = (metrics.ascent - metrics.descent) / 2;
1881
+ break;
1882
+ case 'bottom':
1883
+ case 'ideographic':
1884
+ ay = -metrics.descent;
1885
+ break;
1886
+ default:
1887
+ ay = 0;
1888
+ }
1889
+ const positioned = [];
1890
+ let cursor = x + ax;
1891
+ for (const run of shaped.runs) {
1892
+ positioned.push({ run, x: cursor, y: y + ay });
1893
+ cursor += run.width;
1894
+ }
1895
+ this.drawGlyphs(3, { color: this.fillStyle }, positioned);
1896
+ }
1897
+
1898
+ strokeText(text, x, y) {
1899
+ // No outline stroking of glyphs; the fill is the honest approximation.
1900
+ const saved = this.fillStyle;
1901
+ this.fillStyle = this.strokeStyle;
1902
+ this.fillText(text, x, y);
1903
+ this.fillStyle = saved;
1904
+ }
1905
+
1906
+ measureText(text) {
1907
+ const r = this._shape(String(text ?? ''));
1908
+ if (!r)
1909
+ return {
1910
+ width: 0,
1911
+ actualBoundingBoxLeft: 0,
1912
+ actualBoundingBoxRight: 0,
1913
+ actualBoundingBoxAscent: 0,
1914
+ actualBoundingBoxDescent: 0,
1915
+ };
1916
+ const { shaped, style } = r;
1917
+ const m = style.font.metrics(style.size);
1918
+ return {
1919
+ width: shaped.width,
1920
+ actualBoundingBoxLeft: 0,
1921
+ actualBoundingBoxRight: shaped.width,
1922
+ actualBoundingBoxAscent: m.ascent,
1923
+ actualBoundingBoxDescent: m.descent,
1924
+ fontBoundingBoxAscent: m.ascent,
1925
+ fontBoundingBoxDescent: m.descent,
1926
+ };
1927
+ }
1928
+
1929
+ // ---- batching ---------------------------------------------------------
1930
+
1931
+ _color(style) {
1932
+ const c = parseColor(style);
1933
+ if (this.globalAlpha >= 1) return c;
1934
+ const a = this.globalAlpha;
1935
+ return [c[0] * a, c[1] * a, c[2] * a, c[3] * a];
1936
+ }
1937
+
1938
+ _setMode(mode, texture, swizzle) {
1939
+ if (
1940
+ mode !== this._mode ||
1941
+ texture !== this._texture ||
1942
+ swizzle !== this._swizzle
1943
+ ) {
1944
+ this._flush();
1945
+ this._mode = mode;
1946
+ this._texture = texture;
1947
+ this._swizzle = swizzle;
1948
+ }
1949
+ }
1950
+
1951
+ /** Two triangles from four corners, in order TL, TR, BR, BL. */
1952
+ _quad(pos, uv, color, params) {
1953
+ if (this._n + 6 > MAX_VERTS) this._flush();
1954
+ const v = this._verts;
1955
+ let o = this._n * STRIDE;
1956
+ for (let k = 0; k < 6; k++) {
1957
+ const i = TRI[k];
1958
+ v[o++] = pos[i * 2];
1959
+ v[o++] = pos[i * 2 + 1];
1960
+ v[o++] = uv[i * 2];
1961
+ v[o++] = uv[i * 2 + 1];
1962
+ v[o++] = color[0];
1963
+ v[o++] = color[1];
1964
+ v[o++] = color[2];
1965
+ v[o++] = color[3];
1966
+ v[o++] = params[0];
1967
+ v[o++] = params[1];
1968
+ v[o++] = params[2];
1969
+ v[o++] = params[3];
1970
+ }
1971
+ this._n += 6;
1972
+ this.shapeStats.quads++;
1973
+ }
1974
+
1975
+ /** One triangle, for the stencil pass. Colour and params are irrelevant. */
1976
+ _tri(x0, y0, x1, y1, x2, y2) {
1977
+ if (this._n + 3 > MAX_VERTS) this._flush();
1978
+ const v = this._verts;
1979
+ let o = this._n * STRIDE;
1980
+ const pts = [x0, y0, x1, y1, x2, y2];
1981
+ for (let k = 0; k < 3; k++) {
1982
+ v[o++] = pts[k * 2];
1983
+ v[o++] = pts[k * 2 + 1];
1984
+ o += 10;
1985
+ }
1986
+ this._n += 3;
1987
+ }
1988
+
1989
+ _flush() {
1990
+ // Before the early return, not after: `clearRect` and `putImageData`
1991
+ // flush, change the blend function and flush again, and the second
1992
+ // flush must not be the one that re-takes the device and puts the
1993
+ // blending back.
1994
+ this._claim();
1995
+ if (this._n === 0) return;
1996
+ const gl = this.gl;
1997
+ const { aPos, aUV, aColor, aParams } = this._loc;
1998
+
1999
+ // Two contexts interleave inside one frame — a window's and the pane a
2000
+ // `<glarea>`'s children are painted on — so the target is bound here,
2001
+ // per batch, rather than trusted to still be bound from `begin()`.
2002
+ this._target?.bind();
2003
+ gl.useProgram(this._program);
2004
+ gl.bindBuffer(gl.ARRAY_BUFFER, this._buffer);
2005
+ gl.bufferSubData(
2006
+ gl.ARRAY_BUFFER,
2007
+ 0,
2008
+ this._verts.subarray(0, this._n * STRIDE),
2009
+ );
2010
+
2011
+ const bytes = STRIDE * 4;
2012
+ gl.enableVertexAttribArray(aPos);
2013
+ gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, bytes, 0);
2014
+ gl.enableVertexAttribArray(aUV);
2015
+ gl.vertexAttribPointer(aUV, 2, gl.FLOAT, false, bytes, 8);
2016
+ gl.enableVertexAttribArray(aColor);
2017
+ gl.vertexAttribPointer(aColor, 4, gl.FLOAT, false, bytes, 16);
2018
+ gl.enableVertexAttribArray(aParams);
2019
+ gl.vertexAttribPointer(aParams, 4, gl.FLOAT, false, bytes, 32);
2020
+
2021
+ gl.uniform1i(this._loc.uMode, this._mode);
2022
+ gl.uniform1i(this._loc.uSwizzle, this._swizzle);
2023
+ if (this._texture) {
2024
+ // Glyphs and masks placed since their atlas was last bound live only
2025
+ // in its CPU copy. A batch can be drawn before the run that placed
2026
+ // them ends — it fills up, or the atlas grows and flushes first — and
2027
+ // then it samples texels those glyphs never reached: garbled text,
2028
+ // kept until that region is next repainted.
2029
+ if (this._texture === this.atlas.texture) this.atlas.bind();
2030
+ else if (this._texture === this.masks.texture) this.masks.bind();
2031
+ gl.activeTexture(gl.TEXTURE0);
2032
+ gl.bindTexture(gl.TEXTURE_2D, this._texture);
2033
+ gl.uniform1i(this._loc.uTex, 0);
2034
+ }
2035
+
2036
+ if (this._clip) {
2037
+ gl.enable(gl.SCISSOR_TEST);
2038
+ // Scissor is y-up from the bottom-left; our clips are y-down. Snap
2039
+ // outward to whole pixels, as XRender's integer clip would.
2040
+ const x0 = Math.floor(this._clip.x);
2041
+ const y0 = Math.floor(this._clip.y);
2042
+ const x1 = Math.ceil(this._clip.x + this._clip.w);
2043
+ const y1 = Math.ceil(this._clip.y + this._clip.h);
2044
+ gl.scissor(
2045
+ x0,
2046
+ Math.max(0, this._height - y1),
2047
+ Math.max(0, x1 - x0),
2048
+ Math.max(0, y1 - y0),
2049
+ );
2050
+ } else {
2051
+ gl.disable(gl.SCISSOR_TEST);
2052
+ }
2053
+
2054
+ if (!this._stencilling) {
2055
+ if (this._clipPath) {
2056
+ gl.enable(gl.STENCIL_TEST);
2057
+ gl.stencilFunc(gl.EQUAL, CLIP_BIT, CLIP_BIT);
2058
+ gl.stencilMask(0);
2059
+ gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP);
2060
+ } else {
2061
+ gl.disable(gl.STENCIL_TEST);
2062
+ }
2063
+ }
2064
+
2065
+ gl.drawArrays(gl.TRIANGLES, 0, this._n);
2066
+ this._n = 0;
2067
+ this._drew = true;
2068
+ this.shapeStats.batches++;
2069
+ }
2070
+
2071
+ _deviceRect(x, y, w, h) {
2072
+ const m = this._m;
2073
+ const x0 = applyX(m, x, y);
2074
+ const y0 = applyY(m, x, y);
2075
+ const x1 = applyX(m, x + w, y + h);
2076
+ const y1 = applyY(m, x + w, y + h);
2077
+ return {
2078
+ x: Math.min(x0, x1),
2079
+ y: Math.min(y0, y1),
2080
+ w: Math.abs(x1 - x0),
2081
+ h: Math.abs(y1 - y0),
2082
+ };
2083
+ }
2084
+
2085
+ destroy() {
2086
+ // Nothing of this context's is installed any more, and the device
2087
+ // record should not be the one thing still holding on to it.
2088
+ this._release();
2089
+ this.atlas.destroy();
2090
+ this.masks.destroy();
2091
+ if (this._buffer) this.gl.deleteBuffer(this._buffer);
2092
+ if (this._program) this.gl.deleteProgram(this._program);
2093
+ this._buffer = null;
2094
+ this._program = null;
2095
+ }
2096
+ }
2097
+
2098
+ const UV_UNIT = Object.freeze([0, 0, 1, 0, 1, 1, 0, 1]);
2099
+ const ZERO4 = Object.freeze([0, 0, 0, 0]);
2100
+
2101
+ // ---- geometry helpers ------------------------------------------------------------
2102
+
2103
+ function intersectRect(a, b) {
2104
+ const x = Math.max(a.x, b.x);
2105
+ const y = Math.max(a.y, b.y);
2106
+ const r = Math.min(a.x + a.w, b.x + b.w);
2107
+ const bt = Math.min(a.y + a.h, b.y + b.h);
2108
+ return { x, y, w: Math.max(0, r - x), h: Math.max(0, bt - y) };
2109
+ }
2110
+
2111
+ function polysBounds(polys) {
2112
+ let x0 = Infinity;
2113
+ let y0 = Infinity;
2114
+ let x1 = -Infinity;
2115
+ let y1 = -Infinity;
2116
+ for (const poly of polys) {
2117
+ const p = poly.pts;
2118
+ for (let i = 0; i < p.length; i += 2) {
2119
+ if (p[i] < x0) x0 = p[i];
2120
+ if (p[i] > x1) x1 = p[i];
2121
+ if (p[i + 1] < y0) y0 = p[i + 1];
2122
+ if (p[i + 1] > y1) y1 = p[i + 1];
2123
+ }
2124
+ }
2125
+ if (!(x1 >= x0 && y1 >= y0)) return null;
2126
+ return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
2127
+ }
2128
+
2129
+ function polysContain(polys, x, y) {
2130
+ let wn = 0;
2131
+ for (const poly of polys) {
2132
+ const p = poly.pts;
2133
+ const n = p.length / 2;
2134
+ for (let i = 0; i < n; i++) {
2135
+ const ax = p[i * 2];
2136
+ const ay = p[i * 2 + 1];
2137
+ const bx = p[((i + 1) % n) * 2];
2138
+ const by = p[((i + 1) % n) * 2 + 1];
2139
+ if (ay <= y) {
2140
+ if (by > y && (bx - ax) * (y - ay) - (x - ax) * (by - ay) > 0) wn++;
2141
+ } else if (by <= y && (bx - ax) * (y - ay) - (x - ax) * (by - ay) < 0)
2142
+ wn--;
2143
+ }
2144
+ }
2145
+ return wn !== 0;
2146
+ }
2147
+
2148
+ /**
2149
+ * Polygons whose non-zero union is the stroke of the given polylines: a
2150
+ * quad per segment, a join polygon per interior vertex (a small circle for
2151
+ * round joins, a bevel triangle otherwise, plus the miter tip when it is
2152
+ * within the limit), and round caps when asked. Every polygon is emitted
2153
+ * with the same orientation so that overlapping pieces add rather than
2154
+ * cancel under the non-zero rule.
2155
+ */
2156
+ function strokePolys(polylines, width, join, cap, miterLimit) {
2157
+ const hw = width / 2;
2158
+ const out = [];
2159
+ const push = (pts) => {
2160
+ // consistent orientation: positive signed area
2161
+ let area = 0;
2162
+ for (let i = 0; i < pts.length; i += 2) {
2163
+ const j = (i + 2) % pts.length;
2164
+ area += pts[i] * pts[j + 1] - pts[j] * pts[i + 1];
2165
+ }
2166
+ if (area < 0) {
2167
+ const rev = [];
2168
+ for (let i = pts.length - 2; i >= 0; i -= 2) rev.push(pts[i], pts[i + 1]);
2169
+ pts = rev;
2170
+ }
2171
+ out.push({ pts, closed: true });
2172
+ };
2173
+ const circle = (cx, cy) => {
2174
+ const n = hw < 2 ? 6 : hw < 6 ? 10 : 16;
2175
+ const pts = [];
2176
+ for (let i = 0; i < n; i++) {
2177
+ const a = (i / n) * Math.PI * 2;
2178
+ pts.push(cx + Math.cos(a) * hw, cy + Math.sin(a) * hw);
2179
+ }
2180
+ push(pts);
2181
+ };
2182
+ for (const line of polylines) {
2183
+ const p = line.pts;
2184
+ let n = p.length / 2;
2185
+ if (n < 2) {
2186
+ if (n === 1 && cap === 'round') circle(p[0], p[1]);
2187
+ continue;
2188
+ }
2189
+ const closed = line.closed;
2190
+ const segs = closed ? n : n - 1;
2191
+ for (let i = 0; i < segs; i++) {
2192
+ const ax = p[i * 2];
2193
+ const ay = p[i * 2 + 1];
2194
+ const j = (i + 1) % n;
2195
+ const bx = p[j * 2];
2196
+ const by = p[j * 2 + 1];
2197
+ let dx = bx - ax;
2198
+ let dy = by - ay;
2199
+ const len = Math.hypot(dx, dy);
2200
+ if (len === 0) continue;
2201
+ dx /= len;
2202
+ dy /= len;
2203
+ const nx = -dy * hw;
2204
+ const ny = dx * hw;
2205
+ let ex = 0;
2206
+ let ey = 0;
2207
+ if (!closed && cap === 'square') {
2208
+ ex = dx * hw;
2209
+ ey = dy * hw;
2210
+ }
2211
+ const sx = i === 0 ? ex : 0;
2212
+ const sy = i === 0 ? ey : 0;
2213
+ const tx = i === segs - 1 ? ex : 0;
2214
+ const ty = i === segs - 1 ? ey : 0;
2215
+ push([
2216
+ ax - sx + nx,
2217
+ ay - sy + ny,
2218
+ bx + tx + nx,
2219
+ by + ty + ny,
2220
+ bx + tx - nx,
2221
+ by + ty - ny,
2222
+ ax - sx - nx,
2223
+ ay - sy - ny,
2224
+ ]);
2225
+ }
2226
+ // joins
2227
+ const first = closed ? 0 : 1;
2228
+ const last = closed ? n - 1 : n - 2;
2229
+ for (let i = first; i <= last; i++) {
2230
+ const cx = p[i * 2];
2231
+ const cy = p[i * 2 + 1];
2232
+ if (join === 'round' || hw <= 1) {
2233
+ circle(cx, cy);
2234
+ continue;
2235
+ }
2236
+ const prev = (i - 1 + n) % n;
2237
+ const next = (i + 1) % n;
2238
+ const ux = cx - p[prev * 2];
2239
+ const uy = cy - p[prev * 2 + 1];
2240
+ const vx = p[next * 2] - cx;
2241
+ const vy = p[next * 2 + 1] - cy;
2242
+ const ul = Math.hypot(ux, uy);
2243
+ const vl = Math.hypot(vx, vy);
2244
+ if (!ul || !vl) continue;
2245
+ const n1x = (-uy / ul) * hw;
2246
+ const n1y = (ux / ul) * hw;
2247
+ const n2x = (-vy / vl) * hw;
2248
+ const n2y = (vx / vl) * hw;
2249
+ const cross = ux * vy - uy * vx;
2250
+ const s = cross > 0 ? -1 : 1; // outer side
2251
+ const o1x = cx + n1x * s;
2252
+ const o1y = cy + n1y * s;
2253
+ const o2x = cx + n2x * s;
2254
+ const o2y = cy + n2y * s;
2255
+ // bevel
2256
+ push([cx, cy, o1x, o1y, o2x, o2y]);
2257
+ if (join === 'miter') {
2258
+ const cosHalf = Math.sqrt((1 + (ux * vx + uy * vy) / (ul * vl)) / 2);
2259
+ if (cosHalf > 1e-3 && 1 / cosHalf <= miterLimit) {
2260
+ const mx = (o1x + o2x) / 2 - cx;
2261
+ const my = (o1y + o2y) / 2 - cy;
2262
+ const ml = Math.hypot(mx, my);
2263
+ if (ml > 1e-6) {
2264
+ const tipLen = hw / cosHalf;
2265
+ const tx = cx + (mx / ml) * tipLen;
2266
+ const ty = cy + (my / ml) * tipLen;
2267
+ push([o1x, o1y, tx, ty, o2x, o2y]);
2268
+ }
2269
+ }
2270
+ }
2271
+ }
2272
+ if (!closed && cap === 'round') {
2273
+ circle(p[0], p[1]);
2274
+ circle(p[(n - 1) * 2], p[(n - 1) * 2 + 1]);
2275
+ }
2276
+ }
2277
+ return out;
2278
+ }
2279
+
2280
+ function compile(gl, type, src, what) {
2281
+ const sh = gl.createShader(type);
2282
+ gl.shaderSource(sh, src);
2283
+ gl.compileShader(sh);
2284
+ if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
2285
+ throw new Error(
2286
+ `${what} shader failed to compile: ${gl.getShaderInfoLog(sh)}`,
2287
+ );
2288
+ }
2289
+ return sh;
2290
+ }