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
package/src/events.js
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
// Synthetic event system: ntk window events → capture/target/bubble dispatch
|
|
2
|
+
// over the drawn node tree, with hit testing, click synthesis, hover
|
|
3
|
+
// enter/leave, wheel mapping (X buttons 4-7) and focus/Tab traversal.
|
|
4
|
+
// Handlers always read from current props, so updates never go stale.
|
|
5
|
+
import {
|
|
6
|
+
runWithPriority,
|
|
7
|
+
DiscreteEventPriority,
|
|
8
|
+
ContinuousEventPriority,
|
|
9
|
+
} from './priority.js';
|
|
10
|
+
|
|
11
|
+
const XK_TAB = 0xff09;
|
|
12
|
+
const WHEEL_BUTTONS = { 4: [0, -48], 5: [0, 48], 6: [-48, 0], 7: [48, 0] };
|
|
13
|
+
// X11 KeyButMask bit for Mod1 (Alt on virtually every layout), same bitmask
|
|
14
|
+
// `shiftKey`/`ctrlKey` above already read `buttons` from.
|
|
15
|
+
const MOD1_MASK = 8;
|
|
16
|
+
|
|
17
|
+
// Click-to-component hook (see ClickToComponent.js). At most one handler is
|
|
18
|
+
// installed, gated by REACT_X11_CLICK_TO_COMPONENT — checked ahead of the
|
|
19
|
+
// normal press handling so an Alt+Click never also starts a drag or moves
|
|
20
|
+
// focus.
|
|
21
|
+
let clickToComponentHandler = null;
|
|
22
|
+
export function setClickToComponentHandler(fn) {
|
|
23
|
+
clickToComponentHandler = fn;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class EventManager {
|
|
27
|
+
constructor(windowNode) {
|
|
28
|
+
this.node = windowNode;
|
|
29
|
+
this.hoverPath = [];
|
|
30
|
+
this.downNode = null;
|
|
31
|
+
this.capturedNode = null;
|
|
32
|
+
this.focused = null;
|
|
33
|
+
// what had focus before `focused`, so a focus scope opened by something
|
|
34
|
+
// that focuses itself still knows where to hand focus back
|
|
35
|
+
this._previousFocus = null;
|
|
36
|
+
// focus scopes, innermost last: [{ node, restore }]
|
|
37
|
+
this.scopes = [];
|
|
38
|
+
// resolved lazily for popups: the manager that owns focus (focusManager)
|
|
39
|
+
this._focusOwner = null;
|
|
40
|
+
// whether the X server sends keys to this window at all. Assume yes
|
|
41
|
+
// until told otherwise: ntk < 3.7 never reports focus changes, and a
|
|
42
|
+
// toolkit that believed it was unfocused would blink no caret at all.
|
|
43
|
+
this.windowFocused = true;
|
|
44
|
+
this._lastClick = { time: 0, x: 0, y: 0, detail: 0 };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The manager that owns focus for this window — normally itself, but for a
|
|
49
|
+
* `<popup>` the nearest enclosing real window's manager. Override-redirect
|
|
50
|
+
* windows never receive the X input focus, so a popup cannot hold it: keys
|
|
51
|
+
* arrive at the owner window, and routing them into the popup's subtree is
|
|
52
|
+
* only possible if both windows share one notion of "the focused node".
|
|
53
|
+
* Nodes inside a popup are still ordinary tree nodes, so capture/bubble
|
|
54
|
+
* from them reaches the owner window's handlers.
|
|
55
|
+
*/
|
|
56
|
+
get focusManager() {
|
|
57
|
+
if (!this.node.isPopup) return this;
|
|
58
|
+
// a popup's parent is a node in the owner window (or an outer popup,
|
|
59
|
+
// whose own delegate resolves recursively). Remember it: the parent link
|
|
60
|
+
// is cut before the deletion bookkeeping runs, and unmounting a modal is
|
|
61
|
+
// exactly when the owner has to hear about it (focus restore).
|
|
62
|
+
const manager = this.node.parent?.root?.events;
|
|
63
|
+
if (manager && manager !== this) this._focusOwner = manager.focusManager;
|
|
64
|
+
return this._focusOwner ?? this;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** DOM-style click counting: repeated presses within 400ms / 4px bump
|
|
68
|
+
* `detail` (2 = double click, 3 = triple …). */
|
|
69
|
+
_clickDetail(native) {
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
const last = this._lastClick;
|
|
72
|
+
const detail =
|
|
73
|
+
now - last.time < 400 &&
|
|
74
|
+
Math.abs(native.x - last.x) <= 4 &&
|
|
75
|
+
Math.abs(native.y - last.y) <= 4
|
|
76
|
+
? last.detail + 1
|
|
77
|
+
: 1;
|
|
78
|
+
this._lastClick = { time: now, x: native.x, y: native.y, detail };
|
|
79
|
+
return detail;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
attach() {
|
|
83
|
+
const wnd = this.node.window;
|
|
84
|
+
if (typeof wnd.on !== 'function') return;
|
|
85
|
+
wnd.on('mousedown', (ev) => this._onMouseDown(ev));
|
|
86
|
+
wnd.on('mouseup', (ev) => this._onMouseUp(ev));
|
|
87
|
+
wnd.on('mousemove', (ev) => this._onMouseMove(ev));
|
|
88
|
+
wnd.on('mouseout', (ev) => this._onMouseOut(ev));
|
|
89
|
+
wnd.on('keydown', (ev) => this._onKey('KeyDown', ev));
|
|
90
|
+
wnd.on('keyup', (ev) => this._onKey('KeyUp', ev));
|
|
91
|
+
// window-level focus (ntk >= 3.7.0): the window manager decides which
|
|
92
|
+
// window gets keys, and the focused node's caret/ring has to follow
|
|
93
|
+
wnd.on('focus', (ev) => this._onWindowFocus(true, ev));
|
|
94
|
+
wnd.on('blur', (ev) => this._onWindowFocus(false, ev));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The DOM keeps `document.activeElement` across a window blur — the
|
|
99
|
+
* element stays focused, it just stops looking active — and so do we: the
|
|
100
|
+
* node keeps focus, its default focus behaviour (a blinking caret) is
|
|
101
|
+
* suspended, and `<window onFocus/onBlur>` gets told.
|
|
102
|
+
*/
|
|
103
|
+
_onWindowFocus(focused, native) {
|
|
104
|
+
if (this.windowFocused === focused) return;
|
|
105
|
+
this.windowFocused = focused;
|
|
106
|
+
const node = this.focused;
|
|
107
|
+
if (node && !node.destroyed) {
|
|
108
|
+
if (focused) node._defaultFocus?.();
|
|
109
|
+
else node._defaultBlur?.();
|
|
110
|
+
}
|
|
111
|
+
runWithPriority(DiscreteEventPriority, () => {
|
|
112
|
+
const prop = focused ? 'onFocus' : 'onBlur';
|
|
113
|
+
this.node.props[prop]?.(
|
|
114
|
+
this._makeEvent(focused ? 'focus' : 'blur', native, this.node),
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
this.node.invalidate(false);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
_public(node) {
|
|
121
|
+
return node.isWindow ? node.window : node;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
_hit(ev) {
|
|
125
|
+
return this.node.hitTest(ev.x, ev.y) ?? this.node;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
_path(target) {
|
|
129
|
+
const path = [];
|
|
130
|
+
for (
|
|
131
|
+
let n = target;
|
|
132
|
+
n;
|
|
133
|
+
n = n === this.node ? null : (n.parent ?? this.node)
|
|
134
|
+
) {
|
|
135
|
+
path.unshift(n);
|
|
136
|
+
if (n === this.node) break;
|
|
137
|
+
}
|
|
138
|
+
return path;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
_makeEvent(type, native, target, extra) {
|
|
142
|
+
const ev = {
|
|
143
|
+
type,
|
|
144
|
+
x: native?.x ?? 0,
|
|
145
|
+
y: native?.y ?? 0,
|
|
146
|
+
target: this._public(target),
|
|
147
|
+
currentTarget: null,
|
|
148
|
+
nativeEvent: native,
|
|
149
|
+
// X11 modifier mask: bit 0 Shift, bit 2 Control. Carried on every
|
|
150
|
+
// event, not just keys — shift+click needs it too.
|
|
151
|
+
shiftKey: Boolean(native?.buttons & 1),
|
|
152
|
+
ctrlKey: Boolean(native?.buttons & 4),
|
|
153
|
+
defaultPrevented: false,
|
|
154
|
+
propagationStopped: false,
|
|
155
|
+
preventDefault() {
|
|
156
|
+
ev.defaultPrevented = true;
|
|
157
|
+
},
|
|
158
|
+
stopPropagation() {
|
|
159
|
+
ev.propagationStopped = true;
|
|
160
|
+
},
|
|
161
|
+
// Pointer capture, DOM-like: while captured, mousemove/mouseup go to
|
|
162
|
+
// the capturing node instead of whatever is under the pointer, so a
|
|
163
|
+
// drag keeps working past the widget's own bounds. Released
|
|
164
|
+
// automatically on mouseup and when the node unmounts.
|
|
165
|
+
capturePointer: () => {
|
|
166
|
+
this.capturedNode = target;
|
|
167
|
+
},
|
|
168
|
+
releasePointer: () => {
|
|
169
|
+
if (this.capturedNode === target) this.capturedNode = null;
|
|
170
|
+
},
|
|
171
|
+
...extra,
|
|
172
|
+
};
|
|
173
|
+
if (target.abs) {
|
|
174
|
+
ev.localX = ev.x - target.abs.x;
|
|
175
|
+
ev.localY = ev.y - target.abs.y;
|
|
176
|
+
}
|
|
177
|
+
return ev;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Capture → target → bubble along the ancestor path. Returns the event. */
|
|
181
|
+
dispatch(name, target, native, extra) {
|
|
182
|
+
const path = this._path(target);
|
|
183
|
+
const ev = this._makeEvent(
|
|
184
|
+
name[0].toLowerCase() + name.slice(1),
|
|
185
|
+
native,
|
|
186
|
+
target,
|
|
187
|
+
extra,
|
|
188
|
+
);
|
|
189
|
+
for (const n of path) {
|
|
190
|
+
const handler = n.props[`on${name}Capture`];
|
|
191
|
+
if (handler) {
|
|
192
|
+
ev.currentTarget = this._public(n);
|
|
193
|
+
handler(ev);
|
|
194
|
+
if (ev.propagationStopped) return ev;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
198
|
+
const handler = path[i].props[`on${name}`];
|
|
199
|
+
if (handler) {
|
|
200
|
+
ev.currentTarget = this._public(path[i]);
|
|
201
|
+
handler(ev);
|
|
202
|
+
if (ev.propagationStopped) return ev;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return ev;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** A press the X server sent us only because we hold a pointer grab:
|
|
209
|
+
* it landed outside this window, so it is a dismissal, not a click. */
|
|
210
|
+
_pressOutside(native) {
|
|
211
|
+
const wnd = this.node.window;
|
|
212
|
+
if (!wnd || typeof this.node.props.onDismiss !== 'function') return false;
|
|
213
|
+
return (
|
|
214
|
+
native.x < 0 ||
|
|
215
|
+
native.y < 0 ||
|
|
216
|
+
native.x >= (wnd.width ?? 0) ||
|
|
217
|
+
native.y >= (wnd.height ?? 0)
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
_onMouseDown(native) {
|
|
222
|
+
if (clickToComponentHandler && Boolean(native.buttons & MOD1_MASK)) {
|
|
223
|
+
clickToComponentHandler(this._hit(native), native);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (this._pressOutside(native)) {
|
|
227
|
+
runWithPriority(DiscreteEventPriority, () => {
|
|
228
|
+
this.node.props.onDismiss?.(
|
|
229
|
+
this._makeEvent('dismiss', native, this.node),
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
runWithPriority(DiscreteEventPriority, () => {
|
|
235
|
+
const wheel = WHEEL_BUTTONS[native.keycode];
|
|
236
|
+
const target = this._hit(native);
|
|
237
|
+
if (wheel) {
|
|
238
|
+
const ev = this.dispatch('Wheel', target, native, {
|
|
239
|
+
deltaX: wheel[0],
|
|
240
|
+
deltaY: wheel[1],
|
|
241
|
+
});
|
|
242
|
+
if (!ev.defaultPrevented) {
|
|
243
|
+
// default action: scroll the nearest enclosing <scrollview>
|
|
244
|
+
for (let n = target; n; n = n.parent) {
|
|
245
|
+
if (n.kind === 'scrollview' || n.kind === 'textarea') {
|
|
246
|
+
n.scrollBy(ev.deltaY);
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
if (n === this.node) break;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
this.downNode = target;
|
|
255
|
+
this._focusFromPress(target);
|
|
256
|
+
const ev = this.dispatch('MouseDown', target, native, {
|
|
257
|
+
button: native.keycode,
|
|
258
|
+
detail: this._clickDetail(native),
|
|
259
|
+
});
|
|
260
|
+
if (!ev.defaultPrevented) {
|
|
261
|
+
target._defaultMouseDown?.(ev);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
_onMouseUp(native) {
|
|
267
|
+
if (WHEEL_BUTTONS[native.keycode]) return; // wheel release
|
|
268
|
+
runWithPriority(DiscreteEventPriority, () => {
|
|
269
|
+
const captured = this._captured();
|
|
270
|
+
const target = captured ?? this._hit(native);
|
|
271
|
+
const ev = this.dispatch('MouseUp', target, native, {
|
|
272
|
+
button: native.keycode,
|
|
273
|
+
});
|
|
274
|
+
// capture ends with the gesture, like implicit DOM pointer capture
|
|
275
|
+
this.capturedNode = null;
|
|
276
|
+
if (this.downNode && !this.downNode.destroyed) {
|
|
277
|
+
this.downNode._defaultMouseUp?.(ev);
|
|
278
|
+
}
|
|
279
|
+
if (this.downNode) {
|
|
280
|
+
// click fires on the nearest common ancestor of press and release
|
|
281
|
+
const downPath = new Set(this._path(this.downNode));
|
|
282
|
+
let clickTarget = target;
|
|
283
|
+
while (clickTarget && !downPath.has(clickTarget)) {
|
|
284
|
+
clickTarget =
|
|
285
|
+
clickTarget.parent ??
|
|
286
|
+
(clickTarget === this.node ? null : this.node);
|
|
287
|
+
}
|
|
288
|
+
if (clickTarget) {
|
|
289
|
+
this.dispatch('Click', clickTarget, native, {
|
|
290
|
+
button: native.keycode,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
this.downNode = null;
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
_onMouseMove(native) {
|
|
299
|
+
runWithPriority(ContinuousEventPriority, () => {
|
|
300
|
+
const captured = this._captured();
|
|
301
|
+
const target = captured ?? this._hit(native);
|
|
302
|
+
// while captured, hover stays put: dragging a slider must not light
|
|
303
|
+
// up every widget the pointer crosses
|
|
304
|
+
this._updateHover(captured ? this.hoverPath : this._path(target), native);
|
|
305
|
+
const ev = this.dispatch('MouseMove', target, native);
|
|
306
|
+
// drags deliver to the pressed node even when the pointer leaves it
|
|
307
|
+
if (this.downNode && !this.downNode.destroyed) {
|
|
308
|
+
this.downNode._defaultMouseDrag?.(ev);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** The capturing node, dropping it if it has gone away. */
|
|
314
|
+
_captured() {
|
|
315
|
+
if (this.capturedNode?.destroyed) this.capturedNode = null;
|
|
316
|
+
return this.capturedNode;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* DOM-like: mousedown moves focus to the nearest focusable ancestor of the
|
|
321
|
+
* hit node. A press outside an open focus scope leaves focus where it is —
|
|
322
|
+
* a modal keeps focus even when the user pokes at what is behind it.
|
|
323
|
+
*/
|
|
324
|
+
_focusFromPress(target) {
|
|
325
|
+
const manager = this.focusManager;
|
|
326
|
+
const scope = manager._scopeRoot();
|
|
327
|
+
if (scope !== manager.node && !this._within(target, scope)) return;
|
|
328
|
+
const focusable = this._path(target)
|
|
329
|
+
.reverse()
|
|
330
|
+
.find((n) => this._isFocusable(n));
|
|
331
|
+
// a press inside a popup on nothing focusable leaves the owner window's
|
|
332
|
+
// focus alone: the press never reached that window, and menus rely on it
|
|
333
|
+
// (their rows are not focusable, the trigger keeps the keys)
|
|
334
|
+
if (!focusable && manager !== this) return;
|
|
335
|
+
manager.focus(focusable ?? null);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Focusable: `focusable`, an explicit `tabIndex` (including a negative
|
|
339
|
+
* one, focusable but not tabbable), or a kind that is focusable by default
|
|
340
|
+
* (`<textinput>`). `focusable={false}` and `disabled` opt back out. */
|
|
341
|
+
_isFocusable(node) {
|
|
342
|
+
if (node.props.disabled) return false;
|
|
343
|
+
return (
|
|
344
|
+
node.props.focusable ??
|
|
345
|
+
(node.props.tabIndex != null ? true : (node.focusableByDefault ?? false))
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
_onMouseOut(native) {
|
|
350
|
+
runWithPriority(ContinuousEventPriority, () => {
|
|
351
|
+
this._updateHover([], native);
|
|
352
|
+
this.node.props.onMouseOut?.(
|
|
353
|
+
this._makeEvent('mouseOut', native, this.node),
|
|
354
|
+
);
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** enter/leave do not propagate: each node on the diff gets its own call. */
|
|
359
|
+
_updateHover(newPath, native) {
|
|
360
|
+
const oldPath = this.hoverPath;
|
|
361
|
+
let common = 0;
|
|
362
|
+
while (
|
|
363
|
+
common < oldPath.length &&
|
|
364
|
+
common < newPath.length &&
|
|
365
|
+
oldPath[common] === newPath[common]
|
|
366
|
+
) {
|
|
367
|
+
common++;
|
|
368
|
+
}
|
|
369
|
+
for (let i = oldPath.length - 1; i >= common; i--) {
|
|
370
|
+
const n = oldPath[i];
|
|
371
|
+
if (!n.destroyed) {
|
|
372
|
+
n.props.onMouseLeave?.(this._makeEvent('mouseLeave', native, n));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
for (let i = common; i < newPath.length; i++) {
|
|
376
|
+
const n = newPath[i];
|
|
377
|
+
n.props.onMouseEnter?.(this._makeEvent('mouseEnter', native, n));
|
|
378
|
+
}
|
|
379
|
+
this.hoverPath = newPath;
|
|
380
|
+
this._updateCursor(newPath);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Apply the deepest hovered node's `cursor` prop to the window.
|
|
384
|
+
* Feature-detected: needs ntk with Window.setCursor (> 3.1.0). */
|
|
385
|
+
_updateCursor(path) {
|
|
386
|
+
const wnd = this.node.window;
|
|
387
|
+
if (typeof wnd.setCursor !== 'function') return;
|
|
388
|
+
let cursor = null;
|
|
389
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
390
|
+
const c = path[i].props.cursor ?? path[i].defaultCursor;
|
|
391
|
+
if (c != null) {
|
|
392
|
+
cursor = c;
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (cursor !== this._appliedCursor) {
|
|
397
|
+
this._appliedCursor = cursor;
|
|
398
|
+
wnd.setCursor(cursor);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
_onKey(name, native) {
|
|
403
|
+
runWithPriority(DiscreteEventPriority, () => {
|
|
404
|
+
const wnd = this.node.window;
|
|
405
|
+
const syms = wnd.X?.keycode2keysyms?.[native.keycode];
|
|
406
|
+
const keysym = syms?.[0];
|
|
407
|
+
// the focused node may live inside a <popup> of this window: focus is
|
|
408
|
+
// shared with the popup (see focusManager), key delivery follows it
|
|
409
|
+
const focused = this.focusManager.focused;
|
|
410
|
+
const target = focused && !focused.destroyed ? focused : this.node;
|
|
411
|
+
const ev = this.dispatch(name, target, native, {
|
|
412
|
+
keycode: native.keycode,
|
|
413
|
+
keysym,
|
|
414
|
+
codepoint: native.codepoint,
|
|
415
|
+
key:
|
|
416
|
+
native.codepoint && native.codepoint >= 0x20
|
|
417
|
+
? String.fromCodePoint(native.codepoint)
|
|
418
|
+
: undefined,
|
|
419
|
+
shiftKey: Boolean(native.buttons & 1),
|
|
420
|
+
ctrlKey: Boolean(native.buttons & 4),
|
|
421
|
+
});
|
|
422
|
+
if (name === 'KeyDown' && keysym === XK_TAB && !ev.defaultPrevented) {
|
|
423
|
+
this._cycleFocus(Boolean(native.buttons & 1));
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
if (name === 'KeyDown' && !ev.defaultPrevented) {
|
|
427
|
+
target._defaultKeyDown?.(ev);
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
focus(node) {
|
|
433
|
+
const manager = this.focusManager;
|
|
434
|
+
if (manager !== this) return manager.focus(node);
|
|
435
|
+
if (node === this.focused) return;
|
|
436
|
+
const old = this.focused;
|
|
437
|
+
this._previousFocus = old;
|
|
438
|
+
this.focused = node;
|
|
439
|
+
if (old && !old.destroyed) {
|
|
440
|
+
old._defaultBlur?.();
|
|
441
|
+
old.props.onBlur?.(this._makeEvent('blur', null, old));
|
|
442
|
+
// the ring/caret it was drawing has to go, and it may be in another
|
|
443
|
+
// window than the new focus (owner window ↔ its popup)
|
|
444
|
+
old.root?.invalidate(false);
|
|
445
|
+
}
|
|
446
|
+
if (node) {
|
|
447
|
+
// keys only reach a node whose window has the X focus
|
|
448
|
+
if (!this.windowFocused) this.node.window?.focus?.();
|
|
449
|
+
this._scrollIntoView(node);
|
|
450
|
+
if (this.windowFocused) node._defaultFocus?.();
|
|
451
|
+
node.props.onFocus?.(this._makeEvent('focus', null, node));
|
|
452
|
+
node.root?.invalidate(false);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Focus scopes. A node with `trapFocus` — a modal `<popup>`, typically —
|
|
458
|
+
* pushes one: while it is the innermost scope Tab only visits focusables
|
|
459
|
+
* inside it, and presses outside it leave focus alone. Popping the scope
|
|
460
|
+
* (usually when the modal unmounts) hands focus back to whatever had it
|
|
461
|
+
* before the scope opened, which is what makes a dialog feel finished
|
|
462
|
+
* rather than abandoned.
|
|
463
|
+
*/
|
|
464
|
+
pushScope(node) {
|
|
465
|
+
const manager = this.focusManager;
|
|
466
|
+
if (manager !== this) return manager.pushScope(node);
|
|
467
|
+
if (this.scopes.some((s) => s.node === node)) return;
|
|
468
|
+
// commitMount runs children before parents, so a scope's own autoFocus
|
|
469
|
+
// may already have taken focus by the time the scope registers — then
|
|
470
|
+
// the node to come back to is the one focused before that.
|
|
471
|
+
const focused = this.focused;
|
|
472
|
+
const restore =
|
|
473
|
+
focused && this._within(focused, node) ? this._previousFocus : focused;
|
|
474
|
+
this.scopes.push({ node, restore });
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
popScope(node) {
|
|
478
|
+
const manager = this.focusManager;
|
|
479
|
+
if (manager !== this) return manager.popScope(node);
|
|
480
|
+
const index = this.scopes.findIndex((s) => s.node === node);
|
|
481
|
+
if (index === -1) return;
|
|
482
|
+
const [scope] = this.scopes.splice(index, 1);
|
|
483
|
+
const focused = this.focused;
|
|
484
|
+
// focus only comes back if it was inside the scope that just closed
|
|
485
|
+
if (focused && !this._within(focused, node)) return;
|
|
486
|
+
const restore = scope.restore;
|
|
487
|
+
const alive = restore && !restore.destroyed && this._isFocusable(restore);
|
|
488
|
+
this.focus(alive ? restore : null);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** The innermost live focus scope, or the window node when there is none.
|
|
492
|
+
* Destroyed scopes are dropped; a hidden one is skipped but kept, since
|
|
493
|
+
* unhiding it puts the trap back. */
|
|
494
|
+
_scopeRoot() {
|
|
495
|
+
while (this.scopes.length > 0 && this.scopes.at(-1).node.destroyed) {
|
|
496
|
+
this.scopes.pop();
|
|
497
|
+
}
|
|
498
|
+
for (let i = this.scopes.length - 1; i >= 0; i--) {
|
|
499
|
+
if (!this.scopes[i].node.hidden) return this.scopes[i].node;
|
|
500
|
+
}
|
|
501
|
+
return this.node;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
_within(node, root) {
|
|
505
|
+
for (let n = node; n; n = n.parent) {
|
|
506
|
+
if (n === root) return true;
|
|
507
|
+
}
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Tab to something inside a scrollview and it should be on screen. */
|
|
512
|
+
_scrollIntoView(node) {
|
|
513
|
+
for (let n = node.parent; n; n = n.parent) {
|
|
514
|
+
if (typeof n.scrollIntoView === 'function') {
|
|
515
|
+
n.scrollIntoView(node);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Focusable nodes in tree order, from `root` down. Windows are their own
|
|
522
|
+
* focus roots, so a nested `<window>` or `<popup>` is not walked into —
|
|
523
|
+
* except when it _is_ the root, which is how a modal popup's own
|
|
524
|
+
* focusables are reached. */
|
|
525
|
+
_focusables(root = this._scopeRoot()) {
|
|
526
|
+
const out = [];
|
|
527
|
+
const walk = (node) => {
|
|
528
|
+
if (node.hidden) return;
|
|
529
|
+
if (this._isFocusable(node)) out.push(node);
|
|
530
|
+
for (const child of node.children) {
|
|
531
|
+
if (!child.isWindow) walk(child);
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
walk(root);
|
|
535
|
+
return out;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Tab order, following the DOM's sequential focus navigation: positive
|
|
539
|
+
* `tabIndex` first in ascending order, then the implicit-zero group in tree
|
|
540
|
+
* order. `tabIndex={-1}` is focusable by press and `focus()` but never
|
|
541
|
+
* tabbable. Ties keep tree order (the sort is made stable by index). */
|
|
542
|
+
_tabbables(root) {
|
|
543
|
+
return this._focusables(root)
|
|
544
|
+
.map((node, i) => ({ node, i, order: node.props.tabIndex ?? 0 }))
|
|
545
|
+
.filter((e) => e.order >= 0)
|
|
546
|
+
.sort((a, b) => {
|
|
547
|
+
if (a.order === b.order) return a.i - b.i;
|
|
548
|
+
if (a.order === 0) return 1;
|
|
549
|
+
if (b.order === 0) return -1;
|
|
550
|
+
return a.order - b.order;
|
|
551
|
+
})
|
|
552
|
+
.map((e) => e.node);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
_cycleFocus(backwards) {
|
|
556
|
+
const manager = this.focusManager;
|
|
557
|
+
if (manager !== this) return manager._cycleFocus(backwards);
|
|
558
|
+
const list = this._tabbables();
|
|
559
|
+
if (list.length === 0) return;
|
|
560
|
+
const index = list.indexOf(this.focused);
|
|
561
|
+
if (index === -1) {
|
|
562
|
+
// nothing focused, or focus sits outside the current scope: Tab enters
|
|
563
|
+
this.focus(backwards ? list[list.length - 1] : list[0]);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
this.focus(
|
|
567
|
+
backwards
|
|
568
|
+
? list[(index || list.length) - 1]
|
|
569
|
+
: list[(index + 1) % list.length],
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** Called when a node leaves the tree so stale references don't linger. */
|
|
574
|
+
forget(node) {
|
|
575
|
+
if (this.downNode === node) this.downNode = null;
|
|
576
|
+
if (this.capturedNode === node) this.capturedNode = null;
|
|
577
|
+
this.hoverPath = this.hoverPath.filter((n) => n !== node);
|
|
578
|
+
const manager = this.focusManager;
|
|
579
|
+
// a scope closing restores focus, so pop before the focus reference goes
|
|
580
|
+
manager.popScope(node);
|
|
581
|
+
if (manager.focused === node) manager.focused = null;
|
|
582
|
+
if (manager._previousFocus === node) manager._previousFocus = null;
|
|
583
|
+
}
|
|
584
|
+
}
|