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,146 @@
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
+ measureLabel,
10
+ movingToward,
11
+ SAFE_HOVER_DELAY,
12
+ screenPoint,
13
+ useAnchor,
14
+ } from './anchor.js';
15
+
16
+ const h = React.createElement;
17
+
18
+ const TOOLTIP_PADDING_X = 8;
19
+
20
+ const TOOLTIP_PADDING_Y = 4;
21
+
22
+ /**
23
+ * <Tooltip label placement delay>…</Tooltip> — a hover hint in a `<popup>`,
24
+ * so it can extend past the owner window's bounds.
25
+ *
26
+ * Wraps its children in a row box that carries the hover handlers and the
27
+ * anchor ref. Shows after `delay` ms of hover, hides immediately on leave
28
+ * (and on mousedown — a tooltip lingering over a menu you just opened is
29
+ * the classic annoyance). `placement` flips automatically near a screen
30
+ * edge, via the same `useAnchor` math `Select` uses.
31
+ *
32
+ * The popup is sized from the measured label, since a `<popup>` is a real
33
+ * X window and needs its size up front rather than after layout.
34
+ */
35
+ export function Tooltip({
36
+ label,
37
+ children,
38
+ placement = 'top',
39
+ delay = 500,
40
+ fontSize = DEFAULT_LABEL_SIZE,
41
+ ...boxProps
42
+ }) {
43
+ const theme = useTheme();
44
+ const ref = useRef(null);
45
+ const measureAnchor = useAnchor(ref);
46
+ const [rect, setRect] = useState(null);
47
+ const timer = useRef(null);
48
+
49
+ const cancel = () => {
50
+ if (timer.current) {
51
+ clearTimeout(timer.current);
52
+ timer.current = null;
53
+ }
54
+ };
55
+ const hide = () => {
56
+ cancel();
57
+ setRect(null);
58
+ };
59
+
60
+ // safe-polygon hover (docs/components.md): leaving the trigger *toward*
61
+ // the tooltip keeps it up, so a tooltip with content in it can be
62
+ // reached instead of vanishing the moment the pointer moves
63
+ const apex = useRef(null);
64
+ const hideSoon = () => {
65
+ cancel();
66
+ timer.current = setTimeout(() => {
67
+ timer.current = null;
68
+ setRect(null);
69
+ }, SAFE_HOVER_DELAY);
70
+ };
71
+ const onMouseMove = (ev) => {
72
+ apex.current = screenPoint(ev) ?? apex.current;
73
+ };
74
+ const onMouseLeave = (ev) => {
75
+ if (rect && movingToward(screenPoint(ev), apex.current, rect)) hideSoon();
76
+ else hide();
77
+ };
78
+
79
+ // a pending timer must not outlive the component
80
+ useEffect(() => cancel, []);
81
+
82
+ const show = () => {
83
+ const node = ref.current;
84
+ if (!node || !label) return;
85
+ const text = measureLabel(node, label, { size: fontSize });
86
+ const width = Math.ceil(text.width) + TOOLTIP_PADDING_X * 2 + 2;
87
+ const height = Math.ceil(text.height) + TOOLTIP_PADDING_Y * 2 + 2;
88
+ const next = measureAnchor({ placement, align: 'center', width, height });
89
+ if (next) setRect(next);
90
+ };
91
+
92
+ const onMouseEnter = () => {
93
+ cancel();
94
+ timer.current = setTimeout(() => {
95
+ timer.current = null;
96
+ show();
97
+ }, delay);
98
+ };
99
+
100
+ return h(
101
+ 'box',
102
+ {
103
+ ref,
104
+ flexDirection: 'row',
105
+ alignItems: 'center',
106
+ onMouseEnter,
107
+ onMouseMove,
108
+ onMouseLeave,
109
+ onMouseDown: hide,
110
+ ...boxProps,
111
+ },
112
+ children,
113
+ rect &&
114
+ h(
115
+ 'popup',
116
+ {
117
+ x: rect.x,
118
+ y: rect.y,
119
+ width: rect.width,
120
+ height: rect.height,
121
+ windowType: 'tooltip',
122
+ backgroundColor: theme.text,
123
+ },
124
+ h(
125
+ 'box',
126
+ {
127
+ // the pointer reaching the tooltip keeps it up; leaving it
128
+ // dismisses, as if the trigger had been left
129
+ onMouseEnter: cancel,
130
+ onMouseLeave: hide,
131
+ flexGrow: 1,
132
+ borderWidth: 1,
133
+ borderColor: theme.text,
134
+ borderRadius: 3,
135
+ backgroundColor: theme.text,
136
+ justifyContent: 'center',
137
+ paddingLeft: TOOLTIP_PADDING_X,
138
+ paddingRight: TOOLTIP_PADDING_X,
139
+ },
140
+ h('text', { color: theme.background, fontSize }, label),
141
+ ),
142
+ ),
143
+ );
144
+ }
145
+
146
+ // --- menus ------------------------------------------------------------------
@@ -0,0 +1,211 @@
1
+ // Popup geometry: where to put a <popup> anchored to a drawn node, and
2
+ // how big to make it around a measured label.
3
+
4
+ import { useCallback } from 'react';
5
+
6
+ /** The X screen the node's window lives on, if reachable (the smoke-test
7
+ * mock app has no screen geometry — callers must cope with null). */
8
+ export function screenOf(node) {
9
+ const app = node?.app;
10
+ const screen = (app?.display ?? app?.X?.display)?.screen?.[0];
11
+ return screen?.pixel_width ? screen : null;
12
+ }
13
+
14
+ /**
15
+ * Where to put a `<popup>` anchored to a drawn node, in **screen**
16
+ * coordinates: the owner window's position plus the node's laid-out rect.
17
+ *
18
+ * `placement` is a preference, not a promise — a menu near the bottom of
19
+ * the screen flips above its trigger rather than opening off-screen, and
20
+ * the result is clamped into the screen either way. The chosen side comes
21
+ * back as `placement` so the caller can style accordingly.
22
+ */
23
+ export function anchorRect(node, options = {}) {
24
+ if (!node?.abs) return null;
25
+ const {
26
+ placement = 'bottom',
27
+ align = 'start',
28
+ offset = 2,
29
+ width = node.abs.width,
30
+ height = 0,
31
+ } = options;
32
+
33
+ const win = node.root?.window;
34
+ const ax = (win?.x ?? 0) + node.abs.x;
35
+ const ay = (win?.y ?? 0) + node.abs.y;
36
+ const aw = node.abs.width;
37
+ const ah = node.abs.height;
38
+
39
+ const screen = screenOf(node);
40
+ const sw = screen?.pixel_width;
41
+ const sh = screen?.pixel_height;
42
+
43
+ const alignAlong = (start, size, extent) =>
44
+ align === 'center'
45
+ ? start + (size - extent) / 2
46
+ : align === 'end'
47
+ ? start + size - extent
48
+ : start;
49
+
50
+ let side = placement;
51
+ let x;
52
+ let y;
53
+
54
+ if (side === 'bottom' || side === 'top') {
55
+ const below = ay + ah + offset;
56
+ const above = ay - height - offset;
57
+ if (side === 'bottom' && sh != null && below + height > sh && above >= 0) {
58
+ side = 'top';
59
+ } else if (
60
+ side === 'top' &&
61
+ above < 0 &&
62
+ (sh == null || below + height <= sh)
63
+ ) {
64
+ side = 'bottom';
65
+ }
66
+ y = side === 'bottom' ? below : above;
67
+ x = alignAlong(ax, aw, width);
68
+ } else {
69
+ const after = ax + aw + offset;
70
+ const before = ax - width - offset;
71
+ if (side === 'right' && sw != null && after + width > sw && before >= 0) {
72
+ side = 'left';
73
+ } else if (
74
+ side === 'left' &&
75
+ before < 0 &&
76
+ (sw == null || after + width <= sw)
77
+ ) {
78
+ side = 'right';
79
+ }
80
+ x = side === 'right' ? after : before;
81
+ y = alignAlong(ay, ah, height);
82
+ }
83
+
84
+ if (sw != null) x = Math.max(0, Math.min(x, sw - width));
85
+ if (sh != null && height) y = Math.max(0, Math.min(y, sh - height));
86
+
87
+ return { x: Math.round(x), y: Math.round(y), width, height, placement: side };
88
+ }
89
+
90
+ /**
91
+ * Where to put a `<popup>` of this size **centred over the owner window**,
92
+ * in screen coordinates, clamped into the screen. A dialog is anchored to
93
+ * the window rather than to a widget, which is the one placement
94
+ * `anchorRect` cannot express.
95
+ */
96
+ export function centerRect(node, { width, height }) {
97
+ if (!node) return null;
98
+ const win = node.root?.window;
99
+ const ww = win?.width ?? width;
100
+ const wh = win?.height ?? height;
101
+ let x = (win?.x ?? 0) + (ww - width) / 2;
102
+ let y = (win?.y ?? 0) + (wh - height) / 2;
103
+
104
+ const screen = screenOf(node);
105
+ if (screen) {
106
+ x = Math.max(0, Math.min(x, screen.pixel_width - width));
107
+ y = Math.max(0, Math.min(y, screen.pixel_height - height));
108
+ }
109
+ return { x: Math.round(x), y: Math.round(y), width, height };
110
+ }
111
+
112
+ /**
113
+ * useAnchor(ref) — stable `measure(options)` returning `anchorRect` for the
114
+ * referenced node. The anchoring math `Select` used to inline, shared with
115
+ * `Tooltip` and anything else that hangs a `<popup>` off a drawn node.
116
+ */
117
+ export function useAnchor(ref) {
118
+ return useCallback((options) => anchorRect(ref.current, options), [ref]);
119
+ }
120
+
121
+ /** Measured size of a single-line label, for sizing a popup around it.
122
+ * Falls back to a rough estimate where no font stack is available. */
123
+ export function measureLabel(node, text, style) {
124
+ const fonts = node?.app?.fonts;
125
+ const size = style?.size ?? DEFAULT_LABEL_SIZE;
126
+ if (!fonts?.layout) {
127
+ return { width: String(text).length * size * 0.55, height: size * 1.4 };
128
+ }
129
+ const layout = fonts.layout(String(text), {
130
+ family: style?.family ?? 'sans-serif',
131
+ size,
132
+ weight: style?.weight ?? 'normal',
133
+ });
134
+ return { width: layout.width, height: layout.height };
135
+ }
136
+
137
+ export const DEFAULT_LABEL_SIZE = 13;
138
+
139
+ /** How long a hover change is held back while the pointer crosses the
140
+ * polygon — long enough to be forgiving, short enough not to feel stuck. */
141
+ export const SAFE_HOVER_DELAY = 320;
142
+
143
+ /**
144
+ * "Safe polygon" hover, after
145
+ * [floating-ui](https://floating-ui.com/docs/usehover#safepolygon).
146
+ *
147
+ * A submenu opens to the side of its parent row, so reaching it means
148
+ * moving the pointer *diagonally* across the rows in between — and those
149
+ * rows would each take the hover and close the submenu being aimed at. The
150
+ * fix is to treat the triangle between where the pointer was and the near
151
+ * edge of the child surface as "still hovering the parent": while the
152
+ * pointer is inside it, hover changes are held back.
153
+ *
154
+ * `apex` is where the pointer was while it was still over the parent (its
155
+ * exit point, near enough), `rect` the child popup — both in **screen**
156
+ * coordinates, because parent and child are different X windows.
157
+ */
158
+ export function movingToward(point, apex, rect, buffer = 8) {
159
+ if (!point || !apex || !rect?.width) return false;
160
+ return pointInPolygon(point, safePolygon(apex, rect, buffer));
161
+ }
162
+
163
+ /** The triangle from `apex` to the child's near edge, grown by `buffer`. */
164
+ export function safePolygon(apex, rect, buffer = 8) {
165
+ const left = rect.x;
166
+ const right = rect.x + rect.width;
167
+ const top = rect.y;
168
+ const bottom = rect.y + rect.height;
169
+
170
+ // the edge the pointer has to cross to reach the child
171
+ let a;
172
+ let b;
173
+ if (apex.x <= left) {
174
+ a = { x: left, y: top - buffer };
175
+ b = { x: left, y: bottom + buffer };
176
+ } else if (apex.x >= right) {
177
+ a = { x: right, y: top - buffer };
178
+ b = { x: right, y: bottom + buffer };
179
+ } else if (apex.y <= top) {
180
+ a = { x: left - buffer, y: top };
181
+ b = { x: right + buffer, y: top };
182
+ } else {
183
+ a = { x: left - buffer, y: bottom };
184
+ b = { x: right + buffer, y: bottom };
185
+ }
186
+ return [apex, a, b];
187
+ }
188
+
189
+ /** Ray casting; the polygon is a triangle here but the test is general. */
190
+ export function pointInPolygon(point, polygon) {
191
+ let inside = false;
192
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
193
+ const pi = polygon[i];
194
+ const pj = polygon[j];
195
+ const straddles = pi.y > point.y !== pj.y > point.y;
196
+ if (
197
+ straddles &&
198
+ point.x < ((pj.x - pi.x) * (point.y - pi.y)) / (pj.y - pi.y) + pi.x
199
+ ) {
200
+ inside = !inside;
201
+ }
202
+ }
203
+ return inside;
204
+ }
205
+
206
+ /** Pointer position of an event in screen coordinates, or null. */
207
+ export function screenPoint(ev) {
208
+ const native = ev?.nativeEvent;
209
+ if (native?.rootx == null || native?.rooty == null) return null;
210
+ return { x: native.rootx, y: native.rooty };
211
+ }
@@ -0,0 +1,17 @@
1
+ // Public widget components. Split out of a single components.js; the
2
+ // modules here are grouped by widget, with the shared plumbing in
3
+ // theme.js (palette + control props), anchor.js (popup geometry),
4
+ // typeahead.js and keys.js.
5
+ export { ThemeProvider, SelectThemeProvider } from './theme.js';
6
+ export { anchorRect, centerRect, useAnchor } from './anchor.js';
7
+ export { Button } from './Button.js';
8
+ export { Dialog } from './Dialog.js';
9
+ export { Checkbox } from './Checkbox.js';
10
+ export { Radio, RadioGroup } from './Radio.js';
11
+ export { Switch } from './Switch.js';
12
+ export { ProgressBar } from './ProgressBar.js';
13
+ export { Slider } from './Slider.js';
14
+ export { Tooltip } from './Tooltip.js';
15
+ export { ContextMenu, MenuBar } from './Menu.js';
16
+ export { Select } from './Select.js';
17
+ export { Canvas3D } from './Canvas3D.js';
@@ -0,0 +1,21 @@
1
+ // X11 keysyms used by the widget keyboard handlers.
2
+
3
+ export const XK_RETURN = 0xff0d;
4
+
5
+ export const XK_ESCAPE = 0xff1b;
6
+
7
+ export const XK_HOME = 0xff50;
8
+
9
+ export const XK_LEFT = 0xff51;
10
+
11
+ export const XK_UP = 0xff52;
12
+
13
+ export const XK_RIGHT = 0xff53;
14
+
15
+ export const XK_DOWN = 0xff54;
16
+
17
+ export const XK_PAGE_UP = 0xff55;
18
+
19
+ export const XK_PAGE_DOWN = 0xff56;
20
+
21
+ export const XK_END = 0xff57;
@@ -0,0 +1,66 @@
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, useState } from 'react';
6
+ import { XK_RETURN } from './keys.js';
7
+
8
+ const h = React.createElement;
9
+
10
+ const DefaultTheme = {
11
+ border: '#b2bec3',
12
+ borderActive: '#2980b9',
13
+ background: 'white',
14
+ text: '#2d3436',
15
+ dim: '#7f8c8d',
16
+ hoverBackground: '#2980b9',
17
+ hoverText: 'white',
18
+ accent: '#2980b9',
19
+ accentHover: '#1f6693',
20
+ accentText: 'white',
21
+ surfaceHover: '#f1f2f6',
22
+ track: '#dfe6e9',
23
+ };
24
+
25
+ const ThemeContext = React.createContext(DefaultTheme);
26
+
27
+ /** Themes all widgets; partial palettes merge over the defaults. */
28
+ export const ThemeProvider = ThemeContext.Provider;
29
+
30
+ export const SelectThemeProvider = ThemeContext.Provider; // back-compat alias
31
+
32
+ export function useTheme() {
33
+ const theme = useContext(ThemeContext);
34
+ return theme === DefaultTheme ? theme : { ...DefaultTheme, ...theme };
35
+ }
36
+
37
+ /** Shared interactive-control plumbing: hover/focus state plus the box
38
+ * props wiring them, click + Space/Enter activation. */
39
+ export function useControl(disabled, onActivate) {
40
+ const [hover, setHover] = useState(false);
41
+ const [focused, setFocused] = useState(false);
42
+ const props = disabled
43
+ ? {}
44
+ : {
45
+ focusable: true,
46
+ cursor: 'pointer',
47
+ onMouseEnter: () => setHover(true),
48
+ onMouseLeave: () => setHover(false),
49
+ onFocus: () => setFocused(true),
50
+ onBlur: () => setFocused(false),
51
+ onClick: () => onActivate?.(),
52
+ onKeyDown: (ev) => {
53
+ if (ev.codepoint === 32 || ev.keysym === XK_RETURN) onActivate?.();
54
+ },
55
+ };
56
+ return { hover: hover && !disabled, focused: focused && !disabled, props };
57
+ }
58
+
59
+ /** String/number children become a <text>; elements pass through. */
60
+ export function labelContent(children, textProps) {
61
+ return React.Children.map(children, (child) =>
62
+ typeof child === 'string' || typeof child === 'number'
63
+ ? h('text', textProps, child)
64
+ : child,
65
+ );
66
+ }
@@ -0,0 +1,56 @@
1
+ // Type-ahead matching shared by Select and the menus.
2
+
3
+ import { useCallback, useRef } from 'react';
4
+
5
+ export const TYPE_AHEAD_TIMEOUT = 700;
6
+
7
+ /**
8
+ * Type-ahead: typing letters jumps to the entry whose label starts with
9
+ * them. Shared by `Select` and the menus.
10
+ *
11
+ * Keystrokes within `timeout` accumulate into one query, so "ca" finds
12
+ * Carrot rather than jumping to Apple then Carrot. Two behaviours the
13
+ * platform conventions call for:
14
+ *
15
+ * - a growing query searches from the *current* entry, so refining a
16
+ * prefix keeps you on it while it still matches;
17
+ * - repeating one letter ("c", "c", "c") cycles through the entries
18
+ * starting with it instead of sticking on the first.
19
+ *
20
+ * Returns the matching index, or -1.
21
+ */
22
+ export function useTypeAhead(timeout = TYPE_AHEAD_TIMEOUT) {
23
+ const state = useRef({ text: '', at: 0 });
24
+ return useCallback(
25
+ (char, items, current, labelOf, selectable) => {
26
+ if (!char || char.length !== 1) return -1;
27
+ const now = Date.now();
28
+ const s = state.current;
29
+ s.text = now - s.at > timeout ? char : s.text + char;
30
+ s.at = now;
31
+
32
+ const query = s.text.toLowerCase();
33
+ const cycling = query.length > 1 && /^(.)\1+$/.test(query);
34
+ const needle = cycling ? query[0] : query;
35
+ const from =
36
+ cycling || query.length === 1 ? (current ?? -1) + 1 : (current ?? 0);
37
+
38
+ const n = items.length;
39
+ for (let k = 0; k < n; k++) {
40
+ const i = (((from + k) % n) + n) % n;
41
+ const item = items[i];
42
+ if (selectable && !selectable(item)) continue;
43
+ const label = String(labelOf(item) ?? '').toLowerCase();
44
+ if (label.startsWith(needle)) return i;
45
+ }
46
+ return -1;
47
+ },
48
+ [timeout],
49
+ );
50
+ }
51
+
52
+ /** A printable character usable for type-ahead. Space is excluded: it
53
+ * activates the focused entry everywhere in this widget set. */
54
+ export function typeAheadChar(ev) {
55
+ return ev.key && ev.key.length === 1 && ev.codepoint > 32 ? ev.key : null;
56
+ }