ntk 7.4.0 → 7.6.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 +49 -0
- package/lib/events_map.js +11 -1
- package/lib/renderingcontext_2d.js +97 -2
- package/lib/surface.js +62 -0
- package/lib/text/font.js +21 -0
- package/lib/window.js +239 -23
- package/lib/xi2.js +290 -0
- package/package.json +1 -1
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/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',
|
|
@@ -1396,14 +1396,35 @@ class RenderingContext2d {
|
|
|
1396
1396
|
}
|
|
1397
1397
|
|
|
1398
1398
|
/**
|
|
1399
|
-
* Composite
|
|
1399
|
+
* Composite glyph runs onto this context, honouring the clip. This is
|
|
1400
|
+
* the primitive `fillText` and `TextLayout.draw` are built on, and it is
|
|
1401
|
+
* public: the run shape below is a documented contract
|
|
1402
|
+
* (docs/text.md#glyph-runs), so renderers that position glyphs
|
|
1403
|
+
* themselves — a terminal grid, a tabular column — can hand-build runs
|
|
1404
|
+
* instead of shaping.
|
|
1405
|
+
*
|
|
1406
|
+
* @param {number} op Render.PictOp (`ctx.Render.PictOp.Over` for normal text)
|
|
1407
|
+
* @param {Picture} src source picture the glyphs paint with — a solid
|
|
1408
|
+
* (`ctx.createSolidPicture(r, g, b, a)`, premultiplied 0..1) or a gradient
|
|
1409
|
+
* @param {Array<{run, x, y, textRendering?}>} positioned runs in visual
|
|
1410
|
+
* order; `x`/`y` is the run's baseline origin in device space. `run` is
|
|
1411
|
+
* `{ font, size, glyphs }` — a `Font`, a pixel size, and glyphs
|
|
1412
|
+
* `{ id, ax, dx, dy }` in drawing order: `id` a font glyph id
|
|
1413
|
+
* (`Font.shape()`'s `glyphs[].id`, or `Font.glyphIdFor(cp)`), `ax` the
|
|
1414
|
+
* pen advance in px, `dx`/`dy` the drawing offset from the pen position
|
|
1415
|
+
* (y-up: positive `dy` raises the glyph). The pen starts at `x`; each
|
|
1416
|
+
* glyph inks at `(pen + dx, y - dy)` and then advances it by `ax`.
|
|
1417
|
+
* `Font.shape()` returns runs of exactly this shape; extra fields
|
|
1418
|
+
* (`codePoints`, `width`, …) are ignored. `textRendering` optionally
|
|
1419
|
+
* overrides the bitmap/vector routing per run (docs/text.md).
|
|
1400
1420
|
*
|
|
1401
1421
|
* CompositeGlyphs writes straight to the destination picture, so it has
|
|
1402
1422
|
* no way to consult our clip mask — text drawn through it used to spill
|
|
1403
1423
|
* out of clipped boxes while every fill and stroke stayed inside. With a
|
|
1404
1424
|
* clip active, render the glyph coverage into the scratch a8 mask
|
|
1405
1425
|
* instead, intersect that with the clip, and paint the real source
|
|
1406
|
-
* through the result — the same shape as _fillPolys.
|
|
1426
|
+
* through the result — the same shape as _fillPolys. A rectangular clip
|
|
1427
|
+
* takes the server-side fast path below instead of the mask.
|
|
1407
1428
|
*/
|
|
1408
1429
|
drawGlyphs(op, src, positioned) {
|
|
1409
1430
|
const app = this.window.app;
|
|
@@ -1652,6 +1673,80 @@ class RenderingContext2d {
|
|
|
1652
1673
|
this._fillPolys(flattenPath(tmp._cmds, this._m), "nonzero");
|
|
1653
1674
|
}
|
|
1654
1675
|
|
|
1676
|
+
/**
|
|
1677
|
+
* Fill a batch of axis-aligned rectangles: `fillRect` once per rectangle
|
|
1678
|
+
* semantically — fillStyle, globalAlpha, the composite op, the clip and
|
|
1679
|
+
* damage reporting all apply — but priced for the "many small rectangles
|
|
1680
|
+
* per frame" caller (issue #253): terminal cell backgrounds, sparkline
|
|
1681
|
+
* bars, heat maps, row striping.
|
|
1682
|
+
*
|
|
1683
|
+
* `rects` is an array of `[x, y, w, h]` quadruples or one flat
|
|
1684
|
+
* `[x0, y0, w0, h0, x1, ...]` array; rectangles with non-positive width
|
|
1685
|
+
* or height are skipped.
|
|
1686
|
+
*
|
|
1687
|
+
* A solid-colour fillStyle under an identity transform and a rectangular
|
|
1688
|
+
* (or absent) clip compiles the whole list into a single
|
|
1689
|
+
* `Render.FillRectangles`, where N `fillRect` calls cost N composites.
|
|
1690
|
+
* Gradient/Picture styles, transforms and non-rectangular clips fall
|
|
1691
|
+
* back to that `fillRect` loop, so the answer is always right and only
|
|
1692
|
+
* the request count varies.
|
|
1693
|
+
*/
|
|
1694
|
+
fillRects(rects) {
|
|
1695
|
+
if (!rects || !rects.length || this.globalAlpha <= 0) return;
|
|
1696
|
+
// normalize to one flat list, dropping empty rectangles up front — the
|
|
1697
|
+
// wire encodes width and height unsigned, so a negative would wrap
|
|
1698
|
+
const flat = [];
|
|
1699
|
+
if (Array.isArray(rects[0])) {
|
|
1700
|
+
for (const r of rects) {
|
|
1701
|
+
if (r[2] > 0 && r[3] > 0) flat.push(r[0], r[1], r[2], r[3]);
|
|
1702
|
+
}
|
|
1703
|
+
} else {
|
|
1704
|
+
for (let i = 0; i + 3 < rects.length; i += 4) {
|
|
1705
|
+
if (rects[i + 2] > 0 && rects[i + 3] > 0)
|
|
1706
|
+
flat.push(rects[i], rects[i + 1], rects[i + 2], rects[i + 3]);
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
if (!flat.length) return;
|
|
1710
|
+
|
|
1711
|
+
const clip = this._clips.length ? this._clipRect() : null;
|
|
1712
|
+
if (
|
|
1713
|
+
!matIsIdentity(this._m) ||
|
|
1714
|
+
!isPlainColor(this._fillStyle) ||
|
|
1715
|
+
(this._clips.length && !clip)
|
|
1716
|
+
) {
|
|
1717
|
+
for (let i = 0; i < flat.length; i += 4)
|
|
1718
|
+
this.fillRect(flat[i], flat[i + 1], flat[i + 2], flat[i + 3]);
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
if (clip && (clip.w === 0 || clip.h === 0)) return; // clipped away
|
|
1722
|
+
const R = this.Render;
|
|
1723
|
+
if (clip) {
|
|
1724
|
+
R.SetPictureClipRectangles(this.picture.id, 0, 0, [
|
|
1725
|
+
clip.x,
|
|
1726
|
+
clip.y,
|
|
1727
|
+
clip.w,
|
|
1728
|
+
clip.h,
|
|
1729
|
+
]);
|
|
1730
|
+
}
|
|
1731
|
+
// globalAlpha folds into the premultiplied colour — scaling all four
|
|
1732
|
+
// components is exactly what compositing at that opacity means, for
|
|
1733
|
+
// any composite op (the mask slot would only scale the source the same
|
|
1734
|
+
// way)
|
|
1735
|
+
const color = this._foldedColor(this._fillStyle);
|
|
1736
|
+
// stay under the server's maximum request size
|
|
1737
|
+
const chunk = 10000 * 4;
|
|
1738
|
+
for (let i = 0; i < flat.length; i += chunk) {
|
|
1739
|
+
R.FillRectangles(
|
|
1740
|
+
this._op(),
|
|
1741
|
+
this.picture.id,
|
|
1742
|
+
color,
|
|
1743
|
+
i === 0 && flat.length <= chunk ? flat : flat.slice(i, i + chunk),
|
|
1744
|
+
);
|
|
1745
|
+
}
|
|
1746
|
+
if (clip) this._resetPictureClip();
|
|
1747
|
+
this._markDirty();
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1655
1750
|
strokeRect(x, y, w, h) {
|
|
1656
1751
|
const m = this._m;
|
|
1657
1752
|
if (m[0] === 1 && m[1] === 0 && m[2] === 0 && m[3] === 1) {
|
package/lib/surface.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import Picture from './picture.js';
|
|
2
2
|
import Pixmap from './pixmap.js';
|
|
3
|
+
import { safeRelease } from './cleanup.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* An offscreen drawing surface: a pixmap, its Picture, and enough of the
|
|
@@ -103,6 +104,67 @@ export class Surface {
|
|
|
103
104
|
return this.pixmap.getContext(name, ...args);
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Scroll the pixels of `src` (surface coordinates, `{x, y, width, height}`)
|
|
109
|
+
* by (dx, dy) in place, server-side: one CopyArea of the band that
|
|
110
|
+
* survives the shift, in place of the caller re-drawing everything that
|
|
111
|
+
* merely moved. Returns true when the copy was issued; false means
|
|
112
|
+
* "nothing survives the shift here", and the caller repaints `src` exactly
|
|
113
|
+
* as it would have without this method — every refusal is the status quo.
|
|
114
|
+
*
|
|
115
|
+
* Refused when the delta is fractional (a sub-pixel shift changes every
|
|
116
|
+
* pixel, so there is nothing to copy), when it is zero, when nothing of
|
|
117
|
+
* `src` survives the shift after clamping to the surface, or on a
|
|
118
|
+
* destroyed surface.
|
|
119
|
+
*
|
|
120
|
+
* This is `Window#scrollRegion` for a retained offscreen surface, minus
|
|
121
|
+
* the damage bookkeeping a pixmap does not have: the caller draws the
|
|
122
|
+
* exposed strip and composites as usual. The overlapping self-copy is
|
|
123
|
+
* fully defined — pixmap contents cannot be occluded, and the server
|
|
124
|
+
* fetches the source region before storing. The copy goes out with a
|
|
125
|
+
* shared graphicsExposures: 0 GC — one per app and depth, created on
|
|
126
|
+
* first use and released with the connection, where a default GC would
|
|
127
|
+
* emit a NoExposure event per copy — and in-order with the caller's
|
|
128
|
+
* follow-up drawing of the exposed strip on the same connection.
|
|
129
|
+
*/
|
|
130
|
+
copyWithin(src, dx, dy) {
|
|
131
|
+
if (this._destroyed) return false;
|
|
132
|
+
if (!Number.isInteger(dx) || !Number.isInteger(dy) || (dx === 0 && dy === 0)) return false;
|
|
133
|
+
const x0 = Math.max(0, Math.floor(src.x));
|
|
134
|
+
const y0 = Math.max(0, Math.floor(src.y));
|
|
135
|
+
const x1 = Math.min(this.width, Math.ceil(src.x + src.width));
|
|
136
|
+
const y1 = Math.min(this.height, Math.ceil(src.y + src.height));
|
|
137
|
+
// the band that survives: dest = clamped src ∩ (clamped src + delta)
|
|
138
|
+
const dstX0 = Math.max(x0, x0 + dx);
|
|
139
|
+
const dstY0 = Math.max(y0, y0 + dy);
|
|
140
|
+
const dstX1 = Math.min(x1, x1 + dx);
|
|
141
|
+
const dstY1 = Math.min(y1, y1 + dy);
|
|
142
|
+
if (dstX1 <= dstX0 || dstY1 <= dstY0) return false;
|
|
143
|
+
const X = this.app.X;
|
|
144
|
+
// a GC is valid for any drawable of its depth on the screen, so one per
|
|
145
|
+
// app and depth serves every surface — the upload-GC pattern in image.js
|
|
146
|
+
const gcs = (this.app._surfaceCopyGC ??= {});
|
|
147
|
+
safeRelease(X, () => {
|
|
148
|
+
let gc = gcs[this.depth];
|
|
149
|
+
if (!gc) {
|
|
150
|
+
gc = gcs[this.depth] = X.AllocID();
|
|
151
|
+
X.CreateGC(gc, this.pixmap.id, { graphicsExposures: 0 });
|
|
152
|
+
}
|
|
153
|
+
X.CopyArea(
|
|
154
|
+
this.pixmap.id,
|
|
155
|
+
this.pixmap.id,
|
|
156
|
+
gc,
|
|
157
|
+
dstX0 - dx,
|
|
158
|
+
dstY0 - dy,
|
|
159
|
+
dstX0,
|
|
160
|
+
dstY0,
|
|
161
|
+
dstX1 - dstX0,
|
|
162
|
+
dstY1 - dstY0
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
106
168
|
destroy() {
|
|
107
169
|
if (this._destroyed) return;
|
|
108
170
|
this._destroyed = true;
|
package/lib/text/font.js
CHANGED
|
@@ -240,6 +240,27 @@ export default class Font {
|
|
|
240
240
|
return this.fk.hasGlyphForCodePoint(codepoint);
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Glyph id for a codepoint, or `null` when this face does not cover it —
|
|
245
|
+
* the lookup twin of `hasGlyph`, for callers that position glyphs
|
|
246
|
+
* themselves (a terminal grid, a tabular column) and hand runs straight
|
|
247
|
+
* to `ctx.drawGlyphs` without shaping. See docs/text.md#glyph-runs.
|
|
248
|
+
*
|
|
249
|
+
* `null` rather than 0: fontkit resolves an uncovered codepoint to the
|
|
250
|
+
* `.notdef` glyph, and a caller building runs wants "not covered" as a
|
|
251
|
+
* branch (pick a fallback face) rather than as a tofu box discovered on
|
|
252
|
+
* screen. This is a cmap lookup only — no shaping, so no ligatures,
|
|
253
|
+
* kerning or contextual forms; text that needs those goes through
|
|
254
|
+
* `shape()`.
|
|
255
|
+
*
|
|
256
|
+
* @param {number} codepoint Unicode codepoint (`str.codePointAt(i)`)
|
|
257
|
+
* @returns {number|null} font glyph id, as `shape()` would report in `glyphs[].id`
|
|
258
|
+
*/
|
|
259
|
+
glyphIdFor(codepoint) {
|
|
260
|
+
if (!this.fk.hasGlyphForCodePoint(codepoint)) return null;
|
|
261
|
+
return this.fk.glyphForCodePoint(codepoint).id;
|
|
262
|
+
}
|
|
263
|
+
|
|
243
264
|
/**
|
|
244
265
|
* Shape a run of text: returns glyphs with pixel-space positioning.
|
|
245
266
|
* RTL runs come back in visual (left-to-right drawing) order.
|
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.
|
|
@@ -592,6 +609,14 @@ export default class Window extends Drawable {
|
|
|
592
609
|
if (args.alwaysOnTop) {
|
|
593
610
|
this.setAlwaysOnTop(true);
|
|
594
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
|
+
}
|
|
595
620
|
if (args.syncRequest && !args.id) {
|
|
596
621
|
// fire and forget: the window manager only reads the counter when it
|
|
597
622
|
// starts managing the window, and map() is the caller's next line at
|
|
@@ -613,19 +638,20 @@ export default class Window extends Drawable {
|
|
|
613
638
|
|
|
614
639
|
X.event_consumers[this.id] = this;
|
|
615
640
|
this.on('event', (ev) => {
|
|
616
|
-
// Present
|
|
617
|
-
// and a sub-type where a core event carries its code — the name
|
|
618
|
-
// below cannot see them,
|
|
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
|
|
619
644
|
if (ev.type === 35) {
|
|
620
645
|
// A direct GL context presenting on this window selected these events
|
|
621
646
|
// itself and owns the pixmaps they name, so they are its to read — and
|
|
622
647
|
// it is the only user of Present there, a GL window having no backing
|
|
623
|
-
// 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.
|
|
624
650
|
if (this._geSink && ev.extension === this._geSinkOpcode) {
|
|
625
651
|
this._geSink.handleEvent(ev);
|
|
626
|
-
|
|
627
|
-
this._handlePresentEvent(ev);
|
|
652
|
+
return;
|
|
628
653
|
}
|
|
654
|
+
this._geHandlers.get(ev.extension)?.(ev);
|
|
629
655
|
return;
|
|
630
656
|
}
|
|
631
657
|
const ntkev = ev; // todo: clone
|
|
@@ -659,9 +685,12 @@ export default class Window extends Drawable {
|
|
|
659
685
|
if (key.codepoint !== undefined) ev.codepoint = key.codepoint;
|
|
660
686
|
}
|
|
661
687
|
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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'));
|
|
665
694
|
}
|
|
666
695
|
// the window manager reporting what it did with the window: a user who
|
|
667
696
|
// hit maximize or a fullscreen hotkey changes the state behind the
|
|
@@ -673,19 +702,7 @@ export default class Window extends Drawable {
|
|
|
673
702
|
() => {}
|
|
674
703
|
);
|
|
675
704
|
}
|
|
676
|
-
|
|
677
|
-
// them in the order they happened (a drag sees the move, then the up)
|
|
678
|
-
this._flushCoalesced();
|
|
679
|
-
if (eventName === 'resize') this._tagResize(ntkev);
|
|
680
|
-
this.emit(eventName, ntkev);
|
|
681
|
-
// a WM_DELETE_WINDOW ClientMessage is the window manager *asking*, and
|
|
682
|
-
// 'close' is that question in a form an application can answer
|
|
683
|
-
if (eventName === 'message') {
|
|
684
|
-
this._emitCloseRequest(ntkev);
|
|
685
|
-
this._handleSyncRequest(ntkev);
|
|
686
|
-
}
|
|
687
|
-
// anything drawn during the handlers becomes visible in one blit
|
|
688
|
-
if (this._dirty) this._present();
|
|
705
|
+
this._deliverEvent(eventName, ntkev);
|
|
689
706
|
});
|
|
690
707
|
|
|
691
708
|
// Events about a *child* of this window: the substructure requests a
|
|
@@ -1169,6 +1186,32 @@ export default class Window extends Drawable {
|
|
|
1169
1186
|
this._armTimer();
|
|
1170
1187
|
}
|
|
1171
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
|
+
|
|
1172
1215
|
_enqueueCoalesced(name, ev) {
|
|
1173
1216
|
const pending = this._frame.pending;
|
|
1174
1217
|
const prev = pending.get(name);
|
|
@@ -1178,6 +1221,21 @@ export default class Window extends Drawable {
|
|
|
1178
1221
|
ev.rects = [{ x: ev.x, y: ev.y, width: ev.width, height: ev.height }];
|
|
1179
1222
|
}
|
|
1180
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;
|
|
1181
1239
|
} else if (xevents.coalesce[name] === 'union') {
|
|
1182
1240
|
prev.coalesced.push(ev);
|
|
1183
1241
|
prev.rects.push({ x: ev.x, y: ev.y, width: ev.width, height: ev.height });
|
|
@@ -1285,6 +1343,163 @@ export default class Window extends Drawable {
|
|
|
1285
1343
|
this._geSinkOpcode = sink ? majorOpcode : 0;
|
|
1286
1344
|
}
|
|
1287
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
|
+
|
|
1288
1503
|
/**
|
|
1289
1504
|
* Present's events, which are the ones the core event table cannot name:
|
|
1290
1505
|
* they arrive as X Generic Events (type 35), carrying an extension opcode
|
|
@@ -1703,6 +1918,7 @@ export default class Window extends Drawable {
|
|
|
1703
1918
|
safeRelease(X, () => fixes.CreateRegion(this._updateRegion, []));
|
|
1704
1919
|
this._presentExt = present;
|
|
1705
1920
|
this._fixesExt = fixes;
|
|
1921
|
+
this._geHandlers.set(present.majorOpcode, (ev) => this._handlePresentEvent(ev));
|
|
1706
1922
|
this._selectPresentInput();
|
|
1707
1923
|
return this;
|
|
1708
1924
|
});
|
package/lib/xi2.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XI2 (X Input Extension 2) device events: what to select, how to turn one
|
|
3
|
+
* into an ntk event, and the per-device bookkeeping a scroll delta needs.
|
|
4
|
+
*
|
|
5
|
+
* The core protocol flattens every input device onto one virtual pointer and
|
|
6
|
+
* one virtual keyboard, and reports a wheel as a click of button 4/5/6/7. A
|
|
7
|
+
* touchpad's two-finger scroll therefore reaches a core client as a series of
|
|
8
|
+
* whole notches, which is why nothing built on core X can scroll smoothly.
|
|
9
|
+
* XI2 carries the same gesture as a *valuator*: an absolute accumulator per
|
|
10
|
+
* scroll axis, whose difference between two events is the distance scrolled,
|
|
11
|
+
* in units of the axis' `increment` (one notch).
|
|
12
|
+
*
|
|
13
|
+
* Three facts shape everything here:
|
|
14
|
+
*
|
|
15
|
+
* 1. **Valuators are absolute.** A delta is `(now - before) / increment`, so
|
|
16
|
+
* the first event from a device has nothing to subtract from and must seed
|
|
17
|
+
* rather than report — otherwise the first scroll of a session jumps by
|
|
18
|
+
* however far the axis had travelled before the window existed.
|
|
19
|
+
* 2. **A device's axes are described once, not per event.** Which valuator is
|
|
20
|
+
* the vertical scroll axis, and what one notch of it is worth, comes from
|
|
21
|
+
* `XIQueryDevice`'s `Scroll` classes. They change under a master device
|
|
22
|
+
* when the user switches from a mouse to a touchpad, which is what
|
|
23
|
+
* `XIDeviceChanged` announces — and that also resets the accumulators.
|
|
24
|
+
* 3. **Selecting XI2 turns off core delivery.** The server delivers an event
|
|
25
|
+
* to a client once: an XI2 selection on a window suppresses the core
|
|
26
|
+
* events of the same types *for that client on that window*. So a window
|
|
27
|
+
* that selects XI2 Motion stops receiving core MotionNotify, and this
|
|
28
|
+
* module has to reproduce the core-shaped event ntk consumers already
|
|
29
|
+
* listen for.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* XI2 event types ntk can deliver, and the ntk event each becomes.
|
|
34
|
+
*
|
|
35
|
+
* Only the types node-x11 decodes into fields are here. The rest —
|
|
36
|
+
* Enter/Leave, FocusIn/FocusOut, HierarchyChanged, the barriers — arrive with
|
|
37
|
+
* their bodies undecoded (`ev.data`), and selecting one would suppress the
|
|
38
|
+
* core event that *is* decoded in exchange for nothing.
|
|
39
|
+
*/
|
|
40
|
+
export const xi2EventName = {
|
|
41
|
+
Motion: 'mousemove',
|
|
42
|
+
ButtonPress: 'mousedown',
|
|
43
|
+
ButtonRelease: 'mouseup',
|
|
44
|
+
KeyPress: 'keydown',
|
|
45
|
+
KeyRelease: 'keyup',
|
|
46
|
+
TouchBegin: 'touchstart',
|
|
47
|
+
TouchUpdate: 'touchmove',
|
|
48
|
+
TouchEnd: 'touchend'
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What `xi2: true` selects: the pointer, which is what smooth scrolling
|
|
53
|
+
* needs. Keyboard and touch are opt-in by name — a window that selects XI2
|
|
54
|
+
* KeyPress takes on decoding it (and loses the core KeyPress it was getting).
|
|
55
|
+
*/
|
|
56
|
+
export const DEFAULT_XI2_EVENTS = ['Motion', 'ButtonPress', 'ButtonRelease'];
|
|
57
|
+
|
|
58
|
+
// XIScrollClass.scrollType
|
|
59
|
+
const SCROLL_VERTICAL = 1;
|
|
60
|
+
|
|
61
|
+
// XIPointerEventFlags.PointerEmulated: this button event is the server's
|
|
62
|
+
// rendering of a scroll axis into a wheel click, for clients that cannot read
|
|
63
|
+
// valuators. Both halves of the scroll are sent, so a client that reads the
|
|
64
|
+
// axis has to drop this one or count every notch twice.
|
|
65
|
+
export const POINTER_EMULATED = 1 << 16;
|
|
66
|
+
|
|
67
|
+
/** Wheel buttons in the core protocol: up, down, left, right. */
|
|
68
|
+
export const WHEEL_BUTTONS = { 4: [0, -1], 5: [0, 1], 6: [-1, 0], 7: [1, 0] };
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Check a list of XI2 event-type names, and say what a caller can ask for.
|
|
72
|
+
*
|
|
73
|
+
* The failure this catches is a silent one: an unknown name reaches
|
|
74
|
+
* `XISelectEvents` as `undefined` and throws from node-x11 naming a bit
|
|
75
|
+
* position, and a *known* name ntk cannot decode (`Enter`) would select
|
|
76
|
+
* happily and then deliver nothing anyone can read.
|
|
77
|
+
*
|
|
78
|
+
* @param {string[]|string} types
|
|
79
|
+
* @returns {string[]}
|
|
80
|
+
*/
|
|
81
|
+
export function normalizeXI2Types(types) {
|
|
82
|
+
const list = typeof types === 'string' ? [types] : Array.from(types ?? []);
|
|
83
|
+
for (const name of list) {
|
|
84
|
+
if (xi2EventName[name]) continue;
|
|
85
|
+
const known = Object.keys(xi2EventName).join(', ');
|
|
86
|
+
throw new Error(
|
|
87
|
+
`ntk: selectXI2 cannot deliver '${name}'. Supported XI2 event types: ${known}. ` +
|
|
88
|
+
(name?.startsWith?.('Raw')
|
|
89
|
+
? 'Raw events carry no window and are only sent for selections on the root, ' +
|
|
90
|
+
'so they are not routed to a window — read them off app.X directly.'
|
|
91
|
+
: 'Other XI2 events arrive with their bodies undecoded, and selecting one ' +
|
|
92
|
+
'would suppress the core event carrying the same information.')
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return list;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The scroll axes of one device, from its `XIQueryDevice` classes:
|
|
100
|
+
* valuator number -> `{ vertical, increment }`.
|
|
101
|
+
*
|
|
102
|
+
* A device with no scroll class (a plain wheel mouse on an old driver, a
|
|
103
|
+
* tablet) yields an empty map, and everything below then leaves scrolling to
|
|
104
|
+
* the emulated buttons.
|
|
105
|
+
*/
|
|
106
|
+
export function scrollAxes(device) {
|
|
107
|
+
const axes = new Map();
|
|
108
|
+
for (const cls of device?.classes ?? []) {
|
|
109
|
+
// ClassType.Scroll
|
|
110
|
+
if (cls.type !== 3) continue;
|
|
111
|
+
if (!cls.increment) continue; // an axis one of whose notches is 0 long
|
|
112
|
+
axes.set(cls.number, {
|
|
113
|
+
vertical: cls.scrollType === SCROLL_VERTICAL,
|
|
114
|
+
increment: cls.increment
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return axes;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Per-device scroll accumulators.
|
|
122
|
+
*
|
|
123
|
+
* Keyed by the *source* device as well as the master, because a master
|
|
124
|
+
* pointer's valuators are whatever its last-used slave put there: switching
|
|
125
|
+
* from the touchpad to the mouse continues the numbering with an unrelated
|
|
126
|
+
* value, and subtracting across that switch is the same jump as subtracting
|
|
127
|
+
* from zero.
|
|
128
|
+
*/
|
|
129
|
+
export class ScrollTracker {
|
|
130
|
+
constructor() {
|
|
131
|
+
this._last = new Map();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Forget a device's axes: the next event from it seeds again. */
|
|
135
|
+
reset(deviceId) {
|
|
136
|
+
for (const key of this._last.keys()) {
|
|
137
|
+
if (key.startsWith(`${deviceId}:`)) this._last.delete(key);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The scroll an event's valuators describe, or null when it carries no
|
|
143
|
+
* scroll axis and is therefore about something else (a pointer move).
|
|
144
|
+
*
|
|
145
|
+
* `moved` distinguishes a scroll from the first event of one: the seeding
|
|
146
|
+
* event carries a scroll axis — so it is not a pointer move and must not be
|
|
147
|
+
* reported as one — but has nothing to subtract from and so no distance to
|
|
148
|
+
* report. Distances are in notches, fractional, positive down and right
|
|
149
|
+
* (the direction a DOM `wheel` event calls positive).
|
|
150
|
+
*
|
|
151
|
+
* @param {object} ev a decoded XI2 device event
|
|
152
|
+
* @param {Map<number, {vertical: boolean, increment: number}>} axes
|
|
153
|
+
* @returns {{deltaX: number, deltaY: number, moved: boolean}|null}
|
|
154
|
+
*/
|
|
155
|
+
delta(ev, axes) {
|
|
156
|
+
if (!axes?.size) return null;
|
|
157
|
+
let deltaX = 0;
|
|
158
|
+
let deltaY = 0;
|
|
159
|
+
let scrolled = false;
|
|
160
|
+
let moved = false;
|
|
161
|
+
for (const [number, axis] of axes) {
|
|
162
|
+
const value = ev.valuators?.[number];
|
|
163
|
+
if (value === undefined) continue;
|
|
164
|
+
scrolled = true;
|
|
165
|
+
const key = `${ev.deviceId}:${ev.sourceId}:${number}`;
|
|
166
|
+
const previous = this._last.get(key);
|
|
167
|
+
this._last.set(key, value);
|
|
168
|
+
// seeding: an absolute accumulator says nothing on its own
|
|
169
|
+
if (previous === undefined || value === previous) continue;
|
|
170
|
+
moved = true;
|
|
171
|
+
// a negative increment is how a device declares its axis inverted, and
|
|
172
|
+
// dividing by it puts the delta back in the natural direction
|
|
173
|
+
const notches = (value - previous) / axis.increment;
|
|
174
|
+
if (axis.vertical) deltaY += notches;
|
|
175
|
+
else deltaX += notches;
|
|
176
|
+
}
|
|
177
|
+
return scrolled ? { deltaX, deltaY, moved } : null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The core `state` field, rebuilt from an XI2 event.
|
|
183
|
+
*
|
|
184
|
+
* ntk hands this to consumers as `ev.buttons` and to the keyboard decoder as
|
|
185
|
+
* the layout group, both of which predate XI2 and read the core bit layout:
|
|
186
|
+
* modifiers in bits 0-7, buttons 1-5 in bits 8-12, the XKB group in 13-14.
|
|
187
|
+
* XI2 splits the same information three ways, and reports buttons as a list.
|
|
188
|
+
*/
|
|
189
|
+
export function coreState(ev) {
|
|
190
|
+
let state = (ev.mods?.effective ?? 0) & 0xff;
|
|
191
|
+
for (const button of ev.buttons ?? []) {
|
|
192
|
+
if (button >= 1 && button <= 5) state |= 1 << (7 + button);
|
|
193
|
+
}
|
|
194
|
+
state |= ((ev.group?.effective ?? 0) & 3) << 13;
|
|
195
|
+
return state;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Core event codes, so a translated event answers `ev.type` the way the event
|
|
199
|
+
// it stands in for would.
|
|
200
|
+
const coreType = {
|
|
201
|
+
mousemove: 6,
|
|
202
|
+
mousedown: 4,
|
|
203
|
+
mouseup: 5,
|
|
204
|
+
keydown: 2,
|
|
205
|
+
keyup: 3
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* An XI2 device event in the shape ntk delivers.
|
|
210
|
+
*
|
|
211
|
+
* Core-compatible where the two overlap — a handler written against
|
|
212
|
+
* `mousedown` cannot tell the difference — with the XI2 extras alongside:
|
|
213
|
+
* `deviceId`/`sourceId`, the sub-pixel `preciseX`/`preciseY` that FP1616
|
|
214
|
+
* coordinates carry and the integer `x`/`y` cannot, and the raw `valuators`.
|
|
215
|
+
*/
|
|
216
|
+
export function toNtkEvent(name, ev) {
|
|
217
|
+
const out = {
|
|
218
|
+
type: coreType[name] ?? ev.type,
|
|
219
|
+
name: ev.name,
|
|
220
|
+
xi2: true,
|
|
221
|
+
time: ev.time,
|
|
222
|
+
root: ev.root,
|
|
223
|
+
wid: ev.wid,
|
|
224
|
+
child: ev.child,
|
|
225
|
+
// core X reports integer coordinates; keep that the delivered contract and
|
|
226
|
+
// put the precision XI2 adds next to it rather than in place of it
|
|
227
|
+
x: Math.round(ev.x),
|
|
228
|
+
y: Math.round(ev.y),
|
|
229
|
+
rootx: Math.round(ev.rootx),
|
|
230
|
+
rooty: Math.round(ev.rooty),
|
|
231
|
+
preciseX: ev.x,
|
|
232
|
+
preciseY: ev.y,
|
|
233
|
+
preciseRootX: ev.rootx,
|
|
234
|
+
preciseRootY: ev.rooty,
|
|
235
|
+
buttons: coreState(ev),
|
|
236
|
+
buttonsDown: ev.buttons,
|
|
237
|
+
deviceId: ev.deviceId,
|
|
238
|
+
sourceId: ev.sourceId,
|
|
239
|
+
flags: ev.flags,
|
|
240
|
+
valuators: ev.valuators,
|
|
241
|
+
sameScreen: 1
|
|
242
|
+
};
|
|
243
|
+
// `detail` is the keycode, the button number or the touch id, depending;
|
|
244
|
+
// core X calls the first two `keycode`, and ntk documents them under that
|
|
245
|
+
if (name === 'touchstart' || name === 'touchmove' || name === 'touchend') {
|
|
246
|
+
out.touchId = ev.detail;
|
|
247
|
+
} else {
|
|
248
|
+
out.keycode = ev.detail;
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* A `wheel` event: `deltaX`/`deltaY` in notches, positive down and right.
|
|
255
|
+
*
|
|
256
|
+
* Notches rather than pixels, because a notch is the only unit the device
|
|
257
|
+
* agrees on — `increment` is defined as the value that makes one — and how
|
|
258
|
+
* many pixels one is worth is the consumer's line height, not ours. A
|
|
259
|
+
* touchpad reports fractions of one, which is the whole point: `smooth` says
|
|
260
|
+
* whether this delta came from a device that can, so a consumer can decide
|
|
261
|
+
* whether to animate the scroll or snap it.
|
|
262
|
+
*
|
|
263
|
+
* @param {string} source 'valuator' (XI2 scroll axes) or 'button' (4-7)
|
|
264
|
+
*/
|
|
265
|
+
export function wheelEvent(base, deltaX, deltaY, source) {
|
|
266
|
+
return {
|
|
267
|
+
// `type` stays that of the X event this was derived from — a button press
|
|
268
|
+
// or a motion — because a wheel has no core event code of its own
|
|
269
|
+
...base,
|
|
270
|
+
name: 'wheel',
|
|
271
|
+
deltaX,
|
|
272
|
+
deltaY,
|
|
273
|
+
deltaMode: 'line',
|
|
274
|
+
smooth: source === 'valuator',
|
|
275
|
+
source
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export default {
|
|
280
|
+
xi2EventName,
|
|
281
|
+
DEFAULT_XI2_EVENTS,
|
|
282
|
+
POINTER_EMULATED,
|
|
283
|
+
WHEEL_BUTTONS,
|
|
284
|
+
normalizeXI2Types,
|
|
285
|
+
scrollAxes,
|
|
286
|
+
ScrollTracker,
|
|
287
|
+
coreState,
|
|
288
|
+
toNtkEvent,
|
|
289
|
+
wheelEvent
|
|
290
|
+
};
|