ntk 7.3.3 → 7.4.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/lib/drawable.js +5 -0
- package/lib/index.js +19 -0
- package/lib/window.js +74 -18
- package/lib/xembed.js +922 -0
- package/package.json +3 -2
package/lib/drawable.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
2
|
|
|
3
|
+
// lib/ may not statically import node builtins (see docs/packaging.md); this
|
|
4
|
+
// file is the one sanctioned exception for node:events, so it is also where
|
|
5
|
+
// the rest of lib/ takes EventEmitter from.
|
|
6
|
+
export { EventEmitter };
|
|
7
|
+
|
|
3
8
|
export default class Drawable extends EventEmitter {
|
|
4
9
|
getContext(name, ...args) {
|
|
5
10
|
const factory = Drawable.renderingContextFactory[name];
|
package/lib/index.js
CHANGED
|
@@ -6,6 +6,16 @@ import { BLANK_CURSOR, CursorCache, cursorNames, cursorShapes, resolveCursorShap
|
|
|
6
6
|
import { DEFAULT_GL_POLICY, GLError, GL_MODES, resolveGLPolicy, wantsDirect } from './gl.js';
|
|
7
7
|
import { GLXError } from './glx.js';
|
|
8
8
|
import Window from './window.js';
|
|
9
|
+
import {
|
|
10
|
+
FocusProxy,
|
|
11
|
+
XEMBED,
|
|
12
|
+
XEMBED_OPCODE_NAMES,
|
|
13
|
+
XEmbedPlug,
|
|
14
|
+
XEmbedSocket,
|
|
15
|
+
decodeXEmbedInfo,
|
|
16
|
+
encodeXEmbedInfo,
|
|
17
|
+
readXEmbedInfo
|
|
18
|
+
} from './xembed.js';
|
|
9
19
|
import { loadLayout } from './yoga.js';
|
|
10
20
|
import { decodeKey, groupForState } from './keyboard.js';
|
|
11
21
|
import Pixmap from './pixmap.js';
|
|
@@ -210,6 +220,15 @@ export {
|
|
|
210
220
|
App,
|
|
211
221
|
Clipboard,
|
|
212
222
|
Window,
|
|
223
|
+
// XEmbed (docs/xembed.md): also available as `ntk/xembed`
|
|
224
|
+
XEmbedSocket,
|
|
225
|
+
XEmbedPlug,
|
|
226
|
+
FocusProxy,
|
|
227
|
+
XEMBED,
|
|
228
|
+
XEMBED_OPCODE_NAMES,
|
|
229
|
+
encodeXEmbedInfo,
|
|
230
|
+
decodeXEmbedInfo,
|
|
231
|
+
readXEmbedInfo,
|
|
213
232
|
BLANK_CURSOR,
|
|
214
233
|
CursorCache,
|
|
215
234
|
cursorNames,
|
package/lib/window.js
CHANGED
|
@@ -499,13 +499,15 @@ export default class Window extends Drawable {
|
|
|
499
499
|
// window was asked to be, so the window manager's first ConfigureNotify
|
|
500
500
|
// reports only what it actually overrode
|
|
501
501
|
this._deliveredGeom = { x: this.x, y: this.y, width: this.width, height: this.height };
|
|
502
|
+
this.windowClass = args.windowClass ?? 0;
|
|
502
503
|
const values = {
|
|
503
|
-
// NorthWest bit gravity: keep the old content anchored during
|
|
504
|
-
// resize instead of discarding it (less flicker between the resize
|
|
505
|
-
// and our redraw)
|
|
506
|
-
bitGravity: 1,
|
|
507
504
|
eventMask: this.eventMask
|
|
508
505
|
};
|
|
506
|
+
// NorthWest bit gravity: keep the old content anchored during resize
|
|
507
|
+
// instead of discarding it (less flicker between the resize and our
|
|
508
|
+
// redraw). An InputOnly window (class 2) has no content to anchor and
|
|
509
|
+
// the attribute is not one it accepts — setting it is a BadMatch.
|
|
510
|
+
if (this.windowClass !== 2) values.bitGravity = 1;
|
|
509
511
|
for (const name of forwardedXAttributes) {
|
|
510
512
|
if (args[name] !== undefined) values[name] = args[name];
|
|
511
513
|
}
|
|
@@ -514,7 +516,6 @@ export default class Window extends Drawable {
|
|
|
514
516
|
// parent of a different depth is a BadMatch (X11 CreateWindow)
|
|
515
517
|
this.visual = args.visual ?? 0;
|
|
516
518
|
this.depth = args.depth ?? 0;
|
|
517
|
-
this.windowClass = args.windowClass ?? 0;
|
|
518
519
|
if (this.visual) {
|
|
519
520
|
if (values.colormap === undefined) {
|
|
520
521
|
this._ownedColormap = app.createColormap(this.visual);
|
|
@@ -2612,16 +2613,21 @@ export default class Window extends Drawable {
|
|
|
2612
2613
|
|
|
2613
2614
|
if (mapped) {
|
|
2614
2615
|
const root = this.app.display.screen[0].root;
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2616
|
+
// EWMH 7.7: delivered to the root with the substructure mask, so the
|
|
2617
|
+
// window manager watching there sees it, but about this window
|
|
2618
|
+
await this.sendClientMessage(
|
|
2619
|
+
'_NET_WM_STATE',
|
|
2620
|
+
[
|
|
2619
2621
|
mode,
|
|
2620
2622
|
ids[0],
|
|
2621
2623
|
ids[1] ?? 0,
|
|
2622
2624
|
1 // source indication: a normal application, not a pager
|
|
2623
|
-
]
|
|
2624
|
-
|
|
2625
|
+
],
|
|
2626
|
+
{
|
|
2627
|
+
target: root,
|
|
2628
|
+
mask: x11.eventMask.SubstructureRedirect | x11.eventMask.SubstructureNotify
|
|
2629
|
+
}
|
|
2630
|
+
);
|
|
2625
2631
|
} else {
|
|
2626
2632
|
await this._writeWmState(ids, mode);
|
|
2627
2633
|
}
|
|
@@ -3168,6 +3174,58 @@ export default class Window extends Drawable {
|
|
|
3168
3174
|
return this;
|
|
3169
3175
|
}
|
|
3170
3176
|
|
|
3177
|
+
/**
|
|
3178
|
+
* Send a ClientMessage about this window (X SendEvent with a
|
|
3179
|
+
* ClientMessage payload).
|
|
3180
|
+
*
|
|
3181
|
+
* ClientMessage is the carrier of every convention layered over the core
|
|
3182
|
+
* protocol — EWMH state changes, WM_PROTOCOLS, XEmbed, XDND, the system
|
|
3183
|
+
* tray — and they differ only in the atom naming the message and the five
|
|
3184
|
+
* 32-bit words in it. `type` is an atom name (or an already-interned id),
|
|
3185
|
+
* `data` the words; anything past the end of the list is sent as zero.
|
|
3186
|
+
*
|
|
3187
|
+
* await wnd.sendClientMessage('WM_PROTOCOLS', [deleteAtom, time]);
|
|
3188
|
+
*
|
|
3189
|
+
* Two options carry the meaning that the raw request buries in argument
|
|
3190
|
+
* order:
|
|
3191
|
+
*
|
|
3192
|
+
* - `target` — where the message is *delivered*, which is not always the
|
|
3193
|
+
* window it is *about*. EWMH messages are about a client window and
|
|
3194
|
+
* delivered to the root, so the window manager (which selected
|
|
3195
|
+
* SubstructureRedirect there) sees them.
|
|
3196
|
+
* - `mask` — who on the target gets it. `0`, the default, delivers to the
|
|
3197
|
+
* client that owns the target window whatever it selected, which is
|
|
3198
|
+
* what a message addressed to another client needs. Root-window EWMH
|
|
3199
|
+
* messages instead pass
|
|
3200
|
+
* `SubstructureRedirect | SubstructureNotify`.
|
|
3201
|
+
*
|
|
3202
|
+
* @param {string|number} type atom name of the message type, or its id
|
|
3203
|
+
* @param {number[]} [data] up to five 32-bit words (ten at format 16,
|
|
3204
|
+
* twenty at format 8)
|
|
3205
|
+
* @param {object} [options] `{ target = this.id, format = 32, mask = 0 }`
|
|
3206
|
+
* @returns {Promise<Window>}
|
|
3207
|
+
*/
|
|
3208
|
+
async sendClientMessage(type, data = [], options = {}) {
|
|
3209
|
+
const { target = this.id, format = 32, mask = 0 } = options;
|
|
3210
|
+
const slots = { 8: 20, 16: 10, 32: 5 }[format];
|
|
3211
|
+
if (!slots) {
|
|
3212
|
+
throw new TypeError(`sendClientMessage: format must be 8, 16 or 32, got ${format}`);
|
|
3213
|
+
}
|
|
3214
|
+
if (data.length > slots) {
|
|
3215
|
+
// the wire event is 32 bytes and no more; sending the first `slots`
|
|
3216
|
+
// words of a longer list would drop the rest without a sound
|
|
3217
|
+
throw new RangeError(
|
|
3218
|
+
`sendClientMessage: a format-${format} ClientMessage carries ${slots} values, got ${data.length}`
|
|
3219
|
+
);
|
|
3220
|
+
}
|
|
3221
|
+
const messageType = typeof type === 'number' ? type : await this.atom(type);
|
|
3222
|
+
if (this._destroyed) return this;
|
|
3223
|
+
safeRelease(this.X, () => {
|
|
3224
|
+
this.X.SendClientMessage(target, this.id, messageType, format, data, mask);
|
|
3225
|
+
});
|
|
3226
|
+
return this;
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3171
3229
|
/**
|
|
3172
3230
|
* Ask the client to close, the polite way: a WM_DELETE_WINDOW client
|
|
3173
3231
|
* message if it advertised the protocol in WM_PROTOCOLS, so it can save
|
|
@@ -3187,13 +3245,11 @@ export default class Window extends Drawable {
|
|
|
3187
3245
|
safeRelease(this.X, () => this.X.KillClient(this.id));
|
|
3188
3246
|
return false;
|
|
3189
3247
|
}
|
|
3190
|
-
|
|
3191
|
-
//
|
|
3192
|
-
//
|
|
3193
|
-
//
|
|
3194
|
-
|
|
3195
|
-
this.X.SendClientMessage(this.id, this.id, wmProtocols, 32, [deleteAtom, 0 /* CurrentTime */], 0)
|
|
3196
|
-
);
|
|
3248
|
+
// the default mask of 0 delivers to the client that created the window,
|
|
3249
|
+
// not to whoever selected events on it — a WM_PROTOCOLS message is
|
|
3250
|
+
// addressed to the owner, so it must not go out with the EWMH
|
|
3251
|
+
// substructure mask
|
|
3252
|
+
await this.sendClientMessage('WM_PROTOCOLS', [deleteAtom, 0 /* CurrentTime */]);
|
|
3197
3253
|
return true;
|
|
3198
3254
|
}
|
|
3199
3255
|
|
package/lib/xembed.js
ADDED
|
@@ -0,0 +1,922 @@
|
|
|
1
|
+
import x11 from 'x11';
|
|
2
|
+
|
|
3
|
+
import { safeRelease } from './cleanup.js';
|
|
4
|
+
import { EventEmitter } from './drawable.js';
|
|
5
|
+
|
|
6
|
+
// XEmbed (http://specifications.freedesktop.org/xembed/0.5/): putting one
|
|
7
|
+
// client's top-level window inside another client's window hierarchy, across
|
|
8
|
+
// toolkits. Two roles — the embedder (GTK's socket, Qt's container) and the
|
|
9
|
+
// client (GTK's plug) — and no X extension: core requests only.
|
|
10
|
+
//
|
|
11
|
+
// The mechanics are deliberately short. The client sets `_XEMBED_INFO` on an
|
|
12
|
+
// unmapped top-level window; the embedder adds that window to its save-set,
|
|
13
|
+
// reparents it into one of its own and says so with an `XEMBED_EMBEDDED_NOTIFY`
|
|
14
|
+
// ClientMessage; from then on the two sides talk in `_XEMBED` messages —
|
|
15
|
+
// activation, focus, modality — while the embedder keeps the client's mapped
|
|
16
|
+
// state in step with the `XEMBED_MAPPED` bit of `_XEMBED_INFO`.
|
|
17
|
+
//
|
|
18
|
+
// Most of the interesting clients do not speak any of it. `xterm -into WID`,
|
|
19
|
+
// `mpv --wid=WID`, VLC and Wine want plain reparenting and set no
|
|
20
|
+
// `_XEMBED_INFO` at all, so a missing property means "map it now, skip the
|
|
21
|
+
// message protocol" — the same thing GtkSocket does, and the path that makes a
|
|
22
|
+
// terminal pane or a video surface work.
|
|
23
|
+
//
|
|
24
|
+
// Two things about focus are worth knowing before reading XEmbedSocket:
|
|
25
|
+
//
|
|
26
|
+
// - The embedder holds the real X input focus; the client gets *logical* focus
|
|
27
|
+
// by message. So activation and focus are things the embedder tells the
|
|
28
|
+
// client about, not things the client observes.
|
|
29
|
+
// - Key events therefore have to be routed by hand. X delivers a key press to
|
|
30
|
+
// the focus window, or to the deepest descendant of it under the pointer —
|
|
31
|
+
// so with focus on the embedder's toplevel, whether the embedded client sees
|
|
32
|
+
// a keystroke would depend on where the mouse happens to be. FocusProxy is
|
|
33
|
+
// the classic answer: an InputOnly window that holds the real focus while
|
|
34
|
+
// the client is logically focused, forwarding what it receives with
|
|
35
|
+
// SendEvent.
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Protocol constants: the version, the `_XEMBED_INFO` flags, the message
|
|
39
|
+
* opcodes and the details `XEMBED_FOCUS_IN` can carry.
|
|
40
|
+
*/
|
|
41
|
+
export const XEMBED = {
|
|
42
|
+
/** highest protocol version this implementation speaks */
|
|
43
|
+
VERSION: 0,
|
|
44
|
+
|
|
45
|
+
// _XEMBED_INFO flags
|
|
46
|
+
/** bit 0 of the flags word: the client wants to be mapped */
|
|
47
|
+
MAPPED: 1 << 0,
|
|
48
|
+
|
|
49
|
+
// opcodes (message data l[1])
|
|
50
|
+
EMBEDDED_NOTIFY: 0,
|
|
51
|
+
WINDOW_ACTIVATE: 1,
|
|
52
|
+
WINDOW_DEACTIVATE: 2,
|
|
53
|
+
REQUEST_FOCUS: 3,
|
|
54
|
+
FOCUS_IN: 4,
|
|
55
|
+
FOCUS_OUT: 5,
|
|
56
|
+
FOCUS_NEXT: 6,
|
|
57
|
+
FOCUS_PREV: 7,
|
|
58
|
+
MODALITY_ON: 10,
|
|
59
|
+
MODALITY_OFF: 11,
|
|
60
|
+
REGISTER_ACCELERATOR: 12,
|
|
61
|
+
UNREGISTER_ACCELERATOR: 13,
|
|
62
|
+
ACTIVATE_ACCELERATOR: 14,
|
|
63
|
+
|
|
64
|
+
// XEMBED_FOCUS_IN details (message data l[2])
|
|
65
|
+
/** focus the client without moving its own logical focus */
|
|
66
|
+
FOCUS_CURRENT: 0,
|
|
67
|
+
/** a Tab arriving from outside: focus the first widget in the tab chain */
|
|
68
|
+
FOCUS_FIRST: 1,
|
|
69
|
+
/** a back-Tab arriving from outside: focus the last one */
|
|
70
|
+
FOCUS_LAST: 2
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** opcode -> name, for logging and for the `'message'` events */
|
|
74
|
+
export const XEMBED_OPCODE_NAMES = Object.freeze({
|
|
75
|
+
0: 'EMBEDDED_NOTIFY',
|
|
76
|
+
1: 'WINDOW_ACTIVATE',
|
|
77
|
+
2: 'WINDOW_DEACTIVATE',
|
|
78
|
+
3: 'REQUEST_FOCUS',
|
|
79
|
+
4: 'FOCUS_IN',
|
|
80
|
+
5: 'FOCUS_OUT',
|
|
81
|
+
6: 'FOCUS_NEXT',
|
|
82
|
+
7: 'FOCUS_PREV',
|
|
83
|
+
10: 'MODALITY_ON',
|
|
84
|
+
11: 'MODALITY_OFF',
|
|
85
|
+
12: 'REGISTER_ACCELERATOR',
|
|
86
|
+
13: 'UNREGISTER_ACCELERATOR',
|
|
87
|
+
14: 'ACTIVATE_ACCELERATOR'
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const NAME = '_XEMBED';
|
|
91
|
+
const INFO = '_XEMBED_INFO';
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The two CARD32s of an `_XEMBED_INFO` property: the protocol version the
|
|
95
|
+
* client speaks and its flags word.
|
|
96
|
+
*
|
|
97
|
+
* @param {object} [info] `{ version, flags, mapped }` — `mapped` is the
|
|
98
|
+
* XEMBED_MAPPED bit spelled out, and wins over the same bit in `flags`
|
|
99
|
+
* @returns {number[]} `[version, flags]`
|
|
100
|
+
*/
|
|
101
|
+
export function encodeXEmbedInfo(info = {}) {
|
|
102
|
+
const { version = XEMBED.VERSION, flags = 0, mapped } = info;
|
|
103
|
+
let word = flags >>> 0;
|
|
104
|
+
if (mapped !== undefined) {
|
|
105
|
+
word = mapped ? word | XEMBED.MAPPED : word & ~XEMBED.MAPPED;
|
|
106
|
+
}
|
|
107
|
+
return [version >>> 0, word >>> 0];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The inverse. `null` for a property that is absent or too short to be one —
|
|
112
|
+
* which is not an error but the ordinary case: a client that speaks no XEmbed
|
|
113
|
+
* sets no `_XEMBED_INFO`, and is embedded by plain reparenting.
|
|
114
|
+
*
|
|
115
|
+
* @param {number[]|null} words the property read `{ as: 'numbers' }`
|
|
116
|
+
* @returns {{version: number, flags: number, mapped: boolean}|null}
|
|
117
|
+
*/
|
|
118
|
+
export function decodeXEmbedInfo(words) {
|
|
119
|
+
if (!words || words.length < 2) return null;
|
|
120
|
+
const flags = words[1] >>> 0;
|
|
121
|
+
return { version: words[0] >>> 0, flags, mapped: (flags & XEMBED.MAPPED) !== 0 };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Read `_XEMBED_INFO` off a window. `null` when it is not set. */
|
|
125
|
+
export async function readXEmbedInfo(window) {
|
|
126
|
+
const words = await window.getProperty(INFO, { as: 'numbers' }).catch(() => null);
|
|
127
|
+
return decodeXEmbedInfo(words);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The window that holds the real X input focus on the client's behalf, and
|
|
132
|
+
* forwards keystrokes into it.
|
|
133
|
+
*
|
|
134
|
+
* X delivers a key press to the focus window or to the deepest descendant of
|
|
135
|
+
* it that contains the pointer, so an embedder that simply focuses its own
|
|
136
|
+
* toplevel would send the embedded client keys only while the mouse is over
|
|
137
|
+
* it. A 1x1 InputOnly window, mapped but clipped entirely outside its parent,
|
|
138
|
+
* has no descendants and covers no pixels: it can hold focus, it can never be
|
|
139
|
+
* entered by the pointer, and everything it receives belongs to whoever is
|
|
140
|
+
* logically focused.
|
|
141
|
+
*
|
|
142
|
+
* Forwarded events carry X's `send_event` flag, as any SendEvent does. Toolkit
|
|
143
|
+
* clients accept them — this is how XEmbed keyboard input has always worked —
|
|
144
|
+
* but a client that filters synthetic events will not see them.
|
|
145
|
+
*/
|
|
146
|
+
export class FocusProxy {
|
|
147
|
+
/**
|
|
148
|
+
* @param {import('./window.js').default} parent the window to create the
|
|
149
|
+
* proxy under; it must be viewable for the proxy to be able to take focus
|
|
150
|
+
*/
|
|
151
|
+
constructor(parent) {
|
|
152
|
+
this.app = parent.app;
|
|
153
|
+
this.X = parent.X;
|
|
154
|
+
this.target = 0;
|
|
155
|
+
// -1,-1: entirely outside the parent, so the pointer can never be in it
|
|
156
|
+
this.window = this.app.createWindow({
|
|
157
|
+
parent,
|
|
158
|
+
x: -1,
|
|
159
|
+
y: -1,
|
|
160
|
+
width: 1,
|
|
161
|
+
height: 1,
|
|
162
|
+
windowClass: 2, // InputOnly
|
|
163
|
+
eventMask: x11.eventMask.KeyPress | x11.eventMask.KeyRelease
|
|
164
|
+
});
|
|
165
|
+
this.window.map();
|
|
166
|
+
this._forward = this._forward.bind(this);
|
|
167
|
+
this.window.on('keydown', this._forward);
|
|
168
|
+
this.window.on('keyup', this._forward);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Take the X input focus and forward what arrives to `windowId`.
|
|
173
|
+
* @param {number} windowId
|
|
174
|
+
*/
|
|
175
|
+
take(windowId) {
|
|
176
|
+
this.target = windowId >>> 0;
|
|
177
|
+
this.window.focus(2); // revert to the parent when we stop being viewable
|
|
178
|
+
return this;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Stop forwarding. Does not move the focus — that is the embedder's call. */
|
|
182
|
+
release() {
|
|
183
|
+
this.target = 0;
|
|
184
|
+
return this;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
destroy() {
|
|
188
|
+
this.target = 0;
|
|
189
|
+
this.window.removeListener('keydown', this._forward);
|
|
190
|
+
this.window.removeListener('keyup', this._forward);
|
|
191
|
+
this.window.destroy();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
_forward(ev) {
|
|
195
|
+
if (!this.target) return;
|
|
196
|
+
// the same event, re-addressed: `wid` is the window it is reported on and
|
|
197
|
+
// `child` the descendant it happened over, neither of which survives the
|
|
198
|
+
// move. Mask 0 delivers it to the window's owner whatever it selected.
|
|
199
|
+
safeRelease(this.X, () =>
|
|
200
|
+
this.X.SendEvent(this.target, 0, 0, { ...ev, wid: this.target, child: 0 })
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The embedder half: a container window that another client's top-level
|
|
207
|
+
* window is reparented into.
|
|
208
|
+
*
|
|
209
|
+
* const socket = new XEmbedSocket(parentWindow, { x, y, width, height });
|
|
210
|
+
* await socket.embed(clientWindowId);
|
|
211
|
+
*
|
|
212
|
+
* Events:
|
|
213
|
+
* 'embedded' `{ id, window, version, xembed }` — `xembed:false` is a
|
|
214
|
+
* client that set no `_XEMBED_INFO` and got plain reparenting
|
|
215
|
+
* 'mappedChange' `(mapped)` — the client asked to be mapped or unmapped
|
|
216
|
+
* 'requestFocus' the client wants the logical focus
|
|
217
|
+
* 'focusNext' / 'focusPrev' — the client ran off the end of its tab chain
|
|
218
|
+
* 'message' `{ opcode, name, detail, data1, data2, time }` — an
|
|
219
|
+
* `_XEMBED` message this class does not act on itself
|
|
220
|
+
* 'gone' the client was destroyed or reparented away by someone else
|
|
221
|
+
*/
|
|
222
|
+
export class XEmbedSocket extends EventEmitter {
|
|
223
|
+
/**
|
|
224
|
+
* @param {import('./window.js').default} parent where the container window
|
|
225
|
+
* goes
|
|
226
|
+
* @param {object} [options] `{ x, y, width, height }` for the container
|
|
227
|
+
* window this creates, or `{ window }` to embed into a window the caller
|
|
228
|
+
* already owns (which it also keeps responsible for destroying).
|
|
229
|
+
* `{ version }` caps the protocol version offered to the client;
|
|
230
|
+
* `{ focusOnRequest: false }` stops `XEMBED_REQUEST_FOCUS` from being
|
|
231
|
+
* answered automatically, for an embedder with a focus manager of its own.
|
|
232
|
+
*/
|
|
233
|
+
constructor(parent, options = {}) {
|
|
234
|
+
super();
|
|
235
|
+
const {
|
|
236
|
+
x = 0,
|
|
237
|
+
y = 0,
|
|
238
|
+
width = 1,
|
|
239
|
+
height = 1,
|
|
240
|
+
window = null,
|
|
241
|
+
version = XEMBED.VERSION,
|
|
242
|
+
focusOnRequest = true
|
|
243
|
+
} = options;
|
|
244
|
+
|
|
245
|
+
this.app = parent.app;
|
|
246
|
+
this.X = this.app.X;
|
|
247
|
+
this.version = version;
|
|
248
|
+
this._focusOnRequest = focusOnRequest;
|
|
249
|
+
this._ownsWindow = !window;
|
|
250
|
+
/** the container window; the client becomes a child of this */
|
|
251
|
+
this.window = window ?? this.app.createWindow({ parent, x, y, width, height });
|
|
252
|
+
|
|
253
|
+
/** the embedded client, as a Window, or null */
|
|
254
|
+
this.client = null;
|
|
255
|
+
/** whether the client answered with `_XEMBED_INFO` */
|
|
256
|
+
this.xembed = false;
|
|
257
|
+
/** the protocol version in use, once a client is embedded */
|
|
258
|
+
this.clientVersion = 0;
|
|
259
|
+
this.active = false;
|
|
260
|
+
this.focused = false;
|
|
261
|
+
|
|
262
|
+
this._proxy = null;
|
|
263
|
+
this._mapped = false;
|
|
264
|
+
this._destroyed = false;
|
|
265
|
+
this._infoAtom = 0;
|
|
266
|
+
this._xembedAtom = 0;
|
|
267
|
+
// the newest server timestamp we have seen, which is what a message
|
|
268
|
+
// should carry: CurrentTime is not a timestamp and cannot arbitrate
|
|
269
|
+
this._time = 0;
|
|
270
|
+
|
|
271
|
+
this._onClientProperty = this._onClientProperty.bind(this);
|
|
272
|
+
this._onClientDestroy = this._onClientDestroy.bind(this);
|
|
273
|
+
this._onClientReparent = this._onClientReparent.bind(this);
|
|
274
|
+
this._onMessage = this._onMessage.bind(this);
|
|
275
|
+
this.window.on('message', this._onMessage);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Take over `clientId` — a top-level window belonging to another client —
|
|
280
|
+
* and put it inside this socket.
|
|
281
|
+
*
|
|
282
|
+
* Resolves once the client is reparented and, for a client that speaks the
|
|
283
|
+
* protocol, told about it; the `'embedded'` event carries the same
|
|
284
|
+
* information. Rejects if the window does not exist.
|
|
285
|
+
*
|
|
286
|
+
* @param {number} clientId
|
|
287
|
+
* @returns {Promise<{id: number, window: object, version: number, xembed: boolean}>}
|
|
288
|
+
*/
|
|
289
|
+
async embed(clientId) {
|
|
290
|
+
if (this.client) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
`xembed: this socket already holds window ${this.client.id}; call release() first, ` +
|
|
293
|
+
'or use one socket per embedded client'
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
if (!clientId) throw new Error('xembed: embed() needs the client window id');
|
|
297
|
+
|
|
298
|
+
return this._attach(this.app.createWindow({ id: clientId }), true);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Take whatever turns up as a child of this socket as the client.
|
|
303
|
+
*
|
|
304
|
+
* `xterm -into WID` and `mpv --wid=WID` are handed a window id and put
|
|
305
|
+
* their own window inside it, so by the time you hear about them there is
|
|
306
|
+
* nothing left to reparent — but everything after that (sizing, the
|
|
307
|
+
* synthetic ConfigureNotify, the mapped state, `'gone'`) is the same
|
|
308
|
+
* embedding. Give the program `socket.window.id` and call this:
|
|
309
|
+
*
|
|
310
|
+
* const socket = new XEmbedSocket(pane, { width: 640, height: 480 });
|
|
311
|
+
* spawn('xterm', ['-into', String(socket.window.id)]);
|
|
312
|
+
* await socket.adopt();
|
|
313
|
+
*
|
|
314
|
+
* A child that is already there is taken immediately.
|
|
315
|
+
*
|
|
316
|
+
* @param {object} [options] `{ timeout }` in ms — without one this waits
|
|
317
|
+
* for as long as it takes, which is right for a program that is still
|
|
318
|
+
* starting up and wrong for one that has already failed to
|
|
319
|
+
* @returns {Promise<{id: number, window: object, version: number, xembed: boolean}>}
|
|
320
|
+
*/
|
|
321
|
+
async adopt(options = {}) {
|
|
322
|
+
const { timeout = 0 } = options;
|
|
323
|
+
if (this.client) {
|
|
324
|
+
throw new Error(`xembed: this socket already holds window ${this.client.id}`);
|
|
325
|
+
}
|
|
326
|
+
if (this._ownsWindow) this.window.map();
|
|
327
|
+
|
|
328
|
+
const child = await new Promise((resolve, reject) => {
|
|
329
|
+
let timer = null;
|
|
330
|
+
let done = false;
|
|
331
|
+
const settle = (fn, value) => {
|
|
332
|
+
if (done) return;
|
|
333
|
+
done = true;
|
|
334
|
+
this.window.removeListener('create', look);
|
|
335
|
+
this.window.removeListener('reparent', look);
|
|
336
|
+
if (timer) clearTimeout(timer);
|
|
337
|
+
fn(value);
|
|
338
|
+
};
|
|
339
|
+
// Which event announces the window depends on how the program got it
|
|
340
|
+
// there: `mpv --wid` creates it as our child (CreateNotify), `xterm
|
|
341
|
+
// -into` creates a toplevel and reparents it in (ReparentNotify, which
|
|
342
|
+
// also fires for a window leaving us). Rather than tell those apart,
|
|
343
|
+
// ask the server what our children actually are.
|
|
344
|
+
const look = () => {
|
|
345
|
+
if (done) return;
|
|
346
|
+
this._firstChild().then((id) => {
|
|
347
|
+
if (id) settle(resolve, this.app.createWindow({ id }));
|
|
348
|
+
}, () => {});
|
|
349
|
+
};
|
|
350
|
+
// Listen first, then look: a program racing us would otherwise fall
|
|
351
|
+
// between the two, appearing after the QueryTree that found nothing and
|
|
352
|
+
// before the selection that would have reported it. `on(…)` selects
|
|
353
|
+
// SubstructureNotify, and requests on one connection are ordered, so by
|
|
354
|
+
// the time the QueryTree is answered the selection is in force.
|
|
355
|
+
this.window.on('create', look);
|
|
356
|
+
this.window.on('reparent', look);
|
|
357
|
+
look();
|
|
358
|
+
if (timeout) {
|
|
359
|
+
timer = setTimeout(() => {
|
|
360
|
+
settle(
|
|
361
|
+
reject,
|
|
362
|
+
new Error(
|
|
363
|
+
`xembed: nothing put a window inside ${this.window.id} within ${timeout}ms. ` +
|
|
364
|
+
'Check that the program was given that id (xterm -into ID, mpv --wid=ID) ' +
|
|
365
|
+
'and that it is talking to the same display.'
|
|
366
|
+
)
|
|
367
|
+
);
|
|
368
|
+
}, timeout);
|
|
369
|
+
timer.unref?.();
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
return this._attach(child, false);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** the first child of the container that is not something we made */
|
|
376
|
+
_firstChild() {
|
|
377
|
+
return new Promise((resolve) => {
|
|
378
|
+
let asked = false;
|
|
379
|
+
safeRelease(this.X, () => {
|
|
380
|
+
asked = true;
|
|
381
|
+
this.X.QueryTree(this.window.id, (err, tree) => {
|
|
382
|
+
if (err) return resolve(0);
|
|
383
|
+
resolve(tree.children.find((id) => id !== this._proxy?.window.id) ?? 0);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
if (!asked) resolve(0); // the connection is gone
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async _attach(client, reparent) {
|
|
391
|
+
// PropertyChange for _XEMBED_INFO, StructureNotify for the lifecycle
|
|
392
|
+
// (destroy, and a reparent that takes the client away from us). This is
|
|
393
|
+
// also the round trip that says whether the window exists at all.
|
|
394
|
+
await client.selectInput(x11.eventMask.PropertyChange | x11.eventMask.StructureNotify);
|
|
395
|
+
|
|
396
|
+
const [info, infoAtom, xembedAtom] = await Promise.all([
|
|
397
|
+
readXEmbedInfo(client),
|
|
398
|
+
this.window.atom(INFO),
|
|
399
|
+
this.window.atom(NAME)
|
|
400
|
+
]);
|
|
401
|
+
if (this._destroyed) return { id: client.id, window: client, version: 0, xembed: false };
|
|
402
|
+
|
|
403
|
+
this._infoAtom = infoAtom;
|
|
404
|
+
this._xembedAtom = xembedAtom;
|
|
405
|
+
this.client = client;
|
|
406
|
+
this.xembed = !!info;
|
|
407
|
+
// both sides speak the lower of the two versions
|
|
408
|
+
this.clientVersion = info ? Math.min(this.version, info.version) : 0;
|
|
409
|
+
|
|
410
|
+
client.on('property', this._onClientProperty);
|
|
411
|
+
client.on('destroy', this._onClientDestroy);
|
|
412
|
+
client.on('reparent', this._onClientReparent);
|
|
413
|
+
|
|
414
|
+
// the client must outlive us: the save-set puts it back under the root if
|
|
415
|
+
// this connection dies while it is our child, instead of taking it with us
|
|
416
|
+
client.addToSaveSet();
|
|
417
|
+
if (reparent) {
|
|
418
|
+
// a container nobody has mapped makes the client unviewable, however
|
|
419
|
+
// mapped the client itself is. One passed in as `{ window }` is the
|
|
420
|
+
// caller's to map.
|
|
421
|
+
if (this._ownsWindow) this.window.map();
|
|
422
|
+
client.reparentTo(this.window, 0, 0);
|
|
423
|
+
}
|
|
424
|
+
client.moveResize(0, 0, this.window.width, this.window.height);
|
|
425
|
+
|
|
426
|
+
if (this.xembed) {
|
|
427
|
+
// data1 is the window the client has been embedded into — the one it
|
|
428
|
+
// sends its own messages back to — and data2 the version in use
|
|
429
|
+
this._send(XEMBED.EMBEDDED_NOTIFY, 0, this.window.id, this.clientVersion);
|
|
430
|
+
}
|
|
431
|
+
// A client that speaks the protocol is mapped only when it asks to be; one
|
|
432
|
+
// that does not never will, so it is mapped now. A window waiting to be
|
|
433
|
+
// embedded is unmapped — that is what waiting looks like — and mapping an
|
|
434
|
+
// adopted one that already showed itself costs nothing.
|
|
435
|
+
this._setMapped(this.xembed ? info.mapped : true);
|
|
436
|
+
await this._syncConfigure();
|
|
437
|
+
|
|
438
|
+
const embedded = {
|
|
439
|
+
id: client.id,
|
|
440
|
+
window: client,
|
|
441
|
+
version: this.clientVersion,
|
|
442
|
+
xembed: this.xembed
|
|
443
|
+
};
|
|
444
|
+
this.emit('embedded', embedded);
|
|
445
|
+
return embedded;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Move and resize the container, and the client with it.
|
|
450
|
+
*
|
|
451
|
+
* The client also gets a synthetic ConfigureNotify with root-relative
|
|
452
|
+
* coordinates: its own says where it is inside our window, which is not the
|
|
453
|
+
* question a client asks (ICCCM 4.1.5).
|
|
454
|
+
*
|
|
455
|
+
* @param {object} [geometry] `{ x, y, width, height }`; anything omitted
|
|
456
|
+
* stays as it is
|
|
457
|
+
* @returns {Promise<XEmbedSocket>}
|
|
458
|
+
*/
|
|
459
|
+
async resize(geometry = {}) {
|
|
460
|
+
const win = this.window;
|
|
461
|
+
const { x = win.x, y = win.y, width = win.width, height = win.height } = geometry;
|
|
462
|
+
if (this._ownsWindow) {
|
|
463
|
+
win.moveResize(x, y, width, height);
|
|
464
|
+
win.x = x;
|
|
465
|
+
win.y = y;
|
|
466
|
+
win.width = width;
|
|
467
|
+
win.height = height;
|
|
468
|
+
}
|
|
469
|
+
if (this.client && !this.client._destroyed) {
|
|
470
|
+
this.client.moveResize(0, 0, width, height);
|
|
471
|
+
await this._syncConfigure(width, height);
|
|
472
|
+
}
|
|
473
|
+
return this;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Tell the client whether the toplevel it lives in is the active window
|
|
478
|
+
* (`XEMBED_WINDOW_ACTIVATE` / `XEMBED_WINDOW_DEACTIVATE`). A client starts
|
|
479
|
+
* out inactive, so this only has to be called when that changes.
|
|
480
|
+
*
|
|
481
|
+
* @param {boolean} [active]
|
|
482
|
+
* @param {object} [options] `{ time }` — the server timestamp of the event
|
|
483
|
+
* that caused this
|
|
484
|
+
*/
|
|
485
|
+
activate(active = true, options = {}) {
|
|
486
|
+
if (this.active === !!active) return this;
|
|
487
|
+
this.active = !!active;
|
|
488
|
+
this._send(active ? XEMBED.WINDOW_ACTIVATE : XEMBED.WINDOW_DEACTIVATE, 0, 0, 0, options);
|
|
489
|
+
return this;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** `activate(false)`. */
|
|
493
|
+
deactivate(options = {}) {
|
|
494
|
+
return this.activate(false, options);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Give the client the logical focus (`XEMBED_FOCUS_IN`) and start routing
|
|
499
|
+
* keystrokes into it.
|
|
500
|
+
*
|
|
501
|
+
* @param {number} [detail] `XEMBED.FOCUS_CURRENT` (a click: focus the client
|
|
502
|
+
* without disturbing which of its widgets is focused),
|
|
503
|
+
* `XEMBED.FOCUS_FIRST` or `XEMBED.FOCUS_LAST` (a Tab or back-Tab arriving
|
|
504
|
+
* from outside)
|
|
505
|
+
* @param {object} [options] `{ time }`
|
|
506
|
+
*/
|
|
507
|
+
focusIn(detail = XEMBED.FOCUS_CURRENT, options = {}) {
|
|
508
|
+
if (!this.client) return this;
|
|
509
|
+
this.focused = true;
|
|
510
|
+
this._send(XEMBED.FOCUS_IN, detail, 0, 0, options);
|
|
511
|
+
this._ensureProxy().take(this.client.id);
|
|
512
|
+
return this;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Take the logical focus away again (`XEMBED_FOCUS_OUT`). */
|
|
516
|
+
focusOut(options = {}) {
|
|
517
|
+
if (!this.focused) return this;
|
|
518
|
+
this.focused = false;
|
|
519
|
+
this._proxy?.release();
|
|
520
|
+
this._send(XEMBED.FOCUS_OUT, 0, 0, 0, options);
|
|
521
|
+
return this;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Turn a modality shield on or off (`XEMBED_MODALITY_ON` / `_OFF`): the
|
|
526
|
+
* client should stop responding to input because the embedder has put up a
|
|
527
|
+
* modal dialog somewhere else.
|
|
528
|
+
*/
|
|
529
|
+
modality(on = true, options = {}) {
|
|
530
|
+
this._send(on ? XEMBED.MODALITY_ON : XEMBED.MODALITY_OFF, 0, 0, 0, options);
|
|
531
|
+
return this;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Send a raw `_XEMBED` message. Everything above is a wrapper over this;
|
|
536
|
+
* it is here for the opcodes ntk does not model (the accelerator ones).
|
|
537
|
+
*/
|
|
538
|
+
send(opcode, detail = 0, data1 = 0, data2 = 0, options = {}) {
|
|
539
|
+
this._send(opcode, detail, data1, data2, options);
|
|
540
|
+
return this;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Hand the client back: reparent it to the root, drop it from our save-set
|
|
545
|
+
* and stop watching it. It becomes an ordinary top-level window again, at
|
|
546
|
+
* the position it occupied on screen, which is what "unplugging" means for
|
|
547
|
+
* `xterm -into` and for GtkSocket alike.
|
|
548
|
+
*
|
|
549
|
+
* @returns {Promise<XEmbedSocket>}
|
|
550
|
+
*/
|
|
551
|
+
async release() {
|
|
552
|
+
const client = this.client;
|
|
553
|
+
if (!client) return this;
|
|
554
|
+
this._detach();
|
|
555
|
+
if (client._destroyed) return this;
|
|
556
|
+
|
|
557
|
+
const { x, y } = await this._rootCoords();
|
|
558
|
+
// stop selecting events on a window that is no longer ours before the
|
|
559
|
+
// reparent, so its ReparentNotify does not come back to us
|
|
560
|
+
safeRelease(this.X, () => {
|
|
561
|
+
client.eventMask = 0;
|
|
562
|
+
this.X.ChangeWindowAttributes(client.id, { eventMask: 0 }, () => {});
|
|
563
|
+
});
|
|
564
|
+
client.reparentTo(this.app.rootWindow(), x, y);
|
|
565
|
+
client.removeFromSaveSet();
|
|
566
|
+
return this;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Release the client, then drop the container window and the focus proxy.
|
|
571
|
+
* A container passed in as `{ window }` belongs to the caller and is left
|
|
572
|
+
* alone.
|
|
573
|
+
*/
|
|
574
|
+
async destroy() {
|
|
575
|
+
if (this._destroyed) return this;
|
|
576
|
+
this._destroyed = true;
|
|
577
|
+
await this.release();
|
|
578
|
+
this.window.removeListener('message', this._onMessage);
|
|
579
|
+
this._proxy?.destroy();
|
|
580
|
+
this._proxy = null;
|
|
581
|
+
if (this._ownsWindow) this.window.destroy();
|
|
582
|
+
return this;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// -------------------------------------------------------------------
|
|
586
|
+
|
|
587
|
+
_ensureProxy() {
|
|
588
|
+
if (!this._proxy) this._proxy = new FocusProxy(this.window);
|
|
589
|
+
return this._proxy;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
_send(opcode, detail = 0, data1 = 0, data2 = 0, options = {}) {
|
|
593
|
+
if (!this.client || this.client._destroyed) return;
|
|
594
|
+
const { time = this._time } = options;
|
|
595
|
+
this.client
|
|
596
|
+
.sendClientMessage(this._xembedAtom || NAME, [time, opcode, detail, data1, data2])
|
|
597
|
+
.catch((err) => this.app.options?.onXError?.(err));
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
_setMapped(mapped) {
|
|
601
|
+
if (this._mapped === !!mapped) return;
|
|
602
|
+
this._mapped = !!mapped;
|
|
603
|
+
if (!this.client || this.client._destroyed) return;
|
|
604
|
+
if (this._mapped) this.client.map();
|
|
605
|
+
else this.client.unmap();
|
|
606
|
+
this.emit('mappedChange', this._mapped);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** where the container's origin is on the root window */
|
|
610
|
+
async _rootCoords() {
|
|
611
|
+
const root = this.app.display.screen[0].root;
|
|
612
|
+
return new Promise((resolve) => {
|
|
613
|
+
safeRelease(this.X, () =>
|
|
614
|
+
this.X.TranslateCoordinates(this.window.id, root, 0, 0, (err, res) =>
|
|
615
|
+
resolve(err ? { x: 0, y: 0 } : { x: res.destX, y: res.destY })
|
|
616
|
+
)
|
|
617
|
+
);
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async _syncConfigure(width = this.window.width, height = this.window.height) {
|
|
622
|
+
const client = this.client;
|
|
623
|
+
if (!client || client._destroyed) return;
|
|
624
|
+
const { x, y } = await this._rootCoords();
|
|
625
|
+
if (client._destroyed) return;
|
|
626
|
+
client.sendConfigureNotify({ x, y, width, height });
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
_onMessage(ev) {
|
|
630
|
+
if (!this._xembedAtom || ev.message_type !== this._xembedAtom) return;
|
|
631
|
+
const [time, opcode, detail, data1, data2] = ev.data;
|
|
632
|
+
if (time) this._time = time >>> 0;
|
|
633
|
+
switch (opcode) {
|
|
634
|
+
case XEMBED.REQUEST_FOCUS:
|
|
635
|
+
this.emit('requestFocus');
|
|
636
|
+
// the client saying the user clicked it. An embedder with its own
|
|
637
|
+
// focus manager turns this off and calls focusIn() itself.
|
|
638
|
+
if (this._focusOnRequest) this.focusIn(XEMBED.FOCUS_CURRENT);
|
|
639
|
+
break;
|
|
640
|
+
case XEMBED.FOCUS_NEXT:
|
|
641
|
+
this.emit('focusNext');
|
|
642
|
+
break;
|
|
643
|
+
case XEMBED.FOCUS_PREV:
|
|
644
|
+
this.emit('focusPrev');
|
|
645
|
+
break;
|
|
646
|
+
default:
|
|
647
|
+
this.emit('message', {
|
|
648
|
+
opcode,
|
|
649
|
+
name: XEMBED_OPCODE_NAMES[opcode],
|
|
650
|
+
detail,
|
|
651
|
+
data1,
|
|
652
|
+
data2,
|
|
653
|
+
time
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
_onClientProperty(ev) {
|
|
659
|
+
if (ev.atom !== this._infoAtom) return;
|
|
660
|
+
if (ev.time) this._time = ev.time >>> 0;
|
|
661
|
+
// state 1 is Delete. The spec has no meaning for a client withdrawing the
|
|
662
|
+
// property mid-embedding, so the mapped state it last asked for stands.
|
|
663
|
+
if (ev.state === 1) return;
|
|
664
|
+
readXEmbedInfo(this.client).then(
|
|
665
|
+
(info) => {
|
|
666
|
+
if (info) this._setMapped(info.mapped);
|
|
667
|
+
},
|
|
668
|
+
() => {}
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
_onClientDestroy() {
|
|
673
|
+
this._detach();
|
|
674
|
+
this.emit('gone');
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
_onClientReparent(ev) {
|
|
678
|
+
// ours is the reparent *into* the socket; anything else is the client
|
|
679
|
+
// being taken away from us
|
|
680
|
+
if (ev.parent === this.window.id) return;
|
|
681
|
+
this._detach();
|
|
682
|
+
this.emit('gone');
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** forget the client without touching it — it is gone, or no longer ours */
|
|
686
|
+
_detach() {
|
|
687
|
+
const client = this.client;
|
|
688
|
+
if (!client) return;
|
|
689
|
+
client.removeListener('property', this._onClientProperty);
|
|
690
|
+
client.removeListener('destroy', this._onClientDestroy);
|
|
691
|
+
client.removeListener('reparent', this._onClientReparent);
|
|
692
|
+
this.client = null;
|
|
693
|
+
this.xembed = false;
|
|
694
|
+
this.clientVersion = 0;
|
|
695
|
+
this.active = false;
|
|
696
|
+
this.focused = false;
|
|
697
|
+
this._mapped = false;
|
|
698
|
+
this._proxy?.release();
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* The client half: a top-level window that offers itself to be embedded.
|
|
704
|
+
*
|
|
705
|
+
* const plug = new XEmbedPlug(app, { width: 300, height: 200 });
|
|
706
|
+
* await plug.ready;
|
|
707
|
+
* console.log(plug.window.id); // hand this to the embedder
|
|
708
|
+
*
|
|
709
|
+
* The window is deliberately left unmapped — an embedder maps it, once it has
|
|
710
|
+
* reparented it and read `XEMBED_MAPPED` out of `_XEMBED_INFO`. Draw into
|
|
711
|
+
* `plug.window` as usual.
|
|
712
|
+
*
|
|
713
|
+
* Events:
|
|
714
|
+
* 'embedded' `(embedderId)` — reparented into someone else's window
|
|
715
|
+
* 'activate' / 'deactivate' — the toplevel we are inside became (in)active
|
|
716
|
+
* 'focusIn' `(detail)` — one of `XEMBED.FOCUS_CURRENT/FIRST/LAST`
|
|
717
|
+
* 'focusOut'
|
|
718
|
+
* 'modality' `(on)`
|
|
719
|
+
* 'message' `{ opcode, name, detail, data1, data2, time }` — anything
|
|
720
|
+
* this class does not model
|
|
721
|
+
* 'released' reparented back to the root: the protocol is over
|
|
722
|
+
*/
|
|
723
|
+
export class XEmbedPlug extends EventEmitter {
|
|
724
|
+
/**
|
|
725
|
+
* @param {import('./app.js').default} app
|
|
726
|
+
* @param {object} [options] `{ version, mapped }` — the two words of
|
|
727
|
+
* `_XEMBED_INFO` — plus `{ window }` to adopt a window instead of
|
|
728
|
+
* creating one. Anything else is passed to `app.createWindow()`.
|
|
729
|
+
*/
|
|
730
|
+
constructor(app, options = {}) {
|
|
731
|
+
super();
|
|
732
|
+
const { version = XEMBED.VERSION, mapped = true, window = null, ...windowArgs } = options;
|
|
733
|
+
|
|
734
|
+
this.app = app;
|
|
735
|
+
this.X = app.X;
|
|
736
|
+
this.version = version >>> 0;
|
|
737
|
+
/** the window to draw into; its id is what an embedder needs */
|
|
738
|
+
this.window = window ?? app.createWindow(windowArgs);
|
|
739
|
+
this._ownsWindow = !window;
|
|
740
|
+
|
|
741
|
+
/** the embedder's window id, or 0 while free-standing */
|
|
742
|
+
this.embedder = 0;
|
|
743
|
+
this._embedderWindow = null;
|
|
744
|
+
/** whether the embedder speaks the message protocol */
|
|
745
|
+
this.xembed = false;
|
|
746
|
+
this.active = false;
|
|
747
|
+
this.focused = false;
|
|
748
|
+
this.modal = false;
|
|
749
|
+
this._mapped = !!mapped;
|
|
750
|
+
this._embedded = false;
|
|
751
|
+
this._xembedAtom = 0;
|
|
752
|
+
this._destroyed = false;
|
|
753
|
+
|
|
754
|
+
this._onMessage = this._onMessage.bind(this);
|
|
755
|
+
this._onReparent = this._onReparent.bind(this);
|
|
756
|
+
this.window.on('message', this._onMessage);
|
|
757
|
+
this.window.on('reparent', this._onReparent);
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Resolves once `_XEMBED_INFO` is on the window — which is what has to be
|
|
761
|
+
* true before its id is given to anyone.
|
|
762
|
+
* @type {Promise<XEmbedPlug>}
|
|
763
|
+
*/
|
|
764
|
+
this.ready = this._publish().then(() => this);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** The `XEMBED_MAPPED` bit: whether the embedder should map us. */
|
|
768
|
+
get mapped() {
|
|
769
|
+
return this._mapped;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Ask the embedder to map or unmap us. An embedder may leave us unmapped
|
|
774
|
+
* while the bit is set (a tab that is not the current one), but must unmap
|
|
775
|
+
* promptly when it clears.
|
|
776
|
+
* @returns {Promise<XEmbedPlug>}
|
|
777
|
+
*/
|
|
778
|
+
async setMapped(mapped) {
|
|
779
|
+
this._mapped = !!mapped;
|
|
780
|
+
await this._publish();
|
|
781
|
+
return this;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Ask for the logical focus (`XEMBED_REQUEST_FOCUS`) — what a client sends
|
|
786
|
+
* when the user clicks it. The embedder answers with `XEMBED_FOCUS_IN`, or
|
|
787
|
+
* does not.
|
|
788
|
+
*/
|
|
789
|
+
requestFocus() {
|
|
790
|
+
return this._send(XEMBED.REQUEST_FOCUS);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Hand the focus back to the embedder because we ran off the end of our own
|
|
795
|
+
* tab chain: it moves on to its next (or previous) widget.
|
|
796
|
+
*/
|
|
797
|
+
focusNext() {
|
|
798
|
+
return this._send(XEMBED.FOCUS_NEXT);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** @see focusNext */
|
|
802
|
+
focusPrev() {
|
|
803
|
+
return this._send(XEMBED.FOCUS_PREV);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** Send a raw `_XEMBED` message to the embedder. */
|
|
807
|
+
send(opcode, detail = 0, data1 = 0, data2 = 0, options = {}) {
|
|
808
|
+
return this._send(opcode, detail, data1, data2, options);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** Stop listening, and destroy the window if this created it. */
|
|
812
|
+
destroy() {
|
|
813
|
+
if (this._destroyed) return this;
|
|
814
|
+
this._destroyed = true;
|
|
815
|
+
this.window.removeListener('message', this._onMessage);
|
|
816
|
+
this.window.removeListener('reparent', this._onReparent);
|
|
817
|
+
if (this._ownsWindow) this.window.destroy();
|
|
818
|
+
return this;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// -------------------------------------------------------------------
|
|
822
|
+
|
|
823
|
+
async _publish() {
|
|
824
|
+
if (!this._xembedAtom) this._xembedAtom = await this.window.atom(NAME);
|
|
825
|
+
await this.window.setProperty(
|
|
826
|
+
INFO,
|
|
827
|
+
encodeXEmbedInfo({ version: this.version, mapped: this._mapped }),
|
|
828
|
+
// the property's type is its own name — that is what the spec says it
|
|
829
|
+
// is, not CARDINAL
|
|
830
|
+
{ type: INFO, format: 32 }
|
|
831
|
+
);
|
|
832
|
+
return this;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
_send(opcode, detail = 0, data1 = 0, data2 = 0, options = {}) {
|
|
836
|
+
if (!this.embedder) return this;
|
|
837
|
+
const { time = 0 } = options;
|
|
838
|
+
// A client's messages go to the embedder's window, and name it: in both
|
|
839
|
+
// directions the message is about the window it is delivered to, so mask
|
|
840
|
+
// 0 hands it to that window's owner whatever it selected.
|
|
841
|
+
if (this._embedderWindow?.id !== this.embedder) {
|
|
842
|
+
this._embedderWindow = this.app.createWindow({ id: this.embedder });
|
|
843
|
+
}
|
|
844
|
+
this._embedderWindow
|
|
845
|
+
.sendClientMessage(this._xembedAtom || NAME, [time, opcode, detail, data1, data2])
|
|
846
|
+
.catch((err) => this.app.options?.onXError?.(err));
|
|
847
|
+
return this;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
_onReparent(ev) {
|
|
851
|
+
const root = this.app.display.screen[0].root;
|
|
852
|
+
if (ev.parent === root) {
|
|
853
|
+
if (!this._embedded) return;
|
|
854
|
+
// spec step 5: back under the root ends the protocol
|
|
855
|
+
this._embedded = false;
|
|
856
|
+
this.embedder = 0;
|
|
857
|
+
this._embedderWindow = null;
|
|
858
|
+
this.xembed = false;
|
|
859
|
+
this.active = this.focused = this.modal = false;
|
|
860
|
+
this.emit('released');
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
this.embedder = ev.parent;
|
|
864
|
+
if (this._embedded) return;
|
|
865
|
+
this._embedded = true;
|
|
866
|
+
// An XEmbed-aware embedder sends XEMBED_EMBEDDED_NOTIFY straight after
|
|
867
|
+
// the reparent, so `xembed` and `version` are worth reading on the next
|
|
868
|
+
// turn rather than inside this handler; an embedder that only reparents
|
|
869
|
+
// (xterm -into, mpv --wid) sends nothing and leaves them as they are.
|
|
870
|
+
this.emit('embedded', this.embedder);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
_onMessage(ev) {
|
|
874
|
+
if (!this._xembedAtom || ev.message_type !== this._xembedAtom) return;
|
|
875
|
+
const [time, opcode, detail, data1, data2] = ev.data;
|
|
876
|
+
switch (opcode) {
|
|
877
|
+
case XEMBED.EMBEDDED_NOTIFY:
|
|
878
|
+
this.xembed = true;
|
|
879
|
+
// data1 is the embedder's window; older embedders leave it 0 and the
|
|
880
|
+
// ReparentNotify is then the only source
|
|
881
|
+
if (data1) this.embedder = data1 >>> 0;
|
|
882
|
+
this.version = Math.min(this.version, data2 >>> 0);
|
|
883
|
+
if (!this._embedded && this.embedder) {
|
|
884
|
+
this._embedded = true;
|
|
885
|
+
this.emit('embedded', this.embedder);
|
|
886
|
+
}
|
|
887
|
+
break;
|
|
888
|
+
case XEMBED.WINDOW_ACTIVATE:
|
|
889
|
+
this.active = true;
|
|
890
|
+
this.emit('activate');
|
|
891
|
+
break;
|
|
892
|
+
case XEMBED.WINDOW_DEACTIVATE:
|
|
893
|
+
this.active = false;
|
|
894
|
+
this.emit('deactivate');
|
|
895
|
+
break;
|
|
896
|
+
case XEMBED.FOCUS_IN:
|
|
897
|
+
this.focused = true;
|
|
898
|
+
this.emit('focusIn', detail);
|
|
899
|
+
break;
|
|
900
|
+
case XEMBED.FOCUS_OUT:
|
|
901
|
+
this.focused = false;
|
|
902
|
+
this.emit('focusOut');
|
|
903
|
+
break;
|
|
904
|
+
case XEMBED.MODALITY_ON:
|
|
905
|
+
case XEMBED.MODALITY_OFF:
|
|
906
|
+
this.modal = opcode === XEMBED.MODALITY_ON;
|
|
907
|
+
this.emit('modality', this.modal);
|
|
908
|
+
break;
|
|
909
|
+
default:
|
|
910
|
+
this.emit('message', {
|
|
911
|
+
opcode,
|
|
912
|
+
name: XEMBED_OPCODE_NAMES[opcode],
|
|
913
|
+
detail,
|
|
914
|
+
data1,
|
|
915
|
+
data2,
|
|
916
|
+
time
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
export default { XEMBED, XEmbedSocket, XEmbedPlug, FocusProxy };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ntk",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.4.0",
|
|
4
4
|
"description": "Desktop UI toolkit for X11 with canvas-like 2d and OpenGL rendering",
|
|
5
5
|
"author": "Andrey Sidorov <sidorares@yandex.ru>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"type": "module",
|
|
25
25
|
"main": "./lib/index.js",
|
|
26
26
|
"exports": {
|
|
27
|
-
".": "./lib/index.js"
|
|
27
|
+
".": "./lib/index.js",
|
|
28
|
+
"./xembed": "./lib/xembed.js"
|
|
28
29
|
},
|
|
29
30
|
"files": [
|
|
30
31
|
"lib"
|