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.
- package/README.md +330 -81
- package/package.json +132 -14
- package/src/ClickToComponent.js +9 -2
- package/src/DevToolsIntegration.js +440 -32
- package/src/Reconciler.js +463 -165
- package/src/a11y.js +1299 -0
- package/src/acceleratorhooks.js +86 -0
- package/src/accelerators.js +128 -0
- package/src/activate.js +168 -0
- package/src/anchor.js +360 -0
- package/src/appcontext.js +161 -0
- package/src/appearance.js +748 -0
- package/src/appearancehooks.js +96 -0
- package/src/apphooks.js +73 -0
- package/src/application.js +748 -0
- package/src/atspi.js +1953 -0
- package/src/bus.js +545 -0
- package/src/bushooks.js +136 -0
- package/src/clientmessage.js +140 -0
- package/src/clipboard.js +121 -0
- package/src/components/Button.js +148 -33
- package/src/components/Checkbox.js +66 -32
- package/src/components/Dialog.js +96 -29
- package/src/components/FileDialog.js +491 -0
- package/src/components/Icon.js +329 -0
- package/src/components/Menu.js +797 -133
- package/src/components/PasswordInput.js +434 -0
- package/src/components/ProgressBar.js +91 -12
- package/src/components/Radio.js +78 -35
- package/src/components/Select.js +255 -65
- package/src/components/Slider.js +102 -41
- package/src/components/SplitPane.js +206 -0
- package/src/components/Switch.js +100 -22
- package/src/components/Table.js +529 -0
- package/src/components/Tabs.js +211 -0
- package/src/components/Tooltip.js +528 -54
- package/src/components/anchor.js +154 -107
- package/src/components/change.js +34 -0
- package/src/components/dnd.js +103 -0
- package/src/components/index.js +16 -3
- package/src/components/keys.js +17 -20
- package/src/components/scribble.js +247 -0
- package/src/components/theme.js +350 -42
- package/src/components/typeahead.js +12 -2
- package/src/compose.js +868 -0
- package/src/compositing.js +224 -0
- package/src/dbusmenu.js +384 -0
- package/src/debug.d.ts +63 -0
- package/src/debug.js +642 -0
- package/src/decorations.js +486 -0
- package/src/desktopsettings.js +209 -0
- package/src/desktopsettingshooks.js +62 -0
- package/src/dnd.js +1725 -0
- package/src/editmenu.js +272 -0
- package/src/errors.js +98 -0
- package/src/events.js +1406 -162
- package/src/extensions.js +45 -0
- package/src/filedialog.js +375 -0
- package/src/filedialoghooks.js +132 -0
- package/src/fonthooks.js +64 -0
- package/src/fonts.js +301 -0
- package/src/foreignnodes.js +519 -0
- package/src/frame/child.js +33 -0
- package/src/frame/childmain.js +274 -0
- package/src/frame/env.js +140 -0
- package/src/frame/index.js +444 -0
- package/src/frame/lifecycle.js +67 -0
- package/src/frame/protocol.js +179 -0
- package/src/frames.js +73 -0
- package/src/glbackend.js +114 -0
- package/src/glnodes.js +125 -67
- package/src/globalmenu.js +683 -0
- package/src/host.d.ts +88 -0
- package/src/host.js +42 -0
- package/src/idle.js +490 -0
- package/src/idlehooks.js +100 -0
- package/src/imagesource.js +349 -0
- package/src/index.d.ts +475 -0
- package/src/index.js +83 -9
- package/src/inputtime.js +206 -0
- package/src/jsx-dev-runtime.d.ts +21 -0
- package/src/jsx-dev-runtime.js +2 -0
- package/src/jsx-runtime.d.ts +39 -0
- package/src/jsx-runtime.js +6 -0
- package/src/keyboard.js +256 -0
- package/src/keyboardstate.js +278 -0
- package/src/keyboardstatehooks.js +58 -0
- package/src/keysyms.d.ts +149 -0
- package/src/keysyms.js +270 -0
- package/src/locale.js +170 -0
- package/src/localehooks.js +47 -0
- package/src/menuitem.js +223 -0
- package/src/node.d.ts +597 -0
- package/src/node.js +44 -0
- package/src/nodes.js +9546 -690
- package/src/ntk.d.ts +44 -0
- package/src/ntk.js +25 -0
- package/src/paintcache.js +366 -0
- package/src/palette.js +380 -0
- package/src/pastestate.js +66 -0
- package/src/portal.js +461 -0
- package/src/priority.js +26 -0
- package/src/refresh/index.d.ts +40 -0
- package/src/refresh/index.js +122 -0
- package/src/refresh/loader.d.ts +37 -0
- package/src/refresh/loader.js +401 -0
- package/src/refresh/register.d.ts +5 -0
- package/src/refresh/register.js +13 -0
- package/src/registry.js +232 -0
- package/src/scale.js +626 -0
- package/src/scalehooks.js +27 -0
- package/src/screencolor.js +640 -0
- package/src/screencolorhooks.js +101 -0
- package/src/screens.js +754 -0
- package/src/screenshooks.js +137 -0
- package/src/startup.js +302 -0
- package/src/style.d.ts +126 -0
- package/src/style.js +33 -0
- package/src/styles.js +1482 -6
- package/src/svgnodes.js +306 -0
- package/src/testing/a11y.js +484 -0
- package/src/testing/components.js +414 -0
- package/src/testing/events.js +407 -0
- package/src/testing/harness.js +455 -0
- package/src/testing/index.d.ts +558 -0
- package/src/testing/index.js +85 -0
- package/src/testing/mock-app.js +463 -0
- package/src/testing/pixels.js +152 -0
- package/src/testing/queries.js +224 -0
- package/src/textrange.js +83 -0
- package/src/textselection.js +439 -0
- package/src/trace-registry.js +63 -0
- package/src/transfer.js +93 -0
- package/src/types/appearance.d.ts +84 -0
- package/src/types/application.d.ts +173 -0
- package/src/types/components.d.ts +820 -0
- package/src/types/dbus.d.ts +177 -0
- package/src/types/elements.d.ts +897 -0
- package/src/types/events.d.ts +549 -0
- package/src/types/filedialog.d.ts +260 -0
- package/src/types/fonts.d.ts +124 -0
- package/src/types/frame.d.ts +146 -0
- package/src/types/globalmenu.d.ts +42 -0
- package/src/types/nodes.d.ts +199 -0
- package/src/types/screencolor.d.ts +84 -0
- package/src/types/style.d.ts +434 -0
- package/src/types/system.d.ts +287 -0
- package/src/windowid.js +151 -0
- package/src/windowstate.js +393 -0
- package/src/xsettings.js +336 -0
- package/src/yoga.d.ts +55 -0
- package/src/yoga.js +135 -0
- package/src/components/Canvas3D.js +0 -28
- package/src/geometry3d.js +0 -223
- package/src/pointer3d.js +0 -158
- package/src/raycast3d.js +0 -146
- package/src/richnodes.js +0 -436
- 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
|
+
};
|
package/src/transfer.js
ADDED
|
@@ -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;
|