react-x11 2.13.0 → 2.14.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 (63) 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/index.d.ts +5 -0
  6. package/src/nodes/boxpaint.js +9 -0
  7. package/src/nodes/preedit.js +64 -14
  8. package/src/scale.js +52 -22
  9. package/src/screencolor.js +104 -17
  10. package/src/wayland/app.js +560 -0
  11. package/src/wayland/backendwindow.js +1123 -0
  12. package/src/wayland/clipboard.js +326 -0
  13. package/src/wayland/connection.js +482 -0
  14. package/src/wayland/context2d.js +2133 -0
  15. package/src/wayland/decorations.js +476 -0
  16. package/src/wayland/dmabuf.js +89 -0
  17. package/src/wayland/dnd.js +581 -0
  18. package/src/wayland/fdutil.js +108 -0
  19. package/src/wayland/framestyle.js +257 -0
  20. package/src/wayland/glarea.js +371 -0
  21. package/src/wayland/glcontext.js +415 -0
  22. package/src/wayland/glyphatlas.js +237 -0
  23. package/src/wayland/input.js +417 -0
  24. package/src/wayland/keysymnames.js +35 -0
  25. package/src/wayland/layershell.js +363 -0
  26. package/src/wayland/outputs.js +601 -0
  27. package/src/wayland/protocols/cursor-shape-v1.json +1 -0
  28. package/src/wayland/protocols/ext-idle-notify-v1.json +1 -0
  29. package/src/wayland/protocols/ext-image-capture-source-v1.json +1 -0
  30. package/src/wayland/protocols/ext-image-copy-capture-v1.json +1 -0
  31. package/src/wayland/protocols/fractional-scale-v1.json +1 -0
  32. package/src/wayland/protocols/index.json +127 -0
  33. package/src/wayland/protocols/keyboard-shortcuts-inhibit-unstable-v1.json +1 -0
  34. package/src/wayland/protocols/linux-dmabuf-v1.json +1 -0
  35. package/src/wayland/protocols/pointer-constraints-unstable-v1.json +1 -0
  36. package/src/wayland/protocols/presentation-time.json +1 -0
  37. package/src/wayland/protocols/primary-selection-unstable-v1.json +1 -0
  38. package/src/wayland/protocols/relative-pointer-unstable-v1.json +1 -0
  39. package/src/wayland/protocols/tablet-v2.json +1 -0
  40. package/src/wayland/protocols/text-input-unstable-v3.json +1 -0
  41. package/src/wayland/protocols/viewporter.json +1 -0
  42. package/src/wayland/protocols/wayland.json +1 -0
  43. package/src/wayland/protocols/wlr-layer-shell-unstable-v1.json +1 -0
  44. package/src/wayland/protocols/wlr-screencopy-unstable-v1.json +1 -0
  45. package/src/wayland/protocols/xdg-activation-v1.json +1 -0
  46. package/src/wayland/protocols/xdg-decoration-unstable-v1.json +1 -0
  47. package/src/wayland/protocols/xdg-output-unstable-v1.json +1 -0
  48. package/src/wayland/protocols/xdg-shell.json +1 -0
  49. package/src/wayland/protocols/xdg-toplevel-icon-v1.json +1 -0
  50. package/src/wayland/readback.js +99 -0
  51. package/src/wayland/screencopy.js +584 -0
  52. package/src/wayland/seat.js +584 -0
  53. package/src/wayland/shm.js +226 -0
  54. package/src/wayland/ssd.js +106 -0
  55. package/src/wayland/surface.js +123 -0
  56. package/src/wayland/swapchain.js +411 -0
  57. package/src/wayland/tablet.js +522 -0
  58. package/src/wayland/target.js +263 -0
  59. package/src/wayland/text.js +113 -0
  60. package/src/wayland/textinput.js +671 -0
  61. package/src/wayland/touch.js +284 -0
  62. package/src/wayland/window.js +827 -0
  63. package/src/wayland/xkb.js +425 -0
@@ -0,0 +1,257 @@
1
+ // What the desktop says a window frame looks like and does — the part of it
2
+ // a client-side frame needs: which buttons go where, the title's font, and
3
+ // what a click on the titlebar means.
4
+ //
5
+ // GTK reads these from GSettings; anything else reads the same keys through
6
+ // the settings portal, which serves the `org.gnome.desktop.*` schemas beside
7
+ // the four standard appearance values (src/appearance.js reads those, and
8
+ // the frame takes its light or dark from there). The keys, with GNOME's
9
+ // defaults:
10
+ //
11
+ // org.gnome.desktop.wm.preferences
12
+ // button-layout 'appmenu:close' left:right, comma lists
13
+ // action-double-click-titlebar 'toggle-maximize'
14
+ // action-middle-click-titlebar 'none'
15
+ // action-right-click-titlebar 'menu'
16
+ // org.gnome.desktop.interface
17
+ // font-name 'Adwaita Sans 11' a Pango description
18
+ // text-scaling-factor 1.0
19
+ //
20
+ // The title is drawn in the *interface* font, bold, because that is what a
21
+ // libadwaita headerbar does: measured against one on a desktop whose
22
+ // `font-name` is 'Sans 23', its title's cap height is a 23pt DejaVu Sans
23
+ // Bold's. `titlebar-font` is for frames the window manager draws (Xwayland
24
+ // windows); a client frame that used it would not match the GTK apps beside
25
+ // it.
26
+ //
27
+ // Where there is no portal — another desktop, a test — the defaults stand.
28
+ // They are GNOME's, so a frame drawn to them is the frame GTK would draw.
29
+
30
+ import { EventEmitter } from 'node:events';
31
+ import { sessionBus } from '../bus.js';
32
+ import { desktopIntegrationEnabled } from '../desktopintegration.js';
33
+
34
+ const PORTAL_NAME = 'org.freedesktop.portal.Desktop';
35
+ const PORTAL_PATH = '/org/freedesktop/portal/desktop';
36
+ const SETTINGS_IFACE = 'org.freedesktop.portal.Settings';
37
+ const WM = 'org.gnome.desktop.wm.preferences';
38
+ const IFACE = 'org.gnome.desktop.interface';
39
+ const NAMESPACES = [WM, IFACE];
40
+
41
+ export const DEFAULTS = Object.freeze({
42
+ buttonLayout: 'appmenu:close',
43
+ fontName: 'Adwaita Sans 11',
44
+ textScaling: 1,
45
+ doubleClick: 'toggle-maximize',
46
+ middleClick: 'none',
47
+ rightClick: 'menu',
48
+ });
49
+
50
+ /** The buttons a client frame can draw; `appmenu`, `icon`, `spacer` have
51
+ * nothing to show here and are dropped. */
52
+ const BUTTONS = new Set(['close', 'minimize', 'maximize']);
53
+
54
+ /**
55
+ * `'icon,menu:minimize,maximize,close'` → `{ left: [], right: ['minimize',
56
+ * 'maximize', 'close'] }`. A layout with no colon is all on the left, as in
57
+ * GTK; a button named twice is drawn once, where it first appears.
58
+ */
59
+ export function parseButtonLayout(value) {
60
+ const [left = '', right = ''] = String(value ?? '').split(':');
61
+ const seen = new Set();
62
+ const side = (list) =>
63
+ list
64
+ .split(',')
65
+ .map((b) => b.trim())
66
+ .filter((b) => {
67
+ if (!BUTTONS.has(b) || seen.has(b)) return false;
68
+ seen.add(b);
69
+ return true;
70
+ });
71
+ return { left: side(left), right: side(right) };
72
+ }
73
+
74
+ const WEIGHTS = {
75
+ thin: 100,
76
+ ultralight: 200,
77
+ 'ultra-light': 200,
78
+ extralight: 200,
79
+ 'extra-light': 200,
80
+ light: 300,
81
+ semilight: 350,
82
+ 'semi-light': 350,
83
+ book: 380,
84
+ regular: 400,
85
+ normal: 400,
86
+ medium: 500,
87
+ semibold: 600,
88
+ 'semi-bold': 600,
89
+ demibold: 600,
90
+ 'demi-bold': 600,
91
+ bold: 700,
92
+ ultrabold: 800,
93
+ 'ultra-bold': 800,
94
+ extrabold: 800,
95
+ 'extra-bold': 800,
96
+ heavy: 900,
97
+ black: 900,
98
+ };
99
+ const STYLE_WORDS = new Set([
100
+ 'italic',
101
+ 'oblique',
102
+ 'roman',
103
+ 'small-caps',
104
+ 'condensed',
105
+ 'semi-condensed',
106
+ 'extra-condensed',
107
+ 'ultra-condensed',
108
+ 'expanded',
109
+ 'semi-expanded',
110
+ 'extra-expanded',
111
+ 'ultra-expanded',
112
+ ]);
113
+
114
+ /**
115
+ * A Pango font description — `"Adwaita Sans Bold 11"`, `"Sans 23"`,
116
+ * `"DejaVu Sans Condensed Italic 12px"` — as `{ family, size, unit, weight,
117
+ * italic }`. Style words are read off the end until one is not a style
118
+ * word; what remains is the family (the first of a comma list).
119
+ */
120
+ export function parseFontName(value) {
121
+ const words = String(value ?? '')
122
+ .trim()
123
+ .split(/\s+/)
124
+ .filter(Boolean);
125
+ let size = null;
126
+ let unit = 'pt';
127
+ const last = words.at(-1);
128
+ if (last && /^\d+(\.\d+)?(px)?$/i.test(last)) {
129
+ if (/px$/i.test(last)) unit = 'px';
130
+ size = parseFloat(last);
131
+ words.pop();
132
+ }
133
+ let weight = 400;
134
+ let italic = false;
135
+ while (words.length > 1) {
136
+ const w = words.at(-1).toLowerCase();
137
+ if (Object.hasOwn(WEIGHTS, w)) weight = WEIGHTS[w];
138
+ else if (STYLE_WORDS.has(w)) {
139
+ if (w === 'italic' || w === 'oblique') italic = true;
140
+ } else break;
141
+ words.pop();
142
+ }
143
+ const family = words.join(' ').split(',')[0].trim() || 'sans-serif';
144
+ return { family, size: size ?? 11, unit, weight, italic };
145
+ }
146
+
147
+ /** The title's font: the interface font, bold, at 96dpi times the scaling
148
+ * factor — in logical pixels. */
149
+ export function titleFont(style = DEFAULTS) {
150
+ const f = parseFontName(style.fontName);
151
+ const scaling = Number(style.textScaling) > 0 ? Number(style.textScaling) : 1;
152
+ const px = (f.unit === 'px' ? f.size : (f.size * 96) / 72) * scaling;
153
+ return { family: f.family, size: px, weight: 700 };
154
+ }
155
+
156
+ /** The two namespaces `ReadAll` answered, as a frame style. */
157
+ export function fromPortal(all) {
158
+ const wm = all?.[WM] ?? {};
159
+ const iface = all?.[IFACE] ?? {};
160
+ const str = (v, d) => (typeof v === 'string' && v ? v : d);
161
+ const num = (v, d) => (typeof v === 'number' && v > 0 ? v : d);
162
+ return Object.freeze({
163
+ buttonLayout: str(wm['button-layout'], DEFAULTS.buttonLayout),
164
+ fontName: str(iface['font-name'], DEFAULTS.fontName),
165
+ textScaling: num(iface['text-scaling-factor'], DEFAULTS.textScaling),
166
+ doubleClick: str(wm['action-double-click-titlebar'], DEFAULTS.doubleClick),
167
+ middleClick: str(wm['action-middle-click-titlebar'], DEFAULTS.middleClick),
168
+ rightClick: str(wm['action-right-click-titlebar'], DEFAULTS.rightClick),
169
+ });
170
+ }
171
+
172
+ /**
173
+ * The desktop's frame style, followed: `value` is always usable (the
174
+ * defaults until the portal answers), and 'change' fires when it moves.
175
+ */
176
+ export class FrameStyle extends EventEmitter {
177
+ constructor() {
178
+ super();
179
+ this.value = DEFAULTS;
180
+ /** 'portal' once the portal answered; 'defaults' until then, or for good */
181
+ this.source = 'defaults';
182
+ this._stop = null;
183
+ }
184
+
185
+ /**
186
+ * Read once and subscribe. Never throws: no portal means the defaults.
187
+ * The order is appearance.js's, for its reasons — the match rule and the
188
+ * handler go on before the read, so a change landing mid-read is caught,
189
+ * and reads are sequenced so an older answer never overwrites a newer one.
190
+ * The bus ref is released at the end: the subscription outlives it, and
191
+ * holding it would keep the process alive for as long as the frame cared
192
+ * what the desktop said.
193
+ */
194
+ async start() {
195
+ if (!desktopIntegrationEnabled()) return this;
196
+ let ref = null;
197
+ try {
198
+ ref = await sessionBus();
199
+ } catch {
200
+ return this;
201
+ }
202
+ if (!ref) return this;
203
+ let sub = null;
204
+ try {
205
+ sub = await ref.bus.watch(
206
+ `type='signal',sender='${PORTAL_NAME}',` +
207
+ `interface='${SETTINGS_IFACE}',member='SettingChanged'`,
208
+ );
209
+ let started = 0;
210
+ let published = 0;
211
+ const bus = ref.bus;
212
+ const refresh = async () => {
213
+ const mine = ++started;
214
+ const all = await bus.invoke(
215
+ {
216
+ destination: PORTAL_NAME,
217
+ path: PORTAL_PATH,
218
+ interface: SETTINGS_IFACE,
219
+ member: 'ReadAll',
220
+ signature: 'as',
221
+ body: [NAMESPACES],
222
+ },
223
+ { timeout: 5_000 },
224
+ );
225
+ if (!all || mine <= published) return;
226
+ published = mine;
227
+ this.value = fromPortal(all);
228
+ this.source = 'portal';
229
+ this.emit('change', this.value);
230
+ };
231
+ const key = bus.mangle(PORTAL_PATH, SETTINGS_IFACE, 'SettingChanged');
232
+ const onSignal = ([namespace]) => {
233
+ if (!NAMESPACES.includes(namespace)) return;
234
+ refresh().catch(() => {
235
+ // the portal went away mid-session; the last answer stands
236
+ });
237
+ };
238
+ bus.signals.on(key, onSignal);
239
+ const subscription = sub;
240
+ this._stop = () => {
241
+ bus.signals.off?.(key, onSignal);
242
+ subscription?.remove().catch(() => {});
243
+ };
244
+ await refresh();
245
+ } catch {
246
+ await sub?.remove().catch(() => {});
247
+ } finally {
248
+ await ref.release();
249
+ }
250
+ return this;
251
+ }
252
+
253
+ stop() {
254
+ this._stop?.();
255
+ this._stop = null;
256
+ }
257
+ }
@@ -0,0 +1,371 @@
1
+ // `<glarea>` on the Wayland backend: a viewport into the window's backing
2
+ // target, not a window of its own.
3
+ //
4
+ // On X11 a `<glarea>` is a child window — a separate drawable the X server
5
+ // stacks above the parent's drawing — and on the Cocoa backend a sublayer
6
+ // the WindowServer composites. Neither exists here. A Wayland client owns
7
+ // one buffer per surface, and a subsurface would be a second swapchain, a
8
+ // second frame clock and a second dma-buf import per frame for what is, on
9
+ // this backend, already one GL context drawing into one target. So the
10
+ // surface is a *rectangle of the backing target*: `makeCurrent()` binds the
11
+ // window's target with the viewport and scissor set to the area's rect, the
12
+ // app's GL calls land inside it, and `SwapBuffers()` is the note that the
13
+ // rect changed. The frame loop orders it — the tree's 2D paint is flushed
14
+ // under it, the area draws over, and the panes its children were painted
15
+ // on are composited over that (backendwindow.js `_runSurfaces`).
16
+ //
17
+ // What `GlAreaNode` (src/glnodes.js) needs is the child-"window" contract:
18
+ // `createWindow({ parent, … })` answering an object with
19
+ // `getContext('opengl', config)`, `setState(rect)`, `map()`, `destroy()`,
20
+ // `requestAnimationFrame`; and from the context `backend`, `ready`,
21
+ // `makeCurrent`, `SwapBuffers`, `canRender`, `onFrameAvailable`, plus the
22
+ // WebGL-shaped GL table itself. The table is the shared one, prototype-
23
+ // delegated as src/cocoa/glarea.js does it, with the entry points that name
24
+ // framebuffer coordinates — `viewport`, `scissor`, `bindFramebuffer`, and
25
+ // the scissor switch — re-based on the rect, so a scene written for a
26
+ // window of its own draws in its corner of ours.
27
+ //
28
+ // Two coordinate systems, as everywhere in this backend: the rect is in the
29
+ // window's content device pixels, y down; GL's viewport and scissor are in
30
+ // the target's pixels from the bottom-left. The content origin (the frame's
31
+ // insets) is added and y is flipped once, here, in `place()`.
32
+ //
33
+ // The pane (`WaylandOverlayPane`) is the other half of the same idea. The
34
+ // children of a `<glarea>` are 2D content drawn *above* its frames
35
+ // (src/gloverlay.js); on a backend that composites, one pane covers the
36
+ // surface and the backend blends it over the GL. Here a pane is an offscreen
37
+ // target the tree paints into during its flush, and the window draws it over
38
+ // the surface with `drawImage` once the surface has drawn — so a translucent
39
+ // legend, an antialiased edge or a shadow blends with the GL frame under it,
40
+ // as on the Cocoa backend and unlike X11's opaque child windows.
41
+
42
+ import { GLTarget } from './target.js';
43
+ import { WaylandContext2D } from './context2d.js';
44
+
45
+ /**
46
+ * The `visual` `chooseGLConfig` answers with. `GlAreaNode` passes the config's
47
+ * visual and depth straight back to `createWindow`, which is how the app
48
+ * tells a `<glarea>`'s child "window" apart from a real one.
49
+ */
50
+ export const GLAREA_VISUAL = 'wayland:glarea';
51
+
52
+ export class WaylandGLArea {
53
+ /**
54
+ * @param {import('./backendwindow.js').WaylandBackendWindow} parent
55
+ * @param {{x?:number,y?:number,width?:number,height?:number}} options
56
+ */
57
+ constructor(parent, options = {}) {
58
+ this.parent = parent;
59
+ this.app = parent.app;
60
+ this.destroyed = false;
61
+ this.mapped = false;
62
+ this._reactX11Node = null;
63
+ this._context = null;
64
+ this._listeners = new Map();
65
+ /** set by `SwapBuffers` for the frame it happened in */
66
+ this.drewThisFrame = false;
67
+ this.rect = {
68
+ x: options.x ?? 0,
69
+ y: options.y ?? 0,
70
+ width: Math.max(1, options.width ?? 1),
71
+ height: Math.max(1, options.height ?? 1),
72
+ };
73
+ parent._surfaces.add(this);
74
+ }
75
+
76
+ get width() {
77
+ return this.rect.width;
78
+ }
79
+
80
+ get height() {
81
+ return this.rect.height;
82
+ }
83
+
84
+ /** Geometry in content device pixels, the unit `GlAreaNode`'s rects are in. */
85
+ setState(rect) {
86
+ if (this.destroyed) return this;
87
+ this.rect = {
88
+ x: rect.x ?? this.rect.x,
89
+ y: rect.y ?? this.rect.y,
90
+ width: Math.max(1, rect.width ?? this.rect.width),
91
+ height: Math.max(1, rect.height ?? this.rect.height),
92
+ };
93
+ return this;
94
+ }
95
+
96
+ move(x, y) {
97
+ return this.setState({ x, y });
98
+ }
99
+
100
+ resize(width, height) {
101
+ return this.setState({ width, height });
102
+ }
103
+
104
+ map() {
105
+ this.mapped = true;
106
+ return this;
107
+ }
108
+
109
+ unmap() {
110
+ this.mapped = false;
111
+ return this;
112
+ }
113
+
114
+ on(name, fn) {
115
+ let set = this._listeners.get(name);
116
+ if (!set) this._listeners.set(name, (set = new Set()));
117
+ set.add(fn);
118
+ return this;
119
+ }
120
+
121
+ off(name, fn) {
122
+ this._listeners.get(name)?.delete(fn);
123
+ return this;
124
+ }
125
+
126
+ emit(name, ev) {
127
+ for (const fn of this._listeners.get(name) ?? []) fn(ev);
128
+ }
129
+
130
+ /**
131
+ * The area's frame is a part of its window's: the callback runs after the
132
+ * tree's paint, in the same frame, so what it draws goes over the 2D and
133
+ * out with the same present.
134
+ */
135
+ requestAnimationFrame(fn) {
136
+ return this.parent.requestSurfaceFrame(fn);
137
+ }
138
+
139
+ getContext(kind, config) {
140
+ if (this.destroyed) return null;
141
+ if (kind !== 'opengl' && kind !== 'gles' && kind !== 'webgl') return null;
142
+ if (!this._context) this._context = createGLAreaContext(this, config);
143
+ return this._context;
144
+ }
145
+
146
+ destroy() {
147
+ if (this.destroyed) return;
148
+ this.destroyed = true;
149
+ this._context?._destroy();
150
+ this._context = null;
151
+ this.parent._surfaces.delete(this);
152
+ }
153
+ }
154
+
155
+ /**
156
+ * The `gl` a `<glarea>`'s `onDraw` receives: the shared WebGL-shaped table,
157
+ * with this area's rect behind the entry points that would otherwise name
158
+ * the whole target.
159
+ */
160
+ function createGLAreaContext(area, config) {
161
+ const parent = area.parent;
162
+ const gl = parent.glctx.gl;
163
+ const ctx = Object.create(gl);
164
+ let destroyed = false;
165
+ // the rect in the target's GL coordinates, as of the last makeCurrent
166
+ let bx = 0;
167
+ let by = 0;
168
+ let bw = 1;
169
+ let bh = 1;
170
+
171
+ const place = () => {
172
+ const o = parent.contentOrigin;
173
+ const r = area.rect;
174
+ const H = parent.glctx.backing?.height ?? 0;
175
+ bx = o.x + r.x;
176
+ bw = r.width;
177
+ bh = r.height;
178
+ by = H - (o.y + r.y + r.height);
179
+ };
180
+
181
+ ctx.backend = 'direct';
182
+ ctx.config = config;
183
+ ctx.glVersion = parent.glctx.glVersion;
184
+ ctx.ready = Promise.resolve();
185
+ ctx.onFrameAvailable = null;
186
+ // No swapchain of its own to be held up by: the frame it draws in is the
187
+ // window's, and the window's frame loop only runs it when there is one.
188
+ ctx.canRender = () => !destroyed && !parent._destroyed;
189
+
190
+ ctx.makeCurrent = () => {
191
+ if (destroyed || parent._destroyed) return;
192
+ parent.app.makeCurrent();
193
+ parent.glctx.bindBacking();
194
+ place();
195
+ // The scissor is what keeps a `clear()` inside the rect. It stays on for
196
+ // the whole draw: `disable(SCISSOR_TEST)` below re-asserts it.
197
+ gl.enable(gl.SCISSOR_TEST);
198
+ gl.scissor(bx, by, bw, bh);
199
+ gl.viewport(bx, by, bw, bh);
200
+ };
201
+
202
+ ctx.viewport = (x, y, w, h) => gl.viewport(bx + x, by + y, w, h);
203
+
204
+ // A scissor of the scene's own is cut to the rect: nothing it asks for
205
+ // can reach outside, and `(0, 0, width, height)` is the whole of it.
206
+ ctx.scissor = (x, y, w, h) => {
207
+ const x0 = Math.max(bx, bx + x);
208
+ const y0 = Math.max(by, by + y);
209
+ const x1 = Math.min(bx + bw, bx + x + w);
210
+ const y1 = Math.min(by + bh, by + y + h);
211
+ gl.scissor(x0, y0, Math.max(0, x1 - x0), Math.max(0, y1 - y0));
212
+ };
213
+
214
+ ctx.disable = (cap) => {
215
+ if (cap === gl.SCISSOR_TEST) {
216
+ gl.scissor(bx, by, bw, bh);
217
+ return;
218
+ }
219
+ gl.disable(cap);
220
+ };
221
+
222
+ // WebGL's "null means the default framebuffer" — and this surface's
223
+ // default is the window's backing target, not GL's framebuffer zero,
224
+ // which here is the swapchain buffer the 2D never draws into. A scene
225
+ // that renders through its own FBO and unbinds at the end lands back on
226
+ // the window (the SSAA pattern, the shadow-map pattern).
227
+ ctx.bindFramebuffer = (target, fb) => {
228
+ gl.bindFramebuffer(
229
+ target,
230
+ fb == null ? (parent.glctx.backing?.fbo ?? null) : fb,
231
+ );
232
+ };
233
+
234
+ // The frame is in the target already; what a swap means here is "this
235
+ // rect changed", for the present's damage and for the panes to be drawn
236
+ // over it again.
237
+ ctx.SwapBuffers = () => {
238
+ if (destroyed) return;
239
+ area.drewThisFrame = true;
240
+ parent._surfaceDrew(area);
241
+ };
242
+
243
+ ctx._destroy = () => {
244
+ destroyed = true;
245
+ };
246
+ // `GlAreaNode.destroySubtree` calls `gl.destroy?.()` before the window's
247
+ ctx.destroy = ctx._destroy;
248
+
249
+ return ctx;
250
+ }
251
+
252
+ /**
253
+ * The pane a `<glarea>`'s children are drawn on: an offscreen target with a
254
+ * 2d context of its own, painted by the tree inside the window's flush and
255
+ * blended over the surface by the window's frame loop.
256
+ *
257
+ * It speaks the verbs the overlay drives (src/gloverlay.js `Pane`):
258
+ * `setState`, `map`, `unmap`, `getContext`, `present`, `destroy`. The
259
+ * context is handed out *inside a frame* — `begin()` has been called — so
260
+ * the overlay can draw at once, as it does on the other backends where a
261
+ * context has no frame; `present()` closes that frame and opens the next.
262
+ */
263
+ export class WaylandOverlayPane {
264
+ /**
265
+ * @param {import('./app.js').WaylandApp} app
266
+ * @param {{parent:object,x?:number,y?:number,width?:number,height?:number}} options
267
+ */
268
+ constructor(app, options) {
269
+ this.app = app;
270
+ this.parent = options.parent;
271
+ this.destroyed = false;
272
+ this.hidden = false;
273
+ /** something has been painted on it: before that there is nothing to composite */
274
+ this.presented = false;
275
+ this.rect = {
276
+ x: options.x ?? 0,
277
+ y: options.y ?? 0,
278
+ width: Math.max(1, options.width ?? 1),
279
+ height: Math.max(1, options.height ?? 1),
280
+ };
281
+ this.target = null;
282
+ this._ctx = null;
283
+ this.parent._panes.add(this);
284
+ }
285
+
286
+ get width() {
287
+ return this.rect.width;
288
+ }
289
+
290
+ get height() {
291
+ return this.rect.height;
292
+ }
293
+
294
+ /** Geometry in content device pixels. A new size is a new (cleared) target
295
+ * and the overlay repaints it whole (`Pane.place` says so). */
296
+ setState(rect) {
297
+ if (this.destroyed) return this;
298
+ const resized =
299
+ rect.width !== this.rect.width || rect.height !== this.rect.height;
300
+ this.rect = { ...rect };
301
+ if (resized && this.target) {
302
+ this.app.makeCurrent();
303
+ this.target.resize(this.rect.width, this.rect.height);
304
+ this.presented = false;
305
+ this._ctx?.begin(this.target.width, this.target.height);
306
+ this.app.rebindWindowTarget();
307
+ }
308
+ return this;
309
+ }
310
+
311
+ map() {
312
+ this.hidden = false;
313
+ return this;
314
+ }
315
+
316
+ unmap() {
317
+ this.hidden = true;
318
+ return this;
319
+ }
320
+
321
+ /** `Pane` listens for 'draw' — pixels lost to a resize. A target's are
322
+ * lost too, and `Pane.place` already repaints whole on a size change. */
323
+ on() {
324
+ return this;
325
+ }
326
+
327
+ getContext(kind = '2d') {
328
+ if (kind !== '2d' || this.destroyed) return null;
329
+ if (!this._ctx) {
330
+ this.app.makeCurrent();
331
+ this.target = new GLTarget(this.app.gl, {
332
+ width: this.rect.width,
333
+ height: this.rect.height,
334
+ stencil: true,
335
+ });
336
+ this._ctx = new WaylandContext2D(this.app.gl, {
337
+ fontManager: this.app.fonts,
338
+ target: this.target,
339
+ });
340
+ this._ctx.init();
341
+ this._ctx.begin(this.target.width, this.target.height);
342
+ // the window's own context is mid-frame; its target goes back
343
+ this.app.rebindWindowTarget();
344
+ }
345
+ return this._ctx;
346
+ }
347
+
348
+ /** What was painted is complete; the window blends it over the surface
349
+ * in this frame. The next paint finds the context open again. */
350
+ present() {
351
+ if (this.destroyed || !this._ctx) return;
352
+ this.app.makeCurrent();
353
+ this._ctx.end();
354
+ this.presented = true;
355
+ this.parent._paneDrew(this);
356
+ this._ctx.begin(this.target.width, this.target.height);
357
+ this.app.rebindWindowTarget();
358
+ }
359
+
360
+ destroy() {
361
+ if (this.destroyed) return;
362
+ this.destroyed = true;
363
+ this.parent._panes.delete(this);
364
+ this.app.makeCurrent();
365
+ this._ctx?.destroy();
366
+ this._ctx = null;
367
+ this.target?.destroy();
368
+ this.target = null;
369
+ this.app.rebindWindowTarget();
370
+ }
371
+ }