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,854 @@
1
+ // A surface with a shell role — a toplevel or a popup — and the handshake
2
+ // that is the biggest single difference between this backend and X11.
3
+ //
4
+ // On X11 a client asks and the server answers: `GetGeometry` returns a size,
5
+ // `ConfigureWindow` sets one, and a request takes effect as issued. Here the
6
+ // client *requests* and the compositor *decides*. A window is created with no
7
+ // size at all; the compositor sends `configure` saying how big it should be
8
+ // and what state it is in, the client acknowledges that serial, and only then
9
+ // may it show a buffer at that size. Nothing about a window's position is
10
+ // knowable, ever.
11
+ //
12
+ // The sequence is strict and a compositor will disconnect a client that gets
13
+ // it wrong:
14
+ //
15
+ // create wl_surface -> get_xdg_surface -> get_toplevel / get_popup
16
+ // commit with NO buffer (asks for the first configure)
17
+ // <- xdg_toplevel.configure(w, h, states) (0x0 means "you choose")
18
+ // <- xdg_surface.configure(serial)
19
+ // ack_configure(serial) -> attach -> damage -> commit
20
+ //
21
+ // **The ack is deferred to the frame that adopts the state.** Acking as soon
22
+ // as the configure arrives is legal, but it tells the compositor the *next*
23
+ // commit is at the new size — and if that commit carries a buffer the
24
+ // renderer painted at the old size, the compositor shows it stretched. So the
25
+ // serial is held and `ackPending()` is called immediately before the commit
26
+ // of a frame painted at the configured size (glcontext.js does this), which
27
+ // is what makes an interactive resize look right.
28
+ //
29
+ // Everything here is synchronous through the library's `$` namespace, because
30
+ // React's commit phase is synchronous and `WindowNode.realize()` calls
31
+ // `createWindow` inside it. The one genuinely asynchronous step — binding
32
+ // globals — is done once by the app at startup.
33
+ //
34
+ // Two more things hang off the same class. A *layer surface* (layershell.js)
35
+ // is what a dock or a wallpaper is: no xdg_surface, a `zwlr_layer_surface_v1`
36
+ // whose configure carries a size and is acked through it. And a toplevel may
37
+ // carry a *decoration* object (ssd.js) whose mode — who draws the frame —
38
+ // rides the same configure sequence and is adopted just before 'configure'
39
+ // is emitted, so the frame that acks it is painted at the right insets.
40
+
41
+ import { EventEmitter } from 'node:events';
42
+ import { createServerDecoration } from './ssd.js';
43
+ import { createLayerSurface } from './layershell.js';
44
+
45
+ /** `xdg_toplevel.state` values. */
46
+ export const TOPLEVEL_STATE = {
47
+ MAXIMIZED: 1,
48
+ FULLSCREEN: 2,
49
+ RESIZING: 3,
50
+ ACTIVATED: 4,
51
+ TILED_LEFT: 5,
52
+ TILED_RIGHT: 6,
53
+ TILED_TOP: 7,
54
+ TILED_BOTTOM: 8,
55
+ SUSPENDED: 9,
56
+ };
57
+
58
+ /** `xdg_toplevel.resize_edge`. */
59
+ export const RESIZE_EDGE = {
60
+ NONE: 0,
61
+ TOP: 1,
62
+ BOTTOM: 2,
63
+ LEFT: 4,
64
+ TOP_LEFT: 5,
65
+ BOTTOM_LEFT: 6,
66
+ RIGHT: 8,
67
+ TOP_RIGHT: 9,
68
+ BOTTOM_RIGHT: 10,
69
+ };
70
+
71
+ /** `xdg_positioner.anchor` / `.gravity` share one numbering. */
72
+ const ANCHOR = {
73
+ NONE: 0,
74
+ TOP: 1,
75
+ BOTTOM: 2,
76
+ LEFT: 3,
77
+ RIGHT: 4,
78
+ TOP_LEFT: 5,
79
+ BOTTOM_LEFT: 6,
80
+ TOP_RIGHT: 7,
81
+ BOTTOM_RIGHT: 8,
82
+ };
83
+ /** `xdg_positioner.constraint_adjustment` bits. */
84
+ const ADJUST = {
85
+ SLIDE_X: 1,
86
+ SLIDE_Y: 2,
87
+ FLIP_X: 4,
88
+ FLIP_Y: 8,
89
+ RESIZE_X: 16,
90
+ RESIZE_Y: 32,
91
+ };
92
+
93
+ /** What a toplevel falls back to when the compositor says "you choose". */
94
+ const DEFAULT_SIZE = { width: 800, height: 600 };
95
+
96
+ /** fractional-scale-v1 reports scale × 120. */
97
+ const SCALE_DENOM = 120;
98
+
99
+ /**
100
+ * How far outside the window the input region reaches, into the margin a
101
+ * client-side shadow is drawn in: the band a resize grab still lands in
102
+ * (decorations.js `RESIZE_MARGIN`). Beyond it, input goes to whatever is
103
+ * under the shadow, as it does beside a GTK window.
104
+ */
105
+ const INPUT_BAND = 8;
106
+
107
+ const NO_MARGINS = Object.freeze({ left: 0, top: 0, right: 0, bottom: 0 });
108
+
109
+ /**
110
+ * A positioner for a popup at a point (its top-left at `x`, `y` in the
111
+ * parent's window geometry) or under an anchor rectangle, sliding and
112
+ * flipping to stay on screen the way `anchor.js` would have done it.
113
+ */
114
+ function popupPositioner(wmBase, { x, y, width, height, anchorRect = null }) {
115
+ const p = wmBase.$.create_positioner();
116
+ p.$.set_size(Math.max(1, Math.round(width)), Math.max(1, Math.round(height)));
117
+ if (anchorRect) {
118
+ p.$.set_anchor_rect(
119
+ Math.round(anchorRect.x),
120
+ Math.round(anchorRect.y),
121
+ Math.max(1, Math.round(anchorRect.width)),
122
+ Math.max(1, Math.round(anchorRect.height)),
123
+ );
124
+ p.$.set_anchor(ANCHOR.BOTTOM_LEFT);
125
+ } else {
126
+ p.$.set_anchor_rect(Math.round(x), Math.round(y), 1, 1);
127
+ p.$.set_anchor(ANCHOR.TOP_LEFT);
128
+ }
129
+ p.$.set_gravity(ANCHOR.BOTTOM_RIGHT);
130
+ p.$.set_constraint_adjustment(
131
+ ADJUST.SLIDE_X | ADJUST.SLIDE_Y | ADJUST.FLIP_Y,
132
+ );
133
+ if (p.version >= 3) p.$.set_reactive();
134
+ return p;
135
+ }
136
+
137
+ export class WaylandWindow extends EventEmitter {
138
+ constructor({ conn, surface, xdgSurface, role, kind, size, parent = null }) {
139
+ super();
140
+ this.conn = conn;
141
+ /** the `wl_surface` — what a renderer attaches buffers to */
142
+ this.surface = surface;
143
+ this.xdgSurface = xdgSurface;
144
+ /** the `xdg_toplevel` or `xdg_popup` */
145
+ this.role = role;
146
+ /** 'toplevel' | 'popup' */
147
+ this.kind = kind;
148
+ this.parent = parent;
149
+ this.toplevel = kind === 'toplevel' ? role : null;
150
+ this.popup = kind === 'popup' ? role : null;
151
+
152
+ /** logical size, as the compositor last configured (or we chose) */
153
+ this.width = size.width;
154
+ this.height = size.height;
155
+ /** popups: position relative to the parent, as configured */
156
+ this.x = 0;
157
+ this.y = 0;
158
+ /**
159
+ * Output scale. Integer from `preferred_buffer_scale`, refined to a
160
+ * fraction by fractional-scale-v1 when the compositor has it. The buffer
161
+ * is `logical × scale`; with a viewport the buffer scale stays 1.
162
+ */
163
+ this.scale = 1;
164
+ this.states = new Set();
165
+ /**
166
+ * The `wl_output` proxies (by id) this surface currently overlaps, from
167
+ * `wl_surface.enter`/`leave` — the only word a client gets on where it
168
+ * is. Empty until the surface is mapped; outputs.js turns ids into
169
+ * monitors.
170
+ */
171
+ this.outputs = new Set();
172
+ /** `xdg_toplevel.configure_bounds`: the logical size to fit in, or null */
173
+ this.bounds = null;
174
+ /** a configure has arrived: a buffer may be committed once it is acked */
175
+ this.configured = false;
176
+ this.mapped = false;
177
+ this.destroyed = false;
178
+
179
+ this._pendingSerial = null;
180
+ this._preferredBufferScale = null;
181
+ this._configured = new Promise((resolve) => {
182
+ this._onConfigured = resolve;
183
+ });
184
+ this._viewport = null;
185
+ this._fractional = null;
186
+ this._geometry = null;
187
+ /** the xdg-decoration object, where the compositor has the protocol (ssd.js) */
188
+ this.decoration = null;
189
+ /** a layer surface's role (layershell.js); null on a toplevel or popup */
190
+ this.layer = null;
191
+ this.layerSurface = null;
192
+ /** the last toplevel configure named a size: maximised, tiled, mid-resize */
193
+ this.sizeImposed = false;
194
+ /** a popup made with `commit: false`, waiting for its map to commit */
195
+ this._initialCommitPending = false;
196
+ /** a popup's setup is over — its initial commit went out — and so is its
197
+ * chance to take a grab */
198
+ this._setupDone = false;
199
+ /** a popup's role-to-be: placement and grab, until its initial commit */
200
+ this._popupSetup = null;
201
+ /**
202
+ * The margin around the window a client-side shadow is drawn in, part of
203
+ * the surface and not of the window: `width`/`height` are the surface's,
204
+ * and window geometry (what the compositor configures, places and tiles)
205
+ * is the surface less this. `marginsFor(states)` answers it per state —
206
+ * a maximised or tiled window has none.
207
+ */
208
+ this.margins = NO_MARGINS;
209
+ this.marginsFor = null;
210
+ /** `wl_compositor`, for the input region; set by whoever made the window */
211
+ this.compositor = null;
212
+ }
213
+
214
+ // ---- creation --------------------------------------------------------------
215
+
216
+ /**
217
+ * A toplevel, built without waiting for anything.
218
+ *
219
+ * The window is not yet usable for painting: the first buffer may only be
220
+ * attached after the first `configure` has been acked. `configured` says
221
+ * when, and `whenConfigured` is the promise for anyone who can wait.
222
+ *
223
+ * @param {object} opts
224
+ * @param {{ manager: object, prefer: 'server'|'client' }} [opts.decorations]
225
+ * the `zxdg_decoration_manager_v1` proxy and which side should draw the
226
+ * frame; omitted where the compositor has no such protocol
227
+ */
228
+ static createSync({
229
+ conn,
230
+ compositor,
231
+ wmBase,
232
+ title,
233
+ appId,
234
+ width,
235
+ height,
236
+ minSize,
237
+ maxSize,
238
+ parent = null,
239
+ decorations = null,
240
+ }) {
241
+ wirePing(wmBase);
242
+ const surface = compositor.$.create_surface();
243
+ const xdgSurface = wmBase.$.get_xdg_surface(surface.id);
244
+ const toplevel = xdgSurface.$.get_toplevel();
245
+
246
+ const win = new WaylandWindow({
247
+ conn,
248
+ surface,
249
+ xdgSurface,
250
+ role: toplevel,
251
+ kind: 'toplevel',
252
+ parent,
253
+ size: {
254
+ width: width ?? DEFAULT_SIZE.width,
255
+ height: height ?? DEFAULT_SIZE.height,
256
+ },
257
+ });
258
+ win._wireCommon();
259
+ win._wireToplevel();
260
+
261
+ toplevel.$.set_title(title ?? 'react-x11');
262
+ toplevel.$.set_app_id(appId ?? 'react-x11');
263
+ if (parent?.toplevel) toplevel.$.set_parent(parent.toplevel.id);
264
+ if (minSize) toplevel.$.set_min_size(minSize.width | 0, minSize.height | 0);
265
+ if (maxSize) toplevel.$.set_max_size(maxSize.width | 0, maxSize.height | 0);
266
+ // Who draws the frame is asked before the first commit; the answer rides
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();
278
+ // the empty commit that asks for the first configure
279
+ surface.$.commit();
280
+ return win;
281
+ }
282
+
283
+ /**
284
+ * A layer surface — a dock, a panel, a wallpaper, an overlay — where the
285
+ * compositor has wlr-layer-shell. The body, and the meaning of the
286
+ * options, live in layershell.js.
287
+ */
288
+ static createLayerSync(opts) {
289
+ return createLayerSurface(WaylandWindow, opts);
290
+ }
291
+
292
+ /**
293
+ * A popup, positioned relative to its parent surface.
294
+ *
295
+ * Where an X `<popup>` places itself at screen coordinates it had to
296
+ * learn, an `xdg_popup` describes *where it wants to be relative to the
297
+ * parent* and how it may be adjusted, and the compositor places it. The
298
+ * flip/slide policy react-x11's `anchor.js` implements client-side on X11
299
+ * is exactly the positioner's constraint adjustment, so the intent
300
+ * transfers; the math moves to the other side of the socket.
301
+ *
302
+ * @param {object} opts
303
+ * @param {WaylandWindow} opts.parent the toplevel or popup this hangs off
304
+ * @param {number} opts.x anchor point, in the parent's surface coordinates
305
+ * @param {number} opts.y
306
+ * @param {number} opts.width requested size
307
+ * @param {number} opts.height
308
+ * @param {object} [opts.grab] `{ seat, serial }` to take an implicit grab
309
+ * (menus: the press outside that dismisses them arrives here)
310
+ */
311
+ static createPopupSync({
312
+ conn,
313
+ compositor,
314
+ wmBase,
315
+ parent,
316
+ x,
317
+ y,
318
+ width,
319
+ height,
320
+ grab = null,
321
+ anchorRect = null,
322
+ commit = true,
323
+ }) {
324
+ if (!parent?.xdgSurface && !parent?.layerSurface)
325
+ throw new Error('a popup needs a parent window on this connection');
326
+ wirePing(wmBase);
327
+ const w = Math.max(1, Math.round(width || 1));
328
+ const h = Math.max(1, Math.round(height || 1));
329
+ const surface = compositor.$.create_surface();
330
+ const xdgSurface = wmBase.$.get_xdg_surface(surface.id);
331
+ const win = new WaylandWindow({
332
+ conn,
333
+ surface,
334
+ xdgSurface,
335
+ role: null,
336
+ kind: 'popup',
337
+ parent,
338
+ size: { width: w, height: h },
339
+ });
340
+ win.x = x;
341
+ win.y = y;
342
+ win._wireCommon();
343
+ // The role — positioner, `get_popup`, grab — is assigned at the initial
344
+ // commit, from the placement and grab the popup has by then.
345
+ // `commit: false` leaves that commit to `commitInitial()`, which the
346
+ // tree's map calls: a grab asked for at map time still precedes it (see
347
+ // `takeGrab`), and a popup moved before it is shown — a tooltip that
348
+ // measured itself at (0, 0) — is placed right the first time instead of
349
+ // flashing in its parent's corner until a reposition lands.
350
+ win._popupSetup = {
351
+ wmBase,
352
+ parent,
353
+ placement: { x, y, width: w, height: h, anchorRect },
354
+ grab: grab?.seat && grab.serial != null ? grab : null,
355
+ };
356
+ win._initialCommitPending = true;
357
+ if (commit) win.commitInitial();
358
+ return win;
359
+ }
360
+
361
+ /** The xdg_popup role, from the placement and grab asked for so far. */
362
+ _assignPopupRole() {
363
+ const { wmBase, parent, placement, grab } = this._popupSetup;
364
+ this._popupSetup = null;
365
+ const positioner = popupPositioner(wmBase, placement);
366
+ // A layer surface is not an xdg_surface: its popups are created with no
367
+ // parent and adopted by the layer surface before their first commit.
368
+ const popup = parent.xdgSurface
369
+ ? this.xdgSurface.$.get_popup(parent.xdgSurface.id, positioner.id)
370
+ : this.xdgSurface.$.get_popup(null, positioner.id);
371
+ positioner.$.destroy();
372
+ if (!parent.xdgSurface) parent.layerSurface.$.get_popup(popup.id);
373
+ this.role = popup;
374
+ this.popup = popup;
375
+ popup.on('configure', (px, py, pw, ph) => {
376
+ this.x = px;
377
+ this.y = py;
378
+ if (pw > 0 && ph > 0) {
379
+ const changed = pw !== this.width || ph !== this.height;
380
+ this.width = pw;
381
+ this.height = ph;
382
+ if (changed) this.emit('resize', { width: pw, height: ph });
383
+ }
384
+ });
385
+ popup.on('popup_done', () => this.emit('close'));
386
+ popup.on('repositioned', () => {});
387
+ if (grab) popup.$.grab(grab.seat.id, grab.serial);
388
+ }
389
+
390
+ // ---- events --------------------------------------------------------------
391
+
392
+ _wireCommon() {
393
+ this.xdgSurface?.on('configure', (serial) =>
394
+ this._onShellConfigure(serial),
395
+ );
396
+ this.surface.on('preferred_buffer_scale', (scale) => {
397
+ // Only authoritative when fractional scale is not in play.
398
+ if (scale > 0) this._preferredBufferScale = scale;
399
+ if (!this._fractional && scale > 0 && scale !== this.scale)
400
+ this._setScale(scale);
401
+ });
402
+ this.surface.on('enter', (output) => {
403
+ this.outputs.add(output);
404
+ this.emit('outputs', this.outputs);
405
+ });
406
+ this.surface.on('leave', (output) => {
407
+ if (this.outputs.delete(output)) this.emit('outputs', this.outputs);
408
+ });
409
+ this.surface.on('preferred_buffer_transform', () => {});
410
+ }
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
+
436
+ /**
437
+ * The shell's configure — xdg_surface's, or the layer surface's with its
438
+ * size already taken — holds the serial for the frame that adopts it. A
439
+ * decoration mode that arrived with it is adopted first, so a listener
440
+ * hears 'decorationmode' before 'configure' and paints at the new insets.
441
+ */
442
+ _onShellConfigure(serial) {
443
+ this._pendingSerial = serial;
444
+ if (this.decoration?.adopt()) {
445
+ this.emit('decorationmode', this.decoration.mode);
446
+ }
447
+ const first = !this.configured;
448
+ this.configured = true;
449
+ if (first) this._onConfigured?.();
450
+ this.emit('configure', {
451
+ width: this.width,
452
+ height: this.height,
453
+ states: this.states,
454
+ serial,
455
+ });
456
+ }
457
+
458
+ _wireToplevel() {
459
+ this.toplevel.on('configure', (width, height, states) => {
460
+ // 0x0 is "pick your own" — the compositor is not imposing a size.
461
+ this.sizeImposed = width > 0 && height > 0;
462
+ const next = decodeStates(states);
463
+ // The configured size is the window geometry's. The surface is that
464
+ // plus the shadow's margin, which depends on the states in this same
465
+ // configure: a window that is being maximised loses its margin in the
466
+ // configure that maximises it.
467
+ const was = this.margins;
468
+ const m = this.marginsFor?.(next) ?? was;
469
+ const gw = width > 0 ? width : this.width - was.left - was.right;
470
+ const gh = height > 0 ? height : this.height - was.top - was.bottom;
471
+ const w = Math.max(1, gw + m.left + m.right);
472
+ const h = Math.max(1, gh + m.top + m.bottom);
473
+ this.margins = m;
474
+ if (w !== this.width || h !== this.height) {
475
+ this.width = w;
476
+ this.height = h;
477
+ this.emit('resize', { width: w, height: h });
478
+ }
479
+ const wasStates = this.states;
480
+ this.states = next;
481
+ if (!sameSet(wasStates, next)) this.emit('statechange', [...next]);
482
+ });
483
+ this.toplevel.on('close', () => this.emit('close'));
484
+ this.toplevel.on('configure_bounds', (w, h) => {
485
+ // The size the compositor recommends fitting in — a monitor less its
486
+ // panels, on GNOME. 0×0 withdraws it. The closest thing this protocol
487
+ // has to a work area, so outputs.js listens.
488
+ this.bounds = w > 0 && h > 0 ? { width: w, height: h } : null;
489
+ this.emit('bounds', this.bounds);
490
+ });
491
+ this.toplevel.on('wm_capabilities', (caps) => {
492
+ this.wmCapabilities = decodeStates(caps);
493
+ });
494
+ }
495
+
496
+ /** Attach fractional scale and a viewport, when the compositor offers them. */
497
+ useScaling({ fractionalScaleManager, viewporter }) {
498
+ if (viewporter && !this._viewport) {
499
+ this._viewport = viewporter.$.get_viewport(this.surface.id);
500
+ }
501
+ if (fractionalScaleManager && !this._fractional) {
502
+ this._fractional = fractionalScaleManager.$.get_fractional_scale(
503
+ this.surface.id,
504
+ );
505
+ this._fractional.on('preferred_scale', (numerator) => {
506
+ const scale = numerator / SCALE_DENOM;
507
+ if (scale > 0 && scale !== this.scale) this._setScale(scale);
508
+ });
509
+ }
510
+ }
511
+
512
+ /**
513
+ * The scale of the output(s) under the surface, as the last resort.
514
+ *
515
+ * Three sources, in order of authority: fractional-scale-v1 (a fraction,
516
+ * per surface), `wl_surface.preferred_buffer_scale` (an integer, per
517
+ * surface, wl_compositor 6), and this — the integer `wl_output.scale` of
518
+ * whatever the surface has entered, which is all a compositor with neither
519
+ * of the first two offers. Ignored the moment either of them has spoken.
520
+ */
521
+ noteOutputScale(scale) {
522
+ if (this._fractional || this._preferredBufferScale != null) return;
523
+ if (scale > 0 && scale !== this.scale) this._setScale(scale);
524
+ }
525
+
526
+ _setScale(scale) {
527
+ this.scale = scale;
528
+ this._applyScale();
529
+ this.emit('scale', scale);
530
+ }
531
+
532
+ /**
533
+ * Tell the compositor how the buffer maps onto the surface.
534
+ *
535
+ * With a viewport the buffer can be any size and the destination is the
536
+ * logical size, which is how a fractional scale is expressed (a buffer
537
+ * scale must be an integer). Without one, the buffer scale carries it.
538
+ */
539
+ _applyScale() {
540
+ if (this._viewport) {
541
+ this._viewport.$.set_destination(this.width, this.height);
542
+ if (this.surface.version >= 3) this.surface.$.set_buffer_scale(1);
543
+ } else if (this.surface.version >= 3) {
544
+ this.surface.$.set_buffer_scale(Math.max(1, Math.round(this.scale)));
545
+ }
546
+ }
547
+
548
+ /** The size the backing buffer has to be for the current scale. */
549
+ get bufferWidth() {
550
+ return Math.max(1, Math.round(this.width * this.scale));
551
+ }
552
+
553
+ get bufferHeight() {
554
+ return Math.max(1, Math.round(this.height * this.scale));
555
+ }
556
+
557
+ /** Resolves once the first configure has arrived. */
558
+ get whenConfigured() {
559
+ return this._configured;
560
+ }
561
+
562
+ /**
563
+ * Acknowledge the configure the next commit adopts, and record the surface
564
+ * geometry it implies. Called by the frame that was painted at this size,
565
+ * immediately before its commit.
566
+ */
567
+ ackPending() {
568
+ if (this._pendingSerial != null) {
569
+ (this.xdgSurface ?? this.layerSurface).$.ack_configure(
570
+ this._pendingSerial,
571
+ );
572
+ this._pendingSerial = null;
573
+ }
574
+ const m = this.margins;
575
+ const g = this._geometry;
576
+ if (!g || g.width !== this.width || g.height !== this.height || g.m !== m) {
577
+ this._geometry = { width: this.width, height: this.height, m };
578
+ // The window is the surface less a client-side shadow's margin; a
579
+ // layer surface has no window geometry — the surface is the window.
580
+ const gw = Math.max(1, this.width - m.left - m.right);
581
+ const gh = Math.max(1, this.height - m.top - m.bottom);
582
+ this.xdgSurface?.$.set_window_geometry(m.left, m.top, gw, gh);
583
+ this._applyInputRegion(m, gw, gh);
584
+ this._applyScale();
585
+ }
586
+ }
587
+
588
+ /**
589
+ * New margins, keeping the window geometry: the surface grows or shrinks
590
+ * round the window, which the compositor sized and placed. Margins change
591
+ * outside a configure too — a decoration mode the compositor settled after
592
+ * the first frame (sway answers server-side, then client-side), and the
593
+ * size has to follow, or the window shrinks by its own shadow.
594
+ */
595
+ setMargins(m) {
596
+ const was = this.margins;
597
+ if (!m || m === was) return;
598
+ const w = Math.max(1, this.width - was.left - was.right + m.left + m.right);
599
+ const h = Math.max(
600
+ 1,
601
+ this.height - was.top - was.bottom + m.top + m.bottom,
602
+ );
603
+ this.margins = m;
604
+ if (w !== this.width || h !== this.height) {
605
+ this.width = w;
606
+ this.height = h;
607
+ this.emit('resize', { width: w, height: h });
608
+ }
609
+ }
610
+
611
+ /**
612
+ * Input goes to the window and a resize band round it, not to the rest of
613
+ * the shadow — a press on the soft edge of a shadow belongs to what is
614
+ * under it. With no margin, the whole surface.
615
+ */
616
+ _applyInputRegion(m, gw, gh) {
617
+ if (!this.compositor || !this.xdgSurface) return;
618
+ const shadowed = m.left || m.top || m.right || m.bottom;
619
+ if (!shadowed && !this._inputRegionSet) return;
620
+ const band = shadowed ? INPUT_BAND : 0;
621
+ const x = Math.max(0, m.left - band);
622
+ const y = Math.max(0, m.top - band);
623
+ const right = Math.min(this.width, m.left + gw + band);
624
+ const bottom = Math.min(this.height, m.top + gh + band);
625
+ const region = this.compositor.$.create_region();
626
+ region.$.add(x, y, right - x, bottom - y);
627
+ this.surface.$.set_input_region(region.id);
628
+ region.$.destroy();
629
+ this._inputRegionSet = true;
630
+ }
631
+
632
+ /** Who draws the frame: 'server' once a compositor has agreed to, else 'client'. */
633
+ get decorationMode() {
634
+ return this.decoration?.mode ?? 'client';
635
+ }
636
+
637
+ /** Whether the compositor has sent a configure we have not adopted yet. */
638
+ get configurePending() {
639
+ return this._pendingSerial != null;
640
+ }
641
+
642
+ // ---- frame clock ---------------------------------------------------------
643
+
644
+ /**
645
+ * Ask to be told when it is a good time to draw the next frame, and hand
646
+ * back the promise that says so.
647
+ *
648
+ * **Call this before the frame's commit, and await it after.** A frame
649
+ * request is only *delivered* by the next commit, so the canonical loop
650
+ * puts the request and the buffer in the same commit. Committing
651
+ * separately to deliver the request costs a whole refresh period — the
652
+ * compositor takes the empty commit as the frame, and the next real one
653
+ * lands a vblank late — which halves the frame rate with nothing in a
654
+ * screenshot to show for it.
655
+ *
656
+ * The library writes a request's bytes synchronously before returning its
657
+ * promise, so not awaiting this until after the commit is what sequences
658
+ * the two.
659
+ *
660
+ * @returns {Promise<number>} the frame timestamp, in the compositor's
661
+ * millisecond clock
662
+ */
663
+ scheduleFrame() {
664
+ if (this.destroyed) return Promise.resolve(0);
665
+ const done = this.surface.frame().then((time) => {
666
+ if (!this.destroyed) this.emit('frame', time);
667
+ return time;
668
+ });
669
+ // The caller attaches its handlers after an await or two; a connection
670
+ // dying in that window (process exit under Bun kills the reader thread
671
+ // first) would otherwise surface as an unhandled rejection. Marking it
672
+ // handled here changes nothing for a caller that does handle it.
673
+ done.catch(() => {});
674
+ return done;
675
+ }
676
+
677
+ // ---- requests ------------------------------------------------------------
678
+
679
+ setTitle(title) {
680
+ this.toplevel?.$.set_title(String(title ?? ''));
681
+ }
682
+
683
+ setAppId(appId) {
684
+ this.toplevel?.$.set_app_id(String(appId ?? ''));
685
+ }
686
+
687
+ /** Size limits, in surface pixels like everything else here; the
688
+ * compositor is told the window's, without the shadow's margin. */
689
+ setMinSize(width, height) {
690
+ const m = this.margins;
691
+ this.toplevel?.$.set_min_size(
692
+ Math.max(0, (width - m.left - m.right) | 0),
693
+ Math.max(0, (height - m.top - m.bottom) | 0),
694
+ );
695
+ }
696
+
697
+ setMaxSize(width, height) {
698
+ const m = this.margins;
699
+ this.toplevel?.$.set_max_size(
700
+ Math.max(0, (width - m.left - m.right) | 0),
701
+ Math.max(0, (height - m.top - m.bottom) | 0),
702
+ );
703
+ }
704
+
705
+ setParent(parent) {
706
+ if (!this.toplevel) return;
707
+ // null clears the parent
708
+ this.toplevel.$.set_parent(parent?.toplevel ?? null);
709
+ }
710
+
711
+ maximize(on = true) {
712
+ if (!this.toplevel) return;
713
+ if (on) this.toplevel.$.set_maximized();
714
+ else this.toplevel.$.unset_maximized();
715
+ }
716
+
717
+ fullscreen(on = true) {
718
+ if (!this.toplevel) return;
719
+ // a null output: whichever the compositor puts a fullscreen window on
720
+ if (on) this.toplevel.$.set_fullscreen(null);
721
+ else this.toplevel.$.unset_fullscreen();
722
+ }
723
+
724
+ minimize() {
725
+ this.toplevel?.$.set_minimized();
726
+ }
727
+
728
+ /**
729
+ * Start an interactive move or resize.
730
+ *
731
+ * There is no other way to move a window: the client cannot place itself,
732
+ * so a titlebar drag is a *request* that hands the gesture to the
733
+ * compositor, which is also why it needs the serial of the button press
734
+ * that started it.
735
+ */
736
+ startMove(seatProxy, serial) {
737
+ this.toplevel?.$.move(seatProxy.id, serial);
738
+ }
739
+
740
+ startResize(seatProxy, serial, edges) {
741
+ this.toplevel?.$.resize(seatProxy.id, serial, edges);
742
+ }
743
+
744
+ /** `x`/`y` surface-local; the request wants them in window geometry. */
745
+ showWindowMenu(seatProxy, serial, x, y) {
746
+ const m = this.margins;
747
+ this.toplevel?.$.show_window_menu(
748
+ seatProxy.id,
749
+ serial,
750
+ (x - m.left) | 0,
751
+ (y - m.top) | 0,
752
+ );
753
+ }
754
+
755
+ /** Reposition a popup (xdg_popup v3): a new positioner, same surface. */
756
+ reposition(wmBase, { x, y, width, height }) {
757
+ if (this._popupSetup) {
758
+ // No role yet: this is the placement the initial commit will use.
759
+ this._popupSetup.placement = { x, y, width, height, anchorRect: null };
760
+ return true;
761
+ }
762
+ if (!this.popup || this.popup.version < 3) return false;
763
+ const p = popupPositioner(wmBase, { x, y, width, height });
764
+ this.popup.$.reposition(
765
+ p.id,
766
+ ++this._repositionToken || (this._repositionToken = 1),
767
+ );
768
+ p.$.destroy();
769
+ return true;
770
+ }
771
+
772
+ /**
773
+ * A popup's explicit grab. Only possible before its initial commit: the
774
+ * compositor finishes a popup's setup on that commit, and a grab after it
775
+ * is `invalid_grab` — a fatal protocol error, "tried to grab after popup
776
+ * was mapped". Every `<popup grab>` hit it while the grab rode the tree's
777
+ * map and the commit rode creation.
778
+ *
779
+ * @returns {boolean} whether the grab was sent
780
+ */
781
+ takeGrab(seat, serial) {
782
+ if (this.kind !== 'popup' || !this._popupSetup || this.destroyed)
783
+ return false;
784
+ this._popupSetup.grab = { seat, serial };
785
+ return true;
786
+ }
787
+
788
+ /** The initial commit of a popup made with `commit: false`. */
789
+ commitInitial() {
790
+ if (!this._initialCommitPending || this.destroyed) return;
791
+ this._initialCommitPending = false;
792
+ if (this._popupSetup) this._assignPopupRole();
793
+ this._setupDone = true;
794
+ this.surface.$.commit();
795
+ }
796
+
797
+ /** Whether a popup is still waiting for its initial commit. */
798
+ get initialCommitPending() {
799
+ return this._initialCommitPending;
800
+ }
801
+
802
+ /** Hide without destroying: a null buffer unmaps the surface. */
803
+ unmap() {
804
+ if (this.destroyed || !this.mapped) return;
805
+ this.surface.$.attach(null, 0, 0);
806
+ this.surface.$.commit();
807
+ this.mapped = false;
808
+ }
809
+
810
+ destroy() {
811
+ if (this.destroyed) return;
812
+ this.destroyed = true;
813
+ // Order matters: the decoration before the toplevel it decorates, the
814
+ // role before the surface it wraps.
815
+ try {
816
+ this.decoration?.destroy();
817
+ this._fractional?.$.destroy?.();
818
+ this._viewport?.$.destroy?.();
819
+ this.role?.$.destroy?.();
820
+ this.xdgSurface?.$.destroy?.();
821
+ this.surface.$.destroy?.();
822
+ } catch {
823
+ /* the connection may already be gone */
824
+ }
825
+ this.emit('destroyed');
826
+ }
827
+ }
828
+
829
+ /**
830
+ * A compositor pings to check the client is alive and kills it if it does
831
+ * not answer. Once per `xdg_wm_base`, which is once per connection.
832
+ */
833
+ function wirePing(wmBase) {
834
+ if (wmBase._pingWired) return;
835
+ wmBase._pingWired = true;
836
+ wmBase.on('ping', (serial) => wmBase.$.pong(serial));
837
+ }
838
+
839
+ /** `xdg_toplevel.configure`'s states arrive as a wl_array of uint32. */
840
+ function decodeStates(states) {
841
+ const out = new Set();
842
+ if (!states) return out;
843
+ const bytes = states instanceof Uint8Array ? states : new Uint8Array(states);
844
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
845
+ for (let i = 0; i + 4 <= bytes.byteLength; i += 4)
846
+ out.add(view.getUint32(i, true));
847
+ return out;
848
+ }
849
+
850
+ function sameSet(a, b) {
851
+ if (a.size !== b.size) return false;
852
+ for (const v of a) if (!b.has(v)) return false;
853
+ return true;
854
+ }