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,1199 @@
1
+ // One window as the renderer sees it: the object `app.createWindow()` hands
2
+ // back and `src/nodes/window/` drives.
3
+ //
4
+ // It wears the interface ntk's `Window` has — `getContext`, `on(...)`,
5
+ // `width`/`height`, `setTitle`, `requestAnimationFrame`, `destroy` and the
6
+ // rest — over a Wayland surface, a GL context and the client-side
7
+ // decorations. Where the X11 shape has no Wayland meaning the method is
8
+ // there and says so rather than silently doing nothing, because a stub that
9
+ // does nothing turns "not implemented" into a rendering bug two layers away.
10
+ //
11
+ // Two coordinate systems meet here and the rule is fixed: **everything the
12
+ // tree sees is content-relative and in device pixels**, as it is on the
13
+ // other backends. The surface is bigger than the content by the frame
14
+ // (decorations.js), and a compositor speaks logical pixels; the conversions
15
+ // happen at this boundary and nowhere else.
16
+ //
17
+ // The frame loop is the compositor's. `requestAnimationFrame` arms a
18
+ // `wl_surface.frame` callback (or paints straight away for the first frame,
19
+ // which nothing can pace), the callback runs the renderer's paint into the
20
+ // backing target, and the result is copied to a swapchain buffer and
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.
25
+ //
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
30
+ // dock, a wallpaper, a notification or a splash is not a toplevel at all
31
+ // where the compositor has layer-shell (layershell.js) — same surface, same
32
+ // loop, no frame.
33
+
34
+ import { EventEmitter } from 'node:events';
35
+ import { writeSync, writeFileSync } from 'node:fs';
36
+ import { snapshotPNG } from './readback.js';
37
+ import { WaylandWindow, TOPLEVEL_STATE } from './window.js';
38
+ import { WaylandGLContext } from './glcontext.js';
39
+ import { FrameWatch } from './framewatch.js';
40
+ import { WaylandContext2D } from './context2d.js';
41
+ import { Decorations } from './decorations.js';
42
+ import { appearanceSnapshot } from '../appearance.js';
43
+ import { decorationPolicy } from './ssd.js';
44
+ import { layerRoleFor } from './layershell.js';
45
+
46
+ // What the X11 window has and this one does not — `getProperty`,
47
+ // `setProperty`, `selectXI2`, `grabKeyboard`, `setBackgroundPixel`,
48
+ // `reparent` — is *absent* rather than stubbed. That is the contract the
49
+ // tree already keeps for the cocoa backend: every caller probes
50
+ // (`wnd.setProperty?.(…)`, `typeof wnd.selectXI2 === 'function'`) and takes
51
+ // the absence as "not on this backend". A method that throws instead turns
52
+ // each of those probes into a crash two layers away (`_NET_WM_DESKTOP` was
53
+ // the first: src/windowstate.js reads it on every window).
54
+
55
+ const STATE_NAMES = {
56
+ [TOPLEVEL_STATE.MAXIMIZED]: ['maximized_vert', 'maximized_horz'],
57
+ [TOPLEVEL_STATE.FULLSCREEN]: ['fullscreen'],
58
+ [TOPLEVEL_STATE.ACTIVATED]: ['focused'],
59
+ [TOPLEVEL_STATE.SUSPENDED]: ['hidden'],
60
+ };
61
+
62
+ /** `REACT_X11_WAYLAND_TRACE=1`: a line on stderr per presented frame. */
63
+ const TRACE = Boolean(process.env.REACT_X11_WAYLAND_TRACE);
64
+ /**
65
+ * `REACT_X11_WAYLAND_SNAPSHOT=<file.png>`: the backing target of the first
66
+ * window, written at its present number `REACT_X11_WAYLAND_SNAPSHOT_AT`
67
+ * (default 30) — what the compositor was handed, read back from the GPU. The
68
+ * one way to see a frame on a desktop that offers no screenshot API.
69
+ */
70
+ const SNAPSHOT = process.env.REACT_X11_WAYLAND_SNAPSHOT || null;
71
+ const SNAPSHOT_AT = Number(process.env.REACT_X11_WAYLAND_SNAPSHOT_AT) || 30;
72
+
73
+ let nextId = 1;
74
+
75
+ const overlaps = (a, b) =>
76
+ a.x < b.x + b.width &&
77
+ b.x < a.x + a.width &&
78
+ a.y < b.y + b.height &&
79
+ b.y < a.y + a.height;
80
+
81
+ export class WaylandBackendWindow extends EventEmitter {
82
+ /**
83
+ * @param {import('./app.js').WaylandApp} app
84
+ * @param {object} attributes what `WindowNode.realize()` built
85
+ */
86
+ constructor(app, attributes = {}) {
87
+ super();
88
+ this.setMaxListeners(0);
89
+ this.app = app;
90
+ this.attributes = attributes;
91
+ this.id = nextId++;
92
+ this.X = app.X;
93
+ this.ownerDocument = null;
94
+ this.isPopup = attributes.overrideRedirect === true;
95
+ // A `<popup dragPreview>` is the picture of a drag. On Wayland it has
96
+ // nothing to be: a popup cannot follow a drag (start_drag's icon surface
97
+ // is the mechanism, and rendering the preview into one is a follow-up —
98
+ // src/wayland/dnd.js), so the preview is inert here — it presents no
99
+ // frame and holds no buffer, staying invisible rather than a static
100
+ // xdg_popup frozen where the drag began.
101
+ this.isDragPreview = attributes.dragPreview === true;
102
+ // A dock, a wallpaper, a notification: a layer surface where the
103
+ // compositor has layer-shell, an ordinary toplevel where it does not.
104
+ this.layerRole =
105
+ !this.isPopup && app.layerShell
106
+ ? layerRoleFor(attributes, { scale: app.scale })
107
+ : null;
108
+ this.isLayer = this.layerRole !== null;
109
+ /** the cursor the tree last asked for over the content */
110
+ this.treeCursor = 'default';
111
+ this._destroyed = false;
112
+ this._contexts = new Map();
113
+ this._raf = [];
114
+ this._frameArmed = false;
115
+ this._everPresented = false;
116
+ this._eagerFired = false;
117
+ this._presentInFlight = false;
118
+ this._frameSize = null;
119
+ this._frameDirty = true;
120
+ this._resized = true;
121
+ this._reactX11Node = null;
122
+ /** `<glarea>`s drawing into this window's target (glarea.js) */
123
+ this._surfaces = new Set();
124
+ /** the panes their children are painted on */
125
+ this._panes = new Set();
126
+ this._surfaceRaf = [];
127
+ /** rects (content px) a surface or pane changed since the last present */
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
+ });
140
+
141
+ // Frame and content sizes. `attributes.width/height` are what the tree
142
+ // measured, in device pixels of content; the surface adds the frame.
143
+ // Popups and layer surfaces have none; a toplevel's is switched off once
144
+ // a compositor agrees to draw it (`_setDecorationMode`).
145
+ this._decorPolicy = decorationPolicy(
146
+ app.options.decorations,
147
+ attributes.decorations,
148
+ );
149
+ this.decor =
150
+ this.isPopup || this.isLayer
151
+ ? null
152
+ : new Decorations({ enabled: this._decorPolicy.draw });
153
+ if (this.decor) {
154
+ const decor = this.decor;
155
+ decor.title = attributes.title ?? 'react-x11';
156
+ decor.measure = app.frameText;
157
+ decor.setStyle(app.frameStyle?.value, appearanceSnapshot());
158
+ // the shell window's margins once it exists; the same answer before
159
+ decor.marginsOf = () =>
160
+ this.wl?.margins ?? Decorations.marginsFor(new Set(), decor.enabled);
161
+ decor.onNeedsRepaint = () => {
162
+ if (this._titleRetry) return;
163
+ this._titleRetry = setTimeout(() => {
164
+ this._titleRetry = null;
165
+ this.repaintFrame();
166
+ }, 60);
167
+ };
168
+ }
169
+ const scale = app.scale;
170
+ const insets = this.insets;
171
+ const contentW = Math.max(1, (attributes.width ?? 640) / scale);
172
+ const contentH = Math.max(1, (attributes.height ?? 480) / scale);
173
+ /** the content size the tree asked for, in logical pixels */
174
+ this._contentWish = { width: contentW, height: contentH };
175
+ const surfaceW = Math.round(contentW + insets.left + insets.right);
176
+ const surfaceH = Math.round(contentH + insets.top + insets.bottom);
177
+
178
+ if (this.isPopup) {
179
+ const parent = app.parentFor(attributes);
180
+ const pi = parent?.geometryInsets ?? { left: 0, top: 0 };
181
+ const ps = parent?.scale ?? scale;
182
+ this.wl = WaylandWindow.createPopupSync({
183
+ conn: app.conn,
184
+ compositor: app.compositor,
185
+ wmBase: app.wmBase,
186
+ parent: parent?.wl,
187
+ x: (attributes.x ?? 0) / ps + pi.left,
188
+ y: (attributes.y ?? 0) / ps + pi.top,
189
+ width: surfaceW,
190
+ height: surfaceH,
191
+ // the initial commit waits for the map — see map()
192
+ commit: false,
193
+ });
194
+ this.parentWindow = parent;
195
+ /** where the tree asked for the popup, in its parent's surface
196
+ * coordinates — the compositor's configure may have slid it */
197
+ this._popupAt = {
198
+ x: (attributes.x ?? 0) / ps + pi.left,
199
+ y: (attributes.y ?? 0) / ps + pi.top,
200
+ };
201
+ } else if (this.isLayer) {
202
+ this.wl = WaylandWindow.createLayerSync({
203
+ conn: app.conn,
204
+ compositor: app.compositor,
205
+ layerShell: app.layerShell,
206
+ width: surfaceW,
207
+ height: surfaceH,
208
+ ...this.layerRole,
209
+ });
210
+ } else {
211
+ this.wl = WaylandWindow.createSync({
212
+ conn: app.conn,
213
+ compositor: app.compositor,
214
+ wmBase: app.wmBase,
215
+ title: attributes.title ?? 'react-x11',
216
+ appId:
217
+ attributes.appId ??
218
+ attributes.class ??
219
+ app.options.appId ??
220
+ 'react-x11',
221
+ width: surfaceW,
222
+ height: surfaceH,
223
+ parent: app.parentFor(attributes, { toplevelOnly: true })?.wl ?? 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,
232
+ });
233
+ }
234
+ this.wl.scale = scale;
235
+ this.wl.compositor = app.compositor;
236
+ if (this.decor) {
237
+ const decor = this.decor;
238
+ this.wl.marginsFor = (states) =>
239
+ Decorations.marginsFor(states, decor.enabled);
240
+ this.wl.margins = this.wl.marginsFor(this.wl.states);
241
+ }
242
+ this.wl.useScaling({
243
+ fractionalScaleManager: app.fractionalScale,
244
+ viewporter: app.viewporter,
245
+ });
246
+ // which monitor it is on, its bounds, and the output-scale fallback
247
+ app.outputs?.watchWindow(this.wl);
248
+ this.glctx = WaylandGLContext.createSync({
249
+ conn: app.conn,
250
+ window: this.wl,
251
+ dmabuf: app.dmabuf,
252
+ policy: app.options.glPolicy ?? {},
253
+ });
254
+ this._wire();
255
+ }
256
+
257
+ _wire() {
258
+ const wl = this.wl;
259
+ wl.on('resize', () => {
260
+ this._resized = true;
261
+ this._frameDirty = true;
262
+ this.emit('resize', {
263
+ width: this.width,
264
+ height: this.height,
265
+ x: 0,
266
+ y: 0,
267
+ });
268
+ });
269
+ wl.on('configure', ({ states }) => {
270
+ if (this.decor) {
271
+ const before = this.decor.insets().top;
272
+ this.decor.setState(states);
273
+ this._frameDirty = true;
274
+ if (this.decor.insets().top !== before) {
275
+ this._resized = true;
276
+ this.emit('resize', {
277
+ width: this.width,
278
+ height: this.height,
279
+ x: 0,
280
+ y: 0,
281
+ });
282
+ }
283
+ }
284
+ // Adopting a configure needs a frame even when nothing else changed:
285
+ // it is the commit after the ack that applies it. `_fireRaf` ends a
286
+ // frame with nothing due and nothing dirty without committing, so
287
+ // without this a popup's reposition — position only, no decorations
288
+ // to repaint — was never acked, and a tooltip stayed where it was
289
+ // first placed: the parent's top-left corner.
290
+ this._frameDirty = true;
291
+ this._armFrame();
292
+ });
293
+ wl.on('decorationmode', (mode) => this._setDecorationMode(mode));
294
+ wl.on('statechange', () => this.emit('statechange', this.getWmStates()));
295
+ wl.on('close', () => {
296
+ if (this.isPopup) this._dismissedByCompositor();
297
+ else this.requestClose();
298
+ });
299
+ wl.on('scale', () => {
300
+ this._resized = true;
301
+ this._frameDirty = true;
302
+ this.app.noteScale();
303
+ this.emit('resize', {
304
+ width: this.width,
305
+ height: this.height,
306
+ x: 0,
307
+ y: 0,
308
+ });
309
+ });
310
+ wl.on('error', (err) => this.emit('error', err));
311
+ }
312
+
313
+ /**
314
+ * The compositor's answer to who draws the frame. 'server' switches the
315
+ * client-side frame off: the insets go to zero, no titlebar is painted,
316
+ * and the content grows into the whole surface — a resize, as far as the
317
+ * tree is concerned, and `set_window_geometry` already covers the buffer.
318
+ * Before the first frame, where the compositor has not imposed a size,
319
+ * the surface is refitted to the content the tree asked for instead, so a
320
+ * floating window comes up the size it was declared rather than a
321
+ * titlebar taller. The answer can change later (a compositor's setting
322
+ * flips) and the same switch runs the other way.
323
+ */
324
+ _setDecorationMode(mode) {
325
+ if (!this.decor) return;
326
+ const enabled = this._decorPolicy.draw && mode !== 'server';
327
+ if (this.decor.enabled === enabled) return;
328
+ this.decor.enabled = enabled;
329
+ // no frame, no shadow: the margin goes with it, and the surface round
330
+ // the window with the margin
331
+ this.wl.setMargins(this.wl.marginsFor?.(this.wl.states));
332
+ if (!this._everPresented && !this.wl.sizeImposed) {
333
+ const i = this.insets;
334
+ const wish = this._contentWish;
335
+ this.wl.width = Math.max(1, Math.round(wish.width + i.left + i.right));
336
+ this.wl.height = Math.max(1, Math.round(wish.height + i.top + i.bottom));
337
+ }
338
+ this._resized = true;
339
+ this._frameDirty = true;
340
+ this.emit('resize', { width: this.width, height: this.height, x: 0, y: 0 });
341
+ }
342
+
343
+ // ---- geometry ---------------------------------------------------------
344
+
345
+ /** The frame's insets in logical pixels. */
346
+ get insets() {
347
+ return this.decor
348
+ ? this.decor.insets()
349
+ : { top: 0, left: 0, right: 0, bottom: 0 };
350
+ }
351
+
352
+ /**
353
+ * The content's offset inside the window geometry: the insets without the
354
+ * shadow's margin. What a popup's position is relative to — xdg-shell
355
+ * places popups in their parent's window geometry, not its surface.
356
+ */
357
+ get geometryInsets() {
358
+ const i = this.insets;
359
+ const m = this.wl?.margins ?? { left: 0, top: 0, right: 0, bottom: 0 };
360
+ return {
361
+ top: i.top - m.top,
362
+ left: i.left - m.left,
363
+ right: i.right - m.right,
364
+ bottom: i.bottom - m.bottom,
365
+ };
366
+ }
367
+
368
+ get scale() {
369
+ return this.wl.scale;
370
+ }
371
+
372
+ /**
373
+ * The monitor this window is on — the output the surface has entered
374
+ * (the densest one when it straddles two), as a `screens.js`-shaped
375
+ * record with the output's `name`, `description`, `scale` and
376
+ * `refreshRate`; null before the first present, when no compositor has
377
+ * said yet.
378
+ */
379
+ get output() {
380
+ return this.app.outputs?.monitorFor(this.wl.outputs) ?? null;
381
+ }
382
+
383
+ /** Content width in device pixels — what the tree lays out to. */
384
+ get width() {
385
+ const i = this.insets;
386
+ return Math.max(
387
+ 1,
388
+ Math.round((this.wl.width - i.left - i.right) * this.wl.scale),
389
+ );
390
+ }
391
+
392
+ get height() {
393
+ const i = this.insets;
394
+ return Math.max(
395
+ 1,
396
+ Math.round((this.wl.height - i.top - i.bottom) * this.wl.scale),
397
+ );
398
+ }
399
+
400
+ get x() {
401
+ return Math.round(this.wl.x * this.wl.scale);
402
+ }
403
+
404
+ get y() {
405
+ return Math.round(this.wl.y * this.wl.scale);
406
+ }
407
+
408
+ /** Content origin inside the buffer, in device pixels. */
409
+ get contentOrigin() {
410
+ const i = this.insets;
411
+ const s = this.wl.scale;
412
+ return { x: Math.round(i.left * s), y: Math.round(i.top * s) };
413
+ }
414
+
415
+ getClientRects() {
416
+ return [
417
+ { x: 0, y: 0, left: 0, top: 0, width: this.width, height: this.height },
418
+ ];
419
+ }
420
+
421
+ measure(callback) {
422
+ callback?.(0, 0, this.width, this.height, 0, 0);
423
+ return { width: this.width, height: this.height };
424
+ }
425
+
426
+ // ---- contexts ---------------------------------------------------------
427
+
428
+ /**
429
+ * The rendering context, by name. `'2d'` is the GPU context drawing into
430
+ * this window's backing target; `'gles'` is the raw GL entry points a
431
+ * `<glarea>` wants, with the backing target bound.
432
+ */
433
+ getContext(name = '2d') {
434
+ if (this._contexts.has(name)) return this._contexts.get(name);
435
+ let ctx;
436
+ if (name === '2d') {
437
+ if (!this.glctx.backing) this.glctx.beginFrame();
438
+ ctx = new WaylandContext2D(this.glctx.gl, {
439
+ fontManager: this.app.fonts,
440
+ target: this.glctx.backing,
441
+ });
442
+ this.app.makeCurrent();
443
+ ctx.init();
444
+ // The renderer creates its context lazily, inside its first paint —
445
+ // by which time this frame has begun, so the context is caught up.
446
+ if (this._frameSize) {
447
+ ctx.begin(
448
+ this._frameSize.width,
449
+ this._frameSize.height,
450
+ this._frameSize.time,
451
+ );
452
+ this._enterContent(ctx);
453
+ }
454
+ } else if (name === 'gles' || name === 'opengl' || name === 'webgl') {
455
+ ctx = this.glctx.gl;
456
+ } else {
457
+ throw new Error(
458
+ `react-x11 (wayland): no '${name}' rendering context — this backend has '2d' and 'gles'`,
459
+ );
460
+ }
461
+ this._contexts.set(name, ctx);
462
+ return ctx;
463
+ }
464
+
465
+ // ---- the frame loop ---------------------------------------------------
466
+
467
+ /**
468
+ * The frame clock in the shape the renderer uses. On X11 this is Present
469
+ * completions plus an estimator; here it is the compositor's own
470
+ * `wl_surface.frame`, which is both simpler and honest about occlusion —
471
+ * an invisible window stops being called back, and so stops painting.
472
+ */
473
+ requestAnimationFrame(fn) {
474
+ this._raf.push(fn);
475
+ this._armFrame();
476
+ return this._raf.length;
477
+ }
478
+
479
+ /** Ask for a repaint of the decorations at the next frame. */
480
+ /**
481
+ * A `<glarea>`'s frame: after the tree's paint, in the same frame of this
482
+ * window, so the GL goes over the 2D and out with the same present.
483
+ */
484
+ requestSurfaceFrame(fn) {
485
+ this._surfaceRaf.push(fn);
486
+ this._armFrame();
487
+ }
488
+
489
+ /** A surface drew: its rect is owed to the compositor. */
490
+ _surfaceDrew(area) {
491
+ this._surfaceDamage.push({ ...area.rect });
492
+ }
493
+
494
+ /** A pane was painted: it is composited over its surface this frame. */
495
+ _paneDrew(pane) {
496
+ this._surfaceDamage.push({ ...pane.rect });
497
+ }
498
+
499
+ repaintFrame() {
500
+ this._frameDirty = true;
501
+ this._armFrame();
502
+ }
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
+
513
+ /**
514
+ * Get a frame in flight.
515
+ *
516
+ * Three rules, each learned from a spin:
517
+ *
518
+ * - **A frame stays armed from its callback until its present has been
519
+ * handed over.** A callback that asks for the next frame (every
520
+ * animation does) queues it for the next vsync, as in a browser; it does
521
+ * not re-fire. The first version cleared the flag before running the
522
+ * callback, and an animation re-entered itself from inside the frame.
523
+ * - **The first frame cannot be paced**, because a compositor only
524
+ * schedules frame callbacks for a surface it is showing — so until the
525
+ * first buffer has been presented, frames run from a *timer*. Never from
526
+ * a microtask: presenting that first buffer needs the event loop (the
527
+ * dma-buf import is a round trip), and a microtask chain that keeps
528
+ * painting never yields to it. That was 100% of a core, nothing on
529
+ * screen, and Ctrl+C ignored.
530
+ * - **Post-present, an empty frame re-arms through the compositor**, so
531
+ * a renderer that keeps asking without drawing costs a commit per
532
+ * refresh, not a spin.
533
+ */
534
+ _armFrame() {
535
+ // A drag preview never presents (see the constructor): no frame loop, so
536
+ // no buffer is ever committed and the surface stays invisible.
537
+ if (this.isDragPreview) return;
538
+ // A closed connection arms nothing: a frame request on it rejects at
539
+ // once, the rejection re-arms, and that loop never yields.
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
+ }
548
+ this._frameArmed = true;
549
+ this.wl.whenConfigured
550
+ .then(() => {
551
+ if (this._destroyed) return;
552
+ if (this._presentInFlight) return; // its vsync runs the next frame
553
+ if (!this._everPresented) {
554
+ const delay = this._eagerFired ? 16 : 0;
555
+ this._eagerFired = true;
556
+ setTimeout(() => this._fireRaf(0), delay);
557
+ return;
558
+ }
559
+ const vsync = this.wl.scheduleFrame();
560
+ this.wl.surface.$.commit();
561
+ this._frameWatch.waiting();
562
+ vsync.then(
563
+ (t) => {
564
+ this._frameWatch.arrived();
565
+ this._fireRaf(t);
566
+ },
567
+ () => this._frameIdle(),
568
+ );
569
+ })
570
+ .catch(() => this._frameIdle());
571
+ }
572
+
573
+ /** The frame is over with nothing presented; run again only if asked. */
574
+ _frameIdle() {
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();
579
+ if (!this._destroyed && (this._raf.length || this._frameDirty)) {
580
+ this._armFrame();
581
+ }
582
+ }
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
+
604
+ /** Translate into the content area and clip to it. */
605
+ _enterContent(ctx) {
606
+ const o = this.contentOrigin;
607
+ ctx.save();
608
+ ctx.translate(o.x, o.y);
609
+ ctx.beginPath();
610
+ // A floating window's bottom corners are rounded like its top ones, and
611
+ // the content is what fills them.
612
+ const r = this.decor?.enabled ? this.decor.radius * this.wl.scale : 0;
613
+ if (r > 0) ctx.roundRect(0, 0, this.width, this.height, [0, 0, r, r]);
614
+ else ctx.rect(0, 0, this.width, this.height);
615
+ ctx.clip();
616
+ }
617
+
618
+ /**
619
+ * Run the frame: open the GL frame, paint the decorations, let the
620
+ * renderer paint the content, close, and present.
621
+ *
622
+ * On X11 none of this is needed — an ntk 2d context is an XRender encoder
623
+ * writing straight at the window, so a paint *is* a present. Here a frame
624
+ * has a beginning and an end, and this is where they go, because the
625
+ * renderer's own paint is the callback in the middle.
626
+ */
627
+ _fireRaf(time) {
628
+ if (this._destroyed) return;
629
+ const due = this._raf.splice(0);
630
+ const frameDirty = this._frameDirty;
631
+ if (due.length === 0 && !frameDirty && this._surfaceRaf.length === 0) {
632
+ this._frameArmed = false;
633
+ return;
634
+ }
635
+
636
+ const size = this.glctx.beginFrame();
637
+ if (!size) return this._frameIdle();
638
+ // This window's GBM surface is current now, and stays the app's notion of
639
+ // "current" for the frame: an offscreen render, a pane, a context made
640
+ // lazily inside the paint — each asks `app.makeCurrent()` — must not
641
+ // switch to another window's surface mid-frame, or the blit lands on
642
+ // that window and `eglSwapBuffers` on this one is EGL_BAD_SURFACE (the
643
+ // popup crash: its first frame made its context, and `makeCurrent`
644
+ // reached for the toplevel's surface it had cached).
645
+ if (this.glctx.chain?.gbm) this.app._currentSurface = this.glctx.chain.gbm;
646
+ const resized = size.resized || this._resized;
647
+ this._resized = false;
648
+ this._frameSize = { width: size.width, height: size.height, time };
649
+ const ctx = this._contexts.get('2d');
650
+ if (ctx) {
651
+ ctx.begin(size.width, size.height, time);
652
+ this._paintFrame(ctx);
653
+ this._enterContent(ctx);
654
+ }
655
+
656
+ // `_frameArmed` stays set: a callback that asks for the next frame is
657
+ // queued for the vsync after this present, not run again now.
658
+ for (const fn of due) {
659
+ try {
660
+ fn(time);
661
+ } catch (err) {
662
+ this.emit('error', err);
663
+ }
664
+ }
665
+ // The input method hears about the focused field now, after the paint
666
+ // has laid the tree out and the caret rectangle is current (textinput.js).
667
+ this.app.textInput?.sync(this);
668
+
669
+ const painted = this._contexts.get('2d');
670
+ if (this._surfaces.size || this._surfaceRaf.length || this._panes.size) {
671
+ this._runSurfaces(painted, time, resized || frameDirty);
672
+ }
673
+ if (painted) {
674
+ painted.restore();
675
+ painted.end();
676
+ }
677
+ this._frameSize = null;
678
+
679
+ const drew =
680
+ painted?.drew || frameDirty || resized || this._surfaceDamage.length > 0;
681
+ if (!drew) return this._frameIdle();
682
+ this._frameDirty = false;
683
+ void this._present(resized || frameDirty ? 'all' : this._damageFor());
684
+ }
685
+
686
+ /**
687
+ * The `<glarea>`s' turn, after the tree has painted: flush the 2D under
688
+ * them, let each draw into its rect, put the 2d context's GL state back,
689
+ * and blend the children's panes over the surfaces.
690
+ *
691
+ * One thing a shared target needs that a child window never did: where
692
+ * the tree repainted *under* a surface that has no frame of its own this
693
+ * time — a background fill reaching under a static scene — the surface's
694
+ * pixels are gone with the repaint, so its node is asked for the frame
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.
703
+ */
704
+ _runSurfaces(ctx, time, wholeFrame) {
705
+ ctx?.flush();
706
+ for (const area of this._surfaces) area.drewThisFrame = false;
707
+ this._drainSurfaceFrames(time);
708
+ if (this._surfaces.size) {
709
+ const damage = wholeFrame ? null : this._reactX11Node?._lastDamageRects;
710
+ for (const area of this._surfaces) {
711
+ if (area.drewThisFrame || !area.mapped || area.destroyed) continue;
712
+ if (damage && !damage.some((r) => overlaps(r, area.rect))) continue;
713
+ area._reactX11Node?.requestFrame?.();
714
+ }
715
+ this._drainSurfaceFrames(time);
716
+ }
717
+ if (!ctx) return;
718
+ ctx.restoreGLState();
719
+ if (this._surfaceDamage.length === 0) return;
720
+ for (const pane of this._panes) {
721
+ if (pane.hidden || !pane.presented || pane.destroyed) continue;
722
+ ctx.drawImage(pane.target, pane.rect.x, pane.rect.y);
723
+ }
724
+ }
725
+
726
+ _drainSurfaceFrames(time) {
727
+ // a callback may queue the next frame; that one is for the next present
728
+ const due = this._surfaceRaf.splice(0);
729
+ for (const fn of due) {
730
+ try {
731
+ fn(time);
732
+ } catch (err) {
733
+ this.emit('error', err);
734
+ }
735
+ }
736
+ }
737
+
738
+ _paintFrame(ctx) {
739
+ if (!this.decor) return;
740
+ const s = this.wl.scale;
741
+ ctx.save();
742
+ ctx.scale(s, s);
743
+ this.decor.paint(ctx, this.wl.width, this.wl.height, this.app.frameText);
744
+ ctx.restore();
745
+ }
746
+
747
+ /**
748
+ * What the renderer repainted, as buffer-space rectangles. The window node
749
+ * keeps the rects its last flush painted; `null` means all of it.
750
+ */
751
+ _damageFor() {
752
+ const node = this._reactX11Node;
753
+ const rects = node?._lastDamageRects;
754
+ const extra = this._surfaceDamage.splice(0);
755
+ if (!rects) return 'all';
756
+ const o = this.contentOrigin;
757
+ return [...rects, ...extra].map((r) => ({
758
+ x: r.x + o.x,
759
+ y: r.y + o.y,
760
+ width: r.width,
761
+ height: r.height,
762
+ }));
763
+ }
764
+
765
+ /** Hand the painted buffer to the compositor and re-arm the clock. */
766
+ async _present(damage) {
767
+ if (this._destroyed) return;
768
+ this._presentInFlight = true;
769
+ if (SNAPSHOT && (this._presents ?? 0) + 1 === SNAPSHOT_AT) {
770
+ try {
771
+ this.glctx.bindBacking();
772
+ const { width, height } = this.glctx.backing;
773
+ writeFileSync(SNAPSHOT, snapshotPNG(this.glctx.gl, width, height));
774
+ writeSync(2, `react-x11 wayland: wrote ${SNAPSHOT}\n`);
775
+ } catch (err) {
776
+ writeSync(2, `react-x11 wayland: snapshot failed: ${err.message}\n`);
777
+ }
778
+ }
779
+ let vsync;
780
+ try {
781
+ vsync = await this.glctx.endFrame(damage);
782
+ } catch (err) {
783
+ this._presentInFlight = false;
784
+ // The connection going away rejects the frame request this present was
785
+ // waiting on (the library aborts in-flight callbacks on close). That is
786
+ // the app closing, not this window failing — and an 'error' nobody
787
+ // listens for throws: closing a window with a frame in flight, which is
788
+ // any window just resized, crashed with "wl_surface.frame: display
789
+ // closed".
790
+ if (this._destroyed || this.app.conn?.destroyed) return;
791
+ this.emit('error', err);
792
+ return this._frameIdle();
793
+ }
794
+ this._everPresented = true;
795
+ this.wl.mapped = true;
796
+ this._presents = (this._presents ?? 0) + 1;
797
+ if (TRACE) {
798
+ // a synchronous write: Bun buffers process.stderr to a file, and a
799
+ // buffer is what a SIGINT leaves behind
800
+ writeSync(
801
+ 2,
802
+ `react-x11 wayland: window ${this.id} present #${this._presents} ` +
803
+ `${damage === 'all' ? 'full' : damage.length + ' rect(s)'}\n`,
804
+ );
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();
810
+ Promise.resolve(vsync).then(
811
+ (t) => {
812
+ this._presentInFlight = false;
813
+ this._frameWatch.arrived();
814
+ if (!this._destroyed) this._fireRaf(t);
815
+ },
816
+ () => {
817
+ this._presentInFlight = false;
818
+ this._frameIdle();
819
+ },
820
+ );
821
+ }
822
+
823
+ // ---- window operations -------------------------------------------------
824
+
825
+ setTitle(title) {
826
+ if (this.decor) {
827
+ this.decor.title = String(title ?? '');
828
+ this.repaintFrame();
829
+ }
830
+ this.wl.setTitle(title);
831
+ return this;
832
+ }
833
+
834
+ setClass() {
835
+ // app_id is fixed at creation; xdg-shell has no later change
836
+ return this;
837
+ }
838
+
839
+ /**
840
+ * A size or a state. The renderer's size tracking calls this with
841
+ * `{ width, height }` (device pixels of content); a string is a WM state.
842
+ *
843
+ * A Wayland client *can* pick its own size — it simply commits a buffer
844
+ * that big — unless the compositor is imposing one (maximised, tiled, or
845
+ * mid-resize), in which case the wish is recorded and not acted on.
846
+ */
847
+ setState(state) {
848
+ if (state && typeof state === 'object') {
849
+ // A popup's rect is one placement: the tree places a popup through
850
+ // here whenever the window has a `setState`, and a tooltip that
851
+ // measured itself hidden at (0, 0) is moved to its trigger this way.
852
+ // Dropping `x`/`y` left it in its parent's corner.
853
+ if (this.isPopup && ('x' in state || 'y' in state)) {
854
+ this._placePopup(state);
855
+ return this;
856
+ }
857
+ if ('width' in state || 'height' in state)
858
+ this.resize(state.width ?? this.width, state.height ?? this.height);
859
+ return this;
860
+ }
861
+ if (state === 'maximized') this.wl.maximize(true);
862
+ else if (state === 'fullscreen') this.wl.fullscreen(true);
863
+ else if (state === 'minimized' || state === 'iconic') this.wl.minimize();
864
+ else if (state === 'normal') {
865
+ this.wl.maximize(false);
866
+ this.wl.fullscreen(false);
867
+ }
868
+ return this;
869
+ }
870
+
871
+ resize(width, height) {
872
+ const st = this.wl.states;
873
+ const imposed =
874
+ st.has(TOPLEVEL_STATE.MAXIMIZED) ||
875
+ st.has(TOPLEVEL_STATE.FULLSCREEN) ||
876
+ st.has(TOPLEVEL_STATE.RESIZING) ||
877
+ [...st].some(
878
+ (s) =>
879
+ s >= TOPLEVEL_STATE.TILED_LEFT && s <= TOPLEVEL_STATE.TILED_BOTTOM,
880
+ );
881
+ if (imposed && this.wl.configured) {
882
+ this._wanted = { width, height };
883
+ return this;
884
+ }
885
+ const i = this.insets;
886
+ const s = this.wl.scale;
887
+ let w = Math.max(1, Math.round(width / s + i.left + i.right));
888
+ let h = Math.max(1, Math.round(height / s + i.top + i.bottom));
889
+ if (this.isPopup) {
890
+ this.wl.reposition(this.app.wmBase, {
891
+ x: this._popupAt.x,
892
+ y: this._popupAt.y,
893
+ width: w,
894
+ height: h,
895
+ });
896
+ }
897
+ if (this.isLayer) {
898
+ // A stretched axis is the compositor's; the wish on it is not granted.
899
+ const role = this.wl.layer;
900
+ if (role.stretchX) w = this.wl.width;
901
+ if (role.stretchY) h = this.wl.height;
902
+ if (w !== this.wl.width || h !== this.wl.height) role.resize(w, h);
903
+ }
904
+ if (w === this.wl.width && h === this.wl.height) return this;
905
+ this.wl.width = w;
906
+ this.wl.height = h;
907
+ this._resized = true;
908
+ this._frameDirty = true;
909
+ this._armFrame();
910
+ return this;
911
+ }
912
+
913
+ move(x, y) {
914
+ if (this.isPopup) {
915
+ this._placePopup({ x, y });
916
+ return this;
917
+ }
918
+ // A toplevel cannot place itself. Not an error: the tree asks on every
919
+ // controlled position, and the compositor's answer is final.
920
+ return this;
921
+ }
922
+
923
+ /**
924
+ * Place a popup: `x`/`y` in its parent's content device pixels, `width`/
925
+ * `height` in its own, any of them omitted to keep what it has. One
926
+ * reposition for the whole rect, from the position the tree asked for —
927
+ * not the one the compositor reported, which it may have slid to keep the
928
+ * popup on screen, and which drifted further with every resize.
929
+ */
930
+ _placePopup({ x, y, width, height }) {
931
+ const p = this.parentWindow;
932
+ const pi = p?.geometryInsets ?? { left: 0, top: 0 };
933
+ const ps = p?.scale ?? this.scale;
934
+ if (x != null) this._popupAt.x = x / ps + pi.left;
935
+ if (y != null) this._popupAt.y = y / ps + pi.top;
936
+ const s = this.wl.scale;
937
+ const w =
938
+ width != null ? Math.max(1, Math.round(width / s)) : this.wl.width;
939
+ const h =
940
+ height != null ? Math.max(1, Math.round(height / s)) : this.wl.height;
941
+ this.wl.reposition(this.app.wmBase, {
942
+ x: this._popupAt.x,
943
+ y: this._popupAt.y,
944
+ width: w,
945
+ height: h,
946
+ });
947
+ if (w !== this.wl.width || h !== this.wl.height) {
948
+ this.wl.width = w;
949
+ this.wl.height = h;
950
+ this._resized = true;
951
+ this._frameDirty = true;
952
+ this._armFrame();
953
+ }
954
+ }
955
+
956
+ getWmStates() {
957
+ const out = [];
958
+ for (const s of this.wl.states)
959
+ for (const n of STATE_NAMES[s] ?? []) out.push(n);
960
+ return out;
961
+ }
962
+
963
+ setWmState(state, on = true) {
964
+ if (
965
+ state === 'maximized_vert' ||
966
+ state === 'maximized_horz' ||
967
+ state === 'maximized'
968
+ )
969
+ this.wl.maximize(on);
970
+ else if (state === 'fullscreen') this.wl.fullscreen(on);
971
+ else if (state === 'hidden' && on) this.wl.minimize();
972
+ return this;
973
+ }
974
+
975
+ setWindowType() {
976
+ return this;
977
+ }
978
+
979
+ setSizeHints(hints = {}) {
980
+ if (this.isPopup) return this;
981
+ const s = this.wl.scale;
982
+ const i = this.insets;
983
+ if (hints.minWidth || hints.minHeight) {
984
+ this.wl.setMinSize(
985
+ (hints.minWidth ?? 0) / s + i.left + i.right,
986
+ (hints.minHeight ?? 0) / s + i.top + i.bottom,
987
+ );
988
+ }
989
+ if (hints.maxWidth || hints.maxHeight) {
990
+ this.wl.setMaxSize(
991
+ (hints.maxWidth ?? 0) / s + i.left + i.right,
992
+ (hints.maxHeight ?? 0) / s + i.top + i.bottom,
993
+ );
994
+ }
995
+ return this;
996
+ }
997
+
998
+ setTransientFor(id) {
999
+ if (this.isPopup) return this;
1000
+ const parent = id == null ? null : this.app.windowById(id);
1001
+ this.wl.setParent(parent?.wl ?? null);
1002
+ return this;
1003
+ }
1004
+
1005
+ /**
1006
+ * The drop side (src/wayland/dnd.js): the window node hands over its
1007
+ * DropSession at realize (`_initDnd`), and from then on the app-wide
1008
+ * `WaylandDnd` routes this surface's data-device events into it. The
1009
+ * method's presence is what tells the tree this backend has drop machinery
1010
+ * of its own.
1011
+ */
1012
+ attachDropTransport(session, node) {
1013
+ this.app.dnd?.attach(this, session, node);
1014
+ return this;
1015
+ }
1016
+
1017
+ /**
1018
+ * The source side: hand a `DragSession`'s gesture to a `wl_data_source`
1019
+ * (see src/wayland/dnd.js). Called by `DragSession._start` once the
1020
+ * threshold is crossed; returns at once, and the session reports back on
1021
+ * the source's own events.
1022
+ */
1023
+ beginDrag(session) {
1024
+ return this.app.dnd?.beginDrag(this, session) ?? null;
1025
+ }
1026
+
1027
+ /** The tree's cursor over the content. */
1028
+ setCursor(name) {
1029
+ this.treeCursor = name ?? 'default';
1030
+ if (
1031
+ this.app.input?.pointerWindow === this &&
1032
+ !this.app.input._frameCursor
1033
+ ) {
1034
+ this.app.seat.setCursor(this.treeCursor);
1035
+ }
1036
+ return this;
1037
+ }
1038
+
1039
+ /**
1040
+ * A popup's grab: the press outside it that should dismiss it arrives
1041
+ * here rather than at whatever is under the pointer. Must precede the
1042
+ * popup's first buffer, which is why the request is sent now and the
1043
+ * callback answers on the next tick.
1044
+ */
1045
+ grabPointer(_opts, callback) {
1046
+ let error = null;
1047
+ if (this.isPopup) {
1048
+ const serial = this.app.seat.lastPressSerial || this.app.seat.lastSerial;
1049
+ try {
1050
+ // Recorded for the initial commit, and false once that is over: a
1051
+ // grab taken then holds for as long as the popup is mapped, and
1052
+ // there is no second one to take.
1053
+ this.wl.takeGrab(this.app.seat.seat, serial);
1054
+ } catch (err) {
1055
+ error = err;
1056
+ }
1057
+ }
1058
+ queueMicrotask(() => callback?.(error));
1059
+ return this;
1060
+ }
1061
+
1062
+ ungrabPointer() {
1063
+ // a popup's grab ends when the popup does
1064
+ return this;
1065
+ }
1066
+
1067
+ raise() {
1068
+ return this;
1069
+ }
1070
+
1071
+ lower() {
1072
+ return this;
1073
+ }
1074
+
1075
+ map() {
1076
+ this._wantMapped = true;
1077
+ // A popup's initial commit waits for its map. The grab the tree takes
1078
+ // straight after mapping (`PopupNode._mapNow`) must reach the compositor
1079
+ // before that commit, and a popup the tree never maps — born `hidden`,
1080
+ // or anchored off screen — should never be configured, let alone shown.
1081
+ // A microtask, so that grab lands first.
1082
+ if (this.wl.initialCommitPending) {
1083
+ queueMicrotask(() => this.wl.commitInitial());
1084
+ }
1085
+ this._armFrame();
1086
+ return this;
1087
+ }
1088
+
1089
+ unmap() {
1090
+ this.wl.unmap();
1091
+ return this;
1092
+ }
1093
+
1094
+ /**
1095
+ * `xdg_popup.popup_done`: the compositor has dismissed this popup — a
1096
+ * press outside it, Escape, the parent losing focus — and already hidden
1097
+ * it. On X11 the grab brings that press here, outside the window, and the
1098
+ * event manager answers it with `onDismiss` (`_dismissOutside`); here the
1099
+ * compositor keeps the press, so the same press is made up, outside, for
1100
+ * the same answer. Without it the tree still thought the menu open while
1101
+ * the screen showed it gone, and the next click on its button closed
1102
+ * nothing.
1103
+ */
1104
+ _dismissedByCompositor() {
1105
+ this.emit('mousedown', {
1106
+ x: -1,
1107
+ y: -1,
1108
+ rootx: -1,
1109
+ rooty: -1,
1110
+ button: 1,
1111
+ buttons: 0,
1112
+ time: Date.now() >>> 0,
1113
+ dismissed: true,
1114
+ });
1115
+ }
1116
+
1117
+ /**
1118
+ * Where this popup's surface lies in the surface of the toplevel its chain
1119
+ * hangs from, in logical pixels: each configure's offset from its parent's
1120
+ * window geometry down the chain, plus the toplevel's shadow margin (a
1121
+ * popup's geometry is its whole surface). What turns a rectangle in the
1122
+ * toplevel into one in the popup — the text-input caret, when mutter has
1123
+ * given a grabbing popup the keyboard.
1124
+ */
1125
+ offsetInRoot() {
1126
+ let x = 0;
1127
+ let y = 0;
1128
+ let w = this;
1129
+ for (let i = 0; w?.isPopup && w.parentWindow && i < 16; i++) {
1130
+ x += w.wl?.x ?? 0;
1131
+ y += w.wl?.y ?? 0;
1132
+ w = w.parentWindow;
1133
+ }
1134
+ const m = w?.wl?.margins ?? { left: 0, top: 0 };
1135
+ return { x: x + m.left, y: y + m.top, root: w };
1136
+ }
1137
+
1138
+ /**
1139
+ * The desktop's frame style or its colours changed (app.js). The frame
1140
+ * repaints; if the titlebar's height moved with the title's font, the
1141
+ * content keeps its size where the compositor lets it and the surface
1142
+ * grows or shrinks round it, and the tree hears a resize where it does
1143
+ * not.
1144
+ */
1145
+ _frameStyleChanged() {
1146
+ if (!this.decor) return;
1147
+ const before = this.insets.top;
1148
+ this.decor.setStyle(this.app.frameStyle?.value, appearanceSnapshot());
1149
+ const delta = this.insets.top - before;
1150
+ this._frameDirty = true;
1151
+ if (delta) {
1152
+ if (!this.wl.sizeImposed) this.wl.height += delta;
1153
+ this._resized = true;
1154
+ this.emit('resize', {
1155
+ width: this.width,
1156
+ height: this.height,
1157
+ x: 0,
1158
+ y: 0,
1159
+ });
1160
+ }
1161
+ this._armFrame();
1162
+ }
1163
+
1164
+ /** The compositor (or the frame's close button) asked; the tree decides. */
1165
+ requestClose() {
1166
+ let prevented = false;
1167
+ this.emit('close', {
1168
+ preventDefault() {
1169
+ prevented = true;
1170
+ },
1171
+ get defaultPrevented() {
1172
+ return prevented;
1173
+ },
1174
+ });
1175
+ if (!prevented) this.app.emit('closeRequest', this);
1176
+ }
1177
+
1178
+ focus() {
1179
+ // Focus is the compositor's to give. xdg-activation can ask, with a token
1180
+ // from a recent interaction; without one the request is ignored.
1181
+ this.app.activate(this);
1182
+ return this;
1183
+ }
1184
+
1185
+ destroy() {
1186
+ if (this._destroyed) return;
1187
+ this._destroyed = true;
1188
+ this._raf.length = 0;
1189
+ this._frameWatch.stop();
1190
+ this.app.dnd?.detach(this);
1191
+ this.app.makeCurrent();
1192
+ for (const ctx of this._contexts.values()) ctx.destroy?.();
1193
+ this._contexts.clear();
1194
+ this.glctx.destroy();
1195
+ this.app._forgetWindow(this);
1196
+ this.wl.destroy();
1197
+ this.emit('_destroyed');
1198
+ }
1199
+ }