react-x11 2.13.0 → 2.14.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 (63) 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/index.d.ts +5 -0
  6. package/src/nodes/boxpaint.js +9 -0
  7. package/src/nodes/preedit.js +64 -14
  8. package/src/scale.js +52 -22
  9. package/src/screencolor.js +104 -17
  10. package/src/wayland/app.js +560 -0
  11. package/src/wayland/backendwindow.js +1123 -0
  12. package/src/wayland/clipboard.js +326 -0
  13. package/src/wayland/connection.js +482 -0
  14. package/src/wayland/context2d.js +2133 -0
  15. package/src/wayland/decorations.js +476 -0
  16. package/src/wayland/dmabuf.js +89 -0
  17. package/src/wayland/dnd.js +581 -0
  18. package/src/wayland/fdutil.js +108 -0
  19. package/src/wayland/framestyle.js +257 -0
  20. package/src/wayland/glarea.js +371 -0
  21. package/src/wayland/glcontext.js +415 -0
  22. package/src/wayland/glyphatlas.js +237 -0
  23. package/src/wayland/input.js +417 -0
  24. package/src/wayland/keysymnames.js +35 -0
  25. package/src/wayland/layershell.js +363 -0
  26. package/src/wayland/outputs.js +601 -0
  27. package/src/wayland/protocols/cursor-shape-v1.json +1 -0
  28. package/src/wayland/protocols/ext-idle-notify-v1.json +1 -0
  29. package/src/wayland/protocols/ext-image-capture-source-v1.json +1 -0
  30. package/src/wayland/protocols/ext-image-copy-capture-v1.json +1 -0
  31. package/src/wayland/protocols/fractional-scale-v1.json +1 -0
  32. package/src/wayland/protocols/index.json +127 -0
  33. package/src/wayland/protocols/keyboard-shortcuts-inhibit-unstable-v1.json +1 -0
  34. package/src/wayland/protocols/linux-dmabuf-v1.json +1 -0
  35. package/src/wayland/protocols/pointer-constraints-unstable-v1.json +1 -0
  36. package/src/wayland/protocols/presentation-time.json +1 -0
  37. package/src/wayland/protocols/primary-selection-unstable-v1.json +1 -0
  38. package/src/wayland/protocols/relative-pointer-unstable-v1.json +1 -0
  39. package/src/wayland/protocols/tablet-v2.json +1 -0
  40. package/src/wayland/protocols/text-input-unstable-v3.json +1 -0
  41. package/src/wayland/protocols/viewporter.json +1 -0
  42. package/src/wayland/protocols/wayland.json +1 -0
  43. package/src/wayland/protocols/wlr-layer-shell-unstable-v1.json +1 -0
  44. package/src/wayland/protocols/wlr-screencopy-unstable-v1.json +1 -0
  45. package/src/wayland/protocols/xdg-activation-v1.json +1 -0
  46. package/src/wayland/protocols/xdg-decoration-unstable-v1.json +1 -0
  47. package/src/wayland/protocols/xdg-output-unstable-v1.json +1 -0
  48. package/src/wayland/protocols/xdg-shell.json +1 -0
  49. package/src/wayland/protocols/xdg-toplevel-icon-v1.json +1 -0
  50. package/src/wayland/readback.js +99 -0
  51. package/src/wayland/screencopy.js +584 -0
  52. package/src/wayland/seat.js +584 -0
  53. package/src/wayland/shm.js +226 -0
  54. package/src/wayland/ssd.js +106 -0
  55. package/src/wayland/surface.js +123 -0
  56. package/src/wayland/swapchain.js +411 -0
  57. package/src/wayland/tablet.js +522 -0
  58. package/src/wayland/target.js +263 -0
  59. package/src/wayland/text.js +113 -0
  60. package/src/wayland/textinput.js +671 -0
  61. package/src/wayland/touch.js +284 -0
  62. package/src/wayland/window.js +827 -0
  63. package/src/wayland/xkb.js +425 -0
@@ -0,0 +1,415 @@
1
+ // Tier D: a GLES rendering context whose frames reach the compositor as
2
+ // dma-buf, with no pixels on the socket and no CPU rasterisation anywhere.
3
+ //
4
+ // The addon this leans on is misnamed by history. `x11-dri`'s `Gpu` is GBM +
5
+ // EGL + GLES on a DRM render node and never touches X — the X part of the
6
+ // package is the *presentation* half, DRI3 and Present, which is exactly the
7
+ // half Wayland replaces. So the accelerated backend costs a swapchain and
8
+ // nothing else; the GL context, the device selection and the buffer export
9
+ // are already there and already shared with the X11 backend.
10
+ //
11
+ // A frame here has three parts, and the middle one is the renderer's:
12
+ //
13
+ // beginFrame() bind the backing target at the window's buffer size
14
+ // …the 2d context paints into it, partially, as it would into a pixmap…
15
+ // endFrame() copy what changed into the swapchain buffer, swap, and
16
+ // commit — with the configure ack and the frame request in
17
+ // that same commit
18
+ //
19
+ // The backing target (target.js) is what makes partial repaints correct on a
20
+ // rotating swapchain; see swapchain.js for why the copy is sized the way it
21
+ // is. It also means the window's EGL config needs no stencil bits of its own
22
+ // — the target carries them — though they are still requested, cheaply, for
23
+ // anything that draws straight at the swapchain (a `<glarea>`).
24
+
25
+ import fs from 'node:fs';
26
+ import { createRequire } from 'node:module';
27
+ import { WaylandSwapchain } from './swapchain.js';
28
+ import { GLTarget } from './target.js';
29
+
30
+ const require = createRequire(import.meta.url);
31
+
32
+ /**
33
+ * One GPU context per pixel format, not one per surface.
34
+ *
35
+ * An EGL context is expensive — a device, a GBM device, a display, a config
36
+ * scan — and every surface in an app wants the same one; sharing it also
37
+ * means textures and programs are shared between surfaces, as they are
38
+ * between canvases in a browser tab. The cost is that exactly one surface is
39
+ * current at a time, which `beginFrame` takes care of.
40
+ *
41
+ * Kept per x11-dri module, which is one object in practice and a fresh fake
42
+ * in each test.
43
+ */
44
+ const gpuCaches = new WeakMap();
45
+
46
+ function cacheFor(dri) {
47
+ let cache = gpuCaches.get(dri);
48
+ if (!cache) gpuCaches.set(dri, (cache = new Map()));
49
+ return cache;
50
+ }
51
+
52
+ /** `/dev/dri/card*`, in numeric order. */
53
+ export function listCardNodes(readdir = fs.readdirSync) {
54
+ let names;
55
+ try {
56
+ names = readdir('/dev/dri');
57
+ } catch {
58
+ return [];
59
+ }
60
+ return names
61
+ .map((name) => /^card(\d+)$/.exec(name))
62
+ .filter(Boolean)
63
+ .sort((a, b) => Number(a[1]) - Number(b[1]))
64
+ .map((m) => `/dev/dri/${m[0]}`);
65
+ }
66
+
67
+ /**
68
+ * The DRM nodes to try for the shared context, in order; `undefined` is
69
+ * x11-dri's own choice, the first render node.
70
+ *
71
+ * A named device is the only one tried: asking for a device and getting
72
+ * another would be a worse surprise than the error. Otherwise the render
73
+ * node, as ever. A machine with no render node at all — a GitHub-hosted
74
+ * runner, a Hyper-V or VirtualBox guest — can still have a card node Mesa
75
+ * renders on in software, through kms_swrast, so those are tried in turn.
76
+ * With neither, x11-dri's default once more, so its own reason is the one
77
+ * reported.
78
+ */
79
+ export function gpuCandidates({ requested, renderNodes, cardNodes }) {
80
+ if (requested) return [requested];
81
+ if (renderNodes.length) return [undefined];
82
+ return cardNodes.length ? cardNodes : [undefined];
83
+ }
84
+
85
+ function requireDri() {
86
+ try {
87
+ return require('x11-dri');
88
+ } catch (err) {
89
+ throw new Error(
90
+ 'the accelerated backend needs the optional x11-dri addon, which is not installed',
91
+ { cause: err },
92
+ );
93
+ }
94
+ }
95
+
96
+ /**
97
+ * The GPU context for a pixel format, made once and shared.
98
+ *
99
+ * The device is `devicePath` (`glPolicy.devicePath`), else
100
+ * `REACT_X11_GL_DEVICE`, else whatever {@link gpuCandidates} finds. The
101
+ * cache is keyed by what was asked for rather than what was found, so the
102
+ * app's context and every window's resolve to the same one.
103
+ *
104
+ * @param {object} [seams] for the tests: the environment, and the card nodes
105
+ */
106
+ export function sharedGpu(
107
+ dri,
108
+ { format, depthSize, stencilSize, devicePath },
109
+ { env = process.env, cardNodes = listCardNodes } = {},
110
+ ) {
111
+ const requested = devicePath || env.REACT_X11_GL_DEVICE || null;
112
+ const cache = cacheFor(dri);
113
+ const key = `${format}|${depthSize}|${stencilSize}|${requested ?? ''}`;
114
+ const existing = cache.get(key);
115
+ if (existing) return existing;
116
+ const candidates = gpuCandidates({
117
+ requested,
118
+ renderNodes: requested ? [] : dri.listRenderNodes(),
119
+ cardNodes: requested ? [] : cardNodes(),
120
+ });
121
+ const failures = [];
122
+ for (const path of candidates) {
123
+ try {
124
+ const gpu = new dri.Gpu({
125
+ format,
126
+ depthSize,
127
+ stencilSize,
128
+ ...(path ? { devicePath: path } : {}),
129
+ });
130
+ cache.set(key, gpu);
131
+ return gpu;
132
+ } catch (err) {
133
+ failures.push({ path, err });
134
+ }
135
+ }
136
+ const tried = failures
137
+ .map(
138
+ ({ path, err }) => `${path ?? 'the default render node'}: ${err.message}`,
139
+ )
140
+ .join('; ');
141
+ throw new Error(`could not create a GPU context on ${tried}`, {
142
+ cause: failures.at(-1).err,
143
+ });
144
+ }
145
+
146
+ export class WaylandGLContext {
147
+ /**
148
+ * Build the context with nothing to await.
149
+ *
150
+ * Only `zwp_linux_dmabuf_v1` had to be bound asynchronously, and an app
151
+ * binds it once at startup; the GPU context itself never needed the
152
+ * compositor. React's commit phase cannot await, and that is where windows
153
+ * are made.
154
+ */
155
+ static createSync({ conn, window, dmabuf, policy = {} }) {
156
+ const dri = requireDri();
157
+ const probe = dri.probe();
158
+ if (probe.gbm !== true || probe.egl !== true || probe.gles !== true) {
159
+ throw new Error(
160
+ `this machine cannot render on the GPU: gbm=${probe.gbm}, egl=${probe.egl}, gles=${probe.gles}`,
161
+ );
162
+ }
163
+ if (!dmabuf) {
164
+ throw new Error(
165
+ 'this compositor does not advertise zwp_linux_dmabuf_v1, so GPU buffers cannot be handed to it',
166
+ );
167
+ }
168
+ // Alpha by default: client-side decorations want rounded corners, and a
169
+ // fully opaque window says so with `set_opaque_region` instead, which is
170
+ // the cheaper signal anyway.
171
+ const alpha = policy.alpha ?? true;
172
+ const format = alpha ? dri.FORMAT.ARGB8888 : dri.FORMAT.XRGB8888;
173
+ const gpu = sharedGpu(dri, {
174
+ format,
175
+ depthSize: policy.depthSize ?? 16,
176
+ stencilSize: policy.stencilSize ?? 8,
177
+ devicePath: policy.devicePath,
178
+ });
179
+ const chain = new WaylandSwapchain({
180
+ surface: window.surface,
181
+ dmabuf,
182
+ gpu,
183
+ dri,
184
+ format,
185
+ policy,
186
+ });
187
+ return new WaylandGLContext({
188
+ conn,
189
+ window,
190
+ dri,
191
+ gpu,
192
+ chain,
193
+ format,
194
+ policy,
195
+ });
196
+ }
197
+
198
+ /** The asynchronous form, for scripts that are not inside a React commit. */
199
+ static async create({ conn, window, policy = {} }) {
200
+ const dmabuf = await conn.bind('zwp_linux_dmabuf_v1');
201
+ return WaylandGLContext.createSync({ conn, window, dmabuf, policy });
202
+ }
203
+
204
+ constructor({ conn, window, dri, gpu, chain, format, policy = {} }) {
205
+ this.conn = conn;
206
+ this.window = window;
207
+ this.dri = dri;
208
+ this.gpu = gpu;
209
+ this.chain = chain;
210
+ this.format = format;
211
+ this.policy = policy;
212
+ /** The GL entry points — WebGL-shaped and camelCase. */
213
+ this.gl = gpu.gl;
214
+ this.destroyed = false;
215
+ /** the persistent pixels the renderer paints into */
216
+ this.backing = null;
217
+ this._surface = null;
218
+ this._inFrame = false;
219
+ this.lastFramePresented = false;
220
+
221
+ chain.onError = (err) => {
222
+ this.destroyed = true;
223
+ window.emit('error', err);
224
+ };
225
+ }
226
+
227
+ /** What the driver actually gave us, which can exceed what was asked for. */
228
+ get glVersion() {
229
+ return this.gpu.glVersion;
230
+ }
231
+
232
+ get features() {
233
+ return this.gpu.features;
234
+ }
235
+
236
+ get width() {
237
+ return this.backing?.width ?? 0;
238
+ }
239
+
240
+ get height() {
241
+ return this.backing?.height ?? 0;
242
+ }
243
+
244
+ /**
245
+ * Make the context current, size the swapchain and the backing target to
246
+ * the window's buffer size, and bind the target for drawing.
247
+ *
248
+ * The size is taken from the window rather than passed in because the
249
+ * compositor owns it: a configure may have landed between two frames, and
250
+ * drawing at the old size would produce a buffer the compositor stretches.
251
+ *
252
+ * @returns {{width:number, height:number, resized:boolean}|null}
253
+ */
254
+ beginFrame() {
255
+ if (this.destroyed) return null;
256
+ const w = this.window.bufferWidth;
257
+ const h = this.window.bufferHeight;
258
+ const surface = this.chain.surfaceFor(w, h);
259
+ this._surface = surface;
260
+ this.gpu.makeCurrent(surface);
261
+ let resized = false;
262
+ if (!this.backing) {
263
+ this.backing = new GLTarget(this.gl, {
264
+ width: w,
265
+ height: h,
266
+ stencil: true,
267
+ });
268
+ resized = true;
269
+ } else if (this.backing.width !== w || this.backing.height !== h) {
270
+ // Contents are dropped: a resize relayouts and repaints everything
271
+ // upstairs (listeners.js: `invalidate(true, null, 'resize')`).
272
+ this.backing.resize(w, h);
273
+ resized = true;
274
+ }
275
+ this.backing.bind();
276
+ this._inFrame = true;
277
+ return { width: w, height: h, resized };
278
+ }
279
+
280
+ /**
281
+ * Route GL draws at the backing target again — for a `<glarea>` that bound
282
+ * its own framebuffer in between, or anything else that touched the
283
+ * binding.
284
+ */
285
+ bindBacking() {
286
+ this.backing?.bind();
287
+ }
288
+
289
+ /**
290
+ * Finish the frame, hand it over, and hand back the frame clock.
291
+ *
292
+ * Pacing is owned here rather than by the caller because the two have to be
293
+ * atomic and the failure is silent if they are not. A `wl_surface.frame`
294
+ * request is only *delivered* by a commit, so a loop that asks for the
295
+ * callback and then discovers it has nothing to present — every buffer
296
+ * still held by the compositor, which is a normal thing to happen — waits
297
+ * forever on a callback the compositor was never told to schedule. That
298
+ * deadlock is what this method exists to make unrepresentable: the request
299
+ * goes out with the commit, and when there is no frame to show, an empty
300
+ * commit delivers it anyway so the clock keeps ticking and the next frame
301
+ * gets its turn.
302
+ *
303
+ * @param {Array<{x,y,width,height}>|'all'} [damage] what the frame changed,
304
+ * in buffer coordinates. Defaults to everything.
305
+ * @returns {Promise<number>} the frame callback's timestamp, once the
306
+ * compositor says it is time to draw again. An async function cannot
307
+ * hand back a promise unsettled — returning `vsync` makes the caller's
308
+ * `await` wait for it — which is what keeps one present in flight in
309
+ * `_present`, and why a connection closing mid-frame rejects there.
310
+ */
311
+ async endFrame(damage = 'all') {
312
+ // Always a promise for the frame clock, even when there is no frame:
313
+ // callers chain off it unconditionally.
314
+ if (this.destroyed || !this._inFrame)
315
+ return Promise.resolve(Promise.resolve(0));
316
+ this._inFrame = false;
317
+ const gl = this.gl;
318
+ const win = this.window;
319
+
320
+ // The copy: backing -> the swapchain's back buffer, sized by what the
321
+ // next buffer might be missing. Then the GPU work is settled (optionally
322
+ // through a fence rather than a stall) before the swap hands it over.
323
+ const rects = this.chain.blitHint(damage);
324
+ this.backing.blitTo(
325
+ null,
326
+ rects === 'all' ? null : rects,
327
+ this.chain.width,
328
+ this.chain.height,
329
+ );
330
+ await this._settleGpu();
331
+
332
+ // Queued before anything that commits, so one commit carries the frame
333
+ // request, the configure ack, and the buffer.
334
+ const vsync = win.scheduleFrame();
335
+ win.ackPending();
336
+ // Another window's frame may have run in the await above and made its
337
+ // own surface current. eglSwapBuffers on a surface that is not current
338
+ // is EGL_BAD_SURFACE (0x300d): the second window of examples/windows.jsx.
339
+ this.gpu.makeCurrent(this._surface);
340
+ const presented = await this.chain.swap(damage);
341
+ // `swap` attached and damaged but did not commit; and when nothing went
342
+ // out — every buffer busy — the frame request still needs a commit to be
343
+ // delivered. Either way, exactly one commit.
344
+ if (!this.destroyed) win.surface.$.commit();
345
+ if (presented) win.mapped = true;
346
+ this.lastFramePresented = presented;
347
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
348
+ return vsync;
349
+ }
350
+
351
+ /**
352
+ * Wait for the GPU to finish this frame *without* blocking the thread.
353
+ *
354
+ * `eglSwapBuffers` inside `Surface.swap()` waits for the frame's commands
355
+ * to complete, and that wait is a hard stall of the JS thread — measured at
356
+ * ~10ms per frame on this machine's virtio-gpu, and size-independent, so it
357
+ * is round-trip latency to the host rather than fill rate. A stalled thread
358
+ * is a thread not reading input or protocol events.
359
+ *
360
+ * A fence turns that stall into something the event loop can interleave
361
+ * with: signal after the draw commands, then poll with a zero timeout,
362
+ * yielding between polls so input and Wayland events get their turn. The
363
+ * work takes just as long — this trades a blocked thread for a busy one
364
+ * (measured: −0.9% frame rate) — so it is off by default and
365
+ * `glPolicy: { fence: true }` turns it on where responsiveness matters more
366
+ * than a core.
367
+ */
368
+ async _settleGpu() {
369
+ if (!this.policy?.fence) return;
370
+ const gl = this.gl;
371
+ if (!this.gpu.features?.sync) return;
372
+ let sync;
373
+ try {
374
+ sync = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0);
375
+ } catch {
376
+ return;
377
+ }
378
+ if (!sync) return;
379
+ gl.flush();
380
+ try {
381
+ for (let spins = 0; spins < 10000; spins++) {
382
+ const r = gl.clientWaitSync(sync, 0, 0);
383
+ if (r === gl.ALREADY_SIGNALED || r === gl.CONDITION_SATISFIED) return;
384
+ if (r !== gl.TIMEOUT_EXPIRED) return;
385
+ await new Promise((resolve) => setImmediate(resolve));
386
+ }
387
+ } finally {
388
+ gl.deleteSync(sync);
389
+ }
390
+ }
391
+
392
+ canRender() {
393
+ return this.chain.canRender();
394
+ }
395
+
396
+ set onReady(fn) {
397
+ this.chain.onReady = fn;
398
+ }
399
+
400
+ destroy() {
401
+ if (this.destroyed) return;
402
+ this.destroyed = true;
403
+ if (this._surface) {
404
+ try {
405
+ this.gpu.makeCurrent(this._surface);
406
+ this.backing?.destroy();
407
+ } catch {
408
+ /* context already gone */
409
+ }
410
+ }
411
+ this.backing = null;
412
+ this.chain.destroy();
413
+ // The Gpu is shared; it outlives any one context.
414
+ }
415
+ }
@@ -0,0 +1,237 @@
1
+ // The client-side glyph cache — the thing XRender's `GlyphSet` used to be.
2
+ //
3
+ // On X11 a glyph is uploaded once into a server-resident glyph set and then
4
+ // named by id in a `CompositeGlyphs` request; the server owns the pixels and
5
+ // the client never sees them again. There is no such thing here. A Wayland
6
+ // client rasterises its own glyphs and composites them itself, so the cache
7
+ // has to live on this side of the socket — and for Tier D specifically, in
8
+ // GPU memory, as a texture that quads sample from.
9
+ //
10
+ // The rasteriser is ntk's own (`Font.rasterize` -> A8 coverage), which is the
11
+ // part of the text stack most worth keeping: shaping, bidi and line breaking
12
+ // stay exactly as they are on X11, and only the final composite changes.
13
+ //
14
+ // Packing is a shelf: glyphs arrive in no useful order and are never freed
15
+ // individually, so the classic row-by-row placement wastes little and costs
16
+ // nothing to maintain. When a shelf will not take the glyph the atlas grows
17
+ // by doubling, which invalidates every entry — the context is told first, so
18
+ // quads already buffered against the old layout are drawn before it changes
19
+ // under them (see `onBeforeGrow`).
20
+ //
21
+ // Uploads are deferred and partial: new glyphs land in a CPU copy and the
22
+ // dirty rectangle goes up with one `texSubImage2D` when the texture is next
23
+ // bound. The first version of this file re-uploaded the whole atlas per new
24
+ // glyph, which at 4096² is 16MB per character of a paragraph's first paint.
25
+
26
+ const INITIAL_SIZE = 512;
27
+ const MAX_SIZE = 4096;
28
+ /** Keep glyphs from bleeding into each other under linear filtering. */
29
+ const PAD = 1;
30
+
31
+ export class GlyphAtlas {
32
+ constructor(gl, { size = INITIAL_SIZE } = {}) {
33
+ this.gl = gl;
34
+ this.size = size;
35
+ /** key -> { x, y, w, h, left, top, u0, v0, u1, v1 } */
36
+ this.entries = new Map();
37
+ this._shelfY = PAD;
38
+ this._shelfH = 0;
39
+ this._shelfX = PAD;
40
+ this._pixels = new Uint8Array(size * size);
41
+ this.texture = null;
42
+ /** dirty rectangle of the CPU copy not yet uploaded, or null */
43
+ this._dirty = null;
44
+ this._fresh = true; // the GPU texture needs a full upload
45
+ this.generation = 0;
46
+ /** called before the atlas grows, so pending quads can be flushed */
47
+ this.onBeforeGrow = null;
48
+ }
49
+
50
+ /**
51
+ * Find or make room for one rasterised glyph.
52
+ *
53
+ * @param {string} key identifies the glyph *and* the size it was rasterised
54
+ * at — a 12px 'a' and a 24px 'a' are different pixels and share nothing.
55
+ * @param {() => {width,height,left,top,data}} raster called only on a miss,
56
+ * so a hit never touches the font machinery
57
+ */
58
+ get(key, raster) {
59
+ const hit = this.entries.get(key);
60
+ if (hit) return hit;
61
+
62
+ const g = raster();
63
+ // A space has no ink. Cache the absence so the miss is paid once.
64
+ if (!g || g.width <= 0 || g.height <= 0) {
65
+ const empty = { empty: true, left: g?.left ?? 0, top: g?.top ?? 0 };
66
+ this.entries.set(key, empty);
67
+ return empty;
68
+ }
69
+
70
+ const spot = this._place(g.width, g.height);
71
+ if (!spot) return { empty: true, left: g.left, top: g.top };
72
+
73
+ for (let row = 0; row < g.height; row++) {
74
+ const src = row * g.width;
75
+ const dst = (spot.y + row) * this.size + spot.x;
76
+ this._pixels.set(g.data.subarray(src, src + g.width), dst);
77
+ }
78
+ this._markDirty(spot.x, spot.y, g.width, g.height);
79
+
80
+ const inv = 1 / this.size;
81
+ const entry = {
82
+ empty: false,
83
+ x: spot.x,
84
+ y: spot.y,
85
+ w: g.width,
86
+ h: g.height,
87
+ left: g.left,
88
+ top: g.top,
89
+ u0: spot.x * inv,
90
+ v0: spot.y * inv,
91
+ u1: (spot.x + g.width) * inv,
92
+ v1: (spot.y + g.height) * inv,
93
+ };
94
+ this.entries.set(key, entry);
95
+ return entry;
96
+ }
97
+
98
+ _markDirty(x, y, w, h) {
99
+ const d = this._dirty;
100
+ if (!d) {
101
+ this._dirty = { x0: x, y0: y, x1: x + w, y1: y + h };
102
+ return;
103
+ }
104
+ d.x0 = Math.min(d.x0, x);
105
+ d.y0 = Math.min(d.y0, y);
106
+ d.x1 = Math.max(d.x1, x + w);
107
+ d.y1 = Math.max(d.y1, y + h);
108
+ }
109
+
110
+ _place(w, h) {
111
+ if (w + PAD * 2 > this.size || h + PAD * 2 > this.size) {
112
+ return this._grow() ? this._place(w, h) : null;
113
+ }
114
+ if (this._shelfX + w + PAD > this.size) {
115
+ this._shelfY += this._shelfH + PAD;
116
+ this._shelfH = 0;
117
+ this._shelfX = PAD;
118
+ }
119
+ if (this._shelfY + h + PAD > this.size) {
120
+ return this._grow() ? this._place(w, h) : null;
121
+ }
122
+ const spot = { x: this._shelfX, y: this._shelfY };
123
+ this._shelfX += w + PAD;
124
+ if (h > this._shelfH) this._shelfH = h;
125
+ return spot;
126
+ }
127
+
128
+ /**
129
+ * Double the atlas and start over.
130
+ *
131
+ * Entries are dropped rather than copied: they only remember where their
132
+ * pixels went, and a repack at this frequency is not worth keeping a second
133
+ * copy of every glyph. Anyone holding an entry across this call has a
134
+ * stale one, which is why `onBeforeGrow` exists.
135
+ */
136
+ _grow() {
137
+ if (this.size >= MAX_SIZE) return false;
138
+ this.onBeforeGrow?.();
139
+ this.size *= 2;
140
+ this._pixels = new Uint8Array(this.size * this.size);
141
+ this.entries.clear();
142
+ this._shelfX = PAD;
143
+ this._shelfY = PAD;
144
+ this._shelfH = 0;
145
+ this._dirty = null;
146
+ this._fresh = true;
147
+ this.generation++;
148
+ if (this.texture) {
149
+ this.gl.deleteTexture(this.texture);
150
+ this.texture = null;
151
+ }
152
+ return true;
153
+ }
154
+
155
+ /**
156
+ * Forget every entry, keeping the texture and its size — for an atlas of
157
+ * transient masks, emptied at the start of each frame. Old pixels stay in
158
+ * the CPU copy until new entries overwrite them; nothing names them.
159
+ */
160
+ reset() {
161
+ if (this.entries.size === 0) return;
162
+ this.entries.clear();
163
+ this._shelfX = PAD;
164
+ this._shelfY = PAD;
165
+ this._shelfH = 0;
166
+ this._dirty = null;
167
+ this.generation++;
168
+ }
169
+
170
+ /** Make the atlas current on the active texture unit, uploading anything new. */
171
+ bind() {
172
+ const gl = this.gl;
173
+ if (!this.texture) {
174
+ this.texture = gl.createTexture();
175
+ gl.bindTexture(gl.TEXTURE_2D, this.texture);
176
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
177
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
178
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
179
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
180
+ this._fresh = true;
181
+ } else {
182
+ gl.bindTexture(gl.TEXTURE_2D, this.texture);
183
+ }
184
+ // One byte of coverage per texel, as R8 — GLES 3's own single-channel
185
+ // format — rather than the legacy ALPHA, which drivers emulate. On
186
+ // virgl the emulation let an upload into the mask atlas reach what the
187
+ // glyph atlas sampled: text drawn after a frame's coverage masks showed
188
+ // pieces of them (a select's chevron where a title's "R" belonged).
189
+ // A8 rows are tightly packed and almost never a multiple of 4.
190
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
191
+ if (this._fresh) {
192
+ gl.texImage2D(
193
+ gl.TEXTURE_2D,
194
+ 0,
195
+ gl.R8,
196
+ this.size,
197
+ this.size,
198
+ 0,
199
+ gl.RED,
200
+ gl.UNSIGNED_BYTE,
201
+ this._pixels,
202
+ );
203
+ this._fresh = false;
204
+ this._dirty = null;
205
+ } else if (this._dirty) {
206
+ const { x0, y0, x1, y1 } = this._dirty;
207
+ const w = x1 - x0;
208
+ const h = y1 - y0;
209
+ // texSubImage2D wants the rows contiguous; a sub-rectangle of the CPU
210
+ // copy is not, so gather them. Small — a frame's worth of new glyphs.
211
+ const rows = new Uint8Array(w * h);
212
+ for (let r = 0; r < h; r++) {
213
+ const src = (y0 + r) * this.size + x0;
214
+ rows.set(this._pixels.subarray(src, src + w), r * w);
215
+ }
216
+ gl.texSubImage2D(
217
+ gl.TEXTURE_2D,
218
+ 0,
219
+ x0,
220
+ y0,
221
+ w,
222
+ h,
223
+ gl.RED,
224
+ gl.UNSIGNED_BYTE,
225
+ rows,
226
+ );
227
+ this._dirty = null;
228
+ }
229
+ return this.texture;
230
+ }
231
+
232
+ destroy() {
233
+ if (this.texture) this.gl.deleteTexture(this.texture);
234
+ this.texture = null;
235
+ this.entries.clear();
236
+ }
237
+ }