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,439 @@
1
+ // The selection a reader drags across a document (issue #259).
2
+ //
3
+ // Not the X selection — that is clipboard.js, and PRIMARY is where this ends
4
+ // up rather than what it is. This is the state: which characters of which
5
+ // elements are lit, what a copy assembles out of them, and the one rule that
6
+ // cannot live outside core — **only one selection on screen at a time**.
7
+ //
8
+ // Three things are worth knowing before reading the rest.
9
+ //
10
+ // **A surface is a `selectable` element, and its participants are whatever
11
+ // under it can answer for its own text.** An element joins by implementing
12
+ // the four accessors in nodes.js (`textContent`, `textIndexAt`,
13
+ // `textCaretRect`, `textRangeRects`) — `<text>` does, and so does a terminal
14
+ // or a log view written outside this package. There is no registration call
15
+ // and no list of blessed kinds: a document is a tree, and the tree is walked.
16
+ //
17
+ // **The separators a copy uses come from the layout, not from the markup.**
18
+ // Core cannot know that one `<text>` is a table cell and another is a
19
+ // paragraph, and asking applications to say so would be a second authoring
20
+ // model for something the screen already shows. So two participants that
21
+ // share a band of pixels are joined with a tab and one that starts below the
22
+ // last is joined with a newline — which is exactly "cells with tabs, rows
23
+ // with newlines" for a table, and plain paragraphs everywhere else.
24
+ //
25
+ // **Losing the selection is a message, not a poll.** `takeVisibleSelection`
26
+ // tells the previous owner it is no longer showing one, and `<textinput>`
27
+ // answers it too — so a drag across a document collapses the highlight in
28
+ // the field beside it, and vice versa, without either of them knowing the
29
+ // other exists.
30
+
31
+ import { callHandler } from './errors.js';
32
+ import { lastInputTime } from './inputtime.js';
33
+ import { ctrlChordLetter } from './keysyms.js';
34
+ import { codePoints, wordRangeAt } from './textrange.js';
35
+ import { tint } from './styles.js';
36
+
37
+ // --- who is showing a selection ------------------------------------------
38
+ //
39
+ // One node per app: a `<textinput>`, or the surface below. The registry is
40
+ // what makes "two selectable surfaces cannot both claim the visible
41
+ // selection" a property of the system rather than of every surface's good
42
+ // behaviour — the loser is *told*, so it has nothing to check.
43
+
44
+ const owners = new WeakMap();
45
+
46
+ /** This node is now showing the app's selection. The previous owner, if it
47
+ * was another node, is told to stop. */
48
+ export function takeVisibleSelection(node) {
49
+ const app = node?.app;
50
+ if (!app) return;
51
+ const previous = owners.get(app);
52
+ if (previous === node) return;
53
+ owners.set(app, node);
54
+ if (previous && !previous.destroyed) previous._selectionLost();
55
+ }
56
+
57
+ /** Give it up without telling anybody: the selection went away on its own. */
58
+ export function dropVisibleSelection(node) {
59
+ const app = node?.app;
60
+ if (app && owners.get(app) === node) owners.delete(app);
61
+ }
62
+
63
+ /** The node showing the app's selection, or null. */
64
+ export function visibleSelectionOwner(app) {
65
+ const node = owners.get(app);
66
+ if (!node) return null;
67
+ if (node.destroyed) {
68
+ owners.delete(app);
69
+ return null;
70
+ }
71
+ return node;
72
+ }
73
+
74
+ // --- finding the surface --------------------------------------------------
75
+
76
+ /**
77
+ * The selection this node's presses belong to: the nearest `selectable`
78
+ * ancestor, or none at all once something on the way up has opted its
79
+ * subtree out.
80
+ *
81
+ * `selectable={false}` is CSS's `user-select: none` and the reason it is
82
+ * checked first: a button's label, a list's bullet or a table's chrome sits
83
+ * inside a document and is not part of it.
84
+ */
85
+ export function selectionSurfaceOf(node) {
86
+ for (let n = node; n; n = n.parent) {
87
+ if (n.props?.selectable === false) return null;
88
+ // an editor answers its own presses; a document above it never sees them
89
+ if (n.hasOwnSelection) return null;
90
+ if (n._textSelection) return n._textSelection;
91
+ if (n.isWindow) break;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /** Elements that answer for their own text, in document order, skipping
97
+ * everything that has opted out and everything with a selection of its own. */
98
+ function participants(surface) {
99
+ const out = [];
100
+ const walk = (node) => {
101
+ for (const child of node.children) {
102
+ if (child.props?.selectable === false) continue;
103
+ // an editor keeps its own selection: a document around it does not get
104
+ // to light up half a field the user is typing in
105
+ if (child.hasOwnSelection) continue;
106
+ if (child.hidden || child.style?.display === 'none') continue;
107
+ // a nested surface owns its subtree, the way the nearest `selectable`
108
+ // ancestor owns a press
109
+ if (child._textSelection) continue;
110
+ if (child.textContent?.() != null) out.push(child);
111
+ else walk(child);
112
+ }
113
+ };
114
+ walk(surface);
115
+ return out;
116
+ }
117
+
118
+ /** How far a point is from a rect, as a score that ranks a miss on the wrong
119
+ * line below any miss on the right one — dragging into the margin should
120
+ * reach the paragraph beside the pointer, not the one it is nearest to. */
121
+ function distanceScore(rect, x, y) {
122
+ const dy =
123
+ y < rect.y
124
+ ? rect.y - y
125
+ : y > rect.y + rect.height
126
+ ? y - rect.y - rect.height
127
+ : 0;
128
+ const dx =
129
+ x < rect.x
130
+ ? rect.x - x
131
+ : x > rect.x + rect.width
132
+ ? x - rect.x - rect.width
133
+ : 0;
134
+ return dy * 4096 + dx;
135
+ }
136
+
137
+ /**
138
+ * Two participants on one line are cells; one that begins below the other is
139
+ * a new row. Derived from the boxes rather than from the elements, because
140
+ * the layout is the only thing here that knows which it is.
141
+ */
142
+ function separatorBetween(previous, next) {
143
+ const above = previous.abs;
144
+ return next.abs.y >= above.y + above.height - 1 ? '\n' : '\t';
145
+ }
146
+
147
+ /**
148
+ * The selection of one surface. Lives on the node as `_selection` and is
149
+ * created by the `selectable` prop; everything public about it is reached
150
+ * through the node (`node.selectAll()`, `node.selectedText()`, ...).
151
+ */
152
+ export class TextSelection {
153
+ constructor(node) {
154
+ this.node = node;
155
+ // { node, index } each, in code points — an unordered pair, because which
156
+ // end moves is the whole difference between extending and starting over
157
+ this.anchor = null;
158
+ this.focus = null;
159
+ // 'char' | 'word' | 'block', from the click count that started the drag.
160
+ // Kept for the whole drag: a selection begun on a double click keeps
161
+ // snapping to words as it grows, which is what every text view does.
162
+ this.granularity = 'char';
163
+ this.dragging = false;
164
+ // node -> { start, end, color }, so a change can repaint what moved
165
+ this.ranges = new Map();
166
+ }
167
+
168
+ // --- gestures ----------------------------------------------------------
169
+
170
+ press(ev) {
171
+ if (ev.button !== 1) return;
172
+ const at = this.positionAt(ev.x, ev.y);
173
+ if (!at) return;
174
+ this.granularity =
175
+ ev.detail >= 3 ? 'block' : ev.detail === 2 ? 'word' : 'char';
176
+ // shift+click extends the selection that is already there, the way it
177
+ // does in a field — the anchor stays where it was
178
+ if (ev.shiftKey && this.anchor) this.focus = at;
179
+ else {
180
+ this.anchor = at;
181
+ this.focus = at;
182
+ }
183
+ this.dragging = true;
184
+ this.apply();
185
+ // A word or a block is complete at the press: there is a selection on
186
+ // screen before the button comes up, so PRIMARY has something to own
187
+ // and the gesture has already answered.
188
+ if (this.granularity !== 'char') this.own();
189
+ }
190
+
191
+ drag(ev) {
192
+ if (!this.dragging) return;
193
+ const at = this.positionAt(ev.x, ev.y);
194
+ if (!at) return;
195
+ this.focus = at;
196
+ this.apply();
197
+ }
198
+
199
+ release() {
200
+ if (!this.dragging) return;
201
+ this.dragging = false;
202
+ this.own();
203
+ }
204
+
205
+ /**
206
+ * Ctrl+A and Ctrl+C, run as this element's default action — which is why a
207
+ * surface is a focus target (a11y.js): the copy has to have somewhere to
208
+ * arrive. Nothing else is bound. A read-only document has no caret, so
209
+ * shift+arrows would be caret browsing, which is a mode rather than a
210
+ * default.
211
+ */
212
+ keyDown(ev) {
213
+ if (!ev.ctrlKey) return;
214
+ const letter = ctrlChordLetter(ev);
215
+ if (letter === 0x61 /* a */) {
216
+ this.selectAll();
217
+ ev.preventDefault();
218
+ } else if (letter === 0x63 /* c */) {
219
+ this.copy('CLIPBOARD');
220
+ ev.preventDefault();
221
+ }
222
+ }
223
+
224
+ // --- the selection itself ----------------------------------------------
225
+
226
+ /** The participant and index nearest a point in window coordinates. */
227
+ positionAt(x, y) {
228
+ let best = null;
229
+ let bestScore = Infinity;
230
+ for (const node of participants(this.node)) {
231
+ const score = distanceScore(node.abs, x, y);
232
+ if (score < bestScore) {
233
+ bestScore = score;
234
+ best = node;
235
+ }
236
+ }
237
+ if (!best) return null;
238
+ return { node: best, index: best.textIndexAt(x, y) };
239
+ }
240
+
241
+ /** Anchor and focus in document order, resolved against the live tree, or
242
+ * null when there is nothing selected (or an end has gone away). */
243
+ ordered() {
244
+ if (!this.anchor || !this.focus) return null;
245
+ const nodes = participants(this.node);
246
+ const ai = nodes.indexOf(this.anchor.node);
247
+ const fi = nodes.indexOf(this.focus.node);
248
+ if (ai < 0 || fi < 0) return null;
249
+ const a = { at: ai, index: this.anchor.index };
250
+ const f = { at: fi, index: this.focus.index };
251
+ const forward = ai < fi || (ai === fi && a.index <= f.index);
252
+ const [start, end] = forward ? [a, f] : [f, a];
253
+ if (this.granularity === 'char') return { nodes, start, end };
254
+ // A word- or block-granular drag snaps both ends outwards, so the
255
+ // selection is always whole words even where the pointer is mid-word.
256
+ const startChars = codePoints(nodes[start.at].textContent() ?? '');
257
+ const endChars = codePoints(nodes[end.at].textContent() ?? '');
258
+ if (this.granularity === 'block') {
259
+ start.index = 0;
260
+ end.index = endChars.length;
261
+ } else {
262
+ // The far end is probed one character back, because an index is a
263
+ // boundary rather than a character: a focus that has just reached the
264
+ // start of the next word belongs to the word it came from, which is
265
+ // what a drag feels like. Unless there is nothing to come from — a
266
+ // double click has both ends on the same boundary.
267
+ const collapsed = end.at === start.at && end.index === start.index;
268
+ const probe = collapsed ? end.index : Math.max(0, end.index - 1);
269
+ start.index = wordRangeAt(startChars, start.index)[0];
270
+ end.index = wordRangeAt(endChars, probe)[1];
271
+ }
272
+ return { nodes, start, end };
273
+ }
274
+
275
+ /** node -> [start, end) for everything the selection covers. */
276
+ rangesOf(ordered) {
277
+ const out = new Map();
278
+ if (!ordered) return out;
279
+ const { nodes, start, end } = ordered;
280
+ for (let i = start.at; i <= end.at; i++) {
281
+ const node = nodes[i];
282
+ const length = codePoints(node.textContent() ?? '').length;
283
+ const from = i === start.at ? start.index : 0;
284
+ const to = i === end.at ? end.index : length;
285
+ if (to > from) out.set(node, [from, to]);
286
+ }
287
+ return out;
288
+ }
289
+
290
+ get isCollapsed() {
291
+ for (const [, [from, to]] of this.ranges) if (to > from) return false;
292
+ return true;
293
+ }
294
+
295
+ /**
296
+ * Push the ranges down to the elements that paint them, and repaint only
297
+ * the ones whose range moved. Every route to a new selection ends here —
298
+ * the drag, the keys, and the programmatic `setSelection`.
299
+ */
300
+ apply() {
301
+ const color =
302
+ this.node.props.selectionColor ?? tint(this.node.theme.accent, 0.35);
303
+ const next = this.rangesOf(this.ordered());
304
+ let changed = false;
305
+ for (const [node, range] of next) {
306
+ const before = node._selRange;
307
+ if (before && before.start === range[0] && before.end === range[1]) {
308
+ continue;
309
+ }
310
+ node._selRange = { start: range[0], end: range[1], color };
311
+ node.invalidate(false, node, 'selection');
312
+ // the same push a `<textinput>` makes when its selection moves: what
313
+ // is lit is what a screen reader should be reading (#288's seam)
314
+ node.notifyA11yTextChanged?.();
315
+ changed = true;
316
+ }
317
+ for (const node of this.ranges.keys()) {
318
+ if (next.has(node)) continue;
319
+ changed = true;
320
+ if (node.destroyed) continue;
321
+ node._selRange = null;
322
+ node.invalidate(false, node, 'selection');
323
+ node.notifyA11yTextChanged?.();
324
+ }
325
+ this.ranges = next;
326
+ if (!changed) return;
327
+ if (this.isCollapsed) dropVisibleSelection(this.node);
328
+ else takeVisibleSelection(this.node);
329
+ const handler = this.node.props.onSelectionChange;
330
+ if (handler) {
331
+ callHandler(this.node, 'onSelectionChange', handler, {
332
+ type: 'selectionChange',
333
+ target: this.node,
334
+ currentTarget: this.node,
335
+ text: this.text(),
336
+ isCollapsed: this.isCollapsed,
337
+ });
338
+ }
339
+ }
340
+
341
+ /** Everything in this surface, and PRIMARY with it — the same rule the
342
+ * mouse gestures follow, and what makes a middle-click paste after Ctrl+A
343
+ * paste what is on screen. */
344
+ selectAll() {
345
+ const nodes = participants(this.node);
346
+ if (!nodes.length) return;
347
+ const last = nodes[nodes.length - 1];
348
+ this.granularity = 'char';
349
+ this.anchor = { node: nodes[0], index: 0 };
350
+ this.focus = {
351
+ node: last,
352
+ index: codePoints(last.textContent() ?? '').length,
353
+ };
354
+ this.apply();
355
+ this.own();
356
+ }
357
+
358
+ clear() {
359
+ this.anchor = null;
360
+ this.focus = null;
361
+ this.dragging = false;
362
+ this.apply();
363
+ }
364
+
365
+ /** Set both ends by hand: `{ node, index }` each, in code points. */
366
+ setSelection(anchor, focus) {
367
+ this.granularity = 'char';
368
+ this.anchor = anchor ? { ...anchor } : null;
369
+ this.focus = focus ? { ...focus } : anchor ? { ...anchor } : null;
370
+ this.apply();
371
+ }
372
+
373
+ /**
374
+ * What a copy puts on the clipboard. Each participant contributes the code
375
+ * points the selection covers, joined by what the layout says they are —
376
+ * see `separatorBetween`.
377
+ */
378
+ text() {
379
+ const ordered = this.ordered();
380
+ if (!ordered) return '';
381
+ const ranges = this.rangesOf(ordered);
382
+ let out = '';
383
+ let previous = null;
384
+ for (const [node, [from, to]] of ranges) {
385
+ if (previous) out += separatorBetween(previous, node);
386
+ out += codePoints(node.textContent() ?? '')
387
+ .slice(from, to)
388
+ .join('');
389
+ previous = node;
390
+ }
391
+ return out;
392
+ }
393
+
394
+ /** Take PRIMARY, which in X is what "there is a selection here" means to
395
+ * every other application on the display. */
396
+ own() {
397
+ if (this.isCollapsed) return;
398
+ takeVisibleSelection(this.node);
399
+ this.copy('PRIMARY');
400
+ }
401
+
402
+ copy(selection) {
403
+ const text = this.text();
404
+ if (!text) return;
405
+ const clipboard = this.node.app?.clipboard;
406
+ if (!clipboard) return; // ntk < 5.4.0: no selection machinery to take
407
+ clipboard
408
+ // ICCCM 2.1: stamped with the input that caused the copy, so a race
409
+ // with another client copying at the same moment is orderable
410
+ .write(text, { selection, time: lastInputTime(this.node.app) })
411
+ .catch((err) => {
412
+ console.warn(
413
+ `react-x11: could not take the ${selection} selection: ${err?.message ?? err}`,
414
+ );
415
+ });
416
+ }
417
+
418
+ /** Somebody else is showing the app's selection now. */
419
+ lost() {
420
+ if (!this.anchor && !this.focus) return;
421
+ this.anchor = null;
422
+ this.focus = null;
423
+ this.dragging = false;
424
+ this.apply();
425
+ }
426
+
427
+ destroy() {
428
+ dropVisibleSelection(this.node);
429
+ for (const node of this.ranges.keys()) {
430
+ if (!node.destroyed) {
431
+ node._selRange = null;
432
+ node.invalidate(false, node, 'selection');
433
+ }
434
+ }
435
+ this.ranges = new Map();
436
+ this.anchor = null;
437
+ this.focus = null;
438
+ }
439
+ }
@@ -0,0 +1,63 @@
1
+ // The seam between the renderer and the optional debug module.
2
+ //
3
+ // Reconciler.js and nodes.js import this file, so it has to stay safe to
4
+ // bundle for the browser playground: no node builtins, no side effects,
5
+ // nothing but a registry of live connections and a pair of hook slots. The
6
+ // heavy half (protocol parsing, sinks, file writing) lives in debug.js and
7
+ // is only loaded when the user imports `react-x11/debug` or sets
8
+ // REACT_X11_TRACE.
9
+
10
+ /** ntk Apps the renderer currently draws through. */
11
+ const apps = new Set();
12
+ const appListeners = new Set();
13
+
14
+ export function registerApp(app) {
15
+ if (!app || apps.has(app)) return;
16
+ apps.add(app);
17
+ for (const fn of [...appListeners]) fn(app);
18
+ }
19
+
20
+ /** A root that owned its connection closed it; stop offering it to traces. */
21
+ export function unregisterApp(app) {
22
+ apps.delete(app);
23
+ }
24
+
25
+ /**
26
+ * The connections the renderer currently draws through.
27
+ *
28
+ * A snapshot, in registration order. `activate.js` uses it to answer "which
29
+ * server issued this XID" when a caller names a window by number and nothing
30
+ * else — one process usually has exactly one connection, and when it has
31
+ * several a bare XID is genuinely ambiguous.
32
+ */
33
+ export function liveApps() {
34
+ return [...apps];
35
+ }
36
+
37
+ /**
38
+ * Call `fn` for every current connection and every future one, until the
39
+ * returned function is called. This is how a trace with no explicit `app`
40
+ * follows the renderer: `createRoot()` may not have connected yet when the
41
+ * trace starts.
42
+ */
43
+ export function onApp(fn) {
44
+ appListeners.add(fn);
45
+ for (const app of apps) fn(app);
46
+ return () => appListeners.delete(fn);
47
+ }
48
+
49
+ /**
50
+ * Hook slots the hot paths poll. A slot is null almost always, and the cost
51
+ * of an unset hook is one property read — which is the whole design: the
52
+ * frame loop must not pay for a tracer nobody started.
53
+ *
54
+ * - `frame({ root, rects, reasons, start, end })` — after a window painted.
55
+ * `rects` is the damage list, null for a full repaint; times come from
56
+ * `performance.now()`.
57
+ * - `commitStart()` / `commitEnd()` — around a React commit.
58
+ */
59
+ export const hooks = {
60
+ frame: null,
61
+ commitStart: null,
62
+ commitEnd: null,
63
+ };
@@ -0,0 +1,93 @@
1
+ // The type vocabulary, shared by the clipboard and drag and drop.
2
+ //
3
+ // The same logical payload arrives under different names depending on who
4
+ // sent it: files are text/uri-list (plus desktop-specific variants), GTK
5
+ // text is text/plain;charset=utf-8 / UTF8_STRING / STRING / COMPOUND_TEXT,
6
+ // Firefox links are UTF-16 text/x-moz-url, Chromium's are _NETSCAPE_URL.
7
+ // String equality on 'text/plain' misses GTK text entirely — normalising
8
+ // this once is the interop feature.
9
+ //
10
+ // It lives here rather than in dnd.js because a paste and a drop face the
11
+ // identical soup: the X selection a file manager offers on CLIPBOARD is the
12
+ // one it offers on XdndSelection. One vocabulary, two gestures — the same
13
+ // split GTK draws with GdkContentFormats and Qt with QMimeData.
14
+
15
+ /** Best-first text flavours, for `e.text` and the 'text' group. */
16
+ export const TEXT_TARGETS = [
17
+ 'text/plain;charset=utf-8',
18
+ 'UTF8_STRING',
19
+ 'text/plain',
20
+ 'STRING',
21
+ 'TEXT',
22
+ 'COMPOUND_TEXT',
23
+ ];
24
+
25
+ export const TYPE_GROUPS = {
26
+ files: [
27
+ 'text/uri-list',
28
+ 'application/x-kde4-urilist',
29
+ 'x-special/gnome-copied-files',
30
+ ],
31
+ uris: ['text/uri-list', 'text/x-moz-url', '_NETSCAPE_URL'],
32
+ text: TEXT_TARGETS,
33
+ };
34
+
35
+ /** Does the offered type list satisfy one accept entry — a semantic group
36
+ * ('files' | 'uris' | 'text') or a concrete type name? MIME names compare
37
+ * case-insensitively; X atom names like UTF8_STRING are exact. */
38
+ export function typeMatches(offered, want) {
39
+ const group = TYPE_GROUPS[want];
40
+ if (group) return group.some((t) => offered.includes(t));
41
+ if (offered.includes(want)) return true;
42
+ const lower = String(want).toLowerCase();
43
+ return offered.some((t) => t.toLowerCase() === lower);
44
+ }
45
+
46
+ /**
47
+ * A group name resolved against what is actually on offer: the first
48
+ * member of the group the owner can convert to, in the group's own
49
+ * preference order. A concrete type is returned unchanged, so callers can
50
+ * pass either without asking which they have.
51
+ */
52
+ export function resolveType(type, offered) {
53
+ const group = TYPE_GROUPS[type];
54
+ if (!group) return type;
55
+ return group.find((t) => offered.includes(t)) ?? type;
56
+ }
57
+
58
+ /** Bytes from a selection, decoded when the target says they are text.
59
+ * STRING is latin-1 by definition; everything else text-ish is UTF-8. */
60
+ export function decodeData(data, target) {
61
+ const textish =
62
+ TEXT_TARGETS.includes(target) || /^text\//i.test(String(target));
63
+ if (!textish) return data;
64
+ return data.toString(target === 'STRING' ? 'latin1' : 'utf8');
65
+ }
66
+
67
+ /**
68
+ * Parse a `text/uri-list` payload (RFC 2483): CRLF-separated, `#` lines are
69
+ * comments, URIs are percent-encoded. `path` is present only for `file:`
70
+ * URIs that are actually local — a remote `file://host/...` has no local
71
+ * path and must not pretend to.
72
+ */
73
+ export function parseUriList(text) {
74
+ const files = [];
75
+ for (const line of String(text).split(/\r?\n/)) {
76
+ const uri = line.trim();
77
+ if (!uri || uri.startsWith('#')) continue;
78
+ const entry = { uri };
79
+ try {
80
+ const url = new URL(uri);
81
+ if (
82
+ url.protocol === 'file:' &&
83
+ (url.hostname === '' || url.hostname === 'localhost')
84
+ ) {
85
+ entry.path = decodeURIComponent(url.pathname);
86
+ }
87
+ } catch {
88
+ // not a parseable URI — keep the raw line, claim no path
89
+ }
90
+ files.push(entry);
91
+ }
92
+ return files;
93
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * What the desktop looks like — light or dark, the accent colour, contrast
3
+ * and reduced motion. See docs/appearance.md.
4
+ */
5
+
6
+ import type { NtkApp } from './nodes.js';
7
+
8
+ /**
9
+ * `'no-preference'` is the desktop declining to say, which per the portal
10
+ * spec means **use your own default** — not "use light".
11
+ */
12
+ export type ColorScheme = 'light' | 'dark' | 'no-preference';
13
+
14
+ /** How the app should be pinned, or `'system'` to follow the desktop. */
15
+ export type ColorSchemePreference = 'system' | 'light' | 'dark';
16
+
17
+ /**
18
+ * Which rung of the ladder answered, or null if nothing has yet.
19
+ *
20
+ * `'cache'` is the answer this machine gave last time, read off disk before
21
+ * the first render so it does not start from the defaults. A real rung
22
+ * replaces it a moment later.
23
+ */
24
+ export type AppearanceSource =
25
+ 'portal' | 'xsettings' | 'macos' | 'cache' | null;
26
+
27
+ export interface SystemAppearance {
28
+ readonly colorScheme: ColorScheme;
29
+ /**
30
+ * `'#ed5b00'`, or **null** — most portal backends do not implement the
31
+ * accent colour, and XSETTINGS has no key for one at all. Fall back to your
32
+ * own brand colour rather than to grey.
33
+ */
34
+ readonly accent: string | null;
35
+ readonly contrast: 'normal' | 'high';
36
+ readonly reducedMotion: boolean;
37
+ /**
38
+ * Null distinguishes "nothing has answered yet" from a desktop that
39
+ * answered `'no-preference'`, and `'cache'` marks the remembered answer the
40
+ * first render starts from.
41
+ */
42
+ readonly source: AppearanceSource;
43
+ }
44
+
45
+ export interface SystemAppearanceOptions {
46
+ /**
47
+ * The ntk connection, so the XSETTINGS rung can run. Without one that rung
48
+ * is skipped and only the portal and macOS are consulted — which is what a
49
+ * call made before `createRoot()` resolves gets.
50
+ */
51
+ app?: NtkApp;
52
+ }
53
+
54
+ /**
55
+ * The desktop's appearance, resolved. Never rejects: a machine with no
56
+ * portal, no settings daemon and no Mac answers `'no-preference'` with a null
57
+ * `source`.
58
+ *
59
+ * The imperative twin of `useSystemAppearance()`, and a **verified** answer
60
+ * rather than the remembered one the first render starts from:
61
+ *
62
+ * ```ts
63
+ * const [root] = await Promise.all([createRoot(), systemAppearance()]);
64
+ * ```
65
+ *
66
+ * Most apps do not need this: the snapshot is seeded from the last run before
67
+ * anything renders. It is for the first launch on a machine, and for anything
68
+ * that must be exact rather than probably right.
69
+ */
70
+ export function systemAppearance(
71
+ options?: SystemAppearanceOptions,
72
+ ): Promise<SystemAppearance>;
73
+
74
+ /**
75
+ * What the desktop looks like, live — re-renders when the user changes it.
76
+ *
77
+ * ```tsx
78
+ * const { colorScheme, accent, reducedMotion } = useSystemAppearance();
79
+ * ```
80
+ *
81
+ * For the common case — an app that just wants to follow the desktop — reach
82
+ * for `<ThemeProvider value={light} dark={dark}>` instead.
83
+ */
84
+ export function useSystemAppearance(): SystemAppearance;