react-x11 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +330 -81
  2. package/package.json +132 -14
  3. package/src/ClickToComponent.js +9 -2
  4. package/src/DevToolsIntegration.js +440 -32
  5. package/src/Reconciler.js +463 -165
  6. package/src/a11y.js +1299 -0
  7. package/src/acceleratorhooks.js +86 -0
  8. package/src/accelerators.js +128 -0
  9. package/src/activate.js +168 -0
  10. package/src/anchor.js +360 -0
  11. package/src/appcontext.js +161 -0
  12. package/src/appearance.js +748 -0
  13. package/src/appearancehooks.js +96 -0
  14. package/src/apphooks.js +73 -0
  15. package/src/application.js +748 -0
  16. package/src/atspi.js +1953 -0
  17. package/src/bus.js +545 -0
  18. package/src/bushooks.js +136 -0
  19. package/src/clientmessage.js +140 -0
  20. package/src/clipboard.js +121 -0
  21. package/src/components/Button.js +148 -33
  22. package/src/components/Checkbox.js +66 -32
  23. package/src/components/Dialog.js +96 -29
  24. package/src/components/FileDialog.js +491 -0
  25. package/src/components/Icon.js +329 -0
  26. package/src/components/Menu.js +797 -133
  27. package/src/components/PasswordInput.js +434 -0
  28. package/src/components/ProgressBar.js +91 -12
  29. package/src/components/Radio.js +78 -35
  30. package/src/components/Select.js +255 -65
  31. package/src/components/Slider.js +102 -41
  32. package/src/components/SplitPane.js +206 -0
  33. package/src/components/Switch.js +100 -22
  34. package/src/components/Table.js +529 -0
  35. package/src/components/Tabs.js +211 -0
  36. package/src/components/Tooltip.js +528 -54
  37. package/src/components/anchor.js +154 -107
  38. package/src/components/change.js +34 -0
  39. package/src/components/dnd.js +103 -0
  40. package/src/components/index.js +16 -3
  41. package/src/components/keys.js +17 -20
  42. package/src/components/scribble.js +247 -0
  43. package/src/components/theme.js +350 -42
  44. package/src/components/typeahead.js +12 -2
  45. package/src/compose.js +868 -0
  46. package/src/compositing.js +224 -0
  47. package/src/dbusmenu.js +384 -0
  48. package/src/debug.d.ts +63 -0
  49. package/src/debug.js +642 -0
  50. package/src/decorations.js +486 -0
  51. package/src/desktopsettings.js +209 -0
  52. package/src/desktopsettingshooks.js +62 -0
  53. package/src/dnd.js +1725 -0
  54. package/src/editmenu.js +272 -0
  55. package/src/errors.js +98 -0
  56. package/src/events.js +1406 -162
  57. package/src/extensions.js +45 -0
  58. package/src/filedialog.js +375 -0
  59. package/src/filedialoghooks.js +132 -0
  60. package/src/fonthooks.js +64 -0
  61. package/src/fonts.js +301 -0
  62. package/src/foreignnodes.js +519 -0
  63. package/src/frame/child.js +33 -0
  64. package/src/frame/childmain.js +274 -0
  65. package/src/frame/env.js +140 -0
  66. package/src/frame/index.js +444 -0
  67. package/src/frame/lifecycle.js +67 -0
  68. package/src/frame/protocol.js +179 -0
  69. package/src/frames.js +73 -0
  70. package/src/glbackend.js +114 -0
  71. package/src/glnodes.js +125 -67
  72. package/src/globalmenu.js +683 -0
  73. package/src/host.d.ts +88 -0
  74. package/src/host.js +42 -0
  75. package/src/idle.js +490 -0
  76. package/src/idlehooks.js +100 -0
  77. package/src/imagesource.js +349 -0
  78. package/src/index.d.ts +475 -0
  79. package/src/index.js +83 -9
  80. package/src/inputtime.js +206 -0
  81. package/src/jsx-dev-runtime.d.ts +21 -0
  82. package/src/jsx-dev-runtime.js +2 -0
  83. package/src/jsx-runtime.d.ts +39 -0
  84. package/src/jsx-runtime.js +6 -0
  85. package/src/keyboard.js +256 -0
  86. package/src/keyboardstate.js +278 -0
  87. package/src/keyboardstatehooks.js +58 -0
  88. package/src/keysyms.d.ts +149 -0
  89. package/src/keysyms.js +270 -0
  90. package/src/locale.js +170 -0
  91. package/src/localehooks.js +47 -0
  92. package/src/menuitem.js +223 -0
  93. package/src/node.d.ts +597 -0
  94. package/src/node.js +44 -0
  95. package/src/nodes.js +9546 -690
  96. package/src/ntk.d.ts +44 -0
  97. package/src/ntk.js +25 -0
  98. package/src/paintcache.js +366 -0
  99. package/src/palette.js +380 -0
  100. package/src/pastestate.js +66 -0
  101. package/src/portal.js +461 -0
  102. package/src/priority.js +26 -0
  103. package/src/refresh/index.d.ts +40 -0
  104. package/src/refresh/index.js +122 -0
  105. package/src/refresh/loader.d.ts +37 -0
  106. package/src/refresh/loader.js +401 -0
  107. package/src/refresh/register.d.ts +5 -0
  108. package/src/refresh/register.js +13 -0
  109. package/src/registry.js +232 -0
  110. package/src/scale.js +626 -0
  111. package/src/scalehooks.js +27 -0
  112. package/src/screencolor.js +640 -0
  113. package/src/screencolorhooks.js +101 -0
  114. package/src/screens.js +754 -0
  115. package/src/screenshooks.js +137 -0
  116. package/src/startup.js +302 -0
  117. package/src/style.d.ts +126 -0
  118. package/src/style.js +33 -0
  119. package/src/styles.js +1482 -6
  120. package/src/svgnodes.js +306 -0
  121. package/src/testing/a11y.js +484 -0
  122. package/src/testing/components.js +414 -0
  123. package/src/testing/events.js +407 -0
  124. package/src/testing/harness.js +455 -0
  125. package/src/testing/index.d.ts +558 -0
  126. package/src/testing/index.js +85 -0
  127. package/src/testing/mock-app.js +463 -0
  128. package/src/testing/pixels.js +152 -0
  129. package/src/testing/queries.js +224 -0
  130. package/src/textrange.js +83 -0
  131. package/src/textselection.js +439 -0
  132. package/src/trace-registry.js +63 -0
  133. package/src/transfer.js +93 -0
  134. package/src/types/appearance.d.ts +84 -0
  135. package/src/types/application.d.ts +173 -0
  136. package/src/types/components.d.ts +820 -0
  137. package/src/types/dbus.d.ts +177 -0
  138. package/src/types/elements.d.ts +897 -0
  139. package/src/types/events.d.ts +549 -0
  140. package/src/types/filedialog.d.ts +260 -0
  141. package/src/types/fonts.d.ts +124 -0
  142. package/src/types/frame.d.ts +146 -0
  143. package/src/types/globalmenu.d.ts +42 -0
  144. package/src/types/nodes.d.ts +199 -0
  145. package/src/types/screencolor.d.ts +84 -0
  146. package/src/types/style.d.ts +434 -0
  147. package/src/types/system.d.ts +287 -0
  148. package/src/windowid.js +151 -0
  149. package/src/windowstate.js +393 -0
  150. package/src/xsettings.js +336 -0
  151. package/src/yoga.d.ts +55 -0
  152. package/src/yoga.js +135 -0
  153. package/src/components/Canvas3D.js +0 -28
  154. package/src/geometry3d.js +0 -223
  155. package/src/pointer3d.js +0 -158
  156. package/src/raycast3d.js +0 -146
  157. package/src/richnodes.js +0 -436
  158. package/src/scene3d.js +0 -683
@@ -0,0 +1,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
+ }
@@ -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 { XK_RETURN } from './keys.js';
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
- const DefaultTheme = {
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
- const ThemeContext = React.createContext(DefaultTheme);
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
- /** Themes all widgets; partial palettes merge over the defaults. */
28
- export const ThemeProvider = ThemeContext.Provider;
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
- export const SelectThemeProvider = ThemeContext.Provider; // back-compat alias
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 theme = useContext(ThemeContext);
34
- return theme === DefaultTheme ? theme : { ...DefaultTheme, ...theme };
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
- /** Shared interactive-control plumbing: hover/focus state plus the box
38
- * props wiring them, click + Space/Enter activation. */
39
- export function useControl(disabled, onActivate) {
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
- focusable: true,
46
- cursor: 'pointer',
47
- onMouseEnter: () => setHover(true),
48
- onMouseLeave: () => setHover(false),
49
- onFocus: () => setFocused(true),
50
- onBlur: () => setFocused(false),
51
- onClick: () => onActivate?.(),
52
- onKeyDown: (ev) => {
53
- if (ev.codepoint === 32 || ev.keysym === XK_RETURN) onActivate?.();
54
- },
55
- };
56
- return { hover: hover && !disabled, focused: focused && !disabled, props };
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
- /** String/number children become a <text>; elements pass through. */
60
- export function labelContent(children, textProps) {
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', textProps, child)
371
+ ? h('text', { style: [capTrim, style] }, child)
64
372
  : child,
65
373
  );
66
374
  }