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 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 shaped glyph runs onto this context, honouring the clip.
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 speaks in X Generic Events, which carry an extension opcode
617
- // and a sub-type where a core event carries its code — the name table
618
- // 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
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
- } else if (this._presentExt && ev.extension === this._presentExt.majorOpcode) {
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
- if (this._coalesce && xevents.coalesce[eventName]) {
663
- this._enqueueCoalesced(eventName, ntkev);
664
- 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'));
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
- // deliver buffered state events before a discrete one so handlers see
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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "7.4.0",
3
+ "version": "7.6.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",