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,95 @@
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, { useContext, useEffect, useMemo, useRef } from 'react';
6
+ import { labelContent, useControl, useTheme } from './theme.js';
7
+ import { XK_DOWN, XK_LEFT, XK_RIGHT, XK_UP } from './keys.js';
8
+
9
+ const h = React.createElement;
10
+
11
+ const RadioGroupContext = React.createContext(null);
12
+
13
+ /**
14
+ * <RadioGroup value onChange>…<Radio value=…>label</Radio>…</RadioGroup> —
15
+ * exclusive choice. Arrow keys move the selection through the group in
16
+ * mount order (wrapping); click or Space selects the focused radio.
17
+ */
18
+ export function RadioGroup({ value, onChange, children, ...boxProps }) {
19
+ const order = useRef([]).current;
20
+ const ctx = useMemo(
21
+ () => ({
22
+ value,
23
+ onChange,
24
+ register: (v) => {
25
+ order.push(v);
26
+ return () => order.splice(order.indexOf(v), 1);
27
+ },
28
+ move: (delta) => {
29
+ if (order.length === 0) return;
30
+ const i = order.indexOf(value);
31
+ const next = order[(i + delta + order.length) % order.length];
32
+ if (next !== value) onChange?.(next);
33
+ },
34
+ }),
35
+ [value, onChange, order],
36
+ );
37
+ return h(
38
+ 'box',
39
+ { gap: 6, ...boxProps },
40
+ h(RadioGroupContext.Provider, { value: ctx }, children),
41
+ );
42
+ }
43
+
44
+ export function Radio({ value, children, label, disabled = false }) {
45
+ const theme = useTheme();
46
+ const group = useContext(RadioGroupContext);
47
+ if (!group) {
48
+ throw new Error('react-x11: <Radio> must be inside a <RadioGroup>');
49
+ }
50
+ useEffect(() => group.register(value), [group, value]);
51
+ const selected = group.value === value;
52
+ const { focused, props } = useControl(disabled, () => {
53
+ if (!selected) group.onChange?.(value);
54
+ });
55
+ const onKeyDown = props.onKeyDown;
56
+ if (onKeyDown) {
57
+ props.onKeyDown = (ev) => {
58
+ if (ev.keysym === XK_DOWN || ev.keysym === XK_RIGHT) group.move(1);
59
+ else if (ev.keysym === XK_UP || ev.keysym === XK_LEFT) group.move(-1);
60
+ else onKeyDown(ev);
61
+ };
62
+ }
63
+ const fill = disabled ? theme.dim : theme.accent;
64
+ return h(
65
+ 'box',
66
+ { flexDirection: 'row', alignItems: 'center', gap: 8, ...props },
67
+ h(
68
+ 'box',
69
+ {
70
+ width: 16,
71
+ height: 16,
72
+ borderRadius: 8,
73
+ borderWidth: 1,
74
+ borderColor: selected
75
+ ? fill
76
+ : focused
77
+ ? theme.borderActive
78
+ : theme.border,
79
+ backgroundColor: theme.background,
80
+ alignItems: 'center',
81
+ justifyContent: 'center',
82
+ },
83
+ selected &&
84
+ h('box', {
85
+ width: 8,
86
+ height: 8,
87
+ borderRadius: 4,
88
+ backgroundColor: fill,
89
+ }),
90
+ ),
91
+ labelContent(children ?? label, {
92
+ color: disabled ? theme.dim : theme.text,
93
+ }),
94
+ );
95
+ }
@@ -0,0 +1,272 @@
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 { useAnchor } from './anchor.js';
8
+ import { typeAheadChar, useTypeAhead } from './typeahead.js';
9
+ import {
10
+ XK_DOWN,
11
+ XK_END,
12
+ XK_ESCAPE,
13
+ XK_HOME,
14
+ XK_PAGE_DOWN,
15
+ XK_PAGE_UP,
16
+ XK_RETURN,
17
+ XK_UP,
18
+ } from './keys.js';
19
+
20
+ const h = React.createElement;
21
+
22
+ const ITEM_HEIGHT = 28;
23
+
24
+ const MAX_MENU_HEIGHT = 220;
25
+
26
+ function normalizeOption(option) {
27
+ return typeof option === 'object' && option !== null
28
+ ? option
29
+ : { value: option, label: String(option) };
30
+ }
31
+
32
+ function Option({ option, selected, active, onPick, onHover, nodeRef }) {
33
+ const theme = useTheme();
34
+ // one highlight, shared by pointer and keyboard: hovering moves the
35
+ // active index rather than tracking a second, competing state
36
+ return h(
37
+ 'box',
38
+ {
39
+ ref: nodeRef,
40
+ height: ITEM_HEIGHT,
41
+ justifyContent: 'center',
42
+ paddingLeft: 10,
43
+ cursor: 'pointer',
44
+ backgroundColor: active ? theme.hoverBackground : theme.background,
45
+ onMouseEnter: () => onHover?.(),
46
+ onClick: () => onPick(option),
47
+ },
48
+ h(
49
+ 'text',
50
+ {
51
+ color: active ? theme.hoverText : theme.text,
52
+ fontWeight: selected ? 'bold' : 'normal',
53
+ },
54
+ option.label,
55
+ ),
56
+ );
57
+ }
58
+
59
+ /**
60
+ * <Select value options onChange placeholder width …boxProps> — a dropdown
61
+ * built on <popup>. The menu is a real override-redirect X11 window
62
+ * anchored below the trigger (owner window position + trigger rect).
63
+ * Closes on pick, Escape, toggling the trigger, or focus loss within the
64
+ * owner window.
65
+ *
66
+ * Keyboard (the trigger keeps focus while the menu is open — the popup is
67
+ * override-redirect and never takes it): Up/Down open the menu, then move
68
+ * the active option with wrapping; Home/End jump to the ends; Enter/Space
69
+ * pick the active option (or open the menu when closed); Escape closes.
70
+ * The active option is scrolled into view.
71
+ */
72
+ export function Select({
73
+ value,
74
+ options = [],
75
+ onChange,
76
+ placeholder = 'Select…',
77
+ width,
78
+ ...boxProps
79
+ }) {
80
+ const theme = useTheme();
81
+ const [open, setOpen] = useState(false);
82
+ const [anchor, setAnchor] = useState(null);
83
+ const [focused, setFocused] = useState(false);
84
+ const [activeIndex, setActiveIndex] = useState(-1);
85
+ const triggerRef = useRef(null);
86
+ const scrollRef = useRef(null);
87
+ const activeRef = useRef(null);
88
+
89
+ const measureAnchor = useAnchor(triggerRef);
90
+ const typeAhead = useTypeAhead();
91
+
92
+ const normalized = options.map(normalizeOption);
93
+ const current = normalized.find((o) => o.value === value);
94
+ const menuHeight = Math.min(
95
+ normalized.length * ITEM_HEIGHT + 8,
96
+ MAX_MENU_HEIGHT,
97
+ );
98
+
99
+ const close = () => setOpen(false);
100
+ const openMenu = () => {
101
+ const node = triggerRef.current;
102
+ if (!node) return;
103
+ // shared anchoring: also flips the menu above the trigger when there is
104
+ // no room below, instead of opening off the bottom of the screen
105
+ const rect = measureAnchor({ placement: 'bottom', height: menuHeight + 2 });
106
+ if (!rect) return;
107
+ setAnchor(rect);
108
+ const selected = normalized.findIndex((o) => o.value === value);
109
+ setActiveIndex(selected >= 0 ? selected : 0);
110
+ setOpen(true);
111
+ };
112
+ const toggle = () => (open ? close() : openMenu());
113
+
114
+ const pick = (option) => {
115
+ close();
116
+ if (option.value !== value) onChange?.(option.value);
117
+ };
118
+
119
+ const move = (delta) => {
120
+ const count = normalized.length;
121
+ if (!count) return;
122
+ setActiveIndex((i) => (i < 0 ? 0 : (i + delta + count) % count));
123
+ };
124
+
125
+ const onKeyDown = (ev) => {
126
+ const count = normalized.length;
127
+ switch (ev.keysym) {
128
+ case XK_ESCAPE:
129
+ if (open) close();
130
+ return;
131
+ case XK_DOWN:
132
+ case XK_UP:
133
+ if (open) move(ev.keysym === XK_DOWN ? 1 : -1);
134
+ else openMenu();
135
+ return;
136
+ case XK_HOME:
137
+ case XK_END:
138
+ if (open && count)
139
+ setActiveIndex(ev.keysym === XK_HOME ? 0 : count - 1);
140
+ return;
141
+ case XK_PAGE_UP:
142
+ case XK_PAGE_DOWN: {
143
+ // a page is what the menu shows at once, so paging lines up with
144
+ // what the user can see rather than an arbitrary count
145
+ if (!open || !count) return;
146
+ const page = Math.max(1, Math.floor(MAX_MENU_HEIGHT / ITEM_HEIGHT));
147
+ const dir = ev.keysym === XK_PAGE_DOWN ? 1 : -1;
148
+ const from = activeIndex < 0 ? 0 : activeIndex;
149
+ setActiveIndex(Math.min(count - 1, Math.max(0, from + dir * page)));
150
+ return;
151
+ }
152
+ default:
153
+ break;
154
+ }
155
+ if (ev.codepoint === 32 || ev.keysym === XK_RETURN) {
156
+ const option = open ? normalized[activeIndex] : null;
157
+ if (option) pick(option);
158
+ else toggle();
159
+ return;
160
+ }
161
+
162
+ // type-ahead: with the menu open it moves the highlight; closed, it
163
+ // changes the value outright, the way a native select does
164
+ const char = typeAheadChar(ev);
165
+ if (!char) return;
166
+ const current = open
167
+ ? activeIndex
168
+ : normalized.findIndex((o) => o.value === value);
169
+ const i = typeAhead(char, normalized, current, (o) => o.label);
170
+ if (i < 0) return;
171
+ if (open) setActiveIndex(i);
172
+ else if (normalized[i].value !== value) onChange?.(normalized[i].value);
173
+ };
174
+
175
+ // keep the highlighted option visible while arrowing through a menu that
176
+ // overflows MAX_MENU_HEIGHT (resolved on the popup's next layout pass)
177
+ useEffect(() => {
178
+ if (open) scrollRef.current?.scrollIntoView(activeRef.current);
179
+ }, [open, activeIndex]);
180
+
181
+ return h(
182
+ 'box',
183
+ {
184
+ ref: triggerRef,
185
+ focusable: true,
186
+ cursor: 'pointer',
187
+ flexDirection: 'row',
188
+ alignItems: 'center',
189
+ gap: 8,
190
+ width,
191
+ padding: 8,
192
+ paddingLeft: 10,
193
+ paddingRight: 10,
194
+ borderWidth: 1,
195
+ borderRadius: 4,
196
+ borderColor: focused || open ? theme.borderActive : theme.border,
197
+ backgroundColor: theme.background,
198
+ onClick: toggle,
199
+ onFocus: () => setFocused(true),
200
+ onBlur: () => {
201
+ setFocused(false);
202
+ close();
203
+ },
204
+ onKeyDown,
205
+ ...boxProps,
206
+ },
207
+ h(
208
+ 'text',
209
+ { color: current ? theme.text : theme.dim },
210
+ current ? current.label : placeholder,
211
+ ),
212
+ h('box', { flexGrow: 1 }),
213
+ h('canvas', {
214
+ width: 10,
215
+ height: 6,
216
+ onDraw: (ctx, { width: w, height: hgt }) => {
217
+ ctx.fillStyle = theme.dim;
218
+ ctx.beginPath();
219
+ ctx.moveTo(0, 0);
220
+ ctx.lineTo(w, 0);
221
+ ctx.lineTo(w / 2, hgt);
222
+ ctx.closePath();
223
+ ctx.fill();
224
+ },
225
+ }),
226
+ open &&
227
+ anchor &&
228
+ h(
229
+ 'popup',
230
+ {
231
+ x: anchor.x,
232
+ y: anchor.y,
233
+ width: anchor.width,
234
+ height: menuHeight + 2,
235
+ backgroundColor: theme.background,
236
+ // a press anywhere else closes the menu, the window frame
237
+ // included — see PopupNode's `grab`
238
+ grab: true,
239
+ onDismiss: close,
240
+ },
241
+ h(
242
+ 'box',
243
+ {
244
+ flexGrow: 1,
245
+ // yoga's default flexShrink is 0: without this the frame keeps
246
+ // its content height, the scrollview inside it never shrinks to
247
+ // the viewport, and a menu longer than MAX_MENU_HEIGHT gets
248
+ // clipped by the popup's edge instead of scrolling
249
+ flexShrink: 1,
250
+ borderWidth: 1,
251
+ borderColor: theme.border,
252
+ backgroundColor: theme.background,
253
+ },
254
+ h(
255
+ 'scrollview',
256
+ { ref: scrollRef, flexGrow: 1, padding: 4 },
257
+ normalized.map((option, index) =>
258
+ h(Option, {
259
+ key: String(option.value),
260
+ option,
261
+ selected: option.value === value,
262
+ active: index === activeIndex,
263
+ nodeRef: index === activeIndex ? activeRef : undefined,
264
+ onHover: () => setActiveIndex(index),
265
+ onPick: pick,
266
+ }),
267
+ ),
268
+ ),
269
+ ),
270
+ ),
271
+ );
272
+ }
@@ -0,0 +1,177 @@
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, { useRef, useState } from 'react';
6
+ import { useTheme } from './theme.js';
7
+ import {
8
+ XK_DOWN,
9
+ XK_END,
10
+ XK_HOME,
11
+ XK_LEFT,
12
+ XK_PAGE_DOWN,
13
+ XK_PAGE_UP,
14
+ XK_RIGHT,
15
+ XK_UP,
16
+ } from './keys.js';
17
+
18
+ const h = React.createElement;
19
+
20
+ const SLIDER_THUMB = 16;
21
+
22
+ /**
23
+ * <Slider value min max step onChange disabled …boxProps> — draggable
24
+ * value control.
25
+ *
26
+ * Dragging uses pointer capture (`ev.capturePointer()`): once the press
27
+ * lands, move and up events keep coming to the track even when the pointer
28
+ * leaves it, so the thumb still tracks a pointer that has wandered far
29
+ * outside the widget — and releasing out there still ends the drag.
30
+ *
31
+ * Keyboard: arrows step, Home/End jump to the ends, PageUp/PageDown move
32
+ * by ten steps.
33
+ */
34
+ export function Slider({
35
+ value = 0,
36
+ min = 0,
37
+ max = 100,
38
+ step = 1,
39
+ onChange,
40
+ disabled = false,
41
+ width,
42
+ height = 4,
43
+ ...boxProps
44
+ }) {
45
+ const theme = useTheme();
46
+ const [focused, setFocused] = useState(false);
47
+ const [dragging, setDragging] = useState(false);
48
+ const trackRef = useRef(null);
49
+
50
+ const span = max - min || 1;
51
+ const clamp = (v) => Math.min(max, Math.max(min, v));
52
+ const quantize = (v) => {
53
+ if (!step) return clamp(v);
54
+ // round to the step grid measured from min, then trim float noise so
55
+ // 0.1-style steps do not accumulate 0.30000000000000004
56
+ const snapped = min + Math.round((v - min) / step) * step;
57
+ const decimals = (String(step).split('.')[1] ?? '').length;
58
+ return clamp(decimals ? Number(snapped.toFixed(decimals)) : snapped);
59
+ };
60
+ const fraction = (clamp(value) - min) / span;
61
+
62
+ const emit = (next) => {
63
+ if (next !== value) onChange?.(next);
64
+ };
65
+
66
+ /** Pointer x -> value, using the track's laid-out rect. */
67
+ const valueAt = (ev) => {
68
+ const node = trackRef.current;
69
+ if (!node?.abs?.width) return value;
70
+ // the thumb is centred on the value, so the usable travel is the track
71
+ // minus one thumb width — otherwise min/max are unreachable at the ends
72
+ const travel = Math.max(1, node.abs.width - SLIDER_THUMB);
73
+ const x = ev.x - node.abs.x - SLIDER_THUMB / 2;
74
+ return quantize(min + (Math.min(travel, Math.max(0, x)) / travel) * span);
75
+ };
76
+
77
+ const controlProps = disabled
78
+ ? {}
79
+ : {
80
+ focusable: true,
81
+ cursor: 'pointer',
82
+ onFocus: () => setFocused(true),
83
+ onBlur: () => setFocused(false),
84
+ onMouseDown: (ev) => {
85
+ ev.capturePointer();
86
+ setDragging(true);
87
+ emit(valueAt(ev));
88
+ },
89
+ onMouseMove: (ev) => {
90
+ if (dragging) emit(valueAt(ev));
91
+ },
92
+ onMouseUp: () => setDragging(false),
93
+ onKeyDown: (ev) => {
94
+ const big = (step || 1) * 10;
95
+ switch (ev.keysym) {
96
+ case XK_LEFT:
97
+ case XK_DOWN:
98
+ emit(quantize(clamp(value - (step || 1))));
99
+ return;
100
+ case XK_RIGHT:
101
+ case XK_UP:
102
+ emit(quantize(clamp(value + (step || 1))));
103
+ return;
104
+ case XK_HOME:
105
+ emit(min);
106
+ return;
107
+ case XK_END:
108
+ emit(max);
109
+ return;
110
+ case XK_PAGE_UP:
111
+ emit(quantize(clamp(value + big)));
112
+ return;
113
+ case XK_PAGE_DOWN:
114
+ emit(quantize(clamp(value - big)));
115
+ return;
116
+ default:
117
+ break;
118
+ }
119
+ },
120
+ };
121
+
122
+ return h(
123
+ 'box',
124
+ {
125
+ ref: trackRef,
126
+ width,
127
+ height: SLIDER_THUMB,
128
+ minWidth: 0,
129
+ justifyContent: 'center',
130
+ ...controlProps,
131
+ ...boxProps,
132
+ },
133
+ // track
134
+ h(
135
+ 'box',
136
+ {
137
+ height,
138
+ borderRadius: height / 2,
139
+ backgroundColor: theme.track,
140
+ flexDirection: 'row',
141
+ alignItems: 'center',
142
+ pointerEvents: 'none',
143
+ },
144
+ // flex ratios, not a percentage width: a percentage child resolves
145
+ // against the space available while the track is still being
146
+ // measured, so it fed back into the slider's own intrinsic width —
147
+ // at value = max the control grew, which moved the handle, which
148
+ // changed the value, and a drag turned into an oscillation
149
+ h('box', {
150
+ flexGrow: fraction,
151
+ flexShrink: 0,
152
+ flexBasis: 0,
153
+ height,
154
+ borderRadius: height / 2,
155
+ backgroundColor: disabled ? theme.dim : theme.accent,
156
+ }),
157
+ h('box', { flexGrow: 1 - fraction, flexShrink: 0, flexBasis: 0 }),
158
+ ),
159
+ // thumb, centred on the value within the same travel the math uses
160
+ h('box', {
161
+ position: 'absolute',
162
+ left: `${fraction * 100}%`,
163
+ marginLeft: -SLIDER_THUMB * fraction,
164
+ width: SLIDER_THUMB,
165
+ height: SLIDER_THUMB,
166
+ borderRadius: SLIDER_THUMB / 2,
167
+ borderWidth: 1,
168
+ borderColor: disabled
169
+ ? theme.border
170
+ : focused || dragging
171
+ ? theme.accentHover
172
+ : theme.border,
173
+ backgroundColor: disabled ? theme.surfaceHover : theme.background,
174
+ pointerEvents: 'none',
175
+ }),
176
+ );
177
+ }
@@ -0,0 +1,49 @@
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 { useControl, useTheme } from './theme.js';
7
+
8
+ const h = React.createElement;
9
+
10
+ /**
11
+ * <Switch checked onChange disabled/> — Checkbox semantics in a sliding
12
+ * pill; the thumb sits at the end matching the state.
13
+ */
14
+ export function Switch({
15
+ checked = false,
16
+ onChange,
17
+ disabled = false,
18
+ ...boxProps
19
+ }) {
20
+ const theme = useTheme();
21
+ const { focused, props } = useControl(disabled, () => onChange?.(!checked));
22
+ return h(
23
+ 'box',
24
+ {
25
+ width: 36,
26
+ height: 20,
27
+ borderRadius: 10,
28
+ padding: 2,
29
+ flexDirection: 'row',
30
+ alignItems: 'center',
31
+ justifyContent: checked ? 'flex-end' : 'flex-start',
32
+ backgroundColor: disabled
33
+ ? theme.track
34
+ : checked
35
+ ? theme.accent
36
+ : focused
37
+ ? theme.dim
38
+ : theme.border,
39
+ ...props,
40
+ ...boxProps,
41
+ },
42
+ h('box', {
43
+ width: 16,
44
+ height: 16,
45
+ borderRadius: 8,
46
+ backgroundColor: theme.background,
47
+ }),
48
+ );
49
+ }