react-x11 0.0.1 → 1.2.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/LICENSE +21 -0
- package/README.md +226 -0
- package/package.json +60 -7
- package/src/ClickToComponent.js +179 -0
- package/src/DevToolsIntegration.js +106 -0
- package/src/Reconciler.js +507 -0
- package/src/components/Button.js +63 -0
- package/src/components/Canvas3D.js +28 -0
- package/src/components/Checkbox.js +69 -0
- package/src/components/Dialog.js +138 -0
- package/src/components/Menu.js +644 -0
- package/src/components/ProgressBar.js +48 -0
- package/src/components/Radio.js +95 -0
- package/src/components/Select.js +272 -0
- package/src/components/Slider.js +177 -0
- package/src/components/Switch.js +49 -0
- package/src/components/Tooltip.js +146 -0
- package/src/components/anchor.js +211 -0
- package/src/components/index.js +17 -0
- package/src/components/keys.js +21 -0
- package/src/components/theme.js +66 -0
- package/src/components/typeahead.js +56 -0
- package/src/events.js +584 -0
- package/src/geometry3d.js +223 -0
- package/src/glnodes.js +275 -0
- package/src/index.js +30 -0
- package/src/mat4.js +235 -0
- package/src/nodes.js +1985 -0
- package/src/pointer3d.js +158 -0
- package/src/priority.js +39 -0
- package/src/raycast3d.js +146 -0
- package/src/richnodes.js +436 -0
- package/src/scene3d.js +683 -0
- package/src/styles.js +189 -0
- package/.npmignore +0 -17
- package/combobox.jsx +0 -69
- package/react-x11.js +0 -119
- package/test-canvas.js +0 -98
|
@@ -0,0 +1,644 @@
|
|
|
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 { useTheme } from './theme.js';
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_LABEL_SIZE,
|
|
9
|
+
anchorRect,
|
|
10
|
+
measureLabel,
|
|
11
|
+
movingToward,
|
|
12
|
+
SAFE_HOVER_DELAY,
|
|
13
|
+
screenOf,
|
|
14
|
+
screenPoint,
|
|
15
|
+
} from './anchor.js';
|
|
16
|
+
import { typeAheadChar, useTypeAhead } from './typeahead.js';
|
|
17
|
+
import {
|
|
18
|
+
XK_DOWN,
|
|
19
|
+
XK_END,
|
|
20
|
+
XK_ESCAPE,
|
|
21
|
+
XK_HOME,
|
|
22
|
+
XK_LEFT,
|
|
23
|
+
XK_PAGE_DOWN,
|
|
24
|
+
XK_PAGE_UP,
|
|
25
|
+
XK_RETURN,
|
|
26
|
+
XK_RIGHT,
|
|
27
|
+
XK_UP,
|
|
28
|
+
} from './keys.js';
|
|
29
|
+
|
|
30
|
+
const h = React.createElement;
|
|
31
|
+
|
|
32
|
+
const MENU_ITEM_HEIGHT = 26;
|
|
33
|
+
|
|
34
|
+
const MENU_SEPARATOR_HEIGHT = 7;
|
|
35
|
+
|
|
36
|
+
const MENU_MIN_WIDTH = 140;
|
|
37
|
+
|
|
38
|
+
const MENU_PAD = 4;
|
|
39
|
+
|
|
40
|
+
const MENU_GUTTER = 24; // room for the check column
|
|
41
|
+
|
|
42
|
+
const MENU_SHORTCUT_GAP = 24;
|
|
43
|
+
// menus size to their content rather than scrolling, so a page is a fixed
|
|
44
|
+
// stride — deriving one from the menu height would just equal Home/End
|
|
45
|
+
const MENU_PAGE_ROWS = 10;
|
|
46
|
+
|
|
47
|
+
const isSelectable = (item) => item && !item.separator && !item.disabled;
|
|
48
|
+
|
|
49
|
+
/** Total popup height for a menu's items (separators are shorter). */
|
|
50
|
+
function menuListHeight(items) {
|
|
51
|
+
const body = items.reduce(
|
|
52
|
+
(sum, item) =>
|
|
53
|
+
sum + (item.separator ? MENU_SEPARATOR_HEIGHT : MENU_ITEM_HEIGHT),
|
|
54
|
+
0,
|
|
55
|
+
);
|
|
56
|
+
return body + MENU_PAD * 2 + 2;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Widest label + shortcut, measured, so the popup can be sized up front. */
|
|
60
|
+
function menuListWidth(node, items, fontSize) {
|
|
61
|
+
let widest = 0;
|
|
62
|
+
for (const item of items) {
|
|
63
|
+
if (item.separator) continue;
|
|
64
|
+
const label = measureLabel(node, item.label ?? '', {
|
|
65
|
+
size: fontSize,
|
|
66
|
+
}).width;
|
|
67
|
+
const shortcut = item.shortcut
|
|
68
|
+
? measureLabel(node, item.shortcut, { size: fontSize }).width +
|
|
69
|
+
MENU_SHORTCUT_GAP
|
|
70
|
+
: 0;
|
|
71
|
+
widest = Math.max(widest, label + shortcut);
|
|
72
|
+
}
|
|
73
|
+
return Math.max(
|
|
74
|
+
MENU_MIN_WIDTH,
|
|
75
|
+
Math.ceil(widest) + MENU_GUTTER + MENU_PAD * 2 + 12,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Next selectable index in `dir`, wrapping, skipping separators/disabled. */
|
|
80
|
+
function nextSelectable(items, from, dir) {
|
|
81
|
+
const n = items.length;
|
|
82
|
+
if (!n) return -1;
|
|
83
|
+
for (let step = 1; step <= n; step++) {
|
|
84
|
+
const i = (from + dir * step + n * step) % n;
|
|
85
|
+
if (isSelectable(items[i])) return i;
|
|
86
|
+
}
|
|
87
|
+
return -1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function MenuRow({
|
|
91
|
+
item,
|
|
92
|
+
active,
|
|
93
|
+
onHover,
|
|
94
|
+
onMove,
|
|
95
|
+
onSelect,
|
|
96
|
+
fontSize,
|
|
97
|
+
nodeRef,
|
|
98
|
+
}) {
|
|
99
|
+
const theme = useTheme();
|
|
100
|
+
if (item.separator) {
|
|
101
|
+
return h(
|
|
102
|
+
'box',
|
|
103
|
+
{ height: MENU_SEPARATOR_HEIGHT, justifyContent: 'center' },
|
|
104
|
+
h('box', { height: 1, backgroundColor: theme.border }),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
const dim = item.disabled;
|
|
108
|
+
const hasSubmenu = item.items?.length > 0;
|
|
109
|
+
return h(
|
|
110
|
+
'box',
|
|
111
|
+
{
|
|
112
|
+
ref: nodeRef,
|
|
113
|
+
height: MENU_ITEM_HEIGHT,
|
|
114
|
+
flexDirection: 'row',
|
|
115
|
+
alignItems: 'center',
|
|
116
|
+
paddingLeft: 8,
|
|
117
|
+
paddingRight: 8,
|
|
118
|
+
cursor: dim ? undefined : 'pointer',
|
|
119
|
+
backgroundColor: active ? theme.hoverBackground : theme.background,
|
|
120
|
+
onMouseEnter: dim ? undefined : onHover,
|
|
121
|
+
onMouseMove: dim ? undefined : onMove,
|
|
122
|
+
onClick: dim ? undefined : () => onSelect(item),
|
|
123
|
+
},
|
|
124
|
+
h(
|
|
125
|
+
'box',
|
|
126
|
+
{ width: MENU_GUTTER - 8, alignItems: 'center' },
|
|
127
|
+
item.checked &&
|
|
128
|
+
h('text', {
|
|
129
|
+
color: active ? theme.hoverText : theme.text,
|
|
130
|
+
fontSize,
|
|
131
|
+
children: '\u2713',
|
|
132
|
+
}),
|
|
133
|
+
),
|
|
134
|
+
h(
|
|
135
|
+
'text',
|
|
136
|
+
{
|
|
137
|
+
color: dim ? theme.dim : active ? theme.hoverText : theme.text,
|
|
138
|
+
fontSize,
|
|
139
|
+
},
|
|
140
|
+
item.label,
|
|
141
|
+
),
|
|
142
|
+
h('box', { flexGrow: 1 }),
|
|
143
|
+
item.shortcut &&
|
|
144
|
+
h(
|
|
145
|
+
'text',
|
|
146
|
+
{
|
|
147
|
+
color: dim ? theme.dim : active ? theme.hoverText : theme.dim,
|
|
148
|
+
fontSize,
|
|
149
|
+
},
|
|
150
|
+
item.shortcut,
|
|
151
|
+
),
|
|
152
|
+
hasSubmenu &&
|
|
153
|
+
h('text', {
|
|
154
|
+
color: dim ? theme.dim : active ? theme.hoverText : theme.dim,
|
|
155
|
+
fontSize,
|
|
156
|
+
children: '\u25b8',
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Items at `depth`, walking `path` down through nested `items`. */
|
|
162
|
+
function levelItems(rootItems, path, depth) {
|
|
163
|
+
let items = rootItems;
|
|
164
|
+
for (let d = 0; d < depth; d++) items = items[path[d]]?.items ?? [];
|
|
165
|
+
return items;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* One level of a menu: an override-redirect `<popup>` at `rect` holding the
|
|
170
|
+
* items for `depth`, plus — recursively — its open submenu.
|
|
171
|
+
*
|
|
172
|
+
* Open state is a single `path` of active indices, one per level, rather
|
|
173
|
+
* than a flat index: `path.length` is how many levels are open, `path[d]`
|
|
174
|
+
* which row is active at level `d` (-1 for none). Moving the selection at
|
|
175
|
+
* any level truncates the path, which closes deeper levels for free.
|
|
176
|
+
*/
|
|
177
|
+
function MenuLevel({
|
|
178
|
+
rect,
|
|
179
|
+
rootItems,
|
|
180
|
+
path,
|
|
181
|
+
depth,
|
|
182
|
+
setPath,
|
|
183
|
+
onSelect,
|
|
184
|
+
onDismiss,
|
|
185
|
+
fontSize,
|
|
186
|
+
}) {
|
|
187
|
+
const theme = useTheme();
|
|
188
|
+
const items = levelItems(rootItems, path, depth);
|
|
189
|
+
const active = path[depth] ?? -1;
|
|
190
|
+
const childItems = items[active]?.items;
|
|
191
|
+
const childOpen = path.length > depth + 1 && childItems?.length > 0;
|
|
192
|
+
|
|
193
|
+
const activeRowRef = useRef(null);
|
|
194
|
+
const [childRect, setChildRect] = useState(null);
|
|
195
|
+
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (!childOpen) {
|
|
198
|
+
setChildRect(null);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const node = activeRowRef.current;
|
|
202
|
+
// the parent popup is always laid out by the time a submenu can be
|
|
203
|
+
// reached (it has to be hovered or arrowed into first), but guard
|
|
204
|
+
// anyway rather than anchoring off a zero rect
|
|
205
|
+
if (!node?.abs?.width) return;
|
|
206
|
+
setChildRect(
|
|
207
|
+
anchorRect(node, {
|
|
208
|
+
placement: 'right',
|
|
209
|
+
align: 'start',
|
|
210
|
+
offset: 0,
|
|
211
|
+
width: menuListWidth(node, childItems, fontSize),
|
|
212
|
+
height: menuListHeight(childItems),
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
}, [childOpen, active, depth, fontSize, rect.x, rect.y]);
|
|
216
|
+
|
|
217
|
+
// "safe polygon" hover: while a submenu is open, the triangle between the
|
|
218
|
+
// pointer and the submenu's near edge counts as still hovering the parent
|
|
219
|
+
// row, so moving diagonally across the rows in between does not close the
|
|
220
|
+
// submenu being aimed at (docs/components.md).
|
|
221
|
+
const apexRef = useRef(null);
|
|
222
|
+
const pendingRef = useRef(null);
|
|
223
|
+
|
|
224
|
+
const cancelPending = () => {
|
|
225
|
+
if (pendingRef.current) {
|
|
226
|
+
clearTimeout(pendingRef.current);
|
|
227
|
+
pendingRef.current = null;
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// any change to the open path — including the pointer reaching the
|
|
232
|
+
// submenu — retires a deferred switch: it would close what was reached
|
|
233
|
+
useEffect(() => cancelPending, []);
|
|
234
|
+
useEffect(cancelPending, [path.join(','), childOpen]);
|
|
235
|
+
|
|
236
|
+
const applyHover = (index) => {
|
|
237
|
+
const base = [...path.slice(0, depth), index];
|
|
238
|
+
// hovering a parent row opens its submenu with nothing selected inside
|
|
239
|
+
setPath(items[index]?.items?.length ? [...base, -1] : base);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const hover = (index, ev) => {
|
|
243
|
+
const point = screenPoint(ev);
|
|
244
|
+
if (
|
|
245
|
+
index !== active &&
|
|
246
|
+
childOpen &&
|
|
247
|
+
childRect &&
|
|
248
|
+
movingToward(point, apexRef.current, childRect)
|
|
249
|
+
) {
|
|
250
|
+
// heading for the open submenu: hold the switch, but not forever —
|
|
251
|
+
// a pointer that stops inside the triangle still meant this row
|
|
252
|
+
cancelPending();
|
|
253
|
+
pendingRef.current = setTimeout(() => {
|
|
254
|
+
pendingRef.current = null;
|
|
255
|
+
applyHover(index);
|
|
256
|
+
}, SAFE_HOVER_DELAY);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
cancelPending();
|
|
260
|
+
applyHover(index);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const move = (index, ev) => {
|
|
264
|
+
// over the row that owns the open submenu: remember where the pointer
|
|
265
|
+
// is, so its exit point becomes the apex of the polygon
|
|
266
|
+
if (index === active) {
|
|
267
|
+
apexRef.current = screenPoint(ev) ?? apexRef.current;
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
// over another row: mouseEnter only fired once, but the pointer is
|
|
271
|
+
// still moving — re-decide, so leaving the polygon switches at once
|
|
272
|
+
// instead of waiting out the delay
|
|
273
|
+
hover(index, ev);
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const choose = (item) => {
|
|
277
|
+
if (item.items?.length) {
|
|
278
|
+
setPath([...path.slice(0, depth), items.indexOf(item), -1]);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
onSelect(item);
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
return h(
|
|
285
|
+
'popup',
|
|
286
|
+
{
|
|
287
|
+
x: rect.x,
|
|
288
|
+
y: rect.y,
|
|
289
|
+
width: rect.width,
|
|
290
|
+
height: rect.height,
|
|
291
|
+
windowType: 'popup_menu',
|
|
292
|
+
backgroundColor: theme.background,
|
|
293
|
+
// the root level holds the pointer grab for the whole menu: a press
|
|
294
|
+
// anywhere else — including this app's own window frame, which
|
|
295
|
+
// belongs to the window manager — closes it instead of vanishing
|
|
296
|
+
// into whatever was clicked. Submenus need no grab of their own;
|
|
297
|
+
// owner-events still delivers their presses to them.
|
|
298
|
+
grab: depth === 0,
|
|
299
|
+
onDismiss: depth === 0 ? onDismiss : undefined,
|
|
300
|
+
},
|
|
301
|
+
h(
|
|
302
|
+
'box',
|
|
303
|
+
{
|
|
304
|
+
flexGrow: 1,
|
|
305
|
+
flexShrink: 1,
|
|
306
|
+
padding: MENU_PAD,
|
|
307
|
+
borderWidth: 1,
|
|
308
|
+
borderColor: theme.border,
|
|
309
|
+
backgroundColor: theme.background,
|
|
310
|
+
},
|
|
311
|
+
items.map((item, index) =>
|
|
312
|
+
h(MenuRow, {
|
|
313
|
+
key: item.separator ? `sep-${index}` : (item.key ?? item.label),
|
|
314
|
+
item,
|
|
315
|
+
active: index === active,
|
|
316
|
+
fontSize,
|
|
317
|
+
nodeRef: index === active ? activeRowRef : undefined,
|
|
318
|
+
onHover: (ev) => hover(index, ev),
|
|
319
|
+
onMove: (ev) => move(index, ev),
|
|
320
|
+
onSelect: choose,
|
|
321
|
+
}),
|
|
322
|
+
),
|
|
323
|
+
),
|
|
324
|
+
childOpen &&
|
|
325
|
+
childRect &&
|
|
326
|
+
h(MenuLevel, {
|
|
327
|
+
rect: childRect,
|
|
328
|
+
rootItems,
|
|
329
|
+
path,
|
|
330
|
+
depth: depth + 1,
|
|
331
|
+
setPath,
|
|
332
|
+
onSelect,
|
|
333
|
+
fontSize,
|
|
334
|
+
}),
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Keyboard handling shared by every menu, operating on the deepest open
|
|
340
|
+
* level: Up/Down move the active item (skipping separators and disabled
|
|
341
|
+
* entries, wrapping), Home/End jump to the ends, Right enters a submenu,
|
|
342
|
+
* Left leaves one, Enter/Space activate, Escape closes one level.
|
|
343
|
+
*
|
|
344
|
+
* Returns true when the key was consumed. Left and Right fall through when
|
|
345
|
+
* there is no submenu to enter or leave, so `MenuBar` can walk the bar.
|
|
346
|
+
*/
|
|
347
|
+
function handleMenuKey(
|
|
348
|
+
ev,
|
|
349
|
+
{ rootItems, path, setPath, select, close, typeAhead },
|
|
350
|
+
) {
|
|
351
|
+
const depth = path.length - 1;
|
|
352
|
+
if (depth < 0) return false;
|
|
353
|
+
const items = levelItems(rootItems, path, depth);
|
|
354
|
+
const active = path[depth];
|
|
355
|
+
const setActive = (i) => setPath([...path.slice(0, depth), i]);
|
|
356
|
+
const enterSubmenu = () => {
|
|
357
|
+
const sub = items[active]?.items;
|
|
358
|
+
if (!sub?.length) return false;
|
|
359
|
+
setPath([...path, nextSelectable(sub, -1, 1)]);
|
|
360
|
+
return true;
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
switch (ev.keysym) {
|
|
364
|
+
case XK_ESCAPE:
|
|
365
|
+
if (depth > 0) setPath(path.slice(0, -1));
|
|
366
|
+
else close();
|
|
367
|
+
return true;
|
|
368
|
+
case XK_DOWN:
|
|
369
|
+
setActive(nextSelectable(items, active, 1));
|
|
370
|
+
return true;
|
|
371
|
+
case XK_UP:
|
|
372
|
+
setActive(nextSelectable(items, active, -1));
|
|
373
|
+
return true;
|
|
374
|
+
case XK_HOME:
|
|
375
|
+
setActive(nextSelectable(items, -1, 1));
|
|
376
|
+
return true;
|
|
377
|
+
case XK_END:
|
|
378
|
+
setActive(nextSelectable(items, items.length, -1));
|
|
379
|
+
return true;
|
|
380
|
+
case XK_PAGE_UP:
|
|
381
|
+
case XK_PAGE_DOWN: {
|
|
382
|
+
// step a viewport's worth of rows, then settle on the nearest
|
|
383
|
+
// selectable entry in the direction of travel so a page never lands
|
|
384
|
+
// on a separator or a disabled row
|
|
385
|
+
const dir = ev.keysym === XK_PAGE_DOWN ? 1 : -1;
|
|
386
|
+
const page = MENU_PAGE_ROWS;
|
|
387
|
+
const from = active < 0 ? (dir > 0 ? -1 : items.length) : active;
|
|
388
|
+
const target = Math.min(items.length - 1, Math.max(0, from + dir * page));
|
|
389
|
+
setActive(
|
|
390
|
+
isSelectable(items[target])
|
|
391
|
+
? target
|
|
392
|
+
: nextSelectable(items, target - dir, dir),
|
|
393
|
+
);
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
case XK_RIGHT:
|
|
397
|
+
return enterSubmenu();
|
|
398
|
+
case XK_LEFT:
|
|
399
|
+
if (depth > 0) {
|
|
400
|
+
setPath(path.slice(0, -1));
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
return false;
|
|
404
|
+
default:
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
if (ev.codepoint === 32 || ev.keysym === XK_RETURN) {
|
|
408
|
+
if (enterSubmenu()) return true;
|
|
409
|
+
const item = items[active];
|
|
410
|
+
if (isSelectable(item)) select(item);
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const char = typeAheadChar(ev);
|
|
415
|
+
if (char && typeAhead) {
|
|
416
|
+
const i = typeAhead(char, items, active, (it) => it.label, isSelectable);
|
|
417
|
+
if (i >= 0) setActive(i);
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* <ContextMenu items>…</ContextMenu> — right-click anywhere in the children
|
|
425
|
+
* to open a menu at the pointer.
|
|
426
|
+
*
|
|
427
|
+
* The wrapper is focusable and takes focus when the menu opens, because the
|
|
428
|
+
* popup is override-redirect and never gets focus itself — the same trick
|
|
429
|
+
* `Select` uses to keep arrow keys working while its menu is open.
|
|
430
|
+
*/
|
|
431
|
+
export function ContextMenu({
|
|
432
|
+
items = [],
|
|
433
|
+
children,
|
|
434
|
+
onSelect,
|
|
435
|
+
fontSize = DEFAULT_LABEL_SIZE,
|
|
436
|
+
...boxProps
|
|
437
|
+
}) {
|
|
438
|
+
const ref = useRef(null);
|
|
439
|
+
const [rect, setRect] = useState(null);
|
|
440
|
+
const [path, setPath] = useState([]);
|
|
441
|
+
const typeAhead = useTypeAhead();
|
|
442
|
+
|
|
443
|
+
const close = () => {
|
|
444
|
+
setRect(null);
|
|
445
|
+
setPath([]);
|
|
446
|
+
};
|
|
447
|
+
const select = (item) => {
|
|
448
|
+
close();
|
|
449
|
+
item.onSelect?.(item);
|
|
450
|
+
onSelect?.(item);
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
const openAt = (ev) => {
|
|
454
|
+
const node = ref.current;
|
|
455
|
+
if (!node || !items.length) return;
|
|
456
|
+
const width = menuListWidth(node, items, fontSize);
|
|
457
|
+
const height = menuListHeight(items);
|
|
458
|
+
const screen = screenOf(node);
|
|
459
|
+
// anchored at the pointer rather than at a widget: clamp by hand, since
|
|
460
|
+
// there is no anchor rect to flip around
|
|
461
|
+
const x = ev.nativeEvent?.rootx ?? ev.x;
|
|
462
|
+
const y = ev.nativeEvent?.rooty ?? ev.y;
|
|
463
|
+
setRect({
|
|
464
|
+
x: screen ? Math.max(0, Math.min(x, screen.pixel_width - width)) : x,
|
|
465
|
+
y: screen ? Math.max(0, Math.min(y, screen.pixel_height - height)) : y,
|
|
466
|
+
width,
|
|
467
|
+
height,
|
|
468
|
+
});
|
|
469
|
+
setPath([-1]);
|
|
470
|
+
node.root?.events?.focus?.(node);
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
return h(
|
|
474
|
+
'box',
|
|
475
|
+
{
|
|
476
|
+
ref,
|
|
477
|
+
focusable: true,
|
|
478
|
+
onMouseDown: (ev) => {
|
|
479
|
+
if (ev.button === 3) openAt(ev);
|
|
480
|
+
else if (rect) close();
|
|
481
|
+
},
|
|
482
|
+
onKeyDown: (ev) => {
|
|
483
|
+
if (!rect) return;
|
|
484
|
+
handleMenuKey(ev, {
|
|
485
|
+
rootItems: items,
|
|
486
|
+
path,
|
|
487
|
+
setPath,
|
|
488
|
+
select,
|
|
489
|
+
close,
|
|
490
|
+
typeAhead,
|
|
491
|
+
});
|
|
492
|
+
},
|
|
493
|
+
onBlur: close,
|
|
494
|
+
...boxProps,
|
|
495
|
+
},
|
|
496
|
+
children,
|
|
497
|
+
rect &&
|
|
498
|
+
path.length > 0 &&
|
|
499
|
+
h(MenuLevel, {
|
|
500
|
+
rect,
|
|
501
|
+
rootItems: items,
|
|
502
|
+
path,
|
|
503
|
+
depth: 0,
|
|
504
|
+
setPath,
|
|
505
|
+
fontSize,
|
|
506
|
+
onSelect: select,
|
|
507
|
+
onDismiss: close,
|
|
508
|
+
}),
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* <MenuBar menus={[{ label, items }]}/> — a horizontal bar of pull-down
|
|
514
|
+
* menus. Click or Enter opens; with one open, hovering another switches to
|
|
515
|
+
* it and Left/Right walk the bar; the usual menu keys work inside.
|
|
516
|
+
*/
|
|
517
|
+
export function MenuBar({
|
|
518
|
+
menus = [],
|
|
519
|
+
onSelect,
|
|
520
|
+
fontSize = DEFAULT_LABEL_SIZE,
|
|
521
|
+
...boxProps
|
|
522
|
+
}) {
|
|
523
|
+
const theme = useTheme();
|
|
524
|
+
const [openIndex, setOpenIndex] = useState(-1);
|
|
525
|
+
const [rect, setRect] = useState(null);
|
|
526
|
+
const [path, setPath] = useState([]);
|
|
527
|
+
const refs = useRef([]);
|
|
528
|
+
const typeAhead = useTypeAhead();
|
|
529
|
+
|
|
530
|
+
const items = openIndex >= 0 ? (menus[openIndex]?.items ?? []) : [];
|
|
531
|
+
|
|
532
|
+
const close = () => {
|
|
533
|
+
setOpenIndex(-1);
|
|
534
|
+
setRect(null);
|
|
535
|
+
setPath([]);
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const openMenu = (index) => {
|
|
539
|
+
const node = refs.current[index];
|
|
540
|
+
const menu = menus[index];
|
|
541
|
+
if (!node || !menu?.items?.length) return;
|
|
542
|
+
const width = menuListWidth(node, menu.items, fontSize);
|
|
543
|
+
const height = menuListHeight(menu.items);
|
|
544
|
+
const next = anchorRect(node, { placement: 'bottom', width, height });
|
|
545
|
+
if (!next) return;
|
|
546
|
+
setRect(next);
|
|
547
|
+
setOpenIndex(index);
|
|
548
|
+
setPath([-1]);
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
const select = (item) => {
|
|
552
|
+
close();
|
|
553
|
+
item.onSelect?.(item);
|
|
554
|
+
onSelect?.(item);
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const moveMenu = (dir) => {
|
|
558
|
+
if (!menus.length) return;
|
|
559
|
+
const n = menus.length;
|
|
560
|
+
openMenu((openIndex + dir + n) % n);
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
return h(
|
|
564
|
+
'box',
|
|
565
|
+
{
|
|
566
|
+
flexDirection: 'row',
|
|
567
|
+
alignItems: 'center',
|
|
568
|
+
backgroundColor: theme.surfaceHover,
|
|
569
|
+
...boxProps,
|
|
570
|
+
},
|
|
571
|
+
menus.map((menu, index) =>
|
|
572
|
+
h(
|
|
573
|
+
'box',
|
|
574
|
+
{
|
|
575
|
+
key: menu.label,
|
|
576
|
+
ref: (node) => {
|
|
577
|
+
refs.current[index] = node;
|
|
578
|
+
},
|
|
579
|
+
focusable: true,
|
|
580
|
+
cursor: 'pointer',
|
|
581
|
+
paddingLeft: 10,
|
|
582
|
+
paddingRight: 10,
|
|
583
|
+
paddingTop: 6,
|
|
584
|
+
paddingBottom: 6,
|
|
585
|
+
backgroundColor:
|
|
586
|
+
openIndex === index ? theme.hoverBackground : undefined,
|
|
587
|
+
onClick: () => (openIndex === index ? close() : openMenu(index)),
|
|
588
|
+
// with a menu already open, hovering the bar switches menus —
|
|
589
|
+
// standard pull-down behaviour
|
|
590
|
+
onMouseEnter: () => {
|
|
591
|
+
if (openIndex >= 0 && openIndex !== index) openMenu(index);
|
|
592
|
+
},
|
|
593
|
+
onBlur: () => {
|
|
594
|
+
if (openIndex === index) close();
|
|
595
|
+
},
|
|
596
|
+
onKeyDown: (ev) => {
|
|
597
|
+
if (openIndex !== index) {
|
|
598
|
+
if (ev.codepoint === 32 || ev.keysym === XK_RETURN) {
|
|
599
|
+
openMenu(index);
|
|
600
|
+
} else if (ev.keysym === XK_LEFT) moveMenu(-1);
|
|
601
|
+
else if (ev.keysym === XK_RIGHT) moveMenu(1);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
// the menu gets first refusal: Left leaves a submenu and Right
|
|
605
|
+
// enters one, and only when there is no submenu to move through
|
|
606
|
+
// do they walk the bar
|
|
607
|
+
const consumed = handleMenuKey(ev, {
|
|
608
|
+
rootItems: items,
|
|
609
|
+
path,
|
|
610
|
+
setPath,
|
|
611
|
+
select,
|
|
612
|
+
close,
|
|
613
|
+
typeAhead,
|
|
614
|
+
});
|
|
615
|
+
if (consumed) return;
|
|
616
|
+
if (ev.keysym === XK_LEFT) moveMenu(-1);
|
|
617
|
+
else if (ev.keysym === XK_RIGHT) moveMenu(1);
|
|
618
|
+
},
|
|
619
|
+
},
|
|
620
|
+
h(
|
|
621
|
+
'text',
|
|
622
|
+
{
|
|
623
|
+
color: openIndex === index ? theme.hoverText : theme.text,
|
|
624
|
+
fontSize,
|
|
625
|
+
},
|
|
626
|
+
menu.label,
|
|
627
|
+
),
|
|
628
|
+
),
|
|
629
|
+
),
|
|
630
|
+
openIndex >= 0 &&
|
|
631
|
+
rect &&
|
|
632
|
+
path.length > 0 &&
|
|
633
|
+
h(MenuLevel, {
|
|
634
|
+
rect,
|
|
635
|
+
rootItems: items,
|
|
636
|
+
path,
|
|
637
|
+
depth: 0,
|
|
638
|
+
setPath,
|
|
639
|
+
fontSize,
|
|
640
|
+
onSelect: select,
|
|
641
|
+
onDismiss: close,
|
|
642
|
+
}),
|
|
643
|
+
);
|
|
644
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
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 from 'react';
|
|
6
|
+
import { useTheme } from './theme.js';
|
|
7
|
+
|
|
8
|
+
const h = React.createElement;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* <ProgressBar value/> — determinate progress, value in [0, 1].
|
|
12
|
+
*/
|
|
13
|
+
export function ProgressBar({
|
|
14
|
+
value = 0,
|
|
15
|
+
color,
|
|
16
|
+
trackColor,
|
|
17
|
+
height = 8,
|
|
18
|
+
...boxProps
|
|
19
|
+
}) {
|
|
20
|
+
const theme = useTheme();
|
|
21
|
+
const clamped = Math.min(1, Math.max(0, value));
|
|
22
|
+
// The fill is expressed as flex ratios rather than a percentage width: a
|
|
23
|
+
// percentage child resolves against the space available while the parent
|
|
24
|
+
// is still being measured, so it fed back into the track's intrinsic
|
|
25
|
+
// width and made whatever contained the bar grow — a dashboard card with
|
|
26
|
+
// a fuller bar came out wider than one with an empty bar, and overflowed
|
|
27
|
+
// the window. Two flexible boxes with a zero basis contribute nothing.
|
|
28
|
+
return h(
|
|
29
|
+
'box',
|
|
30
|
+
{
|
|
31
|
+
height,
|
|
32
|
+
borderRadius: height / 2,
|
|
33
|
+
backgroundColor: trackColor ?? theme.track,
|
|
34
|
+
overflow: 'hidden',
|
|
35
|
+
flexDirection: 'row',
|
|
36
|
+
minWidth: 0,
|
|
37
|
+
...boxProps,
|
|
38
|
+
},
|
|
39
|
+
h('box', {
|
|
40
|
+
flexGrow: clamped,
|
|
41
|
+
flexShrink: 0,
|
|
42
|
+
flexBasis: 0,
|
|
43
|
+
borderRadius: height / 2,
|
|
44
|
+
backgroundColor: color ?? theme.accent,
|
|
45
|
+
}),
|
|
46
|
+
h('box', { flexGrow: 1 - clamped, flexShrink: 0, flexBasis: 0 }),
|
|
47
|
+
);
|
|
48
|
+
}
|