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.
- package/package.json +11 -8
- package/src/Reconciler.js +34 -21
- package/src/components/Select.js +8 -2
- package/src/events.js +8 -2
- package/src/glnodes.js +15 -3
- package/src/index.d.ts +5 -0
- package/src/nodes/boxpaint.js +9 -0
- package/src/nodes/preedit.js +64 -14
- package/src/ntk.js +10 -1
- package/src/scale.js +52 -22
- package/src/screencolor.js +104 -17
- package/src/types/system.d.ts +14 -1
- package/src/wayland/app.js +574 -0
- package/src/wayland/backendwindow.js +1199 -0
- package/src/wayland/clipboard.js +326 -0
- package/src/wayland/connection.js +485 -0
- package/src/wayland/context2d.js +2290 -0
- package/src/wayland/decorations.js +476 -0
- package/src/wayland/device.js +55 -0
- package/src/wayland/dmabuf.js +89 -0
- package/src/wayland/dnd.js +581 -0
- package/src/wayland/fdutil.js +108 -0
- package/src/wayland/framestyle.js +257 -0
- package/src/wayland/framewatch.js +190 -0
- package/src/wayland/glarea.js +372 -0
- package/src/wayland/glcontext.js +415 -0
- package/src/wayland/glyphatlas.js +237 -0
- package/src/wayland/input.js +417 -0
- package/src/wayland/keysymnames.js +35 -0
- package/src/wayland/layershell.js +363 -0
- package/src/wayland/outputs.js +601 -0
- package/src/wayland/protocols/cursor-shape-v1.json +1 -0
- package/src/wayland/protocols/ext-idle-notify-v1.json +1 -0
- package/src/wayland/protocols/ext-image-capture-source-v1.json +1 -0
- package/src/wayland/protocols/ext-image-copy-capture-v1.json +1 -0
- package/src/wayland/protocols/fractional-scale-v1.json +1 -0
- package/src/wayland/protocols/index.json +131 -0
- package/src/wayland/protocols/kde-server-decoration.json +1 -0
- package/src/wayland/protocols/keyboard-shortcuts-inhibit-unstable-v1.json +1 -0
- package/src/wayland/protocols/linux-dmabuf-v1.json +1 -0
- package/src/wayland/protocols/pointer-constraints-unstable-v1.json +1 -0
- package/src/wayland/protocols/presentation-time.json +1 -0
- package/src/wayland/protocols/primary-selection-unstable-v1.json +1 -0
- package/src/wayland/protocols/relative-pointer-unstable-v1.json +1 -0
- package/src/wayland/protocols/tablet-v2.json +1 -0
- package/src/wayland/protocols/text-input-unstable-v3.json +1 -0
- package/src/wayland/protocols/viewporter.json +1 -0
- package/src/wayland/protocols/wayland.json +1 -0
- package/src/wayland/protocols/wlr-layer-shell-unstable-v1.json +1 -0
- package/src/wayland/protocols/wlr-screencopy-unstable-v1.json +1 -0
- package/src/wayland/protocols/xdg-activation-v1.json +1 -0
- package/src/wayland/protocols/xdg-decoration-unstable-v1.json +1 -0
- package/src/wayland/protocols/xdg-output-unstable-v1.json +1 -0
- package/src/wayland/protocols/xdg-shell.json +1 -0
- package/src/wayland/protocols/xdg-toplevel-icon-v1.json +1 -0
- package/src/wayland/readback.js +99 -0
- package/src/wayland/screencopy.js +584 -0
- package/src/wayland/seat.js +584 -0
- package/src/wayland/shm.js +226 -0
- package/src/wayland/ssd.js +234 -0
- package/src/wayland/surface.js +141 -0
- package/src/wayland/swapchain.js +411 -0
- package/src/wayland/tablet.js +522 -0
- package/src/wayland/target.js +269 -0
- package/src/wayland/text.js +113 -0
- package/src/wayland/textinput.js +671 -0
- package/src/wayland/touch.js +284 -0
- package/src/wayland/window.js +854 -0
- package/src/wayland/xkb.js +425 -0
- 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
|
+
}
|