ntk 7.3.3 → 7.5.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/app.js CHANGED
@@ -93,6 +93,8 @@ export default class App {
93
93
  this._solidPictures = new Map();
94
94
  this._rasterizer = undefined;
95
95
  this._shm = undefined;
96
+ this._xinputPromise = null;
97
+ this._devicesPromise = null;
96
98
  // node-x11 emits X errors it cannot route to a request callback as
97
99
  // 'error' on the client — from inside its packet parser. With no
98
100
  // listener that emit throws and the parser never re-arms, silently
@@ -299,6 +301,53 @@ export default class App {
299
301
  });
300
302
  }
301
303
 
304
+ /**
305
+ * The XInput extension, or `null` where the server has none — asked once
306
+ * per connection.
307
+ *
308
+ * `ext.xi2` is `null` on a server that answers the extension query but
309
+ * speaks XI1 only, and every XI2 request needs it, so callers check both.
310
+ *
311
+ * @returns {Promise<object|null>}
312
+ */
313
+ xinput() {
314
+ if (!this._xinputPromise) {
315
+ this._xinputPromise = new Promise((resolve) => {
316
+ this.X.require('xinput', (err, ext) => resolve(err ? null : ext));
317
+ });
318
+ }
319
+ return this._xinputPromise;
320
+ }
321
+
322
+ /**
323
+ * Every input device the server knows about, from XI2's `XIQueryDevice`:
324
+ * `{deviceId, use, attachment, enabled, name, classes}`, the classes
325
+ * describing each device's keys, buttons, valuators and scroll axes (see
326
+ * node-x11's docs/ext/xinput.md). `[]` where there is no XI2.
327
+ *
328
+ * Cached, because it is what `Window.selectXI2` consults to find the
329
+ * scroll axes behind a wheel delta and it must not cost a round trip per
330
+ * event. The cache is dropped whenever a window with XI2 selected sees an
331
+ * `XIDeviceChanged` — a master pointer's axes are those of whichever slave
332
+ * moved last, so they change under it when the user picks up the mouse
333
+ * after using the touchpad.
334
+ *
335
+ * @param {{refresh?: boolean}} [options]
336
+ * @returns {Promise<object[]>}
337
+ */
338
+ inputDevices({ refresh = false } = {}) {
339
+ if (refresh) this._devicesPromise = null;
340
+ if (!this._devicesPromise) {
341
+ this._devicesPromise = this.xinput().then((XI) => {
342
+ if (!XI || !XI.xi2) return [];
343
+ return new Promise((resolve) => {
344
+ XI.XIQueryDevice(XI.AllDevices, (err, devices) => resolve(err ? [] : devices));
345
+ });
346
+ });
347
+ }
348
+ return this._devicesPromise;
349
+ }
350
+
302
351
  /** selection/clipboard transfer: write()/read() text (docs/clipboard.md) */
303
352
  get clipboard() {
304
353
  if (!this._clipboard) this._clipboard = new Clipboard(this);
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/events_map.js CHANGED
@@ -67,6 +67,10 @@ export const mask = {
67
67
  // not an X event of its own: Window derives it from the PropertyNotify for
68
68
  // _NET_WM_STATE, so it needs the same mask a 'property' listener does
69
69
  statechange: x11.eventMask.PropertyChange,
70
+ // also derived: a wheel is a click of button 4-7 in the core protocol, and
71
+ // a scroll valuator under XI2 (see lib/xi2.js). ButtonPress covers the core
72
+ // half; the XI2 half is opt-in per window and selects itself
73
+ wheel: x11.eventMask.ButtonPress,
70
74
  selection: 0,
71
75
  selection_request: 0,
72
76
  message: 0
@@ -81,7 +85,12 @@ export const mask = {
81
85
  export const coalesce = {
82
86
  mousemove: 'last',
83
87
  resize: 'last',
84
- expose: 'union'
88
+ expose: 'union',
89
+ // 'accumulate' — scroll distance adds up. Keeping the last delta instead
90
+ // would throw away everything but the final step of a fast scroll, and a
91
+ // frame's worth of a touchpad's sub-notch deltas is exactly the case the
92
+ // smooth-scroll path exists for.
93
+ wheel: 'accumulate'
85
94
  };
86
95
 
87
96
  export const toSnake = {
@@ -90,6 +99,7 @@ export const toSnake = {
90
99
  onMouseOut: 'mouseout',
91
100
  onMouseDown: 'mousedown',
92
101
  onMouseUp: 'mouseup',
102
+ onWheel: 'wheel',
93
103
  onMap: 'map',
94
104
  onUnmap: 'unmap',
95
105
  onResize: 'resize',
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
@@ -7,6 +7,17 @@ import { packIcons, unpackIcons } from './imagedata.js';
7
7
  import Pixmap from './pixmap.js';
8
8
  import * as xevents from './events_map.js';
9
9
  import { decodeKey } from './keyboard.js';
10
+ import {
11
+ DEFAULT_XI2_EVENTS,
12
+ POINTER_EMULATED,
13
+ ScrollTracker,
14
+ WHEEL_BUTTONS,
15
+ normalizeXI2Types,
16
+ scrollAxes,
17
+ toNtkEvent,
18
+ wheelEvent,
19
+ xi2EventName
20
+ } from './xi2.js';
10
21
 
11
22
  /**
12
23
  * How many rectangles a frame's dirty region is allowed to hold.
@@ -435,10 +446,16 @@ export default class Window extends Drawable {
435
446
  this._updateRegion = 0;
436
447
  this._presentSerial = 0;
437
448
  this._presentEid = 0;
449
+ // Generic Events (type 35) carry an extension opcode where a core event
450
+ // carries its code, so they are routed by opcode: Present's completions,
451
+ // XI2's device events, and whatever else selects them later.
452
+ this._geHandlers = new Map();
438
453
  // A direct GL context's swap chain, when one is presenting on this window
439
- // (see _setGenericEventSink)
454
+ // — an override ahead of that table (see _setGenericEventSink)
440
455
  this._geSink = null;
441
456
  this._geSinkOpcode = 0;
457
+ // XI2 state, once a window has selected it (see selectXI2)
458
+ this._xi2 = null;
442
459
  // The vblank clock (see _onPresentComplete): the period learnt from
443
460
  // completion events, the samples it is drawn from, and the latch the
444
461
  // watchdog sets when completions stop arriving.
@@ -499,13 +516,15 @@ export default class Window extends Drawable {
499
516
  // window was asked to be, so the window manager's first ConfigureNotify
500
517
  // reports only what it actually overrode
501
518
  this._deliveredGeom = { x: this.x, y: this.y, width: this.width, height: this.height };
519
+ this.windowClass = args.windowClass ?? 0;
502
520
  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
521
  eventMask: this.eventMask
508
522
  };
523
+ // NorthWest bit gravity: keep the old content anchored during resize
524
+ // instead of discarding it (less flicker between the resize and our
525
+ // redraw). An InputOnly window (class 2) has no content to anchor and
526
+ // the attribute is not one it accepts — setting it is a BadMatch.
527
+ if (this.windowClass !== 2) values.bitGravity = 1;
509
528
  for (const name of forwardedXAttributes) {
510
529
  if (args[name] !== undefined) values[name] = args[name];
511
530
  }
@@ -514,7 +533,6 @@ export default class Window extends Drawable {
514
533
  // parent of a different depth is a BadMatch (X11 CreateWindow)
515
534
  this.visual = args.visual ?? 0;
516
535
  this.depth = args.depth ?? 0;
517
- this.windowClass = args.windowClass ?? 0;
518
536
  if (this.visual) {
519
537
  if (values.colormap === undefined) {
520
538
  this._ownedColormap = app.createColormap(this.visual);
@@ -591,6 +609,14 @@ export default class Window extends Drawable {
591
609
  if (args.alwaysOnTop) {
592
610
  this.setAlwaysOnTop(true);
593
611
  }
612
+ if (args.xi2) {
613
+ // fire and forget: until the extension answers, the window is on core
614
+ // events, which is where it would have been anyway. `await
615
+ // wnd.selectXI2()` is the form that says whether the server has XI2.
616
+ this.selectXI2(args.xi2 === true ? undefined : args.xi2).catch((err) =>
617
+ this.app.options.onXError?.(err)
618
+ );
619
+ }
594
620
  if (args.syncRequest && !args.id) {
595
621
  // fire and forget: the window manager only reads the counter when it
596
622
  // starts managing the window, and map() is the caller's next line at
@@ -612,19 +638,20 @@ export default class Window extends Drawable {
612
638
 
613
639
  X.event_consumers[this.id] = this;
614
640
  this.on('event', (ev) => {
615
- // Present speaks in X Generic Events, which carry an extension opcode
616
- // and a sub-type where a core event carries its code — the name table
617
- // below cannot see them, and they are not events user code asked for
641
+ // Present and XI2 speak in X Generic Events, which carry an extension
642
+ // opcode and a sub-type where a core event carries its code — the name
643
+ // table below cannot see them, so they are routed by opcode instead
618
644
  if (ev.type === 35) {
619
645
  // A direct GL context presenting on this window selected these events
620
646
  // itself and owns the pixmaps they name, so they are its to read — and
621
647
  // it is the only user of Present there, a GL window having no backing
622
- // store to blit.
648
+ // store to blit. Hence an override rather than another table entry:
649
+ // it claims the same extension the window's own blit path uses.
623
650
  if (this._geSink && ev.extension === this._geSinkOpcode) {
624
651
  this._geSink.handleEvent(ev);
625
- } else if (this._presentExt && ev.extension === this._presentExt.majorOpcode) {
626
- this._handlePresentEvent(ev);
652
+ return;
627
653
  }
654
+ this._geHandlers.get(ev.extension)?.(ev);
628
655
  return;
629
656
  }
630
657
  const ntkev = ev; // todo: clone
@@ -658,9 +685,12 @@ export default class Window extends Drawable {
658
685
  if (key.codepoint !== undefined) ev.codepoint = key.codepoint;
659
686
  }
660
687
  }
661
- if (this._coalesce && xevents.coalesce[eventName]) {
662
- this._enqueueCoalesced(eventName, ntkev);
663
- return;
688
+ // a wheel is a button in the core protocol; say so in the units a
689
+ // consumer wants. Suppressed once XI2 is delivering the same scroll as
690
+ // valuators, which is the whole reason to select it (see selectXI2).
691
+ if (eventName === 'mousedown' && WHEEL_BUTTONS[ev.keycode] && !this._xi2?.smooth) {
692
+ const [deltaX, deltaY] = WHEEL_BUTTONS[ev.keycode];
693
+ this._deliverEvent('wheel', wheelEvent({ ...ntkev }, deltaX, deltaY, 'button'));
664
694
  }
665
695
  // the window manager reporting what it did with the window: a user who
666
696
  // hit maximize or a fullscreen hotkey changes the state behind the
@@ -672,19 +702,7 @@ export default class Window extends Drawable {
672
702
  () => {}
673
703
  );
674
704
  }
675
- // deliver buffered state events before a discrete one so handlers see
676
- // them in the order they happened (a drag sees the move, then the up)
677
- this._flushCoalesced();
678
- if (eventName === 'resize') this._tagResize(ntkev);
679
- this.emit(eventName, ntkev);
680
- // a WM_DELETE_WINDOW ClientMessage is the window manager *asking*, and
681
- // 'close' is that question in a form an application can answer
682
- if (eventName === 'message') {
683
- this._emitCloseRequest(ntkev);
684
- this._handleSyncRequest(ntkev);
685
- }
686
- // anything drawn during the handlers becomes visible in one blit
687
- if (this._dirty) this._present();
705
+ this._deliverEvent(eventName, ntkev);
688
706
  });
689
707
 
690
708
  // Events about a *child* of this window: the substructure requests a
@@ -1168,6 +1186,32 @@ export default class Window extends Drawable {
1168
1186
  this._armTimer();
1169
1187
  }
1170
1188
 
1189
+ /**
1190
+ * Deliver one named event: buffer it if its kind coalesces, and otherwise
1191
+ * flush what is buffered before emitting, so handlers see events in the
1192
+ * order they happened (a drag sees the move, then the up).
1193
+ *
1194
+ * Every source ends here — the core event stream, the XI2 one, and the
1195
+ * `wheel` both of them derive.
1196
+ */
1197
+ _deliverEvent(name, ev) {
1198
+ if (this._coalesce && xevents.coalesce[name]) {
1199
+ this._enqueueCoalesced(name, ev);
1200
+ return;
1201
+ }
1202
+ this._flushCoalesced();
1203
+ if (name === 'resize') this._tagResize(ev);
1204
+ this.emit(name, ev);
1205
+ // a WM_DELETE_WINDOW ClientMessage is the window manager *asking*, and
1206
+ // 'close' is that question in a form an application can answer
1207
+ if (name === 'message') {
1208
+ this._emitCloseRequest(ev);
1209
+ this._handleSyncRequest(ev);
1210
+ }
1211
+ // anything drawn during the handlers becomes visible in one blit
1212
+ if (this._dirty) this._present();
1213
+ }
1214
+
1171
1215
  _enqueueCoalesced(name, ev) {
1172
1216
  const pending = this._frame.pending;
1173
1217
  const prev = pending.get(name);
@@ -1177,6 +1221,21 @@ export default class Window extends Drawable {
1177
1221
  ev.rects = [{ x: ev.x, y: ev.y, width: ev.width, height: ev.height }];
1178
1222
  }
1179
1223
  pending.set(name, ev);
1224
+ } else if (xevents.coalesce[name] === 'accumulate') {
1225
+ // scroll distance adds up, and the frame's scroll happened wherever the
1226
+ // pointer ended up. The first event of the frame carries the sum, the
1227
+ // way the union path grows its first event's rectangle.
1228
+ prev.coalesced.push(ev);
1229
+ prev.deltaX += ev.deltaX;
1230
+ prev.deltaY += ev.deltaY;
1231
+ prev.x = ev.x;
1232
+ prev.y = ev.y;
1233
+ prev.rootx = ev.rootx;
1234
+ prev.rooty = ev.rooty;
1235
+ prev.time = ev.time;
1236
+ prev.buttons = ev.buttons;
1237
+ prev.smooth = ev.smooth;
1238
+ prev.source = ev.source;
1180
1239
  } else if (xevents.coalesce[name] === 'union') {
1181
1240
  prev.coalesced.push(ev);
1182
1241
  prev.rects.push({ x: ev.x, y: ev.y, width: ev.width, height: ev.height });
@@ -1284,6 +1343,163 @@ export default class Window extends Drawable {
1284
1343
  this._geSinkOpcode = sink ? majorOpcode : 0;
1285
1344
  }
1286
1345
 
1346
+ /**
1347
+ * Ask for XI2 device events on this window, and deliver them as the events
1348
+ * ntk already has names for.
1349
+ *
1350
+ * What this buys is the wheel: the core protocol reports a scroll as a
1351
+ * click of button 4/5/6/7, so a touchpad's two-finger gesture arrives as
1352
+ * whole notches however finely the hardware measured it. XI2 carries the
1353
+ * same gesture as a scroll *valuator*, and `wheel` then reports fractions
1354
+ * of a notch (see docs/window.md "Wheel and smooth scrolling"). Touch and
1355
+ * per-device input come with it — `ev.deviceId`/`ev.sourceId` are on every
1356
+ * translated event, and the core protocol has no way to say either.
1357
+ *
1358
+ * Opt-in per window, via `createWindow({ xi2: true })` or this call,
1359
+ * because selecting XI2 changes what the server sends: a window that has
1360
+ * not asked keeps costing exactly what it did before.
1361
+ *
1362
+ * **Selecting XI2 replaces the core events of the same types.** The server
1363
+ * delivers an event to a client once, and an XI2 selection wins, so
1364
+ * `selectXI2(['Motion'])` means this window's `mousemove` is built from
1365
+ * `XIMotion` from then on. It carries the same fields (plus `ev.xi2`,
1366
+ * the device ids and sub-pixel `preciseX`/`preciseY`), so handlers do not
1367
+ * change — but a window that selects XI2 KeyPress and nothing else still
1368
+ * gets core motion, and mixing the two per-type is what the `types`
1369
+ * argument is for.
1370
+ *
1371
+ * The emulated wheel buttons are dropped once the valuators are flowing:
1372
+ * the server sends both halves of a smooth scroll (the axis and a button
1373
+ * 4-7 click flagged `PointerEmulated`) so that clients which cannot read
1374
+ * valuators still scroll, and delivering both would count every notch
1375
+ * twice. So an app that opts in reads `wheel`, not `mousedown` on button 4.
1376
+ *
1377
+ * @param {string[]|string} [types] XI2 event type names —
1378
+ * `Motion`, `ButtonPress`, `ButtonRelease`, `KeyPress`, `KeyRelease`,
1379
+ * `TouchBegin`, `TouchUpdate`, `TouchEnd`. An empty array deselects.
1380
+ * @param {{deviceId?: number}} [options] which device to select for,
1381
+ * defaulting to every master device (the virtual pointer and keyboard
1382
+ * the user is actually driving). A selection is per device, so a
1383
+ * selection on one device id does not replace one made on another.
1384
+ * @returns {Promise<boolean>} false where the server has no XI2 — in which
1385
+ * case nothing changed and the window keeps its core events, `wheel`
1386
+ * included, at notch granularity
1387
+ */
1388
+ selectXI2(types = DEFAULT_XI2_EVENTS, { deviceId } = {}) {
1389
+ const names = normalizeXI2Types(types);
1390
+ return Promise.all([this.app.xinput(), this.app.inputDevices()]).then(([XI, devices]) => {
1391
+ if (!XI || !XI.xi2 || this._destroyed) return false;
1392
+ const target = deviceId ?? XI.AllMasterDevices;
1393
+ if (!names.length) {
1394
+ safeRelease(this.X, () => XI.XISelectEvents(this.id, { deviceId: target, mask: 0 }));
1395
+ this._geHandlers.delete(XI.majorOpcode);
1396
+ this._xi2 = null;
1397
+ return true;
1398
+ }
1399
+ // DeviceChanged rides along uninvited: it is the only announcement that
1400
+ // a master pointer's valuators are now some other slave's, which
1401
+ // invalidates both the axis map and the accumulators built on it. Its
1402
+ // body is undecoded, and the device id is all this needs from it.
1403
+ safeRelease(this.X, () =>
1404
+ XI.XISelectEvents(this.id, { deviceId: target, mask: [...names, 'DeviceChanged'] })
1405
+ );
1406
+ this._xi2 = {
1407
+ ext: XI,
1408
+ deviceId: target,
1409
+ types: names,
1410
+ // evtype -> the ntk event it becomes, resolved through the extension's
1411
+ // own table rather than hardcoded numbers
1412
+ byType: new Map(names.map((name) => [XI.EventType[name], xi2EventName[name]])),
1413
+ scroll: this._xi2?.scroll ?? new ScrollTracker(),
1414
+ axes: new Map(devices.map((device) => [device.deviceId, scrollAxes(device)])),
1415
+ // whether the core wheel buttons are now redundant: a scroll-capable
1416
+ // device plus a selection that carries its valuators
1417
+ smooth: names.includes('Motion') && devices.some((device) => scrollAxes(device).size > 0)
1418
+ };
1419
+ this._geHandlers.set(XI.majorOpcode, (ev) => this._handleXI2Event(ev));
1420
+ return true;
1421
+ });
1422
+ }
1423
+
1424
+ /**
1425
+ * One XI2 Generic Event: scroll valuators become `wheel`, everything else
1426
+ * becomes the core-shaped event it stands in for.
1427
+ */
1428
+ _handleXI2Event(ev) {
1429
+ const xi2 = this._xi2;
1430
+ if (!xi2) return;
1431
+ if (ev.evtype === xi2.ext.EventType.DeviceChanged) {
1432
+ this._refreshXI2Devices(ev.deviceId);
1433
+ return;
1434
+ }
1435
+ const name = xi2.byType.get(ev.evtype);
1436
+ if (!name) return;
1437
+
1438
+ const base = toNtkEvent(name, ev);
1439
+ base.window = this;
1440
+ base.target = this;
1441
+
1442
+ if (name === 'mousemove') {
1443
+ // a scroll is a Motion carrying scroll axes; the pointer itself did not
1444
+ // move, so it is a wheel event and not also a mouse move — including
1445
+ // the first one, which has nothing to subtract from yet
1446
+ const scroll = xi2.scroll.delta(ev, this._xi2Axes(ev));
1447
+ if (scroll) {
1448
+ if (scroll.moved) {
1449
+ this._deliverEvent('wheel', wheelEvent(base, scroll.deltaX, scroll.deltaY, 'valuator'));
1450
+ }
1451
+ return;
1452
+ }
1453
+ }
1454
+ if (name === 'mousedown' || name === 'mouseup') {
1455
+ const wheelButton = WHEEL_BUTTONS[ev.detail];
1456
+ // the click the server emulated for clients that cannot read valuators
1457
+ // — we can, and the valuator half of the same scroll is already on its
1458
+ // way, so this one is noise
1459
+ if (wheelButton && ev.flags & POINTER_EMULATED) return;
1460
+ if (wheelButton && name === 'mousedown') {
1461
+ const wheel = wheelEvent({ ...base }, wheelButton[0], wheelButton[1], 'button');
1462
+ this._deliverEvent('wheel', wheel);
1463
+ }
1464
+ }
1465
+ if (name === 'keydown' || name === 'keyup') {
1466
+ const key = decodeKey(this.X.keycode2keysyms[ev.detail], base.buttons);
1467
+ if (key) {
1468
+ base.keysym = key.keysym;
1469
+ base.baseKeysym = key.baseKeysym;
1470
+ base.group = key.group;
1471
+ if (key.codepoint !== undefined) base.codepoint = key.codepoint;
1472
+ }
1473
+ }
1474
+ this._deliverEvent(name, base);
1475
+ }
1476
+
1477
+ /** This window's scroll-axis map for the device an event came from. */
1478
+ _xi2Axes(ev) {
1479
+ return this._xi2?.axes.get(ev.deviceId);
1480
+ }
1481
+
1482
+ /**
1483
+ * A master device's slave changed: re-read what its axes are now, and drop
1484
+ * the accumulators, because the values arriving next belong to a different
1485
+ * device and subtracting across the switch is the jump seeding exists to
1486
+ * avoid.
1487
+ */
1488
+ _refreshXI2Devices(deviceId) {
1489
+ const xi2 = this._xi2;
1490
+ if (!xi2) return;
1491
+ xi2.scroll.reset(deviceId);
1492
+ this.app.inputDevices({ refresh: true }).then(
1493
+ (devices) => {
1494
+ if (this._xi2 !== xi2) return; // deselected or reselected meanwhile
1495
+ xi2.axes = new Map(devices.map((device) => [device.deviceId, scrollAxes(device)]));
1496
+ xi2.smooth =
1497
+ xi2.types.includes('Motion') && devices.some((device) => scrollAxes(device).size > 0);
1498
+ },
1499
+ () => {}
1500
+ );
1501
+ }
1502
+
1287
1503
  /**
1288
1504
  * Present's events, which are the ones the core event table cannot name:
1289
1505
  * they arrive as X Generic Events (type 35), carrying an extension opcode
@@ -1702,6 +1918,7 @@ export default class Window extends Drawable {
1702
1918
  safeRelease(X, () => fixes.CreateRegion(this._updateRegion, []));
1703
1919
  this._presentExt = present;
1704
1920
  this._fixesExt = fixes;
1921
+ this._geHandlers.set(present.majorOpcode, (ev) => this._handlePresentEvent(ev));
1705
1922
  this._selectPresentInput();
1706
1923
  return this;
1707
1924
  });
@@ -2612,16 +2829,21 @@ export default class Window extends Drawable {
2612
2829
 
2613
2830
  if (mapped) {
2614
2831
  const root = this.app.display.screen[0].root;
2615
- const messageType = await this.atom('_NET_WM_STATE');
2616
- if (this._destroyed) return false;
2617
- safeRelease(this.X, () => {
2618
- this.X.SendClientMessage(root, this.id, messageType, 32, [
2832
+ // EWMH 7.7: delivered to the root with the substructure mask, so the
2833
+ // window manager watching there sees it, but about this window
2834
+ await this.sendClientMessage(
2835
+ '_NET_WM_STATE',
2836
+ [
2619
2837
  mode,
2620
2838
  ids[0],
2621
2839
  ids[1] ?? 0,
2622
2840
  1 // source indication: a normal application, not a pager
2623
- ]);
2624
- });
2841
+ ],
2842
+ {
2843
+ target: root,
2844
+ mask: x11.eventMask.SubstructureRedirect | x11.eventMask.SubstructureNotify
2845
+ }
2846
+ );
2625
2847
  } else {
2626
2848
  await this._writeWmState(ids, mode);
2627
2849
  }
@@ -3168,6 +3390,58 @@ export default class Window extends Drawable {
3168
3390
  return this;
3169
3391
  }
3170
3392
 
3393
+ /**
3394
+ * Send a ClientMessage about this window (X SendEvent with a
3395
+ * ClientMessage payload).
3396
+ *
3397
+ * ClientMessage is the carrier of every convention layered over the core
3398
+ * protocol — EWMH state changes, WM_PROTOCOLS, XEmbed, XDND, the system
3399
+ * tray — and they differ only in the atom naming the message and the five
3400
+ * 32-bit words in it. `type` is an atom name (or an already-interned id),
3401
+ * `data` the words; anything past the end of the list is sent as zero.
3402
+ *
3403
+ * await wnd.sendClientMessage('WM_PROTOCOLS', [deleteAtom, time]);
3404
+ *
3405
+ * Two options carry the meaning that the raw request buries in argument
3406
+ * order:
3407
+ *
3408
+ * - `target` — where the message is *delivered*, which is not always the
3409
+ * window it is *about*. EWMH messages are about a client window and
3410
+ * delivered to the root, so the window manager (which selected
3411
+ * SubstructureRedirect there) sees them.
3412
+ * - `mask` — who on the target gets it. `0`, the default, delivers to the
3413
+ * client that owns the target window whatever it selected, which is
3414
+ * what a message addressed to another client needs. Root-window EWMH
3415
+ * messages instead pass
3416
+ * `SubstructureRedirect | SubstructureNotify`.
3417
+ *
3418
+ * @param {string|number} type atom name of the message type, or its id
3419
+ * @param {number[]} [data] up to five 32-bit words (ten at format 16,
3420
+ * twenty at format 8)
3421
+ * @param {object} [options] `{ target = this.id, format = 32, mask = 0 }`
3422
+ * @returns {Promise<Window>}
3423
+ */
3424
+ async sendClientMessage(type, data = [], options = {}) {
3425
+ const { target = this.id, format = 32, mask = 0 } = options;
3426
+ const slots = { 8: 20, 16: 10, 32: 5 }[format];
3427
+ if (!slots) {
3428
+ throw new TypeError(`sendClientMessage: format must be 8, 16 or 32, got ${format}`);
3429
+ }
3430
+ if (data.length > slots) {
3431
+ // the wire event is 32 bytes and no more; sending the first `slots`
3432
+ // words of a longer list would drop the rest without a sound
3433
+ throw new RangeError(
3434
+ `sendClientMessage: a format-${format} ClientMessage carries ${slots} values, got ${data.length}`
3435
+ );
3436
+ }
3437
+ const messageType = typeof type === 'number' ? type : await this.atom(type);
3438
+ if (this._destroyed) return this;
3439
+ safeRelease(this.X, () => {
3440
+ this.X.SendClientMessage(target, this.id, messageType, format, data, mask);
3441
+ });
3442
+ return this;
3443
+ }
3444
+
3171
3445
  /**
3172
3446
  * Ask the client to close, the polite way: a WM_DELETE_WINDOW client
3173
3447
  * message if it advertised the protocol in WM_PROTOCOLS, so it can save
@@ -3187,13 +3461,11 @@ export default class Window extends Drawable {
3187
3461
  safeRelease(this.X, () => this.X.KillClient(this.id));
3188
3462
  return false;
3189
3463
  }
3190
- const wmProtocols = await this.atom('WM_PROTOCOLS');
3191
- // mask 0: deliver to the client that created the window, not to whoever
3192
- // selected events on it — a WM_PROTOCOLS message is addressed to the
3193
- // owner, so it must not go out with the EWMH substructure default
3194
- safeRelease(this.X, () =>
3195
- this.X.SendClientMessage(this.id, this.id, wmProtocols, 32, [deleteAtom, 0 /* CurrentTime */], 0)
3196
- );
3464
+ // the default mask of 0 delivers to the client that created the window,
3465
+ // not to whoever selected events on it — a WM_PROTOCOLS message is
3466
+ // addressed to the owner, so it must not go out with the EWMH
3467
+ // substructure mask
3468
+ await this.sendClientMessage('WM_PROTOCOLS', [deleteAtom, 0 /* CurrentTime */]);
3197
3469
  return true;
3198
3470
  }
3199
3471