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,476 @@
1
+ // Client-side decorations: the frame a window draws for itself where the
2
+ // compositor will not — GNOME's mutter has no xdg-decoration at all, so
3
+ // without one a window there is a bare rectangle.
4
+ //
5
+ // It is drawn to look like the frames beside it, which on GNOME are
6
+ // libadwaita's: from what the desktop says (framestyle.js — the button
7
+ // layout, the title's font, what titlebar clicks mean; appearance.js — light
8
+ // or dark) and from a libadwaita 1.7 window measured on a GNOME 48 desktop:
9
+ //
10
+ // - a headerbar 47px tall for an 11pt title and as tall as a bigger title
11
+ // needs (48px for 23pt), a hairline under it, a 1px highlight along its
12
+ // top in the dark scheme, 12px corners;
13
+ // - the title bold in the interface font, centred across the window;
14
+ // - window buttons as 24px circles 37px apart, the outermost centred 24px
15
+ // from the edge, holding the icon theme's own 16px symbolic glyphs;
16
+ // - a button is the bar mixed towards the text colour: 10% at rest, 15%
17
+ // under the pointer, 30% pressed — which gives exactly the greys
18
+ // measured, in both schemes and both focus states;
19
+ // - an unfocused window's text at half strength over a flatter bar;
20
+ // - a soft shadow, larger for the focused window, cast downward.
21
+ //
22
+ // The shadow is drawn in a margin round the window that is part of the
23
+ // surface and not of the window: `set_window_geometry` excludes it, the
24
+ // input region stops a resize band into it, and a maximised or tiled window
25
+ // has none (window.js owns that boundary; `marginsFor` here says how big).
26
+ // `insets()` includes it, so everything that converts between surface and
27
+ // content coordinates — input, drag and drop, the text-input caret — needs
28
+ // no idea there is a shadow at all.
29
+ //
30
+ // Interaction is requests: a titlebar press starts `xdg_toplevel.move`, a
31
+ // press in the band starts `resize` with the edges it is nearest, and the
32
+ // buttons do what they say. input.js routes; this answers where things are.
33
+
34
+ import fs from 'node:fs';
35
+ import { Path2D } from 'ntk';
36
+ import { RESIZE_EDGE, TOPLEVEL_STATE } from './window.js';
37
+ import { DEFAULTS, parseButtonLayout, titleFont } from './framestyle.js';
38
+
39
+ /** how far outside the window a resize grab still lands (the input band) */
40
+ export const RESIZE_MARGIN = 8;
41
+ /** how far along an edge from a corner a grab resizes both ways */
42
+ const CORNER = 18;
43
+ const RADIUS = 12;
44
+ const SEPARATOR = 1;
45
+ const BUTTON = 24;
46
+ const BUTTON_STEP = 37;
47
+ const BUTTON_INSET = 24;
48
+
49
+ /** the room a shadow needs; a maximised, tiled or fullscreen window has none */
50
+ export const SHADOW_MARGINS = Object.freeze({
51
+ left: 30,
52
+ top: 26,
53
+ right: 30,
54
+ bottom: 38,
55
+ });
56
+ const NO_MARGINS = Object.freeze({ left: 0, top: 0, right: 0, bottom: 0 });
57
+
58
+ /** [focused, unfocused] per scheme; the rest is derived. */
59
+ const PALETTE = {
60
+ dark: {
61
+ bar: ['#2e2e32', '#222226'],
62
+ fg: ['#ffffff', '#909092'],
63
+ highlight: 'rgba(255, 255, 255, 0.07)',
64
+ separator: 0.36,
65
+ },
66
+ light: {
67
+ bar: ['#ffffff', '#fafafb'],
68
+ fg: ['#333338', '#959597'],
69
+ highlight: null,
70
+ separator: 0.12,
71
+ },
72
+ };
73
+
74
+ /**
75
+ * Shadow layers — offset down, feather, opacity — focused, then not. The
76
+ * rounded-rectangle shader feathers as `1 - smoothstep(-f, f, d)`: half
77
+ * strength at the window's edge, nothing `f` beyond it. Four such layers
78
+ * fit libadwaita's measured falloff (the alpha of black at 1, 5, 10, 20px
79
+ * from a focused window's edge: 0.18, 0.12, 0.071, 0.032 against 0.19,
80
+ * 0.12, 0.071, 0.023 here); an unfocused window's is tighter and fainter.
81
+ * The margins above leave the widest room enough to fade out before the
82
+ * surface ends, so no layer is cut off.
83
+ */
84
+ const SHADOWS = [
85
+ [
86
+ { dy: 1, blur: 6, alpha: 0.1 },
87
+ { dy: 2, blur: 18, alpha: 0.14 },
88
+ { dy: 3, blur: 34, alpha: 0.13 },
89
+ { dy: 4, blur: 48, alpha: 0.06 },
90
+ ],
91
+ [
92
+ { dy: 1, blur: 5, alpha: 0.12 },
93
+ { dy: 1, blur: 14, alpha: 0.14 },
94
+ { dy: 2, blur: 26, alpha: 0.06 },
95
+ ],
96
+ ];
97
+
98
+ const hexRgb = (hex) => {
99
+ const n = parseInt(hex.slice(1), 16);
100
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
101
+ };
102
+ /** `a` moved towards `b` by `t`, as a CSS colour. */
103
+ function mix(a, b, t) {
104
+ const x = hexRgb(a);
105
+ const y = hexRgb(b);
106
+ const c = x.map((v, i) => Math.round(v + (y[i] - v) * t));
107
+ return `rgb(${c[0]}, ${c[1]}, ${c[2]})`;
108
+ }
109
+
110
+ // The window buttons' glyphs, from the installed icon theme when it has
111
+ // them — they are the pixels every GTK window beside this one shows — and
112
+ // Adwaita's, embedded, when it does not.
113
+ const GLYPH_DIRS = [
114
+ '/usr/share/icons/Adwaita/symbolic/ui',
115
+ '/usr/share/icons/Adwaita/scalable/ui',
116
+ ];
117
+ const FALLBACK_GLYPHS = {
118
+ 'window-close':
119
+ 'M4.7 4 L8 7.3 L11.3 4 L12 4.7 L8.7 8 L12 11.3 L11.3 12 L8 8.7 L4.7 12 L4 11.3 L7.3 8 L4 4.7 Z',
120
+ 'window-maximize':
121
+ 'm 3.988281 3.992188 v 8.011718 h 8.011719 v -8.011718 z m 2 2 h 4.011719 v 4.011718 h -4.011719 z',
122
+ 'window-restore':
123
+ 'm 4.988281 4.992188 v 6.011718 h 6.011719 v -6.011718 z m 2 2 h 2.011719 v 2.011718 h -2.011719 z',
124
+ 'window-minimize': 'm 4 10.007812 h 8 v 1.988282 h -8 z',
125
+ };
126
+ const glyphs = new Map();
127
+ function glyph(name) {
128
+ if (glyphs.has(name)) return glyphs.get(name);
129
+ let path = null;
130
+ for (const dir of GLYPH_DIRS) {
131
+ try {
132
+ const svg = fs.readFileSync(`${dir}/${name}-symbolic.svg`, 'utf8');
133
+ const ds = [...svg.matchAll(/\sd="([^"]+)"/g)].map((m) => m[1]);
134
+ if (ds.length) {
135
+ path = new Path2D(ds.join(' '));
136
+ break;
137
+ }
138
+ } catch {
139
+ // not in this directory
140
+ }
141
+ }
142
+ path ??= new Path2D(FALLBACK_GLYPHS[name]);
143
+ glyphs.set(name, path);
144
+ return path;
145
+ }
146
+
147
+ export class Decorations {
148
+ /**
149
+ * @param {object} [opts]
150
+ * @param {boolean} [opts.enabled=true] false: no frame at all (the
151
+ * compositor draws one, or the window asked for none)
152
+ */
153
+ constructor({ enabled = true } = {}) {
154
+ this.enabled = enabled;
155
+ this.title = '';
156
+ this.active = false;
157
+ this.maximized = false;
158
+ this.tiled = false;
159
+ this.fullscreen = false;
160
+ this.hover = null; // 'close' | 'maximize' | 'minimize' | null
161
+ this.pressed = null;
162
+ /** `(text, size, weight, family) => { width, ascent, descent, runs } | null` */
163
+ this.measure = null;
164
+ /** the shell window's current margins (window.js) */
165
+ this.marginsOf = null;
166
+ /** asks for another frame when the title's font was still loading */
167
+ this.onNeedsRepaint = null;
168
+ this.setStyle(DEFAULTS, null);
169
+ }
170
+
171
+ /** The margin a shadow needs in these states. */
172
+ static marginsFor(states, enabled = true) {
173
+ if (!enabled) return NO_MARGINS;
174
+ for (const s of states ?? []) {
175
+ if (
176
+ s === TOPLEVEL_STATE.MAXIMIZED ||
177
+ s === TOPLEVEL_STATE.FULLSCREEN ||
178
+ (s >= TOPLEVEL_STATE.TILED_LEFT && s <= TOPLEVEL_STATE.TILED_BOTTOM)
179
+ ) {
180
+ return NO_MARGINS;
181
+ }
182
+ }
183
+ return SHADOW_MARGINS;
184
+ }
185
+
186
+ /**
187
+ * What the desktop says (framestyle.js) and its light or dark
188
+ * (appearance.js's snapshot). The titlebar's height follows the title's
189
+ * font, so a caller compares `insets()` before and after.
190
+ */
191
+ setStyle(style, appearance) {
192
+ this.style = style ?? DEFAULTS;
193
+ this.dark = appearance?.colorScheme === 'dark';
194
+ this.highContrast = appearance?.contrast === 'high';
195
+ this.font = titleFont(this.style);
196
+ this.layout = parseButtonLayout(this.style.buttonLayout);
197
+ // A line of the title's font, plus the headerbar's padding; never less
198
+ // than libadwaita's 47px. From the font size, not from shaped metrics,
199
+ // so the height is known before the font has been opened.
200
+ this.barHeight = Math.max(47, Math.ceil(this.font.size * 1.17 + 12));
201
+ }
202
+
203
+ setState(states) {
204
+ this.maximized = states.has(TOPLEVEL_STATE.MAXIMIZED);
205
+ this.tiled = [...states].some(
206
+ (s) => s >= TOPLEVEL_STATE.TILED_LEFT && s <= TOPLEVEL_STATE.TILED_BOTTOM,
207
+ );
208
+ this.fullscreen = states.has(TOPLEVEL_STATE.FULLSCREEN);
209
+ this.active = states.has(TOPLEVEL_STATE.ACTIVATED);
210
+ }
211
+
212
+ margins() {
213
+ if (!this.enabled) return NO_MARGINS;
214
+ return this.marginsOf?.() ?? NO_MARGINS;
215
+ }
216
+
217
+ /** The frame's insets round the content, shadow margin included, in
218
+ * logical pixels of the surface. */
219
+ insets() {
220
+ if (!this.enabled || this.fullscreen) return { ...NO_MARGINS };
221
+ const m = this.margins();
222
+ return {
223
+ top: m.top + this.barHeight + SEPARATOR,
224
+ left: m.left,
225
+ right: m.right,
226
+ bottom: m.bottom,
227
+ };
228
+ }
229
+
230
+ /** How much bigger than the content the surface is. */
231
+ extra() {
232
+ const i = this.insets();
233
+ return { width: i.left + i.right, height: i.top + i.bottom };
234
+ }
235
+
236
+ /** Rounded while the window floats; square once maximised or tiled. */
237
+ get radius() {
238
+ return this.maximized || this.tiled || this.fullscreen ? 0 : RADIUS;
239
+ }
240
+
241
+ /** The buttons, where they are: window-geometry coordinates. */
242
+ _buttons(geomWidth) {
243
+ const cy = Math.floor((this.barHeight - 1) / 2);
244
+ const out = [];
245
+ this.layout.left.forEach((id, k) => {
246
+ out.push({ id, cx: BUTTON_INSET + k * BUTTON_STEP, cy });
247
+ });
248
+ const right = this.layout.right;
249
+ right.forEach((id, k) => {
250
+ out.push({
251
+ id,
252
+ cx: geomWidth - BUTTON_INSET - (right.length - 1 - k) * BUTTON_STEP,
253
+ cy,
254
+ });
255
+ });
256
+ return out;
257
+ }
258
+
259
+ /** How far from an edge the buttons on that side reach. */
260
+ _reach(side) {
261
+ const n = this.layout[side].length;
262
+ return n ? BUTTON_INSET + (n - 1) * BUTTON_STEP + BUTTON / 2 + 6 : 12;
263
+ }
264
+
265
+ /**
266
+ * What is under a surface-local point: a resize edge, a titlebar button,
267
+ * the titlebar itself, or the content.
268
+ *
269
+ * @returns {{kind:'content'}|{kind:'titlebar'}|{kind:'button',id:string}|{kind:'resize',edges:number}}
270
+ */
271
+ hitTest(x, y, surfaceWidth, surfaceHeight) {
272
+ if (!this.enabled || this.fullscreen) return { kind: 'content' };
273
+ const m = this.margins();
274
+ const gx = x - m.left;
275
+ const gy = y - m.top;
276
+ const gw = surfaceWidth - m.left - m.right;
277
+ const gh = surfaceHeight - m.top - m.bottom;
278
+ const inside = gx >= 0 && gx < gw && gy >= 0 && gy < gh;
279
+ if (!inside) {
280
+ if (this.maximized || this.tiled) return { kind: 'content' };
281
+ // the band outside the window: the edges the point is beyond, and a
282
+ // corner when it is near one
283
+ let left = gx < 0;
284
+ let right = gx >= gw;
285
+ let top = gy < 0;
286
+ let bottom = gy >= gh;
287
+ if (left || right) {
288
+ top ||= gy < CORNER;
289
+ bottom ||= gy >= gh - CORNER;
290
+ }
291
+ if (top || bottom) {
292
+ left ||= gx < CORNER;
293
+ right ||= gx >= gw - CORNER;
294
+ }
295
+ let edges = 0;
296
+ if (left) edges |= RESIZE_EDGE.LEFT;
297
+ else if (right) edges |= RESIZE_EDGE.RIGHT;
298
+ if (top) edges |= RESIZE_EDGE.TOP;
299
+ else if (bottom) edges |= RESIZE_EDGE.BOTTOM;
300
+ return { kind: 'resize', edges };
301
+ }
302
+ if (gy < this.barHeight) {
303
+ for (const b of this._buttons(gw)) {
304
+ if (
305
+ Math.abs(gx - b.cx) <= BUTTON / 2 + 4 &&
306
+ Math.abs(gy - b.cy) <= BUTTON / 2 + 4
307
+ )
308
+ return { kind: 'button', id: b.id };
309
+ }
310
+ return { kind: 'titlebar' };
311
+ }
312
+ return { kind: 'content' };
313
+ }
314
+
315
+ /** The cursor for a hit. */
316
+ static cursorFor(hit) {
317
+ if (hit.kind !== 'resize') return 'default';
318
+ const e = hit.edges;
319
+ if (e === RESIZE_EDGE.TOP_LEFT || e === RESIZE_EDGE.BOTTOM_RIGHT)
320
+ return 'nwse-resize';
321
+ if (e === RESIZE_EDGE.TOP_RIGHT || e === RESIZE_EDGE.BOTTOM_LEFT)
322
+ return 'nesw-resize';
323
+ if (e === RESIZE_EDGE.LEFT || e === RESIZE_EDGE.RIGHT) return 'ew-resize';
324
+ return 'ns-resize';
325
+ }
326
+
327
+ /** The colours for the current scheme and focus. */
328
+ _colors() {
329
+ const p = this.dark ? PALETTE.dark : PALETTE.light;
330
+ const k = this.active ? 0 : 1;
331
+ const bar = p.bar[k];
332
+ const fg = p.fg[k];
333
+ const sep = this.highContrast ? Math.min(1, p.separator * 2) : p.separator;
334
+ return {
335
+ bar,
336
+ fg,
337
+ highlight: this.active ? p.highlight : null,
338
+ // opaque: the row under the bar belongs to the frame, and a
339
+ // translucent line there would be drawn over nothing
340
+ separator: mix(bar, '#000006', sep),
341
+ button: (id) =>
342
+ mix(
343
+ bar,
344
+ fg,
345
+ this.pressed === id ? 0.3 : this.hover === id ? 0.15 : 0.1,
346
+ ),
347
+ };
348
+ }
349
+
350
+ /**
351
+ * Draw the frame into the surface. `width`/`height` are the surface's in
352
+ * logical pixels; the context's transform already carries the output
353
+ * scale. Idempotent, because it runs every frame over the last one: the
354
+ * shadow's region is cleared before the shadow goes down, and nothing is
355
+ * drawn inside the window but the bar, which is opaque.
356
+ */
357
+ paint(ctx, width, height, measure = this.measure) {
358
+ if (!this.enabled || this.fullscreen) return;
359
+ const m = this.margins();
360
+ const gx = m.left;
361
+ const gy = m.top;
362
+ const gw = width - m.left - m.right;
363
+ const gh = height - m.top - m.bottom;
364
+ const r = this.radius;
365
+ const c = this._colors();
366
+
367
+ if (m.left || m.top || m.right || m.bottom) {
368
+ // outside the window only: the region between the surface and the
369
+ // rounded window, even-odd
370
+ ctx.save();
371
+ ctx.beginPath();
372
+ ctx.rect(0, 0, width, height);
373
+ ctx.roundRect(gx, gy, gw, gh, r);
374
+ ctx.clip('evenodd');
375
+ ctx.clearRect(0, 0, width, height);
376
+ for (const s of SHADOWS[this.active ? 0 : 1]) {
377
+ ctx.fillShadow(
378
+ { x: gx, y: gy + s.dy, width: gw, height: gh },
379
+ r,
380
+ s.blur,
381
+ `rgba(0, 0, 0, ${s.alpha})`,
382
+ );
383
+ }
384
+ ctx.restore();
385
+ }
386
+
387
+ // the headerbar: rounded at the top, square where it meets the content
388
+ const bh = this.barHeight;
389
+ ctx.fillStyle = c.bar;
390
+ if (r > 0) {
391
+ ctx.beginPath();
392
+ ctx.roundRect(gx, gy, gw, bh, r);
393
+ ctx.fill();
394
+ ctx.fillRect(gx, gy + r, gw, bh - r);
395
+ } else {
396
+ ctx.fillRect(gx, gy, gw, bh);
397
+ }
398
+ if (c.highlight) {
399
+ ctx.fillStyle = c.highlight;
400
+ ctx.fillRect(gx + r, gy, gw - 2 * r, 1);
401
+ }
402
+ ctx.fillStyle = c.separator;
403
+ ctx.fillRect(gx, gy + bh, gw, SEPARATOR);
404
+
405
+ for (const b of this._buttons(gw)) {
406
+ // centred on the pixel, not on the grid line between two: a 24px
407
+ // circle at a whole-pixel centre sits half a pixel up and left of
408
+ // libadwaita's
409
+ const cx = gx + b.cx + 0.5;
410
+ const cy = gy + b.cy + 0.5;
411
+ ctx.fillStyle = c.button(b.id);
412
+ ctx.beginPath();
413
+ ctx.roundRect(
414
+ cx - BUTTON / 2,
415
+ cy - BUTTON / 2,
416
+ BUTTON,
417
+ BUTTON,
418
+ BUTTON / 2,
419
+ );
420
+ ctx.fill();
421
+ const name =
422
+ b.id === 'maximize'
423
+ ? this.maximized
424
+ ? 'window-restore'
425
+ : 'window-maximize'
426
+ : `window-${b.id}`;
427
+ ctx.save();
428
+ ctx.translate(cx - 8, cy - 8);
429
+ ctx.fillStyle = c.fg;
430
+ ctx.fill(glyph(name));
431
+ ctx.restore();
432
+ }
433
+
434
+ this._paintTitle(ctx, gx, gy, gw, c.fg, measure);
435
+ }
436
+
437
+ _paintTitle(ctx, gx, gy, gw, color, measure) {
438
+ if (!this.title || !measure) return;
439
+ const f = this.font;
440
+ const fit = (text) => measure(text, f.size, f.weight, f.family);
441
+ let label = fit(this.title);
442
+ if (!label) {
443
+ // the font is still opening; ask again rather than show no title
444
+ this.onNeedsRepaint?.();
445
+ return;
446
+ }
447
+ const side = Math.max(this._reach('left'), this._reach('right'));
448
+ const avail = Math.max(0, gw - 2 * side);
449
+ if (label.width > avail) {
450
+ // ellipsised at the end, as a GTK title is: the longest prefix that fits
451
+ let lo = 0;
452
+ let hi = this.title.length;
453
+ let best = null;
454
+ while (lo <= hi) {
455
+ const mid = (lo + hi) >> 1;
456
+ const l = fit(this.title.slice(0, mid).trimEnd() + '…');
457
+ if (l && l.width <= avail) {
458
+ best = l;
459
+ lo = mid + 1;
460
+ } else hi = mid - 1;
461
+ }
462
+ if (!best) return;
463
+ label = best;
464
+ }
465
+ const x = gx + (gw - label.width) / 2;
466
+ const baseline =
467
+ gy + (this.barHeight - (label.ascent + label.descent)) / 2 + label.ascent;
468
+ ctx.save();
469
+ ctx.beginPath();
470
+ ctx.rect(gx + side, gy, avail, this.barHeight);
471
+ ctx.clip();
472
+ ctx.translate(x, baseline);
473
+ ctx.drawTextRuns(label.runs.map((run) => ({ ...run, color })));
474
+ ctx.restore();
475
+ }
476
+ }
@@ -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,89 @@
1
+ // Turning a GPU buffer into something the compositor can show.
2
+ //
3
+ // This is the whole of the Wayland side of Tier D, and it is short, which is
4
+ // the point the RFC was making: x11-dri's `Surface.swap()` already answers
5
+ // `{ fd, stride, offset, modifier }`, and that is exactly the argument list
6
+ // `zwp_linux_buffer_params_v1.add` wants. No pixels cross the socket — the
7
+ // descriptor goes over once per buffer, and every later frame in that buffer
8
+ // is an `attach` of a `wl_buffer` the compositor already holds.
9
+ //
10
+ // The one judgement call is `create` versus `create_immed`. `create_immed`
11
+ // saves a round trip and answers the buffer synchronously; the price is that
12
+ // a refusal is a *protocol error*, which is fatal and takes the connection
13
+ // with it. A refusal is not hypothetical — it is what happens when client and
14
+ // compositor are on different DRM devices and the tiled layout means nothing
15
+ // to the one displaying it — and it has a real remedy, a linear buffer. So
16
+ // the first import of a generation goes through `create`, whose `failed`
17
+ // event is catchable and gives the fallback somewhere to happen. Buffers are
18
+ // created a handful of times per generation and never again in steady state,
19
+ // so this is a startup cost, not a per-frame one.
20
+
21
+ /** `zwp_linux_buffer_params_v1.flags` — none of which we want. */
22
+ const NO_FLAGS = 0;
23
+
24
+ /**
25
+ * Import one dma-buf as a `wl_buffer`.
26
+ *
27
+ * Takes ownership of `out.fd`: the transport closes descriptors once they are
28
+ * on the wire, which is the contract every fd-passing Wayland request has.
29
+ *
30
+ * @param {object} dmabuf a bound `zwp_linux_dmabuf_v1`
31
+ * @param {object} out a `SwapResult` from `Surface.swap()`, with `isNew: true`
32
+ * @param {number} format a DRM fourcc — `x11-dri`'s `FORMAT.XRGB8888` and friends
33
+ * @returns {Promise<object>} the `wl_buffer` proxy
34
+ */
35
+ export async function importDmabuf(dmabuf, out, format) {
36
+ const params = await dmabuf.create_params();
37
+ // A single plane. The modifier is split across two uint arguments because
38
+ // the wire has no 64-bit integer type.
39
+ const modifier = BigInt(out.modifier ?? 0n);
40
+ await params.add(
41
+ out.fd,
42
+ 0, // plane_idx
43
+ out.offset >>> 0,
44
+ out.stride >>> 0,
45
+ Number(modifier >> 32n) >>> 0,
46
+ Number(modifier & 0xffffffffn) >>> 0,
47
+ );
48
+
49
+ return await new Promise((resolve, reject) => {
50
+ let settled = false;
51
+ params.on('created', (buffer) => {
52
+ if (settled) return;
53
+ settled = true;
54
+ // The params object is single-use; the buffer outlives it.
55
+ params.destroy().catch(() => {});
56
+ resolve(buffer);
57
+ });
58
+ params.on('failed', () => {
59
+ if (settled) return;
60
+ settled = true;
61
+ params.destroy().catch(() => {});
62
+ reject(
63
+ new DmabufRefused(
64
+ `the compositor refused a ${out.width}x${out.height} dma-buf ` +
65
+ `(stride ${out.stride}, modifier 0x${modifier.toString(16)})`,
66
+ ),
67
+ );
68
+ });
69
+ params.create(out.width, out.height, format, NO_FLAGS).catch((err) => {
70
+ if (settled) return;
71
+ settled = true;
72
+ reject(err);
73
+ });
74
+ });
75
+ }
76
+
77
+ /**
78
+ * The compositor would not take this buffer.
79
+ *
80
+ * Distinguished from any other failure because it has a remedy — retry the
81
+ * generation with a linear layout — and because it is the expected answer on
82
+ * a multi-GPU machine rather than a bug.
83
+ */
84
+ export class DmabufRefused extends Error {
85
+ constructor(message) {
86
+ super(message);
87
+ this.name = 'DmabufRefused';
88
+ }
89
+ }