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
package/src/dnd.js ADDED
@@ -0,0 +1,1725 @@
1
+ // XDND drag and drop, the drop-target half: react-x11 windows accept drags
2
+ // from other X11 applications (file managers, editors, browsers).
3
+ //
4
+ // Design: docs/architecture/drag-and-drop.md; protocol: XDND version 5.
5
+ // The advertisement (`XdndAware`) is a 4-byte property written
6
+ // unconditionally on every top-level window at realize time — it creates no
7
+ // X windows, so it is orthogonal to the windowed/windowless split.
8
+ // Laziness lives in the protocol's own channel instead: a window with no
9
+ // registered drop target answers the first XdndPosition with "not
10
+ // accepting" plus a suppression rectangle covering the whole window, which
11
+ // silences the source until the pointer leaves. De-advertising on unmount
12
+ // would race sources that cache the window list at drag start (GTK does),
13
+ // and would churn a property per commit for nothing a user can see.
14
+ //
15
+ // Drop handling is ordinary props on ordinary nodes — `dropAccept`,
16
+ // `onDragEnter/Over/Leave`, `onDrop` — dispatched through the same
17
+ // hit-test → capture/bubble machinery as every pointer event. `dropAccept`
18
+ // is data, not code, so XdndStatus is answered in the ClientMessage handler
19
+ // without waiting on React: an advertised window that stalls on a render
20
+ // stalls the *source application's* drag cursor.
21
+
22
+ import {
23
+ runWithPriority,
24
+ DiscreteEventPriority,
25
+ ContinuousEventPriority,
26
+ } from './priority.js';
27
+ import { callHandler, reportHandlerError } from './errors.js';
28
+ import { desktopSettings } from './desktopsettings.js';
29
+ import { discrete } from './events.js';
30
+ import {
31
+ TEXT_TARGETS,
32
+ TYPE_GROUPS,
33
+ decodeData,
34
+ parseUriList,
35
+ resolveType,
36
+ typeMatches,
37
+ } from './transfer.js';
38
+
39
+ export const XDND_VERSION = 5;
40
+
41
+ /** How long an async onDrop may hold the source's XdndFinished. The reply
42
+ * must always go out — a forgotten await in one app must not hang another
43
+ * application's drag gesture. */
44
+ const DROP_TIMEOUT = 10_000;
45
+
46
+ // Edge auto-scroll. How near a viewport's edge the pointer has to be for
47
+ // the drag to start scrolling it, how often a step goes out, and the
48
+ // fastest one step can be.
49
+ const AUTOSCROLL_EDGE = 24;
50
+ const AUTOSCROLL_INTERVAL = 30;
51
+ const AUTOSCROLL_STEP = 14;
52
+
53
+ const ATOM_NAMES = [
54
+ 'XdndAware',
55
+ 'XdndSelection',
56
+ 'XdndTypeList',
57
+ 'XdndProxy',
58
+ 'XdndEnter',
59
+ 'XdndPosition',
60
+ 'XdndStatus',
61
+ 'XdndLeave',
62
+ 'XdndDrop',
63
+ 'XdndFinished',
64
+ 'XdndActionCopy',
65
+ 'XdndActionMove',
66
+ 'XdndActionLink',
67
+ 'XdndActionAsk',
68
+ 'XdndActionPrivate',
69
+ // read off the source's window when it asks the target to choose
70
+ 'XdndActionList',
71
+ 'XdndActionDescription',
72
+ ];
73
+
74
+ // per-connection atom table: X -> { atoms|null, promise, names: Map }
75
+ const tables = new WeakMap();
76
+
77
+ /** Intern the XDND atom vocabulary once per connection (one batched write;
78
+ * node-x11 caches interned atoms, so re-entry costs nothing). Resolves to
79
+ * `{ <name>: atom, byAtom: Map<atom, name> }`, also readable synchronously
80
+ * via `dndAtomsOf` once resolved. */
81
+ export function dndAtoms(X) {
82
+ let entry = tables.get(X);
83
+ if (!entry) {
84
+ entry = { atoms: null, promise: null, names: new Map() };
85
+ entry.promise = Promise.all(
86
+ ATOM_NAMES.map(
87
+ (name) =>
88
+ new Promise((resolve, reject) =>
89
+ X.InternAtom(false, name, (err, atom) =>
90
+ err ? reject(err) : resolve(atom),
91
+ ),
92
+ ),
93
+ ),
94
+ ).then((ids) => {
95
+ const atoms = { byAtom: new Map() };
96
+ ATOM_NAMES.forEach((name, i) => {
97
+ atoms[name] = ids[i];
98
+ atoms.byAtom.set(ids[i], name);
99
+ });
100
+ entry.atoms = atoms;
101
+ return atoms;
102
+ });
103
+ tables.set(X, entry);
104
+ }
105
+ return entry.promise;
106
+ }
107
+
108
+ /** The resolved atom table, or null while the interning is in flight. */
109
+ export function dndAtomsOf(X) {
110
+ return tables.get(X)?.atoms ?? null;
111
+ }
112
+
113
+ /** Atom → name, cached per connection. Type-list atoms are arbitrary MIME
114
+ * strings interned by the source, so unknowns go through GetAtomName. */
115
+ function atomName(X, atom) {
116
+ const entry = tables.get(X);
117
+ const known = entry?.atoms?.byAtom.get(atom);
118
+ if (known) return Promise.resolve(known);
119
+ const cache = entry?.names;
120
+ const hit = cache?.get(atom);
121
+ if (hit) return hit;
122
+ const p = new Promise((resolve) =>
123
+ X.GetAtomName(atom, (err, name) => resolve(err ? null : name)),
124
+ );
125
+ cache?.set(atom, p);
126
+ return p;
127
+ }
128
+
129
+ export { parseUriList, typeMatches };
130
+
131
+ /**
132
+ * Does a node's `dropAccept` accept this offer? `dropAccept` is a type
133
+ * name, a semantic group, an array of either, or a predicate over the
134
+ * offered names. Absent means "accepts anything" — a bare `onDrop` is a
135
+ * valid dropzone.
136
+ */
137
+ export function matchAccept(accept, offered) {
138
+ if (accept == null) return true;
139
+ if (typeof accept === 'function') return Boolean(accept(offered));
140
+ const list = Array.isArray(accept) ? accept : [accept];
141
+ return list.some((want) => typeMatches(offered, want));
142
+ }
143
+
144
+ /** The props that make a node a drop target (and register it, see
145
+ * Reconciler commitMount / Node.applyProps). `dropAccept` is deliberately
146
+ * namespaced — a generic name like `accept` could ride in on a prop spread
147
+ * and silently turn a box into a dropzone. */
148
+ const DROP_PROPS = [
149
+ 'dropAccept',
150
+ 'onDrop',
151
+ 'onDropCapture',
152
+ 'onDragEnter',
153
+ 'onDragLeave',
154
+ 'onDragOver',
155
+ 'onDragOverCapture',
156
+ ];
157
+
158
+ export function hasDropProps(props) {
159
+ if (!props) return false;
160
+ for (const key of DROP_PROPS) {
161
+ if (props[key] != null) return true;
162
+ }
163
+ return false;
164
+ }
165
+
166
+ const ACTION_NAMES = {
167
+ XdndActionCopy: 'copy',
168
+ XdndActionMove: 'move',
169
+ XdndActionLink: 'link',
170
+ XdndActionAsk: 'ask',
171
+ XdndActionPrivate: 'private',
172
+ };
173
+ const ACTION_ATOMS = {
174
+ copy: 'XdndActionCopy',
175
+ move: 'XdndActionMove',
176
+ link: 'XdndActionLink',
177
+ ask: 'XdndActionAsk',
178
+ private: 'XdndActionPrivate',
179
+ };
180
+
181
+ function actionName(atoms, atom) {
182
+ return ACTION_NAMES[atoms.byAtom.get(atom)] ?? 'copy';
183
+ }
184
+
185
+ function actionAtom(atoms, name) {
186
+ return atoms[ACTION_ATOMS[name] ?? 'XdndActionCopy'];
187
+ }
188
+
189
+ /** Decode selection bytes for a text-ish target; anything else stays a
190
+ * Buffer. STRING is defined as latin-1; everything textual else is UTF-8. */
191
+ /**
192
+ * The viewport a drag over `path` scrolls: the nearest enclosing scroll
193
+ * container with somewhere to go, found by walking out from the hit node the
194
+ * same way the wheel's default action does ([events.js](./events.js)) — and
195
+ * skipping the same ones, so the two gestures agree about which viewport a
196
+ * point belongs to.
197
+ *
198
+ * A scroll *container* — `isScroller()` — rather than anything the wheel
199
+ * would scroll: `<textarea>` and its kind answer `canScroll` because they
200
+ * pan their own pixels, but nothing can be dropped into one, so scrolling
201
+ * it here would move text the drag cannot reach.
202
+ */
203
+ function scrollerIn(path) {
204
+ for (let i = path.length - 1; i >= 0; i--) {
205
+ const n = path[i];
206
+ if (n.isScroller?.() && n.canScroll(1, 1)) return n;
207
+ }
208
+ return null;
209
+ }
210
+
211
+ /**
212
+ * Pixels to scroll per step for a pointer at `x, y` over `rect`, or null
213
+ * when it is not near enough to an edge to scroll at all.
214
+ *
215
+ * The rate ramps with depth into the band rather than being constant: a
216
+ * drag crossing a long list crawls at a constant slow rate, and one
217
+ * nudging the next row into view overshoots at a constant fast one. Each
218
+ * axis takes the nearer of its two edges, so a viewport shorter than two
219
+ * bands still scrolls one way rather than fighting itself.
220
+ */
221
+ function edgeVelocity(rect, x, y, scale = 1) {
222
+ const inside =
223
+ x >= rect.x &&
224
+ x <= rect.x + rect.width &&
225
+ y >= rect.y &&
226
+ y <= rect.y + rect.height;
227
+ if (!inside) return null;
228
+ const edge = AUTOSCROLL_EDGE * scale;
229
+ const ramp = (distance) =>
230
+ distance >= edge
231
+ ? 0
232
+ : Math.ceil((1 - Math.max(0, distance) / edge) * AUTOSCROLL_STEP * scale);
233
+ const top = y - rect.y;
234
+ const bottom = rect.y + rect.height - y;
235
+ const left = x - rect.x;
236
+ const right = rect.x + rect.width - x;
237
+ const dy = top < bottom ? -ramp(top) : ramp(bottom);
238
+ const dx = left < right ? -ramp(left) : ramp(right);
239
+ return dx === 0 && dy === 0 ? null : { dx, dy };
240
+ }
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // The per-top-level session.
244
+ // ---------------------------------------------------------------------------
245
+
246
+ /**
247
+ * One per realized top-level window (`<window>` at the root, `<popup>`),
248
+ * owned by the WindowNode as `_dnd`. Handles the incoming half of XDND:
249
+ * Enter/Position/Leave/Drop in, Status/Finished out. Messages for drags
250
+ * over nested `<window>` children also arrive here — since XDND v3 only
251
+ * top-levels advertise — and are routed down in JS via each child's
252
+ * `_screenOrigin`.
253
+ */
254
+ export class DropSession {
255
+ constructor(windowNode) {
256
+ this.node = windowNode;
257
+ this.X = windowNode.app.X;
258
+ // FIFO gate: messages queue behind atom interning and XdndEnter's
259
+ // async type-list resolution, so a Position never overtakes its Enter.
260
+ this._chain = Promise.resolve();
261
+ this._reset();
262
+ }
263
+
264
+ _reset() {
265
+ this._stopAutoScroll();
266
+ this.sourceWid = 0;
267
+ this.version = 0;
268
+ this.types = [];
269
+ this.path = [];
270
+ // what an `ask` source offered, read once per drag (see _readAskOffer)
271
+ this._askOffer = null;
272
+ this.accepted = null;
273
+ this.acceptedAction = 'copy';
274
+ this.requestedAction = 'copy';
275
+ this.lastPoint = null;
276
+ // which transport is feeding this session: 'external' (XDND wire) or
277
+ // 'internal' (a DragSession in this process). Carried onto every drag
278
+ // event as `e.source`.
279
+ this._source = 'external';
280
+ }
281
+
282
+ /** Entry point, from the window's 'message' listener. */
283
+ handleMessage(ev) {
284
+ if (ev.format !== 32 || !this.node.window) return;
285
+ this._chain = this._chain
286
+ .then(() => dndAtomsOf(this.X) ?? dndAtoms(this.X))
287
+ .then((atoms) => this._route(ev, atoms))
288
+ .catch(() => {}); // connection teardown mid-drag; nothing to do
289
+ }
290
+
291
+ _route(ev, atoms) {
292
+ switch (ev.message_type) {
293
+ case atoms.XdndEnter:
294
+ return this._onEnter(ev);
295
+ case atoms.XdndPosition:
296
+ return this._onPosition(ev, atoms);
297
+ case atoms.XdndLeave:
298
+ return this._onLeave(ev);
299
+ case atoms.XdndDrop:
300
+ return this._onDrop(ev, atoms);
301
+ }
302
+ }
303
+
304
+ /** A node is leaving the tree: no stale references (mirrors
305
+ * EventManager.forget). */
306
+ forget(node) {
307
+ this.path = this.path.filter((n) => n !== node);
308
+ if (this.accepted === node) this.accepted = null;
309
+ }
310
+
311
+ async _onEnter(ev) {
312
+ this._clearPath(); // a fresh enter ends any stale session
313
+ this._reset();
314
+ this.sourceWid = ev.data[0] >>> 0;
315
+ this.version = Math.min(XDND_VERSION, ev.data[1] >>> 24);
316
+ const typeAtoms =
317
+ ev.data[1] & 1
318
+ ? await this._readTypeList(this.sourceWid)
319
+ : [ev.data[2], ev.data[3], ev.data[4]].filter(Boolean);
320
+ // returned before positions are processed — the chain awaits this
321
+ this.types = (
322
+ await Promise.all(typeAtoms.map((a) => atomName(this.X, a)))
323
+ ).filter(Boolean);
324
+ }
325
+
326
+ /** The source offers more than three types: the full list is on its
327
+ * window as XdndTypeList (type ATOM). */
328
+ _readTypeList(sourceWid) {
329
+ return new Promise((resolve) => {
330
+ this.X.GetProperty(
331
+ 0,
332
+ sourceWid,
333
+ dndAtomsOf(this.X).XdndTypeList,
334
+ 0,
335
+ 0,
336
+ 0x1000,
337
+ (err, prop) => {
338
+ if (err || !prop?.data?.length) return resolve([]);
339
+ const atoms = [];
340
+ for (let o = 0; o + 4 <= prop.data.length; o += 4) {
341
+ atoms.push(prop.data.readUInt32LE(o));
342
+ }
343
+ resolve(atoms);
344
+ },
345
+ );
346
+ });
347
+ }
348
+
349
+ /**
350
+ * What a source offering `ask` is offering, read from its window: the
351
+ * actions as `XdndActionList` (type ATOM) and, beside it, the source's
352
+ * own words for them as `XdndActionDescription` — a run of NUL-separated
353
+ * strings, one per action, positionally matched.
354
+ *
355
+ * Read once per drag, and only when a position actually asks, because
356
+ * the two round trips buy nothing for the copy/move/link sources that
357
+ * are the overwhelming majority.
358
+ */
359
+ async _readAskOffer(sourceWid) {
360
+ if (this._askOffer) return this._askOffer;
361
+ const dnd = dndAtomsOf(this.X);
362
+ const [list, describedBytes] = await Promise.all([
363
+ this._readAtomList(sourceWid, dnd.XdndActionList),
364
+ this._readBytes(sourceWid, dnd.XdndActionDescription),
365
+ ]);
366
+ const actions = (await Promise.all(list.map((a) => atomName(this.X, a))))
367
+ .map((name) => ACTION_NAMES[name])
368
+ .filter(Boolean);
369
+ // trailing NUL is conventional, so drop the empty tail it leaves
370
+ const described = describedBytes.toString('utf8').split('\0');
371
+ if (described.at(-1) === '') described.pop();
372
+ this._askOffer = {
373
+ actions,
374
+ // positional: a source that describes none, or describes fewer than
375
+ // it offers, leaves the rest to us to name
376
+ descriptions: actions.map((name, i) => described[i] ?? null),
377
+ };
378
+ return this._askOffer;
379
+ }
380
+
381
+ _readAtomList(wid, atom) {
382
+ return new Promise((resolve) => {
383
+ this.X.GetProperty(0, wid, atom, 0, 0, 0x1000, (err, prop) => {
384
+ if (err || !prop?.data?.length) return resolve([]);
385
+ const atoms = [];
386
+ for (let o = 0; o + 4 <= prop.data.length; o += 4) {
387
+ atoms.push(prop.data.readUInt32LE(o));
388
+ }
389
+ resolve(atoms);
390
+ });
391
+ });
392
+ }
393
+
394
+ _readBytes(wid, atom) {
395
+ return new Promise((resolve) => {
396
+ this.X.GetProperty(0, wid, atom, 0, 0, 0x1000, (err, prop) =>
397
+ resolve(err || !prop?.data ? Buffer.alloc(0) : prop.data),
398
+ );
399
+ });
400
+ }
401
+
402
+ async _onPosition(ev, atoms) {
403
+ if (ev.data[0] >>> 0 !== this.sourceWid) return;
404
+ const packed = ev.data[2] >>> 0;
405
+ const rootX = packed >>> 16;
406
+ const rootY = packed & 0xffff;
407
+ const time = ev.data[3] >>> 0;
408
+ this.requestedAction = actionName(atoms, ev.data[4] >>> 0);
409
+
410
+ // Zero registered targets anywhere under this top-level: one refusal
411
+ // with a rectangle covering the whole window, and a well-behaved
412
+ // source goes quiet until the pointer leaves. This is the lazy channel
413
+ // — no node work, no React, one message per window-entry.
414
+ if (this.node._dndTargetCount() === 0) {
415
+ this._sendStatus(atoms, { accept: false, rect: this._windowRect() });
416
+ return;
417
+ }
418
+
419
+ // `ask` means the source wants the user asked which action to perform,
420
+ // so the choices have to be in hand before a handler is called. Only
421
+ // this branch pays for them, and only once per drag.
422
+ if (this.requestedAction === 'ask' && !this._askOffer) {
423
+ await this._readAskOffer(this.sourceWid);
424
+ // a leave or a new drag may have overtaken the round trips
425
+ if (ev.data[0] >>> 0 !== this.sourceWid) return;
426
+ }
427
+
428
+ const answer = this._overAt(rootX, rootY, time);
429
+ this._sendStatus(atoms, {
430
+ accept: answer.accept,
431
+ action: answer.action,
432
+ rect:
433
+ answer.freeze && this.accepted ? this._nodeRect(this.accepted) : null,
434
+ });
435
+ }
436
+
437
+ /**
438
+ * One position update, whichever transport delivered it: route the root
439
+ * point, hit-test, find the accepting node from `dropAccept` data, diff
440
+ * the drag path (enter/leave + `:drag-over`), and give `onDragOver` its
441
+ * synchronous chance to override. Returns the answer — XDND sends it as
442
+ * XdndStatus, an internal DragSession reads it directly.
443
+ */
444
+ _overAt(rootX, rootY, time) {
445
+ const { windowNode, x, y } = this._routePoint(rootX, rootY);
446
+ const target = windowNode.hitTest(x, y) ?? windowNode;
447
+ const path = windowNode.events._path(target);
448
+ const native = { x, y, rootx: rootX, rooty: rootY, time, buttons: 0 };
449
+
450
+ // deepest node that declares drop handling and matches the offer —
451
+ // answered from `dropAccept` data, no React in the loop
452
+ let accepted = null;
453
+ for (let i = path.length - 1; i >= 0; i--) {
454
+ const n = path[i];
455
+ if (n.props.disabled || !hasDropProps(n.props)) continue;
456
+ if (matchAccept(n.props.dropAccept, this.types)) {
457
+ accepted = n;
458
+ break;
459
+ }
460
+ }
461
+ this.accepted = accepted;
462
+ this.lastPoint = { windowNode, x, y, rootX, rootY, time };
463
+ this._updateAutoScroll(path, x, y);
464
+
465
+ const answer = {
466
+ accept: Boolean(accepted),
467
+ action: this.requestedAction,
468
+ freeze: false,
469
+ };
470
+ runWithPriority(ContinuousEventPriority, () => {
471
+ this._updateDragPath(path, native);
472
+ // onDragOver may override the declarative answer, synchronously —
473
+ // same latency budget as any event handler, no render awaited
474
+ const over = this._makeDragEvent(
475
+ windowNode,
476
+ 'dragOver',
477
+ native,
478
+ target,
479
+ answer,
480
+ );
481
+ this._dispatchOver(windowNode, path, over);
482
+ });
483
+ this.acceptedAction = answer.action;
484
+ return answer;
485
+ }
486
+
487
+ // -- the internal transport (a DragSession in this process) --------------
488
+ //
489
+ // Same session, same path diffing, same handlers — no wire. `e.source`
490
+ // says 'internal', `e.items` carries live values, and the answer goes
491
+ // back as a return value instead of an XdndStatus.
492
+
493
+ localOver(rootX, rootY, offer, time) {
494
+ this._source = 'internal';
495
+ this.sourceWid = 0;
496
+ this.types = offer.types;
497
+ this.requestedAction = offer.action;
498
+ const answer = this._overAt(rootX, rootY, time);
499
+ return { accepted: answer.accept, action: answer.action };
500
+ }
501
+
502
+ localLeave() {
503
+ if (this._source !== 'internal') return;
504
+ discrete(() => this._clearPath())();
505
+ this.accepted = null;
506
+ this.lastPoint = null;
507
+ this._source = 'external';
508
+ }
509
+
510
+ /** The internal drop: dispatch with the live extras a DragSession built
511
+ * (`items`, prefetched `files`/`text`, a local `getData`). Synchronous
512
+ * from the caller's point of view — `onDragEnd` follows immediately,
513
+ * like the DOM's dragend after drop. */
514
+ localDrop(offer, extras, time) {
515
+ this._source = 'internal';
516
+ this.types = offer.types;
517
+ const point = this.lastPoint;
518
+ const accepted = this.accepted;
519
+ const action = this.acceptedAction;
520
+ if (!accepted || accepted.destroyed || !point) {
521
+ this.localLeave();
522
+ return { handled: false, action: null };
523
+ }
524
+ // the same outcome object the wire transport uses, so `e.accept('move')`
525
+ // in a drop handler means one thing across both. In-app handlers are
526
+ // dispatched synchronously and not awaited, so only a synchronous
527
+ // answer reaches `onDragEnd` — an async one is too late, exactly as it
528
+ // is for the rest of the in-app drop path. Not awaited is not the same
529
+ // as not observed, though: `_dispatchDrop` attaches to whatever a
530
+ // handler returns even with no `pending` array to collect it, because
531
+ // an unobserved rejection here is a dead process rather than a late
532
+ // answer (#203).
533
+ const outcome = { accept: true, action, freeze: false };
534
+ discrete(() => {
535
+ runWithPriority(DiscreteEventPriority, () => {
536
+ const targetNode = this._dropTarget(point) ?? accepted;
537
+ const drop = this._makeDragEvent(
538
+ point.windowNode,
539
+ 'drop',
540
+ {
541
+ x: point.x,
542
+ y: point.y,
543
+ rootx: point.rootX,
544
+ rooty: point.rootY,
545
+ time,
546
+ buttons: 0,
547
+ },
548
+ targetNode,
549
+ outcome,
550
+ );
551
+ Object.assign(drop, extras);
552
+ this._dispatchDrop(point.windowNode, targetNode, drop);
553
+ this._clearPath();
554
+ });
555
+ })();
556
+ this.accepted = null;
557
+ this.lastPoint = null;
558
+ this._source = 'external';
559
+ return {
560
+ handled: outcome.accept,
561
+ action: outcome.accept ? outcome.action : null,
562
+ };
563
+ }
564
+
565
+ _onLeave(ev) {
566
+ if (ev.data[0] >>> 0 !== this.sourceWid) return;
567
+ discrete(() => this._clearPath())();
568
+ this.accepted = null;
569
+ this.sourceWid = 0;
570
+ }
571
+
572
+ async _onDrop(ev, atoms) {
573
+ if (ev.data[0] >>> 0 !== this.sourceWid) return;
574
+ // captured now: a new drag may Enter while an async onDrop is still
575
+ // settling, and the late XdndFinished must go to the *dropped* source
576
+ const sourceWid = this.sourceWid;
577
+ const version = this.version;
578
+ const action = this.acceptedAction;
579
+ const accepted = this.accepted;
580
+ const point = this.lastPoint;
581
+ if (!accepted || accepted.destroyed || !point) {
582
+ this._sendFinished(atoms, sourceWid, version, { accepted: false });
583
+ discrete(() => this._clearPath())();
584
+ this._reset();
585
+ return;
586
+ }
587
+ const time = ev.data[2] >>> 0;
588
+
589
+ // Prefetch the common payloads so `e.files` / `e.text` read
590
+ // synchronously in the handler; anything else stays behind
591
+ // `await e.getData(type)`.
592
+ let files = [];
593
+ let text;
594
+ try {
595
+ if (typeMatches(this.types, 'files')) {
596
+ const raw = await this._getData('text/uri-list', time);
597
+ text = String(raw);
598
+ files = parseUriList(raw);
599
+ } else {
600
+ const best = TEXT_TARGETS.find((t) => this.types.includes(t));
601
+ if (best) text = await this._getData(best, time);
602
+ }
603
+ } catch {
604
+ // conversion refused or timed out; the handler still runs with
605
+ // getData available for retries of other types
606
+ }
607
+
608
+ // What XdndFinished waits for. `_dispatchDrop` fills it with promises
609
+ // whose rejections it has already turned into a reported, refused drop,
610
+ // so nothing here rejects and `finish` is reached either way.
611
+ const pending = [];
612
+ // What the handler settles on. `accept`/`reject` mean something
613
+ // different at drop time than during a hover — "I took it, doing this"
614
+ // rather than "I would take it" — and this is what XdndFinished
615
+ // reports. An `ask` source is the reason it can be changed at all: the
616
+ // point of asking is that the answer is not known until now, and a
617
+ // handler that puts a menu up settles it later still.
618
+ const outcome = { accept: true, action, freeze: false };
619
+ discrete(() => {
620
+ runWithPriority(DiscreteEventPriority, () => {
621
+ const targetNode = this._dropTarget(point) ?? accepted;
622
+ const drop = this._makeDragEvent(
623
+ point.windowNode,
624
+ 'drop',
625
+ {
626
+ x: point.x,
627
+ y: point.y,
628
+ rootx: point.rootX,
629
+ rooty: point.rootY,
630
+ time,
631
+ buttons: 0,
632
+ },
633
+ targetNode,
634
+ outcome,
635
+ );
636
+ drop.files = files;
637
+ drop.text = text;
638
+ // `time` is captured, not read off the session: an async onDrop can
639
+ // still be holding getData after a later drag has reset us
640
+ drop.getData = (type) => this._getData(this._resolveType(type), time);
641
+ this._dispatchDrop(point.windowNode, targetNode, drop, pending);
642
+ this._clearPath();
643
+ });
644
+ })();
645
+
646
+ // XdndFinished goes out when every handler promise settles, or when
647
+ // the watchdog fires — whichever is first. Always.
648
+ let finished = false;
649
+ const finish = () => {
650
+ if (finished) return;
651
+ finished = true;
652
+ clearTimeout(timer);
653
+ // read now, not captured: an async handler settles the outcome
654
+ // between the dispatch above and this call
655
+ this._sendFinished(atoms, sourceWid, version, {
656
+ accepted: outcome.accept,
657
+ action: outcome.action,
658
+ });
659
+ // only clear session state if no new drag has entered meanwhile
660
+ if (this.sourceWid === sourceWid) this._reset();
661
+ };
662
+ const timer = setTimeout(() => {
663
+ if (process.env.NODE_ENV !== 'production' && !finished) {
664
+ console.warn(
665
+ 'react-x11: onDrop has not settled after ' +
666
+ `${DROP_TIMEOUT}ms; sending XdndFinished anyway so the drag ` +
667
+ 'source is not left hanging.',
668
+ );
669
+ }
670
+ finish();
671
+ }, DROP_TIMEOUT);
672
+ timer.unref?.();
673
+ Promise.allSettled(pending).then(finish);
674
+ }
675
+
676
+ /** The node under the last position, re-hit-tested at drop time — the
677
+ * tree may have re-rendered between the last motion and the button
678
+ * release. */
679
+ _dropTarget(point) {
680
+ const node = point.windowNode;
681
+ if (node.destroyed) return null;
682
+ return node.hitTest(point.x, point.y) ?? node;
683
+ }
684
+
685
+ // -- dispatch ------------------------------------------------------------
686
+
687
+ /** Drag events ride the same synthetic-event shape as pointer events,
688
+ * with the XDND extras flattened on. `answer` wires accept/reject/freeze
689
+ * for dragOver; enter/leave/drop pass null and get inert versions. */
690
+ _makeDragEvent(windowNode, type, native, target, answer) {
691
+ const types = this.types;
692
+ return windowNode.events._makeEvent(type, native, target, {
693
+ types,
694
+ has: (want) => typeMatches(types, want),
695
+ action: this.requestedAction,
696
+ // Only an `ask` source fills these: the actions it will accept, and
697
+ // its own words for them, positionally matched (null where it gave
698
+ // none). Empty otherwise, so a handler can read them unguarded.
699
+ actions: this._askOffer?.actions ?? [],
700
+ actionDescriptions: this._askOffer?.descriptions ?? [],
701
+ source: this._source,
702
+ // root-window coordinates, divided to logical like every coordinate
703
+ // a handler reads (src/scale.js)
704
+ screenX: native.rootx / windowNode.events.scale,
705
+ screenY: native.rooty / windowNode.events.scale,
706
+ accept: (action) => {
707
+ if (!answer) return;
708
+ answer.accept = true;
709
+ if (action) answer.action = action;
710
+ },
711
+ reject: () => {
712
+ if (answer) answer.accept = false;
713
+ },
714
+ freeze: () => {
715
+ if (answer) answer.freeze = true;
716
+ },
717
+ });
718
+ }
719
+
720
+ /** Capture → target → bubble for onDragOver, sharing the path already
721
+ * computed for the position. */
722
+ _dispatchOver(windowNode, path, ev) {
723
+ const events = windowNode.events;
724
+ for (const n of path) {
725
+ const handler = n.props.onDragOverCapture;
726
+ if (handler) {
727
+ ev.currentTarget = events._public(n);
728
+ callHandler(n, 'onDragOverCapture', handler, ev);
729
+ if (ev.propagationStopped) return;
730
+ }
731
+ }
732
+ for (let i = path.length - 1; i >= 0; i--) {
733
+ const handler = path[i].props.onDragOver;
734
+ if (handler) {
735
+ ev.currentTarget = events._public(path[i]);
736
+ callHandler(path[i], 'onDragOver', handler, ev);
737
+ if (ev.propagationStopped) return;
738
+ }
739
+ }
740
+ }
741
+
742
+ /** onDrop dispatch that observes the handlers' returned promises, and
743
+ * hands them to a caller that has to *wait* for them —
744
+ * `events.dispatch` does neither. Same capture → target → bubble walk.
745
+ *
746
+ * Observing and waiting are separate, and only the waiting is optional:
747
+ * `_onDrop` passes `pending` because XdndFinished cannot go out until the
748
+ * handlers settle, `localDrop` passes nothing because nothing in-app
749
+ * waits (its `onDragEnd` follows immediately, by design). Observing is
750
+ * unconditional — an `async onDrop` that throws is a failed drop, and a
751
+ * failed drop nobody attached to is an unhandled rejection, which under
752
+ * node's default `--unhandled-rejections=throw` kills the application
753
+ * (#203). */
754
+ _dispatchDrop(windowNode, targetNode, ev, pending) {
755
+ const events = windowNode.events;
756
+ const nodePath = events._path(targetNode);
757
+ const call = (n, name, handler) => {
758
+ ev.currentTarget = events._public(n);
759
+ try {
760
+ const result = handler(ev);
761
+ if (result && typeof result.then === 'function') {
762
+ // settled first, pushed second: `pending?.push(result.then(…))`
763
+ // would short-circuit the whole expression when there is no
764
+ // `pending`, which is the bug this is here to prevent.
765
+ const settled = result.then(
766
+ () => {},
767
+ (error) => this._dropFailed(ev, n, name, error),
768
+ );
769
+ pending?.push(settled);
770
+ }
771
+ } catch (error) {
772
+ this._dropFailed(ev, n, name, error);
773
+ }
774
+ };
775
+ for (const n of nodePath) {
776
+ if (n.props.onDropCapture) {
777
+ call(n, 'onDropCapture', n.props.onDropCapture);
778
+ if (ev.propagationStopped) return;
779
+ }
780
+ }
781
+ for (let i = nodePath.length - 1; i >= 0; i--) {
782
+ if (nodePath[i].props.onDrop) {
783
+ call(nodePath[i], 'onDrop', nodePath[i].props.onDrop);
784
+ if (ev.propagationStopped) return;
785
+ }
786
+ }
787
+ }
788
+
789
+ /** A drop handler that failed, whichever way it failed.
790
+ *
791
+ * Both halves matter. It is **reported** on the same channel as every
792
+ * other handler throw, so an `async onDrop` that rejects is as loud as a
793
+ * synchronous one rather than silent (or, before #203, fatal). And it
794
+ * **refuses the drop**: the handler did not store what it was given, and
795
+ * XdndFinished's accepted bit is what a `move` source deletes its own
796
+ * copy on — reporting success after the handler threw is how a failed
797
+ * drop turns into lost data. A later handler that succeeds may still
798
+ * accept, exactly as one may today. */
799
+ _dropFailed(ev, node, name, error) {
800
+ ev.reject();
801
+ reportHandlerError(node, name, error);
802
+ }
803
+
804
+ // -- edge auto-scroll ----------------------------------------------------
805
+ //
806
+ // A drop target below the fold is otherwise unreachable: the pointer is
807
+ // already at the bottom of the list, and a source that sees no motion
808
+ // sends no more positions. So the timer, not the next position, is what
809
+ // advances the drag — which is also why the suppression rectangle
810
+ // (`e.freeze()`) is opt-in and off by default.
811
+
812
+ /**
813
+ * Aim the auto-scroll at whatever the pointer is over now, or stop it.
814
+ * Called from every position update, so the velocity tracks the pointer
815
+ * without the timer having to re-measure anything.
816
+ */
817
+ _updateAutoScroll(path, x, y) {
818
+ const node = scrollerIn(path);
819
+ const velocity = node?.abs
820
+ ? edgeVelocity(node.abs, x, y, node.scale)
821
+ : null;
822
+ if (!velocity) return this._stopAutoScroll();
823
+ this._autoScroll = { node, ...velocity };
824
+ // already stepping: the line above is the whole update
825
+ if (this._autoScrollTimer) return;
826
+ this._autoScrollTimer = setInterval(
827
+ () => this._autoScrollStep(),
828
+ AUTOSCROLL_INTERVAL,
829
+ );
830
+ // a drag must never be the reason a process cannot exit
831
+ this._autoScrollTimer.unref?.();
832
+ }
833
+
834
+ _stopAutoScroll() {
835
+ if (this._autoScrollTimer) clearInterval(this._autoScrollTimer);
836
+ this._autoScrollTimer = null;
837
+ this._autoScroll = null;
838
+ }
839
+
840
+ /**
841
+ * One step. Re-routing after it is the point of the whole feature: the
842
+ * content moved under a stationary pointer, so which node is hovered —
843
+ * and whether it accepts — can differ from one step to the next.
844
+ *
845
+ * Nothing is sent to the source. An `XdndStatus` is the answer to an
846
+ * `XdndPosition`, and an unsolicited one is not in the protocol; the next
847
+ * real position carries the fresh answer instead.
848
+ */
849
+ _autoScrollStep() {
850
+ const state = this._autoScroll;
851
+ const point = this.lastPoint;
852
+ if (!state || !point || state.node.destroyed) return this._stopAutoScroll();
853
+ const { node, dx, dy } = state;
854
+ // Nothing may escape a timer callback: there is no caller to catch it,
855
+ // so a throw here would take the process down rather than end a drag.
856
+ // The tree can be torn down between steps — a handler on the way past
857
+ // is free to unmount whatever this was scrolling.
858
+ try {
859
+ const before = `${node.scrollX},${node.scrollY}`;
860
+ if (node._scrollByDevice) node._scrollByDevice(dx, dy);
861
+ else node.scrollBy({ x: dx / node.scale, y: dy / node.scale });
862
+ // at the limit: no movement, so nothing under the pointer changed and
863
+ // there is nothing to re-route. The timer stays armed — the content
864
+ // can still grow, and the pointer is still in the band.
865
+ if (`${node.scrollX},${node.scrollY}` === before) return;
866
+ this._overAt(point.rootX, point.rootY, point.time);
867
+ } catch {
868
+ this._stopAutoScroll();
869
+ }
870
+ }
871
+
872
+ /** Enter/leave diffing over the drag path — the hover algorithm, with
873
+ * `:drag-over` in place of `:hover`. Non-bubbling, like the DOM's. */
874
+ _updateDragPath(newPath, native) {
875
+ const oldPath = this.path;
876
+ let common = 0;
877
+ while (
878
+ common < oldPath.length &&
879
+ common < newPath.length &&
880
+ oldPath[common] === newPath[common]
881
+ ) {
882
+ common++;
883
+ }
884
+ for (let i = oldPath.length - 1; i >= common; i--) {
885
+ const n = oldPath[i];
886
+ if (n.destroyed) continue;
887
+ n.setStyleState(':drag-over', false);
888
+ if (n.props.onDragLeave) {
889
+ callHandler(
890
+ n,
891
+ 'onDragLeave',
892
+ n.props.onDragLeave,
893
+ this._makeDragEvent(n.root, 'dragLeave', native, n, null),
894
+ );
895
+ }
896
+ }
897
+ for (let i = common; i < newPath.length; i++) {
898
+ const n = newPath[i];
899
+ n.setStyleState(':drag-over', true);
900
+ if (n.props.onDragEnter) {
901
+ callHandler(
902
+ n,
903
+ 'onDragEnter',
904
+ n.props.onDragEnter,
905
+ this._makeDragEvent(n.root, 'dragEnter', native, n, null),
906
+ );
907
+ }
908
+ }
909
+ this.path = newPath;
910
+ }
911
+
912
+ _clearPath() {
913
+ // every way a drag ends — leave, drop, reset — comes through here
914
+ this._stopAutoScroll();
915
+ const native = this.lastPoint
916
+ ? {
917
+ x: this.lastPoint.x,
918
+ y: this.lastPoint.y,
919
+ rootx: this.lastPoint.rootX,
920
+ rooty: this.lastPoint.rootY,
921
+ buttons: 0,
922
+ }
923
+ : { x: 0, y: 0, rootx: 0, rooty: 0, buttons: 0 };
924
+ this._updateDragPath([], native);
925
+ }
926
+
927
+ // -- data ----------------------------------------------------------------
928
+
929
+ /** One selection conversion, decoded for text-ish targets. ntk's
930
+ * clipboard drives ConvertSelection + INCR reassembly.
931
+ *
932
+ * `time` is the timestamp from `XdndDrop`'s `l[2]`, which the protocol
933
+ * says to convert with — never CurrentTime. Mainstream sources serve
934
+ * whatever they currently offer and would not notice, but one that
935
+ * validates the timestamp fails mysteriously, which is the worst failure
936
+ * profile there is. */
937
+ async _getData(target, time) {
938
+ const clipboard = this.node.app.clipboard;
939
+ if (!clipboard) throw new Error('react-x11: no clipboard on this app');
940
+ const data = await clipboard.read({
941
+ selection: 'XdndSelection',
942
+ target,
943
+ time,
944
+ });
945
+ return typeof data === 'string' ? data : decodeData(data, target);
946
+ }
947
+
948
+ /** `getData('files')` and friends: resolve a semantic group to the first
949
+ * concretely offered member. */
950
+ _resolveType(type) {
951
+ return resolveType(type, this.types);
952
+ }
953
+
954
+ // -- geometry ------------------------------------------------------------
955
+
956
+ /** Root point → the deepest realized `<window>` under it (nested child
957
+ * windows never advertise; the message lands here), in that window's
958
+ * coordinates. */
959
+ _routePoint(rootX, rootY) {
960
+ const local = (wn) => {
961
+ const origin = wn.window?._screenOrigin ?? {
962
+ x: wn.window?.x ?? 0,
963
+ y: wn.window?.y ?? 0,
964
+ };
965
+ return { x: rootX - origin.x, y: rootY - origin.y };
966
+ };
967
+ const descend = (wn) => {
968
+ const kids = wn.children.filter(
969
+ (c) => c.isWindow && !c.isPopup && c.window && !c.hidden,
970
+ );
971
+ // _xStack is bottom-to-top server order; hit the topmost first
972
+ const order = [...kids].sort(
973
+ (a, b) =>
974
+ wn._xStack.indexOf(b.window.id) - wn._xStack.indexOf(a.window.id),
975
+ );
976
+ for (const child of order) {
977
+ const p = local(child);
978
+ if (
979
+ p.x >= 0 &&
980
+ p.y >= 0 &&
981
+ p.x < (child.window.width ?? 0) &&
982
+ p.y < (child.window.height ?? 0)
983
+ ) {
984
+ return descend(child);
985
+ }
986
+ }
987
+ return wn;
988
+ };
989
+ const windowNode = descend(this.node);
990
+ return { windowNode, ...local(windowNode) };
991
+ }
992
+
993
+ _windowRect() {
994
+ const wnd = this.node.window;
995
+ const origin = wnd?._screenOrigin ?? { x: wnd?.x ?? 0, y: wnd?.y ?? 0 };
996
+ return {
997
+ x: origin.x,
998
+ y: origin.y,
999
+ width: wnd?.width ?? 0,
1000
+ height: wnd?.height ?? 0,
1001
+ };
1002
+ }
1003
+
1004
+ _nodeRect(node) {
1005
+ const wnd = node.root?.window;
1006
+ const origin = wnd?._screenOrigin ?? { x: wnd?.x ?? 0, y: wnd?.y ?? 0 };
1007
+ const abs = node.abs ?? { x: 0, y: 0, width: 0, height: 0 };
1008
+ return {
1009
+ x: origin.x + abs.x,
1010
+ y: origin.y + abs.y,
1011
+ width: abs.width,
1012
+ height: abs.height,
1013
+ };
1014
+ }
1015
+
1016
+ // -- replies -------------------------------------------------------------
1017
+
1018
+ /**
1019
+ * XdndStatus. `rect` null means "keep the positions coming" (flag bit 1
1020
+ * set, empty rectangle) — suppression silently breaks insertion carets
1021
+ * and edge auto-scroll, so it is opt-in via `e.freeze()`, plus the
1022
+ * automatic whole-window refusal when nothing is registered.
1023
+ */
1024
+ _sendStatus(atoms, { accept, action = 'copy', rect = null }) {
1025
+ const wid = this.node.window?.id;
1026
+ if (!wid || !this.sourceWid) return;
1027
+ const r = rect ?? { x: 0, y: 0, width: 0, height: 0 };
1028
+ this._send(this.sourceWid, atoms.XdndStatus, [
1029
+ wid,
1030
+ (accept ? 1 : 0) | (rect ? 0 : 2),
1031
+ (((r.x & 0xffff) << 16) | (r.y & 0xffff)) >>> 0,
1032
+ (((r.width & 0xffff) << 16) | (r.height & 0xffff)) >>> 0,
1033
+ accept ? actionAtom(atoms, action) : 0,
1034
+ ]);
1035
+ }
1036
+
1037
+ _sendFinished(atoms, sourceWid, version, { accepted, action = 'copy' }) {
1038
+ const wid = this.node.window?.id;
1039
+ if (!wid || !sourceWid || version < 2) return;
1040
+ const v5 = version >= 5;
1041
+ this._send(sourceWid, atoms.XdndFinished, [
1042
+ wid,
1043
+ v5 && accepted ? 1 : 0,
1044
+ v5 && accepted ? actionAtom(atoms, action) : 0,
1045
+ 0,
1046
+ 0,
1047
+ ]);
1048
+ }
1049
+
1050
+ /** Replies address the source window with event mask 0 — delivered to
1051
+ * its owner client, per ICCCM SendEvent semantics for client-to-client
1052
+ * messages. */
1053
+ _send(destWid, typeAtom, data) {
1054
+ try {
1055
+ this.X.SendClientMessage(destWid, destWid, typeAtom, 32, data, 0);
1056
+ } catch {
1057
+ // the source died mid-drag; its ClientMessages stop arriving too
1058
+ }
1059
+ }
1060
+ }
1061
+
1062
+ // ---------------------------------------------------------------------------
1063
+ // The drag source.
1064
+ //
1065
+ // One drag session, two transports (docs/architecture/drag-and-drop.md §6.3):
1066
+ // while the pointer is over this process's own windows the drag is local —
1067
+ // no wire, live JS payloads, the DropSession above delivering to the same
1068
+ // handlers with `e.source === 'internal'`. The moment it leaves them, the
1069
+ // session promotes to XDND: the payload is published on XdndSelection
1070
+ // through ntk's clipboard, the foreign target is found by descending from
1071
+ // the root, and Enter/Position/Leave/Drop go out. Handlers cannot tell the
1072
+ // transports apart except by asking.
1073
+ // ---------------------------------------------------------------------------
1074
+
1075
+ // How far a press moves before it is a drag rather than a click lives in
1076
+ // `desktopsettings.js`: it is `Net/DndDragThreshold` where the desktop
1077
+ // published one, and this renderer's own 4px where it did not. A drag that
1078
+ // starts sooner here than in every other application is a drag people begin
1079
+ // by accident.
1080
+
1081
+ /** How long to wait for a target's XdndFinished before ending the drag
1082
+ * anyway — the mirror of the drop side's watchdog. */
1083
+ const FINISH_TIMEOUT = 5_000;
1084
+ /** ButtonPress | ButtonRelease | PointerMotion: what the active grab must
1085
+ * keep selecting when the cursor is swapped mid-drag. */
1086
+ const GRAB_EVENT_MASK = 4 | 8 | 64;
1087
+
1088
+ export function hasDragProps(props) {
1089
+ return Boolean(props?.draggable);
1090
+ }
1091
+
1092
+ // Realized top-level WindowNodes per app: "is this root point over one of
1093
+ // our windows" is the internal/external transport switch, and the same set
1094
+ // answers internal cross-window routing.
1095
+ const appTopLevels = new WeakMap();
1096
+
1097
+ export function registerTopLevel(windowNode) {
1098
+ const app = windowNode.app;
1099
+ let set = appTopLevels.get(app);
1100
+ if (!set) appTopLevels.set(app, (set = new Set()));
1101
+ set.add(windowNode);
1102
+ }
1103
+
1104
+ export function forgetTopLevel(windowNode) {
1105
+ appTopLevels.get(windowNode.app)?.delete(windowNode);
1106
+ }
1107
+
1108
+ /** The top-level under a root point. Popups sit above plain windows and a
1109
+ * later realization above an earlier one — a heuristic, since the server
1110
+ * owns top-level stacking; good enough for drags. A `<popup dragPreview>`
1111
+ * follows the pointer by design and must never be the thing under it. */
1112
+ function topLevelAt(app, rootX, rootY) {
1113
+ const set = appTopLevels.get(app);
1114
+ if (!set) return null;
1115
+ let hit = null;
1116
+ for (const node of set) {
1117
+ if (node.destroyed || !node.window || node.props.dragPreview) continue;
1118
+ const wnd = node.window;
1119
+ const origin = wnd._screenOrigin ?? { x: wnd.x ?? 0, y: wnd.y ?? 0 };
1120
+ if (
1121
+ rootX >= origin.x &&
1122
+ rootY >= origin.y &&
1123
+ rootX < origin.x + (wnd.width ?? 0) &&
1124
+ rootY < origin.y + (wnd.height ?? 0)
1125
+ ) {
1126
+ if (!hit || node.isPopup || !hit.isPopup) hit = node;
1127
+ }
1128
+ }
1129
+ return hit;
1130
+ }
1131
+
1132
+ /** Called on every button-1 press (events.js): the nearest draggable
1133
+ * ancestor of the hit node arms the window's DragSession, or nothing
1134
+ * happens at all. */
1135
+ export function armDrag(windowNode, target, native) {
1136
+ let source = null;
1137
+ for (
1138
+ let n = target;
1139
+ n;
1140
+ n = n === windowNode ? null : (n.parent ?? windowNode)
1141
+ ) {
1142
+ if (hasDragProps(n.props) && !n.props.disabled) {
1143
+ source = n;
1144
+ break;
1145
+ }
1146
+ if (n === windowNode) break;
1147
+ }
1148
+ if (!source) return null;
1149
+ const session = (windowNode._dragSession ??= new DragSession(windowNode));
1150
+ session.arm(source, native);
1151
+ return session;
1152
+ }
1153
+
1154
+ const isBinaryData = (v) =>
1155
+ Buffer.isBuffer(v) || ArrayBuffer.isView(v) || v instanceof ArrayBuffer;
1156
+
1157
+ function internAtom(X, name) {
1158
+ return new Promise((resolve, reject) =>
1159
+ X.InternAtom(false, name, (err, atom) =>
1160
+ err ? reject(err) : resolve(atom),
1161
+ ),
1162
+ );
1163
+ }
1164
+
1165
+ export class DragSession {
1166
+ constructor(windowNode) {
1167
+ this.node = windowNode;
1168
+ this.app = windowNode.app;
1169
+ this.X = windowNode.app.X;
1170
+ this._reset();
1171
+ }
1172
+
1173
+ _reset() {
1174
+ this.phase = 'idle'; // 'idle' | 'armed' | 'dragging'
1175
+ this.source = null;
1176
+ this.press = null;
1177
+ this.types = [];
1178
+ this.actions = ['copy'];
1179
+ this.currentAction = 'copy';
1180
+ this.accepted = false;
1181
+ this.localSession = null;
1182
+ this._data = null;
1183
+ this._resolved = null;
1184
+ this._published = false;
1185
+ this._typeAtoms = null;
1186
+ this._atoms = null;
1187
+ this._cursor = null;
1188
+ this.ext = null;
1189
+ }
1190
+
1191
+ /** The pressed node (or an ancestor) is draggable: remember where, and
1192
+ * wait for the threshold. Below it, the gesture is still a click. */
1193
+ arm(source, native) {
1194
+ this._reset();
1195
+ this.phase = 'armed';
1196
+ this.source = source;
1197
+ this.press = {
1198
+ x: native.x,
1199
+ y: native.y,
1200
+ rootx: native.rootx ?? native.x,
1201
+ rooty: native.rooty ?? native.y,
1202
+ time: (native.time ?? 0) >>> 0,
1203
+ };
1204
+ }
1205
+
1206
+ cancel() {
1207
+ if (this.phase === 'dragging') {
1208
+ this.source?.setStyleState?.(':dragging', false);
1209
+ this.localSession?.localLeave();
1210
+ }
1211
+ this._reset();
1212
+ }
1213
+
1214
+ /** A node leaving the tree mid-gesture: abort rather than dangle. */
1215
+ forget(node) {
1216
+ if (this.source === node) this.cancel();
1217
+ }
1218
+
1219
+ /** Every mousemove while armed or dragging. Returns true when the drag
1220
+ * consumed the motion — the caller then skips hover and mousemove. */
1221
+ motion(native) {
1222
+ if (this.phase === 'armed') {
1223
+ const moved =
1224
+ Math.abs(native.x - this.press.x) + Math.abs(native.y - this.press.y);
1225
+ // the desktop's threshold is logical pixels; the motion is device
1226
+ if (moved < desktopSettings(this.app).dragThreshold * this.node.scale)
1227
+ return false;
1228
+ if (!this._start(native)) return false;
1229
+ }
1230
+ if (this.phase !== 'dragging') return false;
1231
+ this._feed(native);
1232
+ return true;
1233
+ }
1234
+
1235
+ _start(native) {
1236
+ const source = this.source;
1237
+ if (!source || source.destroyed) {
1238
+ this._reset();
1239
+ return false;
1240
+ }
1241
+ this._data = source.props.dragData ?? {};
1242
+ this.types = Object.keys(this._data);
1243
+ const actions = source.props.dragActions;
1244
+ this.actions =
1245
+ Array.isArray(actions) && actions.length > 0 ? actions : ['copy'];
1246
+ this.currentAction = this.actions[0];
1247
+ this._resolved = new Map();
1248
+ const ev = this.node.events.dispatch('DragStart', source, native, {
1249
+ types: this.types,
1250
+ action: this.currentAction,
1251
+ source: 'internal',
1252
+ screenX: (native.rootx ?? native.x) / this.node.events.scale,
1253
+ screenY: (native.rooty ?? native.y) / this.node.events.scale,
1254
+ });
1255
+ if (ev.defaultPrevented) {
1256
+ this._reset();
1257
+ return false;
1258
+ }
1259
+ this.phase = 'dragging';
1260
+ source.setStyleState(':dragging', true);
1261
+ this._setCursor('grab');
1262
+ return true;
1263
+ }
1264
+
1265
+ /** dragData values resolve once per drag: thunks are called on first
1266
+ * use — at delivery for an internal drop, at promotion for an external
1267
+ * one — and never at mousedown. */
1268
+ _resolve(type) {
1269
+ if (!this._resolved.has(type)) {
1270
+ const value = this._data[type];
1271
+ this._resolved.set(type, typeof value === 'function' ? value() : value);
1272
+ }
1273
+ return this._resolved.get(type);
1274
+ }
1275
+
1276
+ _offer() {
1277
+ return {
1278
+ types: this.types,
1279
+ action: this.actions[0],
1280
+ source: 'internal',
1281
+ };
1282
+ }
1283
+
1284
+ /** What an internal drop event carries beyond the external one: the live
1285
+ * values themselves, plus the same prefetched `files`/`text` shape so a
1286
+ * handler cannot tell the transports apart unless it asks. */
1287
+ _dropExtras() {
1288
+ const items = {};
1289
+ for (const t of this.types) items[t] = this._resolve(t);
1290
+ let files = [];
1291
+ const uriType = this.types.find((t) => TYPE_GROUPS.files.includes(t));
1292
+ if (uriType) {
1293
+ const v = this._resolve(uriType);
1294
+ if (typeof v === 'string') files = parseUriList(v);
1295
+ }
1296
+ let text;
1297
+ const best = TEXT_TARGETS.find((t) => this.types.includes(t));
1298
+ if (best) {
1299
+ const v = this._resolve(best);
1300
+ if (typeof v === 'string') text = v;
1301
+ }
1302
+ const getData = (type) =>
1303
+ Promise.resolve(this._resolve(resolveType(type, this.types)));
1304
+ return { items, files, text, getData };
1305
+ }
1306
+
1307
+ _feed(native) {
1308
+ const rootX = native.rootx ?? native.x;
1309
+ const rootY = native.rooty ?? native.y;
1310
+ const over = topLevelAt(this.app, rootX, rootY);
1311
+ if (over?._dnd) {
1312
+ // internal: deliver through the window's own DropSession
1313
+ if (this.ext?.current) this._extLeave();
1314
+ if (this.localSession && this.localSession !== over._dnd) {
1315
+ this.localSession.localLeave();
1316
+ }
1317
+ this.localSession = over._dnd;
1318
+ const result = this.localSession.localOver(
1319
+ rootX,
1320
+ rootY,
1321
+ this._offer(),
1322
+ (native.time ?? 0) >>> 0,
1323
+ );
1324
+ this.accepted = result.accepted;
1325
+ if (result.accepted) this.currentAction = result.action;
1326
+ this._setCursor(result.accepted ? 'grab' : 'not-allowed');
1327
+ } else {
1328
+ // external: over a foreign window (or nothing)
1329
+ if (this.localSession) {
1330
+ this.localSession.localLeave();
1331
+ this.localSession = null;
1332
+ this.accepted = false;
1333
+ }
1334
+ this._extMotion(rootX, rootY, (native.time ?? 0) >>> 0);
1335
+ }
1336
+ const source = this.source;
1337
+ if (source && !source.destroyed && source.props.onDrag) {
1338
+ callHandler(
1339
+ source,
1340
+ 'onDrag',
1341
+ source.props.onDrag,
1342
+ this.node.events._makeEvent('drag', native, source, {
1343
+ types: this.types,
1344
+ action: this.currentAction,
1345
+ source: this.localSession ? 'internal' : 'external',
1346
+ accepted: this.accepted,
1347
+ screenX: rootX / this.node.events.scale,
1348
+ screenY: rootY / this.node.events.scale,
1349
+ }),
1350
+ );
1351
+ }
1352
+ }
1353
+
1354
+ /** Button release. Returns true when a drag ran (the caller suppresses
1355
+ * mouseup/click, like the DOM after a drag gesture). */
1356
+ release(native) {
1357
+ if (this.phase !== 'dragging') {
1358
+ this._reset();
1359
+ return false;
1360
+ }
1361
+ const source = this.source;
1362
+ source?.setStyleState?.(':dragging', false);
1363
+ if (this.localSession) {
1364
+ let outcome = { handled: false, action: null };
1365
+ if (this.accepted) {
1366
+ outcome = this.localSession.localDrop(
1367
+ this._offer(),
1368
+ this._dropExtras(),
1369
+ (native.time ?? 0) >>> 0,
1370
+ );
1371
+ } else {
1372
+ this.localSession.localLeave();
1373
+ }
1374
+ this._end(
1375
+ native,
1376
+ outcome.handled ? outcome.action : null,
1377
+ outcome.handled,
1378
+ );
1379
+ } else if (this.ext?.current && this.accepted) {
1380
+ this._extDrop(native); // async: onDragEnd follows XdndFinished
1381
+ } else {
1382
+ if (this.ext?.current) this._extLeave();
1383
+ this._end(native, null, false);
1384
+ }
1385
+ return true;
1386
+ }
1387
+
1388
+ _end(native, action, dropped) {
1389
+ const source = this.source;
1390
+ if (this._published) this._disown();
1391
+ if (source && !source.destroyed) {
1392
+ this.node.events.dispatch('DragEnd', source, native, {
1393
+ types: this.types,
1394
+ action,
1395
+ dropped,
1396
+ source: 'internal',
1397
+ screenX: (native.rootx ?? native.x ?? 0) / this.node.events.scale,
1398
+ screenY: (native.rooty ?? native.y ?? 0) / this.node.events.scale,
1399
+ });
1400
+ }
1401
+ this._reset();
1402
+ }
1403
+
1404
+ /** Swap the active grab's cursor (X ChangeActivePointerGrab works on the
1405
+ * implicit button grab too). Core-font approximations — see the doc's
1406
+ * XCursor caveat; the mask must keep selecting motion and release or the
1407
+ * gesture goes silent. */
1408
+ _setCursor(name) {
1409
+ if (name === this._cursor) return;
1410
+ const X = this.X;
1411
+ if (typeof X.ChangeActivePointerGrab !== 'function') return;
1412
+ const cursors = this.app.cursors;
1413
+ if (!cursors?.get) return;
1414
+ try {
1415
+ X.ChangeActivePointerGrab(cursors.get(name), 0, GRAB_EVENT_MASK);
1416
+ this._cursor = name;
1417
+ } catch {
1418
+ // no cursor feedback is a cosmetic failure, never a functional one
1419
+ }
1420
+ }
1421
+
1422
+ // -- the external transport (XDND out) -----------------------------------
1423
+
1424
+ /** First motion outside our windows: publish the payload. Thunks resolve
1425
+ * now; live objects are JSON-serialised for the wire (internal drops got
1426
+ * them by reference). ntk's clipboard owns XdndSelection and serves the
1427
+ * conversions, INCR included. */
1428
+ async _publish() {
1429
+ if (this._published) return;
1430
+ this._published = true;
1431
+ this._atoms = await dndAtoms(this.X);
1432
+ this._typeAtoms = await Promise.all(
1433
+ this.types.map((t) => internAtom(this.X, t)),
1434
+ );
1435
+ const map = {};
1436
+ for (const t of this.types) {
1437
+ let v = this._resolve(t);
1438
+ if (typeof v !== 'string' && !isBinaryData(v)) v = JSON.stringify(v);
1439
+ map[t] = v;
1440
+ }
1441
+ const clipboard = this.app.clipboard;
1442
+ if (clipboard && this.types.length > 0) {
1443
+ await clipboard.write(map, {
1444
+ selection: 'XdndSelection',
1445
+ ...(this.press.time ? { time: this.press.time } : {}),
1446
+ });
1447
+ }
1448
+ if (
1449
+ this._typeAtoms.length > 3 &&
1450
+ typeof this.node.window?.setProperty === 'function'
1451
+ ) {
1452
+ await this.node.window.setProperty('XdndTypeList', this._typeAtoms, {
1453
+ type: 'ATOM',
1454
+ });
1455
+ }
1456
+ }
1457
+
1458
+ /** Positions are serialized behind the publish and the async target
1459
+ * descent; only the newest queued point is ever resolved, so a motion
1460
+ * burst costs one descent, not one per event. */
1461
+ _extMotion(rootX, rootY, time) {
1462
+ const ext = (this.ext ??= {
1463
+ current: null,
1464
+ queue: null,
1465
+ busy: false,
1466
+ suppress: null,
1467
+ finish: null,
1468
+ });
1469
+ ext.queue = { rootX, rootY, time };
1470
+ if (ext.busy) return;
1471
+ ext.busy = true;
1472
+ (async () => {
1473
+ try {
1474
+ await this._publish();
1475
+ while (this.ext?.queue && this.phase === 'dragging') {
1476
+ const point = this.ext.queue;
1477
+ this.ext.queue = null;
1478
+ await this._extStep(point);
1479
+ }
1480
+ } catch {
1481
+ // connection teardown mid-drag
1482
+ } finally {
1483
+ if (this.ext) this.ext.busy = false;
1484
+ }
1485
+ })();
1486
+ }
1487
+
1488
+ async _extStep({ rootX, rootY, time }) {
1489
+ const atoms = this._atoms;
1490
+ const found = await this._findTarget(rootX, rootY);
1491
+ const ext = this.ext;
1492
+ if (!ext || this.phase !== 'dragging') return;
1493
+ const current = ext.current;
1494
+ if ((found?.wid ?? 0) !== (current?.wid ?? 0)) {
1495
+ if (current) {
1496
+ this._sendTo(current.sendWid, atoms.XdndLeave, [
1497
+ this.node.window.id,
1498
+ 0,
1499
+ 0,
1500
+ 0,
1501
+ 0,
1502
+ ]);
1503
+ }
1504
+ ext.current = found;
1505
+ ext.suppress = null;
1506
+ this.accepted = false;
1507
+ this._setCursor('not-allowed');
1508
+ if (found) {
1509
+ const flags =
1510
+ ((XDND_VERSION << 24) | (this.types.length > 3 ? 1 : 0)) >>> 0;
1511
+ const t = this._typeAtoms ?? [];
1512
+ this._sendTo(found.sendWid, atoms.XdndEnter, [
1513
+ this.node.window.id,
1514
+ flags,
1515
+ t[0] ?? 0,
1516
+ t[1] ?? 0,
1517
+ t[2] ?? 0,
1518
+ ]);
1519
+ }
1520
+ }
1521
+ if (!ext.current) return;
1522
+ const s = ext.suppress;
1523
+ if (
1524
+ s &&
1525
+ rootX >= s.x &&
1526
+ rootY >= s.y &&
1527
+ rootX < s.x + s.width &&
1528
+ rootY < s.y + s.height
1529
+ ) {
1530
+ return; // the target asked for silence inside this rectangle
1531
+ }
1532
+ this._sendTo(ext.current.sendWid, atoms.XdndPosition, [
1533
+ this.node.window.id,
1534
+ 0,
1535
+ (((rootX & 0xffff) << 16) | (rootY & 0xffff)) >>> 0,
1536
+ time >>> 0,
1537
+ actionAtom(atoms, this.actions[0]),
1538
+ ]);
1539
+ }
1540
+
1541
+ /**
1542
+ * The XDND target under a root point: descend the window tree with
1543
+ * TranslateCoordinates, checking XdndAware at each level (since v3 it is
1544
+ * on the top-level client window, which under a reparenting WM sits a
1545
+ * level or two below the frame). XdndProxy redirects the sends. Per
1546
+ * motion and uncached — 2–4 round trips, fine locally; the drag-start
1547
+ * window cache from the design doc is the remote-X follow-up.
1548
+ */
1549
+ async _findTarget(rootX, rootY) {
1550
+ const root = this.X.display?.screen?.[0]?.root;
1551
+ if (root == null) return null;
1552
+ let wid = root;
1553
+ for (let depth = 0; depth < 16; depth++) {
1554
+ const child = await this._translate(root, wid, rootX, rootY);
1555
+ if (!child) return null;
1556
+ if (this._isOurs(child)) return null;
1557
+ const aware = await this._readAware(child);
1558
+ if (aware) {
1559
+ return {
1560
+ wid: child,
1561
+ sendWid: aware.proxy || child,
1562
+ version: Math.min(XDND_VERSION, aware.version),
1563
+ };
1564
+ }
1565
+ wid = child;
1566
+ }
1567
+ return null;
1568
+ }
1569
+
1570
+ _translate(srcWid, dstWid, x, y) {
1571
+ return new Promise((resolve) => {
1572
+ this.X.TranslateCoordinates(srcWid, dstWid, x, y, (err, res) =>
1573
+ resolve(err ? 0 : (res?.child ?? 0)),
1574
+ );
1575
+ });
1576
+ }
1577
+
1578
+ _isOurs(wid) {
1579
+ const set = appTopLevels.get(this.app);
1580
+ if (!set) return false;
1581
+ for (const node of set) {
1582
+ if (node.window?.id === wid) return true;
1583
+ }
1584
+ return false;
1585
+ }
1586
+
1587
+ _readAware(wid) {
1588
+ const atoms = this._atoms;
1589
+ const read = (atom) =>
1590
+ new Promise((resolve) => {
1591
+ this.X.GetProperty(0, wid, atom, 0, 0, 4, (err, prop) =>
1592
+ resolve(
1593
+ err || !prop?.data || prop.data.length < 4 ? null : prop.data,
1594
+ ),
1595
+ );
1596
+ });
1597
+ return Promise.all([read(atoms.XdndAware), read(atoms.XdndProxy)]).then(
1598
+ ([aware, proxy]) =>
1599
+ aware
1600
+ ? {
1601
+ version: aware.readUInt32LE(0),
1602
+ proxy: proxy ? proxy.readUInt32LE(0) : 0,
1603
+ }
1604
+ : null,
1605
+ );
1606
+ }
1607
+
1608
+ /** XdndStatus / XdndFinished sent back by the foreign target, routed here
1609
+ * from the source window's 'message' listener. */
1610
+ handleMessage(ev) {
1611
+ if (ev.format !== 32) return;
1612
+ const atoms = dndAtomsOf(this.X);
1613
+ if (!atoms) return; // cannot be ours: we intern before we publish
1614
+ if (ev.message_type === atoms.XdndStatus) this._onStatus(ev, atoms);
1615
+ else if (ev.message_type === atoms.XdndFinished) this._onFinished(ev);
1616
+ }
1617
+
1618
+ _onStatus(ev, atoms) {
1619
+ const ext = this.ext;
1620
+ if (!ext?.current || ev.data[0] >>> 0 !== ext.current.wid) return;
1621
+ const accept = Boolean(ev.data[1] & 1);
1622
+ this.accepted = accept;
1623
+ if (accept) this.currentAction = actionName(atoms, ev.data[4] >>> 0);
1624
+ const wantInside = Boolean(ev.data[1] & 2);
1625
+ const rw = ev.data[3] >>> 16;
1626
+ const rh = ev.data[3] & 0xffff;
1627
+ ext.suppress =
1628
+ wantInside || (rw === 0 && rh === 0)
1629
+ ? null
1630
+ : {
1631
+ x: ev.data[2] >>> 16,
1632
+ y: ev.data[2] & 0xffff,
1633
+ width: rw,
1634
+ height: rh,
1635
+ };
1636
+ this._setCursor(accept ? 'grab' : 'not-allowed');
1637
+ }
1638
+
1639
+ _onFinished(ev) {
1640
+ const ext = this.ext;
1641
+ if (ext?.finish) {
1642
+ const finish = ext.finish;
1643
+ ext.finish = null;
1644
+ finish({
1645
+ accepted: Boolean(ev.data[1] & 1),
1646
+ actionAtom: ev.data[2] >>> 0,
1647
+ });
1648
+ }
1649
+ }
1650
+
1651
+ _extLeave() {
1652
+ const ext = this.ext;
1653
+ if (!ext?.current || !this._atoms) return;
1654
+ this._sendTo(ext.current.sendWid, this._atoms.XdndLeave, [
1655
+ this.node.window.id,
1656
+ 0,
1657
+ 0,
1658
+ 0,
1659
+ 0,
1660
+ ]);
1661
+ ext.current = null;
1662
+ ext.suppress = null;
1663
+ this.accepted = false;
1664
+ }
1665
+
1666
+ _extDrop(native) {
1667
+ const ext = this.ext;
1668
+ const atoms = this._atoms;
1669
+ const target = ext.current;
1670
+ this._sendTo(target.sendWid, atoms.XdndDrop, [
1671
+ this.node.window.id,
1672
+ 0,
1673
+ (native.time ?? 0) >>> 0,
1674
+ 0,
1675
+ 0,
1676
+ ]);
1677
+ let done = false;
1678
+ const finish = (result) => {
1679
+ if (done) return;
1680
+ done = true;
1681
+ clearTimeout(timer);
1682
+ if (this.ext) this.ext.finish = null;
1683
+ // pre-v5 XdndFinished carries no verdict: treat silence and old
1684
+ // versions as "done, with the action the status negotiated"
1685
+ const rejected = result != null && result.accepted === false;
1686
+ const action =
1687
+ result?.accepted && target.version >= 5 && result.actionAtom
1688
+ ? actionName(atoms, result.actionAtom)
1689
+ : this.currentAction;
1690
+ this._end(native, rejected ? null : action, !rejected);
1691
+ };
1692
+ const timer = setTimeout(() => finish(null), FINISH_TIMEOUT);
1693
+ timer.unref?.();
1694
+ if (target.version >= 2) ext.finish = finish;
1695
+ else finish(null);
1696
+ }
1697
+
1698
+ /** Give XdndSelection up at drag end. Ownership is otherwise held until
1699
+ * someone else drags — harmless, except that a stale offer answered long
1700
+ * after XdndFinished is exactly what the spec warns about.
1701
+ *
1702
+ * `clipboard.clear()` rather than a raw `SetSelectionOwner` (ntk >= 5.4.0):
1703
+ * it releases with the timestamp the selection was acquired with, as ICCCM
1704
+ * 2.3.1 requires, so a drag that has already lost XdndSelection to another
1705
+ * client leaves it alone — where CurrentTime would have taken it away from
1706
+ * them. It also keeps ntk's own record of what it owns in step, instead of
1707
+ * waiting for the SelectionClear to come back and say so. */
1708
+ _disown() {
1709
+ // Nothing here may throw: `_end` calls this before dispatching DragEnd,
1710
+ // so a throw would cost the app the end of its own drag.
1711
+ try {
1712
+ this.app?.clipboard?.clear('XdndSelection')?.catch(() => {});
1713
+ } catch {
1714
+ // a connection already closing
1715
+ }
1716
+ }
1717
+
1718
+ _sendTo(destWid, typeAtom, data) {
1719
+ try {
1720
+ this.X.SendClientMessage(destWid, destWid, typeAtom, 32, data, 0);
1721
+ } catch {
1722
+ // the target died mid-drag
1723
+ }
1724
+ }
1725
+ }