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,486 @@
|
|
|
1
|
+
// The two decorations that are not a colour: the gradient behind a box
|
|
2
|
+
// (`backgroundImage`) and the shadow under it (`boxShadow`). Both are pure
|
|
3
|
+
// here — strings in, numbers out — so the renderer half in `nodes.js` is
|
|
4
|
+
// only geometry and compositing, and the parsing can be tested without a
|
|
5
|
+
// server (issue #345).
|
|
6
|
+
//
|
|
7
|
+
// ## Why they are strings
|
|
8
|
+
//
|
|
9
|
+
// Every other paint property in this vocabulary is a number or a colour,
|
|
10
|
+
// and an object (`{ from, to, angle }`) would have been the house shape.
|
|
11
|
+
// These two are strings because a gradient and a shadow are the two style
|
|
12
|
+
// values people already know by heart in CSS's spelling, because a state
|
|
13
|
+
// block wants to overwrite the whole decoration at once rather than merge
|
|
14
|
+
// half an object into it, and because `linear-gradient(#2b5876, #4e4376)`
|
|
15
|
+
// pastes out of a design tool. Parsing is memoized per string, so the cost
|
|
16
|
+
// of the choice is one Map lookup per painted frame.
|
|
17
|
+
//
|
|
18
|
+
// ## What is deliberately not here
|
|
19
|
+
//
|
|
20
|
+
// - `radial-gradient` / `conic-gradient`: RENDER has both and ntk exposes
|
|
21
|
+
// them, but CSS's sizing keywords (`closest-side`, `farthest-corner`, an
|
|
22
|
+
// ellipse with two radii and a position) are most of the work and none of
|
|
23
|
+
// the demand. `linear-gradient` is what a header, a card and a selected
|
|
24
|
+
// row are made of.
|
|
25
|
+
// - `inset` shadows: a different drawing (the coverage is the box *minus*
|
|
26
|
+
// the blurred rect) and a different damage story, since an inset shadow
|
|
27
|
+
// never leaves the node. Rejected loudly rather than ignored — see
|
|
28
|
+
// `parseBoxShadow` — because silently painting an outer shadow where an
|
|
29
|
+
// inner one was asked for is a bug the author cannot see the cause of.
|
|
30
|
+
// - `textShadow`: per glyph, through the glyph cache, and much rarer in a
|
|
31
|
+
// desktop UI.
|
|
32
|
+
|
|
33
|
+
/** A number, with or without the `px` CSS wants and this vocabulary does not. */
|
|
34
|
+
const LENGTH = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:px)?$/i;
|
|
35
|
+
/** …and the same thing as a percentage, which colour stops also take. */
|
|
36
|
+
const PERCENT = /^[+-]?(?:\d+\.?\d*|\.\d+)%$/;
|
|
37
|
+
const ANGLE = /^([+-]?(?:\d+\.?\d*|\.\d+))(deg|grad|rad|turn)$/i;
|
|
38
|
+
|
|
39
|
+
const TO_ANGLE = { top: 0, right: 90, bottom: 180, left: 270 };
|
|
40
|
+
const VERTICAL = new Set(['top', 'bottom']);
|
|
41
|
+
const HORIZONTAL = new Set(['left', 'right']);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Split on `,` or on whitespace, but only at the top level: a stop is
|
|
45
|
+
* `rgba(0, 0, 0, .4)` as often as it is `#333`, and every naive split on
|
|
46
|
+
* this vocabulary gets that wrong.
|
|
47
|
+
*/
|
|
48
|
+
function splitTop(text, byComma) {
|
|
49
|
+
const out = [];
|
|
50
|
+
let depth = 0;
|
|
51
|
+
let start = 0;
|
|
52
|
+
const push = (end) => {
|
|
53
|
+
const piece = text.slice(start, end).trim();
|
|
54
|
+
if (byComma || piece) out.push(piece);
|
|
55
|
+
};
|
|
56
|
+
for (let i = 0; i < text.length; i++) {
|
|
57
|
+
const ch = text[i];
|
|
58
|
+
if (ch === '(') depth++;
|
|
59
|
+
else if (ch === ')') depth--;
|
|
60
|
+
else if (
|
|
61
|
+
depth === 0 &&
|
|
62
|
+
(byComma ? ch === ',' : ch === ' ' || ch === '\t')
|
|
63
|
+
) {
|
|
64
|
+
push(i);
|
|
65
|
+
start = i + 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
push(text.length);
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const number = (text) => parseFloat(text);
|
|
73
|
+
|
|
74
|
+
function toDegrees(value, unit) {
|
|
75
|
+
switch (unit.toLowerCase()) {
|
|
76
|
+
case 'deg':
|
|
77
|
+
return value;
|
|
78
|
+
case 'grad':
|
|
79
|
+
return (value * 360) / 400;
|
|
80
|
+
case 'rad':
|
|
81
|
+
return (value * 180) / Math.PI;
|
|
82
|
+
default:
|
|
83
|
+
return value * 360; // turn
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* `linear-gradient(...)` → `{ angle, corner, stops }`, or null for `none`
|
|
89
|
+
* and for a value that is not a gradient at all.
|
|
90
|
+
*
|
|
91
|
+
* `angle` is CSS's: degrees clockwise from "up", so `180deg` is `to bottom`,
|
|
92
|
+
* which is also the default when a value names no direction. `corner` is
|
|
93
|
+
* `'top right'` and friends, which cannot become an angle until the box is
|
|
94
|
+
* known — the corner keywords are defined so that the gradient line is
|
|
95
|
+
* perpendicular to the box's *other* diagonal, and that depends on the
|
|
96
|
+
* aspect ratio (see `linearGradientGeometry`).
|
|
97
|
+
*
|
|
98
|
+
* Stops keep their authored position (`{ value, unit }`) or `null` for one
|
|
99
|
+
* that was left out; distributing those needs the gradient line's length,
|
|
100
|
+
* which is again the box's business.
|
|
101
|
+
*
|
|
102
|
+
* Throws on a malformed value rather than dropping it: a gradient that does
|
|
103
|
+
* not paint is a blank panel, and "why is my header empty" is a much worse
|
|
104
|
+
* afternoon than an error naming the property.
|
|
105
|
+
*/
|
|
106
|
+
export function parseLinearGradient(value) {
|
|
107
|
+
if (typeof value !== 'string') return null;
|
|
108
|
+
const text = value.trim();
|
|
109
|
+
if (!text || text === 'none') return null;
|
|
110
|
+
const open = text.indexOf('(');
|
|
111
|
+
const name = open === -1 ? text : text.slice(0, open).trim();
|
|
112
|
+
if (name !== 'linear-gradient') {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`react-x11: backgroundImage ${JSON.stringify(value)} is not supported ` +
|
|
115
|
+
"(expected linear-gradient(…) or 'none'). url() images belong on " +
|
|
116
|
+
'<image src>, and a radial or conic gradient on a <canvas onDraw>.',
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (!text.endsWith(')')) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`react-x11: backgroundImage ${JSON.stringify(value)} is missing its ` +
|
|
122
|
+
'closing parenthesis',
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const parts = splitTop(text.slice(open + 1, -1), true);
|
|
126
|
+
let angle = null;
|
|
127
|
+
let corner = null;
|
|
128
|
+
const first = parts[0] ?? '';
|
|
129
|
+
const asAngle = ANGLE.exec(first);
|
|
130
|
+
if (asAngle) {
|
|
131
|
+
angle = toDegrees(number(asAngle[1]), asAngle[2]);
|
|
132
|
+
parts.shift();
|
|
133
|
+
} else if (first.startsWith('to ') || first === 'to') {
|
|
134
|
+
const sides = splitTop(first.slice(2), false);
|
|
135
|
+
corner = readSides(sides, value);
|
|
136
|
+
if (typeof corner === 'number') {
|
|
137
|
+
angle = corner;
|
|
138
|
+
corner = null;
|
|
139
|
+
}
|
|
140
|
+
parts.shift();
|
|
141
|
+
}
|
|
142
|
+
const stops = parts.map((part) => readStop(part, value));
|
|
143
|
+
if (stops.length < 2) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`react-x11: backgroundImage ${JSON.stringify(value)} needs at least ` +
|
|
146
|
+
'two colour stops',
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
return { angle: angle ?? (corner ? null : TO_ANGLE.bottom), corner, stops };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** `to bottom` → 180; `to bottom right` → the corner it names. */
|
|
153
|
+
function readSides(sides, value) {
|
|
154
|
+
const bad = () =>
|
|
155
|
+
new Error(
|
|
156
|
+
`react-x11: backgroundImage ${JSON.stringify(value)} has an unusable ` +
|
|
157
|
+
"direction (expected 'to top', 'to bottom right', … or an angle " +
|
|
158
|
+
"like '135deg')",
|
|
159
|
+
);
|
|
160
|
+
if (sides.length === 1) {
|
|
161
|
+
const one = TO_ANGLE[sides[0]];
|
|
162
|
+
if (one === undefined) throw bad();
|
|
163
|
+
return one;
|
|
164
|
+
}
|
|
165
|
+
if (sides.length !== 2) throw bad();
|
|
166
|
+
const [a, b] = sides;
|
|
167
|
+
const vertical = VERTICAL.has(a) ? a : VERTICAL.has(b) ? b : null;
|
|
168
|
+
const horizontal = HORIZONTAL.has(a) ? a : HORIZONTAL.has(b) ? b : null;
|
|
169
|
+
if (!vertical || !horizontal) throw bad();
|
|
170
|
+
return `${vertical} ${horizontal}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** `#333`, `$accent 40%`, `rgba(0, 0, 0, .4) 12px`. */
|
|
174
|
+
function readStop(part, value) {
|
|
175
|
+
if (!part) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`react-x11: backgroundImage ${JSON.stringify(value)} has an empty ` +
|
|
178
|
+
'colour stop',
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
const pieces = splitTop(part, false);
|
|
182
|
+
const last = pieces[pieces.length - 1];
|
|
183
|
+
if (pieces.length === 1 && (PERCENT.test(last) || LENGTH.test(last))) {
|
|
184
|
+
// CSS's *colour hint* — a bare position between two stops that moves the
|
|
185
|
+
// midpoint of the blend. It is a curve, not a stop, and RENDER has no
|
|
186
|
+
// way to express one; two stops either side say the same thing.
|
|
187
|
+
throw new Error(
|
|
188
|
+
`react-x11: backgroundImage ${JSON.stringify(value)} uses a colour ` +
|
|
189
|
+
`hint ("${last}"), which is not supported — name the colour at that ` +
|
|
190
|
+
'position instead.',
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (pieces.length > 1 && (PERCENT.test(last) || LENGTH.test(last))) {
|
|
194
|
+
pieces.pop();
|
|
195
|
+
return {
|
|
196
|
+
color: pieces.join(' '),
|
|
197
|
+
position: { value: number(last), unit: PERCENT.test(last) ? '%' : 'px' },
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
return { color: part, position: null };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Where the gradient line runs for this box, and the stops along it — the
|
|
205
|
+
* whole of what `ctx.createLinearGradient` and `addColorStop` need.
|
|
206
|
+
*
|
|
207
|
+
* CSS's geometry, which is not the obvious one: the line passes through the
|
|
208
|
+
* box's centre at the given angle, and its length is the box's *projection*
|
|
209
|
+
* onto it (`|w·sinθ| + |h·cosθ|`), so that the two corners nearest each end
|
|
210
|
+
* land exactly on the end colours. That is what makes `135deg` on a wide
|
|
211
|
+
* header look the way it does in a browser instead of running out a third of
|
|
212
|
+
* the way across.
|
|
213
|
+
*
|
|
214
|
+
* ### The padding, and why it is not cosmetic
|
|
215
|
+
*
|
|
216
|
+
* Past its last stop an XRender gradient is **transparent**, not clamped:
|
|
217
|
+
* `RepeatPad` is a picture attribute the gradient path does not set today
|
|
218
|
+
* (sidorares/ntk#271, and the `TODO` in ntk's own `CanvasGradient`). CSS and
|
|
219
|
+
* canvas both clamp. So the line is extended by `pad` device pixels at each
|
|
220
|
+
* end and the stop offsets are remapped onto the longer line, with the end
|
|
221
|
+
* colours pinned at 0 and 1 — which *is* pad semantics, expressed in stops.
|
|
222
|
+
* Two pixels would do for the float noise at a corner; the width here also
|
|
223
|
+
* covers a caller that rounds the rect it fills after asking for the
|
|
224
|
+
* geometry.
|
|
225
|
+
*
|
|
226
|
+
* Note that this cannot be caught by a pixel test against the in-process X
|
|
227
|
+
* server: node-x11's RENDER pads by construction (`gradientAt` clamps to the
|
|
228
|
+
* edge stops), where a real server does not. `test/decorations.test.js`
|
|
229
|
+
* asserts the stops instead, which is the part that is ours.
|
|
230
|
+
*
|
|
231
|
+
* Returns null for a box with no area, which has no gradient line at all.
|
|
232
|
+
*/
|
|
233
|
+
export function linearGradientGeometry(spec, rect, pad = 4) {
|
|
234
|
+
const { width, height } = rect;
|
|
235
|
+
if (!(width > 0) || !(height > 0)) return null;
|
|
236
|
+
const degrees = spec.corner
|
|
237
|
+
? cornerAngle(spec.corner, width, height)
|
|
238
|
+
: spec.angle;
|
|
239
|
+
const theta = (degrees * Math.PI) / 180;
|
|
240
|
+
// 0° points up and angles run clockwise, so the direction vector is
|
|
241
|
+
// (sin, -cos) in a coordinate system whose y grows downwards
|
|
242
|
+
const dx = Math.sin(theta);
|
|
243
|
+
const dy = -Math.cos(theta);
|
|
244
|
+
const length = Math.abs(width * dx) + Math.abs(height * dy);
|
|
245
|
+
if (!(length > 0)) return null;
|
|
246
|
+
const cx = rect.x + width / 2;
|
|
247
|
+
const cy = rect.y + height / 2;
|
|
248
|
+
const half = length / 2;
|
|
249
|
+
const offsets = resolveStopOffsets(spec.stops, length);
|
|
250
|
+
const total = length + pad * 2;
|
|
251
|
+
const stops = [];
|
|
252
|
+
if (pad > 0) stops.push([0, spec.stops[0].color]);
|
|
253
|
+
for (let i = 0; i < spec.stops.length; i++) {
|
|
254
|
+
stops.push([(pad + offsets[i] * length) / total, spec.stops[i].color]);
|
|
255
|
+
}
|
|
256
|
+
if (pad > 0) stops.push([1, spec.stops[spec.stops.length - 1].color]);
|
|
257
|
+
return {
|
|
258
|
+
x0: cx - dx * (half + pad),
|
|
259
|
+
y0: cy - dy * (half + pad),
|
|
260
|
+
x1: cx + dx * (half + pad),
|
|
261
|
+
y1: cy + dy * (half + pad),
|
|
262
|
+
stops,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The magic angle behind `to bottom right`: the gradient line is turned so
|
|
268
|
+
* that a perpendicular through it passes through the two *other* corners, so
|
|
269
|
+
* a square gives 45° and a wide box tends towards `to right`.
|
|
270
|
+
*/
|
|
271
|
+
function cornerAngle(corner, width, height) {
|
|
272
|
+
const base = (Math.atan2(width, height) * 180) / Math.PI;
|
|
273
|
+
switch (corner) {
|
|
274
|
+
case 'top right':
|
|
275
|
+
return base;
|
|
276
|
+
case 'bottom right':
|
|
277
|
+
return 180 - base;
|
|
278
|
+
case 'bottom left':
|
|
279
|
+
return 180 + base;
|
|
280
|
+
default: // top left
|
|
281
|
+
return 360 - base;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Stop positions as fractions of the gradient line, CSS's rules: a missing
|
|
287
|
+
* first is 0 and a missing last is 1, a run of missing ones is spread evenly
|
|
288
|
+
* between the neighbours that do have a position, and a position that goes
|
|
289
|
+
* backwards is pulled up to the largest so far (which is how a hard colour
|
|
290
|
+
* break — two stops at the same offset — stays expressible).
|
|
291
|
+
*/
|
|
292
|
+
function resolveStopOffsets(stops, length) {
|
|
293
|
+
const out = stops.map(({ position }) => {
|
|
294
|
+
if (!position) return null;
|
|
295
|
+
return position.unit === '%'
|
|
296
|
+
? position.value / 100
|
|
297
|
+
: position.value / length;
|
|
298
|
+
});
|
|
299
|
+
if (out[0] === null) out[0] = 0;
|
|
300
|
+
if (out[out.length - 1] === null) out[out.length - 1] = 1;
|
|
301
|
+
for (let i = 1; i < out.length; i++) {
|
|
302
|
+
if (out[i] !== null) continue;
|
|
303
|
+
let end = i + 1;
|
|
304
|
+
while (out[end] === null) end++;
|
|
305
|
+
const from = out[i - 1];
|
|
306
|
+
const step = (out[end] - from) / (end - i + 1);
|
|
307
|
+
for (let j = i; j < end; j++) out[j] = from + step * (j - i + 1);
|
|
308
|
+
i = end - 1;
|
|
309
|
+
}
|
|
310
|
+
let highest = 0;
|
|
311
|
+
for (let i = 0; i < out.length; i++) {
|
|
312
|
+
highest = out[i] = Math.max(highest, Math.min(1, Math.max(0, out[i])));
|
|
313
|
+
}
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* `boxShadow` → a list of `{ dx, dy, blur, spread, color }`, painted in the
|
|
319
|
+
* order CSS paints them: first in the list is nearest the front, so the
|
|
320
|
+
* renderer draws the list back to front.
|
|
321
|
+
*
|
|
322
|
+
* `color` may be null, which is CSS's `currentColor` — the node's own ink,
|
|
323
|
+
* which the caller resolves because only it knows the cascade.
|
|
324
|
+
*
|
|
325
|
+
* Throws on `inset` and on anything unparseable; see the note at the top of
|
|
326
|
+
* this file for why a shadow that silently does the wrong thing is worse
|
|
327
|
+
* than one that refuses.
|
|
328
|
+
*/
|
|
329
|
+
export function parseBoxShadow(value) {
|
|
330
|
+
if (typeof value !== 'string') return null;
|
|
331
|
+
const text = value.trim();
|
|
332
|
+
if (!text || text === 'none') return null;
|
|
333
|
+
return splitTop(text, true).map((one) => readShadow(one, value));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function readShadow(part, value) {
|
|
337
|
+
const bad = (why) =>
|
|
338
|
+
new Error(
|
|
339
|
+
`react-x11: boxShadow ${JSON.stringify(value)} ${why} (expected ` +
|
|
340
|
+
"'<x> <y> [blur] [spread] [colour]', e.g. '0 2px 8px rgba(0, 0, 0, .4)')",
|
|
341
|
+
);
|
|
342
|
+
const pieces = splitTop(part, false);
|
|
343
|
+
if (!pieces.length) throw bad('has an empty shadow');
|
|
344
|
+
if (pieces.some((p) => p.toLowerCase() === 'inset')) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`react-x11: boxShadow ${JSON.stringify(value)} asks for an inset ` +
|
|
347
|
+
'shadow, which is not implemented — only outer shadows are. An ' +
|
|
348
|
+
'inner glow can be drawn with an inset border or a <canvas onDraw>.',
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
const lengths = [];
|
|
352
|
+
let color = null;
|
|
353
|
+
for (const piece of pieces) {
|
|
354
|
+
if (LENGTH.test(piece)) {
|
|
355
|
+
if (lengths.length === 4) throw bad('has more than four lengths');
|
|
356
|
+
lengths.push(number(piece));
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (color !== null) throw bad(`has more than one colour ("${piece}")`);
|
|
360
|
+
color = piece;
|
|
361
|
+
}
|
|
362
|
+
if (lengths.length < 2) throw bad('needs an x and a y offset');
|
|
363
|
+
const [dx, dy, blur = 0, spread = 0] = lengths;
|
|
364
|
+
if (blur < 0) throw bad('has a negative blur radius');
|
|
365
|
+
return { dx, dy, blur, spread, color };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The gaussian a CSS blur radius means, and the room it needs.
|
|
370
|
+
*
|
|
371
|
+
* CSS defines the blur radius as *twice* the standard deviation, and ntk's
|
|
372
|
+
* `Picture.setBlurFilter(size, sigma)` takes the convolution kernel's edge
|
|
373
|
+
* length rather than a radius — so the two names that look like they match
|
|
374
|
+
* are the two that must not be passed to each other. The kernel is cut at
|
|
375
|
+
* 3σ, where the gaussian is down to 1% and the truncation is invisible;
|
|
376
|
+
* `pad` is the same distance, and it is what stops the blur clipping square
|
|
377
|
+
* against the edge of the surface it was rendered into.
|
|
378
|
+
*
|
|
379
|
+
* `MAX_KERNEL` bounds a pathological value: the server convolves N² taps per
|
|
380
|
+
* pixel, so a 200px blur asked for by accident would otherwise be a frame
|
|
381
|
+
* that never lands.
|
|
382
|
+
*/
|
|
383
|
+
const MAX_KERNEL = 61;
|
|
384
|
+
|
|
385
|
+
export function blurKernel(blur) {
|
|
386
|
+
const sigma = blur / 2;
|
|
387
|
+
const reach = Math.ceil(3 * sigma);
|
|
388
|
+
const size = Math.min(MAX_KERNEL, 2 * reach + 1);
|
|
389
|
+
return { sigma, size, pad: (size - 1) / 2 + 1 };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* How far outside the node's own box this shadow list reaches, in device
|
|
394
|
+
* pixels — the damage inflation, and the reason a shadow is not just a
|
|
395
|
+
* colour. Symmetric on purpose: a shadow offset down and right claims the
|
|
396
|
+
* same slack above and left, which costs a few pixels of repaint and saves
|
|
397
|
+
* every caller from carrying four numbers around.
|
|
398
|
+
*/
|
|
399
|
+
export function shadowExtent(shadows) {
|
|
400
|
+
let extent = 0;
|
|
401
|
+
for (const s of shadows ?? []) {
|
|
402
|
+
const reach =
|
|
403
|
+
Math.max(Math.abs(s.dx), Math.abs(s.dy)) +
|
|
404
|
+
s.spread +
|
|
405
|
+
(s.blur > 0 ? blurKernel(s.blur).pad : 0);
|
|
406
|
+
if (reach > extent) extent = reach;
|
|
407
|
+
}
|
|
408
|
+
return Math.ceil(Math.max(0, extent));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// --- memoized parsing ------------------------------------------------------
|
|
412
|
+
//
|
|
413
|
+
// A style value is a string that is usually the same string as last frame —
|
|
414
|
+
// a hoisted style, a theme token resolved to the same colour — so the parse
|
|
415
|
+
// is keyed on it and never repeated. Failures are cached as `null` too:
|
|
416
|
+
// `validateStyle` has already thrown for them in development, and a frame in
|
|
417
|
+
// production must not re-throw once per node per frame for a value nothing
|
|
418
|
+
// is going to fix mid-run.
|
|
419
|
+
|
|
420
|
+
const MAX_CACHE = 256;
|
|
421
|
+
const gradients = new Map();
|
|
422
|
+
const shadows = new Map();
|
|
423
|
+
|
|
424
|
+
function memoize(cache, key, parse) {
|
|
425
|
+
if (cache.has(key)) return cache.get(key);
|
|
426
|
+
let parsed = null;
|
|
427
|
+
try {
|
|
428
|
+
parsed = parse(key);
|
|
429
|
+
} catch {
|
|
430
|
+
parsed = null;
|
|
431
|
+
}
|
|
432
|
+
if (cache.size >= MAX_CACHE) cache.clear();
|
|
433
|
+
cache.set(key, parsed);
|
|
434
|
+
return parsed;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The display scale's route into these two values, which is unlike every
|
|
439
|
+
* other style length's: `boxShadow` and `backgroundImage` are *strings*,
|
|
440
|
+
* so the multiply at the style funnel (styles.js, scaleResolvedStyle)
|
|
441
|
+
* cannot reach the numbers inside them — and the parses are memoized on
|
|
442
|
+
* the raw string, so the scaled result has to be memoized beside it
|
|
443
|
+
* rather than patched after. The scale rides into the memo key.
|
|
444
|
+
*/
|
|
445
|
+
function scaleShadows(specs, scale) {
|
|
446
|
+
if (!specs || scale === 1) return specs;
|
|
447
|
+
return specs.map((s) => ({
|
|
448
|
+
...s,
|
|
449
|
+
dx: s.dx * scale,
|
|
450
|
+
dy: s.dy * scale,
|
|
451
|
+
blur: s.blur * scale,
|
|
452
|
+
spread: s.spread * scale,
|
|
453
|
+
}));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function scaleGradient(spec, scale) {
|
|
457
|
+
if (!spec || scale === 1) return spec;
|
|
458
|
+
let stops = spec.stops;
|
|
459
|
+
if (stops?.some((stop) => stop.position?.unit === 'px')) {
|
|
460
|
+
stops = stops.map((stop) =>
|
|
461
|
+
stop.position?.unit === 'px'
|
|
462
|
+
? {
|
|
463
|
+
...stop,
|
|
464
|
+
position: { ...stop.position, value: stop.position.value * scale },
|
|
465
|
+
}
|
|
466
|
+
: stop,
|
|
467
|
+
);
|
|
468
|
+
return { ...spec, stops };
|
|
469
|
+
}
|
|
470
|
+
return spec;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** `parseLinearGradient`, memoized and non-throwing: the paint path's door.
|
|
474
|
+
* `scale` converts any `px` stop positions to device pixels — percentages
|
|
475
|
+
* and angles mean the same thing at any density. */
|
|
476
|
+
export const gradientSpec = (value, scale = 1) =>
|
|
477
|
+
memoize(gradients, scale === 1 ? value : `${scale}\u0000${value}`, () =>
|
|
478
|
+
scaleGradient(parseLinearGradient(value), scale),
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
/** `parseBoxShadow`, memoized and non-throwing. `scale` converts the
|
|
482
|
+
* offsets, the blur and the spread to device pixels. */
|
|
483
|
+
export const shadowSpecs = (value, scale = 1) =>
|
|
484
|
+
memoize(shadows, scale === 1 ? value : `${scale}\u0000${value}`, () =>
|
|
485
|
+
scaleShadows(parseBoxShadow(value), scale),
|
|
486
|
+
);
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// The desktop's interaction settings: how fast a caret blinks, how long a
|
|
2
|
+
// double click has, how far a press has to move before it is a drag, and
|
|
3
|
+
// whether it wants animation at all.
|
|
4
|
+
//
|
|
5
|
+
// These were four constants in this codebase — `CARET_BLINK_MS` in nodes.js,
|
|
6
|
+
// `DRAG_THRESHOLD` in dnd.js, and the 400ms/4px pair inside
|
|
7
|
+
// `EventManager._clickDetail` — every one of them a guess at a number the
|
|
8
|
+
// desktop already publishes and every other toolkit already reads.
|
|
9
|
+
//
|
|
10
|
+
// ## Why this is not part of the appearance ladder
|
|
11
|
+
//
|
|
12
|
+
// `appearance.js` climbs three sources for colour scheme and accent, because
|
|
13
|
+
// those are answered by three different things and the portal is the modern
|
|
14
|
+
// one. Nothing here has a portal equivalent: `org.freedesktop.appearance` has
|
|
15
|
+
// four keys and none of them is a timing. XSETTINGS is the whole story, and
|
|
16
|
+
// where there is no settings daemon — a bare `startx`, most window managers,
|
|
17
|
+
// XQuartz — the defaults below stand.
|
|
18
|
+
//
|
|
19
|
+
// So this reads the map `xsettings.js` already maintains, rather than
|
|
20
|
+
// standing up a source of its own.
|
|
21
|
+
//
|
|
22
|
+
// ## Two of these are read synchronously, which is what shapes the module
|
|
23
|
+
//
|
|
24
|
+
// A caret arms its blink inside `defaultFocus()` and a drag decides it has
|
|
25
|
+
// started inside a mousemove. Neither has a round trip available, so
|
|
26
|
+
// `desktopSettings(app)` is a synchronous read of an already-populated map
|
|
27
|
+
// and `createRoot()` starts XSETTINGS **without awaiting it** — the settings
|
|
28
|
+
// land in a few milliseconds and the first interaction is much later than
|
|
29
|
+
// that. A field focused on the very first frame gets the defaults and the
|
|
30
|
+
// desktop's cadence from the next focus onward; that is the one seam, and it
|
|
31
|
+
// is worth more than the round trips on every app's startup path.
|
|
32
|
+
|
|
33
|
+
import { beginXSettings, watchXSettings, xsettings } from './xsettings.js';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What is true with no settings daemon to ask.
|
|
37
|
+
*
|
|
38
|
+
* These are **this renderer's existing constants**, not GTK's defaults. The
|
|
39
|
+
* two disagree — GTK blinks on a 1200ms cycle and drags at 8px, against 1060
|
|
40
|
+
* and 4 here — and changing what an app does on a desktop that never
|
|
41
|
+
* expressed a preference is a different decision from honouring one that
|
|
42
|
+
* did. This module only does the second.
|
|
43
|
+
*/
|
|
44
|
+
export const DEFAULTS = Object.freeze({
|
|
45
|
+
caretBlink: true,
|
|
46
|
+
// GTK, Qt and Windows all land within a few tens of milliseconds of this,
|
|
47
|
+
// and a blink out of step with the rest of the desktop is noticed even
|
|
48
|
+
// when the number cannot be named. Re-exported as `CARET_BLINK_MS` from
|
|
49
|
+
// `react-x11/node`, so an element drawing its own caret has one number to
|
|
50
|
+
// agree with rather than two.
|
|
51
|
+
caretBlinkMs: 530,
|
|
52
|
+
doubleClickMs: 400,
|
|
53
|
+
doubleClickDistance: 4,
|
|
54
|
+
// A press is a click until it moves this far. GTK's is 8; this is the
|
|
55
|
+
// DOM's, which is what the drag machinery here was built against.
|
|
56
|
+
dragThreshold: 4,
|
|
57
|
+
// Whether the desktop wants things to move. On by default because that is
|
|
58
|
+
// what the desktops that never say anything mean, and because the setting
|
|
59
|
+
// exists to be turned *off* — nobody enables animation, they disable it.
|
|
60
|
+
animations: true,
|
|
61
|
+
source: null,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const sessions = new WeakMap();
|
|
65
|
+
|
|
66
|
+
/** A positive integer from the map, or undefined — a settings daemon can
|
|
67
|
+
* export a key with a string value or a nonsense one, and a caret with a
|
|
68
|
+
* blink period of `'fast'` would never come back on. */
|
|
69
|
+
function positive(map, key) {
|
|
70
|
+
const value = map?.get(key);
|
|
71
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
72
|
+
? value
|
|
73
|
+
: undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* An XSETTINGS map → the settings. Pure, and exported for the test: the
|
|
78
|
+
* in-process X server has no settings daemon, so this is the part of the
|
|
79
|
+
* path worth pinning without one.
|
|
80
|
+
*
|
|
81
|
+
* **`Net/CursorBlinkTime` is a full cycle**, on and off together — that is
|
|
82
|
+
* what GTK means by `gtk-cursor-blink-time` and what every daemon writes —
|
|
83
|
+
* and `caretBlinkMs` is how long the caret stays in *each* state, because
|
|
84
|
+
* that is the number that goes into a timer. Halving it is the whole
|
|
85
|
+
* conversion, and forgetting to is a caret that blinks at half speed and
|
|
86
|
+
* looks broken rather than wrong.
|
|
87
|
+
*/
|
|
88
|
+
export function fromXSettings(map) {
|
|
89
|
+
if (!map) return DEFAULTS;
|
|
90
|
+
const cycle = positive(map, 'Net/CursorBlinkTime');
|
|
91
|
+
const blink = map.get('Net/CursorBlink');
|
|
92
|
+
return Object.freeze({
|
|
93
|
+
// 0 is "do not blink at all", which is an accessibility setting rather
|
|
94
|
+
// than a preference: a moving thing on screen is what some people cannot
|
|
95
|
+
// read past. A caret that ignores it is the bug this module was written
|
|
96
|
+
// for. Only an explicit 0 turns it off — an absent key is not an answer.
|
|
97
|
+
caretBlink: blink !== 0,
|
|
98
|
+
caretBlinkMs: cycle
|
|
99
|
+
? Math.max(1, Math.round(cycle / 2))
|
|
100
|
+
: DEFAULTS.caretBlinkMs,
|
|
101
|
+
doubleClickMs:
|
|
102
|
+
positive(map, 'Net/DoubleClickTime') ?? DEFAULTS.doubleClickMs,
|
|
103
|
+
doubleClickDistance:
|
|
104
|
+
positive(map, 'Net/DoubleClickDistance') ?? DEFAULTS.doubleClickDistance,
|
|
105
|
+
dragThreshold:
|
|
106
|
+
positive(map, 'Net/DndDragThreshold') ?? DEFAULTS.dragThreshold,
|
|
107
|
+
// `Gtk/EnableAnimations` is GTK's `gtk-enable-animations`, which every
|
|
108
|
+
// GNOME and Xfce session writes and which "reduce motion" in the
|
|
109
|
+
// accessibility panel turns off. Like the blink above, only an explicit
|
|
110
|
+
// 0 is an answer: a desktop that never exported the key has not asked
|
|
111
|
+
// for stillness, it has said nothing.
|
|
112
|
+
animations: map.get('Gtk/EnableAnimations') !== 0,
|
|
113
|
+
source: 'xsettings',
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The settings for a connection, **synchronously**. Always a complete answer:
|
|
119
|
+
* the defaults before the daemon has been read, and on a display with none.
|
|
120
|
+
*
|
|
121
|
+
* Cached rather than derived per call, because `useDesktopSettings()` reads
|
|
122
|
+
* it through `useSyncExternalStore` and because the drag path calls it on
|
|
123
|
+
* every mousemove.
|
|
124
|
+
*/
|
|
125
|
+
export function desktopSettings(app) {
|
|
126
|
+
const session = sessions.get(app);
|
|
127
|
+
if (!session) return DEFAULTS;
|
|
128
|
+
if (!session.snapshot) {
|
|
129
|
+
session.snapshot = fromXSettings(xsettings(app));
|
|
130
|
+
}
|
|
131
|
+
return session.snapshot;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Start reading the settings on `app`.
|
|
136
|
+
*
|
|
137
|
+
* Deliberately **not** awaited by `createRoot()` — see the note at the top of
|
|
138
|
+
* the file. Never rejects.
|
|
139
|
+
*/
|
|
140
|
+
export function beginDesktopSettings(app) {
|
|
141
|
+
if (sessions.has(app)) return;
|
|
142
|
+
const session = { snapshot: null, listeners: new Set(), stop: null };
|
|
143
|
+
sessions.set(app, session);
|
|
144
|
+
beginXSettings(app).then(
|
|
145
|
+
() => {
|
|
146
|
+
if (!sessions.has(app)) return;
|
|
147
|
+
session.snapshot = null;
|
|
148
|
+
notify(session);
|
|
149
|
+
session.stop = watchXSettings(app, () => {
|
|
150
|
+
session.snapshot = null;
|
|
151
|
+
notify(session);
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
() => {
|
|
155
|
+
// no daemon, no raw connection, a headless mock: the defaults stand,
|
|
156
|
+
// which is what they are for
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function notify(session) {
|
|
162
|
+
for (const fn of [...session.listeners]) {
|
|
163
|
+
try {
|
|
164
|
+
fn();
|
|
165
|
+
} catch {
|
|
166
|
+
// one subscriber throwing must not take the others with it
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Subscribe to the settings changing. Not public — `useDesktopSettings()` is. */
|
|
172
|
+
export function watchDesktopSettings(app, fn) {
|
|
173
|
+
let session = sessions.get(app);
|
|
174
|
+
if (!session) {
|
|
175
|
+
beginDesktopSettings(app);
|
|
176
|
+
session = sessions.get(app);
|
|
177
|
+
}
|
|
178
|
+
if (!session) return () => {};
|
|
179
|
+
session.listeners.add(fn);
|
|
180
|
+
return () => session.listeners.delete(fn);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Tear down with the root that started it. */
|
|
184
|
+
export function endDesktopSettings(app) {
|
|
185
|
+
const session = sessions.get(app);
|
|
186
|
+
if (!session) return;
|
|
187
|
+
session.stop?.();
|
|
188
|
+
session.listeners.clear();
|
|
189
|
+
sessions.delete(app);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Test seam: state the settings without a settings daemon. Takes the same
|
|
194
|
+
* shape the hook returns; anything left out keeps its default.
|
|
195
|
+
*/
|
|
196
|
+
export function setDesktopSettingsForTests(app, values) {
|
|
197
|
+
let session = sessions.get(app);
|
|
198
|
+
if (!session) {
|
|
199
|
+
session = { snapshot: null, listeners: new Set(), stop: null };
|
|
200
|
+
sessions.set(app, session);
|
|
201
|
+
}
|
|
202
|
+
session.snapshot = Object.freeze({
|
|
203
|
+
...DEFAULTS,
|
|
204
|
+
...values,
|
|
205
|
+
source: values ? 'test' : null,
|
|
206
|
+
});
|
|
207
|
+
notify(session);
|
|
208
|
+
return session.snapshot;
|
|
209
|
+
}
|