react-x11 1.2.0 → 2.0.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.
Files changed (158) hide show
  1. package/README.md +330 -81
  2. package/package.json +132 -14
  3. package/src/ClickToComponent.js +9 -2
  4. package/src/DevToolsIntegration.js +440 -32
  5. package/src/Reconciler.js +463 -165
  6. package/src/a11y.js +1299 -0
  7. package/src/acceleratorhooks.js +86 -0
  8. package/src/accelerators.js +128 -0
  9. package/src/activate.js +168 -0
  10. package/src/anchor.js +360 -0
  11. package/src/appcontext.js +161 -0
  12. package/src/appearance.js +748 -0
  13. package/src/appearancehooks.js +96 -0
  14. package/src/apphooks.js +73 -0
  15. package/src/application.js +748 -0
  16. package/src/atspi.js +1953 -0
  17. package/src/bus.js +545 -0
  18. package/src/bushooks.js +136 -0
  19. package/src/clientmessage.js +140 -0
  20. package/src/clipboard.js +121 -0
  21. package/src/components/Button.js +148 -33
  22. package/src/components/Checkbox.js +66 -32
  23. package/src/components/Dialog.js +96 -29
  24. package/src/components/FileDialog.js +491 -0
  25. package/src/components/Icon.js +329 -0
  26. package/src/components/Menu.js +797 -133
  27. package/src/components/PasswordInput.js +434 -0
  28. package/src/components/ProgressBar.js +91 -12
  29. package/src/components/Radio.js +78 -35
  30. package/src/components/Select.js +255 -65
  31. package/src/components/Slider.js +102 -41
  32. package/src/components/SplitPane.js +206 -0
  33. package/src/components/Switch.js +100 -22
  34. package/src/components/Table.js +529 -0
  35. package/src/components/Tabs.js +211 -0
  36. package/src/components/Tooltip.js +528 -54
  37. package/src/components/anchor.js +154 -107
  38. package/src/components/change.js +34 -0
  39. package/src/components/dnd.js +103 -0
  40. package/src/components/index.js +16 -3
  41. package/src/components/keys.js +17 -20
  42. package/src/components/scribble.js +247 -0
  43. package/src/components/theme.js +350 -42
  44. package/src/components/typeahead.js +12 -2
  45. package/src/compose.js +868 -0
  46. package/src/compositing.js +224 -0
  47. package/src/dbusmenu.js +384 -0
  48. package/src/debug.d.ts +63 -0
  49. package/src/debug.js +642 -0
  50. package/src/decorations.js +486 -0
  51. package/src/desktopsettings.js +209 -0
  52. package/src/desktopsettingshooks.js +62 -0
  53. package/src/dnd.js +1725 -0
  54. package/src/editmenu.js +272 -0
  55. package/src/errors.js +98 -0
  56. package/src/events.js +1406 -162
  57. package/src/extensions.js +45 -0
  58. package/src/filedialog.js +375 -0
  59. package/src/filedialoghooks.js +132 -0
  60. package/src/fonthooks.js +64 -0
  61. package/src/fonts.js +301 -0
  62. package/src/foreignnodes.js +519 -0
  63. package/src/frame/child.js +33 -0
  64. package/src/frame/childmain.js +274 -0
  65. package/src/frame/env.js +140 -0
  66. package/src/frame/index.js +444 -0
  67. package/src/frame/lifecycle.js +67 -0
  68. package/src/frame/protocol.js +179 -0
  69. package/src/frames.js +73 -0
  70. package/src/glbackend.js +114 -0
  71. package/src/glnodes.js +125 -67
  72. package/src/globalmenu.js +683 -0
  73. package/src/host.d.ts +88 -0
  74. package/src/host.js +42 -0
  75. package/src/idle.js +490 -0
  76. package/src/idlehooks.js +100 -0
  77. package/src/imagesource.js +349 -0
  78. package/src/index.d.ts +475 -0
  79. package/src/index.js +83 -9
  80. package/src/inputtime.js +206 -0
  81. package/src/jsx-dev-runtime.d.ts +21 -0
  82. package/src/jsx-dev-runtime.js +2 -0
  83. package/src/jsx-runtime.d.ts +39 -0
  84. package/src/jsx-runtime.js +6 -0
  85. package/src/keyboard.js +256 -0
  86. package/src/keyboardstate.js +278 -0
  87. package/src/keyboardstatehooks.js +58 -0
  88. package/src/keysyms.d.ts +149 -0
  89. package/src/keysyms.js +270 -0
  90. package/src/locale.js +170 -0
  91. package/src/localehooks.js +47 -0
  92. package/src/menuitem.js +223 -0
  93. package/src/node.d.ts +597 -0
  94. package/src/node.js +44 -0
  95. package/src/nodes.js +9546 -690
  96. package/src/ntk.d.ts +44 -0
  97. package/src/ntk.js +25 -0
  98. package/src/paintcache.js +366 -0
  99. package/src/palette.js +380 -0
  100. package/src/pastestate.js +66 -0
  101. package/src/portal.js +461 -0
  102. package/src/priority.js +26 -0
  103. package/src/refresh/index.d.ts +40 -0
  104. package/src/refresh/index.js +122 -0
  105. package/src/refresh/loader.d.ts +37 -0
  106. package/src/refresh/loader.js +401 -0
  107. package/src/refresh/register.d.ts +5 -0
  108. package/src/refresh/register.js +13 -0
  109. package/src/registry.js +232 -0
  110. package/src/scale.js +626 -0
  111. package/src/scalehooks.js +27 -0
  112. package/src/screencolor.js +640 -0
  113. package/src/screencolorhooks.js +101 -0
  114. package/src/screens.js +754 -0
  115. package/src/screenshooks.js +137 -0
  116. package/src/startup.js +302 -0
  117. package/src/style.d.ts +126 -0
  118. package/src/style.js +33 -0
  119. package/src/styles.js +1482 -6
  120. package/src/svgnodes.js +306 -0
  121. package/src/testing/a11y.js +484 -0
  122. package/src/testing/components.js +414 -0
  123. package/src/testing/events.js +407 -0
  124. package/src/testing/harness.js +455 -0
  125. package/src/testing/index.d.ts +558 -0
  126. package/src/testing/index.js +85 -0
  127. package/src/testing/mock-app.js +463 -0
  128. package/src/testing/pixels.js +152 -0
  129. package/src/testing/queries.js +224 -0
  130. package/src/textrange.js +83 -0
  131. package/src/textselection.js +439 -0
  132. package/src/trace-registry.js +63 -0
  133. package/src/transfer.js +93 -0
  134. package/src/types/appearance.d.ts +84 -0
  135. package/src/types/application.d.ts +173 -0
  136. package/src/types/components.d.ts +820 -0
  137. package/src/types/dbus.d.ts +177 -0
  138. package/src/types/elements.d.ts +897 -0
  139. package/src/types/events.d.ts +549 -0
  140. package/src/types/filedialog.d.ts +260 -0
  141. package/src/types/fonts.d.ts +124 -0
  142. package/src/types/frame.d.ts +146 -0
  143. package/src/types/globalmenu.d.ts +42 -0
  144. package/src/types/nodes.d.ts +199 -0
  145. package/src/types/screencolor.d.ts +84 -0
  146. package/src/types/style.d.ts +434 -0
  147. package/src/types/system.d.ts +287 -0
  148. package/src/windowid.js +151 -0
  149. package/src/windowstate.js +393 -0
  150. package/src/xsettings.js +336 -0
  151. package/src/yoga.d.ts +55 -0
  152. package/src/yoga.js +135 -0
  153. package/src/components/Canvas3D.js +0 -28
  154. package/src/geometry3d.js +0 -223
  155. package/src/pointer3d.js +0 -158
  156. package/src/raycast3d.js +0 -146
  157. package/src/richnodes.js +0 -436
  158. package/src/scene3d.js +0 -683
@@ -0,0 +1,463 @@
1
+ // The mock ntk application used by the headless tests: enough of a window,
2
+ // a 2d context and an X connection for the renderer to run with no server.
3
+ import React from 'react';
4
+
5
+ import { setAppearanceForTests } from '../appearance.js';
6
+ import { setCompositingForTests } from '../compositing.js';
7
+ import { setScreensForTests } from '../screens.js';
8
+ import { loadLayout } from '../yoga.js';
9
+
10
+ // As in harness.js: a mock-app test must not register with a live AT-SPI
11
+ // registry on the machine running it.
12
+ process.env.NO_AT_BRIDGE ??= '1';
13
+
14
+ // The layout engine's WebAssembly loads in createRoot() rather than at import
15
+ // time — that is what keeps top-level await out of the bundle, and so lets an
16
+ // app ship as a single executable (see docs/packaging.md). A mock app never
17
+ // goes through createRoot, so the nodes built here would find no Yoga.Node.
18
+ await loadLayout();
19
+
20
+ export { React };
21
+
22
+ // A minimal stand-in for an ntk application object, so the renderer can be
23
+ // exercised without a running X server.
24
+ export function createMockApp() {
25
+ // enough of an emitter for the connection watch in createRoot
26
+ const listeners = {};
27
+ const app = {
28
+ X: {
29
+ display: { screen: [{ root: 1 }] },
30
+ keycode2keysyms: {},
31
+ InternAtom(onlyIfExists, name, cb) {
32
+ // unique ids, so per-name atom tables (src/dnd.js) work on the mock
33
+ if (!app._atoms.has(name)) app._atoms.set(name, 1000 + app._atoms.size);
34
+ cb(null, app._atoms.get(name));
35
+ },
36
+ ConfigureWindow(id, options) {
37
+ app.configureCalls.push([id, options]);
38
+ },
39
+ // Startup notification broadcasts through this (src/startup.js), and
40
+ // it is the only way to see the message: it goes to the root window,
41
+ // which the mock does not model as a window with properties.
42
+ SendClientMessage(destination, wid, type, format, data, eventMask) {
43
+ app.clientMessages.push({
44
+ destination,
45
+ wid,
46
+ type,
47
+ format,
48
+ data,
49
+ eventMask,
50
+ });
51
+ },
52
+ on(event, fn) {
53
+ (listeners[event] ??= []).push(fn);
54
+ },
55
+ emit(event, ...args) {
56
+ for (const fn of listeners[event] ?? []) fn(...args);
57
+ },
58
+ },
59
+ configureCalls: [],
60
+ clientMessages: [],
61
+ _atoms: new Map([['WM_DELETE_WINDOW', 999]]),
62
+ closed: 0,
63
+ close() {
64
+ app.closed++;
65
+ return Promise.resolve();
66
+ },
67
+ // A clipboard that behaves like ntk's without a server: selections are
68
+ // owned in this process, so a write is visible to the next read. Enough
69
+ // for anything that copies or pastes — the ICCCM machinery itself is
70
+ // tested against the real thing in test/clipboard.test.js.
71
+ clipboard: {
72
+ /** every write, in order: `{ data, selection, time }` */
73
+ writes: [],
74
+ /** selection name -> the target map currently owned */
75
+ owned: new Map(),
76
+ _watchers: new Map(),
77
+ write(data, { selection = 'CLIPBOARD', time } = {}) {
78
+ app.clipboard.writes.push({ data, selection, time });
79
+ const map =
80
+ typeof data === 'string'
81
+ ? { UTF8_STRING: data, STRING: data }
82
+ : { ...data };
83
+ app.clipboard.owned.set(selection, map);
84
+ for (const fn of app.clipboard._watchers.get(selection) ?? []) {
85
+ fn({ selection, owner: 1, reason: 'new-owner' });
86
+ }
87
+ return Promise.resolve();
88
+ },
89
+ clear(selection = 'CLIPBOARD') {
90
+ app.clipboard.owned.delete(selection);
91
+ for (const fn of app.clipboard._watchers.get(selection) ?? []) {
92
+ fn({ selection, owner: 0, reason: 'new-owner' });
93
+ }
94
+ return Promise.resolve();
95
+ },
96
+ targets({ selection = 'CLIPBOARD' } = {}) {
97
+ return Promise.resolve([
98
+ ...Object.keys(app.clipboard.owned.get(selection) ?? {}),
99
+ ]);
100
+ },
101
+ read({ selection = 'CLIPBOARD', target } = {}) {
102
+ const map = app.clipboard.owned.get(selection);
103
+ if (!map) {
104
+ return Promise.reject(
105
+ new Error(
106
+ `clipboard: nothing to paste — ${selection} has no owner`,
107
+ ),
108
+ );
109
+ }
110
+ if (target === undefined) {
111
+ const text = map.UTF8_STRING ?? map.STRING;
112
+ return text === undefined
113
+ ? Promise.reject(
114
+ new Error('clipboard: owner refused both text targets'),
115
+ )
116
+ : Promise.resolve(text);
117
+ }
118
+ if (!(target in map)) {
119
+ return Promise.reject(
120
+ new Error(`clipboard: owner cannot convert to ${target}`),
121
+ );
122
+ }
123
+ const value = map[target];
124
+ return Promise.resolve(
125
+ typeof value === 'string' ? Buffer.from(value, 'utf8') : value,
126
+ );
127
+ },
128
+ watch(selection, handler) {
129
+ const list = app.clipboard._watchers.get(selection) ?? [];
130
+ list.push(handler);
131
+ app.clipboard._watchers.set(selection, list);
132
+ return Promise.resolve(() => {
133
+ const at = list.indexOf(handler);
134
+ if (at !== -1) list.splice(at, 1);
135
+ });
136
+ },
137
+ },
138
+ windows: [],
139
+ // A display that does have a 32-bit TrueColor visual, so `<window
140
+ // transparent>` takes its real path here. Delete it from the app to
141
+ // model the displays that do not (XQuartz), where the renderer falls
142
+ // back to an opaque window.
143
+ findArgbVisual() {
144
+ return { visual: 0x21, depth: 32 };
145
+ },
146
+ // …and one with a compositor running — see the bottom of this function,
147
+ // where that half is seeded.
148
+ createWindow(attributes) {
149
+ const handlers = {};
150
+ const ops = [];
151
+ const ctx = {
152
+ ops,
153
+ fillStyle: null,
154
+ strokeStyle: null,
155
+ lineWidth: 1,
156
+ fillRect(x, y, w, h) {
157
+ ops.push(['fillRect', x, y, w, h, ctx.fillStyle]);
158
+ },
159
+ // A batch is `fillRect` per rectangle — that is what ntk's own
160
+ // `fillRects` promises, and the only difference there is the request
161
+ // count, which a mock with no server does not have. So it records
162
+ // the fills themselves: an assertion on what was drawn holds whether
163
+ // the drawing batched or not, and `[x, y, w, h]` quadruples and one
164
+ // flat list are the same drawing too.
165
+ fillRects(rects) {
166
+ const flat = Array.isArray(rects?.[0]) ? rects.flat() : (rects ?? []);
167
+ for (let i = 0; i + 3 < flat.length; i += 4) {
168
+ if (flat[i + 2] > 0 && flat[i + 3] > 0) {
169
+ ctx.fillRect(flat[i], flat[i + 1], flat[i + 2], flat[i + 3]);
170
+ }
171
+ }
172
+ },
173
+ // ntk's context has this beside `fillRect`, so an `onDraw` that
174
+ // outlines a box paints on a real server and used to crash on the
175
+ // mock — a missing method here is an app's headless test failing at
176
+ // something that works
177
+ strokeRect(x, y, w, h) {
178
+ ops.push(['strokeRect', x, y, w, h, ctx.strokeStyle, ctx.lineWidth]);
179
+ },
180
+ beginPath() {},
181
+ clearRect(x, y, w, h) {
182
+ ops.push(['clearRect', x, y, w, h]);
183
+ },
184
+ rect(x, y, w, h) {
185
+ ops.push(['rect', x, y, w, h]);
186
+ },
187
+ roundRect(x, y, w, h, r) {
188
+ ops.push(['roundRect', x, y, w, h, r]);
189
+ },
190
+ fill() {
191
+ ops.push(['fill', ctx.fillStyle]);
192
+ },
193
+ stroke() {
194
+ ops.push(['stroke', ctx.strokeStyle, ctx.lineWidth]);
195
+ },
196
+ clip() {
197
+ ops.push(['clip']);
198
+ },
199
+ save() {
200
+ ops.push(['save']);
201
+ },
202
+ restore() {
203
+ ops.push(['restore']);
204
+ },
205
+ translate(x, y) {
206
+ ops.push(['translate', x, y]);
207
+ },
208
+ setLineDash(segments) {
209
+ ops.push(['setLineDash', segments]);
210
+ },
211
+ moveTo(x, y) {
212
+ ops.push(['moveTo', x, y]);
213
+ },
214
+ lineTo(x, y) {
215
+ ops.push(['lineTo', x, y]);
216
+ },
217
+ closePath() {
218
+ ops.push(['closePath']);
219
+ },
220
+ drawImage(...args) {
221
+ ops.push(['drawImage']);
222
+ },
223
+ // the raw-pixel write, recorded with its destination — where these
224
+ // pixels land is the thing to assert about an `onDraw` that uses it,
225
+ // since putImageData ignores the transform (docs/elements.md,
226
+ // "<canvas>") and an un-offset call is a real bug this mock should
227
+ // not hide by crashing first
228
+ putImageData(data, x, y) {
229
+ ops.push(['putImageData', data?.width, data?.height, x, y]);
230
+ },
231
+ // enough of the canvas surface for SvgView.draw to run headlessly
232
+ scale(sx, sy) {
233
+ ops.push(['scale', sx, sy]);
234
+ },
235
+ transform() {},
236
+ setTransform() {},
237
+ getTransform() {
238
+ return { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
239
+ },
240
+ arc() {},
241
+ ellipse() {},
242
+ bezierCurveTo() {},
243
+ quadraticCurveTo() {},
244
+ createLinearGradient() {
245
+ return { addColorStop() {} };
246
+ },
247
+ createRadialGradient() {
248
+ return { addColorStop() {} };
249
+ },
250
+ };
251
+ const wnd = {
252
+ id: app.windows.length + 100,
253
+ X: app.X,
254
+ attributes,
255
+ x: attributes.x,
256
+ y: attributes.y,
257
+ width: attributes.width,
258
+ height: attributes.height,
259
+ title: attributes.title,
260
+ mapped: false,
261
+ destroyed: false,
262
+ parent: null,
263
+ calls: [],
264
+ ctx,
265
+ map() {
266
+ wnd.mapped = true;
267
+ wnd.calls.push(['map']);
268
+ },
269
+ unmap() {
270
+ wnd.mapped = false;
271
+ wnd.calls.push(['unmap']);
272
+ },
273
+ reparentTo(parent, x, y) {
274
+ wnd.parent = parent;
275
+ wnd.calls.push(['reparentTo', parent.id, x, y]);
276
+ },
277
+ destroy() {
278
+ wnd.destroyed = true;
279
+ wnd.calls.push(['destroy']);
280
+ },
281
+ // ntk >= 3.7.0: menus hold a pointer grab while they are up
282
+ grabPointer(options, cb) {
283
+ wnd.grabbed = true;
284
+ wnd.calls.push(['grabPointer']);
285
+ cb?.(null, 0);
286
+ },
287
+ ungrabPointer() {
288
+ wnd.grabbed = false;
289
+ wnd.calls.push(['ungrabPointer']);
290
+ },
291
+ // ntk >= 7.5.0. `xi2: 'auto'` creates a window on core events and
292
+ // calls this the first time it is scrolled, so the call is the
293
+ // observable half of the upgrade — `wnd.xi2Selected` is what a test
294
+ // asserts on. Resolves `true` the way a server with XInput2 answers;
295
+ // `app.hasXI2 = false` is the other kind of display.
296
+ selectXI2(types) {
297
+ wnd.calls.push(['selectXI2', types]);
298
+ if (app.hasXI2 === false) return Promise.resolve(false);
299
+ wnd.xi2Selected = types === undefined || types.length > 0;
300
+ return Promise.resolve(true);
301
+ },
302
+ resize(width, height) {
303
+ wnd.width = width;
304
+ wnd.height = height;
305
+ wnd.calls.push(['resize', width, height]);
306
+ },
307
+ move(x, y) {
308
+ wnd.x = x;
309
+ wnd.y = y;
310
+ wnd.calls.push(['move', x, y]);
311
+ },
312
+ setTitle(title) {
313
+ wnd.title = title;
314
+ wnd.calls.push(['setTitle', title]);
315
+ },
316
+ setSizeHints(hints) {
317
+ wnd.calls.push(['setSizeHints', hints]);
318
+ },
319
+ setClass(instance, className) {
320
+ wnd.calls.push(['setClass', instance, className]);
321
+ },
322
+ setWindowType(type) {
323
+ wnd.calls.push(['setWindowType', type]);
324
+ },
325
+ // ntk >= 4.3: ICCCM WM_TRANSIENT_FOR
326
+ setTransientFor(owner) {
327
+ wnd.transientFor = owner;
328
+ wnd.calls.push(['setTransientFor', owner]);
329
+ },
330
+ setAlwaysOnTop(on) {
331
+ wnd.calls.push(['setAlwaysOnTop', on]);
332
+ },
333
+ // ntk >= 4.1: the general _NET_WM_STATE surface. Resolves to whether
334
+ // the WM advertises the state; the mock says yes.
335
+ setWmState(names, action = 'add') {
336
+ wnd.calls.push(['setWmState', names, action]);
337
+ return Promise.resolve(true);
338
+ },
339
+ getWmStates() {
340
+ return Promise.resolve(wnd.wmStates ?? []);
341
+ },
342
+ setProperty(name, value, options) {
343
+ wnd.calls.push(['setProperty', name, value, options]);
344
+ return Promise.resolve(wnd);
345
+ },
346
+ deleteProperty(name) {
347
+ wnd.calls.push(['deleteProperty', name]);
348
+ return Promise.resolve(wnd);
349
+ },
350
+ setActions() {
351
+ wnd.calls.push(['setActions']);
352
+ },
353
+ setCursor(name) {
354
+ wnd.cursor = name;
355
+ wnd.calls.push(['setCursor', name]);
356
+ },
357
+ getContext() {
358
+ return ctx;
359
+ },
360
+ // ntk >= 4.3 (sidorares/ntk#139): the scroll-blit fast path. Records
361
+ // and accepts; a test wanting the fallback deletes the method or
362
+ // replaces it with one returning false.
363
+ scrollRegion(rect, dx, dy) {
364
+ wnd.calls.push(['scrollRegion', rect, dx, dy]);
365
+ return true;
366
+ },
367
+ // ntk >= 5.2 (sidorares/ntk#148): the frame-clock gate a discrete
368
+ // input's paint goes through (issue #141). No server here to fall
369
+ // behind, so
370
+ // it is open unless a test closes it — set `wnd.frameInFlight` to a
371
+ // function of your own, or flip `wnd._frameInFlight`, to model a
372
+ // connection that has not acknowledged the last frame.
373
+ frameInFlight() {
374
+ return Boolean(wnd._frameInFlight);
375
+ },
376
+ on(name, fn) {
377
+ (handlers[name] ??= []).push(fn);
378
+ },
379
+ emit(name, ev) {
380
+ for (const fn of handlers[name] ?? []) fn(ev);
381
+ },
382
+ };
383
+ app.windows.push(wnd);
384
+ return wnd;
385
+ },
386
+ };
387
+ // The mock display composites. Seeded here rather than probed, because
388
+ // there is no X connection to ask — and seeding it means `createRoot`
389
+ // finds a session already open and leaves it alone. Tests that want the
390
+ // other half of the matrix — ARGB visuals with nothing blending them,
391
+ // where a transparent corner would render black — call
392
+ // `setCompositingForTests(app, false)` before rendering.
393
+ setCompositingForTests(app, true);
394
+ // And the desktop's appearance, for the same reason and a sharper one: the
395
+ // built-in palette follows the system, so an unpinned test renders in
396
+ // whatever colours the developer's own desktop happens to be in. Pinned to
397
+ // "no desktop said anything", which is the truth here and resolves to the
398
+ // light palette. `setAppearanceForTests({ colorScheme: 'dark' })` is the
399
+ // other half of the matrix.
400
+ setAppearanceForTests({});
401
+ // And a monitor, for the third reason of the same kind: a `<window>` with
402
+ // no size is sized from its content and capped at the screen, and a mock
403
+ // with no screen to cap against would let a headless test grow a window to
404
+ // a size no desktop would ever have given it. One 1280x800 output, which
405
+ // is a modest laptop — big enough not to be in the way of a test that is
406
+ // about something else, small enough that a test about the cap can reach
407
+ // it. `setScreensForTests(app, {})` takes it away again.
408
+ setScreensForTests(app, {
409
+ monitors: [{ x: 0, y: 0, width: 1280, height: 800 }],
410
+ });
411
+ return app;
412
+ }
413
+
414
+ /**
415
+ * Move the pointer inside a window, in window coordinates.
416
+ *
417
+ * `time` is the X server timestamp in milliseconds. Anything that reads the
418
+ * pointer's *speed* rather than its position needs it — the attention
419
+ * tracker estimates a trajectory from consecutive samples — and a test that
420
+ * leaves it out gets the wall clock, which makes the velocity depend on how
421
+ * fast the machine ran the loop.
422
+ */
423
+ export function moveMouse(wnd, x, y, { time } = {}) {
424
+ wnd.emit('mousemove', time === undefined ? { x, y } : { x, y, time });
425
+ }
426
+
427
+ /** Press and release button 1 at a point; pass {release:false} to hold. */
428
+ export function pressButton(wnd, x, y, { press = true, release = true } = {}) {
429
+ if (press) wnd.emit('mousedown', { x, y, keycode: 1 });
430
+ if (release) wnd.emit('mouseup', { x, y, keycode: 1 });
431
+ }
432
+
433
+ /**
434
+ * A scroll at a point, in **notches** — ntk's `wheel` event, which is what
435
+ * the renderer reads whether the connection had XI2 or only the wheel
436
+ * buttons behind it.
437
+ *
438
+ * A notch is one click of a wheel, and a fraction of one is what a touchpad
439
+ * measures, so `{ deltaY: 0.25, smooth: true }` is a quarter of a notch from
440
+ * a device that can do that — the case the emulated buttons cannot express
441
+ * and a test of smooth scrolling is about. `buttons` takes the X modifier
442
+ * mask, for the Shift that turns a vertical wheel sideways.
443
+ */
444
+ export function spinWheel(
445
+ wnd,
446
+ x,
447
+ y,
448
+ { deltaX = 0, deltaY = 1, smooth = false, buttons = 0 } = {},
449
+ ) {
450
+ wnd.emit('wheel', {
451
+ name: 'wheel',
452
+ x,
453
+ y,
454
+ rootx: x,
455
+ rooty: y,
456
+ buttons,
457
+ deltaX,
458
+ deltaY,
459
+ deltaMode: 'line',
460
+ smooth,
461
+ source: smooth ? 'valuator' : 'button',
462
+ });
463
+ }
@@ -0,0 +1,152 @@
1
+ // Pixel assertions: what the X server actually put on the surface, read
2
+ // back with GetImage.
3
+ //
4
+ // This is the assertion that cannot lie. A snapshot of a node tree proves
5
+ // the tree; a pixel proves the tree, the layout, the clip, the paint order,
6
+ // the colour parsing and the wire encoding all at once — and it is the only
7
+ // way to catch the class of bug where everything is committed correctly and
8
+ // nothing reaches the screen.
9
+ //
10
+ // Two details the readback demands:
11
+ //
12
+ // - **Straight RGBA.** `getImageData` normalizes the server's native byte
13
+ // order and premultiplication away, so `[i], [i+1], [i+2]` is red, green,
14
+ // blue — the canvas contract. This used to be a raw BGRA readback the
15
+ // caller had to un-swizzle, and reading it the old way now swaps red and
16
+ // blue, which looks like a colour bug in the code under test.
17
+ // - **Antialiasing means tolerance.** A glyph edge or a rounded corner is
18
+ // never exactly the fill colour, so every comparison here takes one.
19
+
20
+ const DEFAULT_TOLERANCE = 8;
21
+
22
+ /** `#rgb`, `#rrggbb` or `[r,g,b]` → `[r, g, b]`. */
23
+ export function toRgb(colour) {
24
+ if (Array.isArray(colour)) return colour.slice(0, 3);
25
+ const hex = String(colour).replace('#', '');
26
+ const full =
27
+ hex.length === 3
28
+ ? hex
29
+ .split('')
30
+ .map((c) => c + c)
31
+ .join('')
32
+ : hex;
33
+ return [
34
+ parseInt(full.slice(0, 2), 16),
35
+ parseInt(full.slice(2, 4), 16),
36
+ parseInt(full.slice(4, 6), 16),
37
+ ];
38
+ }
39
+
40
+ function readImage(ctx, x, y, width, height) {
41
+ return new Promise((resolve, reject) =>
42
+ ctx.getImageData(x, y, width, height, (err, data) =>
43
+ err ? reject(err) : resolve(data),
44
+ ),
45
+ );
46
+ }
47
+
48
+ /** `[r, g, b]` at a window coordinate. */
49
+ export async function pixelAt(ctx, x, y) {
50
+ const image = await readImage(ctx, x, y, 1, 1);
51
+ // ntk's getImageData speaks straight RGBA, like canvas — the server's own
52
+ // byte order is its business, not ours
53
+ return [image.data[0], image.data[1], image.data[2]];
54
+ }
55
+
56
+ /** Whether two colours are the same within a tolerance. */
57
+ export function isNear(rgb, want, tolerance = DEFAULT_TOLERANCE) {
58
+ const target = toRgb(want);
59
+ return rgb.every((c, i) => Math.abs(c - target[i]) <= tolerance);
60
+ }
61
+
62
+ const hex = (rgb) =>
63
+ '#' + rgb.map((c) => c.toString(16).padStart(2, '0')).join('');
64
+
65
+ /** Assert the pixel at `x,y`. Throws with both colours in hex on a miss. */
66
+ export async function expectPixel(ctx, x, y, want, options = {}) {
67
+ const { tolerance = DEFAULT_TOLERANCE, message = null } = options;
68
+ const got = await pixelAt(ctx, x, y);
69
+ if (isNear(got, want, tolerance)) return got;
70
+ throw new Error(
71
+ `${message ? message + ': ' : ''}expected ${hex(toRgb(want))} at ${x},${y}` +
72
+ ` (±${tolerance}), got ${hex(got)}`,
73
+ );
74
+ }
75
+
76
+ /**
77
+ * Poll until the pixel arrives, or fail. For anything that finishes on its
78
+ * own schedule — a transition, an image decode, an async rich-content
79
+ * reflow — where the alternative is a sleep long enough to be flaky anyway.
80
+ */
81
+ export async function waitForPixel(ctx, x, y, want, options = {}) {
82
+ const {
83
+ timeout = 2000,
84
+ interval = 20,
85
+ tolerance = DEFAULT_TOLERANCE,
86
+ message = null,
87
+ } = options;
88
+ const deadline = Date.now() + timeout;
89
+ let got;
90
+ for (;;) {
91
+ got = await pixelAt(ctx, x, y);
92
+ if (isNear(got, want, tolerance)) return got;
93
+ if (Date.now() >= deadline) break;
94
+ await new Promise((r) => setTimeout(r, interval));
95
+ }
96
+ throw new Error(
97
+ `${message ? message + ': ' : ''}timed out after ${timeout}ms waiting for ` +
98
+ `${hex(toRgb(want))} at ${x},${y} (±${tolerance}); last read ${hex(got)}`,
99
+ );
100
+ }
101
+
102
+ /** How many pixels in a region are near a colour — "did it draw anything?" */
103
+ export async function countPixels(
104
+ ctx,
105
+ region,
106
+ want,
107
+ tolerance = DEFAULT_TOLERANCE,
108
+ ) {
109
+ const { x = 0, y = 0, width, height } = region;
110
+ const image = await readImage(ctx, x, y, width, height);
111
+ const target = toRgb(want);
112
+ let n = 0;
113
+ for (let i = 0; i < width * height; i++) {
114
+ const rgb = [
115
+ image.data[i * 4],
116
+ image.data[i * 4 + 1],
117
+ image.data[i * 4 + 2],
118
+ ];
119
+ if (rgb.every((c, k) => Math.abs(c - target[k]) <= tolerance)) n++;
120
+ }
121
+ return n;
122
+ }
123
+
124
+ /**
125
+ * Write a PNG of the window (or a region of it). Not an assertion — the
126
+ * thing to reach for when a pixel assertion fails and the message is not
127
+ * enough, and how a PR gets a screenshot rendered by its own code.
128
+ */
129
+ export async function toPNG(ctx, file, region = {}) {
130
+ const { PNG } = await import('pngjs');
131
+ const { writeFileSync } = await import('node:fs');
132
+ const { x = 0, y = 0, width, height } = region;
133
+ if (!width || !height) {
134
+ throw new Error(
135
+ 'react-x11/test: toPNG needs { width, height } — the context does not ' +
136
+ 'know how big the window is.',
137
+ );
138
+ }
139
+ const image = await readImage(ctx, x, y, width, height);
140
+ const png = new PNG({ width, height });
141
+ for (let i = 0; i < width * height; i++) {
142
+ // both sides are straight RGBA now; only the alpha is ours to decide,
143
+ // and a screenshot wants an opaque one
144
+ png.data[i * 4 + 0] = image.data[i * 4 + 0];
145
+ png.data[i * 4 + 1] = image.data[i * 4 + 1];
146
+ png.data[i * 4 + 2] = image.data[i * 4 + 2];
147
+ png.data[i * 4 + 3] = 255;
148
+ }
149
+ const buffer = PNG.sync.write(png);
150
+ if (file) writeFileSync(file, buffer);
151
+ return buffer;
152
+ }