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.
@@ -0,0 +1,55 @@
1
+ // Who owns the GL state right now.
2
+ //
3
+ // Every 2d context on this backend draws through one GLES context: a
4
+ // window's, the pane a `<glarea>`'s children are painted on, and each
5
+ // offscreen surface's are all the same device, differing only in which
6
+ // framebuffer they bind and what they left the blend, scissor and stencil
7
+ // state saying. That is unlike every other backend react-x11 has — an
8
+ // XRender context is an encoder aimed at a drawable, a Cocoa one is a CGL
9
+ // bitmap that carries its own state — and it is why ntk's advice to hold a
10
+ // surface's context and draw into it whenever you like did not hold here:
11
+ // a held context's draws landed wherever the *last* context had pointed the
12
+ // device (issue #566).
13
+ //
14
+ // So the device gets an owner. A context claims it before it touches GL,
15
+ // and claiming is where the target is bound and the state it assumes is put
16
+ // back. Whoever drew in between does not have to know who comes next, and a
17
+ // caller does not have to know there is a device at all.
18
+ //
19
+ // One record per `gl`, keyed weakly: a GPU that goes away takes its record
20
+ // with it.
21
+ const devices = new WeakMap();
22
+
23
+ /** Whether this `gl` can key the table — a context may be built without one. */
24
+ const shared = (gl) =>
25
+ gl !== null && (typeof gl === 'object' || typeof gl === 'function');
26
+
27
+ /**
28
+ * The record for a device. `owner` is the context whose state is installed,
29
+ * or null when something outside this bookkeeping — foreign GL in a
30
+ * `<glarea>`, a framebuffer blit — has had its way with it.
31
+ *
32
+ * A context built with no GL behind it — `new WaylandContext2D(null)`, the
33
+ * seams above the first draw that `test/wayland/pure.test.js` and
34
+ * `glyph-runs.test.js` exercise — shares nothing, and gets a record of its
35
+ * own rather than an error.
36
+ *
37
+ * @param {object} gl the GLES entry points
38
+ * @returns {{ owner: object|null }}
39
+ */
40
+ export function glDevice(gl) {
41
+ if (!shared(gl)) return { owner: null };
42
+ let device = devices.get(gl);
43
+ if (!device) devices.set(gl, (device = { owner: null }));
44
+ return device;
45
+ }
46
+
47
+ /**
48
+ * Say that the state no longer belongs to whoever last claimed it, so the
49
+ * next context to draw re-establishes its own. Call after touching GL
50
+ * behind the contexts' backs.
51
+ */
52
+ export function releaseDevice(gl) {
53
+ const device = shared(gl) ? devices.get(gl) : null;
54
+ if (device) device.owner = null;
55
+ }
@@ -0,0 +1,190 @@
1
+ // Noticing that the compositor has stopped showing this surface.
2
+ //
3
+ // After the first present the frame loop has exactly one source of liveness:
4
+ // `wl_surface.frame`, which a compositor is entitled never to send to a
5
+ // surface it is not showing — occluded, on another workspace, minimised, the
6
+ // screen locked. That is the right behaviour to drive a client with (drawing
7
+ // into a surface nobody sees is work for nothing) and it has one hole: a
8
+ // callback that never fires is not an error. Nothing rejects, nothing throws,
9
+ // the frame stays armed, and every later `_armFrame()` returns at its guard.
10
+ // The app above goes on re-rendering into a tree that reaches no screen, and
11
+ // from outside the process that is indistinguishable from a hang (#567).
12
+ //
13
+ // So the loop is watched. A frame callback outstanding longer than
14
+ // `REACT_X11_WAYLAND_FRAME_STALL_MS` (2s; `0` turns the watch off) is taken
15
+ // as "the compositor has parked us", which is
16
+ //
17
+ // - **said once on stderr, in development**, when the window still had
18
+ // something to draw — the line that the debugging session behind #567
19
+ // would have ended at; and
20
+ // - **published**, as `useWindowState().presenting`, so an app can stop
21
+ // animating and simulating instead of feeding a loop nobody is turning.
22
+ //
23
+ // Nothing here paces or re-arms anything: the parked callback is still queued
24
+ // with the compositor and fires the moment the surface is shown again, which
25
+ // is what un-parks the loop and flips `presenting` back. A timer that painted
26
+ // anyway would be drawing into a surface nobody sees, which is the thing the
27
+ // frame callback is right about.
28
+
29
+ import { writeSync } from 'node:fs';
30
+
31
+ const DEV = process.env.NODE_ENV !== 'production';
32
+
33
+ /** How long a frame callback may be outstanding before the loop counts as
34
+ * parked, in milliseconds. `0` — or anything that is not a number — turns
35
+ * the watch off entirely: no timer, no warning, `presenting` always true. */
36
+ export const FRAME_STALL_MS = (() => {
37
+ const raw = process.env.REACT_X11_WAYLAND_FRAME_STALL_MS;
38
+ if (raw === undefined || raw === '') return 2000;
39
+ const ms = Number(raw);
40
+ return Number.isFinite(ms) && ms >= 0 ? ms : 0;
41
+ })();
42
+
43
+ /**
44
+ * The one line. It names the three things the symptom does not: that this is
45
+ * the compositor's decision and not a crash, that the window is the thing to
46
+ * look at, and where to read the state from in code.
47
+ */
48
+ export function stallMessage(id, ms) {
49
+ return (
50
+ `react-x11 (wayland): window ${id} has had a frame callback outstanding ` +
51
+ `for ${(ms / 1000).toFixed(1)}s with a repaint waiting — the compositor ` +
52
+ 'is not showing this surface (covered, on another workspace, minimised, ' +
53
+ 'or the screen is locked), so nothing this app draws will reach the ' +
54
+ 'screen until it does. The app is not hung. Read it in the tree as ' +
55
+ 'useWindowState().presenting, which is false until the next frame ' +
56
+ 'callback arrives. Said once per window; ' +
57
+ 'REACT_X11_WAYLAND_FRAME_STALL_MS=0 turns it off.\n'
58
+ );
59
+ }
60
+
61
+ // Synchronous, like the frame trace in backendwindow.js and for the same
62
+ // reason: Bun buffers `process.stderr` to a file, and a buffer is what a
63
+ // SIGINT leaves behind — which is exactly how a run that looks hung ends.
64
+ const toStderr = (message) => writeSync(2, message);
65
+
66
+ /**
67
+ * One window's frame-callback liveness.
68
+ *
69
+ * Three calls from the frame loop, and they are the whole interface:
70
+ * `waiting()` when a `wl_surface.frame` has been asked for, `arrived()` when
71
+ * one comes back, `idle()` when the loop stops waiting for one at all (a
72
+ * rejected request, a closing connection). `presenting` is the answer, and
73
+ * `onChange` fires only when it turns over.
74
+ *
75
+ * The timers are injected so the policy above can be tested without a
76
+ * compositor, a GPU, or two seconds of wall clock.
77
+ */
78
+ export class FrameWatch {
79
+ /**
80
+ * @param {object} opts
81
+ * @param {number|string} [opts.id] the window's id, for the message
82
+ * @param {number} [opts.stallMs] how long is too long
83
+ * @param {() => boolean} [opts.pending] is a repaint waiting? — only then
84
+ * is the silence worth a line on stderr
85
+ * @param {(presenting: boolean) => void} [opts.onChange]
86
+ * @param {(message: string) => void} [opts.warn]
87
+ * @param {boolean} [opts.dev] whether to warn at all
88
+ */
89
+ constructor({
90
+ id = 0,
91
+ stallMs = FRAME_STALL_MS,
92
+ pending = () => false,
93
+ onChange = () => {},
94
+ warn = toStderr,
95
+ dev = DEV,
96
+ setTimer = (fn, ms) => {
97
+ const t = setTimeout(fn, ms);
98
+ // a diagnostic must never be the reason a process stays up
99
+ t.unref?.();
100
+ return t;
101
+ },
102
+ clearTimer = (t) => clearTimeout(t),
103
+ } = {}) {
104
+ this.id = id;
105
+ this.stallMs = stallMs;
106
+ this.presenting = true;
107
+ this._pending = pending;
108
+ this._onChange = onChange;
109
+ this._warn = warn;
110
+ this._dev = dev;
111
+ this._setTimer = setTimer;
112
+ this._clearTimer = clearTimer;
113
+ this._timer = null;
114
+ this._warned = false;
115
+ this._stopped = false;
116
+ }
117
+
118
+ /** A frame callback has been asked for. */
119
+ waiting() {
120
+ // An already-running timer is left alone: it measures the age of the
121
+ // oldest outstanding request, which is the one that matters.
122
+ if (this._stopped || this._timer || !(this.stallMs > 0)) return;
123
+ this._timer = this._setTimer(() => {
124
+ this._timer = null;
125
+ this._stall();
126
+ }, this.stallMs);
127
+ }
128
+
129
+ /** A frame callback arrived: the compositor is showing this surface. */
130
+ arrived() {
131
+ this._disarm();
132
+ this._publish(true);
133
+ }
134
+
135
+ /** Nothing is outstanding — a rejected request, a frame that drew nothing
136
+ * and asked for no other. Says nothing about whether we are being shown. */
137
+ idle() {
138
+ this._disarm();
139
+ }
140
+
141
+ /**
142
+ * A repaint was asked for while a frame was already in flight.
143
+ *
144
+ * There is nothing to arm — the flight is armed, and the request will ride
145
+ * it — but if that flight is the compositor's silence then this is the
146
+ * moment the app starts drawing for nobody, and it is the other order the
147
+ * bug arrives in: a window can park while it has nothing to draw (nothing
148
+ * to warn about) and be given work a minute later, by which time the
149
+ * deadline has come and gone.
150
+ */
151
+ workArrived() {
152
+ if (this._stopped || this.presenting) return;
153
+ this._maybeWarn();
154
+ }
155
+
156
+ /** The window is going away. */
157
+ stop() {
158
+ this._stopped = true;
159
+ this._disarm();
160
+ }
161
+
162
+ _disarm() {
163
+ if (this._timer === null) return;
164
+ this._clearTimer(this._timer);
165
+ this._timer = null;
166
+ }
167
+
168
+ _stall() {
169
+ if (this._stopped) return;
170
+ this._maybeWarn();
171
+ this._publish(false);
172
+ }
173
+
174
+ /**
175
+ * The line, once per window, and only where the app had something to draw:
176
+ * a window that is simply idle is not being let down by anyone, and a
177
+ * warning there would fire on every app that comes to rest.
178
+ */
179
+ _maybeWarn() {
180
+ if (!this._dev || this._warned || !this._pending()) return;
181
+ this._warned = true;
182
+ this._warn(stallMessage(this.id, this.stallMs));
183
+ }
184
+
185
+ _publish(presenting) {
186
+ if (this.presenting === presenting) return;
187
+ this.presenting = presenting;
188
+ this._onChange(presenting);
189
+ }
190
+ }
@@ -336,6 +336,7 @@ export class WaylandOverlayPane {
336
336
  this._ctx = new WaylandContext2D(this.app.gl, {
337
337
  fontManager: this.app.fonts,
338
338
  target: this.target,
339
+ makeCurrent: () => this.app.makeCurrent(),
339
340
  });
340
341
  this._ctx.init();
341
342
  this._ctx.begin(this.target.width, this.target.height);
@@ -78,6 +78,10 @@
78
78
  "zxdg_decoration_manager_v1",
79
79
  "zxdg_toplevel_decoration_v1"
80
80
  ],
81
+ "kde-server-decoration": [
82
+ "org_kde_kwin_server_decoration_manager",
83
+ "org_kde_kwin_server_decoration"
84
+ ],
81
85
  "primary-selection-unstable-v1": [
82
86
  "zwp_primary_selection_device_manager_v1",
83
87
  "zwp_primary_selection_device_v1",
@@ -0,0 +1 @@
1
+ [{"name":"org_kde_kwin_server_decoration_manager","version":1,"requests":[{"name":"create","args":[{"name":"id","type":"new_id","interface":"org_kde_kwin_server_decoration"},{"name":"surface","type":"object","interface":"wl_surface"}]}],"events":[{"name":"default_mode","args":[{"name":"mode","type":"uint"}]}],"enums":{"mode":{"entries":[{"name":"none","value":0},{"name":"client","value":1},{"name":"server","value":2}]}}},{"name":"org_kde_kwin_server_decoration","version":1,"requests":[{"name":"release","type":"destructor","args":[]},{"name":"request_mode","args":[{"name":"mode","type":"uint"}]}],"events":[{"name":"mode","args":[{"name":"mode","type":"uint"}]}],"enums":{"mode":{"entries":[{"name":"none","value":0},{"name":"client","value":1},{"name":"server","value":2}]}}}]
@@ -1,4 +1,5 @@
1
- // Server-side decorations, through xdg-decoration.
1
+ // Server-side decorations: asking the compositor to draw the frame, and
2
+ // falling back to drawing it ourselves only when nothing will.
2
3
  //
3
4
  // The compositor may draw the frame — wlroots compositors and KDE do, when
4
5
  // asked, and GNOME does not advertise the protocol at all — so "who draws the
@@ -11,6 +12,25 @@
11
12
  // client_side — which is the state the backend was already in, so
12
13
  // `decorations.js` stays and is switched off rather than removed.
13
14
  //
15
+ // ## Two protocols say the same thing
16
+ //
17
+ // `xdg-decoration-unstable-v1` is the one to ask with, and where it is
18
+ // offered it is the only one used. But it arrived late (2018) and KDE had
19
+ // shipped `org_kde_kwin_server_decoration` years before, so a compositor may
20
+ // advertise only the older one — KWin still carries both, and a handful of
21
+ // smaller compositors never picked up the xdg protocol. Asking with whatever
22
+ // is there is the difference between a real titlebar and an imitation, so
23
+ // `createServerDecoration` tries them in that order and only gives up on
24
+ // server-side when neither global exists.
25
+ //
26
+ // The KDE protocol is the same conversation in different words: it hangs off
27
+ // the `wl_surface` rather than the `xdg_toplevel`, its enum has a third value
28
+ // (`none`, no frame at all), its destructor is `release`, and — the one
29
+ // difference that matters — its `mode` event is not part of a configure
30
+ // sequence. Nothing promises a configure will follow it, so that driver
31
+ // flushes itself (`flushesOutsideConfigure`); the xdg one keeps the stricter
32
+ // discipline below.
33
+ //
14
34
  // The mode is adopted, not applied on arrival, for the reason the configure
15
35
  // ack is deferred (window.js): a mode change is a size change — the surface
16
36
  // loses or gains its own titlebar — and the frame that adopts it has to be
@@ -22,6 +42,15 @@ import { EventEmitter } from 'node:events';
22
42
  /** `zxdg_toplevel_decoration_v1.mode`. */
23
43
  export const DECORATION_MODE = { CLIENT_SIDE: 1, SERVER_SIDE: 2 };
24
44
 
45
+ /**
46
+ * `org_kde_kwin_server_decoration.mode`. The two that overlap with
47
+ * xdg-decoration share its numbering; `NONE` — no frame from either side —
48
+ * has no xdg equivalent and is only ever received, never asked for. A window
49
+ * that wants no frame asks for `CLIENT` and then draws nothing, which is the
50
+ * same picture and one less thing for `decorationPolicy` to express.
51
+ */
52
+ export const KDE_DECORATION_MODE = { NONE: 0, CLIENT: 1, SERVER: 2 };
53
+
25
54
  /**
26
55
  * What `decorations` means, at the app (`createRoot({ decorations })`) and
27
56
  * per window (`<window decorations={false}>`, the one value the tree passes):
@@ -45,7 +74,63 @@ export function decorationPolicy(appOption, windowOption) {
45
74
  return { draw, prefer };
46
75
  }
47
76
 
48
- export class ServerDecoration extends EventEmitter {
77
+ /**
78
+ * What both protocols have in common: a mode that arrives from the
79
+ * compositor, is held until the frame that can paint it adopts it, and an
80
+ * object that has to be destroyed before the thing it decorates.
81
+ */
82
+ class Decoration extends EventEmitter {
83
+ constructor() {
84
+ super();
85
+ /** the mode in effect: 'server' | 'client', null before the first answer */
86
+ this.mode = null;
87
+ /** a mode the compositor sent that the next configure adopts */
88
+ this.pending = null;
89
+ this.destroyed = false;
90
+ }
91
+
92
+ /**
93
+ * Whether an answer can arrive with no configure behind it, in which case
94
+ * whoever owns this has to flush it rather than wait (window.js). False
95
+ * for xdg-decoration, whose mode is part of the configure sequence.
96
+ */
97
+ get flushesOutsideConfigure() {
98
+ return false;
99
+ }
100
+
101
+ /** The compositor's answer, held until `adopt()`. */
102
+ _answer(mode) {
103
+ this.pending = mode;
104
+ if (this.flushesOutsideConfigure) this.emit('pending');
105
+ }
106
+
107
+ /**
108
+ * Adopt the pending mode, if there is one.
109
+ *
110
+ * @returns {boolean} whether the mode in effect changed
111
+ */
112
+ adopt() {
113
+ if (this.pending == null) return false;
114
+ const changed = this.pending !== this.mode;
115
+ this.mode = this.pending;
116
+ this.pending = null;
117
+ if (changed) this.emit('mode', this.mode);
118
+ return changed;
119
+ }
120
+
121
+ /** Before the toplevel: destroying them the other way round is a protocol error. */
122
+ destroy() {
123
+ if (this.destroyed) return;
124
+ this.destroyed = true;
125
+ try {
126
+ this._release();
127
+ } catch {
128
+ /* the connection may already be gone */
129
+ }
130
+ }
131
+ }
132
+
133
+ export class ServerDecoration extends Decoration {
49
134
  /**
50
135
  * Create the decoration object and state a preference. Must run before
51
136
  * the toplevel's first commit; `WaylandWindow.createSync` calls it there.
@@ -58,13 +143,8 @@ export class ServerDecoration extends EventEmitter {
58
143
  constructor({ manager, toplevel, prefer = 'server' }) {
59
144
  super();
60
145
  this.proxy = manager.$.get_toplevel_decoration(toplevel.id);
61
- /** the mode in effect: 'server' | 'client', null before the first configure */
62
- this.mode = null;
63
- /** a mode the compositor sent that the next xdg_surface.configure adopts */
64
- this.pending = null;
65
- this.destroyed = false;
66
146
  this.proxy.on('configure', (mode) => {
67
- this.pending = mode === DECORATION_MODE.SERVER_SIDE ? 'server' : 'client';
147
+ this._answer(mode === DECORATION_MODE.SERVER_SIDE ? 'server' : 'client');
68
148
  });
69
149
  this.prefer(prefer);
70
150
  }
@@ -79,28 +159,76 @@ export class ServerDecoration extends EventEmitter {
79
159
  );
80
160
  }
81
161
 
162
+ _release() {
163
+ this.proxy.$.destroy();
164
+ }
165
+ }
166
+
167
+ /**
168
+ * The same conversation over `org_kde_kwin_server_decoration`, for the
169
+ * compositors that have only that one. It decorates the `wl_surface`, so it
170
+ * is created from the surface rather than the toplevel — but the ordering
171
+ * rule is the toplevel's all the same, since that is what carries the frame.
172
+ */
173
+ export class KdeServerDecoration extends Decoration {
82
174
  /**
83
- * Adopt the pending mode, if there is one.
84
- *
85
- * @returns {boolean} whether the mode in effect changed
175
+ * @param {object} opts
176
+ * @param {object} opts.manager the `org_kde_kwin_server_decoration_manager`
177
+ * @param {object} opts.surface the `wl_surface` proxy
178
+ * @param {'server'|'client'} [opts.prefer='server']
86
179
  */
87
- adopt() {
88
- if (this.pending == null) return false;
89
- const changed = this.pending !== this.mode;
90
- this.mode = this.pending;
91
- this.pending = null;
92
- if (changed) this.emit('mode', this.mode);
93
- return changed;
180
+ constructor({ manager, surface, prefer = 'server' }) {
181
+ super();
182
+ this.proxy = manager.$.create(surface.id);
183
+ this.proxy.on('mode', (mode) => {
184
+ // `none` is not server-side, and this side draws nothing for it: the
185
+ // window that asked for no frame is already drawing nothing.
186
+ this._answer(mode === KDE_DECORATION_MODE.SERVER ? 'server' : 'client');
187
+ });
188
+ this.prefer(prefer);
94
189
  }
95
190
 
96
- /** Before the toplevel: destroying them the other way round is a protocol error. */
97
- destroy() {
191
+ /** Nothing sequences this protocol's `mode` event; window.js flushes it. */
192
+ get flushesOutsideConfigure() {
193
+ return true;
194
+ }
195
+
196
+ prefer(prefer) {
98
197
  if (this.destroyed) return;
99
- this.destroyed = true;
100
- try {
101
- this.proxy.$.destroy();
102
- } catch {
103
- /* the connection may already be gone */
104
- }
198
+ this.proxy.$.request_mode(
199
+ prefer === 'client'
200
+ ? KDE_DECORATION_MODE.CLIENT
201
+ : KDE_DECORATION_MODE.SERVER,
202
+ );
203
+ }
204
+
205
+ _release() {
206
+ this.proxy.$.release();
105
207
  }
106
208
  }
209
+
210
+ /**
211
+ * Ask whichever protocol the compositor has, preferring the standard one.
212
+ * Returns null only when there is nobody to ask — the case where the frame
213
+ * has to be this backend's own (GNOME).
214
+ *
215
+ * @param {object} opts
216
+ * @param {object} [opts.manager] `zxdg_decoration_manager_v1`, if bound
217
+ * @param {object} [opts.kdeManager] `org_kde_kwin_server_decoration_manager`
218
+ * @param {object} opts.toplevel the `xdg_toplevel` proxy
219
+ * @param {object} opts.surface the `wl_surface` proxy
220
+ * @param {'server'|'client'} [opts.prefer='server']
221
+ * @returns {Decoration|null}
222
+ */
223
+ export function createServerDecoration({
224
+ manager,
225
+ kdeManager,
226
+ toplevel,
227
+ surface,
228
+ prefer = 'server',
229
+ }) {
230
+ if (manager) return new ServerDecoration({ manager, toplevel, prefer });
231
+ if (kdeManager)
232
+ return new KdeServerDecoration({ manager: kdeManager, surface, prefer });
233
+ return null;
234
+ }
@@ -13,6 +13,16 @@
13
13
  // only one surface is current at a time. Every target here shares the app's
14
14
  // one EGL context, so `render()` asks the app to make it current before
15
15
  // binding the target — which normally costs nothing, because it already is.
16
+ //
17
+ // Sharing one device is also why `getContext('2d')` used to be a trap. ntk
18
+ // tells callers who draw often to hold a context rather than take one per
19
+ // frame, and a held context here drew wherever the device was last pointed
20
+ // — which, outside `render()`, was the window: the draws went nowhere
21
+ // visible and nothing said so (#566). The context now takes the device back
22
+ // whenever it finds it in someone else's hands (device.js), so holding one
23
+ // works as ntk documents, and a node that painted into a surface in the
24
+ // middle of the window's frame no longer has to put the window's GL state
25
+ // back by hand either.
16
26
 
17
27
  import { WaylandContext2D } from './context2d.js';
18
28
  import { GLTarget } from './target.js';
@@ -57,6 +67,10 @@ export class WaylandSurface {
57
67
  this._ctx = new WaylandContext2D(this.app.gl, {
58
68
  fontManager: this.app.fonts,
59
69
  target: this.target,
70
+ // A surface may be drawn into between frames, when the app's notion
71
+ // of a current GL surface is whatever it last was; the context asks
72
+ // for one before it re-takes the device, as `render()` does.
73
+ makeCurrent: () => this.app.makeCurrent(),
60
74
  });
61
75
  this.app.makeCurrent();
62
76
  this._ctx.init();
@@ -67,6 +81,10 @@ export class WaylandSurface {
67
81
  /**
68
82
  * Draw into the surface through a context that starts clean — identity
69
83
  * transform, no clip — and is flushed when the callback returns.
84
+ *
85
+ * Drawing through `getContext('2d')` directly works too, and keeps the
86
+ * transform and clip it was left with; this is the scoped form, for a
87
+ * caller that wants a frame's worth of state rather than a canvas's.
70
88
  */
71
89
  render(fn) {
72
90
  if (this._destroyed) return this;
@@ -21,6 +21,8 @@
21
21
  // asking EGL for it means the window's own config no longer has to carry
22
22
  // stencil bits at all; the request in glcontext.js stays as belt and braces.
23
23
 
24
+ import { releaseDevice } from './device.js';
25
+
24
26
  export class GLTarget {
25
27
  /**
26
28
  * @param {object} gl the GLES entry points
@@ -164,6 +166,9 @@ export class GLTarget {
164
166
  );
165
167
  }
166
168
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
169
+ // The framebuffer and the scissor were the 2d contexts' to say; they are
170
+ // not any more, so the next one to draw re-establishes them (device.js).
171
+ releaseDevice(gl);
167
172
  }
168
173
 
169
174
  /**
@@ -229,6 +234,7 @@ export class GLTarget {
229
234
  gl.NEAREST,
230
235
  );
231
236
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
237
+ releaseDevice(gl);
232
238
  return true;
233
239
  }
234
240
 
@@ -39,7 +39,7 @@
39
39
  // is emitted, so the frame that acks it is painted at the right insets.
40
40
 
41
41
  import { EventEmitter } from 'node:events';
42
- import { ServerDecoration } from './ssd.js';
42
+ import { createServerDecoration } from './ssd.js';
43
43
  import { createLayerSurface } from './layershell.js';
44
44
 
45
45
  /** `xdg_toplevel.state` values. */
@@ -264,14 +264,17 @@ export class WaylandWindow extends EventEmitter {
264
264
  if (minSize) toplevel.$.set_min_size(minSize.width | 0, minSize.height | 0);
265
265
  if (maxSize) toplevel.$.set_max_size(maxSize.width | 0, maxSize.height | 0);
266
266
  // Who draws the frame is asked before the first commit; the answer rides
267
- // the first configure (ssd.js).
268
- if (decorations?.manager) {
269
- win.decoration = new ServerDecoration({
270
- manager: decorations.manager,
271
- toplevel,
272
- prefer: decorations.prefer,
273
- });
274
- }
267
+ // the first configure (ssd.js). Whichever protocol the compositor has is
268
+ // asked — the standard one first — and only a compositor with neither
269
+ // leaves the frame to this backend.
270
+ win.decoration = createServerDecoration({
271
+ manager: decorations?.manager,
272
+ kdeManager: decorations?.kdeManager,
273
+ toplevel,
274
+ surface,
275
+ prefer: decorations?.prefer,
276
+ });
277
+ win._wireDecoration();
275
278
  // the empty commit that asks for the first configure
276
279
  surface.$.commit();
277
280
  return win;
@@ -406,6 +409,30 @@ export class WaylandWindow extends EventEmitter {
406
409
  this.surface.on('preferred_buffer_transform', () => {});
407
410
  }
408
411
 
412
+ /**
413
+ * The KDE protocol's `mode` event is not part of a configure sequence
414
+ * (ssd.js), so a change of heart after the window is up would otherwise
415
+ * sit in `pending` waiting for a configure that nothing promises. Flush it
416
+ * in a microtask: a configure in the same batch of messages still gets
417
+ * there first and adopts it the ordinary way, and only a mode that really
418
+ * arrived alone is applied on its own — `setMargins` already knows how to
419
+ * resize outside a configure.
420
+ *
421
+ * Before the first configure there is nothing to flush: that configure is
422
+ * coming, and riding it keeps the first frame at the right insets.
423
+ */
424
+ _wireDecoration() {
425
+ const decoration = this.decoration;
426
+ if (!decoration?.flushesOutsideConfigure) return;
427
+ decoration.on('pending', () => {
428
+ if (!this.configured) return;
429
+ queueMicrotask(() => {
430
+ if (this.destroyed) return;
431
+ if (decoration.adopt()) this.emit('decorationmode', decoration.mode);
432
+ });
433
+ });
434
+ }
435
+
409
436
  /**
410
437
  * The shell's configure — xdg_surface's, or the layer surface's with its
411
438
  * size already taken — holds the serial for the frame that adopts it. A