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/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
+ }