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,247 @@
|
|
|
1
|
+
// The scribble a masked field is drawn with, and the arithmetic behind it.
|
|
2
|
+
//
|
|
3
|
+
// A row of bullets answers the wrong question. It says *how many characters
|
|
4
|
+
// you have typed* — loudly, countably, from across the room — and it says
|
|
5
|
+
// nothing at all about the keystroke that just landed, because one more
|
|
6
|
+
// identical dot at the end of a row of identical dots is the least visible
|
|
7
|
+
// change a field could make.
|
|
8
|
+
//
|
|
9
|
+
// This inverts both. A stroke is drawn through points chosen by a generator
|
|
10
|
+
// seeded from the window and the value, so **every keystroke redraws the
|
|
11
|
+
// whole curve**: the feedback is the entire mask moving, not a mark
|
|
12
|
+
// appearing. And nothing in the shape is per-character — the pen visits a
|
|
13
|
+
// number of points taken from the mask's *width*, where a character is worth
|
|
14
|
+
// about half a point, so no part of the stroke can be matched to anything
|
|
15
|
+
// that was typed.
|
|
16
|
+
//
|
|
17
|
+
// It is a scribble rather than a wave, and that is a decision rather than a
|
|
18
|
+
// look: a stroke whose `x` only ever increases is the plot of a function, and
|
|
19
|
+
// the eye reads it as one — value against position, meaning in the peaks —
|
|
20
|
+
// however wild the `y` is. So the pen doubles back, crosses what it has
|
|
21
|
+
// already drawn, and leaves loops. `scribblePoints` is where that happens.
|
|
22
|
+
//
|
|
23
|
+
// What does grow is the width, because a mask that did not grow would say
|
|
24
|
+
// nothing about progress at all. It grows by a per-position advance drawn
|
|
25
|
+
// from a **window-seeded** stream rather than by a fixed step, so the width
|
|
26
|
+
// is monotonic in the length (typing always widens it) without being a clean
|
|
27
|
+
// multiple of anything (a glance does not give a character count). Those two
|
|
28
|
+
// streams are deliberately separate: the shape reshuffles on every keystroke,
|
|
29
|
+
// the width never does.
|
|
30
|
+
//
|
|
31
|
+
// The honest limits, since a mask that oversells itself is worse than one
|
|
32
|
+
// that does not: an observer who watches the field grow keystroke by
|
|
33
|
+
// keystroke still learns the length, and the width still puts a long password
|
|
34
|
+
// in a different bracket from a short one. This hides a glance, not a
|
|
35
|
+
// recording.
|
|
36
|
+
|
|
37
|
+
/** FNV-1a over the UTF-16 units, which is all a seed needs to be. */
|
|
38
|
+
export function hash32(text) {
|
|
39
|
+
let h = 0x811c9dc5;
|
|
40
|
+
for (let i = 0; i < text.length; i++) {
|
|
41
|
+
h ^= text.charCodeAt(i);
|
|
42
|
+
h = Math.imul(h, 0x01000193);
|
|
43
|
+
}
|
|
44
|
+
return h >>> 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* mulberry32: a small, fast, well-distributed PRNG with a 32-bit state.
|
|
49
|
+
*
|
|
50
|
+
* The point is not statistical quality, it is that the sequence is a pure
|
|
51
|
+
* function of the seed — the same value in the same window draws the same
|
|
52
|
+
* scribble on every repaint, so a frame that redraws a damaged strip cannot
|
|
53
|
+
* come back with a different curve than the frame before it.
|
|
54
|
+
*/
|
|
55
|
+
export function seededRandom(seed) {
|
|
56
|
+
let a = seed >>> 0;
|
|
57
|
+
return () => {
|
|
58
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
59
|
+
let t = a;
|
|
60
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
61
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
62
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* How many points the pen visits, per pixel of mask.
|
|
68
|
+
*
|
|
69
|
+
* Deriving the count from the **width** rather than fixing it keeps the
|
|
70
|
+
* scrawl at one density: a fixed count stretches into a few long shallow
|
|
71
|
+
* strokes as the field fills, which both stops looking like a scribble and —
|
|
72
|
+
* since density would then fall with length — says as much about the length
|
|
73
|
+
* as the count would. Width is already on show; density does not have to be.
|
|
74
|
+
*
|
|
75
|
+
* The count is not per-character either way. At this rate a character is
|
|
76
|
+
* worth about half a point, so nothing in the stroke can be matched to
|
|
77
|
+
* anything that was typed.
|
|
78
|
+
*/
|
|
79
|
+
const PX_PER_POINT = 9;
|
|
80
|
+
const MIN_POINTS = 7;
|
|
81
|
+
|
|
82
|
+
/** How far apart, in pixels, the curve is sampled when it is drawn. */
|
|
83
|
+
const SAMPLE_PX = 2;
|
|
84
|
+
const MAX_POINTS = 30;
|
|
85
|
+
|
|
86
|
+
/** How many points a mask this wide is drawn through. */
|
|
87
|
+
export function scribblePointCount(width) {
|
|
88
|
+
return Math.max(
|
|
89
|
+
MIN_POINTS,
|
|
90
|
+
Math.min(MAX_POINTS, Math.round(width / PX_PER_POINT)),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** How far the pen may jump out of order: the width of the window the
|
|
95
|
+
* visiting order is shuffled inside. See `scribblePoints`. */
|
|
96
|
+
export const SCRIBBLE_GROUP = 3;
|
|
97
|
+
|
|
98
|
+
/** The narrowest and widest a character may push the mask, as a fraction of
|
|
99
|
+
* the reference advance. Wide enough that the steps do not read as a ruler,
|
|
100
|
+
* narrow enough that the mask still tracks what has been typed. */
|
|
101
|
+
const MIN_ADVANCE = 0.55;
|
|
102
|
+
const MAX_ADVANCE = 1.35;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* How wide the mask for a value of `length` characters is, in pixels.
|
|
106
|
+
*
|
|
107
|
+
* `unit` is the reference advance — one character of the field's own font —
|
|
108
|
+
* and `seed` is derived from the **window**, never from the value, which is
|
|
109
|
+
* what makes position 7 contribute the same width whatever is typed there.
|
|
110
|
+
* So the mask only ever grows as you type, and never twitches when a
|
|
111
|
+
* character is replaced.
|
|
112
|
+
*/
|
|
113
|
+
export function maskWidth(length, unit, seed, max = Infinity) {
|
|
114
|
+
if (length <= 0) return 0;
|
|
115
|
+
const rnd = seededRandom(seed);
|
|
116
|
+
let width = 0;
|
|
117
|
+
for (let i = 0; i < length; i++) {
|
|
118
|
+
const advance = unit * (MIN_ADVANCE + rnd() * (MAX_ADVANCE - MIN_ADVANCE));
|
|
119
|
+
width += advance;
|
|
120
|
+
// A field that has run out of room stops growing, the way a text input
|
|
121
|
+
// that has scrolled stops showing you where the end is — and the loop
|
|
122
|
+
// stops with it, so a pasted novel is not a hundred thousand rounds of a
|
|
123
|
+
// generator whose answer is already known.
|
|
124
|
+
if (width >= max) return max;
|
|
125
|
+
}
|
|
126
|
+
return width;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The points the pen visits, inside a `width` × `height` box.
|
|
131
|
+
*
|
|
132
|
+
* **Not in left-to-right order.** A stroke whose `x` only ever increases is a
|
|
133
|
+
* plot of a function — a waveform, a time series — and it reads as one however
|
|
134
|
+
* wild the `y` is: the eye follows it as "value against position" and starts
|
|
135
|
+
* looking for meaning in the peaks. A pen moved at random over a piece of
|
|
136
|
+
* paper does not do that. It doubles back, crosses what it has already drawn,
|
|
137
|
+
* and leaves loops.
|
|
138
|
+
*
|
|
139
|
+
* So the points are laid out in **columns** — one per column, which is what
|
|
140
|
+
* guarantees the stroke covers the width it was given rather than knotting
|
|
141
|
+
* itself in one corner — and then the *visiting order is shuffled*. The pen
|
|
142
|
+
* therefore starts somewhere in the middle, sweeps across, comes back, and
|
|
143
|
+
* crosses itself on the way.
|
|
144
|
+
*/
|
|
145
|
+
export function scribblePoints({
|
|
146
|
+
width,
|
|
147
|
+
height,
|
|
148
|
+
seed,
|
|
149
|
+
points = scribblePointCount(width),
|
|
150
|
+
group = SCRIBBLE_GROUP,
|
|
151
|
+
inset = 2,
|
|
152
|
+
}) {
|
|
153
|
+
const rnd = seededRandom(seed);
|
|
154
|
+
const span = Math.max(1, height - inset * 2);
|
|
155
|
+
const usable = Math.max(1, width - inset * 2);
|
|
156
|
+
const column = usable / points;
|
|
157
|
+
const out = [];
|
|
158
|
+
for (let i = 0; i < points; i++) {
|
|
159
|
+
out.push({
|
|
160
|
+
// inside its own column, so no two points share a place and the whole
|
|
161
|
+
// width is used, but nowhere near the middle of it
|
|
162
|
+
x: inset + i * column + rnd() * column,
|
|
163
|
+
y: inset + rnd() * span,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
// Fisher-Yates within a sliding group rather than over the whole list. A
|
|
167
|
+
// full shuffle puts consecutive points a third of the width apart on
|
|
168
|
+
// average, and in a box this short every one of those moves is a long
|
|
169
|
+
// shallow sweep — the stroke ends up a bundle of near-horizontal scratches.
|
|
170
|
+
// Shuffling locally keeps the moves short, so the pen has room to go up and
|
|
171
|
+
// down between them, and it still doubles back and crosses itself.
|
|
172
|
+
for (let start = 0; start < out.length; start += group) {
|
|
173
|
+
const end = Math.min(out.length, start + group);
|
|
174
|
+
for (let i = end - 1; i > start; i--) {
|
|
175
|
+
const j = start + Math.floor(rnd() * (i - start + 1));
|
|
176
|
+
const swap = out[i];
|
|
177
|
+
out[i] = out[j];
|
|
178
|
+
out[j] = swap;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The pen's path as a **dense polyline**, sampled off a Catmull-Rom spline
|
|
186
|
+
* through the points — a curve that passes through every one of them rather
|
|
187
|
+
* than being pulled towards it.
|
|
188
|
+
*
|
|
189
|
+
* Sampled here rather than handed to the context as béziers, which is what
|
|
190
|
+
* this did first. A mask is 17 pixels tall, its control points land within a
|
|
191
|
+
* pixel of each other constantly, and ntk's stroker answers a curve that
|
|
192
|
+
* small with the occasional spike: a stray tail shooting out of the field,
|
|
193
|
+
* several times the width of the mask. The same points drawn as line segments
|
|
194
|
+
* are exact at any size, and a curve sampled every couple of pixels is
|
|
195
|
+
* smooth long before anyone can see the difference. (The béziers themselves
|
|
196
|
+
* are fine — the shape is right when it is drawn eight times bigger — so this
|
|
197
|
+
* is a workaround for the stroker, not for the geometry.)
|
|
198
|
+
*
|
|
199
|
+
* Every sample is clamped into the box, so "the stroke stays in the field" is
|
|
200
|
+
* arithmetic rather than a hope.
|
|
201
|
+
*/
|
|
202
|
+
export function scribblePath(pts, { width, height, inset = 2 }) {
|
|
203
|
+
const clampX = (v) => Math.min(width, Math.max(0, v));
|
|
204
|
+
const clampY = (v) => Math.min(height - inset, Math.max(inset, v));
|
|
205
|
+
const out = [{ x: clampX(pts[0].x), y: clampY(pts[0].y) }];
|
|
206
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
207
|
+
const p0 = pts[i - 1] ?? pts[i];
|
|
208
|
+
const p1 = pts[i];
|
|
209
|
+
const p2 = pts[i + 1];
|
|
210
|
+
const p3 = pts[i + 2] ?? p2;
|
|
211
|
+
const span = Math.abs(p2.x - p1.x) + Math.abs(p2.y - p1.y);
|
|
212
|
+
const steps = Math.max(3, Math.min(14, Math.round(span / SAMPLE_PX)));
|
|
213
|
+
for (let step = 1; step <= steps; step++) {
|
|
214
|
+
const t = step / steps;
|
|
215
|
+
const t2 = t * t;
|
|
216
|
+
const t3 = t2 * t;
|
|
217
|
+
// Catmull-Rom basis, uniform parameterisation
|
|
218
|
+
const at = (a, b, c, d) =>
|
|
219
|
+
0.5 *
|
|
220
|
+
(2 * b +
|
|
221
|
+
(c - a) * t +
|
|
222
|
+
(2 * a - 5 * b + 4 * c - d) * t2 +
|
|
223
|
+
(3 * b - a - 3 * c + d) * t3);
|
|
224
|
+
out.push({
|
|
225
|
+
x: clampX(at(p0.x, p1.x, p2.x, p3.x)),
|
|
226
|
+
y: clampY(at(p0.y, p1.y, p2.y, p3.y)),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Stroke the scribble for `seed` into a `width` × `height` box. */
|
|
234
|
+
export function strokeScribble(ctx, { width, height, seed, color, lineWidth }) {
|
|
235
|
+
if (!(width > 2) || !(height > 2)) return;
|
|
236
|
+
const inset = 2;
|
|
237
|
+
const pts = scribblePoints({ width, height, seed, inset });
|
|
238
|
+
const path = scribblePath(pts, { width, height, inset });
|
|
239
|
+
ctx.strokeStyle = color;
|
|
240
|
+
ctx.lineWidth = lineWidth ?? 1.5;
|
|
241
|
+
ctx.lineCap = 'round';
|
|
242
|
+
ctx.lineJoin = 'round';
|
|
243
|
+
ctx.beginPath();
|
|
244
|
+
ctx.moveTo(path[0].x, path[0].y);
|
|
245
|
+
for (const point of path.slice(1)) ctx.lineTo(point.x, point.y);
|
|
246
|
+
ctx.stroke();
|
|
247
|
+
}
|
package/src/components/theme.js
CHANGED
|
@@ -1,66 +1,374 @@
|
|
|
1
1
|
// Widget components built purely on the host primitives — no reconciler
|
|
2
2
|
// support needed. Plain createElement (no JSX) so the library stays
|
|
3
3
|
// build-step-free for consumers.
|
|
4
|
+
//
|
|
5
|
+
// The palettes themselves are in `../palette.js`, one layer down: a `$token`
|
|
6
|
+
// in a style is resolved by walking the *node* tree, which knows nothing
|
|
7
|
+
// about React, and both routes have to agree on what "no theme was given"
|
|
8
|
+
// means.
|
|
4
9
|
|
|
5
|
-
import React, { useContext, useState } from 'react';
|
|
6
|
-
import {
|
|
10
|
+
import React, { useContext, useMemo, useState } from 'react';
|
|
11
|
+
import { useAppearanceWhen } from '../appearancehooks.js';
|
|
12
|
+
import { EnvValue, registerFrameProvider } from '../frame/env.js';
|
|
13
|
+
import { DarkTheme, DefaultTheme, resolveTheme } from '../palette.js';
|
|
7
14
|
|
|
8
15
|
const h = React.createElement;
|
|
9
16
|
|
|
10
|
-
|
|
11
|
-
border: '#b2bec3',
|
|
12
|
-
borderActive: '#2980b9',
|
|
13
|
-
background: 'white',
|
|
14
|
-
text: '#2d3436',
|
|
15
|
-
dim: '#7f8c8d',
|
|
16
|
-
hoverBackground: '#2980b9',
|
|
17
|
-
hoverText: 'white',
|
|
18
|
-
accent: '#2980b9',
|
|
19
|
-
accentHover: '#1f6693',
|
|
20
|
-
accentText: 'white',
|
|
21
|
-
surfaceHover: '#f1f2f6',
|
|
22
|
-
track: '#dfe6e9',
|
|
23
|
-
};
|
|
17
|
+
export { DarkTheme, DefaultTheme, resolveTheme };
|
|
24
18
|
|
|
25
|
-
|
|
19
|
+
// `null` means **no provider above here**, which is different from "the
|
|
20
|
+
// default palette": with nothing said, the palette to use is the desktop's,
|
|
21
|
+
// and that is not a constant. `useTheme()` substitutes it, and subscribes so
|
|
22
|
+
// the widget re-renders when the desktop changes.
|
|
23
|
+
//
|
|
24
|
+
// A provider always publishes a complete palette, so nothing downstream ever
|
|
25
|
+
// has to merge again.
|
|
26
|
+
const ThemeContext = React.createContext(null);
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
// The provider's box fills its parent, which is what an app-level provider
|
|
29
|
+
// wants; `style` is there for the ones that wrap a single control.
|
|
30
|
+
const FILL = Object.freeze({ flexGrow: 1 });
|
|
29
31
|
|
|
30
|
-
|
|
32
|
+
/**
|
|
33
|
+
* <ThemeProvider value={palette}> — the palette everything below reads, by
|
|
34
|
+
* both routes at once. A partial palette merges over whatever is already in
|
|
35
|
+
* force, exactly as a nested `theme` prop merges in the tree.
|
|
36
|
+
*
|
|
37
|
+
* There are two consumers and they are not the same mechanism: widgets read
|
|
38
|
+
* React context through `useTheme()`, while a `$token` in a style resolves
|
|
39
|
+
* against the nearest `theme` **prop** above the node — resolution walks the
|
|
40
|
+
* node tree and knows nothing about React. So the provider feeds both: the
|
|
41
|
+
* merged palette goes on the context *and* onto a real node in the tree.
|
|
42
|
+
* Skip the second and `<ThemeProvider value={dark}>` over
|
|
43
|
+
* `<box style={{ color: '$text' }}>` silently paints nothing (#119).
|
|
44
|
+
*
|
|
45
|
+
* ## What "already in force" means
|
|
46
|
+
*
|
|
47
|
+
* **The desktop's palette.** With no provider at all an app is dark on a dark
|
|
48
|
+
* desktop, so a provider that names an accent and a corner radius keeps
|
|
49
|
+
* following the desktop for everything it did not name — which is what an app
|
|
50
|
+
* that wants to look like it belongs there wants, and what it would have had
|
|
51
|
+
* to write `dark={…}` by hand for otherwise.
|
|
52
|
+
*
|
|
53
|
+
* `colorScheme` is the override, for an app that owns the choice rather than
|
|
54
|
+
* the desktop — a preference in its own settings, or a design that only works
|
|
55
|
+
* one way:
|
|
56
|
+
*
|
|
57
|
+
* ```jsx
|
|
58
|
+
* <ThemeProvider value={brand} colorScheme="light"> // never follows
|
|
59
|
+
* <ThemeProvider value={brand} colorScheme={settings.theme}>
|
|
60
|
+
* ```
|
|
61
|
+
*
|
|
62
|
+
* `dark` is the other half: a palette layered on only when the scheme in
|
|
63
|
+
* force is dark, for a design whose two schemes are not one recolour of the
|
|
64
|
+
* other.
|
|
65
|
+
*
|
|
66
|
+
* ```jsx
|
|
67
|
+
* <ThemeProvider value={light} dark={{ background: '#101418' }}>
|
|
68
|
+
* ```
|
|
69
|
+
*
|
|
70
|
+
* The desktop's **accent** is deliberately not adopted on its own: an app in
|
|
71
|
+
* dark mode did not ask for its buttons to change colour, and most portal
|
|
72
|
+
* backends report no accent at all. Take it explicitly where you want it, and
|
|
73
|
+
* keep a fallback:
|
|
74
|
+
*
|
|
75
|
+
* ```jsx
|
|
76
|
+
* const { accent } = useSystemAppearance();
|
|
77
|
+
* <ThemeProvider value={{ ...brand, accent: accent ?? brand.accent }}>
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
export function ThemeProvider({
|
|
81
|
+
value,
|
|
82
|
+
dark,
|
|
83
|
+
colorScheme = 'system',
|
|
84
|
+
style,
|
|
85
|
+
children,
|
|
86
|
+
}) {
|
|
87
|
+
const outer = useContext(ThemeContext);
|
|
88
|
+
// **Pinning is the complete opt-out.** A provider that names its own scheme
|
|
89
|
+
// subscribes to nothing, and the widgets under it read the provided palette
|
|
90
|
+
// rather than the store — so an app that does not want react-x11 asking the
|
|
91
|
+
// desktop anything says `colorScheme="light"` once at the top.
|
|
92
|
+
const follows = colorScheme === 'system';
|
|
93
|
+
const system = useAppearanceWhen(follows);
|
|
94
|
+
const wantsDark =
|
|
95
|
+
colorScheme === 'dark' || (follows && system.colorScheme === 'dark');
|
|
96
|
+
const theme = useMemo(
|
|
97
|
+
() =>
|
|
98
|
+
// An outer provider is the base; with none, the base is the scheme's
|
|
99
|
+
// own built-in palette. So `value` names what this app changes and
|
|
100
|
+
// everything else keeps following the desktop.
|
|
101
|
+
resolveTheme(
|
|
102
|
+
dark && wantsDark ? { ...value, ...dark } : value,
|
|
103
|
+
outer ?? (wantsDark ? DarkTheme : DefaultTheme),
|
|
104
|
+
),
|
|
105
|
+
[outer, value, dark, wantsDark],
|
|
106
|
+
);
|
|
107
|
+
// A provider that **names** a direction plants it as a style too, so the
|
|
108
|
+
// node tree mirrors along with the widgets: `useTheme().direction` is what
|
|
109
|
+
// a `<Slider>` reads, and the `direction` style property is what yoga
|
|
110
|
+
// reads, and they have to be the same answer. Only when this provider named
|
|
111
|
+
// it — every provider publishes a *complete* palette, so planting
|
|
112
|
+
// `theme.direction` unconditionally would pin an inner provider's subtree
|
|
113
|
+
// back to LTR inside a `<box style={{ direction: 'rtl' }}>`.
|
|
114
|
+
const named = value?.direction ?? (wantsDark ? dark?.direction : undefined);
|
|
115
|
+
const boxStyle = useMemo(() => {
|
|
116
|
+
const base = named ? [FILL, { direction: named }] : [FILL];
|
|
117
|
+
return style ? [...base, style] : base.length === 1 ? FILL : base;
|
|
118
|
+
}, [style, named]);
|
|
119
|
+
return h(
|
|
120
|
+
ThemeContext.Provider,
|
|
121
|
+
{ value: theme },
|
|
122
|
+
// The palette also crosses into any <Frame> below here (THEME_ENV_KEY):
|
|
123
|
+
// it is the one ambient thing the *app* authors, so a pane that said
|
|
124
|
+
// nothing about colour comes up in the app's palette rather than one
|
|
125
|
+
// frame of default. With no provider at all nothing is published, and
|
|
126
|
+
// the pane follows the desktop by itself — same answer, no bridge.
|
|
127
|
+
h(
|
|
128
|
+
EnvValue,
|
|
129
|
+
{ k: THEME_ENV_KEY, value: theme },
|
|
130
|
+
planted(children, theme, boxStyle),
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** What the theme is called on a frame's wire (docs/frame.md). */
|
|
136
|
+
export const THEME_ENV_KEY = 'react-x11:theme';
|
|
137
|
+
|
|
138
|
+
// The child half of the default theme bridge: recreate with the *real*
|
|
139
|
+
// provider, because the palette travels two routes — the context widgets
|
|
140
|
+
// read, and the `theme` prop planted on a node so `$token` styles resolve
|
|
141
|
+
// (#119) — and only ThemeProvider itself feeds both. The bridged value is
|
|
142
|
+
// the complete merged palette, so it wins every token; a pane's own inner
|
|
143
|
+
// ThemeProvider still overrides below it, which is the opt-out a pane that
|
|
144
|
+
// wants its own look already has.
|
|
145
|
+
registerFrameProvider(
|
|
146
|
+
THEME_ENV_KEY,
|
|
147
|
+
(value, children) => h(ThemeProvider, { value }, children),
|
|
148
|
+
// directly around the pane's window: `planted` puts the palette on a
|
|
149
|
+
// window among its direct children, and the window is where it must land
|
|
150
|
+
{ innermost: true },
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The node that carries the palette into the tree. Normally a box — but a
|
|
155
|
+
* `<window>` may only be a root child or nested in another window, never
|
|
156
|
+
* inside a box, so a provider above one plants the prop on the windows
|
|
157
|
+
* themselves instead of coming between them. An explicit `theme` on a child
|
|
158
|
+
* still wins, since that is what it means everywhere else.
|
|
159
|
+
*/
|
|
160
|
+
function planted(children, theme, style) {
|
|
161
|
+
const kids = React.Children.toArray(children);
|
|
162
|
+
if (kids.some((k) => React.isValidElement(k) && k.type === 'window')) {
|
|
163
|
+
return kids.map((k) =>
|
|
164
|
+
React.isValidElement(k)
|
|
165
|
+
? React.cloneElement(k, { theme: k.props.theme ?? theme })
|
|
166
|
+
: k,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return h('box', { theme, style }, children);
|
|
170
|
+
}
|
|
31
171
|
|
|
172
|
+
/**
|
|
173
|
+
* The palette in force here — already merged over any outer provider, and the
|
|
174
|
+
* same object the provider planted in the tree, so `useTheme()` and a `$token`
|
|
175
|
+
* always read one palette.
|
|
176
|
+
*
|
|
177
|
+
* **With no provider it is the desktop's**, and this re-renders when the
|
|
178
|
+
* desktop changes, which is what makes a react-x11 app that says nothing
|
|
179
|
+
* about colour go dark on a dark desktop. `node.theme` in `nodes.js` answers
|
|
180
|
+
* the same question for the other route.
|
|
181
|
+
*
|
|
182
|
+
* Identity matters: widgets plant what this returns on their own root node,
|
|
183
|
+
* and a fresh object every render would re-resolve every `$token` beneath it
|
|
184
|
+
* and defeat the resolution cache. Both built-in palettes are module
|
|
185
|
+
* constants, so the unprovided answer is stable too.
|
|
186
|
+
*/
|
|
32
187
|
export function useTheme() {
|
|
33
|
-
const
|
|
34
|
-
|
|
188
|
+
const provided = useContext(ThemeContext);
|
|
189
|
+
const system = useAppearanceWhen(provided == null);
|
|
190
|
+
if (provided) return provided;
|
|
191
|
+
return system.colorScheme === 'dark' ? DarkTheme : DefaultTheme;
|
|
35
192
|
}
|
|
36
193
|
|
|
37
|
-
/**
|
|
38
|
-
*
|
|
39
|
-
|
|
194
|
+
/**
|
|
195
|
+
* Which way the widgets here read — `'ltr'` or `'rtl'`.
|
|
196
|
+
*
|
|
197
|
+
* **What a widget mirrors is not what a box mirrors.** Yoga mirrors the boxes
|
|
198
|
+
* on its own from the `direction` style property, so most of the widget set
|
|
199
|
+
* needs nothing from this: a `<Checkbox>` is a `row` with a gap and a
|
|
200
|
+
* `<ProgressBar>` is two flex ratios, and both come out mirrored without a
|
|
201
|
+
* line of code. This is for the decisions yoga cannot make — which way an
|
|
202
|
+
* arrow key steps, which way a glyph points, which side a menu opens on.
|
|
203
|
+
*
|
|
204
|
+
* It comes from the palette because that is the channel a widget can read
|
|
205
|
+
* during its own render, and because an app with a language menu is already
|
|
206
|
+
* swapping a `<ThemeProvider>`. The provider plants the matching style
|
|
207
|
+
* property as it goes, so the boxes and the widgets under it mirror together.
|
|
208
|
+
*
|
|
209
|
+
* The one case the two can part company is a bare `<box style={{ direction:
|
|
210
|
+
* 'rtl' }}>` wrapped around widgets with no provider: the layout mirrors and
|
|
211
|
+
* a widget's own arithmetic does not. Use a `<ThemeProvider>` to mirror a
|
|
212
|
+
* region that contains widgets. Where a widget is measuring a **pointer**
|
|
213
|
+
* against a laid-out box it reads `node.direction` instead — the coordinate
|
|
214
|
+
* has to be interpreted in the direction the box was really laid out in, or a
|
|
215
|
+
* drag runs backwards.
|
|
216
|
+
*/
|
|
217
|
+
export function useDirection() {
|
|
218
|
+
return useTheme().direction === 'rtl' ? 'rtl' : 'ltr';
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Shared interactive-control plumbing: hover/focus/press state plus the box
|
|
223
|
+
* props wiring them, click + Space/Enter activation.
|
|
224
|
+
*
|
|
225
|
+
* `styled: true` says the widget expresses hover, focus and the press with
|
|
226
|
+
* `:hover`, `:focus` and `:active` style blocks. Then none of it is React
|
|
227
|
+
* state: no enter/leave handlers, no re-render on pointer move, and the
|
|
228
|
+
* returned `hover`/`focused`/`pressed` stay false. **Prefer it.** The state
|
|
229
|
+
* blocks are a repaint of one node where the React state is a re-render of
|
|
230
|
+
* the widget and its label.
|
|
231
|
+
*
|
|
232
|
+
* The exception is a control whose pressed part is not the node the press
|
|
233
|
+
* lands on. `:active` marks the press chain — the pressed node and its
|
|
234
|
+
* ancestors — so a `<Checkbox>` pressed anywhere along its row cannot light
|
|
235
|
+
* up its 16px well that way, the well being a sibling of the label rather
|
|
236
|
+
* than an ancestor of it. There the press is React state, which costs one
|
|
237
|
+
* render for a discrete event that in the ordinary case was about to
|
|
238
|
+
* re-render anyway when the value changed.
|
|
239
|
+
*/
|
|
240
|
+
export function useControl(disabled, onActivate, { styled = false } = {}) {
|
|
40
241
|
const [hover, setHover] = useState(false);
|
|
41
242
|
const [focused, setFocused] = useState(false);
|
|
243
|
+
const [pressed, setPressed] = useState(false);
|
|
244
|
+
const activation = {
|
|
245
|
+
focusable: true,
|
|
246
|
+
// The click, and nothing else: Space and Enter are a click on anything
|
|
247
|
+
// with an `onClick` now (`Node.defaultKeyDown`, issue #329), so the
|
|
248
|
+
// hand-rolled key mapping that used to live here is gone — and with it
|
|
249
|
+
// the reason a widget answered the keyboard while a hand-built control
|
|
250
|
+
// beside it did not.
|
|
251
|
+
//
|
|
252
|
+
// the event travels: `ButtonProps.onPress` has always been declared as
|
|
253
|
+
// taking one, and a handler that wants `ev.shiftKey` or `ev.detail` —
|
|
254
|
+
// shift-click, double-click — has no other way to get it. From the
|
|
255
|
+
// keyboard it is the synthesized click, carrying the key press's own
|
|
256
|
+
// modifiers, so Shift+Enter still reads as a shift-click.
|
|
257
|
+
onClick: (ev) => onActivate?.(ev),
|
|
258
|
+
};
|
|
42
259
|
const props = disabled
|
|
43
|
-
?
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
260
|
+
? // the node still has to *say* it is disabled: `:disabled` style
|
|
261
|
+
// blocks, the focus rule and the AT-SPI ENABLED state all read the
|
|
262
|
+
// prop off the node, not off the widget's closure
|
|
263
|
+
{ disabled: true }
|
|
264
|
+
: styled
|
|
265
|
+
? activation
|
|
266
|
+
: {
|
|
267
|
+
...activation,
|
|
268
|
+
onMouseEnter: () => setHover(true),
|
|
269
|
+
// a press released off the control never sends it a mouseup, so
|
|
270
|
+
// leaving is what ends the press — which is also what it means:
|
|
271
|
+
// releasing out there activates nothing
|
|
272
|
+
onMouseLeave: () => {
|
|
273
|
+
setHover(false);
|
|
274
|
+
setPressed(false);
|
|
275
|
+
},
|
|
276
|
+
onMouseDown: () => setPressed(true),
|
|
277
|
+
onMouseUp: () => setPressed(false),
|
|
278
|
+
onFocus: () => setFocused(true),
|
|
279
|
+
onBlur: () => {
|
|
280
|
+
setFocused(false);
|
|
281
|
+
setPressed(false);
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
return {
|
|
285
|
+
hover: !styled && hover && !disabled,
|
|
286
|
+
focused: !styled && focused && !disabled,
|
|
287
|
+
pressed: !styled && pressed && !disabled,
|
|
288
|
+
props,
|
|
289
|
+
// `cursor` is style, so it travels in the style channel — put it first
|
|
290
|
+
// in the widget's style array and anything it declares still wins
|
|
291
|
+
style: disabled ? undefined : POINTER,
|
|
292
|
+
};
|
|
57
293
|
}
|
|
58
294
|
|
|
59
|
-
|
|
60
|
-
|
|
295
|
+
const POINTER = Object.freeze({ cursor: 'pointer' });
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* What every widget label is measured to: the capitals down to the last
|
|
299
|
+
* baseline, rather than the font's line box.
|
|
300
|
+
*
|
|
301
|
+
* **A line box is not the letters.** It is ascent plus descent plus line
|
|
302
|
+
* gap, and the space it leaves over a capital differs from the space under a
|
|
303
|
+
* baseline by `(ascent - capHeight) - descent` — a property of the typeface,
|
|
304
|
+
* not of the design. So a label centred in a row, or padded inside a button,
|
|
305
|
+
* is only ever optically even by luck, and which way it is off changes with
|
|
306
|
+
* the font: at 14px, SF NS leaves 3.7px above the cap against 2.9px below
|
|
307
|
+
* the baseline and the label rides low, while Helvetica leaves 0.7 against
|
|
308
|
+
* 3.2 and it rides high. Nothing in the widget can correct for that, because
|
|
309
|
+
* the widget does not know the face it will be drawn in.
|
|
310
|
+
*
|
|
311
|
+
* Trimming makes the box *be* the letters, so the padding around a label is
|
|
312
|
+
* the padding you asked for and centring centres what can be seen. It is
|
|
313
|
+
* `textBoxTrim` in styling.md — CSS's `text-box-trim: trim-both` with
|
|
314
|
+
* `text-box-edge: cap alphabetic` — and the palette's `paddingY` is sized
|
|
315
|
+
* for the trimmed box.
|
|
316
|
+
*
|
|
317
|
+
* **Labels, not glyph marks.** A check, a submenu arrow or an icon drawn as
|
|
318
|
+
* text is centred on its own middle rather than sitting on a baseline, and
|
|
319
|
+
* cap-trimming its box moves it off centre. Those keep the full line box.
|
|
320
|
+
*/
|
|
321
|
+
export const capTrim = Object.freeze({ textBoxTrim: 'cap-alphabetic' });
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* How tall a trimmed single-line label comes out at this size — the band the
|
|
325
|
+
* space around a row has to be even about.
|
|
326
|
+
*
|
|
327
|
+
* From the size rather than from the face on purpose: a popup is **sized
|
|
328
|
+
* before it is laid out** (a menu is an X window, and its width and height go
|
|
329
|
+
* to the server with the map request), so the row height cannot wait for a
|
|
330
|
+
* font to be matched. Cap height is 0.70–0.73em in every UI face measured —
|
|
331
|
+
* Arial .717, Helvetica .717, SF NS .704, Verdana .727, DejaVu .729 — so this
|
|
332
|
+
* lands within a pixel of the metrics for all of them, and the trim keeps the
|
|
333
|
+
* label centred inside whatever it lands on.
|
|
334
|
+
*/
|
|
335
|
+
export const capBand = (fontSize) => Math.round(fontSize * 0.72);
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The geometry of rows on a rounded sheet: how far a row sits inside the
|
|
339
|
+
* sheet's edge, and how round its own corners are.
|
|
340
|
+
*
|
|
341
|
+
* **Concentric corners.** A rounded rect inside a rounded rect only looks
|
|
342
|
+
* like one shape when the two curves share a centre, which happens exactly
|
|
343
|
+
* when the inner radius is the outer radius less the gap between them. Any
|
|
344
|
+
* other pairing leaves the pill's corner either tighter or wider than the
|
|
345
|
+
* sheet's, and the eye reads the mismatch as a wobble along the corner even
|
|
346
|
+
* where it cannot name it. So the inset and the pill's radius are not two
|
|
347
|
+
* numbers a widget picks: given the sheet's radius, choosing one fixes the
|
|
348
|
+
* other, and this returns the pair.
|
|
349
|
+
*
|
|
350
|
+
* The inset is the free choice — a pill has to be *seen* to be inset, and it
|
|
351
|
+
* is also what the popup's own size was measured with — so the widget names
|
|
352
|
+
* it and the radius follows. `radiusPopupItem` is the ceiling rather than the
|
|
353
|
+
* value: a theme that wants rounder pills says so by rounding the sheet they
|
|
354
|
+
* sit on, which is the only way the two can agree.
|
|
355
|
+
*/
|
|
356
|
+
export const rowRadius = (theme, border, inset) =>
|
|
357
|
+
Math.max(
|
|
358
|
+
0,
|
|
359
|
+
Math.min(
|
|
360
|
+
theme.radiusPopupItem ?? Infinity,
|
|
361
|
+
(theme.radiusPopup ?? 0) - border - inset,
|
|
362
|
+
),
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
/** String/number children become a `<text>` with `style`; elements pass
|
|
366
|
+
* through untouched. Strings are labels, so they are trimmed to their
|
|
367
|
+
* letters — see {@link capTrim}. */
|
|
368
|
+
export function labelContent(children, style) {
|
|
61
369
|
return React.Children.map(children, (child) =>
|
|
62
370
|
typeof child === 'string' || typeof child === 'number'
|
|
63
|
-
? h('text',
|
|
371
|
+
? h('text', { style: [capTrim, style] }, child)
|
|
64
372
|
: child,
|
|
65
373
|
);
|
|
66
374
|
}
|