react-x11 2.14.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.14.0",
3
+ "version": "2.15.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
package/src/glnodes.js CHANGED
@@ -272,9 +272,18 @@ export class GlAreaNode extends Node {
272
272
  // (`EventManager._surfaceAt`).
273
273
  this._joinSurfaces();
274
274
  this.gl = wnd.getContext('opengl', config);
275
- // a buffer freed by the display is a frame that can be drawn again
275
+ // A buffer freed by the display is a frame that can be drawn again — for
276
+ // a frame that was waiting for one. The Cocoa surface and the CGL
277
+ // context reopen their gate one display period after *every* swap, not
278
+ // only after a refused one, and a request per reopening turned
279
+ // frameLoop 'demand' into a loop at display rate: a still scene drew
280
+ // every frame, forever.
276
281
  if (typeof this.gl?.onFrameAvailable !== 'undefined') {
277
- this.gl.onFrameAvailable = () => this.requestFrame();
282
+ this.gl.onFrameAvailable = () => {
283
+ if (!this._frameRefused) return;
284
+ this._frameRefused = false;
285
+ this.requestFrame();
286
+ };
278
287
  }
279
288
  // The context is only usable once MakeCurrent has answered, and that is
280
289
  // where a server refusing indirect GLX says so (ntk gives the rejection
@@ -384,7 +393,10 @@ export class GlAreaNode extends Node {
384
393
  // On the direct backend every buffer may still be held by the display,
385
394
  // and drawing into one before it comes back would paint what is on
386
395
  // screen. `onFrameAvailable` asks for this frame again when one frees.
387
- if (direct && gl.canRender && !gl.canRender()) return false;
396
+ if (direct && gl.canRender && !gl.canRender()) {
397
+ this._frameRefused = true;
398
+ return false;
399
+ }
388
400
  // binds this surface — the GPU context is shared between every <glarea>
389
401
  // on the connection — and picks up a resize
390
402
  gl.makeCurrent?.();
package/src/ntk.js CHANGED
@@ -37,13 +37,22 @@ export { default } from 'ntk';
37
37
  * ntk's offscreen `Surface`, on whichever backend `app` is.
38
38
  *
39
39
  * An app that makes its own surfaces answers `createSurface(options)` —
40
- * the Cocoa app does, over a CG bitmap (src/cocoa/surface.js) and an ntk
40
+ * the Cocoa app does, over a CG bitmap (src/cocoa/surface.js), the Wayland
41
+ * app over a GL render target (src/wayland/surface.js) — and an ntk
41
42
  * connection has no such method and gets ntk's pixmap. The result is
42
43
  * whichever implementation answered, not an instance of this class: the
43
44
  * contract is the shape — `width`/`height`, `getContext('2d')`, `render`,
44
45
  * `clear`, `copyWithin`, `destroy`, and `ctx.drawImage(surface, …)` —
45
46
  * (docs/extending.md "Scrolling the pixels, not just the offset"), and
46
47
  * nothing needs `instanceof`.
48
+ *
49
+ * Part of that shape is that a context may be **held**: ntk tells a caller
50
+ * doing many draws to take `getContext('2d')` once rather than one per
51
+ * frame, and that holds on every backend here, including the ones where a
52
+ * surface is a GPU target sharing a device with the window (#566). Draws
53
+ * through a held context land in the surface whenever they are made, and
54
+ * leave nothing for the caller to restore; `render(fn)` is the scoped form,
55
+ * for a caller that wants a frame's clean transform and clip.
47
56
  */
48
57
  export class Surface {
49
58
  constructor(app, options) {
@@ -100,7 +100,8 @@ export function useScale(): number;
100
100
  export interface WindowState {
101
101
  /** This window has the keyboard. */
102
102
  readonly focused: boolean;
103
- /** **The one to branch on**: not minimized, and not fully covered. */
103
+ /** **The one to branch on**: not minimized, not fully covered, and still
104
+ * being given frames to draw. */
104
105
  readonly visible: boolean;
105
106
  /**
106
107
  * Fully covered by other windows — **always false when a compositing
@@ -109,6 +110,18 @@ export interface WindowState {
109
110
  * entirely visible. Prefer `visible`.
110
111
  */
111
112
  readonly obscured: boolean;
113
+ /**
114
+ * The display is still scheduling frames for this window.
115
+ *
116
+ * False on the **Wayland** backend once the compositor has stopped sending
117
+ * frame callbacks — a surface it is not showing gets none, so nothing the
118
+ * app draws reaches the screen until it starts again. It is how that
119
+ * backend answers the question `obscured` answers on a bare X server, it
120
+ * folds into `visible` the same way, and it takes a couple of seconds of
121
+ * silence to be sure. Always true where the backend has no such signal:
122
+ * X11, Cocoa, and the headless mock.
123
+ */
124
+ readonly presenting: boolean;
112
125
  /** `_NET_WM_STATE_HIDDEN`: iconified, or shaded away. */
113
126
  readonly minimized: boolean;
114
127
  /** Both axes. One axis alone shows up in `states`. */
@@ -26,6 +26,7 @@
26
26
  import { EventEmitter } from 'node:events';
27
27
  import { createRequire } from 'node:module';
28
28
  import { WaylandConnection } from './connection.js';
29
+ import { releaseDevice } from './device.js';
29
30
  import { WaylandSeat } from './seat.js';
30
31
  import { WaylandBackendWindow } from './backendwindow.js';
31
32
  import { WaylandSurface } from './surface.js';
@@ -107,6 +108,8 @@ export class WaylandApp extends EventEmitter {
107
108
  this.outputs = null;
108
109
  /** xdg-decoration, layer-shell, shm: null where the compositor has none */
109
110
  this.decorationManager = null;
111
+ /** the older KDE decoration protocol; only bound if the above is missing */
112
+ this.kdeDecorationManager = null;
110
113
  this.layerShell = null;
111
114
  this.shm = null;
112
115
  /** screen capture and the eyedropper (screencopy.js); null on GNOME */
@@ -159,6 +162,13 @@ export class WaylandApp extends EventEmitter {
159
162
  // defers to it: the pin is read here instead, from the same options.
160
163
  await app._beginOutputs(conn, options);
161
164
  app.decorationManager = await conn.bind('zxdg_decoration_manager_v1');
165
+ // The older KDE protocol says the same thing, and some compositors have
166
+ // only that one. Bound where the standard one is missing, which is the
167
+ // only time it would be used — a frame the compositor draws beats one
168
+ // this backend imitates (ssd.js).
169
+ app.kdeDecorationManager = app.decorationManager
170
+ ? null
171
+ : await conn.bind('org_kde_kwin_server_decoration_manager');
162
172
  app.layerShell = await conn.bind('zwlr_layer_shell_v1');
163
173
  app.shm = await conn.bind('wl_shm');
164
174
  app.seat = await WaylandSeat.bind(conn);
@@ -255,6 +265,10 @@ export class WaylandApp extends EventEmitter {
255
265
 
256
266
  /** After an offscreen render, put the window's backing target back. */
257
267
  rebindWindowTarget() {
268
+ // The framebuffer changes here without any 2d context being told, so
269
+ // none of them owns the device's state any more and the next one to
270
+ // draw re-establishes its own (device.js).
271
+ if (this.gl) releaseDevice(this.gl);
258
272
  for (const w of this.windows.values()) {
259
273
  if (w._frameSize) {
260
274
  w.glctx.bindBacking();
@@ -18,12 +18,15 @@
18
18
  // `wl_surface.frame` callback (or paints straight away for the first frame,
19
19
  // which nothing can pace), the callback runs the renderer's paint into the
20
20
  // backing target, and the result is copied to a swapchain buffer and
21
- // committed with the next frame request in the same commit.
21
+ // committed with the next frame request in the same commit. A compositor
22
+ // stops answering those for a surface it is not showing, and the loop then
23
+ // parks with nothing to say for itself — so it is watched (framewatch.js),
24
+ // which is where `presenting` comes from.
22
25
  //
23
- // The frame is drawn here only until a compositor agrees to draw it:
24
- // where xdg-decoration is offered the window asks for server-side
25
- // decorations (ssd.js) and, granted them, switches its own off, which the
26
- // tree sees as the content growing. And a window whose `windowType` is a
26
+ // The frame is drawn here only as a last resort: where either decoration
27
+ // protocol is offered the window asks for server-side decorations (ssd.js)
28
+ // and, granted them, switches its own off, which the tree sees as the
29
+ // content growing. And a window whose `windowType` is a
27
30
  // dock, a wallpaper, a notification or a splash is not a toplevel at all
28
31
  // where the compositor has layer-shell (layershell.js) — same surface, same
29
32
  // loop, no frame.
@@ -33,6 +36,7 @@ import { writeSync, writeFileSync } from 'node:fs';
33
36
  import { snapshotPNG } from './readback.js';
34
37
  import { WaylandWindow, TOPLEVEL_STATE } from './window.js';
35
38
  import { WaylandGLContext } from './glcontext.js';
39
+ import { FrameWatch } from './framewatch.js';
36
40
  import { WaylandContext2D } from './context2d.js';
37
41
  import { Decorations } from './decorations.js';
38
42
  import { appearanceSnapshot } from '../appearance.js';
@@ -122,6 +126,17 @@ export class WaylandBackendWindow extends EventEmitter {
122
126
  this._surfaceRaf = [];
123
127
  /** rects (content px) a surface or pane changed since the last present */
124
128
  this._surfaceDamage = [];
129
+ /**
130
+ * The frame loop's liveness (framewatch.js). A compositor is entitled to
131
+ * stop sending frame callbacks to a surface it is not showing, and that
132
+ * silence is the only thing that says so — it is not an error, and
133
+ * nothing above here can see it (#567).
134
+ */
135
+ this._frameWatch = new FrameWatch({
136
+ id: this.id,
137
+ pending: () => this._hasFrameWork(),
138
+ onChange: (presenting) => this.emit('presenting', presenting),
139
+ });
125
140
 
126
141
  // Frame and content sizes. `attributes.width/height` are what the tree
127
142
  // measured, in device pixels of content; the surface adds the frame.
@@ -206,9 +221,14 @@ export class WaylandBackendWindow extends EventEmitter {
206
221
  width: surfaceW,
207
222
  height: surfaceH,
208
223
  parent: app.parentFor(attributes, { toplevelOnly: true })?.wl ?? null,
209
- decorations: app.decorationManager
210
- ? { manager: app.decorationManager, prefer: this._decorPolicy.prefer }
211
- : null,
224
+ decorations:
225
+ app.decorationManager || app.kdeDecorationManager
226
+ ? {
227
+ manager: app.decorationManager,
228
+ kdeManager: app.kdeDecorationManager,
229
+ prefer: this._decorPolicy.prefer,
230
+ }
231
+ : null,
212
232
  });
213
233
  }
214
234
  this.wl.scale = scale;
@@ -481,6 +501,15 @@ export class WaylandBackendWindow extends EventEmitter {
481
501
  this._armFrame();
482
502
  }
483
503
 
504
+ /** Is there a repaint waiting on the next frame? The three ways there can
505
+ * be one: the renderer's callbacks, a `<glarea>`'s, and the frame the
506
+ * decorations ask for. */
507
+ _hasFrameWork() {
508
+ return (
509
+ this._raf.length > 0 || this._surfaceRaf.length > 0 || this._frameDirty
510
+ );
511
+ }
512
+
484
513
  /**
485
514
  * Get a frame in flight.
486
515
  *
@@ -508,7 +537,14 @@ export class WaylandBackendWindow extends EventEmitter {
508
537
  if (this.isDragPreview) return;
509
538
  // A closed connection arms nothing: a frame request on it rejects at
510
539
  // once, the rejection re-arms, and that loop never yields.
511
- if (this._frameArmed || this._destroyed || this.app.conn?.destroyed) return;
540
+ if (this._destroyed || this.app.conn?.destroyed) return;
541
+ if (this._frameArmed) {
542
+ // The repaint rides the frame already in flight — unless that flight is
543
+ // the compositor's silence, in which case this is the moment the app
544
+ // started drawing for nobody (framewatch.js).
545
+ this._frameWatch.workArrived();
546
+ return;
547
+ }
512
548
  this._frameArmed = true;
513
549
  this.wl.whenConfigured
514
550
  .then(() => {
@@ -522,8 +558,12 @@ export class WaylandBackendWindow extends EventEmitter {
522
558
  }
523
559
  const vsync = this.wl.scheduleFrame();
524
560
  this.wl.surface.$.commit();
561
+ this._frameWatch.waiting();
525
562
  vsync.then(
526
- (t) => this._fireRaf(t),
563
+ (t) => {
564
+ this._frameWatch.arrived();
565
+ this._fireRaf(t);
566
+ },
527
567
  () => this._frameIdle(),
528
568
  );
529
569
  })
@@ -533,11 +573,34 @@ export class WaylandBackendWindow extends EventEmitter {
533
573
  /** The frame is over with nothing presented; run again only if asked. */
534
574
  _frameIdle() {
535
575
  this._frameArmed = false;
576
+ // Nothing is outstanding now — which is not the same as being shown, so
577
+ // the watch stops counting rather than declaring anything.
578
+ this._frameWatch.idle();
536
579
  if (!this._destroyed && (this._raf.length || this._frameDirty)) {
537
580
  this._armFrame();
538
581
  }
539
582
  }
540
583
 
584
+ /**
585
+ * Is the compositor still scheduling frames for this surface?
586
+ *
587
+ * False once a frame callback has gone unanswered for long enough to mean
588
+ * the surface is not being shown (framewatch.js) — the app's own animation
589
+ * and simulation are drawing for nobody until this is true again. The tree
590
+ * reads it through `useWindowState().presenting`, which also folds it into
591
+ * `visible`; this is the window-object half of that.
592
+ */
593
+ get presenting() {
594
+ return this._frameWatch.presenting;
595
+ }
596
+
597
+ /** Subscribe to it. The X11 and Cocoa windows have no such signal, and
598
+ * `windowstate.js` takes the absence of this method as "always true". */
599
+ onPresentingChange(fn) {
600
+ this.on('presenting', fn);
601
+ return () => this.off('presenting', fn);
602
+ }
603
+
541
604
  /** Translate into the content area and clip to it. */
542
605
  _enterContent(ctx) {
543
606
  const o = this.contentOrigin;
@@ -630,6 +693,13 @@ export class WaylandBackendWindow extends EventEmitter {
630
693
  * time — a background fill reaching under a static scene — the surface's
631
694
  * pixels are gone with the repaint, so its node is asked for the frame
632
695
  * now, before the present, rather than showing the fill for a frame.
696
+ *
697
+ * `restoreGLState()` is here for the foreign GL specifically: a
698
+ * `<glarea>` draws with entry points nobody is tracking, so the state it
699
+ * left has to be assumed lost. An *offscreen* surface painted in the
700
+ * middle of the frame needs nothing said about it — its context and this
701
+ * one hand the device back and forth on their own (device.js), so a node
702
+ * that drew into one does not have to know the window has GL state.
633
703
  */
634
704
  _runSurfaces(ctx, time, wholeFrame) {
635
705
  ctx?.flush();
@@ -733,9 +803,14 @@ export class WaylandBackendWindow extends EventEmitter {
733
803
  `${damage === 'all' ? 'full' : damage.length + ' rect(s)'}\n`,
734
804
  );
735
805
  }
806
+ // The frame request `endFrame` put in this commit is the loop's only
807
+ // liveness from here on: the compositor answers it when it next shows
808
+ // this surface, and never if it stops (framewatch.js).
809
+ this._frameWatch.waiting();
736
810
  Promise.resolve(vsync).then(
737
811
  (t) => {
738
812
  this._presentInFlight = false;
813
+ this._frameWatch.arrived();
739
814
  if (!this._destroyed) this._fireRaf(t);
740
815
  },
741
816
  () => {
@@ -1111,6 +1186,7 @@ export class WaylandBackendWindow extends EventEmitter {
1111
1186
  if (this._destroyed) return;
1112
1187
  this._destroyed = true;
1113
1188
  this._raf.length = 0;
1189
+ this._frameWatch.stop();
1114
1190
  this.app.dnd?.detach(this);
1115
1191
  this.app.makeCurrent();
1116
1192
  for (const ctx of this._contexts.values()) ctx.destroy?.();
@@ -66,6 +66,9 @@ export const PROTOCOLS = [
66
66
  'tablet-v2',
67
67
  'xdg-activation-v1',
68
68
  'xdg-decoration-unstable-v1',
69
+ // the older KDE protocol that does the same job, for the compositors that
70
+ // have only that one (ssd.js picks between them)
71
+ 'kde-server-decoration',
69
72
  'primary-selection-unstable-v1',
70
73
  'text-input-unstable-v3',
71
74
  'pointer-constraints-unstable-v1',
@@ -43,6 +43,7 @@ import { createRequire } from 'node:module';
43
43
  import path from 'node:path';
44
44
  import { pathToFileURL } from 'node:url';
45
45
  import { Path2D, Image as NtkImage } from 'ntk';
46
+ import { glDevice } from './device.js';
46
47
  import { GlyphAtlas } from './glyphatlas.js';
47
48
  import { GLTarget } from './target.js';
48
49
 
@@ -203,6 +204,17 @@ function scaleOf(m) {
203
204
  return Math.sqrt(Math.abs(m[0] * m[3] - m[1] * m[2])) || 1;
204
205
  }
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
+
206
218
  const colorCache = new Map();
207
219
 
208
220
  /**
@@ -390,13 +402,20 @@ export class WaylandContext2D {
390
402
  * @param {object} [opts]
391
403
  * @param {object} [opts.fontManager] an ntk FontManager, for `fillText`
392
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
393
409
  */
394
- constructor(gl, { fontManager = null, target = null } = {}) {
410
+ constructor(gl, { fontManager = null, target = null, makeCurrent } = {}) {
395
411
  this.gl = gl;
396
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;
397
416
  // `TextLayout.draw` opens with `ctx.window.app.display.Render` and reads
398
417
  // one constant from it. Satisfy the shape rather than fork the layout.
399
- this.window = { app: { display: { Render: { PictOp: { Over: 3 } } } } };
418
+ this.window = { app: { display: { Render: RENDER } } };
400
419
  this.atlas = new GlyphAtlas(gl);
401
420
  // Quads already buffered against the atlas must be drawn before the
402
421
  // atlas re-lays itself out under them.
@@ -495,8 +514,12 @@ export class WaylandContext2D {
495
514
 
496
515
  /** Where this context draws. A window's backing, or a surface's target. */
497
516
  attach(target) {
498
- if (this._target !== target) this._flush();
517
+ if (this._target === target) return;
518
+ this._flush();
499
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();
500
523
  }
501
524
 
502
525
  get target() {
@@ -509,6 +532,7 @@ export class WaylandContext2D {
509
532
  */
510
533
  begin(width, height, timestamp = 0) {
511
534
  this.init();
535
+ this._device.owner = this;
512
536
  const t = this._target;
513
537
  if (t) {
514
538
  t.bind();
@@ -540,9 +564,11 @@ export class WaylandContext2D {
540
564
  }
541
565
 
542
566
  /**
543
- * The GL state every draw here assumes. `begin()` sets it; a `<glarea>`
544
- * that drew in the middle of the frame may have changed any of it, and
545
- * `restoreGLState()` puts it back without starting a new frame.
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.
546
572
  */
547
573
  _applyState() {
548
574
  const gl = this.gl;
@@ -558,14 +584,8 @@ export class WaylandContext2D {
558
584
  // Premultiplied alpha throughout — colours are premultiplied on the way
559
585
  // in, and the glyph and texture paths both produce premultiplied output.
560
586
  gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
561
- }
562
-
563
- /** After foreign GL drew — a `<glarea>`'s frame — before drawing again. */
564
- restoreGLState() {
565
- const gl = this.gl;
566
- this._flush();
567
- this._applyState();
568
- // …and what `begin()` never had to say, because nothing here changes it
587
+ // …and what a frame of this context's own never has to say, because
588
+ // nothing here changes it. Foreign GL does.
569
589
  if (typeof gl.bindVertexArray === 'function') gl.bindVertexArray(null);
570
590
  gl.blendEquation(gl.FUNC_ADD);
571
591
  gl.stencilMask(0xff);
@@ -574,6 +594,51 @@ export class WaylandContext2D {
574
594
  gl.activeTexture(gl.TEXTURE0);
575
595
  }
576
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
+
577
642
  /** Flush what is buffered without ending the frame. */
578
643
  flush() {
579
644
  this._flush();
@@ -866,8 +931,29 @@ export class WaylandContext2D {
866
931
  this._rect(x, y, w, h, 0, this.strokeStyle, Math.max(0.5, this.lineWidth));
867
932
  }
868
933
 
869
- /** The batched form react-x11 uses for backgrounds and selection bands. */
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
+ */
870
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
+ }
871
957
  for (const r of rects) {
872
958
  const x = r.x ?? r[0];
873
959
  const y = r.y ?? r[1];
@@ -1093,6 +1179,10 @@ export class WaylandContext2D {
1093
1179
  _fillPolys(polys, rule, style) {
1094
1180
  const bounds = polysBounds(polys);
1095
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();
1096
1186
  let color = null;
1097
1187
  if (!(style instanceof Gradient)) {
1098
1188
  color = this._color(style);
@@ -1334,6 +1424,7 @@ export class WaylandContext2D {
1334
1424
  * `(img, sx, sy, sw, sh, dx, dy, dw, dh)`, as canvas.
1335
1425
  */
1336
1426
  drawImage(img, ...args) {
1427
+ this._claim();
1337
1428
  const src = this._textureFor(img);
1338
1429
  if (!src) return;
1339
1430
  let sx = 0;
@@ -1421,6 +1512,9 @@ export class WaylandContext2D {
1421
1512
  getImageData(x, y, w, h) {
1422
1513
  this._flush();
1423
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();
1424
1518
  const W = Math.max(0, w | 0);
1425
1519
  const H = Math.max(0, h | 0);
1426
1520
  const raw = new Uint8Array(W * H * 4);
@@ -1543,6 +1637,44 @@ export class WaylandContext2D {
1543
1637
 
1544
1638
  // ---- text -------------------------------------------------------------
1545
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
+
1546
1678
  /**
1547
1679
  * ntk's glyph-drawing contract, which is how `TextLayout.draw` reaches a
1548
1680
  * context — and therefore how every `<text>`, `<textinput>` and
@@ -1551,16 +1683,17 @@ export class WaylandContext2D {
1551
1683
  * The signature is XRender's: a `PictOp`, a source `Picture`, and glyph
1552
1684
  * runs already placed at absolute baselines. The op is always `Over` for
1553
1685
  * text and the source is always a solid colour, so what this does is take
1554
- * the colour back out of the stand-in `_stylePicture` handed over and draw
1555
- * the runs through the atlas.
1686
+ * the colour back out of the ink handed over (`_inkOf`) and draw the runs
1687
+ * through the atlas.
1556
1688
  *
1557
1689
  * @param {number} op ignored — text is always drawn `Over`
1558
- * @param {{color?:string}} src what `_stylePicture` returned
1690
+ * @param {object|string|number[]|null} src a `createSolidPicture` ink, a
1691
+ * `_stylePicture` stand-in, a colour, or null for the fill style
1559
1692
  * @param {Array<{run:object, x:number, y:number}>} positioned
1560
1693
  */
1561
1694
  drawGlyphs(op, src, positioned) {
1562
1695
  if (!Array.isArray(positioned)) return;
1563
- const color = src?.color ?? this.fillStyle;
1696
+ const color = this._inkOf(src);
1564
1697
  const runs = [];
1565
1698
  for (const p of positioned) {
1566
1699
  const run = p.run;
@@ -1583,6 +1716,22 @@ export class WaylandContext2D {
1583
1716
  return { color };
1584
1717
  }
1585
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
+
1586
1735
  get _backgroundPicture() {
1587
1736
  return { color: this.fillStyle };
1588
1737
  }
@@ -1838,6 +1987,11 @@ export class WaylandContext2D {
1838
1987
  }
1839
1988
 
1840
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();
1841
1995
  if (this._n === 0) return;
1842
1996
  const gl = this.gl;
1843
1997
  const { aPos, aUV, aColor, aParams } = this._loc;
@@ -1929,6 +2083,9 @@ export class WaylandContext2D {
1929
2083
  }
1930
2084
 
1931
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();
1932
2089
  this.atlas.destroy();
1933
2090
  this.masks.destroy();
1934
2091
  if (this._buffer) this.gl.deleteBuffer(this._buffer);