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.
@@ -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
+ }