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,434 @@
1
+ // Widget components built purely on the host primitives — no reconciler
2
+ // support needed. Plain createElement (no JSX) so the library stays
3
+ // build-step-free for consumers.
4
+
5
+ import React, { useEffect, useRef, useState } from 'react';
6
+ import { createStyles } from '../styles.js';
7
+ import { useClipboard } from '../appcontext.js';
8
+ import { windowIdOf } from '../windowid.js';
9
+ import { useTheme } from './theme.js';
10
+ import { Icon } from './Icon.js';
11
+ import { changeEvent } from './change.js';
12
+ import { measureLabel } from './anchor.js';
13
+ import { hash32, maskWidth, strokeScribble } from './scribble.js';
14
+ import {
15
+ MOD,
16
+ XK_BACKSPACE,
17
+ XK_DELETE,
18
+ XK_INSERT,
19
+ XK_KP_ENTER,
20
+ XK_RETURN,
21
+ ctrlChordLetter,
22
+ } from '../keysyms.js';
23
+
24
+ const h = React.createElement;
25
+
26
+ // The reference advance the mask's width is measured in: one character of
27
+ // the field's own font, so a themed field keeps the mask in proportion —
28
+ // and the *only* text this widget ever lays out while it is masked.
29
+ const REFERENCE_GLYPH = 'n';
30
+ const ICON = 18;
31
+ /** The eye inside the 18px peek button. The old drawing was 16×12 — the
32
+ * almond fills a square box the same way, with the lid at 0.27. */
33
+ const EYE = 16;
34
+
35
+ // Ctrl chords this field answers, as keysym letters.
36
+ const CTRL_U = 0x75;
37
+ const CTRL_V = 0x76;
38
+
39
+ // Control characters, which a paste has no business contributing: a manager's
40
+ // clipboard entry often ends in a newline, and an auto-type sequence that
41
+ // pastes and then submits would otherwise put that newline *in* the secret.
42
+ // eslint-disable-next-line no-control-regex
43
+ const CONTROL_CHARS = /[\x00-\x1f\x7f]/g;
44
+
45
+ const s = createStyles({
46
+ field: {
47
+ flexDirection: 'row',
48
+ alignItems: 'center',
49
+ gap: 6,
50
+ padding: 8,
51
+ // logical: the text's own inset, and the tighter one on the side the
52
+ // reveal button sits on, which is wherever the row ends
53
+ paddingStart: 10,
54
+ paddingEnd: 6,
55
+ },
56
+ slot: { flexGrow: 1, flexShrink: 1, minWidth: 0, justifyContent: 'center' },
57
+ icon: {
58
+ width: ICON,
59
+ height: ICON,
60
+ alignItems: 'center',
61
+ justifyContent: 'center',
62
+ flexShrink: 0,
63
+ cursor: 'pointer',
64
+ transition: { backgroundColor: 80 },
65
+ },
66
+ caps: { width: 12, height: 12, flexShrink: 0 },
67
+ });
68
+
69
+ /** Caps Lock: an arrow standing on a bar, which is what the key is engraved
70
+ * with wherever it is engraved at all. */
71
+ function CapsGlyph({ color }) {
72
+ return h('canvas', {
73
+ style: s.caps,
74
+ onDraw: (ctx, { width, height }) => {
75
+ ctx.fillStyle = color;
76
+ ctx.beginPath();
77
+ ctx.moveTo(width / 2, 1);
78
+ ctx.lineTo(width - 1, height / 2);
79
+ ctx.lineTo(width * 0.72, height / 2);
80
+ ctx.lineTo(width * 0.72, height - 4);
81
+ ctx.lineTo(width * 0.28, height - 4);
82
+ ctx.lineTo(width * 0.28, height / 2);
83
+ ctx.lineTo(1, height / 2);
84
+ ctx.closePath();
85
+ ctx.fill();
86
+ ctx.fillRect(width * 0.28, height - 2.5, width * 0.44, 2);
87
+ },
88
+ });
89
+ }
90
+
91
+ /** Code points, not UTF-16 units: deleting half a surrogate pair is how a
92
+ * password with an emoji in it becomes one nobody can retype. */
93
+ const chars = (text) => Array.from(text);
94
+
95
+ /**
96
+ * <PasswordInput value onChange …boxProps> — a masked field whose mask is a
97
+ * **scribble**, not a row of bullets.
98
+ *
99
+ * <PasswordInput value={secret} onChange={(ev) => setSecret(ev.value)} />
100
+ *
101
+ * Bullets answer the wrong question. They report how many characters have
102
+ * been typed — countably, from across the room — and report almost nothing
103
+ * about the keystroke that just landed, one more identical dot at the end of
104
+ * a row of identical dots being the least visible change a field could make.
105
+ *
106
+ * Here one stroke is drawn through points from a generator seeded by the
107
+ * window and the value, so **every keystroke redraws the whole curve** and
108
+ * nothing in the shape is per-character. It is a scribble rather than a wave
109
+ * on purpose: the pen visits its points out of order, so it doubles back,
110
+ * crosses what it has drawn and leaves loops, where a stroke whose `x` only
111
+ * increases would be read as the plot of a function. The width grows with
112
+ * what has been typed — a mask that did not would say nothing about progress
113
+ * — by a per-position advance from a *window*-seeded stream, so it is
114
+ * monotonic without being a ruler. `scribble.js` carries the reasoning and
115
+ * the limits; the headline limit is that this hides a glance, not a
116
+ * recording.
117
+ *
118
+ * **The secret is never laid out or drawn while it is masked.** The mask's
119
+ * width comes from one reference character, so the password never enters
120
+ * ntk's shaping cache and its glyphs never reach the X server. Revealing it
121
+ * costs exactly what revealing it costs.
122
+ *
123
+ * **While it is masked** editing is smaller than `<textinput>`'s, because a
124
+ * scribble has nowhere to put a caret: type, Backspace, Ctrl+Backspace or
125
+ * Ctrl+U or Delete to clear, Ctrl+V or Shift+Insert to paste, Enter for
126
+ * `onSubmit`. No caret, no selection, no undo history — a rewindable secret
127
+ * is not a feature.
128
+ *
129
+ * **Revealed it is an ordinary text input**, because that is what it looks
130
+ * like and anything else would be a trap: a real `<textinput>` takes its
131
+ * place, with a caret, a selection, the arrow keys, a click into the middle
132
+ * of the word, undo and the edit menu. Focus follows the swap in both
133
+ * directions.
134
+ *
135
+ * What holds in both states is that **nothing leaves by a selection**:
136
+ * Ctrl+C and Ctrl+X do nothing, the revealed input carries `sensitive` so its
137
+ * menu has no Cut or Copy, and neither state ever takes PRIMARY — so a middle
138
+ * click in another application cannot spill the secret. What is on screen
139
+ * stops being on screen; what is on the clipboard does not.
140
+ *
141
+ * Those two exclusions are also the integration story, which is why they are
142
+ * exactly this shape: **paste** is how every password manager on the desktop
143
+ * delivers a secret it did not type, and **typing** is the other half —
144
+ * XTEST auto-type arrives as ordinary key events and this field cannot tell
145
+ * it from a person. See docs/components.md.
146
+ *
147
+ * The eye is a pointer affordance and not a tab stop, as GTK's peek icon is.
148
+ * `revealed` + `onRevealChange` make it controlled, for an app that wants the
149
+ * toggle somewhere its keyboard can reach.
150
+ */
151
+ export function PasswordInput({
152
+ value,
153
+ defaultValue,
154
+ onChange,
155
+ onSubmit,
156
+ name,
157
+ placeholder = 'Password',
158
+ revealable = true,
159
+ revealed,
160
+ onRevealChange,
161
+ maxLength,
162
+ drawMask,
163
+ disabled = false,
164
+ style,
165
+ ...boxProps
166
+ }) {
167
+ const theme = useTheme();
168
+ const fieldRef = useRef(null);
169
+ const inputRef = useRef(null);
170
+ const hideTimer = useRef(null);
171
+ const clipboard = useClipboard();
172
+ const [ownValue, setOwnValue] = useState(defaultValue ?? '');
173
+ const [ownRevealed, setOwnRevealed] = useState(false);
174
+ const [focused, setFocused] = useState(false);
175
+ const [capsLock, setCapsLock] = useState(false);
176
+ const wasShowing = useRef(false);
177
+ const leaving = useRef(false);
178
+
179
+ const text = String(value ?? ownValue ?? '');
180
+ const length = chars(text).length;
181
+ const showing = revealed === undefined ? ownRevealed : revealed;
182
+
183
+ // The palette's size, not a constant: the dots are drawn to stand in for
184
+ // the characters the field would have shown, and the field shows them at
185
+ // the size it inherits.
186
+ const size = theme.fontSize;
187
+ const unit = measureLabel(fieldRef.current, REFERENCE_GLYPH, { size });
188
+ const lineHeight = Math.ceil(unit.height || size * 1.4);
189
+
190
+ // The window is in the seed so the same password does not draw the same
191
+ // curve in two windows, or in this one tomorrow: an X id is not a secret,
192
+ // but it is not a constant either.
193
+ const windowSeed = hash32(`w${windowIdOf(fieldRef) ?? 0}`);
194
+ const shapeSeed = (windowSeed ^ hash32(text)) >>> 0;
195
+
196
+ const commit = (next) => {
197
+ const clipped =
198
+ maxLength == null ? next : chars(next).slice(0, maxLength).join('');
199
+ if (clipped === text) return;
200
+ if (value === undefined) setOwnValue(clipped);
201
+ onChange?.(changeEvent('password', name, clipped));
202
+ };
203
+
204
+ const reveal = (on) => {
205
+ if (revealed === undefined) setOwnRevealed(on);
206
+ onRevealChange?.(on);
207
+ };
208
+
209
+ // Revealing swaps the mask for a real <textinput>, so focus has to move
210
+ // with it — twice, and back again — while "hide it on the way out" must not
211
+ // fire for a move *within* the widget. The blur schedules the hide and any
212
+ // focus inside cancels it, which is the plain version of `:focus-within`
213
+ // for two nodes that know about each other.
214
+ const cancelHide = () => {
215
+ if (hideTimer.current == null) return;
216
+ clearTimeout(hideTimer.current);
217
+ hideTimer.current = null;
218
+ };
219
+ const scheduleHide = () => {
220
+ cancelHide();
221
+ hideTimer.current = setTimeout(() => {
222
+ hideTimer.current = null;
223
+ leaving.current = true;
224
+ reveal(false);
225
+ setCapsLock(false);
226
+ }, 0);
227
+ };
228
+ useEffect(() => cancelHide, []);
229
+ useEffect(() => {
230
+ if (showing) {
231
+ // the input is the keyboard from here, and it has just mounted
232
+ inputRef.current?.focus();
233
+ } else if (wasShowing.current && !leaving.current) {
234
+ // hidden while the widget still has the keyboard — the eye was pressed,
235
+ // or the app moved `revealed` — so the mask takes it back. Hidden
236
+ // *because* the keyboard left is the other case, and taking focus back
237
+ // there would drag it out of whatever the user had just moved to.
238
+ fieldRef.current?.focus();
239
+ }
240
+ wasShowing.current = showing;
241
+ leaving.current = false;
242
+ // `showing` alone: this is about the *swap*, not about anything the refs
243
+ // hold, and a dependency on them would move the focus again for a render
244
+ // that only changed the value.
245
+ }, [showing]);
246
+
247
+ const paste = (selection) => {
248
+ try {
249
+ clipboard
250
+ .readText({ selection })
251
+ // nothing owns the selection, or its owner refused text: a field that
252
+ // threw here would take the app down over an empty clipboard
253
+ .then(
254
+ (pasted) => commit(text + String(pasted).replace(CONTROL_CHARS, '')),
255
+ () => {},
256
+ );
257
+ } catch {
258
+ // no clipboard on this app object at all (a mock, an old ntk)
259
+ }
260
+ };
261
+
262
+ const onKeyDown = (ev) => {
263
+ if (disabled || showing) return;
264
+ setCapsLock(Boolean(ev.nativeEvent?.buttons & MOD.Lock));
265
+
266
+ if (ev.keysym === XK_RETURN || ev.keysym === XK_KP_ENTER) {
267
+ onSubmit?.(text);
268
+ return;
269
+ }
270
+ if (ev.keysym === XK_BACKSPACE) {
271
+ // Ctrl+Backspace deletes a word everywhere else, and a password has no
272
+ // words — so it clears, which is what someone who has lost track of
273
+ // what is in there actually wants.
274
+ commit(ev.ctrlKey ? '' : chars(text).slice(0, -1).join(''));
275
+ return;
276
+ }
277
+ if (ev.keysym === XK_DELETE) {
278
+ commit('');
279
+ return;
280
+ }
281
+ if (ev.keysym === XK_INSERT) {
282
+ if (ev.shiftKey) paste('CLIPBOARD');
283
+ return;
284
+ }
285
+ if (ev.ctrlKey) {
286
+ const letter = ctrlChordLetter(ev);
287
+ // Ctrl+U clears the line, as readline and every pinentry on the desktop
288
+ // do; Ctrl+V pastes. Nothing for C, X or A — there is no selection to
289
+ // copy, and the secret does not leave by the clipboard.
290
+ if (letter === CTRL_U) commit('');
291
+ else if (letter === CTRL_V) paste('CLIPBOARD');
292
+ return;
293
+ }
294
+ // A printable character and only that: a control code is not text, and
295
+ // neither is a key that types nothing at all.
296
+ if (ev.codepoint != null && ev.codepoint >= 32 && ev.codepoint !== 127) {
297
+ commit(text + String.fromCodePoint(ev.codepoint));
298
+ }
299
+ };
300
+
301
+ const draw = (ctx, box) => {
302
+ const info = {
303
+ width: maskWidth(
304
+ length,
305
+ unit.width || size * 0.55,
306
+ windowSeed,
307
+ box.width,
308
+ ),
309
+ height: box.height,
310
+ seed: shapeSeed,
311
+ color: disabled ? theme.textMuted : theme.text,
312
+ length,
313
+ };
314
+ if (drawMask) drawMask(ctx, info);
315
+ else strokeScribble(ctx, info);
316
+ };
317
+
318
+ return h(
319
+ 'box',
320
+ {
321
+ theme,
322
+ role: 'textbox',
323
+ ref: fieldRef,
324
+ focusable: !disabled,
325
+ onKeyDown: disabled ? undefined : onKeyDown,
326
+ onFocus: () => {
327
+ cancelHide();
328
+ setFocused(true);
329
+ // The box stays focusable while revealed so that a press on the eye
330
+ // lands *inside* the widget rather than nowhere — but the input is
331
+ // the keyboard then, so hand it straight on.
332
+ if (showing) inputRef.current?.focus();
333
+ },
334
+ onBlur: () => {
335
+ setFocused(false);
336
+ // A field nobody is in should not still be showing the secret: the
337
+ // reveal is for the moment you are looking at it, not a mode. A move
338
+ // to the revealed input is not leaving, which is what the deferred
339
+ // hide and its cancel are for.
340
+ scheduleHide();
341
+ },
342
+ ...boxProps,
343
+ style: [
344
+ s.field,
345
+ {
346
+ cursor: disabled ? 'default' : 'text',
347
+ borderWidth: theme.borderWidth,
348
+ borderRadius: theme.radius,
349
+ borderColor: focused ? theme.borderFocus : theme.border,
350
+ backgroundColor: disabled ? theme.surfaceHover : theme.surface,
351
+ },
352
+ style,
353
+ ],
354
+ },
355
+ h(
356
+ 'box',
357
+ { style: [s.slot, { height: lineHeight }] },
358
+ showing
359
+ ? // Revealed, the field is an ordinary text input and behaves like
360
+ // one: a caret, a selection, the arrow keys, a click into the
361
+ // middle of the word, undo, the edit menu. `sensitive` is the one
362
+ // thing it is not allowed — nothing here reaches a selection, in
363
+ // either direction out — because what is on screen stops being on
364
+ // screen and what is on the clipboard does not.
365
+ h('textinput', {
366
+ ref: inputRef,
367
+ sensitive: true,
368
+ value: text,
369
+ placeholder,
370
+ maxLength,
371
+ name,
372
+ onChange: (ev) => commit(ev.value),
373
+ onSubmit: () => onSubmit?.(text),
374
+ onFocus: () => {
375
+ cancelHide();
376
+ setFocused(true);
377
+ },
378
+ onBlur: () => {
379
+ setFocused(false);
380
+ scheduleHide();
381
+ },
382
+ style: {
383
+ flexGrow: 1,
384
+ minWidth: 0,
385
+ height: lineHeight,
386
+ backgroundColor: 'transparent',
387
+ // No ring of its own: the field around it already borders in
388
+ // `borderFocus` while the keyboard is inside, and a second ring
389
+ // an inch inside the first reads as two controls. It earns the
390
+ // opt-out because focus here is never ambiguous — the input only
391
+ // exists while it is the thing being typed into.
392
+ outlineWidth: 0,
393
+ },
394
+ })
395
+ : length === 0
396
+ ? h('text', { style: { color: theme.textMuted } }, placeholder)
397
+ : h('canvas', {
398
+ // `onDraw` is a plain prop compared by identity, so a fresh
399
+ // closure each render is what repaints the mask — and it
400
+ // repaints that one node, not the field around it.
401
+ onDraw: draw,
402
+ style: { flexGrow: 1, height: lineHeight },
403
+ }),
404
+ ),
405
+ capsLock && !showing && h(CapsGlyph, { color: theme.textMuted }),
406
+ revealable &&
407
+ !disabled &&
408
+ h(
409
+ 'box',
410
+ {
411
+ role: 'button',
412
+ // Not a tab stop, and not a focus target for a press either: focus
413
+ // stays on the field, so leaving the widget is the only blur it
414
+ // ever sees — which is what makes "hide it again on the way out"
415
+ // mean what it says.
416
+ focusable: false,
417
+ onClick: () => reveal(!showing),
418
+ style: [
419
+ s.icon,
420
+ { borderRadius: theme.radiusSmall },
421
+ {
422
+ ':hover': { backgroundColor: theme.surfaceHover },
423
+ ':active': { backgroundColor: theme.surfaceActive },
424
+ },
425
+ ],
426
+ },
427
+ h(Icon, {
428
+ name: showing ? 'eyeOff' : 'eye',
429
+ size: EYE,
430
+ color: disabled ? theme.border : theme.textMuted,
431
+ }),
432
+ ),
433
+ );
434
+ }
@@ -3,46 +3,125 @@
3
3
  // build-step-free for consumers.
4
4
 
5
5
  import React from 'react';
6
+ import { useDesktopSettings } from '../desktopsettingshooks.js';
6
7
  import { useTheme } from './theme.js';
7
8
 
8
9
  const h = React.createElement;
9
10
 
11
+ // How wide the travelling block is, as a share of the track, and how long it
12
+ // takes to cross. 40% is wide enough to read as a thing rather than a tick
13
+ // at any bar width, and a little over a second is the cadence GTK, Qt and
14
+ // the web all landed near — fast enough to say "working", slow enough not to
15
+ // be the loudest thing on the screen.
16
+ const BLOCK = '40%';
17
+ const CROSSING_MS = 1100;
18
+
10
19
  /**
11
20
  * <ProgressBar value/> — determinate progress, value in [0, 1].
21
+ *
22
+ * <ProgressBar indeterminate/> — working, with no idea how far along. A
23
+ * block slides across the track forever, and the bar says `aria-busy` with
24
+ * no value, which is what tells a screen reader that "42%" is not coming.
25
+ *
26
+ * The slide is a **loop** in the style (`animation`,
27
+ * [styling.md](../../docs/styling.md#loops)), not a timer here: it runs on
28
+ * the window's own frame clock, claims the track as its damage every frame
29
+ * instead of invalidating the window, and stops itself when the window is
30
+ * unmapped, minimized or buried. A `setInterval` calling `setState` would
31
+ * do none of those three, and would re-render this component sixty times a
32
+ * second to move a rectangle.
33
+ *
34
+ * The travel is in **percentages of the track**, so nothing here measures
35
+ * anything: the bar is right at whatever width its container gives it and
36
+ * stays right across a resize, with no layout read and no re-render. It is
37
+ * on the logical `start` edge, so the block travels the way the text does.
38
+ *
39
+ * **Reduced motion is honoured twice over.** Core refuses to start the loop
40
+ * when the desktop asks for less motion (`useDesktopSettings().animations`),
41
+ * which is what makes every loop in every app obey it. That leaves the
42
+ * resting look, and it is this component's to choose: the block parks in the
43
+ * middle of the track rather than at its off-screen starting point, so a bar
44
+ * that cannot move still shows something in progress — and not a full track,
45
+ * which is what "finished" looks like.
12
46
  */
13
47
  export function ProgressBar({
14
48
  value = 0,
49
+ indeterminate = false,
15
50
  color,
16
51
  trackColor,
17
52
  height = 8,
53
+ style,
18
54
  ...boxProps
19
55
  }) {
20
56
  const theme = useTheme();
57
+ const { animations } = useDesktopSettings();
21
58
  const clamped = Math.min(1, Math.max(0, value));
59
+ const track = [
60
+ {
61
+ height: height,
62
+ borderRadius: height / 2,
63
+ backgroundColor: trackColor ?? theme.track,
64
+ overflow: 'hidden',
65
+ flexDirection: 'row',
66
+ minWidth: 0,
67
+ },
68
+ style,
69
+ ];
70
+ const aria = indeterminate
71
+ ? { 'aria-busy': true }
72
+ : { 'aria-valuenow': clamped, 'aria-valuemin': 0, 'aria-valuemax': 1 };
73
+
74
+ if (indeterminate) {
75
+ const moving = animations !== false;
76
+ return h(
77
+ 'box',
78
+ { theme, role: 'progressbar', ...aria, ...boxProps, style: track },
79
+ h('box', {
80
+ style: {
81
+ position: 'absolute',
82
+ top: 0,
83
+ height,
84
+ width: BLOCK,
85
+ // `from` is left off the loop below on purpose: it defaults to
86
+ // what the style declares, so this one value is both where the
87
+ // block rests and where each crossing begins.
88
+ start: moving ? `-${BLOCK}` : '30%',
89
+ borderRadius: height / 2,
90
+ backgroundColor: color ?? theme.accent,
91
+ ...(moving && {
92
+ animation: { start: { to: '100%', duration: CROSSING_MS } },
93
+ }),
94
+ },
95
+ }),
96
+ );
97
+ }
98
+
22
99
  // The fill is expressed as flex ratios rather than a percentage width: a
23
100
  // percentage child resolves against the space available while the parent
24
101
  // is still being measured, so it fed back into the track's intrinsic
25
102
  // width and made whatever contained the bar grow — a dashboard card with
26
103
  // a fuller bar came out wider than one with an empty bar, and overflowed
27
104
  // the window. Two flexible boxes with a zero basis contribute nothing.
105
+ // (The indeterminate block above is out of flow, so it is measured by
106
+ // nobody and the same trap does not reach it.)
28
107
  return h(
29
108
  'box',
30
109
  {
31
- height,
32
- borderRadius: height / 2,
33
- backgroundColor: trackColor ?? theme.track,
34
- overflow: 'hidden',
35
- flexDirection: 'row',
36
- minWidth: 0,
110
+ theme,
111
+ role: 'progressbar',
112
+ ...aria,
37
113
  ...boxProps,
114
+ style: track,
38
115
  },
39
116
  h('box', {
40
- flexGrow: clamped,
41
- flexShrink: 0,
42
- flexBasis: 0,
43
- borderRadius: height / 2,
44
- backgroundColor: color ?? theme.accent,
117
+ style: {
118
+ flexGrow: clamped,
119
+ flexShrink: 0,
120
+ flexBasis: 0,
121
+ borderRadius: height / 2,
122
+ backgroundColor: color ?? theme.accent,
123
+ },
45
124
  }),
46
- h('box', { flexGrow: 1 - clamped, flexShrink: 0, flexBasis: 0 }),
125
+ h('box', { style: { flexGrow: 1 - clamped, flexShrink: 0, flexBasis: 0 } }),
47
126
  );
48
127
  }