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,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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
}
|